Запуск конкурентного кода в Go умещается в одно слово: go перед вызовом функции. Канал создаётся выражением make(chan int), и учебник объявляет проблему синхронизации решённой. Реальные проекты рассказывают другую историю: гонки данных, которые компилятор молча пропускает, горутины, утекающие по одной на запрос, взаимные блокировки на каналах и паники, уносящие весь процесс. Модель действительно проще ручных потоков, но дистанция между словами "пишется легко" и "отлаживается легко" в конкурентном коде измеряется неделями. Ниже разбор того, какие ошибки выживают в Go-коде, что ловит рантайм без помощи человека и какие инструменты действительно приближают правду.

Что обещает конкурентная модель Go и что она даёт на самом деле

Синтаксис держит обещание. Горутина стартует за микросекунды, стек начинается с единиц килобайт и растёт по мере надобности, планировщик M:N распределяет тысячи горутин по ядрам, число которых задаёт GOMAXPROCS. По умолчанию оно равно числу процессорных ядер, и путь к этому умолчанию был небыстрым: до Go 1.5 рантайм использовал одно ядро, если программист явно не просил больше. Поговорка языка про разделяемую память стала визитной карточкой: не общайтесь через общие переменные, делитесь данными через общение. Канал выглядит безопасным контейнером, а мьютекс считается запасным выходом для скучных случаев.

За словами про общение стоит конкретная дисциплина. Отправка в канал это точка синхронизации: всё, что записала горутина до отправки, видно получателю после чтения. Модель памяти языка формализована, к релизу Go 1.19 её пересобрали и описали happens-before строго, поэтому спор о том, увидит ли читатель новое значение, решается чтением документа, а не спорами на форумах.

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

var wg sync.WaitGroup
for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        process(n)
    }(i)
}
wg.Wait()

Передача счётчика параметром когда-то была единственной защитой от общего доступа к переменной цикла. С версии Go 1.22 семантика изменилась: переменная for создаётся заново на каждой итерации, и старый баг с захватом общего i ушёл в учебники истории. Другие гонки из учебников не вывелись.

Гонки данных которые компилятор молча пропускает

Самая короткая гонка в мире пишется одной строкой. Тысяча горутин инкрементирует общий счётчик, и операция counter++ распадается на чтение, сложение и запись:

var counter int
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for j := 0; j < 100; j++ {
            counter++
        }
    }()
}
wg.Wait()
fmt.Println(counter)

Ожидание 100000, реальный вывод скачет между запусками. Никакой ошибки, никакого стектрейса, только неверный результат в продакшене и чувство, что с железом что-то не так. Команда go vet ловит копирование мьютексов и часть ошибок printf, но гонок не видит.

Второй по популярности источник это общая карта. Рантайм следит за ней сам, и при одновременной записи процесс падает с сообщением "fatal error: concurrent map writes". Звучит как защита, на деле это минимальный зонт: если две горутины пишут в map через какие-нибудь поля структуры, а третья читает срез, детектор рантайма молчит. Тихие порчи остаются, падение происходит лишь там, где рантайм догадался поставить проверку.

Страшнее всего гонка тем, что она не оставляет следов. Повреждённое значение проходит по цепочке, попадает в базу, и через неделю его находят аналитики, потому что сумма не сходится с копейками. Ни стектрейса, ни лога, ни временной привязки к релизу: баг воспроизводится раз в неделю на одной машине из двадцати. Инженеры в такой истории обычно ищут проблему в сети и железе, потому что код же компилируется.

Race detector ловит только те гонки которые успели произойти

Главный инструмент Go это флаг -race. Детектор на основе happens-before отслеживает каждое обращение к памяти и связывает его с точками синхронизации: каналами, мьютексами, WaitGroup, атомиками:

go test -race ./...
go run -race cmd/server/main.go

При находке вывод предельно конкретен:

==================
WARNING: DATA RACE
Read at 0x00c0000a4010 by goroutine 8:
  main.main.func1()
      /srv/app/main.go:12 +0x38

Previous write at 0x00c0000a4010 by goroutine 7:
  main.main.func1()
      /srv/app/main.go:12 +0x4c
==================

Ложных срабатываний детектор не даёт, каждая находка это настоящая гонка. Плата соответствующая: потребление памяти растёт в 5-10 раз, время исполнения в 2-20 раз, поэтому в продакшене его держат на канареечном инстансе или не держат вовсе. Главное ограничение фундаментальное: детектор видит только те гонки, которые случились во время исполнения. Гонка на редкой ветке обработки ошибки не поймана, пока тест не прошёл по этой ветке. Вывод жёсткий: юнит-тесты с -race обязательны, но без стресс-тестов и гонок под нагрузкой они ловят лишь верхний слой.

Простые счётчики лечатся атомиком, структурные поля мьютексом:

var hits atomic.Int64

func handler() {
    hits.Add(1)
}

var mu sync.Mutex

func snapshot() []byte {
    mu.Lock()
    defer mu.Unlock()
    return append([]byte(nil), state...)
}

Тип atomic.Int64 появился в Go 1.19 и избавил от ошибок с выравниванием при самодельных атомиках.

Нюанс сборки, о котором вспоминают в конце: детектор требует включённого cgo, то есть на сервере сборки нужен рабочий компилятор C, а кросс-компиляция под платформу без готового тулчейна превратится в отдельный разговор. В конвейере CI гонки с -race удлиняют прогон тестов, и команды договариваются о ночном стресс-прогоне с детектором, оставляя быстрые проверки без него. Компромисс понятен, но уязвим: ветка, которую не трогал ночной стресс, снова остаётся без контроля.

Семантика каналов закрытие и молчаливые зависания

Канал окружён строгими правилами, и каждое нарушение роняет процесс или вешает его навсегда. Отправка в закрытый канал паникует, повторное закрытие паникует, чтение из пустого закрытого канала возвращает нулевое значение и флаг false:

ch := make(chan Job, 64)
close(ch)      // закрытие в зоне ответственности отправителя
v, ok := <-ch  // ok=false после опустошения

Нулевой канал это отдельная ловушка: чтение из него и запись в него блокируются навсегда, без паники и сообщений. В select такая ветка играет роль выключателя: nil-канал никогда не готов, и ветку можно динамически отключать подменой переменной. Зависание обнаруживается не сразу: горутина просто спит, пользователи ждут, графики молчат.

Сам select дисциплинирует код лучше, чем цепочки if, потому что ждёт несколько событий сразу и делает выход по отмене явным:

select {
case job := <-queue:
    handle(job)
case <-ctx.Done():
    return ctx.Err()
case <-time.After(5 * time.Second):
    return ErrIdleTimeout
}

Роли каналов фиксируются типами направления: chan<- int для функции, которая только отправляет, и <-chan int для той, что только читает. Компилятор отловит попытку закрыть канал получателем или записать в read-only параметр, и это тот редкий случай, когда ошибка конкурентности не доходит до рантайма.

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

Таймеры тоже долго были источником утечек: time.After внутри цикла создавал новый таймер на каждую итерацию, и до срабатывания он не собирался сборщиком мусора. С Go 1.23 семантика изменилась: таймеры и тикеры, на которые больше нет ссылок, собираются сразу, даже без вызова Stop. Старый код от этого не сломался, но классическая утечка из туториалов ушла в прошлое.

Утечки горутины и инструменты которые их находят

Горутина стоит копеек, пока их тысяча. Проблема начинается на графике NumGoroutine: число ползёт вверх на 50 единиц в час, а сброс происходит только при перезапуске пода. Утечка выглядит скромно: функция читает из канала, в который после таймаута никто не пишет, или ждёт response.Body, который забыли закрыть.

Ловится утечка профилем горутин. Стандартная связка выглядит так:

import _ "net/http/pprof"

go func() {
    http.ListenAndServe("localhost:6060", nil)
}()

Дальше в ход идёт инструмент:

go tool pprof http://localhost:6060/debug/pprof/goroutine

Дамп показывает состояние каждой горутины: chan receive, chan send, select, IO wait. Стоит искать самую частую повторяющуюся стопку: если 40 000 горутин висят в одной функции, вопрос закрыт, осталось найти, кто не закрыл канал. Число живых горутин удобно выносить в метрику, чтобы график увидел дрейф раньше людей:

expvar.Publish("goroutines", expvar.Func(func() any {
    return runtime.NumGoroutine()
}))

Метрика NumGoroutine в мониторинге это ровно одна линия, и она честнее любых рассуждений: у здорового сервиса кривая возвращается к базовому уровню после всплеска, у утекающего держит новое плато. В тестах утечки проверяет goleak: отложенный вызов VerifyNone падает, если тест породил горутины и не дождался их завершения. Для медленных операций подключают профили блокировок через SetBlockProfileRate и профиль мьютексов через SetMutexProfileFraction.

Паники в горутинах и взаимные блокировки

Паника в горутине без recover завершает весь процесс, recover в родительской горутине не помогает: границы у защиты нет. Сервис с тысячей рабочих горутин умирает целиком из-за одного nil в недрах парсера. Переменная окружения GOTRACEBACK=all разворачивает дамп всех горутин в момент падения, без неё инженер увидит стек только виновника.

С дедлоками картина двухслойная. Полный ступор рантайм распознаёт сам и падает с сообщением "fatal error: all goroutines are asleep - deadlock!". Частичный дедлок не виден никому: три горутины ждут друг друга, четвёртая обслуживает health-проверку, процесс жив, пользователи страдают. Тесты на взаимные блокировки пишутся через таймауты на WaitGroup и контексты, а не надеются на рантайм.

Воркеры, выполняющие чужую работу, оборачивают защиту сами, иначе одна паника в задаче уронит весь пул:

go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("worker panic: %v", r)
        }
    }()
    handle(<-queue)
}()

Дисциплина вместо обещанной простоты

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

jobs := make(chan Request, 128)
for i := 0; i < 32; i++ {
    go worker(jobs, results)
}

Группы горутин удобно вести через errgroup, который собирает первую ошибку и отменяет остальное через контекст:

g, ctx := errgroup.WithContext(ctx)
for _, task := range tasks {
    task := task
    g.Go(func() error {
        return process(ctx, task)
    })
}
if err := g.Wait(); err != nil {
    return err
}

Свод правил для ревью конкурентного кода короткий:
1. каждая переменная принадлежит одной горутине, обмен идёт через канал;
2. мьютекс для счётчиков и маленьких структур, канал для потоков событий;
3. канал закрывает только отправитель и только один раз;
4. у каждой горутины есть путь завершения через контекст;
5. тесты запускаются с -race и со стресс-нагрузкой;
6. утечки горутин проверяются goleak и графиком NumGoroutine.

Простота Go не отменила сложность конкурентности, она её замаскировала. Слово go создаёт горутину за микросекунду, а понимание того, что она делает с общими переменными, не покупается синтаксисом. Язык даёт честный детектор гонок, профили и дампы горутин, то есть всё, чтобы разобраться за вечер, а не за неделю. Внешняя простота модели работает на того, кто уже знает правила: она экономит рутину, а не мышление.