Горутины виснут непонятно почему, случайная запись в закрытый канал вызывает 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, там будут и другие посты на эту тему.