Горутины виснут непонятно почему, случайная запись в закрытый канал вызывает panic, нормально протестировать приложение вообще невозможно.


Наверняка многие из вас сталкивались с такой проблемой: синтаксис языка Go вроде бы очень простой, можно сказать примитивный, да и горутины создаются элементарно, но при этом написать мало-мальски серьёзную программу, которая конкурентно что-то делает, внезапно оказывается не так-то просто.


Чтобы не запутаться, люди придумали концепцию structured concurrency, которую можно применять и в Go.


Маркетинговый bullshit


Много где написано, что горутины и каналы существенно упрощают написание многопоточных программ по сравнению с другими языками. Однако реже пишут, что это всё равно очень сложно. Сложно как с точки зрения синтаксиса (многословно), так и с точки зрения "не запутаться".


Структурное программирование


Начнём с простой синхронной программы. Когда-то давным-давно, люди мало использовали процедуры и функции. Типичная программа представляла собой простыню кода, в которой время от времени появлялись команды if [что-то] goto [туда-то]. Т.е. программа могла произвольно кидать тебя в разные свои части. При этом отследить, какая переменная в какой момент времени чему равна, было очень сложно. Была путаница и много багов.


В итоге в 1968 году Дейкстра опубликовал эпохальную статью "Оператор goto считается вредным", и началась эра структурного программирования — когда программа является иерархической структурой, состоящей из блоков и подпрограмм, а goto все избегают.


Сейчас это кажется очевидным, и абсолютно все так и пишут. В проектах Каруны используется много разных языков, но везде плюс-минус одно и то же: функции вызывают другие функции, у них есть понятный вход и выход, практически никогда нет goto (исключения всё же бывают), программа нормально читается. Но когда-то люди этого не понимали.


В Фортране, кстати, есть совершенно, на мой взгляд, чудовищная конструкция:


IF ( z ) 10, 20, 30

Она означает: если z < 0, то перейди на метку 10, если z=0, то на метку 20, а если > 0, то на 30. Читаемость таких условных переходов очень сложна, надо держать в голове пол программы.


Structured Concurrency


В концепции structured concurrency идеи примерно те же, что и в структурном программировании, только немного с другой стороны. Давайте рассмотрим их сразу в контексте языка Go.


Дождаться завершения горутины


По сути, оператор go— это аналог того же старого доброго goto, он отправляет тебя куда подальше, и делай потом что хочешь. А программу всё так же лучше структурировать скоупом функций.


Если говорить на примере Gо, то если вы в какой-то функции создали одну или несколько горутин, то лучше в этой же функции и дождаться их завершения, прежде чем выходить. Если вы этого не сделаете, то ваше приложение превратится в хаотический набор неконтролируемых потоков, запущенных непонятно когда и кем. Разобраться и дебажить это очень тяжело. Покрыть тестами невозможно.


// плохо
func DoSomething() {
    go func() {
       // do something
    }
}

// лучше
func DoSomething() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func(wg *sync.WaitGroup) {
        // do something
        wg.Done()
    }(&wg)
    wg.Wait()
}

Как проектировать апи: снаружи оно должно казаться синхронным


Вся возня с горутинами и т.д. должна быть инкапсулирована внутри функции, а снаружи это должно смотреться как синхронный вызов.


DoSomething() // внутри есть горутины
// к этому моменту горутины завершены

В Go не нужно писать async/await, как в других языках. Поэтому код читается абсолютно линейно, и ты можешь даже не задумываться, что там внутри происходит — один поток или миллион. Но вся магия нарушится, если эта функция породит внутри горутину и вернет исполнение, не дождавшись. В общем, чем меньше сайд-эффектов, тем лучше.


Это хорошо видно по стандартной библиотеке и популярным пакетам — вы можете написать высококонкурентный http-сервер с базой данных и вообще не знать, что такое горутина или канал — всё скрыто под капотом. Просто бездумно пихай json в базу и говори всем, что ты великий гошник.


err := http.ListenAndServe(":8080", nil)

Здесь ListenAndServe под капотом создаёт 100500 горутин, каналов и всего такого, но при использовании мы этого не видим: просто запустили функцию, а когда сервер перестанет принимать запросы, мы идём дальше. Т.е. функция блокирует поток выполнения, пока всё не сделает. После завершения функции никаких горутин не останется.


Если же нужно, чтобы вызов был параллельно с чем-то, то это можно сделать на стороне вызывающего. Когда нет сайд эффектов, каждый слой иерархии горутин становится абсолютно понятен, так как тебе не надо держать в голове, что происходит внутри каждой функции, и в целом код писать проще.


В каком отделении Где пишете в канал, там его и закрывайте


Типичный вопрос на собеседовании: что будет, если писать в закрытый канал? Однако этот вопрос станет невалидным, если не делать так, что ты в одном месте программы пишешь в канал, а в совсем других местах закрываешь, пытаясь держать в голове все кейсы и состояния, куда и как может прийти исполнение кода.


Т.е. допустим есть некий источник данных для канала. Пока данные идут, пишем в канал, когда источник иссякает, то там же надо канал и закрыть.


Конечно, ситуации бывают разные. Но если закрывать канал после того, как туда всё записал, в той же горутине, то разобраться в таком коде намного проще.


Инкапсуляция мьютексов


Шаренные переменные сложны. В любом коде на любом языке. Чем меньше случаев, когда нужно из разных мест писать в одну переменную, тем лучше. И уж тем более в конкурентном коде.


И если это всё-таки надо, то лучше, опять же, инкапсулировать (7 бед — один ответ): сделать структуру, в которую положить и изменяемое поле, и mutex для его защиты. А мутировать через методы, которые будут делать Lock и Unlock.



type SafeCounter struct {
    mu sync.Mutex
    v  map[string]int
}

func (c *SafeCounter) Inc(key string) {
    c.mu.Lock()
    c.v[key]++
    c.mu.Unlock()
}

func (c *SafeCounter) Value(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.v[key]
}

В данном случае, кстати, лучше вообще заменить на sync.Map.


Ещё один момент. Есть известная каждому гошнику фраза "Do not communicate by sharing memory; instead, share memory by communicating". Полезная максима, но она не означает, что про мютексы надо забыть совсем и всё делать на каналах. Тот же счётчик можно написать через каналы, но это будет выглядеть намного сложнее.


Используйте тот метод, который наиболее выразителен и/или прост в вашем случае.


Кроме того, было исследование, которое показало, что количество ошибок примерно одинаково, что так, что эдак.


Вывод


Везде нужен осознанный подход. Нельзя пускать на самотёк управление горутинами и данными — это очень быстро выйдет боком. Но с минимальными усилиями код можно сделать намного более понятным: уменьшить количество комбинаций состояний, которое надо держать в голове при написании программ.


Также советую взглянуть на библиотеку https://github.com/sourcegraph/conc, хоть она и не дошла до версии 1.0, демонстрирует подход, как можно структурировать гошный concurrent код. Заодно попроще обрабатывать panic, да и в целом убрать многословность, которой страдает язык.


Ну и, конечно же, подписывайтесь на мой канал Cross Join, там будут и другие посты на эту тему.

Комментарии (17)


  1. oWart
    27.05.2024 13:37
    +2

    Пример из conc либы:

    За что я люблю Go и не люблю, например, JS...
    Слева – перед глазами очевидный и простой код, справа – грёбаный сахар и магия под капотом.
    Спасибо, но нет.


    1. varanio Автор
      27.05.2024 13:37
      +2

      Толя, код справа на Go написан )))

      Хотя понимаю, о чем ты


      1. oWart
        27.05.2024 13:37
        +5

        Да оно то понятно, что на Go...
        Go прекрасен тем, что в стандартные либы не тащат сахар и этим он мне нравится.


    1. lazy_val
      27.05.2024 13:37

      В любом случае спасибо за наводку, занятная библиотека, надо будет глянуть при случае что там внутри ))


    1. Dancho67
      27.05.2024 13:37
      +1

      Простите за наивный вопрос, но у вас в среде разработки "Go to Reference" не работает?)) Если мы пишем крупный проект, то рано или поздно , дабы не нарушить DRY нам все равно придется написать функцию обёртку и начинается велесипедо строение вместо того, чтобы просто использовать стд-шную функцию. Более того, я уверен, что после стабилизации итераторов, в стандартной библиотеке скорее всего появится похожая функция.


    1. ollegio
      27.05.2024 13:37
      +4

      У вас претензия к тому, что кто-то написал содержимое функции concMap за вас и упаковал в библиотеку?

      Никакого негатива не подразумеваю, просто не понял суть комметария


      1. varanio Автор
        27.05.2024 13:37
        +1

        Если я правильно понял, то там, где в js модно писать map(..).filter(...).reduce(...), и там под капотом всё магически перетасовывается, то в go ты напишешь простой цикл с одним ифом, и будешь иметь полный контроль и 100% понимание происходящего


    1. qeeveex
      27.05.2024 13:37

      Не соглашусь с вами. Дробя код на функции и библотеки мы тем самым можем по отдельности их протестить.

      Если все находится в одной функции, то тест существенно разрастается и становится сложным в сопровождении.


  1. rsashka
    27.05.2024 13:37
    +1

    В итоге в 1968 году Дейкстра опубликовал эпохальную статью "Оператор goto считается вредным", и началась эра структурного программирования — когда программа является иерархической структурой, состоящей из блоков и подпрограмм, а goto все избегают.

    Он писал немного про другое


    1. varanio Автор
      27.05.2024 13:37
      +1

      Я имел в виду, что Дейкстра вынес это обсуждение в массы (что там, где много goto, там много ошибок), в результате чего люди стали писать по-другому. Т.е. это толчок к развитию, а не то, что Дейкстара писал про иерархии подпрограмм


      1. rsashka
        27.05.2024 13:37
        +1

        Он писал не про это. Он писал, что отказ от инстуркций управляющих ходом алгоритма (безусловный переход) или меняющих состояние переменных (оператор присовниения) поможет автоматизировать доказательства правильности программ.

        Вот только это рпдполжение было сделано для чисто теоретичсекой теоремы Бёма и Якопини и в результате он пришел к выводу, что "Тестирование выявляет только наличие, но никак не отсутствие ошибок".

        Но я согласен, что все это послужило толчком к развитию.


        1. varanio Автор
          27.05.2024 13:37
          +1

          Я, кстати, сейчас посмотрел, в статье было про плотность ошибок при использовании goto. Но это, как я понял, было примечание редактора (Вирта).


        1. max_mustermann
          27.05.2024 13:37

          Конкретно в "Go To Statement Considered Harmful", написано именно об этом: что goto вреден, вносит хаос и так далее.

          >Вот только это рпдполжение было сделано для чисто теоретичсекой теоремы Бёма и Якопини

          В оригинальной статье эта теорема упоминается одной строкой в разделе "благодарности и обсуждение", в контексте "кстати вот доказали, что можно вообще без goto".


          >в результате он пришел к выводу, что "Тестирование выявляет только наличие, но никак не отсутствие ошибок".

          Это cовершенно другая статья, сама фраза там никакой не "вывод" а просто язвительное замечание-шутка.


  1. Sly_tom_cat
    27.05.2024 13:37

    Ну про закрыть канал писателем - это очевидно же.... но только если пишет один....

    Безусловная фраза:
    > В данном случае, кстати, лучше вообще заменить на sync.Map.
    примерно в 100500 раз хуже оператора goto.


    1. varanio Автор
      27.05.2024 13:37

      1) не совсем очевидно, как показывает практика

      2) если данные прилетают из двух источников, то лучше сделать по отдельному каналу. А потом fan in.

      3) чем плох sync.Map? Что там везде any? В любом случае, понятность кода может создавать оверхед. Даже, когда линейный код тупо разбивается на функции - это уже нехилый оверхед, далеко не всё инлайнится компилятором


    1. qeeveex
      27.05.2024 13:37
      +1

      > В данном случае, кстати, лучше вообще заменить на sync.Map.
      примерно в 100500 раз хуже оператора goto.

      Немного не понял. Как можно заменить sync.Map оператором `goto`?


    1. olivera507224
      27.05.2024 13:37

      Ну про закрыть канал писателем - это очевидно же.... но только если пишет один....

      В случае многих писателей можно смело утверждать, что эти писатели тоже где-то создаются и кем-то ожидаются. Соответственно, этот кто-то ждёт пока отработают все писатели и закрывает канал, в который отправлялись данные.