Конкурентность — то, ради чего многие берут Go. Горутина запускается одним словом, стоит копейки, а каналы дают способ связывать их без ручной возни с блокировками. Но именно эта лёгкость и подводит: дешёвая горутина легко утекает, канал легко превращается в дедлок, а «share memory by communicating» не отменяет гонок данных, если всё-таки шарить память. Эта статья — про то, как устроена конкурентность в Go и как обойти типичные ловушки.
Это первая, вводная статья серии «Конкурентность в Go»: она задаёт основу — горутины, каналы, context и обзор подводных камней, — а дальше серия углубляется в примитивы sync, модель памяти, продвинутые паттерны и отладку. Более широкий разбор моделей конкурентности в разных языках — в отдельной статье «Горутины, корутины, потоки»; здесь — практика именно по Go.
В статье
- Горутины: дешёвая конкурентность
- Планировщик: M:N, GOMAXPROCS и вытеснение
- Конкурентность ≠ параллелизм
- Каналы: рандеву и буферы
- select, таймауты и nil-каналы
- Каналы против мьютексов: честный выбор
- context: отмена и дедлайны
- Рабочие паттерны: обзор
- Подводные камни
- Checklist
- Демо и версии
- Документация и первоисточники
Горутины: дешёвая конкурентность
Горутина — это функция, запущенная на исполнение конкурентно с вызывающим кодом. Синтаксически — одно слово go перед вызовом:
package main
import (
"fmt"
"time"
)
func main() {
go say("привет из горутины")
say("привет из main")
time.Sleep(100 * time.Millisecond) // грубое ожидание — так делать не надо, см. ниже
}
func say(s string) {
fmt.Println(s)
}Ключевое отличие горутины от потока ОС — цена. Поток ОС резервирует стек фиксированного (и крупного) размера — обычно порядка мегабайта, — а его создание и переключение контекста проходят через ядро. Горутина же:
- начинается с маленького растущего стека (несколько килобайт), который увеличивается и уменьшается по необходимости — рантайм копирует стек в больший сегмент, когда места не хватает;
- живёт в пользовательском пространстве — создание, завершение и переключение между горутинами делает планировщик рантайма Go, без системного вызова на каждый чих.
Поэтому запустить десятки и сотни тысяч горутин — норма, а не экзотика. Но «дешёвая» не значит «бесплатная»: каждая горутина держит стек и, что важнее, часто держит ссылку на канал или ресурс. Забытая горутина, зависшая навсегда, — это утечка, и её цена платится не разово, а постоянно.
Ещё одно важное свойство: когда main возвращается, программа завершается — рантайм не ждёт остальные горутины. time.Sleep в примере выше — костыль, чтобы горутина успела отработать до выхода. В реальном коде для ожидания используют sync.WaitGroup, каналы или errgroup (см. паттерны), а не сон на глазок.
Планировщик: M:N, GOMAXPROCS и вытеснение
Горутины не мапятся на потоки ОС один-к-одному. Планировщик Go — M:N: множество горутин (M штук в математическом смысле) мультиплексируются на меньший пул потоков ОС (N штук). Внутри рантайма это описывается тремя сущностями:
- G (goroutine) — сама горутина: её стек и состояние;
- M (machine) — поток ОС, который реально исполняет код;
- P (processor) — логический процессор, контекст планирования; держит локальную очередь готовых горутин. Чтобы M исполнял Go-код, ему нужен P.
Число P задаётся переменной GOMAXPROCS — это максимальное количество горутин, исполняющих Go-код одновременно. По умолчанию оно равно числу доступных CPU (runtime.NumCPU()).
import "runtime"
// прочитать текущее значение (передав отрицательное число):
n := runtime.GOMAXPROCS(-1)
// изменить (обычно не нужно — трогайте только осознанно):
// runtime.GOMAXPROCS(4)Планировщик использует work stealing: если у одного P опустела локальная очередь, он «крадёт» готовые горутины из очереди соседнего P или из глобальной очереди — так нагрузка выравнивается между потоками.
Когда горутина делает блокирующий системный вызов (например, чтение из сети или файла), рантайм умеет отвязать P от заблокированного M и отдать P другому потоку, чтобы остальные горутины продолжали работать. Поэтому «блокирующий» с точки зрения кода сетевой вызов не блокирует весь пул — под капотом сетевой ввод-вывод работает через неблокирующий poller.
Про контейнеры и
GOMAXPROCS. Начиная с Go 1.25 значение по умолчанию учитывает CPU-лимиты cgroup: в контейнере с ограничением в 2 CPU рантайм по умолчанию не выставитGOMAXPROCSпо числу ядер всей ноды. До этого в контейнерах приходилось выставлять значение вручную (или библиотекой), иначе планировщик считал доступными все ядра хоста. Проверьте поведение на своей версии.
Вытеснение: почему длинный цикл больше не вешает планировщик
До Go 1.14 планировщик был кооперативным: горутина уступала управление только в «точках безопасности» — на вызовах функций, аллокациях, операциях с каналами. Плотный цикл без единого вызова функции мог захватить P и не отдавать его — вплоть до подвисания сборки мусора, которой нужно остановить все горутины.
// До Go 1.14 такой цикл мог не дать себя вытеснить:
func spin() {
for {
// нет вызовов функций, нет точек безопасности —
// кооперативный планировщик не мог сюда «влезть»
}
}С Go 1.14 появилось асинхронное вытеснение (async preemption) на основе сигналов: рантайм может прервать даже такой цикл и переключить горутину. Практический вывод — длинные вычислительные циклы больше не «вешают» планировщик и не мешают GC. Полагаться на это как на инструмент проектирования не стоит, но понимать, почему CPU-bound код теперь ведёт себя корректно, полезно.
Конкурентность ≠ параллелизм
Это разные вещи, и Go-сообщество на этом настаивает (канонично — доклад Роба Пайка «Concurrency is not parallelism»):
- Конкурентность — это про структуру: как программа разбита на независимо продвигающиеся задачи. Свойство кода.
- Параллелизм — это про исполнение: сколько задач реально работают в один и тот же момент на разных ядрах. Свойство железа и рантайма.
При GOMAXPROCS=1 программа с сотней горутин полностью конкурентна, но не параллельна: в любой момент исполняется ровно одна горутина, планировщик их чередует. Поднимите GOMAXPROCS — и та же конкурентная структура начнёт исполняться параллельно, без изменений в коде.
Отсюда практический вывод: горутины не делают программу быстрее автоматически. Если задача чисто вычислительная (CPU-bound), параллелизм ограничен числом ядер, и лишние горутины лишь добавят накладных расходов на планирование. Выигрыш конкурентности особенно заметен на I/O-bound нагрузке, где горутины большую часть времени ждут (сеть, диск, БД), а не считают.
Каналы: рандеву и буферы
Канал — типизированный конвейер, по которому горутины передают значения. Девиз Go: «Не общайтесь через разделяемую память — разделяйте память, общаясь» (share memory by communicating). Вместо того чтобы защищать общие данные мьютексом, канал передаёт владение данными от одной горутины к другой.
ch := make(chan int) // небуферизованный канал int
bufCh := make(chan int, 8) // буферизованный, ёмкость 8Небуферизованный: рандеву
У небуферизованного канала нет места «внутри». Отправка блокируется до тех пор, пока другая горутина не будет готова принять, и наоборот — это рандеву (встреча). Момент передачи синхронизирует обе горутины:
func main() {
done := make(chan struct{})
go func() {
fmt.Println("работаю…")
close(done) // сигнал: закончил
}()
<-done // блокируемся, пока горутина не закроет канал
fmt.Println("готово")
}chan struct{} — идиома для канала-сигнала: значение не важно, важен сам факт передачи/закрытия, а struct{} не занимает памяти.
Буферизованный: развязка на ёмкость буфера
У буферизованного канала есть внутренняя очередь. Отправка блокируется, только когда буфер полон; приём — когда буфер пуст. Это развязывает отправителя и получателя по времени в пределах ёмкости:
ch := make(chan int, 2)
ch <- 1 // не блокирует — есть место
ch <- 2 // не блокирует — есть место
// ch <- 3 // заблокировалось бы: буфер полон, приёмника нет
fmt.Println(<-ch, <-ch) // 1 2Буфер — не «ускоритель», а инструмент управления обратным давлением (backpressure). Слишком большой буфер маскирует то, что потребитель не поспевает за производителем, и превращает быструю ошибку в скрытое накопление. Ёмкость выбирают осознанно, а не «на всякий случай побольше».
Направления каналов
Тип канала можно сузить до только-отправки или только-приёма — это делает API самодокументируемым и ловит ошибки на этапе компиляции:
func produce(out chan<- int) { // out — только для отправки
for i := 0; i < 3; i++ {
out <- i
}
close(out)
}
func consume(in <-chan int) { // in — только для приёма
for v := range in { // range читает, пока канал не закрыт
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go produce(ch)
consume(ch)
}Закрытие и чтение из закрытого канала
Закрывает канал отправитель, когда значений больше не будет. Приёмник узнаёт о закрытии по второму возвращаемому значению:
v, ok := <-ch
// ok == false и v == нулевое значение типа, если канал закрыт и опустошёнВажные правила, нарушение которых — паника:
| Операция | Небуф./буф. канал | nil-канал | Закрытый канал |
|---|---|---|---|
Отправка ch <- x |
блок до готовности/места | блокируется навсегда | паника |
Приём <-ch |
блок до значения | блокируется навсегда | сразу нулевое значение, ok == false |
close(ch) |
закрывает | паника | паника (повторное закрытие) |
Из таблицы следуют две частые ошибки: отправка в закрытый канал и повторное закрытие — обе роняют программу паникой. Отсюда правило «закрывает всегда отправитель, и ровно один раз». Если отправителей несколько, закрытие координируют отдельно (например, через sync.WaitGroup и одну закрывающую горутину).
nil-каналы: не баг, а инструмент
Чтение и запись в nil-канал блокируются навсегда. На первый взгляд бесполезно, но в select это способ динамически отключить ветку: присвоив каналу nil, вы исключаете его case из рассмотрения (см. следующий раздел).
select, таймауты и nil-каналы
select ждёт готовности одной из нескольких канальных операций. Если готовы несколько — выбор случайный (это защищает от голодания одной из веток):
select {
case v := <-ch1:
fmt.Println("из ch1:", v)
case ch2 <- x:
fmt.Println("отправили в ch2")
case <-time.After(time.Second):
fmt.Println("таймаут")
}default: неблокирующие операции
Ветка default срабатывает, если ни одна другая не готова прямо сейчас — так делают неблокирующую отправку/приём:
select {
case v := <-ch:
handle(v)
default:
// канал пуст — не ждём, идём дальше
}Таймаут: time.After и его ловушка
time.After(d) возвращает канал, в который через d придёт время. Удобно для таймаута одной операции. Но в горячем цикле это ловушка: каждый оборот создаёт новый таймер.
// Осторожно в цикле: на каждой итерации — новый таймер.
for {
select {
case v := <-work:
process(v)
case <-time.After(time.Second): // создаётся каждый раз
return
}
}Исторически такой таймер не мог быть собран сборщиком мусора, пока не сработает, — на высокой частоте это давало заметное давление на память (в Go 1.23 поведение таймеров улучшили, и неиспользуемый таймер теперь может собираться раньше). Для повторяющихся таймаутов надёжнее один переиспользуемый time.NewTimer с Stop/Reset — или, что чаще правильнее, отмена через context.
Отключение ветки через nil-канал
Приём из nil-канала блокируется навсегда, поэтому такая ветка select никогда не выберется. Это позволяет включать и выключать источник на лету:
func merge(in1, in2 <-chan int, out chan<- int) {
for in1 != nil || in2 != nil {
select {
case v, ok := <-in1:
if !ok {
in1 = nil // источник иссяк — выключаем его ветку
continue
}
out <- v
case v, ok := <-in2:
if !ok {
in2 = nil
continue
}
out <- v
}
}
close(out)
}Без трюка с nil закрытый канал в select выбирался бы бесконечно (он всегда «готов» отдать нулевое значение), и цикл крутился бы вхолостую.
Каналы против мьютексов: честный выбор
Расхожий совет «в Go всё делают каналами» — вредная догма. И каналы, и sync-примитивы — законные инструменты; выбор зависит от того, что вы делаете. Официальная вики Go формулирует это прямо: используйте то, что выразительнее и проще для конкретной задачи.
Канал уместен, когда:
- передаётся владение данными — значение уходит от одной горутины к другой, и первая к нему больше не обращается;
- нужна координация и оркестрация горутин (сигналы, завершение, конвейеры);
- строится pipeline — стадии обработки, соединённые каналами;
- распределяется работа между воркерами.
Мьютекс/атомик уместнее, когда:
- защищается общее изменяемое состояние, живущее на месте: кэш, счётчик, map, конфигурация;
- операция — простое «прочитал/обновил поле», и заворачивать это в канальный протокол значит писать больше кода без выгоды.
Мини-иллюстрация: обычный счётчик проще и быстрее защитить мьютексом (или сделать атомарным), чем гонять инкременты через канал.
type Counter struct {
mu sync.Mutex
n int64
}
func (c *Counter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
func (c *Counter) Value() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}Рядом стоят и другие примитивы из sync: sync.WaitGroup — дождаться завершения группы горутин; sync.Once — гарантированно однократная инициализация; sync.Pool — переиспользование объектов под аллокационным давлением; sync/atomic — атомарные счётчики и флаги без мьютекса. Детальный разбор — во второй статье серии про пакет sync и атомики, а гарантии видимости, которые всё это даёт, — в статье про модель памяти.
Антипаттерн в обе стороны. Канал там, где хватило бы мьютекса, — это лишний протокол, лишняя горутина-владелец и труднее отлаживаемый код. Мьютекс там, где естественнее конвейер, — это ручная возня с блокировками вместо потока данных. Ориентир простой: передаёте данные — канал; защищаете состояние — мьютекс/атомик.
context: отмена и дедлайны
Горутину нельзя «убить» извне — в Go нет принудительного завершения. Единственный корректный способ остановить работу — попросить её остановиться и дождаться, пока она сама выйдет. Стандартный механизм для этого — пакет context.
context.Context несёт сигнал отмены, дедлайн и (реже) значения запроса вниз по дереву вызовов. Конструкторы:
context.Background()— корневой контекст (вmain, инициализации, тестах);context.WithCancel(parent)— возвращает контекст и функциюcancel()для ручной отмены;context.WithTimeout(parent, d)— отмена черезd;context.WithDeadline(parent, t)— отмена в момент времениt.
Все они возвращают производный контекст и функцию cancel, которую нужно вызвать (обычно defer cancel()), чтобы освободить связанные ресурсы, — даже если отмена уже произошла по таймауту.
func fetchAll(ctx context.Context, urls []string) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel() // обязательно, иначе утечка таймера/ресурсов
for _, u := range urls {
if err := fetchOne(ctx, u); err != nil {
return err
}
}
return nil
}Проброс: первым аргументом, не в структуре
Соглашение Go: context передаётся явно, первым параметром, с именем ctx. Его не сохраняют в поля структуры и не прячут в глобалы:
func fetchOne(ctx context.Context, url string) error { /* ... */ return nil }Так контекст течёт вниз по всей цепочке вызовов, и отмена наверху доходит до самой глубокой операции.
Реакция на отмену: ctx.Done()
Долгая или блокирующая работа обязана слушать ctx.Done() — канал, который закрывается при отмене или дедлайне. ctx.Err() скажет, что случилось: context.Canceled или context.DeadlineExceeded.
func worker(ctx context.Context, jobs <-chan Job) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // отменили или вышел дедлайн — выходим
case job, ok := <-jobs:
if !ok {
return nil // задачи кончились
}
if err := do(ctx, job); err != nil {
return err
}
}
}
}Отмена распространяется по дереву: отменив родительский контекст, вы отменяете все производные от него, а значит и все горутины, которые их слушают. Это и есть способ остановить целое поддерево работы одним cancel().
Типичная ошибка: игнорировать ctx в долгой операции
Контекст бесполезен, если его никто не проверяет. Функция, которая приняла ctx, но не смотрит на ctx.Done() в длинном цикле и не передаёт ctx дальше (в HTTP-клиент, в запрос к БД, в дочерние вызовы), — отменяется только на бумаге. Стандартная библиотека уже умеет отмену: http.NewRequestWithContext, db.QueryContext и подобные прекращают работу по ctx. Ваша задача — дотянуть ctx до них и проверять его в собственных циклах.
Рабочие паттерны: обзор
Здесь — карта основных паттернов. Детальный разбор с полными реализациями — в четвёртой статье серии про паттерны конкурентности; канонический источник — доклад Роба Пайка «Go Concurrency Patterns» и статья блога Go Concurrency Patterns: Pipelines.
Worker pool: ограничение параллелизма
Фиксированное число воркеров читает задачи из общего канала. Так вы ограничиваете параллелизм (не плодя горутину на каждую задачу) и держите нагрузку под контролем:
func pool(ctx context.Context, jobs <-chan int, results chan<- int, workers int) {
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
results <- j * j // «обработка»
}
}
}()
}
wg.Wait()
close(results)
}Тонкость этого примера: приём защищён ctx.Done(), а вот отправка results <- j * j — нет. Если потребитель results перестанет читать, а контекст отменят, воркер заблокируется на отправке и отмену уже не увидит. Здесь это опущено ради простоты вводного примера; полностью защищённый вариант — со select и на стороне отправки — в статье про паттерны.
Семафор на буферизованном канале
Когда нужно не пул воркеров, а просто «не больше N одновременно», подойдёт буферизованный канал как счётный семафор:
sem := make(chan struct{}, 4) // максимум 4 одновременно
for _, task := range tasks {
sem <- struct{}{} // занять слот (блокирует, если все 4 заняты)
go func(t Task) {
defer func() { <-sem }() // освободить слот
handle(t)
}(task)
}Fan-out / fan-in
Fan-out — несколько горутин читают из одного канала, распараллеливая обработку. Fan-in — несколько каналов сливаются в один. Вместе они масштабируют этап конвейера.
Pipeline: стадии на каналах
Обработка разбивается на стадии, соединённые каналами: выход одной — вход следующей. Каждая стадия — горутина, читающая из входного канала и пишущая в выходной; закрытие входного канала каскадно завершает конвейер. Для досрочной остановки используют отдельный done-канал или, чаще, context.
errgroup: группа горутин с ошибкой и отменой
golang.org/x/sync/errgroup — самый удобный способ запустить группу горутин, дождаться их всех и получить первую ошибку. errgroup.WithContext вдобавок автоматически отменяет общий контекст, как только одна из горутин вернула ошибку:
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // не более 8 горутин одновременно
results := make([]string, len(urls))
for i, u := range urls {
i, u := i, u // до Go 1.22 — обязательно; см. подводные камни
g.Go(func() error {
body, err := fetchOne(ctx, u)
if err != nil {
return err // первая ошибка отменит ctx для остальных
}
results[i] = body
return nil
})
}
return g.Wait() // ждём всех, возвращаем первую ошибку
}
func fetchOne(ctx context.Context, url string) (string, error) { return "", nil }Это почти всегда лучше, чем вручную сводить ошибки из горутин через каналы. SetLimit заодно закрывает потребность в отдельном семафоре.
Rate limiting через тикер
time.Ticker даёт равномерный поток тиков — простая основа для ограничения частоты (для серьёзных задач есть golang.org/x/time/rate):
lim := time.NewTicker(200 * time.Millisecond) // максимум 5 в секунду
defer lim.Stop()
for _, req := range requests {
<-lim.C // ждём следующий тик
go handle(req)
}Подводные камни
Обзорно — здесь; развёрнутый разбор с инструментами и разбором конкретных случаев — в пятой статье серии про отладку гонок и утечек.
Гонки данных: компилятор их не ловит
Гонка данных — это конкурентный доступ к одной ячейке памяти из нескольких горутин без синхронизации, где хотя бы один доступ — запись. Компилятор Go не обнаруживает гонки: код спокойно скомпилируется и часто даже «работает» — до тех пор, пока планировщик не сложит операции неудачно.
// ГОНКА: две горутины пишут в один счётчик без синхронизации.
func main() {
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // читает, инкрементирует, пишет — не атомарно
}()
}
wg.Wait()
fmt.Println(counter) // почти наверняка не 1000
}Ловят гонки race detector — встроенный инструмент рантайма, включаемый флагом -race:
go run -race .
go test -race ./...
go build -race -o app .Под -race рантайм инструментирует доступы к памяти и на реальной гонке печатает отчёт — примерно такого вида (адреса и номера строк здесь иллюстративные):
==================
WARNING: DATA RACE
Read at 0x00c0000b4008 by goroutine 8:
main.main.func1()
/app/main.go:14 +0x…
Previous write at 0x00c0000b4008 by goroutine 7:
main.main.func1()
/app/main.go:14 +0x…
==================
Found 1 data race(s)Важные оговорки: детектор находит только те гонки, что реально произошли во время прогона (не доказывает их отсутствие), и заметно замедляет программу с ростом потребления памяти — поэтому -race гоняют в тестах и CI, а не в проде. Починка гонки — синхронизация: мьютекс, атомик или передача данных через канал (гарантии, которые они дают, — в статье про модель памяти).
Отдельно: конкурентная запись в обычную map — это не просто гонка, а немедленное аварийное завершение с сообщением вида fatal error: concurrent map writes, которое рантайм ловит специально. Защищайте map мьютексом или берите sync.Map под подходящий сценарий.
Утечки горутин
Горутина, навсегда заблокированная на канале, которого никто не разблокирует, никогда не завершится и не освободит ни стек, ни удерживаемые ресурсы. Классика — отправка в канал, который никто уже не читает:
// УТЕЧКА: если получатель ушёл (например, по таймауту в вызывающем коде),
// горутина навсегда повиснет на отправке в небуферизованный ch.
func leak() <-chan int {
ch := make(chan int)
go func() {
ch <- compute() // повиснет, если никто не прочитает
}()
return ch
}Лечится это контекстом (горутина слушает ctx.Done() и выходит), выбором ёмкости буфера или гарантией, что читатель всегда есть. Найти утечки помогает runtime.NumGoroutine() (растёт со временем — подозрительно) и профиль горутин через net/http/pprof или runtime/pprof: дамп покажет, где именно застряли горутины.
Обновление 20.08.2026. В Go 1.27 профиль
goroutineleakвышел из эксперимента и доступен вruntime/pprof— это готовый инструмент для ситуаций, описанных выше. Стенд из этой статьи иgoleakв его тестах (ниже, в разделе демо) остаются в силе:goleakрешает другую задачу — проверяет отсутствие утечек на выходе из конкретного теста, а не строит профиль по живому процессу. Разбор нового профиля — в отдельной статье.
Дедлоки
Дедлок — взаимная блокировка: горутины ждут друг друга по кругу. На каналах простейший случай — приём из канала, в который никто не отправит:
func main() {
ch := make(chan int)
<-ch // никто не отправит — вечная блокировка
}Если все горутины программы оказались заблокированы, рантайм это обнаруживает и падает с сообщением вида:
fatal error: all goroutines are asleep - deadlock!Важная оговорка: этот детектор срабатывает, только когда заблокирована вся программа. Частичный дедлок — когда часть горутин повисла, а main продолжает жить, — рантайм не заметит; это будет выглядеть как утечка. На мьютексах классическая причина дедлока — разный порядок захвата двух блокировок в разных местах кода; лечится единым порядком захвата.
Захват переменной цикла: что изменилось в Go 1.22
До Go 1.22 переменная цикла for была одна на все итерации. Горутина, замкнувшая её, видела не то значение, что было на своей итерации, а последнее — очень частый источник багов:
// До Go 1.22 — БАГ: все горутины, скорее всего, напечатают одно и то же
// (последнее) значение i, потому что замыкают общую переменную.
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}Каноническое лечение до 1.22 — «затенить» переменную копией на каждой итерации:
for i := 0; i < 3; i++ {
i := i // копия на итерацию
go func() {
fmt.Println(i)
}()
}В Go 1.22 семантику изменили: теперь переменная цикла создаётся заново на каждой итерации, и этот класс багов в новых горутинах исчез — строчка i := i больше не нужна. Но помнить о нём стоит: вы встретите старый код с этим приёмом, а если целитесь в модуль с go ниже 1.22 в go.mod, действует прежняя семантика. То же касается и примера с errgroup выше.
Checklist
- Каждая запущенная горутина имеет понятный способ завершиться — через
ctx.Done(), закрытие канала илиWaitGroup? Нет вечно висящих отправок/приёмов? mainдожидается нужных горутин (WaitGroup/канал/errgroup), а не полагается наtime.Sleep?- Канал закрывает отправитель, ровно один раз; в закрытый канал никто не пишет?
- Ёмкость буфера выбрана осознанно (backpressure), а не «побольше на всякий случай»?
- Долгие и блокирующие операции слушают
ctx.Done()и пробрасываютctxвглубь (в HTTP/БД-вызовы)? - Для каждого
WithCancel/WithTimeout/WithDeadlineвызываетсяcancel()(обычноdefer cancel())? - Разделяемое состояние защищено (мьютекс/атомик/канал), а не «кажется, и так норм»? Прогнали
go test -race? - Параллелизм ограничен (worker pool, семафор,
errgroup.SetLimit), а не «горутина на каждую задачу» без предела? - Выбор канал-vs-мьютекс сделан по существу (передаёте данные — канал; защищаете состояние — мьютекс), а не по догме?
- Если целитесь в
goниже 1.22 — учтён захват переменной цикла?
Демо и версии
- Код: живой стенд
digital-cookbook/go-concurrency/— запускаемые версии паттернов из статьи (worker pool сselectна приёме И отправке, fan-in/out, pipeline,errgroup), каждый с тестом на корректность и отсутствие утечек (goleak); весь модуль проходитgo test -race ./.... Фрагменты в тексте — выжимки безpackage/импортов; полные программы — в стенде (goroutines/). - Версии: примеры требуют Go 1.25+ (значение
GOMAXPROCSпо умолчанию учитывает cgroup-лимиты начиная с Go 1.25) иgolang.org/x/sync(дляerrgroup). Вехи, на которые опирается статья: асинхронное вытеснение — Go 1.14; область видимости переменной цикла на итерацию — Go 1.22; cgroup-awareGOMAXPROCSпо умолчанию — Go 1.25. Замеры производительности (если понадобятся) снимайте у себя черезgo test -bench, а не берите числа из статей.
Документация и первоисточники
- Официально: Effective Go, The Go Memory Model, Data Race Detector, пакет
context,golang.org/x/sync/errgroup. - Блог Go: Go Concurrency Patterns: Pipelines and cancellation, Go Concurrency Patterns: Context.
- Смежное на сайте: обзор моделей конкурентности в разных языках; дальше по серии — пакет sync и атомики, модель памяти: happens-before, паттерны конкурентности, отладка гонок и утечек.
Комментарии