Горутины и каналы в Go: руководство по конкурентности
Практика3 минРедакция Innovialabs
Коротко
Горутина — лёгкая функция, запущенная через go, а канал — безопасный способ передать ей данные; завершением управляют WaitGroup и context, иначе горутины утекают.
Коротко: как устроена конкурентность в Go
Горутина — это функция, которую рантайм Go выполняет конкурентно с остальным кодом. Запускается она ключевым словом go и стоит дёшево: можно держать тысячи горутин одновременно.
Канал — типизированная труба, через которую горутины обмениваются данными. Принцип Go: не делите память для общения, а общайтесь, чтобы делить память.
func main() {
ch := make(chan string)
go func() {
ch <- "готово"
}()
fmt.Println(<-ch)
}
Без чтения из канала main завершилась бы раньше горутины, и сообщение потерялось бы.
Небуферизованные и буферизованные каналы
| Тип | Создание | Поведение |
|---|---|---|
| Небуферизованный | make(chan int) | Отправка ждёт, пока кто-то прочитает. Это точка синхронизации |
| Буферизованный | make(chan int, 10) | Отправка блокируется, только когда буфер полон |
Практические правила:
- Небуферизованный канал — когда важно, что получатель действительно принял значение.
- Буферизованный — чтобы сгладить всплески между быстрым производителем и медленным потребителем. Буфер не лечит дедлок, а лишь откладывает его.
- Закрывает канал отправитель, а не получатель. Чтение из закрытого канала возвращает нулевое значение, отправка в закрытый — панику.
for v := range chчитает до закрытия канала.
select: ожидание нескольких событий
select ждёт первый готовый из нескольких каналов. Типичное применение — таймаут:
select {
case res := <-results:
fmt.Println(res)
case <-time.After(2 * time.Second):
fmt.Println("таймаут")
}
Ветка default делает select неблокирующим: если ни один канал не готов, сразу выполняется она.
WaitGroup: дождаться группы горутин
Когда результат не нужен, а нужно лишь дождаться завершения, используйте sync.WaitGroup:
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(url)
}
wg.Wait()
Вызывайте Add до запуска горутины, а Done — через defer, чтобы он сработал даже при раннем выходе.
context: отмена и дедлайны
context.Context передаёт сигнал отмены вниз по цепочке вызовов. Это главный инструмент против зависших горутин:
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
process(j)
}
}
}
Создавайте контекст через context.WithTimeout или context.WithCancel и всегда вызывайте cancel() через defer.
Как избежать утечек горутин
Утечка — горутина, которая навсегда заблокирована и никогда не завершится. Память она не отдаёт. Частые причины:
- Отправка в канал, который никто не читает, например после таймаута в вызывающем коде. Решение: буфер на 1 или
selectсctx.Done(). - Чтение из канала, который никто не закроет. Договоритесь, кто владелец канала и кто его закрывает.
- Бесконечный цикл без проверки контекста.
- Забытый
wg.Done()—Waitбудет ждать вечно.
Проверка: runtime.NumGoroutine() в тестах или профилировщик pprof покажут, растёт ли число горутин со временем.
Частые ошибки
- Гонки данных при записи в общую map или срез из нескольких горутин. Запускайте тесты с флагом
-race. - Захват переменной цикла. В современных версиях Go каждая итерация получает свою переменную, но в старом коде лучше передавать значение аргументом.
- Неограниченный запуск горутин на каждую задачу. Для тысяч задач используйте пул воркеров с фиксированным числом горутин.