Тема 02

Конкурентность

Секция, ради которой Go и берут. Её спрашивают всегда, копают глубже всех остальных и почти наверняка попросят что-нибудь написать руками — worker pool, merge, таймаут. Поэтому здесь важны не определения, а механика: что делает рантайм в каждый момент и как это выглядит из горутины.

Что реально проверяют этой секцией

Три вещи. Первая — понимаешь ли ты, кто кого блокирует: горутина, поток ОС или весь процесс. Вторая — умеешь ли останавливать то, что запустил: любая незакрытая горутина в проде — это утечка памяти и файловых дескрипторов. Третья — чувствуешь ли ты гонку глазами, ещё до race-детектора. Кандидат, который на вопрос «а что если никто не читает из канала» отвечает «программа зависнет», и кандидат, который отвечает «горутина-отправитель встанет в sendq, её M подберёт другую горутину, а вот если это была последняя живая горутина — рантайм напечатает all goroutines are asleep» — это два разных грейда.

2.1Горутины и планировщик

Здесь проверяют, понимаешь ли ты, что горутина не «лёгкий поток», а структура данных, которую двигает планировщик рантайма. Дальше вопросы про каналы, дедлоки и утечки идут легко, если эта картина в голове есть, и не идут никак, если её нет.

Сначала — четыре слова, без которых GMP не читается

Практический вопрос тут один: что происходит с программой, пока горутина чего-то ждёт? Ответ «ну ждёт» стоит грейда: от того, чего именно она ждёт, зависит, освободилось ли ядро процессора, вырос ли расход памяти и не встал ли заодно соседний код. Разбирать его, впрочем, не выйдет, пока не договоримся о четырёх словах: без них три буквы GMP ниже читаются как шум.

1. Поток ОС — то, что реально исполняет код, и его дорого плодить

Процессор не знает ни про программы, ни про функции: он исполняет инструкции. Единица, которой операционная система раздаёт процессорное время, называется поток (thread, по-русски ещё говорят «нить»). Поток заводит ядро ОС по запросу программы (на Linux это системный вызов clone, за которым обычно стоит pthread_create), ядро же ведёт его учёт и решает, когда он работает.

За словом «дорого» стоят три конкретные вещи. Память: каждому потоку ядро отводит собственный стек фиксированного размера, обычно 1–8 МБ. Резервируется он виртуально, физические страницы подтягиваются по мере обращения, но само адресное пространство расходуется сразу, и на миллион потоков это терабайты. Создание: 10–100 микросекунд, потому что надо сходить в ядро и завести служебные структуры. Существование: чем больше потоков, тем больше работы у планировщика ядра при каждом выборе. Отсюда практический потолок: десятки тысяч потоков уже много, миллион не поднять.

2. Переключение контекста — сохранить одно состояние и загрузить другое

В контекст входит всё, что нужно, чтобы продолжить прерванное исполнение ровно с того же места: значения регистров процессора, указатель на вершину стека (SP) и указатель на текущую инструкцию (PC). Переключение контекста значит сохранить контекст того, кто работал, и загрузить контекст следующего.

Между потоками ОС это делает ядро, и обходится оно примерно в 1–2 микросекунды. Дорого не копирование регистров само по себе, а сопутствующее: переход в режим ядра и обратно, а главное, холодные кэши процессора. Новый поток лезет в свои данные, которых в кэше нет, и первые тысячи обращений идут в оперативную память вместо кэша.

3. Системный вызов — просьба к ядру, на время которой поток встаёт

Только через системный вызов (syscall) программа может попросить ядро сделать то, чего не умеет сама: прочитать файл, отправить байты в сокет, узнать время, взять у ОС ещё памяти. Программа складывает номер вызова и аргументы в регистры и передаёт управление ядру.

Если результата ещё нет (скажем, read из сокета, в который никто пока не написал), ядро усыпляет вызвавший поток до готовности. Вот это и есть «блокирующий вызов». Блокируется не абстрактная «программа», а конкретный поток ОС: он занят, но при этом ничего не делает. Именно поэтому классическая модель «поток на соединение» упирается в потолок: десять тысяч ждущих соединений означают десять тысяч спящих потоков.

4. Планировщик — тот, кто решает, кому работать сейчас

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

Планировщик ядра вытесняющий. При вытеснении (preemption) задачу снимают с процессора принудительно, без её согласия: истёк отмеренный ей квант времени (обычно единицы миллисекунд), сработало прерывание от таймера, управление ушло планировщику. Наоборот устроено кооперативное планирование: задача обязана уступить сама, и одна зациклившаяся останавливает всех остальных. Забегая вперёд: планировщик Go начинал как кооперативный, а настоящее вытеснение получил только в 1.14.

Зачем Go понадобился свой планировщик

Go построен на идее «на каждую задачу по исполнителю»: на каждое соединение, на каждый запрос, на каждую стадию конвейера. Это десятки и сотни тысяч одновременных задач, и подавляющее большинство из них в любой момент просто чего-то ждёт. Потоков ОС столько не завести: упрёмся в память и в стоимость создания (пункт 1), а каждое ожидание в системном вызове будет выводить из игры целый поток (пункт 3).

Поэтому Go завёл второй уровень планирования, поверх ядерного. Рантайм создаёт немного потоков ОС и сам раскладывает по ним много горутин, переключая их в пользовательском пространстве, вообще не обращаясь к ядру. Такую схему называют моделью M:N (M задач исполняются на N потоках), а сам процесс раскладки мультиплексированием: много логических исполнителей поверх немногих физических.

Что даёт эта надстройка и чего она стоит

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

Что такое горутина физически

Горутина живёт в памяти как значение типа runtime.g: структура в куче рантайма, где лежат указатель на её стек, сохранённый контекст выполнения (gobuf: SP, PC, указатель на саму g), статус, ссылка на m, на котором она сейчас исполняется, поля для defer/panic-цепочек, для профилировщика и для планировщика. Сам объект g занимает порядка 200–400 байт в зависимости от версии; главный расход всё равно стек, он стартует с 2 КБ.

Оператор go f(x) компилируется в вызов runtime.newproc: рантайм берёт свободную g из кэша (gfree у P или глобального списка) либо создаёт новую, копирует аргументы в её стек, выставляет gobuf.pc на тело функции, переводит в статус _Grunnable и кладёт в слот runnext текущего P. Никакого обращения к ядру: запуск горутины стоит десятки-сотни наносекунд, а не микросекунды, как у pthread_create.

ПроцессПоток ОСГорутина
Кто создаётядро (fork)ядро (clone)рантайм Go, в user space
Кто планируетпланировщик ядрапланировщик ядра, вытесняющийпланировщик Go + сигнал для вытеснения
Адресное пространствосвоё, изолированноеобщее с процессомобщее с процессом
Стексвоя память целикомфиксированный, 1–8 МБ, резервируется виртуально2 КБ, растёт и сжимается копированием
Стоимость созданиямиллисекунды~10–100 мкс~0,1–0,3 мкс
Переключениесмена таблиц страниц, TLB flush~1–2 мкс, вход в ядро~50–200 нс, сохранить 3 регистра в gobuf
Сколько потянет машинатысячитысячи–десятки тысячсотни тысяч–миллионы
Есть ли idPIDTIDесть goid, но намеренно не выставлен в API
Готовая формулировка

«Горутина — это задача, а не поток. Она мультиплексируется на потоки ОС в пропорции M:N. Дёшева она по трём причинам: маленький растущий стек вместо фиксированного мегабайтного, переключение без входа в ядро, и планировщик, который знает точки, где горутину безопасно снять, потому что сам их и расставил.»

Три буквы: G, M, P

  • G (goroutine) — задача. Их заводят миллионами.
  • M (machine) — поток ОС. Только M реально исполняет код на ядре процессора. Число M плавает, лимит по умолчанию 10 000 (debug.SetMaxThreads).
  • P (processor) — право исполнять Go-код плюс всё, что нужно для локальной работы без глобальных локов: локальная очередь runnable-горутин на 256 слотов, слот runnext, кэш аллокатора mcache, кэш свободных g, кэш таймеров, буфер GC-работы. Число P фиксировано и равно GOMAXPROCS.

Чтобы выполнить горутину, M должен владеть P. Значит, одновременно исполняется ровно GOMAXPROCS горутин, сколько бы потоков ни было создано. P появился в Go 1.1 именно ради локальных очередей: до этого была одна глобальная очередь под одним мьютексом, и планировщик не масштабировался.

Глобальная очередь runnable-горутин sched.runq — одна на процесс, под глобальным локом проверяется раз в 61 тик — чтобы очередь не голодала P0 локальная очередь, 256 слотов runnext G G G G M0 поток ОС G7 выполняется P1 очередь заполнена runnext G G G G G G M1 поток ОС G12 выполняется P2 очередь пуста M2 ищет работу простаивает work stealing: пустой P крадёт половину очереди у случайного соседа GOMAXPROCS = число P. Одновременно исполняется ровно столько горутин, сколько P, а не сколько M: P — это право исполнять Go-код. Горутин может быть миллион, M — сотни (потолок 10 000), P — обычно по числу ядер. Это и есть модель M:N.
GMP. Каждый P держит свою очередь и работает без глобальных локов; глобальная очередь работает только как «перелив» и балансир. Простаивающий P не ждёт, а идёт красть работу, и неравномерная нагрузка сама собой размазывается по ядрам.

Как планировщик выбирает следующую горутину

Цикл schedule() вызывает findRunnable(), и порядок поиска там строго определён. На собесе его просят назвать по шагам:

  1. Каждый 61-й тик планировщика: заглянуть в глобальную очередь и взять оттуда одну горутину. Магическое число нужно, чтобы эта очередь не голодала, пока локальная бесконечно пополняет сама себя.
  2. runnext текущего P, слот на одну горутину. Туда попадает та, которую только что породил go или разбудил канал: расчёт на локальность кэша, «мы только что её создали, данные горячие».
  3. Локальная очередь P (runqget), lock-free кольцевой буфер на 256 элементов.
  4. Глобальная очередь: берём сразу пачку (len/GOMAXPROCS + 1), чтобы не ходить под глобальный лок на каждую горутину.
  5. netpoll в неблокирующем режиме: забрать горутины, чьи сокеты стали готовы. Netpoller, часть рантайма, следит за готовностью сетевых дескрипторов и держит у себя список горутин, ждущих каждый из них; подробности ниже, в разделе про блокировки.
  6. При work stealing («воровство работы») простаивающий исполнитель сам забирает часть очереди у загруженного, вместо того чтобы кто-то сверху раздавал задачи поровну. Здесь это до четырёх попыток украсть у случайно выбранного P половину его локальной очереди. На последней попытке разрешено красть и runnext, и таймеры.
  7. Не нашлось ничего: M отдаёт P в список простаивающих и паркуется (stopm), пока его кто-нибудь не разбудит.
sysmon — поток без P

Отдельный M, который крутится вне модели GMP (ему P не нужен) и спит от 20 мкс до 10 мс. Он делает четыре вещи: retake отбирает P у потока, застрявшего в syscall дольше 20 мкс, и помечает на вытеснение горутину, которая крутится дольше 10 мс; netpoll, если сеть никто не опрашивал больше 10 мс; forcegc, если GC не запускался 2 минуты; и scavenger возвращает неиспользуемую память ОС. Без sysmon Go-программа с одной бесконечно считающей горутиной вешала бы GC.

Блокировки: syscall, netpoller, gopark

Спрашивают это так: «горутина заблокировалась, что с потоком?». Ответ зависит от вида блокировки, и вариантов ровно три.

Что случилось Что делает рантайм Поток M Контекст P syscall read() файл, cgo, exec entersyscall помечает P как _Psyscall; sysmon через 20 мкс отбирает его заблокирован в ядре вместе с горутиной отцепляется и уходит к другому M — работа идёт conn.Read() сокет не готов fd неблокирующий, EAGAIN: gopark(G) и регистрация в netpoller (epoll/kqueue/IOCP) не блокируется — берёт следующую G остаётся при своём M, простоя нет <-ch, mu.Lock() данных нет gopark: G снимается с M и кладётся в очередь ожидания канала/семафора — без ядра переключается на другую G за десятки наносекунд остаётся при своём M, простоя нет Блокировка горутины не равна блокировке потока. Поток теряется только на настоящем блокирующем syscall и в cgo — поэтому 100 000 горутин на сети живут на десятке потоков, а 100 000 горутин на чтении файлов породят сотни потоков и съедят память.
Три вида блокировки. Только первая строка стоит потока ОС. Вторая держится на netpoller: Go переводит все сетевые дескрипторы в неблокирующий режим и сам мультиплексирует их через epoll/kqueue, пряча это за синхронным API.

netpoller тонко оборачивает epoll (Linux), kqueue (BSD/macOS), IOCP (Windows) и io_uring-подобные механизмы. Когда ты открываешь сокет через net, рантайм ставит дескриптору O_NONBLOCK и регистрирует его в поллере. Твой conn.Read(buf) выглядит синхронным, но внутри: попытка readEAGAINgopark горутины со ссылкой на её g в структуре pollDesc. Когда epoll_wait (его дёргает планировщик в findRunnable и sysmon) сообщает о готовности, netpollready достаёт g и переводит её в _Grunnable. Это и есть «асинхронный I/O с синхронным кодом», ради которого в других языках пишут async/await.

Что netpoller не покрывает

Обычные файлы на Linux. epoll для регулярных файлов бесполезен: они «всегда готовы», а ждём мы на самом деле дисковый I/O внутри ядра. Поэтому os.File.Read уходит в честный блокирующий syscall, и тысяча горутин, читающих тысячу файлов, породит близко к тысяче потоков ОС. То же самое с cgo-вызовами и os/exec. Если на собесе спросят «почему у сервиса 800 потоков при GOMAXPROCS=8», ответ почти всегда «блокирующие syscall или cgo».

Померить это стало проще: в Go 1.26 в runtime/metrics завезли метрики планировщика: /sched/threads/total:threads (сколько потоков ОС реально держит рантайм) и разбивку горутин по состояниям /sched/goroutines/running, /runnable, /waiting, /not-in-go. Последняя как раз и считает горутины, застрявшие в системном вызове или в cgo, то есть ровно те, что оттягивают на себя потоки. Раньше это приходилось выводить из pprof/threadcreate и дампа горутин.

Вытеснение: кооперативное и асинхронное

До Go 1.14 планировщик был кооперативным: снять горутину можно было только в точке, где она сама заглядывает к рантайму. Такими точками были вызовы функций (в прологе есть проверка стека), операции с каналами, аллокации, runtime.Gosched(), syscall. Механика: планировщик выставлял g.stackguard0 = stackPreempt, заведомо «переполненное» значение; пролог функции из-за него уходил в morestack, а тот видел флаг и передавал управление планировщику.

Дырка была очевидная: цикл без вызовов функций и без аллокаций вытеснить нельзя.

func main() {
    runtime.GOMAXPROCS(1)
    go func() { for {} }()      // до Go 1.14: этот цикл навсегда захватывал единственный P
    time.Sleep(time.Millisecond)
    fmt.Println("этой строки в Go 1.13 не будет, а в 1.14+ будет")
}

// запуск на go1.27:
// этой строки в Go 1.13 не будет, а в 1.14+ будет

В Go 1.14 появилось асинхронное вытеснение (proposal 24543). Sysmon видит, что горутина крутится дольше 10 мс, и посылает потоку сигнал SIGURG. Обработчик сигнала проверяет, что горутина стоит в «асинхронно безопасной точке» (нет незавершённой работы с указателями, не в критической секции рантайма), и подменяет адрес возврата на asyncPreempt: та сохраняет все регистры (поэтому кадр у неё большой) и уходит в планировщик. Почему именно SIGURG: он редко используется и не конфликтует с debuggers и библиотеками.

Ответ на «может ли горутина захватить процессор»

«С Go 1.14 практически нет: через 10 мс придёт SIGURG и её вытеснят. Но есть исключения, где вытеснение невозможно: код внутри cgo-вызова, участки рантайма с //go:nosplit, отключённые сигналы, а также случай, когда все P заняты и вытеснять некуда. И вытеснение не спасает от логической монополии: горутина, которая держит мьютекс и считает миллиард итераций, вытеснится, но лок не отпустит.»

GOMAXPROCS и контейнеры

GOMAXPROCS задаёт число P, то есть максимальное число горутин, исполняющих Go-код одновременно. По умолчанию до Go 1.25 это runtime.NumCPU(), то есть количество логических ядер, видимых процессу. В контейнере это ядра всей ноды, а не лимит cgroup.

Отсюда классическая проблема Kubernetes: под с limits.cpu: "1" на 64-ядерной ноде получает GOMAXPROCS=64. Go честно запускает 64 горутины параллельно, cgroup выдаёт им суммарно одно ядро, и всё это упирается в CFS throttling: планировщик ядра замораживает контейнер до конца периода (обычно 100 мс). В итоге рваные p99-латенси, раздутый time-to-first-byte и «непонятные» паузы GC, потому что GC тоже раскладывается на GOMAXPROCS воркеров.

// До Go 1.25 делали так:
import _ "go.uber.org/automaxprocs"   // читает cgroup-лимит и вызывает runtime.GOMAXPROCS

// Или вручную:
runtime.GOMAXPROCS(4)                  // но это статика, при смене лимита не обновится

// В Go 1.25 (при go >= 1.25 в go.mod) из коробки:
//  * дефолт = min(NumCPU, ceil(cgroup CPU limit)), но не меньше 2
//  * значение пересчитывается при смене лимита (проверка примерно раз в секунду)
//  * runtime.GOMAXPROCS(0) теперь просто читает значение, не фиксируя его
//  * отключается: GODEBUG=containermaxprocs=0,updatemaxprocs=0 или явный GOMAXPROCS=N
Практика

Никогда не крути GOMAXPROCS «на глаз» ради производительности: на CPU-bound задачах это почти всегда деградация. Оправданных случаев три: попасть в cgroup-лимит (теперь это решает сама версия Go), детерминизм в тестах (GOMAXPROCS=1 иногда прячет гонки, а иногда, наоборот, делает их воспроизводимыми) и обход багов в чужих библиотеках. И помни: GOMAXPROCS не ограничивает число потоков, их ограничивает debug.SetMaxThreads, по умолчанию 10 000, при превышении процесс падает.

Стек горутины: 2 КБ и как он растёт

Горутина стартует со стеком в 2 КБ (константа _StackMin, такова она с Go 1.4). До Go 1.3 использовались сегментированные стеки: при нехватке места выделялся новый сегмент и связывался со старым. От этого отказались из-за «hot split»: если граница сегмента попадала внутрь горячего цикла, каждая итерация выделяла и освобождала сегмент, и скорость падала в разы. С Go 1.4 стеки копирующие.

потолок 1 ГБ на 64 бит (250 МБ на 32) — дальше fatal error: stack overflow 2 КБ стек старт горутины 4 КБ стек 1-е переполнение 8 КБ стек 2-е 16 КБ … стек каждый раз ×2 morestack copystack copystack shrinkstack во время GC: ужать вдвое, если занято < 1/4 Стек растёт не расширением, а переездом: рантайм выделяет вдвое больший блок, копирует туда старый и правит все указатели, которые смотрели внутрь стека — для этого компилятор и хранит точные карты указателей. Поэтому адрес переменной на стеке нестабилен.
Рост стека. Пролог каждой функции сравнивает SP с g.stackguard0; если места не хватает, уходит в morestack и copystack. Тот же механизм приспособили под кооперативное вытеснение: планировщик просто пишет в stackguard0 значение stackPreempt.

Вопросы

12
Суть: горутина — структура runtime.g в куче плюс маленький растущий стек; её планирует рантайм Go в user space и мультиплексирует на потоки ОС в модели M:N.

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

Три источника дешевизны

  1. Стек. Поток резервирует фиксированный (1–8 МБ виртуально, страницы подтягиваются по обращению). Горутина стартует с 2 КБ реальной памяти и растёт копированием. Миллион потоков занимает терабайты виртуального адресного пространства, а миллион горутин обходится несколькими гигабайтами реальной памяти.
  2. Переключение. Сменить горутину значит сохранить SP, PC и указатель на g в gobuf и загрузить другие. Не нужно менять привилегии, не нужно сбрасывать TLB, кэши остаются тёплыми. ~50–200 нс против 1–2 мкс.
  3. Кто планирует. Планировщик Go знает семантику: он видит, что горутина встала на канале, и переключает мгновенно, а не ждёт истечения кванта. Ядро такого не знает.

Чем горутина хуже потока

  • Нет приоритетов, нет affinity к ядру, нет real-time гарантий.
  • Нельзя убить извне (см. отдельный вопрос).
  • Настоящий блокирующий syscall всё равно займёт целый поток ОС.
  • Нет изоляции: паника в любой горутине без recover роняет весь процесс.
Добивка про goid

У горутины есть числовой goid, его видно в панике и в runtime.Stack. Но в публичном API его нет намеренно: команда Go боялась, что появятся goroutine-local storage и «привязка контекста к id», как с ThreadLocal в Java, а это ломает всю модель явной передачи контекста. Достают его грязным парсингом runtime.Stack, но на ревью такое не проходит.

Суть: 2 КБ на старте; при нехватке рантайм выделяет вдвое больший блок, копирует туда содержимое и правит указатели внутрь стека. Потолок — 1 ГБ на 64 бит.

Механика

Компилятор вставляет в пролог почти каждой функции сравнение указателя стека с g.stackguard0. Если для кадра места не хватает, управление уходит в runtime.morestack, оттуда в newstack и copystack: выделить блок вдвое больше, скопировать байты, обойти все указатели, которые смотрели внутрь старого стека, и сдвинуть их на разницу адресов. Для этого компилятор хранит точные карты указателей на каждый safe-point: тот же механизм обслуживает и точный GC.

Обратное делает shrinkstack: во время GC, если горутина использует меньше четверти своего стека, он ужимается вдвое.

Историческая часть, которую любят спрашивать

До Go 1.3 стеки были сегментированными: новый кусок связывался со старым списком. Проблема называлась «hot split»: если граница сегмента попадала внутрь горячего цикла, каждая итерация выделяла и освобождала сегмент, и профиль проваливался на порядок. В 1.3–1.4 перешли на копирующие стеки, а начальный размер снизили с 8 КБ до 2 КБ.

// Показать рост стека можно так:
func deep(n int) {
    var pad [512]byte           // чтобы кадр был заметным
    _ = pad
    if n > 0 { deep(n - 1) }
}
// gctrace рост стека не покажет. Его видно в CPU-профиле как runtime.morestack
// и runtime.copystack: go test -run TestDeep -cpuprofile cpu.out
Практическое следствие

Адрес переменной на стеке нестабилен: после copystack он изменится. Поэтому нельзя хранить uintptr на стековые данные, нельзя передавать указатель на стековую переменную в C (без cgo.Handle или runtime.Pinner), и поэтому же переменная, чей адрес утекает наружу, обычно уезжает в кучу. Ещё следствие: бесконечная рекурсия падает не сразу, а когда стек дорастёт до 1 ГБ, и падает с fatal error: stack overflow, которую recover не ловит.

Суть: конкурентность — свойство структуры программы (задачи независимы и могут чередоваться), параллелизм — свойство исполнения (задачи реально идут одновременно на разных ядрах).

Конкурентная программа остаётся конкурентной на одном ядре: GOMAXPROCS=1 не мешает запустить тысячу горутин, они просто будут чередоваться. Параллелизм появляется, когда ядер больше одного и GOMAXPROCS > 1. Отсюда несимметричность: конкурентность даёт возможность параллелизма, но не наоборот.

Пример, который лучше мантры

HTTP-сервер, который на каждый запрос ходит в базу и в стороннее API. На одном ядре он всё равно обслужит тысячу одновременных соединений: пока одна горутина ждёт ответа сети, ядро свободно, и на нём работает другая. Это чистая конкурентность без параллелизма, и выигрыш тут не от ядер, а от того, что ожидание не блокирует.

Теперь обратный случай: посчитать SHA-256 от тысячи файлов. Это CPU-bound работа, и конкурентность сама по себе не ускорит ничего: на одном ядре тысяча горутин посчитает ровно столько же, сколько один цикл, только с накладными расходами на переключения. Ускорит именно параллелизм: восемь ядер дадут примерно восьмикратный выигрыш.

КонкурентностьПараллелизм
Что этодизайн: задачи независимыисполнение: задачи идут одновременно
Нужно ли много ядернетда
Что даётотзывчивость, утилизацию ожиданийпропускную способность на CPU
Где выигрываетI/O-bound: сеть, диск, БДCPU-bound: хеши, сжатие, матрицы
В Go этогорутины, каналы, selectGOMAXPROCS и число ядер
Как это звучит на собесе

Цитата Роба Пайка «Concurrency is not parallelism» сама по себе ничего не показывает, её называют все. Показывает продолжение: «конкурентность про то, как разложить задачу на независимо продвигающиеся части; параллелизм про то, как эти части исполнить. Go даёт первое как языковую конструкцию, второе как настройку рантайма.»

Суть: G — горутина (задача), M — поток ОС (исполнитель), P — контекст с локальной очередью и кэшами (право исполнять Go-код). Для работы нужна связка M+P; число P фиксировано и равно GOMAXPROCS.

Зачем понадобился P

В Go 1.0 модель была GM: одна глобальная очередь под одним мьютексом. На многоядерных машинах это упиралось в contention, а ещё убивало локальность: горутина могла уехать на любой поток, и кэш процессора обнулялся. В Go 1.1 Дмитрий Вьюков добавил P: теперь у каждого P своя очередь на 256 слотов (lock-free кольцевой буфер) и свой mcache, так что обычные аллокации и планирование идут без единого атомарного конфликта.

Порядок поиска работы (findRunnable)

  1. раз в 61 тик: одна горутина из глобальной очереди (чтобы та не голодала);
  2. runnext: слот на одну «только что созданную/разбуженную» горутину, ради локальности кэша;
  3. локальная очередь P;
  4. глобальная очередь: пачкой len/GOMAXPROCS + 1;
  5. неблокирующий netpoll;
  6. work stealing: до 4 попыток украсть половину очереди у случайного P;
  7. ничего нет, и тогда P в idle-список, M паркуется.

Где рождается горутина

go f() кладёт новую G в runnext текущего P. Если локальная очередь переполнена (256 штук), половина её вместе с новой горутиной уезжает в глобальную очередь одним батчем. Это и есть «перелив»: одному P не дают накопить всё.

Тонкость про runnext

runnext ломает FIFO: последняя созданная горутина запускается первой. Размен осознанный: локальность кэша против справедливости. Чтобы горутину в runnext не выбил мгновенно следующий go, у неё есть «наследование» кванта: если её сразу выбьют, старая уедет в начало локальной очереди. И красть runnext у соседа разрешено только на последней попытке стилинга, да и то с задержкой в 3 мкс, чтобы дать паре «отправитель-получатель» доработать.

Суть: M:N — много горутин на мало потоков. Потоки на ядра раскладывает уже планировщик ОС, Go на это не влияет.

Цепочка такая: G → (P) → M → поток ОС → ядро. Горутина попадает в очередь P; M, владеющий этим P, снимает её и исполняет; поток ОС планируется ядром на физическое ядро по своим правилам. Go не управляет affinity и не может «прибить» горутину к ядру: ближайшее, что есть, это runtime.LockOSThread: она прибивает горутину к конкретному потоку (нужно для OpenGL, некоторых C-библиотек и Linux namespace).

Количественно

  • G: сколько запустил, столько и есть, упираешься только в память.
  • P: ровно GOMAXPROCS, фиксировано на время работы (в 1.25 может меняться при смене cgroup-лимита).
  • M: динамически. Рантайм создаёт новый M, когда текущий уходит в блокирующий syscall и P нужно отдать другому. Потолок 10 000.

Поэтому в проде число потоков работает как диагностика. GOMAXPROCS=8 и 12 потоков это норма. GOMAXPROCS=8 и 600 потоков значат, что где-то массово блокирующие syscall или cgo, и это стоит памяти (у каждого M ещё и свой системный стек) и переключений контекста.

// сколько потоков у процесса
// Linux:  cat /proc/<pid>/status | grep Threads
// изнутри программы:
import "runtime/pprof"
p := pprof.Lookup("threadcreate")   // сколько потоков вообще создавалось
fmt.Println(p.Count())              // 5 на пустой программе с GOMAXPROCS=14.
                                    // Число зависит от машины и от истории процесса,
                                    // а не от текущего числа живых потоков
Суть: на блокирующем syscall M застревает вместе с горутиной, но P у него отбирают и отдают другому потоку. Сетевой I/O под это вообще не попадает: дескрипторы неблокирующие, ожидание живёт в netpoller (epoll/kqueue/IOCP), горутина паркуется без потока.

Блокирующий syscall

  1. Перед вызовом рантайм зовёт entersyscall: сохраняет контекст горутины и переводит P в состояние _Psyscall.
  2. Если syscall возвращается быстро, exitsyscall подхватывает тот же P: дешёвый быстрый путь.
  3. Если нет, sysmon через ~20 мкс видит зависший _Psyscall и делает handoffp: отбирает P и отдаёт другому M (взяв свободный или создав новый). Работа на этом ядре продолжается.
  4. Когда syscall наконец вернётся, «осиротевший» M попробует получить любой свободный P; если не выйдет, положит горутину в глобальную очередь и припаркуется в пуле потоков.

netpoller

Всё, что открыто через пакет netos.Pipe, и таймеры на части платформ), рантайм переводит в O_NONBLOCK и регистрирует в поллере. Вызов conn.Read внутри выглядит так: попытка readEAGAINgopark горутины со ссылкой на её g в pollDesc. Горутина снята с потока, поток пошёл выполнять следующую.

Поллер дёргают в трёх местах: в findRunnable (неблокирующий netpoll), перед парковкой последнего M (блокирующий, иначе некому будет проснуться) и в sysmon, если сеть не опрашивали больше 10 мс. Готовые горутины переводят в _Grunnable и кладут в очередь.

На что ловят
  • Файловый I/O через netpoller не идёт (на Linux epoll для обычных файлов бесполезен). Под os.File.Read лежит честный блокирующий syscall.
  • cgo-вызов ведёт себя так же: занимает поток на всё время, вытеснить нельзя, и вдобавок стоит ~50–100 нс на переход туда-обратно.
  • Отсюда рецепт: файловые операции и cgo гоняем через ограниченный пул (семафор на буферизированном канале), а не «горутина на каждый файл».
Суть: до 1.14 вытеснение было только в safe-точках (вызов функции, аллокация, операция с каналом); с 1.14 sysmon шлёт потоку SIGURG и вытесняет горутину почти в любой точке. «Захватить» ядро сейчас практически нельзя, но исключения есть.

Кооперативное вытеснение

Планировщик записывает в g.stackguard0 специальное значение stackPreempt. Пролог следующей вызванной функции сравнивает SP с этим полем, «видит переполнение», уходит в morestack, а тот распознаёт флаг и передаёт управление планировщику. Механизм роста стека переиспользован тут красиво, но работает только там, где есть вызов функции.

// Go 1.13 и раньше: программа зависала намертво
func main() {
    runtime.GOMAXPROCS(1)
    go func() { for { } }()     // ни вызовов, ни аллокаций: вытеснить негде
    time.Sleep(time.Second)
    fmt.Println("недостижимо в 1.13")
}

// запуск на go1.27: строка печатается через секунду, асинхронное вытеснение работает.
// недостижимо в 1.13

Асинхронное вытеснение (Go 1.14)

Sysmon замечает горутину, работающую дольше 10 мс, и шлёт её потоку сигнал SIGURG. Обработчик проверяет, что точка «асинхронно безопасна» (регистры описаны картами указателей, мы не в критической секции рантайма, не в //go:nosplit-функции), и подменяет адрес возврата на runtime.asyncPreempt: та сохраняет весь регистровый файл и уходит в планировщик. SIGURG выбрали потому, что он реально не используется и не мешает отладчикам.

Где всё ещё можно «захватить» процессор

  • Внутри cgo-вызова: сигнал не поможет, чужой код мы не контролируем.
  • В функциях рантайма с //go:nosplit и с отключёнными сигналами.
  • При GODEBUG=asyncpreemptoff=1: его иногда включают для отладки, потому что асинхронное вытеснение мешает воспроизводить некоторые баги.
  • Логически: горутина держит мьютекс и молотит цикл. Её вытеснят, но лок она не отдаст, и все ожидающие всё равно стоят. Вытеснение спасает планировщик, а не твой дизайн.
Зачем это вообще делали

Затевали это не ради справедливости, а ради GC. Сборщику нужно остановить все горутины на stop-the-world; если хоть одна не доходит до safe-точки, STW-пауза тянется до бесконечности. Именно поэтому асинхронное вытеснение приземлили вместе с работой над суб-миллисекундными паузами.

Суть: это число P, то есть предел параллельного исполнения Go-кода. До 1.25 дефолт — число ядер ноды, а не cgroup-лимит, отсюда throttling в Kubernetes. В Go 1.25 рантайм стал container-aware и ещё умеет обновлять значение на лету.

Что именно ограничивает

Не число потоков и не число горутин, а количество контекстов P. Одновременно исполняется Go-код максимум в GOMAXPROCS горутинах. Потоков при этом может быть сколько угодно: те, что сидят в блокирующих syscall, P не держат.

Проблема контейнеров

Под с limits.cpu: "1" на 64-ядерной ноде до Go 1.25 получал GOMAXPROCS=64, потому что runtime.NumCPU() читает маску affinity, а не cgroup-квоту. Дальше включается CFS throttling: cgroup выдаёт квоту на период 100 мс, 64 потока выжирают её за первые единицы миллисекунд, и остаток периода контейнер заморожен. Симптомы: рваные p99, «непонятные» лаги, метрика container_cpu_cfs_throttled_seconds_total ползёт вверх. GC тоже страдает: он масштабируется по GOMAXPROCS и создаёт лишних воркеров.

Что делать

// До Go 1.25 стандарт де-факто:
import _ "go.uber.org/automaxprocs"    // при старте читает cgroup v1/v2 и ставит GOMAXPROCS

// В Go 1.25 (нужно go >= 1.25 в go.mod) это в рантайме:
//   default = min(runtime.NumCPU(), ceil(cgroup CPU limit)), минимум 2
//   значение обновляется само при смене лимита (опрос ~раз в секунду)
//   GOMAXPROCS(0) больше не «замораживает» значение, а просто читает
//   выключить: GODEBUG=containermaxprocs=0,updatemaxprocs=0
//   переопределить: переменная окружения GOMAXPROCS=N или runtime.GOMAXPROCS(n)
Практика
  • Лимит в 1 CPU для Go-сервиса плох сам по себе: рантайму нужны ядра под GC и sysmon. Реалистичный минимум это 2.
  • Не выставляй GOMAXPROCS руками «для скорости»: на CPU-bound это почти всегда деградация.
  • Потоки ограничивает debug.SetMaxThreads (дефолт 10 000), а не GOMAXPROCS. Упёрлись, и процесс падает с fatal error: thread exhaustion.
Суть: технически нормально — это примерно 2–4 ГБ памяти и работающая программа. Плохо становится, когда каждая горутина держит ресурс (соединение, буфер, файловый дескриптор) или когда они дерутся за один лок.

Считаем стоимость

  • Стек 2 КБ минимум, но после первого же вызова с локальными массивами это 4–8 КБ. Миллион → 2–8 ГБ.
  • Структура g весит сотни байт, плюс место в очередях.
  • Работа GC: каждый стек это корень для сканирования. Миллион корней ощутимо удлиняет фазу сканирования и STW-моменты.
  • Планировщик: миллион элементов в очередях, стилинг чаще, локальность хуже.
// Это отработает, но посмотри на RSS процесса
var wg sync.WaitGroup
for i := 0; i < 1_000_000; i++ {
    wg.Add(1)
    go func() { defer wg.Done(); time.Sleep(time.Second) }()
}
fmt.Println(runtime.NumGoroutine())   // 1000001, а не 1000000:
                                      // NumGoroutine считает и саму main-горутину
wg.Wait()

Когда ок

  • Горутина «лёгкая»: держит только сокет и маленький буфер (классика: сервер на сотни тысяч WebSocket-соединений).
  • Работа I/O-bound, горутины почти всегда спят в netpoller.
  • Нет общего узкого места: общего мьютекса, общей мапы, одного канала-бутылочного горлышка.

Когда нет

  • Каждая горутина открывает соединение к БД: пул на 20 коннектов, миллион ожидающих это очередь, а не параллелизм. Ограничивать надо на входе.
  • Каждая держит буфер на 32 КБ, вот и 32 ГБ.
  • CPU-bound задачи: больше GOMAXPROCS считающих горутин пользы не даёт, только оверхед на переключения.
  • Горутина на каждый элемент коллекции, типичный антипаттерн: дешевле пул из GOMAXPROCS воркеров.
Как отвечать

«Горутины дёшевы, но не бесплатны, и главное, узкое место почти никогда не в них. Ограничивать нужно то, за что они борются: коннекты к БД, память под буферы, RPS к стороннему API. Отсюда worker pool и семафор вместо неограниченного go в цикле.»

Суть: нельзя. В Go нет kill, Thread.stop и аналогов — горутина завершается только сама, вернувшись из своей функции. Остановка всегда кооперативная: сигнал через ctx.Done() или done-канал.

Почему так спроектировано

Принудительно убивать поток давно считается ошибкой дизайна (в Java Thread.stop deprecated с 1.2). Убитая горутина оставила бы за собой захваченные мьютексы, недописанные структуры данных, незакрытые файлы и невыполненные defer. Go выбрал единственный безопасный вариант: горутина сама решает, где ей корректно закончиться.

Правильный шаблон

func worker(ctx context.Context, in <-chan Job, out chan<- Result) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()                 // выходим явно
        case job, ok := <-in:
            if !ok {
                return nil                   // источник закрыт, штатно завершаемся
            }
            res, err := handle(ctx, job)     // ctx прокидываем внутрь, иначе отмена не дойдёт
            if err != nil {
                return err
            }
            select {                          // отправка тоже должна отменяться
            case out <- res:
            case <-ctx.Done():
                return ctx.Err()
            }
        }
    }
}

Три частые ошибки

  • Отмену проверяем, а отправку нет. Классическая утечка: горутина увидела ctx.Done(), но зависла на out <- res, потому что читателя уже нет. Каждая блокирующая операция должна быть в select с ctx.Done().
  • Долгая работа без точек проверки. Если внутри цикл на минуту, проверяй ctx.Err() внутри цикла, а не только снаружи.
  • ctx не прокинут в вызываемый код. Отдавай ctx в db.QueryContext и в http.NewRequestWithContext, иначе отмена «застрянет» на первом же блокирующем вызове.
Что нельзя остановить вообще

Горутину, застрявшую в syscall без таймаута, в cgo-вызове или в time.Sleep(time.Hour). Отмена контекста её не разбудит. Поэтому: Sleep заменяем на select с time.After или ctx.Done(), сетевым операциям ставим SetDeadline, а на cgo-вызовы вешаем внешний таймаут по процессу.

Суть: Gosched() — добровольно уступить процессор (горутина остаётся runnable и уходит в глобальную очередь); NumGoroutine() — текущее число живых горутин, главная дешёвая метрика для поиска утечек.

runtime.Gosched()

Вызывает mcall(gosched_m): горутину переводят из _Grunning в _Grunnable, кладут в глобальную очередь (важно: не в локальную, чтобы дать шанс другим P), и M идёт выбирать следующую. Горутина не блокируется и не спит, она просто пропускает ход.

После Go 1.14 нужда в нём почти отпала: асинхронное вытеснение справляется само. Остались редкие случаи: бенчмарки и тесты, где нужно спровоцировать переключение; очень длинные вычислительные циклы, где хочется отдать управление раньше 10 мс; спин-ожидание в низкоуровневом коде.

// Gosched не годится для синхронизации: он не барьер и не даёт happens-before
var ready bool
go func() { data = compute(); ready = true }()
for !ready { runtime.Gosched() }   // гонка: компилятор вправе вынести !ready из цикла
// правильно: канал, sync.WaitGroup или atomic.Bool

runtime.NumGoroutine()

Возвращает gcount(): число горутин в состояниях, отличных от «мертва» и «системная». Стоит это несколько атомарных чтений, так что вешать на /metrics можно спокойно, хоть каждую секунду.

// Эта метрика нужна любому Go-сервису: по ней ловят утечку горутин
goroutines := prometheus.NewGaugeFunc(
    prometheus.GaugeOpts{Name: "go_goroutines_current"},
    func() float64 { return float64(runtime.NumGoroutine()) },
)
// Растущий без насыщения график = утечка. Дальше:
//   curl localhost:6060/debug/pprof/goroutine?debug=2
// и смотрим, на какой строке они все стоят.
Соседи по пакету, которые тоже спросят
  • runtime.NumCPU(): логические ядра, доступные процессу (маска affinity).
  • runtime.GOMAXPROCS(n): установить или прочитать число P.
  • runtime.Gosched(): уступить ход.
  • runtime.Goexit(): завершить текущую горутину, выполнив все её defer. Вызовешь в main, получишь панику no goroutines ... - deadlock.
  • runtime.LockOSThread(): прибить горутину к потоку (OpenGL, namespaces).
  • runtime.Stack(buf, all): снять дамп стеков всех горутин.
Суть: четыре рабочих способа — sync.WaitGroup, канал-сигнал, errgroup и (для одного значения) канал результата. time.Sleep — не способ.

1. sync.WaitGroup — когда горутин много и результат не нужен

var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)                          // Add до go, не внутри горутины
    go func() {                        // Go 1.22+: u уже своя на каждой итерации
        defer wg.Done()                // Done в defer сработает и при панике
        fetch(u)
    }()
}
wg.Wait()

// В Go 1.25 то же короче, и Add/Done уже не забудешь:
var wg sync.WaitGroup
for _, u := range urls {
    wg.Go(func() { fetch(u) })
}
wg.Wait()

2. Канал-сигнал — когда горутина одна

done := make(chan struct{})
go func() { defer close(done); work() }()
<-done                                 // ждём

// с таймаутом:
select {
case <-done:
case <-time.After(5 * time.Second):
    return errors.New("timeout")       // горутина продолжит работать, см. предупреждение ниже
}

3. errgroup — когда нужны ошибки и отмена

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)                          // не больше 8 одновременно
for _, u := range urls {
    g.Go(func() error { return fetch(ctx, u) })
}
if err := g.Wait(); err != nil {       // вернёт первую ошибку и отменит ctx для остальных
    return err
}

4. Канал результата — когда нужно одно значение

res := make(chan Result, 1)            // буфер 1, чтобы отправитель не завис, если мы ушли
go func() { res <- compute() }()
r := <-res
Два подвоха
  • Таймаут на ожидании не останавливает горутину. Ты перестал ждать, а она продолжает работать и жрать ресурсы. Отменяет по-настоящему только context, прокинутый внутрь.
  • time.Sleep вместо синхронизации остаётся самым частым красным флагом на собесе. Это не гарантия, а угадывание; на загруженной машине или под -race тайминги плывут, и тест начинает мигать.

2.2Каналы

Самая нагруженная глава всей темы. Забудь про «трубу»: за каналом стоит структура hchan с мьютексом, кольцевым буфером и двумя очередями ожидающих горутин. Держи в голове именно её, и все аксиомы и «а что будет, если…» выводятся сами, без зубрёжки.

Сначала — что такое канал, если забыть про рантайм

Практических вопросов тут ровно два: кто из двух горутин заблокируется, и что разблокирует его обратно, и что происходит с обеими сторонами при закрытии канала. Все «а что будет, если…» с собеса вырастают из ответов на них. Но прежде чем лезть в структуру hchan, проговорим картинку бытовыми словами.

1. Канал — это очередь, которая умеет усыплять

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

У небуферизированного канала (make(chan T)) вместимость ноль, очередь та же. «Положить и уйти» в неё нельзя никогда, поэтому передача идёт только из рук в руки, когда обе стороны одновременно оказались на месте.

2. Кольцевой буфер — как сделана сама очередь

Кольцевой буфер (ring buffer) собран из обычного массива фиксированной длины и двух индексов: откуда читать и куда писать. Дойдя до конца массива, индекс не выходит за границу, а перескакивает в начало, потому что считается по модулю длины. Массив от этого ведёт себя как замкнутый в кольцо, и очередь работает без единой аллокации: память под элементы выделяется один раз, в make, и дальше переиспользуется по кругу.

3. Парковка горутины — как выглядит «заснула»

На парковке (в рантайме это функция gopark) планировщик снимает горутину с потока ОС, переводит её в состояние ожидания (_Gwaiting) и оставляет ссылку на неё там, где её потом кто-нибудь найдёт и разбудит. Поток при этом не блокируется, он тут же берёт следующую готовую горутину. Обратная операция, пробуждение (goready), возвращает горутину в очередь готовых к исполнению.

«Там, где найдут» у канала означает две очереди ожидания внутри него самого: одна для тех, кто ждёт значения, вторая для тех, кто ждёт свободного места. Элемент такой очереди зовут sudog (от pseudo-g). Хранит он запись «вот эта горутина ждёт вот на этом канале, и значение надо положить вот по этому адресу в её стеке», а не саму горутину. Отдельная запись нужна как раз потому, что одна горутина может стоять сразу в нескольких очередях: именно так работает select по нескольким каналам.

Зачем вообще знать внутренности канала

Ради трёх вещей, которые иначе приходится зубрить наизусть. Первая: предсказывать блокировки. Если помнить, что отправка сначала заглядывает в очередь ожидающих читателей и только потом в буфер, сразу понятно, почему default в select на небуферизированном канале почти всегда промахивается. Вторая: оценивать цену. Каждая операция с каналом берёт мьютекс и делает одно-два копирования значения, отсюда и «канал в 3–5 раз дороже мьютекса», и совет не гонять через канал крупные структуры. И третья, отвечать на «а что если»: паника при отправке в закрытый канал, нулевое значение при чтении из него, вечное ожидание на nil-канале следуют из одной и той же картинки, а не из трёх отдельных правил.

Что такое канал по сути

Канал работает как типизированная потокобезопасная очередь с блокировкой и встроенной точкой синхронизации. Переменная типа chan T хранит указатель на hchan в куче (поэтому канал и «работает» при передаче в функцию по значению: копируется указатель). make(chan T, n) вызывает runtime.makechan, который выделяет одним куском заголовок hchan и, если n > 0, массив на n элементов сразу за ним.

// runtime/chan.go, упрощённо, но поля настоящие
type hchan struct {
    qcount   uint           // сколько элементов сейчас в буфере
    dataqsiz uint           // размер буфера (0 для небуферизированного)
    buf      unsafe.Pointer // указатель на кольцевой массив из dataqsiz элементов
    elemsize uint16         // размер одного элемента
    closed   uint32         // 0 или 1
    elemtype *_type         // тип элемента, нужен для копирования и для GC
    sendx    uint           // индекс, куда писать следующий элемент
    recvx    uint           // индекс, откуда читать следующий
    recvq    waitq          // список горутин, ждущих приёма  (sudog)
    sendq    waitq          // список горутин, ждущих отправки (sudog)
    lock     mutex          // обычный мьютекс рантайма: да, канал внутри лочится
}

// sudog описывает горутину в очереди: G + место, куда/откуда копировать
type sudog struct {
    g        *g
    next     *sudog
    prev     *sudog
    elem     unsafe.Pointer // адрес переменной в стеке этой горутины
    c        *hchan         // на каком канале ждёт
    isSelect bool           // ждёт в составе select
    success  bool           // получила значение (true) или канал закрыли (false)
}
hchan qcount элементов в буфере dataqsiz размер буфера buf кольцевой массив elemsize размер элемента closed 0 или 1 elemtypeтип элемента sendx индекс записи recvx индекс чтения recvq кто ждёт чтения sendq кто ждёт записи lock мьютекс рантайма recvx — откуда читать sendx — куда писать 0 1 v12 v23 v34 5 буфер кольцевой: индексы считаются по модулю dataqsiz здесь dataqsiz = 6, qcount = 3, recvx = 2, sendx = 5 Приём берёт buf[recvx] и двигает recvx. Отправка кладёт в buf[sendx] и двигает sendx. recvq — горутины, ждущие значения sudog g = G4 elem = &x sudog g = G9 elem = &y sudog g = G11 elem = &z sudog хранит: g — какая горутина ждёт elem — адрес значения next/prev — связный список success — было ли значение sendq пусто (есть читатели — значит писателей нет) Обе очереди непусты одновременно быть не могут: если кто-то ждёт значения, отправитель отдаст его напрямую и не встанет в sendq. Исключение — горутины, ждущие в select сразу на нескольких каналах, и момент закрытия канала.
hchan. Всё, что делает канал каналом, здесь: мьютекс на все операции, кольцевой буфер с двумя индексами и два связных списка sudog. Из этой картинки и выводится, почему отправка в закрытый канал паникует, а чтение отдаёт zero value.

Механика отправки: chansend

Порядок проверок в runtime.chansend стоит уметь рассказать наизусть. Обрати внимание на пункт 3: если кто-то уже ждёт, значение не проходит через буфер вообще, оно копируется прямо в стек получателя. Так экономится одно копирование, а данные остаются горячими в кэше.

ch <- v ch == nil ? gopark без единого шанса на пробуждение — горутина висит вечно (в main это deadlock-паника рантайма) нет closed == 1 ? panic: send on closed channel Это не ошибка, а паника — восстановить можно только recover нет recvq непуста ? ПРЯМАЯ ПЕРЕДАЧА: memmove в стек ждущей горутины по sudog.elem, затем goready(g). Буфер не используется вообще нет qcount < dataqsiz ? memmove в buf[sendx]; sendx = (sendx+1) % dataqsiz; qcount++ Отправитель не блокируется и идёт дальше нет места нет sudog{g, elem: &v} кладётся в sendq, затем gopark. Разбудит получатель, забрав значение прямо из sudog Приём симметричен: сначала смотрим sendq (взять у ждущего отправителя), потом буфер, потом сами паркуемся в recvq. Тонкость: если буфер полон и есть ждущий отправитель, приём берёт из буфера, а sudog отправителя дописывает своё значение в освободившийся слот.
chansend. Пять веток, и любое «а что если» сводится к выбору одной из них. Заметь: закрытость проверяется до всего остального, поэтому даже буферизированный канал со свободным местом внутри всё равно паникует.

Небуферизированный канал: рандеву

У небуферизированного канала dataqsiz == 0, и ветка «положить в буфер» не срабатывает никогда. Значит, отправить получится только тогда, когда получатель уже готов, а принять, когда готов отправитель. Это и называют рандеву (rendezvous): операция завершается в момент встречи, значение переезжает атомарно, минуя любое промежуточное хранилище.

отправитель получатель ch <- 1 <-ch работает gopark: sudog в sendq, горутина спит разбужена, идёт дальше занят своими делами забирает значение получил 1 memmove напрямую: стек отправителя в стек получателя goready: отправитель снова runnable Отправка и приём в небуферизированном канале — одно событие. Кто пришёл первым, тот и ждёт; второй завершает обе операции разом. Отсюда гарантия: вернулись из ch <- v — значит получатель уже забрал значение. У буферизированного канала такой гарантии нет.
Рандеву. Значение никогда не «лежит в канале»: оно переезжает из стека в стек в момент встречи. Поэтому небуферизированный канал передаёт данные и заодно синхронизирует обе стороны.

Аксиомы канала — полная таблица

Операцияnil-каналоткрытый, пустойоткрытый, есть данные/местозакрытый
ch <- v блокировка навсегда блокировка до читателя
(или пока не появится место)
успех panic: send on closed channel
v := <-ch блокировка навсегда блокировка до писателя успех zero value, сразу, бесконечно много раз
v, ok := <-ch блокировка навсегда блокировка v, true zero, falseтолько после того, как буфер вычерпан
close(ch) panic: close of nil channel успех, будит всех ждущих panic: close of closed channel
for v := range ch блокировка навсегда итерирует, блокируясь между значениями дочитывает буфер и выходит из цикла
len(ch) / cap(ch) 0 / 0 qcount / dataqsiz — читаются без лока, значение мгновенно устаревает
select с этим каналом case никогда не сработает
(так «выключают» ветку)
участвует в выборе, когда готов приём всегда готов — вернёт zero, false
Три вещи, которые выводятся из hchan, а не заучиваются
  • Почему nil-канал блокирует, а не паникует. chansend/chanrecv явно проверяют c == nil и вызывают gopark с причиной waitReasonChanSendNilChan. Сделано это специально: в select ветку выключают, обнуляя канал.
  • Почему close будит всех. closechan проходит по recvq и sendq целиком, каждому sudog обнуляет elem, ставит success = false и вызывает goready. Отсюда и паттерн broadcast, и то, что ждущие отправители при close получат панику.
  • Почему из закрытого канала можно дочитать буфер. chanrecv проверяет закрытость вместе с qcount == 0: пока в буфере что-то есть, он отдаёт значение и ok = true.

Кто закрывает канал и как не словить панику

// Правило: закрывает тот, кто пишет, и только если писатель один.
func producer(out chan<- int) {
    defer close(out)            // эта функция владеет каналом
    for i := 0; i < 10; i++ {
        out <- i
    }
}

// Плохо: писателей несколько, и второй close паникует
func bad(out chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    defer close(out)            // panic: close of closed channel
    out <- 1
}

// Хорошо: закрывает координатор, когда все писатели закончили
func good(out chan int) {
    var wg sync.WaitGroup
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); out <- i }()
    }
    go func() { wg.Wait(); close(out) }()   // ровно один close, после всех
}
Дисциплина владения каналом
  1. У канала ровно один владелец: тот, кто его создал, пишет в него и закрывает.
  2. Владелец отдаёт наружу направленный тип: <-chan T для читателей. Тогда закрыть его физически невозможно: close на receive-only канале не компилируется.
  3. Если писателей несколько, они не закрывают ничего; закрывает координатор после wg.Wait().
  4. Сигнал «остановись» идёт отдельным каналом (done или ctx.Done()) в обратную сторону, а не через закрытие канала данных.

Стоимость операций

ОперацияПорядок стоимостиПочему
send/recv, обе стороны готовы~25–80 нсlock, memmove, unlock, goready
send/recv с парковкой~200–800 нсплюс gopark, попадание в планировщик, пробуждение
mu.Lock() без конкуренции~15–25 нсодин CAS
atomic.AddInt64~5–10 нсодна инструкция с блокировкой шины/кэш-линии
select с 2 case~50–150 нсlock всех каналов в порядке адресов, случайная перестановка

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

Направленные каналы, chan struct{} и канал внутри канала

Тип канала сужается до одного направления: chan<- T только отправляет, <-chan T только принимает. Преобразование одностороннее и бесплатное: двунаправленный канал неявно приводится к направленному, обратно никогда. Выигрыша в скорости тут нет, есть контракт, проверяемый компилятором: функция, получившая <-chan T, физически не может ни записать в него, ни закрыть его. Отсюда идиома конструктора конвейера:

// Возвращаем receive-only: снаружи канал не испортить.
func gen(nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)          // закрывает только владелец
        for _, n := range nums {
            out <- n
        }
    }()
    return out
}

// Потребитель принимает receive-only и обязан вернуть тоже receive-only.
func square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

По chan struct{} едет факт, а не значение. struct{}{} занимает ноль байт, все такие значения ссылаются на один и тот же адрес runtime.zerobase, так что memmove элемента копирует 0 байт. С chan bool по скорости разница мизерная, а по смыслу решающая: тип сам говорит «здесь нет данных, здесь только сигнал», и читателю не приходится гадать, что значит пришедший false. Поэтому у ctx.Done() и тип <-chan struct{}.

Канал остаётся обычным значением (указатель на hchan), поэтому его кладут в другой канал, в структуру, в мапу, передают в функцию. Тип chan chan int выглядит пугающе, но на нём стоит паттерн «канал ответа внутри запроса»: клиент кладёт в общий канал запросов структуру, где одним из полей лежит его личный канал для ответа.

Клиенты Общий канал запросов Пул воркеров client A reply chan int client B reply chan int client C reply chan int requests chan Job Job{ Arg: 7, Reply: chanA } адрес личного канала едет внутри Job{ Arg: 3, Reply: chanB } воркер не знает, кто клиент свободные слоты буфера worker 1 берёт Job worker 2 считает worker 3 j.Reply <- res ответ идёт напрямую в личный канал клиента, минуя общую очередь
RPC поверх каналов. Общий канал раздаёт работу, а обратный адрес едет внутри самого сообщения. Воркер не знает клиента, клиент не знает воркера, но каждый получает ровно свой ответ, и никакой мапы «id → результат» не нужно.
type Job struct {
    Arg   int
    Reply chan<- Result        // обратный адрес; тип направленный: воркер может только писать
}
type Result struct {
    Val int
    Err error
}

func worker(jobs <-chan Job) {
    for j := range jobs {
        v, err := do(j.Arg)
        j.Reply <- Result{v, err}   // буфер 1 у reply, чтобы воркер не завис на брошенном клиенте
    }
}

func call(jobs chan<- Job, arg int, ctx context.Context) (int, error) {
    reply := make(chan Result, 1)   // буфер 1 обязателен
    select {
    case jobs <- Job{arg, reply}:
    case <-ctx.Done():
        return 0, ctx.Err()
    }
    select {
    case r := <-reply:
        return r.Val, r.Err
    case <-ctx.Done():
        return 0, ctx.Err()         // клиент ушёл, но воркер всё равно допишет в буфер и не зависнет
    }
}
Почему у канала ответа буфер ровно 1

Сделаешь make(chan Result) без буфера, клиент уйдёт по таймауту, и воркер навсегда встанет на j.Reply <- ...: рандеву не с кем провести. Классическая утечка горутин в самодельных RPC. Буфер 1 даёт воркеру «положить и уйти»: значение никого не ждёт, а брошенный канал соберёт GC. То же правило работает и в time.AfterFunc, и внутри context.

Broadcast, неблокирующая отправка и таймаут

Канал раздаёт по принципу «один получатель на одно значение»: если из канала читают трое, а ты отправил одно значение, его заберёт ровно один, и рантайм выберет того, кто дольше всех ждёт в recvq (очередь FIFO). Разослать всем через отправку невозможно в принципе. А через закрытие можно: closechan будит всю очередь recvq целиком, и дальше любое чтение из закрытого канала возвращается мгновенно.

// Broadcast: один close будит разом всех N ждущих.
done := make(chan struct{})
for i := 0; i < 5; i++ {
    go func(id int) {
        <-done                       // все пятеро стоят в recvq
        fmt.Println(id, "поехали")
    }(i)
}
close(done)                          // единственный способ уведомить всех
// вывод (пять строк, порядок меняется от запуска к запуску):
//   4 поехали     4 поехали     0 поехали
//   0 поехали     2 поехали     4 поехали
//   1 поехали  →  3 поехали  →  1 поехали   — три прогона подряд
//   2 поехали     0 поехали     3 поехали
//   3 поехали     1 поехали     2 поехали
// close будит всю recvq разом, но кто первым доберётся до Println, решает планировщик.
// (В самом фрагменте main завершится раньше горутин; чтобы увидеть вывод, нужен WaitGroup.)

// Неблокирующая отправка: не встанем, даже если некому взять.
select {
case ch <- v:
    // отправлено
default:
    // канал полон (или небуферизированный и получателя сейчас нет): дропаем
    dropped.Add(1)
}

// Неблокирующий приём:
select {
case v := <-ch:
    use(v)
default:
    // пусто
}

// Таймаут 5 секунд на операцию с каналом.
select {
case v := <-ch:
    use(v)
case <-time.After(5 * time.Second):
    return errors.New("timeout")
}
Три подвоха этого куска кода
  • default на небуферизированном канале почти всегда промахивается. Отправка сработает, только если в recvq прямо в этот момент кто-то стоит. Если получатель ещё не дошёл до <-ch, уйдём в default и потеряем значение. На буферизированном же default означает честное «буфер полон» и работает предсказуемо.
  • time.After в цикле течёт таймерами. Каждый вызов создаёт *time.Timer, который живёт в куче таймеров рантайма до своего срабатывания и не отменяется, даже если select ушёл в другую ветку. Цикл с time.After(time.Minute) и тысячей итераций в секунду накопит миллионы живых таймеров. Правильно делать так: один переиспользуемый time.Timer со Stop()/Reset() или context.WithTimeout.
  • Таймаут не отменяет саму операцию. Горутина, которая пишет в ch, после нашего выхода по таймауту всё ещё стоит и ждёт получателя. Таймаут спасает вызывающего, но не исполнителя: тому нужен отдельный сигнал отмены.
// Плохо: новый таймер на каждой итерации; до Go 1.23 каждый ещё и жил до истечения минуты
for {
    select {
    case v := <-ch:
        use(v)
    case <-time.After(time.Minute):
        return
    }
}

// Хорошо: один таймер на весь цикл
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
    select {
    case v := <-ch:
        use(v)
        if !t.Stop() {
            <-t.C          // до Go 1.23 обязательно: осушить канал перед Reset
        }
        t.Reset(time.Minute)
    case <-t.C:
        return
    }
}
Go 1.23 переписал таймеры

До 1.23 у time.Timer был буферизированный канал на 1 элемент, поэтому «протухшее» значение могло остаться в нём после Stop и всплыть после Reset. Отсюда и ритуал с осушением. В Go 1.23 канал таймера стал небуферизированным и особым: Stop/Reset гарантированно выбрасывают несработавшее значение, а сам таймер стал достижимым для сборщика мусора даже без Stop (раньше неостановленный таймер удерживался кучей рантайма). Осушать больше не нужно, хотя код с осушением остался корректным. На собесе про это почти не спрашивают, но упомянуть стоит: сильный ход.

len(ch), «резиновый» канал и что передавать внутрь

len(ch) и cap(ch) читают qcount и dataqsiz без взятия мьютекса канала. Формально гонки данных тут нет (чтение выровненного слова атомарно), но логически это гонка: между твоим if len(ch) < cap(ch) и следующей строкой ch <- v буфер могут заполнить другие горутины, и ты всё равно заблокируешься. Значение len устаревает ровно в момент возврата. Спросить «готов ли канал» корректно можно единственным способом: select с default, там проверка и операция идут атомарно под локом канала.

Размер буфера задают один раз, в make, и поменять его нельзя: dataqsiz зашит в структуру, а буфер выделен одним куском рядом. «Резиновый» канал собирают вручную из горутины-посредника со слайсом внутри:

// Канал с неограниченным буфером: горутина-посредник и слайс как очередь.
func elastic[T any](in <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        var q []T
        for {
            var send chan T                 // nil, пока очередь пуста
            var head T
            if len(q) > 0 {
                send, head = out, q[0]      // включаем ветку отправки
            }
            select {
            case v, ok := <-in:
                if !ok {
                    for _, v := range q {   // дослать остаток и выйти
                        out <- v
                    }
                    return
                }
                q = append(q, v)
            case send <- head:              // при nil-канале этот case выключен
                q = q[1:]
            }
        }
    }()
    return out
}
Указатель или значение в канал

Канал всегда копирует elemtype.size байт. Отправил структуру на 200 байт, получил memmove на 200 байт (а при рандеву два раза по 200, из стека в стек). Указатель обойдётся в 8 байт, но объект обязан лежать в куче, значит escape analysis отправит его туда и добавит работы GC.

  • Значение идёт по умолчанию. Даёт естественную изоляцию: получатель работает со своей копией, гонки невозможны. Для структур до ~100 байт копия дешевле аллокации.
  • Указатель берут, когда объект большой, когда получатель должен его мутировать, когда это уже указатель (например *http.Request). Цена: владение придётся передавать дисциплинированно. Отправитель после ch <- p к p больше не прикасается, иначе получится гонка, которую race-детектор поймает не всегда.
  • Слайсы, мапы и строки уже ссылочные заголовки. Отправка []byte копирует 24 байта заголовка, но массив под ним общий: два владельца одного буфера. Самый частый источник «невидимых» гонок в конвейерах.

Вопросы

20
Суть: канал — это указатель на структуру hchan в куче: кольцевой буфер, обычный мьютекс и две FIFO-очереди припаркованных горутин. Вся «магия» сводится к «взять лок, скопировать байты, разбудить кого надо».

Поля hchan

  • В qcount и dataqsiz лежат число элементов и вместимость буфера (это и есть len/cap).
  • buf указывает на массив dataqsiz * elemsize байт; у небуферизированного канала он nil.
  • sendx и recvx: индексы записи и чтения в кольце.
  • recvq и sendq держат двусвязные списки sudog: горутины, стоящие на приёме и на отправке.
  • А в lock mutex лежит обычный runtime.mutex. Канал не lock-free.
  • closed uint32, elemtype, elemsize.

Внутри sudog лежит всё про ожидание: указатель на g, указатель elem на место, откуда взять или куда положить значение, флаг success, ссылки на соседей. У одной горутины бывает несколько sudog сразу, ровно это происходит в select, где она встаёт в очереди сразу нескольких каналов. Кэшируются sudog в P и в глобальном пуле, чтобы не аллоцировать на каждой блокировке.

Главная оптимизация — прямая передача

Если при отправке в recvq кто-то есть, значение не кладётся в буфер: send вызывает sendDirect и копирует значение прямо в стек получателя, минуя буфер вообще. Это экономит одну копию и, что важнее, оставляет данные горячими в кэше. Симметрично работает приём с непустой sendq.

Единственное место в Go, где пишут в чужой стек

sendDirect пишет по указателю в стек другой горутины, которая в этот момент припаркована. Это нарушает обычное правило «стек горутины трогает только она сама», поэтому там стоит специальный write barrier (typedmemmove с явным memmove и барьером), а copystack отдельно умеет чинить указатели sudog.elem, если стек припаркованной горутины переехал.

Суть: очередь под мьютексом плюс два списка ожидающих плюс механизм «усыпить горутину и разбудить её из чужого контекста». Дальше вопрос только в том, какие оптимизации добавить.

Тут проверяют не память, а умение разложить механизм. Отвечай по нарастающей: сначала наивная версия, потом настоящая.

Шаг 1: наивная версия

Структура: mutex, слайс-очередь, sync.Cond для отправителей и второй для получателей. Send: лок, пока очередь полна висим на cond.Wait(), кладём, condRecv.Signal(), анлок. Это рабочий канал, его правда можно написать на собесе за пять минут. Минусы: два лишних пробуждения на каждую передачу, нет прямой передачи, и select по нескольким каналам так не сделать.

Шаг 2: что добавляет рантайм

  • Свои очереди вместо Cond. Нужен явный список ожидающих (sudog), чтобы будить конкретную горутину, а не всех, и чтобы соблюдать FIFO-справедливость.
  • gopark/goready вместо futex. Блокировать надо горутину, а не поток: поток должен уйти выполнять другую работу. Обычной библиотеке такое недоступно, отсюда и вывод, что каналы обязаны жить в рантайме.
  • Прямая передача. Если ждущий уже есть, копируем ему в стек и не трогаем буфер.
  • Поддержка select. Горутина должна уметь стоять в нескольких очередях сразу, и ровно один из каналов должен её «выиграть»: это делается через g.selectDone с CAS.
  • Кольцевой буфер вместо слайса: фиксированный размер, никаких реаллокаций, индексы вместо сдвигов.
Фраза, которая закрывает вопрос

«Канал нельзя написать библиотекой не потому, что он сложный, а потому что ему нужны примитивы планировщика, gopark и goready. Всё остальное в hchan обычный код: мьютекс, кольцо и два списка.»

Суть: небуферизированный — это рандеву: отправитель и получатель встречаются в одной точке времени, и обе стороны узнают о факте передачи. Буферизированный — очередь: отправитель уходит, ничего не зная о судьбе значения.
make(chan T)make(chan T, N)
Когда send разблокируетсякогда получатель забралкогда есть место в буфере
Гарантия отправителю«это принято»«это поставлено в очередь»
happens-beforesend и recv взаимно синхронизируют обе горутиныsend i-го значения происходит до завершения recv i-го; и recv i-го — до завершения send (i+N)-го
Копий значения1 (стек → стек)2 (стек → буфер → стек), если никто не ждал
Задержкавыше: почти всегда парковканиже: пока есть место, send не паркуется
Что скрываетничеговсплеск нагрузки — и задержку обнаружения того, что потребитель умер

Практический критерий выбора

  • Буфер 0 берут, когда нужна синхронизация и обратное давление (backpressure). Продюсер физически не может обогнать консьюмера. Это дефолт, с которого стоит начинать.
  • Буфер 1 работает как «почтовый ящик»: отправитель кладёт и уходит, гарантированно не зависая. Канал ответа в RPC, канал ошибки, сигнальный канал.
  • Буфер N сглаживает рывки, когда средняя скорость потребителя выше средней скорости продюсера, а пики короткие. Число надо обосновать (например, «столько же, сколько воркеров»), а не «поставим 100, пусть будет».
Чем большой буфер вреден

Буфер маскирует проблему: система выглядит работающей, пока он не переполнится, а потом падает резко. Плюс всё, что лежит в буфере, при аварийной остановке теряется: персистентной очереди тут нет. И плюс задержка: значение может пролежать в буфере секунды, а отправитель уже отчитался «отправлено». Нужна надёжная очередь, бери Kafka или RabbitMQ, а не chan.

Суть: nil-канал блокирует навсегда во всех операциях, кроме close — там паника. Закрытый канал: чтение отдаёт zero/false бесконечно, запись и повторный close — паника.
Операцияnil-каналзакрытый канал
ch <- vблокировка навсегдаpanic: send on closed channel
<-chблокировка навсегдаzero value, мгновенно, сколько угодно раз
v, ok := <-chблокировка навсегдаok == false после того, как буфер вычерпан
close(ch)panic: close of nil channelpanic: close of closed channel
len/cap0 / 0реальные значения
rangeблокировка навсегдадочитает буфер и выйдет

Почему именно так — логика, а не произвол

  • nil блокирует, а не паникует, чтобы в select можно было отключать ветку присваиванием ch = nil. Свойство спроектированное, а не побочный эффект.
  • close паникует на nil, потому что закрывает владелец, а владелец обязан знать, что канал создан. Молчаливое «ничего не делаем» скрыло бы ошибку.
  • Send в закрытый паникует, потому что закрытие означает «данных больше не будет». Тихо проглотить значение значит потерять данные, а это хуже паники.
  • Recv из закрытого не паникует, потому что получателей может быть много, они узнают о конце асинхронно, и это нормальный путь завершения, а не ошибка.
  • Повторный close паникует: значит, владельцев у канала несколько, то есть где-то ошибка в архитектуре.
Вопрос-ловушка

«А если горутина стоит в ch <- v, и другая делает close(ch)?» Ответ: closechan пройдёт по sendq, разбудит отправителя, и тот, проснувшись, увидит c.closed != 0 и запаникует. То есть паника «send on closed channel» может прилететь не в момент close, а в момент пробуждения уже стоявшего отправителя. Отсюда правило: закрывать может только писатель, и только если он один.

Суть: да. close не выбрасывает буфер; читатель сначала вычерпает всё, что лежит, и только потом начнёт получать zero, false.
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

v, ok := <-ch   // 1, true
v, ok = <-ch    // 2, true
v, ok = <-ch    // 0, false: буфер пуст, канал закрыт
v, ok = <-ch    // 0, false, и так бесконечно

// range делает то же самое и выходит сам, но здесь канал уже вычерпан
// четырьмя строками выше, поэтому цикл ничего не напечатает и сразу завершится:
for v := range ch { fmt.Println(v) }   // ни одной строки

// А вот если бы range шёл сразу после close, не читая вручную:
ch2 := make(chan int, 3)
ch2 <- 1
ch2 <- 2
close(ch2)
for v := range ch2 { fmt.Println(v) }  // 1
                                       // 2

Механика

В chanrecv проверка выглядит так: «если канал закрыт и qcount == 0, вернуть zero и false». Оба условия вместе. Пока в кольцевом буфере что-то есть, работает обычная ветка: скопировать из buf[recvx], сдвинуть recvx, уменьшить qcount. closechan при этом трогает только флаг closed и очереди ожидающих, а буфер не очищает.

Идиома «дочитать до конца» перед выходом

Из-за этого свойства конвейер завершают так: закрыть канал и дождаться, пока range сам выйдет. Не нужно ни счётчиков оставшихся элементов, ни отдельного «стоп-сообщения»: close одновременно и говорит «данных больше не будет», и разрешает забрать всё, что уже отправлено. Этим Go-конвейер и отличается от очереди с sentinel-значением, где сигнал завершения можно потерять, если получателей несколько.

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

Пошагово, что делает рантайм

  1. chansend берёт лок канала, видит пустую recvq и dataqsiz == 0.
  2. Достаёт sudog из кэша, кладёт в него указатель на отправляемое значение (значение остаётся в стеке отправителя, копии пока нет) и ставит его в sendq.
  3. Вызывает gopark: статус _Gwaiting, причина chan send, лок канала отпускается.
  4. Управление уходит в schedule(). Поток M не блокируется, он подбирает следующую runnable-горутину и продолжает жечь процессор.
  5. Когда придёт получатель, chanrecv найдёт наш sudog, скопирует значение напрямую из нашего стека в свой и вызовет goready.

«Блокировка по существу»

Так называют настоящую, семантическую блокировку: горутина не может продолжить, потому что логически ждёт события. Есть ещё «блокировка по реализации», это когда встал поток ОС. В Go на канале, мьютексе, WaitGroup и сетевом I/O блокируется только горутина: планировщик её снимает и переиспользует поток. А вот блокирующий syscall к диску, вызов через cgo или runtime.LockOSThread блокируют настоящий поток: P у него отберёт sysmon, но сам M будет стоять.

Когда всё-таки падает вся программа

fatal error: all goroutines are asleep - deadlock! печатается, только если каждая горутина ждёт события, которое может дать исключительно другая горутина Go. Если параллельно живёт хоть один time.Sleep, открытый сетевой сокет или горутина в syscall, рантайм считает, что кто-то ещё может проснуться, и молчит. Поэтому в реальном сервисе зависшая горутина ничего не роняет, а тихо течёт, и ловится только через pprof.

Суть: ровно одна, и это гарантия языка. Какая именно — определяется порядком FIFO в recvq: тот, кто встал в очередь раньше. Дублирования не бывает никогда.

recvq устроен как обычный двусвязный список, а dequeue() берёт голову. Значит, при отправке одного значения его получит горутина, которая раньше всех припарковалась на этом канале. Отсюда канал и работает распределителем работы: N воркеров в for job := range jobs разберут задачи без дублей и без дополнительной синхронизации.

Осторожно с формулировкой «FIFO»

FIFO соблюдается в очереди ожидания канала, но задачи от этого не расходятся по воркерам равномерно и предсказуемо. Если воркер не успел встать в recvq и в этот момент пришло значение через непустой буфер, порядок будет другим. Спецификация языка гарантирует только «одно значение, один получатель»; какой именно получатель его заберёт, решает рантайм, и полагаться на это нельзя. И select с несколькими готовыми каналами вообще выбирает случайно.

jobs := make(chan int)
for w := 1; w <= 3; w++ {
    go func(id int) {
        for j := range jobs {          // трое читают из одного канала
            fmt.Println("worker", id, "job", j)
        }
    }(w)
}
jobs <- 42        // ровно одну строку напечатает какой-то один worker
close(jobs)       // а вот это увидят все трое: broadcast через close
Суть: отправкой — никак, значение заберёт один. Broadcast делается close(ch) (одноразовый сигнал всем) либо явным fan-out: по каналу на подписчика.

Вариант 1: close — сигнал «всем, один раз»

done := make(chan struct{})
// N горутин делают <-done и все просыпаются от одного close
close(done)

Работает мгновенно и для тех, кто ещё не успел подписаться: чтение из закрытого канала возвращается сразу. Упирается всё в одноразовость: повторно «раззакрыть» нельзя, данные передать нельзя (только факт). Именно так устроен ctx.Done(). Чтобы передать вместе с сигналом ещё и значение, используют пару: сначала записать значение в поле под мьютексом, потом закрыть канал. Читатель после пробуждения гарантированно увидит запись (close happens-before приём из закрытого канала).

Вариант 2: fan-out — по каналу на подписчика

type Broker[T any] struct {
    mu   sync.RWMutex
    subs map[chan T]struct{}
}

func (b *Broker[T]) Subscribe() (<-chan T, func()) {
    ch := make(chan T, 16)                 // буфер, чтобы медленный подписчик не тормозил всех
    b.mu.Lock()
    b.subs[ch] = struct{}{}
    b.mu.Unlock()
    return ch, func() {                    // отписка
        b.mu.Lock()
        delete(b.subs, ch)
        b.mu.Unlock()
        close(ch)
    }
}

func (b *Broker[T]) Publish(v T) {
    b.mu.RLock()
    defer b.mu.RUnlock()
    for ch := range b.subs {
        select {
        case ch <- v:                      // отправили
        default:                           // подписчик не успевает: дропаем, а не блокируем всех
        }
    }
}
Три грабли брокера
  • Блокирующая рассылка: если писать ch <- v без select/default, один залипший подписчик остановит публикацию всем остальным. Нужно решить политику явно: дропать, копить в личной очереди или отключать медленного.
  • Кто закрывает канал подписчика: закрывать должен брокер, и только после удаления из мапы под тем же локом, иначе Publish напишет в уже закрытый канал и получит панику.
  • sync.Cond.Broadcast подойдёт, если подписчики ждут изменения состояния, а не получают сообщения. Но в 95% случаев в Go выбирают каналы, потому что Cond не дружит с select и с context.
Суть: select с default — единственный корректный способ. Проверка через len(ch) < cap(ch) — гонка.
select {
case ch <- v:
    // приняли
default:
    // не приняли: дропаем, считаем метрику, кладём в overflow
}

Почему select, а не len

select с default компилируется в selectnbsendchansend(c, v, block=false): рантайм берёт лок канала и в одной критической секции и проверяет готовность, и выполняет отправку. Между «проверил» и «отправил» ничего вклиниться не может. Вариант if len(ch) < cap(ch) { ch <- v } распадается на две разные операции: между ними другая горутина заполнит буфер, и ты заблокируешься именно там, где обещал не блокироваться.

НебуферизированныйБуферизированный
Когда case сработаеттолько если получатель прямо сейчас стоит в recvqесли есть свободное место в буфере или ждёт получатель
Насколько предсказуемоплохо: зависит от гонки планировщикахорошо: default честно значит «полно»
Типичный смысл default«никто не слушает»«очередь переполнена»
Где применяютпочти нигде — обычно ошибка проектированияметрики, логи, телеметрия, notify-каналы
Классическая ошибка

Неблокирующая отправка в небуферизированный канал в цикле «отправим, если кто-то читает» почти всегда молча теряет данные: получатель готов взять значение через сто наносекунд, но в момент проверки он ещё не встал в очередь, и ты уходишь в default. Нужно «не потерять, но и не ждать вечно», бери буфер 1 или таймаут вместо default. Идиома с default оправдана только там, где потерю значения выбрали сознательно (сэмплирование метрик, сигнал «есть новая работа», где важен факт, а не число сигналов).

Суть: для канала — select с time.After или, лучше, с ctx.Done(). Для чужой блокирующей функции — запустить её в отдельной горутине и ждать её результат в select, помня, что сама функция так не отменится.

Таймаут на операцию с каналом

select {
case v := <-ch:
    return v, nil
case <-time.After(5 * time.Second):
    return zero, context.DeadlineExceeded
}

// В сервисе почти всегда лучше наследовать таймаут от вызывающего:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
select {
case v := <-ch:
    return v, nil
case <-ctx.Done():
    return zero, ctx.Err()
}

Обёртка над функцией, которая не умеет отменяться

func withTimeout[T any](ctx context.Context, f func() (T, error)) (T, error) {
    type res struct {
        v   T
        err error
    }
    done := make(chan res, 1)          // буфер 1: горутина не зависнет, если мы ушли
    go func() {
        v, err := f()
        done <- res{v, err}
    }()
    select {
    case r := <-done:
        return r.v, r.err
    case <-ctx.Done():
        var zero T
        return zero, ctx.Err()         // а f() продолжает работать в фоне
    }
}
Что здесь обязательно проговорить вслух
  • Буфер 1 у done, иначе горутина навсегда встанет на отправке, когда мы уже вернулись по таймауту. Это и есть утечка горутин номер один в проде.
  • Таймаут не отменяет f(). Она доработает, съест свои ресурсы, возможно, откроет соединение и запишет в БД. Мы просто перестали её ждать. Настоящая отмена возможна, только если функция сама принимает ctx или у неё есть SetDeadline/Close.
  • Ресурсы под контролем. Если такие обёртки висят на каждом запросе, а f() тормозит по 30 секунд, число живых горутин растёт линейно с нагрузкой. Значит, нужен ещё и семафор, ограничивающий количество одновременных вызовов.
  • Для сети таймаут делается правильно, через conn.SetReadDeadline или http.Client.Timeout: там отменяется сама операция, а не одно лишь ожидание.
Суть: range завершается только когда канал закрыт и буфер вычерпан. Закрывает всегда отправитель — получатель не имеет права, потому что не знает, закончил ли писатель.

Цикл for v := range ch компилятор разворачивает ровно в for { v, ok := <-ch; if !ok { break }; ... }. Никаких других способов выйти у него нет: ни len(ch) == 0, ни таймаут его не завершат. Отсюда два практических следствия.

  • Если канал не закрыть, горутина с range останется навсегда. Типовая утечка, и часто её не замечают, потому что программа продолжает работать.
  • Если выходить надо ещё и по отмене контекста, range не подходит, нужен select.
// range: выход только по close
for v := range ch {
    process(v)
}
// сюда попадём, лишь когда
// кто-то сделает close(ch)
// select: выход по close или по отмене
for {
    select {
    case v, ok := <-ch:
        if !ok { return }
        process(v)
    case <-ctx.Done():
        return
    }
}

Правило закрытия и почему оно именно такое

Закрытие утверждает: «данных больше не будет». Знать это может только тот, кто данные производит. Если получатель закроет канал, любой продолжающий писать отправитель получит панику. Отсюда полный свод:

  1. Писатель один: он и закрывает, обычно defer close(ch).
  2. Писателей много: никто из них не закрывает, закрывает координатор после wg.Wait() в отдельной горутине.
  3. Надо остановить писателя: шли отдельный канал done/ctx в обратную сторону, а не закрывай канал данных получателем.
  4. Канал вообще можно не закрывать, если он больше не нужен: GC соберёт его вместе с hchan. Закрытие нужно только как сигнал.
Суть: в языке нет атомарной «отправь, если не закрыт» — проверить закрытость и отправить одной операцией невозможно. Значит, проблема решается не проверкой, а дисциплиной владения.

Почему проверка не работает

// Так не работает: между проверкой и отправкой гонка.
if !isClosed(ch) {
    ch <- v      // между if и этой строкой канал могли закрыть -> panic
}

Более того, встроенного isClosed вообще нет. Узнать о закрытости можно единственным способом: попробовать прочитать, а это разрушающая операция.

Работающие подходы, по возрастанию надёжности

  1. Дисциплина владения (правильный ответ). У канала один владелец: он создаёт, пишет и закрывает. Наружу отдаётся <-chan T, так что чужой код физически не может ни писать, ни закрывать. 90% проблем исчезает на этом шаге.
  2. Отдельный done-канал. Сигнал остановки идёт навстречу потоку данных. Отправитель проверяет его в том же select, что и отправку:
    select {
    case ch <- v:
    case <-done:      // нам сказали остановиться: выходим, не закрывая ch
        return
    }
    Запомни: закрывается done, а не ch. Канал данных вообще может остаться незакрытым, его соберёт GC.
  3. sync.Once на close. Если по архитектуре закрыть канал может несколько мест, остаётся один корректный вариант: обернуть закрытие:
    type SafeChan struct {
        ch   chan int
        once sync.Once
    }
    func (s *SafeChan) Close() { s.once.Do(func() { close(s.ch) }) }
    Это убирает панику «close of closed channel», но не убирает панику «send on closed channel»: отправитель по-прежнему может писать в уже закрытый канал.
  4. Мьютекс вокруг отправки и закрытия. Полностью безопасно, но убивает саму идею канала: RWMutex, отправка под RLock, закрытие под Lock с флагом. Годится как аварийный вариант в чужом легаси.
  5. recover в отправителе. Работает, но это признание в проигрыше: паника уже разрушила инварианты, значение потеряно, и такой код не проходит ревью.
Формулировка для собеса

«Вопрос “как безопасно писать в канал, который могут закрыть” выдаёт размазанное владение каналом. Лечится он не защитой на отправке, а перепроектированием: канал данных закрывает только его писатель, а сигнал остановки идёт отдельным каналом в обратную сторону.»

Суть: узнать можно, но узнанное мгновенно устаревает. len/cap годятся для метрик и логов, для управления потоком — нет. Атомарная проверка — только select с default.

Что делают len и cap

len(ch) читает поле qcount, cap(ch) читает dataqsiz, и оба без взятия c.lock. Data race по букве memory model тут нет (это выровненное чтение одного слова, и рантайм читает его напрямую), но есть race condition на уровне логики: между твоей проверкой и действием состояние канала меняют другие горутины.

// Опасно: TOCTOU, классическая race condition
if len(ch) < cap(ch) {
    ch <- v          // здесь буфер уже могли забить, заблокируемся
}

// Правильно: проверка и отправка атомарны под локом канала
select {
case ch <- v:
default:
}

// Допустимо: метрики, где неточность не критична
metrics.QueueDepth.Set(float64(len(ch)))

Как дочитать канал до конца перед завершением (drain)

// 1. Отправитель закрыл канал, лучший случай:
for v := range ch {         // сам выйдет, когда буфер опустеет
    handle(v)
}

// 2. Канал не закрыт, забираем только то, что уже лежит:
for {
    select {
    case v := <-ch:
        handle(v)
    default:
        return              // готового больше нет, уходим
    }
}

// 3. Дренаж с дедлайном: забрать хвост, но не ждать вечно
t := time.NewTimer(2 * time.Second)
defer t.Stop()
for {
    select {
    case v, ok := <-ch:
        if !ok { return }
        handle(v)
    case <-t.C:
        return              // хвост не успели: потерю принимаем осознанно
    }
}
Подвох варианта 2

default означает «в этот момент готового значения нет», а не «отправитель закончил». Если продюсер ещё жив и просто чуть отстал, ты выйдешь из дренажа и потеряешь то, что он отправит через микросекунду. Детерминированный дренаж бывает один: когда отправитель закрыл канал. Тогда range гарантированно заберёт всё, что было отправлено, и завершится. Отсюда типовой порядок graceful shutdown: остановить продюсеров → закрыть канал → дождаться, пока консьюмеры допереварят.

Суть: это контракт, проверяемый компилятором. Направленный тип делает невозможными целые классы ошибок: чужой код не закроет ваш канал и не запишет туда мусор.
  • chan T двунаправленный, можно всё, включая close.
  • chan<- T умеет отправку и close. Чтение не компилируется.
  • <-chan T: только приём. Ни отправка, ни close не компилируются: invalid operation: cannot close receive-only channel.

Приведение работает только в сторону сужения и делается неявно при передаче аргумента. Обратно не выйдет даже через явный каст: chan<- int в chan int не приводится. Это и делает гарантию настоящей: получив <-chan T, вернуть себе право писать нельзя.

// Сигнатура сама документирует роль каждой стороны.
func produce(out chan<- Job)        { defer close(out); ... }  // может закрыть
func consume(in  <-chan Job)        { for j := range in { ... } }  // не может испортить
func pipe(in <-chan int) <-chan int { ... }                    // стадия конвейера

ch := make(chan Job)
go produce(ch)     // ch неявно сузился до chan<- Job
consume(ch)        // и до <-chan Job
Практика

В параметрах функций всегда указывай направление: это бесплатная документация и проверка. В полях структур по вкусу, но если структура отдаёт канал наружу через геттер, геттер должен возвращать <-chan T. Внутри одной функции, где канал и создаётся, и используется, направление не нужно. И запомни побочный эффект: <-chan T нельзя передать туда, где ждут chan T. Иногда это ломает подстановку в чужой API, и тогда придётся держать двунаправленный канал внутри и сужать только на выходе.

Суть: да, канал — обычное значение первого класса, его можно передавать куда угодно, включая другой канал. chan struct{} — канал чистого сигнала: элемент занимает 0 байт, и тип сам говорит «данных здесь нет».

Канал в канале

Тип chan chan T абсолютно легален, и на нём стоят два рабочих паттерна: «канал ответа внутри запроса» (см. схему выше) и «канал подтверждения остановки». Второй выглядит так:

// Просим воркер остановиться и ждём подтверждения, что он закончил.
type stopReq struct{ ack chan struct{} }

stop := make(chan stopReq)

go func() {                                  // воркер
    for {
        select {
        case r := <-stop:
            cleanup()
            close(r.ack)                     // «я всё, можешь идти дальше»
            return
        case j := <-jobs:
            handle(j)
        }
    }
}()

ack := make(chan struct{})
stop <- stopReq{ack}
<-ack                                        // синхронная остановка с подтверждением

Почему struct{}, а не bool

  • unsafe.Sizeof(struct{}{}) == 0. В буферизированном канале на 1000 элементов буфер займёт 0 байт полезных данных вместо 1000; все нулевые значения указывают на runtime.zerobase, и memmove копирует 0 байт. Экономия честная, но в 99% случаев неважная.
  • Важнее семантика. chan bool заставляет читателя гадать: а что значит false? «Остановись» или «не останавливайся»? С chan struct{} вопроса не возникает: значение неинформативно по типу, информативен только факт получения (или закрытия).
  • Это идиома стандартной библиотеки: ctx.Done() <-chan struct{}, http.Server.Shutdown, все внутренние сигнальные каналы рантайма. map[T]struct{} как множество работает на той же идее.
Когда сигналят закрытием, а когда отправкой

close(done) даёт одноразовый broadcast всем: любой, кто читает, узнаёт мгновенно, даже если подписался позже. done <- struct{}{} шлёт адресный сигнал одному, и его можно повторять. Первое берут для отмены и завершения, второе годится для тактов и подтверждений. Перепутаешь, получишь баг «сигнал получил только один воркер из пяти».

Суть: нельзя. dataqsiz задаётся в makechan и неизменяем, буфер выделен одним блоком вместе с hchan. «Резиновый» канал делается горутиной-посредником со слайсом-очередью внутри.

Почему нельзя

makechan считает размер один раз: если elemsize == 0 или size == 0, выделяется только hchan; иначе hchan и буфер size * elemsize, часто одной аллокацией подряд в памяти. Поменять размер значило бы перевыделить буфер, переложить элементы, поправить sendx/recvx под локом и заодно испортить указатели sudog.elem. Команда Go сознательно оставила канал примитивом с фиксированной ёмкостью: неограниченная очередь прячет отсутствие backpressure, а это чаще баг, чем фича.

Если очень нужно

Два входных и выходных канала плюс горутина-диспетчер со слайсом. Весь трюк в том, чтобы выключать ветку отправки через nil-канал, когда очередь пуста (код выше, в теории главы). Свойства такой конструкции:

  • in никогда не блокирует отправителя, очередь растёт в куче;
  • порядок FIFO сохраняется;
  • цена такая: лишняя горутина, лишняя передача через канал и неограниченный рост памяти, если потребитель медленнее продюсера.
Почему на ревью такое чаще всего заворачивают

Неограниченный буфер откладывает OOM, а не отменяет его. Если продюсер стабильно быстрее консьюмера, никакая очередь не спасёт: она лишь переносит момент падения. На «канал переполняется» правильно отвечать так: ограничить продюсера (backpressure), дропать с метрикой (select/default), добавить консьюмеров или вынести очередь во внешнюю систему с персистентностью. «Резиновый канал» уместен только там, где всплеск заведомо конечен и известен по размеру.

Суть: по умолчанию значение — оно даёт изоляцию и не грузит GC. Указатель — когда объект большой (сотни байт и больше), нужен мутируемый общий объект, или это уже указатель по природе.
ЗначениеУказатель
Что копируетсяelemsize байт (при рандеву — один раз стек→стек)8 байт
Аллокацияобычно нет, объект живёт в стекеобъект обязан уехать в кучу
Нагрузка на GCнулевая+1 объект для сканирования
Гонкиневозможны: у каждого своя копиявозможны, если отправитель продолжит трогать объект
Мутация получателемне видна отправителювидна — иногда это и нужно
Порог выгодыпримерно 64–128 байт: ниже — значение дешевле, выше — начинает выигрывать указатель

Что важнее размера — владение

Отправляя указатель, ты отдаёшь владение объектом. Контракт должен быть явным: после ch <- p отправитель к *p не прикасается ни на чтение, ни на запись. Нарушишь, получишь гонку, которую race-детектор поймает, только если оба доступа реально совпали по времени в тестовом прогоне. То есть в проде она всплывёт через полгода. Со значением такого класса ошибок не существует физически.

// Опасно: буфер переиспользуется после отправки
buf := make([]byte, 4096)
for {
    n, _ := r.Read(buf)
    ch <- buf[:n]        // отправили заголовок слайса, массив общий
    // следующая итерация перезапишет то, что читает получатель
}

// Безопасно: копия на каждую отправку
for {
    n, _ := r.Read(buf)
    chunk := make([]byte, n)
    copy(chunk, buf[:n])
    ch <- chunk
}

// Безопасно и без аллокаций: буфер возвращается по второму каналу (пул)
free := make(chan []byte, 8)
b := <-free
n, _ := r.Read(b)
ch <- b[:n]              // получатель, закончив, вернёт b в free
Слайс, мапа и строка в канале

Формально это «передача по значению», но копируется только заголовок: 24 байта для слайса, 16 для строки, 8 для мапы. Массив под слайсом и бакеты мапы остаются общими. Поэтому chan []byte де-факто работает каналом указателей со всеми их рисками, а chan string безопасен, потому что строка иммутабельна.

Суть: «обратный адрес в конверте» — в структуру запроса кладётся личный канал ответа с буфером 1. Воркеру не нужно знать клиента, клиенту не нужно знать воркера, и никакой мапы «id → результат» не требуется.
type Request struct {
    Payload int
    Reply   chan<- Response          // направленный: воркер только пишет
}
type Response struct {
    Result int
    Err    error
}

type Pool struct {
    reqs chan Request
    wg   sync.WaitGroup
}

func NewPool(n int) *Pool {
    p := &Pool{reqs: make(chan Request)}
    p.wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer p.wg.Done()
            for r := range p.reqs {      // выйдут все n, когда закроем reqs
                v, err := handle(r.Payload)
                r.Reply <- Response{v, err}   // не заблокируется: буфер 1
            }
        }()
    }
    return p
}

// Для вызывающего Call синхронный, внутри асинхронный.
func (p *Pool) Call(ctx context.Context, v int) (int, error) {
    reply := make(chan Response, 1)       // всё держится на буфере 1
    select {
    case p.reqs <- Request{v, reply}:
    case <-ctx.Done():
        return 0, ctx.Err()               // не смогли даже поставить задачу
    }
    select {
    case r := <-reply:
        return r.Result, r.Err
    case <-ctx.Done():
        return 0, ctx.Err()               // ушли, воркер допишет в буфер и освободится
    }
}

func (p *Pool) Close() { close(p.reqs); p.wg.Wait() }

Что здесь принципиально

  • Буфер 1 у канала ответа. Без него брошенный по таймауту запрос навсегда подвешивает воркер. С ним воркер кладёт ответ в буфер, идёт за следующей задачей, а осиротевший канал забирает GC.
  • Отмена на обоих select. Клиент может не успеть ни поставить задачу, ни дождаться ответа, поэтому отменяемы должны быть обе точки.
  • Направленный тип chan<- Response в структуре: воркер физически не может закрыть чужой канал ответа.
  • Закрытие reqs как сигнал остановки всем воркерам сразу, а wg.Wait() как гарантия, что все доработали.
Альтернатива и почему она хуже

Можно завести один общий канал ответов и map[requestID]chan Response под мьютексом. Работает, но тащит за собой разделяемое состояние, лок на каждый запрос и чистку мапы при таймаутах, иначе она течёт. Паттерн «канал в сообщении» обходится вообще без общего состояния: время жизни канала ответа равно времени жизни запроса, и убирать за собой не нужно.

Суть: в самом select приоритетов нет — выбор среди готовых case случайный, и это гарантировано спецификацией. Приоритет строится вручную: вложенный select с default.
// high приоритетнее low: сначала пытаемся забрать из high неблокирующе.
for {
    select {
    case v := <-high:
        handle(v)
    case v := <-low:
        // прежде чем обработать low, ещё раз проверим high
        select {
        case h := <-high:
            handle(h)
            // low-значение уже забрано из канала, «вернуть» его нельзя,
            // поэтому либо обработать позже, либо держать свою очередь
            handle(v)
        default:
            handle(v)
        }
    case <-ctx.Done():
        return
    }
}
Скрытая проблема этого кода

Как только внешний select выбрал ветку low, значение уже извлечено из канала, «положить обратно» нельзя. Поэтому честный приоритет требует, чтобы низкоприоритетное значение можно было отложить: либо обработать после разбора очереди high, либо буферизовать в своём слайсе. Кто просто пишет вложенный select и думает, что low «не заберётся», ошибается.

Более чистый вариант — сначала дренаж high

for {
    // 1) выгребаем весь high, пока он не опустеет
    for {
        select {
        case v := <-high:
            handle(v)
            continue
        default:
        }
        break
    }
    // 2) high пуст, блокируемся на всём сразу
    select {
    case v := <-high:
        handle(v)
    case v := <-low:
        handle(v)
    case <-ctx.Done():
        return
    }
}
Чем добить ответ
  • Риск голодания: при постоянном потоке high-задач low не обработается никогда. В проде обычно вводят квоту: «после K high-задач обязательно берём одну low». Это weighted round-robin, а не строгий приоритет.
  • Почему выбор случайный. Если бы select проверял case сверху вниз, первый постоянно готовый канал заглушил бы все остальные, а порядок написания веток стал бы семантически значимым. Случайность делает голодание невозможным «по умолчанию» и заставляет писать приоритет явно, если он действительно нужен.
  • Иногда правильнее вообще не делать приоритет в select, а завести две отдельные группы воркеров с разными квотами: так проще рассуждать и наблюдать.
Суть: каждый вызов time.After создаёт таймер, который живёт в куче таймеров рантайма до самого срабатывания. В цикле это тысячи и миллионы живых таймеров, память и нагрузка на планировщик. Замена — переиспользуемый time.Timer или context.WithTimeout.

Что именно течёт

time.After(d) = NewTimer(d).C. Наружу отдают только канал, ссылки на сам *Timer у тебя нет, значит Stop() вызвать невозможно. Таймер лежит в per-P куче таймеров рантайма и удерживает канал и замыкание до истечения d. Если select ушёл в другую ветку, таймер всё равно досидит свой срок. При цикле в 10 000 итераций в секунду и time.After(time.Minute) в пике живёт до 600 000 таймеров, а это десятки мегабайт и заметный рост стоимости операций с кучей таймеров.

// Плохо
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Minute):
        return
    }
}
// Хорошо
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
    select {
    case v := <-ch:
        handle(v)
        if !t.Stop() { <-t.C }
        t.Reset(time.Minute)
    case <-t.C:
        return
    }
}

Три штатные замены

  1. Один time.Timer на цикл со Stop/Reset, если таймаут отсчитывается заново после каждого события (idle timeout).
  2. context.WithTimeout берут, когда дедлайн общий на всю операцию и должен пробрасываться вглубь. ctx.Done() в select, один defer cancel(), никаких лишних таймеров: cancel честно останавливает внутренний таймер.
  3. time.Ticker нужен для периодического такта, а не для однократного дедлайна. Обязательно defer ticker.Stop(): неостановленный тикер будит рантайм, пока на него есть ссылка, а до Go 1.23 — вечно.
Что изменилось в Go 1.23

До 1.23 неостановленный Timer/Ticker был недостижим для GC, пока не сработает: рантайм держал на него ссылку из кучи таймеров. Именно поэтому time.After в цикле считался утечкой в строгом смысле слова. В Go 1.23 (при go-директиве 1.23+ в go.mod) таймеры стали обычными объектами кучи: несработавший time.After, на который никто не ссылается, GC теперь соберёт досрочно, а канал таймера стал небуферизированным, так что ритуал «осушить перед Reset» больше не нужен. Но совет не поменялся: даже собираемый таймер до сборки занимает память и слот в куче таймеров, и явный Timer по-прежнему дешевле и предсказуемее. На собесе стоит сказать обе половины: и про классическую утечку, и про то, что в 1.23 её остроту снизили.

2.3select

Ждать несколько событий сразу умеет только select, другой такой конструкции в языке нет. На собесе его спрашивают в двух режимах: «расскажи, как выбирается case» (проверка на знание рантайма) и «напиши таймаут / отмену / воркер» (проверка на руки). Готовым надо быть к обоим.

Что такое select на самом деле

Синтаксически select похож на switch, но общего между ними ровно ноль. switch сравнивает по цепочке сверху вниз. select зовёт рантайм-функцию runtime.selectgo, которой компилятор передаёт два массива: scases (описание каждой ветки: канал, направление, куда положить или откуда взять значение) и order (рабочий буфер под перестановки). selectgo возвращает индекс выигравшего case и флаг recvOK, а компилятор дальше делает обычный переход по таблице.

Два случая компилятор оптимизирует, не доходя до selectgo: select с одним case превращается в обычную операцию с каналом, а select с одним case и default сворачивается в selectnbsend/selectnbrecv, то есть в chansend/chanrecv с флагом block = false. Полный алгоритм включается с двух и более каналов.

1 Собрать scases: канал, направление, адрес значения для каждой ветки, включая ветки с nil-каналом nil-канал остаётся в массиве, но он никогда не станет готовым 2 pollorder = случайная перестановка индексов lockorder = сортировка каналов по адресу в памяти перестановка — алгоритм Фишера-Йетса на быстром PRNG, без блокировок 3 Взять локи ВСЕХ каналов в порядке lockorder одинаковые адреса — берём один раз сортировка по адресу — защита от дедлока между двумя select 4 Проход 1: обойти case в порядке pollorder нашёлся готовый — выполнить обмен, снять локи, выйти быстрый путь: парковки не будет именно здесь возникает случайность 5 Есть ветка default? Снять локи и выполнить её это и делает select неблокирующим default проверяется ПОСЛЕ всех каналов, а не наравне с ними default нет — придётся ждать 6 Проход 2: на каждый case — свой sudog в очередь канала одна горутина одновременно стоит в N очередях sudog берутся из кэша P, аллокации обычно нет 7 gopark: статус _Gwaiting, все локи отпущены M забирает следующую горутину и работает дальше поток ОС не блокируется ни на секунду 8 Кто-то пришёл: CAS на g.selectDone 0 в 1 выигрывает ровно один канал, остальные получают отказ без этого CAS два канала могли бы разбудить нас дважды и оба передать 9 Проход 3: снять свои sudog со ВСЕХ остальных очередей снова под локами в lockorder, sudog вернуть в кэш пропустить этот шаг нельзя: иначе очереди каналов протухнут 10 Вернуть индекс выигравшего case, выполнить его тело значение уже лежит там, куда его положил передающий стоимость select с 2 case без парковки — примерно 50-150 нс
selectgo. Три прохода: попробовать без блокировки, встать во все очереди, после пробуждения сняться со всех. Случайность появляется на шаге 2 и срабатывает на шаге 4; сортировка по адресу на шаге 3 нужна, чтобы два select с пересекающимися каналами не устроили дедлок на самих локах.
Горутина в select sudog на каждый case Очереди каналов g (наша) _Gwaiting selectDone = 0 waiting: список sudog sudog A elem указывает в наш стек sudog B elem указывает в наш стек sudog C elem указывает в наш стек chA: recvq наш sudog стоит в очереди не выиграл — снимаем на шаге 9 chB: пришёл отправитель CAS selectDone 0 в 1 — успех значение скопировано, goready(g) chC: recvq наш sudog стоит в очереди не выиграл — снимаем на шаге 9 Если бы CAS не было: отправитель в chA и отправитель в chC могли бы одновременно решить, что мы их получатель, и оба скопировали бы значение — одно из них потерялось бы.
Одна горутина — N очередей. Вот почему select обязан жить в рантайме: библиотечными средствами нельзя атомарно встать в несколько очередей ожидания и гарантировать, что разбудит нас ровно один.

Почему выбор случайный

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

От чего защищает случайность
  • От голодания. При проверке сверху вниз первый постоянно готовый канал заглушил бы все остальные навсегда. Достаточно поставить case <-ticker.C первым, а case <-work вторым: работа не выполнялась бы никогда.
  • От неявной семантики порядка. Если бы порядок веток значил приоритет, перестановка двух case (безобидный рефакторинг) меняла бы поведение программы. Случайность делает порядок веток заведомо неважным.
  • От синхронизации фаз. Несколько одинаковых воркеров с одинаковым select при детерминированном выборе выстроились бы в одну и ту же последовательность и стабильно дрались бы за один канал. Случайность их «расфазирует».

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

a := make(chan int, 1)
b := make(chan int, 1)
var na, nb int
for i := 0; i < 100_000; i++ {
    a <- 1                     // оба канала готовы одновременно
    b <- 1
    select {
    case <-a:
        na++
        <-b                    // дренаж, чтобы следующая итерация началась с пустых каналов
    case <-b:
        nb++
        <-a
    }
}
fmt.Println(na, nb)

// три запуска подряд на go1.27:
// 50149 49851
// 50068 49932
// 49986 50014
// Ровно 50/50 не бывает: выбор псевдослучайный, а не по очереди.

Обратная сторона: приоритет между каналами приходится писать руками, вложенным select с default (разобрано в вопросе главы 2.2). И ещё: default в случайной перестановке не участвует, его черёд наступает строго после того, как ни один канал не оказался готов.

select с default, пустой select и nil-канал

ФормаЧто делаетКогда применяют
select { case ... }блокируется, пока хоть один case не станет готовосновной режим: ожидание любого из событий
select { case ...; default: }никогда не блокируетсянеблокирующие приём/отправка, дренаж, «попробовать без ожидания»
select {}блокирует горутину навсегда«повиснуть» в main после запуска фоновых сервисов
case с nil-каналомветка никогда не сработаетдинамическое отключение ветки
все каналы nil, default нетвечная блокировкаобычно баг: забыли инициализировать канал
// На select{} горутина паркуется навсегда и больше не проснётся.
func main() {
    go server.ListenAndServe()
    go metrics.Serve()
    select {}          // main не завершится; но если уснут все горутины -> fatal deadlock
}

select {} компилируется в прямой вызов runtime.block(): тот делает gopark с причиной select (no cases), и проснуться горутина уже не сможет. В реальном коде это почти всегда хуже, чем <-ctx.Done() или http.Server.ListenAndServe() прямо в main: не даёт ни graceful shutdown, ни возможности вернуть код выхода.

Фаза 1: работают обе ветки select { case v, ok := <-in: case out <- head: } in активен out активен Фаза 2: очередь пуста, отправлять нечего out = nil select { case <-in: ; case out <- head: } вторая ветка выключена: nil никогда не готов in активен out выключен Зачем так делают: без выключения ветка out отправляла бы мусор или крутила бы busy-loop. Альтернатив нет: удалить case из select во время выполнения невозможно, набор веток статичен. nil-канал — это runtime-switch.
nil-канал как выключатель. Набор веток select компилятор фиксирует намертво, так что управлять им на ходу можно единственным способом: подменять сам канал на nil. Ради этого приёма чтение и запись в nil-канал блокируют, а не паникуют.

Типовые конструкции, которые просят написать

// 1. Таймаут на ожидание значения
select {
case v := <-ch:
    use(v)
case <-time.After(2 * time.Second):
    return errTimeout
}

// 2. Отмена по контексту (в проде почти всегда вместо time.After)
select {
case v := <-ch:
    use(v)
case <-ctx.Done():
    return ctx.Err()
}

// 3. Graceful-цикл воркера: работа, периодический такт и отмена
func worker(ctx context.Context, jobs <-chan Job, out chan<- Result) error {
    tick := time.NewTicker(30 * time.Second)
    defer tick.Stop()
    for {
        select {
        case j, ok := <-jobs:
            if !ok {
                return nil                 // продюсер закрыл канал, завершаемся штатно
            }
            r, err := handle(ctx, j)
            if err != nil {
                return err
            }
            select {                        // отправка результата тоже отменяема
            case out <- r:
            case <-ctx.Done():
                return ctx.Err()
            }
        case <-tick.C:
            reportHealth()
        case <-ctx.Done():
            return ctx.Err()                // внешняя отмена
        }
    }
}

// 4. Неблокирующее чтение и запись
select {
case v := <-ch: use(v)
default:                                    // пусто, идём дальше
}

// 5. Ждём первый из нескольких источников (гонка реплик)
select {
case r := <-replicaA:
    return r
case r := <-replicaB:
    return r
case <-ctx.Done():
    return zero, ctx.Err()
}
Ошибка, которую видно сразу

В пункте 3 всё держится на вложенном select при отправке результата. Начинающие пишут out <- r напрямую и получают воркер, который прекрасно реагирует на отмену, пока ждёт задачу, и наглухо виснет, когда пытается отдать результат никем не читаемому каналу. Правило: любая блокирующая операция в отменяемом цикле должна стоять в select с ctx.Done(), включая отправку.

Чек-лист «правильного» select в проде
  1. Есть ветка <-ctx.Done(), если цикл вообще должен уметь останавливаться.
  2. Принимаешь с , ok, если канал могут закрыть, и выходишь по явной ветке на !ok.
  3. Никаких time.After в теле цикла: только заранее созданный Timer/Ticker со Stop.
  4. default есть, только если ты сознательно готов потерять значение; default внутри for без блокирующей ветки даёт busy-loop на 100% CPU.
  5. Наружу отправляешь тоже через select с отменой.

Вопросы

6
Суть: select — вызов runtime.selectgo: залочить все каналы в порядке адресов, обойти case в случайном порядке, при готовом — выполнить и выйти; иначе default; иначе встать sudogом во все очереди и заснуть, а после пробуждения сняться с остальных.

Три прохода

  1. Проход 1 — пробуем без блокировки. Каналы залочены в lockorder (сортировка по адресу), обход идёт в pollorder (случайная перестановка). Готовый case — это непустая противоположная очередь ожидающих, либо место/данные в буфере, либо закрытый канал на приёме. Нашли: обмен, разлочить всё, вернуть индекс.
  2. Проход 2 — встаём в очереди. Если готовых нет и default нет, на каждый case заводится свой sudog и встаёт в sendq или recvq нужного канала. Потом gopark: горутина уходит в _Gwaiting, все локи отпускаются, поток ОС продолжает работать с другими горутинами.
  3. Проход 3 — уборка. Кто-то пришёл на один из каналов, выиграл гонку через CAS на g.selectDone, передал значение и вызвал goready. Проснувшись, selectgo снова берёт все локи и снимает свои sudog со всех очередей, возвращая их в кэш.

Две неочевидные детали

  • Зачем сортировка по адресу. Два select в разных горутинах могут пересекаться по каналам. Если бы один брал локи в порядке A, B, а другой в порядке B, A, вышел бы классический дедлок на внутренних локах рантайма. Глобальный порядок «по возрастанию адреса» это исключает.
  • Зачем CAS на selectDone. Горутина стоит в нескольких очередях, и два разных канала могут одновременно решить, что партнёр именно она. CAS 0→1 определяет единственного победителя; проигравший видит, что горутина уже занята, и идёт искать следующего в очереди.
Оптимизации компилятора

select с одним case компилируется в обычную операцию с каналом. select с одним case и default уходит в selectnbrecv/selectnbsend (то есть chanrecv или chansend с block=false). Пустой select {} становится runtime.block(). Полный selectgo зовётся только с двух и более каналов, и стоит он порядка 50–150 нс на быстром пути.

Суть: случайность — гарантия спецификации, а не деталь реализации. Она убирает голодание веток и делает порядок написания case семантически незначимым.

Что было бы при проверке сверху вниз

for {
    select {
    case <-ticker.C:      // если бы порядок задавал приоритет,
        report()          // тикер раз в 10 мс всегда был бы готов,
    case j := <-jobs:     // и вот эта ветка не выполнилась бы никогда
        handle(j)
    }
}

Со случайным выбором обе ветки в среднем берут по половине готовых итераций, и порядок, в котором разработчик написал case, ни на что не влияет.

Три эффекта, ради которых это сделано

  • Нет голодания. Ни одну ветку не заглушит соседняя, какой бы «горячей» она ни была.
  • Перестановка веток не меняет поведение. Значит, рефакторинг безопасен, а ревьюер не должен держать в голове приоритеты.
  • Расфазировка одинаковых воркеров. Пять горутин с одинаковым select не выстраиваются в одну и ту же последовательность решений.

Как именно реализована случайность

В selectgo массив индексов перемешивает алгоритм Фишера-Йетса, а случайные числа даёт быстрый потоковый генератор рантайма (cheaprandn, состояние живёт в m, без блокировок и без обращения к math/rand). Перемешивается только pollorder, порядок опроса; локи (lockorder) берутся строго по возрастанию адреса.

Что это значит на практике

Тесты, которые полагаются на то, какая ветка сработает при двух готовых каналах, писать нельзя. Приоритет «просто поставь важную ветку первой» не работает. И не строй логику на предположении «ctx.Done() сработает раньше, чем придёт последнее значение»: если оба готовы, с вероятностью 50% получишь значение, а не отмену. Нужен абсолютный приоритет отмены, проверяй её отдельным неблокирующим select перед основным.

Суть: default выполняется, если ни один case не готов прямо сейчас. Это единственный способ выполнить операцию с каналом атомарно-или-никак, не рискуя заблокироваться.
// Неблокирующий приём
select {
case v := <-ch: use(v)
default:        // канал пуст
}

// Неблокирующая отправка
select {
case ch <- v:
default:        // буфер полон / получателя нет
}

// Неблокирующая проверка отмены
select {
case <-ctx.Done(): return ctx.Err()
default:        // не отменён, считаем дальше
}

Ключевое свойство: атомарность

Проверка «готов ли канал» и сама операция идут внутри одной критической секции под локом канала. Поэтому select/default нельзя заменить на if len(ch) > 0 { v := <-ch }: между if и чтением значение успеет забрать другая горутина, и ты заблокируешься.

Где применяют по делу

  • Телеметрия и логи: лучше потерять сэмпл метрики, чем притормозить обработку запроса.
  • Сигнальные каналы с буфером 1: «есть новая работа», и если сигнал уже лежит, второй не нужен.
  • Дренаж: забрать всё, что уже накопилось, и уйти.
  • Периодическая проверка отмены внутри долгого вычисления.
Два способа выстрелить себе в ногу
  • busy-loop. for { select { case ...: ...; default: } } без всякой паузы и без блокирующей ветки крутит 100% ядра впустую. Если по логике нужно «ждать», то default здесь лишний.
  • default на небуферизированном канале. Отправка сработает, только если получатель уже стоит в очереди. Промах почти гарантирован, и данные тихо теряются. Для «не потерять, но не ждать вечно» нужен буфер или таймаут.
Суть: блокирует текущую горутину навсегда, без возможности пробуждения. Компилируется в runtime.block()gopark с причиной «select (no cases)».

Такая горутина не потребляет CPU и не занимает поток ОС, она просто выпадает из планировщика насовсем. Разбудить её нечем: у неё нет ни одного sudog ни в одной очереди.

Единственное осмысленное применение

func main() {
    go worker()
    go metricsServer()
    select {}     // «главная горутина, повиси, пока работают фоновые»
}

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

Почему select{} в проде — красный флаг
  • Нет graceful shutdown: по SIGTERM процесс умрёт мгновенно, не дав закрыть соединения и дописать буферы.
  • Нет кода возврата: программа не может завершиться с ошибкой.
  • Если все остальные горутины тоже уснут на каналах, рантайм напечатает fatal error: all goroutines are asleep - deadlock!, и указывать на проблему будет твой select {}.
  • Идиоматичная замена: <-ctx.Done(), где ctx создан через signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM), либо просто блокирующий srv.ListenAndServe() прямо в main.
Соседний вопрос

«А чем select {} отличается от for {}?» Принципиально: for {} это busy-loop, он жжёт 100% ядра, и до Go 1.14 такой цикл без вызовов функций вообще нельзя было вытеснить, из-за чего он мог подвесить GC. select {} паркуется честно и не ест ничего.

Суть: ветка с nil-каналом никогда не станет готовой — это штатный способ «выключить» case во время выполнения. Набор веток select фиксирован компилятором, менять можно только сами каналы.

В selectgo case с nil-каналом просто пропускается при опросе и не получает sudog. Поэтому он не мешает остальным, не тратит ресурсов и никогда не выигрывает. Если все каналы nil и default нет, горутина заблокируется навсегда, ровно как на select {}.

Паттерн 1: отправлять, только когда есть что

var out chan Result          // nil, пока результата нет
var pending Result
for {
    select {
    case v := <-in:
        pending = compute(v)
        out = results        // включаем ветку отправки
    case out <- pending:
        out = nil            // выключаем: отправлять больше нечего
    case <-ctx.Done():
        return
    }
}

Без этого приёма пришлось бы либо крутить default-цикл, либо отправлять нулевые значения, либо разводить два разных select и дублировать в каждом логику отмены.

Паттерн 2: аккуратное завершение при закрытии одного из источников

// merge двух каналов: закрывшийся источник обнуляем, чтобы не крутиться на нём вечно
for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok { a = nil; continue }   // закрытый канал всегда готов, поэтому обнуляем
        out <- v
    case v, ok := <-b:
        if !ok { b = nil; continue }
        out <- v
    }
}
close(out)
Почему без обнуления это busy-loop

Закрытый канал в select всегда готов на приём: он мгновенно отдаёт zero, false. Если после !ok не присвоить a = nil, select будет бесконечно выбирать эту ветку, крутя цикл на полной скорости и не давая второму каналу ни единого шанса. В самописном merge это ошибка номер один, её и ищут, когда просят написать fan-in руками.

Суть: три шаблона, которые надо уметь писать не задумываясь. Ключевые детали — таймер создаётся один раз, отмена проверяется во всех блокирующих точках, включая отправку результата.

Таймаут

// Разово можно и time.After
select {
case v := <-ch:
    return v, nil
case <-time.After(5 * time.Second):
    return zero, errTimeout
}

// В цикле только переиспользуемый таймер
t := time.NewTimer(5 * time.Second)
defer t.Stop()

Отмена по контексту

select {
case v := <-ch:
    use(v)
case <-ctx.Done():
    return ctx.Err()      // context.Canceled или context.DeadlineExceeded
}

Так лучше, чем time.After: дедлайн приходит сверху, его наследуют все вложенные вызовы, снимает его явный cancel(), и лишних таймеров не плодится.

Graceful-цикл воркера

func worker(ctx context.Context, jobs <-chan Job, out chan<- Result) error {
    tick := time.NewTicker(time.Minute)
    defer tick.Stop()
    for {
        select {
        case j, ok := <-jobs:
            if !ok {
                return nil                  // 1) продюсер закрыл вход, выходим штатно
            }
            r, err := handle(ctx, j)
            if err != nil {
                return err
            }
            select {                         // 2) отправка результата тоже отменяема
            case out <- r:
            case <-ctx.Done():
                return ctx.Err()
            }
        case <-tick.C:
            heartbeat()
        case <-ctx.Done():
            return ctx.Err()                 // 3) внешняя отмена
        }
    }
}
Четыре вещи, за которые здесь ставят плюс
  1. Два независимых способа завершиться: закрытие входного канала (данные кончились) и отмена контекста (нас просят прекратить). Их нельзя смешивать.
  2. Вложенный select на отправке. Без него отмена работает ровно до того момента, пока воркер не соберётся отдать результат, а дальше он висит намертво.
  3. defer tick.Stop(): неостановленный Ticker продолжает тикать и держать ресурсы.
  4. Возврат error, а не молчаливый return: вызывающий должен уметь отличить «доработал» от «отменили» и от «упал».
Что спросят следом

«А если handle длинная и не смотрит на ctx?» Тогда воркер не остановится в разумное время, сколько бы select ты ни написал снаружи. Отменяемость обязана быть сквозной: ctx прокидывают внутрь и проверяют в самой длинной операции, в цикле по батчу, в запросе к БД, в HTTP-вызове. Внешний select лишь решает, ждать ли результат; остановить чужую работу он не может.

2.4Примитивы синхронизации: sync и atomic

Каналы — визитная карточка Go, но 80% реального кода держится на мьютексах и атомиках. Здесь проверяют, понимаешь ли ты цену каждого примитива и умеешь ли выбирать, а не хвататься за первое, что вспомнилось.

Сначала — пять слов, которые дальше идут без пояснений

Вся глава про одно: «чем защитить общие данные и сколько это стоит». Внутренности примитивов разбираю подробно, и в разборе постоянно всплывают одни и те же четыре термина плюс пятый, мимо которого обычно проходят. Проговорим их сразу.

1. Критическая секция и лок

Критическая секция — участок кода, который нельзя исполнять двум горутинам одновременно, потому что он трогает общие данные. Лок (замок; он же мьютекс — от mutual exclusion, «взаимное исключение») — механизм, который это обеспечивает: перед секцией Lock(), после — Unlock(), и внутри гарантированно ровно один. Обороты «взять лок», «захватить», «держать лок», «под локом» — все про промежуток между этими двумя вызовами.

2. Семафор — счётчик разрешений со списком спящих

Семафор — счётчик, к которому приложен список ожидающих. Операция «занять» уменьшает счётчик, а если он уже ноль — вызвавший засыпает и попадает в список. Операция «освободить» увеличивает счётчик и будит одного из спящих. Мьютекс и есть семафор на одно разрешение.

Слово используют в двух разных смыслах, и их полезно не путать. Семафор рантайма (runtime_Semacquire / runtime_Semrelease) — внутренний механизм, на котором построены Mutex, RWMutex и WaitGroup; он усыпляет не поток ОС, а горутину, то есть делает ту самую парковку из главы 2.2. Семафор как приём — способ ограничить число одновременных операций буферизированным каналом ёмкости N или пакетом golang.org/x/sync/semaphore; про него в главе 2.7.

3. Спин — подождать, не засыпая

Спин (spin, «покрутиться»; ещё говорят «активное ожидание», «busy-wait») — это когда вместо того чтобы заснуть, горутина несколько раз впустую прокручивает короткий цикл и снова проверяет условие. Смысл чисто арифметический: заснуть и проснуться стоит сотни наносекунд, а занятый лок часто освобождается за десятки. Успел владелец отпустить, пока мы крутились, — мы сэкономили два переключения. Не успел — потратили немного процессора зря и всё равно заснули. Поэтому спин всегда ограничен несколькими итерациями, а не «крутимся, пока не дадут».

4. Голодание (starvation) — ждёшь дольше всех и не получаешь ничего

Голодание (starvation) — ситуация, когда горутина формально не заблокирована навсегда, но раз за разом проигрывает конкуренцию и не продвигается. Классический пример: горутина проснулась, чтобы взять освободившийся лок, но её опередила другая — та, что как раз крутилась на процессоре и вообще не спала. Один раз это не беда; беда в том, что под нагрузкой так может повторяться неограниченно долго. От дедлока отличается тем, что система в целом прекрасно работает и метрики зелёные — страдает конкретный невезучий участник.

5. happens-before — обещание видимости, а не порядок по часам

Термин, который недооценивают чаще прочих: его переводят как «происходит до» и на этом успокаиваются, хотя ко времени он отношения почти не имеет. В одну фразу: happens-before — это гарантия, что записи, сделанные одной горутиной, будут видны другой. Без такой гарантии «увидит» не обещано вообще: ни через наносекунду, ни через час. Почему так и что именно ломается, разобрано ниже, в разделе «atomic и модель памяти». Прочитай целиком: на собесе плывут чаще всего именно здесь.

sync.Mutex изнутри

Структура спартанская: два поля, state int32 и sema uint32. Ни владельца, ни счётчика рекурсии, ни привязки к горутине. Отсюда и растёт половина граблей.

sync.Mutex.state — одно 32-битное слово, всё через atomic.CompareAndSwap waiters биты 3..31 — сколько горутин спит на семафоре starving бит 2 woken бит 1 locked бит 0 Unlock ненагруженного мьютекса — это один atomic.AddInt32(state, -1). Быстрый путь Lock — один CAS 0 в 1. Normal mode — режим по умолчанию Unlock просто снимает бит locked и будит одного из очереди. Разбуженная горутина НЕ получает лок автоматически: она конкурирует с новыми горутинами, которые прямо сейчас крутятся на CPU и уже стоят у входа. Плюс: максимальная пропускная способность — лок отдаётся тому, чьи данные горячие в кэше. Минус: старая горутина может проигрывать снова и снова. Starvation mode — режим голодания Unlock передаёт лок НАПРЯМУЮ первому в очереди. Новые горутины даже не пытаются захватить: они сразу встают в хвост очереди, спиннинг выключен. Плюс: строгий FIFO, никто не голодает. Минус: каждая передача лока — переключение горутины, пропускная способность падает в разы. Выход из режима: очередь опустела или ждали меньше 1 мс. ждём > 1 мс назад Порог 1 мс жёстко зашит в runtime как starvationThresholdNs — это не настройка.
Мьютекс. Всё состояние — 32 бита плюс семафор рантайма. Два режима нужны, чтобы совместить несовместимое: пропускную способность (normal) и отсутствие голодания (starvation). Переключается он сам, снаружи этого не видно.

Что происходит при Lock, по шагам

  1. Fast path. CAS(state, 0, mutexLocked). Если получилось — всё, лок наш, стоимость ~15–25 нс. Так заканчивается подавляющее большинство вызовов.
  2. Spin. Если лок занят, но мьютекс в normal mode, очередь коротка и есть свободные P, горутина крутится активно: до 4 итераций по 30 инструкций PAUSE (procyield). Расчёт простой: владелец отпустит лок через десятки наносекунд, и это дешевле парковки с пробуждением.
  3. Парковка. Спин не помог — увеличиваем счётчик waiters и вызываем runtime_SemacquireMutex. Это семафор рантайма: горутина уходит в _Gwaiting, поток ОС свободен. Никакого futex на уровне потока здесь нет — блокируется именно горутина.
  4. Пробуждение. Проснувшись, горутина смотрит режим: в normal — пробует захватить снова (и может проиграть новичку), в starvation — получает лок сразу, потому что Unlock передал его напрямую.
  5. Переключение режима. Если горутина в момент захвата обнаружила, что ждала дольше 1 мс, она выставляет бит starving. Обратно в normal мьютекс выходит, когда очередь опустела или очередной ждущий провёл в ней меньше 1 мс.
У мьютекса нет владельца, отсюда три следствия
  • Повторный Lock из той же горутины = вечная блокировка. Мьютекс не реентерабельный: он не знает, кто его взял, и не может «пропустить своего». Если это была единственная живая горутина, получишь fatal error: all goroutines are asleep - deadlock!, иначе тихое зависание.
  • Unlock может сделать другая горутина. Формально это разрешено и иногда используется (передача владения), но такой код почти невозможно читать.
  • Unlock незалоченного мьютекса — паника sync: unlock of unlocked mutex, и её нельзя поймать recover в другой горутине.
// Самоблокировка, классика на собесе
var mu sync.Mutex
func A() { mu.Lock(); defer mu.Unlock(); B() }   // B тоже берёт mu
func B() { mu.Lock(); defer mu.Unlock() }        // deadlock

// Разделяем: публичный метод берёт лок, внутренний зовут уже под локом
func (s *Store) Get(k string) V {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.getLocked(k)
}
func (s *Store) getLocked(k string) V { return s.m[k] }   // лок не берёт

// TryLock (Go 1.18+) для диагностики, а не для попыток взять лок в цикле
if mu.TryLock() {
    defer mu.Unlock()
    // ...
} else {
    metrics.LockContention.Inc()
}

RWMutex: когда действительно быстрее

RWMutex разрешает либо одного писателя, либо любое число читателей. Внутри — обычный Mutex для писателей плюс два счётчика (readerCount, readerWait) и два семафора. RLock делает atomic.AddInt32(&readerCount, 1) и, если значение неотрицательное, сразу возвращается.

СценарийMutexRWMutexКомментарий
Короткая критическая секция (чтение поля)быстреемедленнееу RWMutex вдвое больше атомарных операций; выигрыш не окупается
Много читателей, редкие записи, секция > ~1 мксузкое местобыстреечитатели идут параллельно
50/50 чтение и записьбыстреемедленнееписатель всё равно всех выстраивает в очередь
Много ядер, очень много читателейосторожноreaderCount — одна кэш-линия, все ядра дерутся за неё (cache line bouncing)
Write starvation и почему её в Go нет

Наивный RWMutex пускает читателей всегда, пока их счётчик больше нуля: при плотном потоке чтений писатель не пройдёт никогда. В Go это закрыто: Lock писателя вычитает из readerCount константу rwmutexMaxReaders (1<<30), делая счётчик отрицательным. Все новые RLock видят отрицательное значение и уходят спать. Писатель ждёт только тех читателей, которые уже внутри. То есть писатель блокирует новых читателей, но не наоборот, и голодания писателя не будет. Зато работает обратное: плотный поток писателей подтормаживает читателей.

RWMutex не рекурсивен и не апгрейдится
  • Вложенный RLock из-под RLock может задедлочить. Если между ними успел встать писатель, внутренний RLock заблокируется, а внешний не отпустится — классический deadlock, документированный в исходниках.
  • Апгрейда RLockLock нет. Нужно отпустить чтение, взять запись и перепроверить состояние: за время между ними мир изменился.
  • RUnlock без RLock — паника sync: RUnlock of unlocked RWMutex.

Почему нельзя копировать мьютекс

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

type Counter struct {
    mu sync.Mutex
    n  int
}

// Плохо: метод на значении копирует при вызове и мьютекс, и счётчик
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }   // go vet: passes lock by value

// Хорошо: указательный ресивер
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }

// Плохо: копия при передаче, при возврате, при присваивании, при range по слайсу структур
func process(c Counter) {}
cs := []Counter{{}, {}}
for _, c := range cs { _ = c }     // c копируется со всем содержимым

// Хорошо: работаем через указатели
func process(c *Counter) {}
for i := range cs { cs[i].Inc() }
go vet copylocks

Анализатор copylocks входит в стандартный go vet, но в урезанный набор, который go test гоняет сам, не входит: запускай go vet явно или go test -vet=all. Он ловит копирование любого типа, содержащего sync.Locker (Mutex, RWMutex, WaitGroup, Cond, Once, Pool, atomic-типы Go 1.19+), на любой глубине вложенности. Технически он опирается на встроенное поле noCopy, пустую структуру с методами Lock()/Unlock(), которая и существует ради одной этой проверки. Этот приём работает и в своих типах: положи в структуру noCopy, и vet начнёт защищать её так же.

WaitGroup, Once, Pool

// WaitGroup держит в одном 12-байтном блоке счётчик, счётчик ждущих и семафор.
var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)                       // до go, всегда
    go func() {
        defer wg.Done()             // всегда через defer, иначе при панике Done потеряется
        fetch(u)
    }()
}
wg.Wait()

// Go 1.25: WaitGroup.Go объединяет Add(1), go и Done
var wg2 sync.WaitGroup
for _, u := range urls {
    wg2.Go(func() { fetch(u) })     // с Add/Done уже не ошибёшься
}
wg2.Wait()
Четыре способа сломать WaitGroup
  1. Add внутри горутины. go func(){ wg.Add(1); ... }() — гонка: Wait может увидеть счётчик 0 и вернуться до того, как горутина успеет стартовать. Add строго до go.
  2. Копирование WaitGroup. Передали по значению в функцию — там своя копия счётчика, Wait в оригинале ждёт вечно. Только *sync.WaitGroup или замыкание.
  3. Забытый Done при панике или раннем return. Только defer wg.Done() первой строкой горутины.
  4. Переиспользование до завершения Wait. Новый Add, пока предыдущий Wait ещё не вернулся, — гонка и паника WaitGroup is reused before previous Wait has returned. Лишний Done даёт negative WaitGroup counter.
// Once: fast path обходится одним атомарным чтением, без мьютекса.
type Once struct {
    done atomic.Uint32
    m    Mutex
}
func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {         // ~1 нс в горячем пути
        o.doSlow(f)
    }
}
func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {         // двойная проверка уже под локом
        defer o.done.Store(1)       // выставляется после f, иначе гонка
        f()
    }
}

// Go 1.21 добавил удобные обёртки:
getConfig := sync.OnceValue(func() *Config { return loadConfig() })
cfg := getConfig()                  // ленивая инициализация без объявления Once
Две тонкости Once
  • done выставляется после возврата f, а не до. Значит, вторая горутина, попавшая в Do во время выполнения f, заблокируется на мьютексе и дождётся завершения инициализации, а не пробежит мимо с недоинициализированным объектом.
  • Если f запаниковала — done всё равно выставляется (это defer), и повторный Do уже ничего не вызовет. Для «повторить при ошибке» нужен sync.OnceValue с явной обработкой либо golang.org/x/sync/singleflight.
// Pool переиспользует объекты, чтобы не грузить GC.
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func handle(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                     // обязательно: Pool отдаёт грязный объект
    defer bufPool.Put(buf)
    // ... работаем с buf ...
}

sync.Map: read + dirty

read — atomic.Pointer[readOnly] Чтение БЕЗ мьютекса: один атомарный Load указателя, потом обычный доступ к неизменяемой мапе. m: map[K]*entry amended bool true — в dirty есть ключи, которых тут нет Значения меняются атомарно прямо в *entry, без лока. dirty — обычная map под sync.Mutex Сюда идут все НОВЫЕ ключи. Любое обращение — под мьютексом, то есть медленно. m: map[K]*entry misses int сколько раз промахнулись мимо read Хранит ВСЕ актуальные ключи, включая те, что есть в read. промах misses++ Промоушен: как только misses больше или равно len(dirty) dirty целиком становится новой read (один atomic.Store), dirty обнуляется, misses = 0 Следующая запись нового ключа заново КОПИРУЕТ всю read в dirty — O(n) под мьютексом. Поэтому нагрузка «много новых ключей» превращает sync.Map в мапу под мьютексом плюс копирование.
sync.Map. Две мапы вместо одной: неизменяемая read для быстрых чтений без лока и dirty под мьютексом для всего остального. Выигрыш есть только там, где ключи стабильны, а чтений сильно больше записей.
ОперацияЧто происходитСтоимость
Load, ключ в readatomic-загрузка указателя + чтение мапыочень дёшево, лока нет
Load, ключа в read нет, amendedлок, поиск в dirty, misses++дорого
Store существующего ключаCAS прямо в *entryдёшево, лока нет
Store нового ключалок, при пустой dirty — копирование всей readочень дорого, O(n)
Deleteпомечает entry как expunged, память не освобождает сразусредне
Rangeпри amended сначала промоутит dirty в readдорого, снимок не консистентен
Когда sync.Map оправдана: по документации, дословно
  1. Ключ пишется один раз, читается много раз — кэши только на дозапись.
  2. Несколько горутин читают, пишут и перезаписывают непересекающиеся наборы ключей — шардирование по горутинам.
Почему обычно лучше mutex + map
  • Нет типизации до дженериков: Load возвращает any, нужен type assertion, каждое значение — интерфейс, то есть лишняя аллокация и работа GC. (В Go 1.24 внутренности переписали на HashTrieMap, стало заметно лучше на записи, но API остался нетипизированным.)
  • Нет len: посчитать элементы можно только полным Range.
  • Нет атомарных композитных операций сложнее LoadOrStore / CompareAndSwap: «прочитать, посчитать, записать» атомарно не сделать.
  • Проседает на записи новых ключей: каждое построение dirty — копирование всей мапы.
  • Шардированная мапа обычно быстрее: 32–256 обычных мап, каждая под своим мьютексом, выбор по хешу ключа. Типизирована, поддерживает len, легко расширяется составными операциями.

atomic и модель памяти

// Старый функциональный API (работает всегда)
var n int64
atomic.AddInt64(&n, 1)
v := atomic.LoadInt64(&n)
ok := atomic.CompareAndSwapInt64(&n, old, new)
old = atomic.SwapInt64(&n, 42)

// Go 1.19: типы. Лучше брать их: неатомарно к ним не обратишься,
// выравнивание гарантировано, go vet ловит копирование.
var c atomic.Int64
c.Add(1)
c.Load()
c.CompareAndSwap(old, new)

var flag atomic.Bool
var p atomic.Pointer[Config]        // типизированный указатель, без unsafe

// atomic.Value атомарно подменяет значение любого типа целиком.
var cfg atomic.Value                // храним *Config
cfg.Store(newConfig)
c := cfg.Load().(*Config)           // тип во всех Store должен быть один и тот же
Атомарность операции не равна атомарности логики
// Каждая строка атомарна. Вся конструкция — гонка.
if atomic.LoadInt64(&n) < limit {   // здесь другая горутина может увеличить n
    atomic.AddInt64(&n, 1)          // и лимит будет превышен
}

// Правильно: одна атомарная операция или цикл CAS
for {
    old := atomic.LoadInt64(&n)
    if old >= limit {
        return errLimit
    }
    if atomic.CompareAndSwapInt64(&n, old, old+1) {
        return nil
    }
}

happens-before: зачем оно нужно и что ломается без него

Первый конкурентный код почти все пишут с одной мыслью в голове: «я записал переменную — значит через мгновение другая горутина увидит новое значение». Мысль неверна, и ломается она сразу в двух местах.

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

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

Happens-before (устоявшегося русского перевода нет, на собесе так и говорят по-английски) — и есть механизм запроса. Формально это отношение между событиями программы: если событие A happens-before события B, то всё, что горутина записала до A, гарантированно видно той горутине, которая выполняет B и всё, что после него. Чего в определении нет, так это времени. «A happens-before B» не означает «A случилось раньше по часам». Означает: «результаты A обязаны быть видны в B». Отношение это частичное: для двух произвольных событий из разных горутин его может не быть ни в одну сторону, и тогда порядок между ними просто не определён.

Взяться такому обещанию неоткуда, кроме как из конкретного списка операций, которые его дают (их называют рёбрами happens-before): запуск горутины, отправка и приём в канале, close, Unlock перед следующим Lock, Once.Do, WaitGroup.Wait, операции sync/atomic. Всё, чего в списке нет, не гарантирует ничего — в том числе time.Sleep, runtime.Gosched() и рассуждение «ну она же стартовала раньше».

Что именно ломается без ребра — на схеме ниже. Горутина A записывает данные и поднимает флаг; горутина B крутится на флаге и потом читает данные. Отказать может любое из трёх звеньев: компилятор поменяет местами две записи в A, потому что между ними нет зависимости по данным; компилятор в B прочитает ready один раз, положит в регистр и превратит цикл в вечный; процессор доставит запись ready раньше записи data. Симптом при этом самый противный из возможных: на ноутбуке и под -race всё зелено, а в проде на другой архитектуре раз в сутки прилетает ноль вместо значения.

Без синхронизации: компилятор и процессор вправе переставить записи // горутина A data = 42 ready = true компилятор может поменять строки местами: между ними нет зависимости по данным // горутина B for !ready {} use(data) увидит ready = true, но data может быть 0: запись висит в store buffer чужого ядра нет ребра С синхронизацией: канал создаёт ребро happens-before data = 42 ch <- struct{}{} всё, что записано ДО отправки, видно после приёма <-ch use(data) // гарантированно 42 приём синхронизирует память обеих горутин happens -before
Happens-before. Синхронизация разводит горутины по времени, но главное не это: она задаёт порядок видимости записей. Без ребра happens-before вторая горутина имеет право не увидеть данные вообще или увидеть их в другом порядке.
Что гарантирует Go memory model
  • Внутри одной горутины всё выполняется так, будто по порядку исходника (sequential consistency для самой себя).
  • Между горутинами порядок задают только рёбра happens-before: старт горутины, отправка/приём в канале, закрытие канала, Unlock → следующий Lock, Once.Do, WaitGroup.Wait, а с Go 1.19 — явно и операции sync/atomic (они sequentially consistent).
  • Программа с data race не имеет определённого поведения. В Go гонка не «даёт случайное число», как в C: спецификация обещает, что она не разрушит память рантайма, но само значение может быть любым, в том числе разорванным по половинкам для 64-битных типов на 32-битных платформах и для интерфейсов.

Канал против мьютекса

ЗадачаИнструментПочему
Защитить поле/структуру от одновременного доступаMutexканал тут в 3–5 раз дороже и запутывает
Счётчик, флаг, версия конфигаatomicдешевле всех, одна инструкция
Передать владение данными между горутинамиканалпередача + синхронизация одной операцией
Раздать работу N воркерамканалестественная очередь, FIFO, close как сигнал конца
Сигнал отмены/завершенияканал (ctx.Done())работает в select, broadcast через close
Ограничить число одновременных операцийканал-семафор или semaphore.Weightedёмкость = лимит
Кэш, мапа, пул соединенийMutex + mapsync.Map оправдана редко
Ждать завершения группы горутинWaitGroup / errgroupканал потребовал бы ручного подсчёта
Как это звучит на собесе

«Don't communicate by sharing memory; share memory by communicating» — это про проектирование, а не про запрет мьютексов. Rob Pike в том же выступлении говорит: используйте то, что понятнее. Мой критерий простой: если данные передаются — канал; если данные охраняются и живут на месте — мьютекс; если это одно число или указатель — atomic. В стандартной библиотеке Go мьютексов в разы больше, чем каналов, и это не случайность.

sync.Cond — редкий гость

// Cond = мьютекс + очередь ожидания «изменилось состояние».
type Queue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []int
}

func NewQueue() *Queue {
    q := &Queue{}
    q.cond = sync.NewCond(&q.mu)
    return q
}

func (q *Queue) Push(v int) {
    q.mu.Lock()
    q.items = append(q.items, v)
    q.mu.Unlock()
    q.cond.Signal()             // разбудить одного ждущего
}

func (q *Queue) Pop() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    for len(q.items) == 0 {     // цикл, а не if: возможны ложные пробуждения
        q.cond.Wait()           // отпускает лок, спит, при пробуждении берёт лок обратно
    }
    v := q.items[0]
    q.items = q.items[1:]
    return v
}
Почему Cond почти не используют
  • Не работает с select. Нельзя ждать «Cond или отмену контекста» — а это нужно почти всегда. Единственный обходной путь — отдельная горутина, которая по отмене дёргает Broadcast.
  • Не отменяем. Горутина в Wait ждёт до победного.
  • Легко ошибиться: Wait обязан стоять в цикле проверки условия, Signal/Broadcast — рядом с изменением состояния, копировать Cond нельзя.
  • Есть замена: канал (для сигнала), WaitGroup (для «дождаться всех»), errgroup, semaphore.Weighted. Реальные применения Cond — очередь с ограниченной ёмкостью и множеством ждущих разных условий, например внутри пула соединений.

Вопросы

13
Суть: внутри — state int32 с битами locked/woken/starving/waiters и семафор рантайма. Fast path — один CAS. Мьютекс не реентерабельный: повторный Lock из той же горутины блокирует её навсегда.

Путь захвата

  1. CAS(state, 0, locked) — успех, ~15–25 нс, дальше ничего не происходит.
  2. Спин. Занят, но normal mode и есть свободные ядра — крутимся до 4 раз по 30 инструкций PAUSE. Дешевле, чем парковка, если владелец вот-вот отпустит.
  3. Парковка. runtime_SemacquireMutex: горутина в _Gwaiting, поток ОС свободен и берёт другую работу. Это семафор рантайма, а не futex потока.
  4. Пробуждение. В normal mode разбуженная горутина конкурирует с новичками и может проиграть: новички уже на CPU. В starvation mode Unlock отдаёт лок ей напрямую.

Два режима и порог 1 мс

  • Normal: максимальная пропускная способность. Лок достаётся тому, кто ближе — кэш горячий, переключений меньше. Риск: старая горутина в очереди голодает.
  • Starvation: включается, когда какая-то горутина прождала больше 1 мс (starvationThresholdNs). Строгий FIFO, спиннинг выключен, новые горутины сразу в хвост. Пропускная способность падает, зато хвост латентности перестаёт расти.
  • Выход обратно в normal: очередь опустела либо очередной ждущий провёл в ней меньше 1 мс.

Повторный Lock

var mu sync.Mutex
mu.Lock()
mu.Lock()      // здесь горутина уходит в _Gwaiting навсегда

У мьютекса нет поля «владелец» — он не знает, кто его взял, и не может пропустить «своего». Горутина встаёт в очередь ожидания самой себя. Если это была единственная живая горутина, рантайм напечатает fatal error: all goroutines are asleep - deadlock!; если в программе есть другая работа — просто тихо зависнет одна горутина, и это утечка.

Как ловят на практике

Реальная самоблокировка почти никогда не выглядит как два Lock подряд. Она выглядит так: публичный метод берёт лок и вызывает другой публичный метод того же типа, который тоже берёт лок. Лечение — разделить на публичный (берёт лок) и приватный ...Locked() (предполагает, что лок уже взят). Именно поэтому в Go нет реентерабельного мьютекса: он маскировал бы кривую структуру кода. Рекурсивный лок сделал бы допустимым состояние «функция вызвана посреди чужой критической секции с полуобновлёнными инвариантами».

Суть: выигрывает только при долгих критических секциях и подавляющем перевесе чтений. На коротких секциях обычный Mutex быстрее. Write starvation в Go закрыта: писатель блокирует новых читателей.

Как устроен

Внутри — Mutex w (очередь писателей между собой), readerCount int32, readerWait int32 и два семафора. RLock: атомарный инкремент readerCount; если результат неотрицательный — сразу внутрь. Lock: берём w, потом вычитаем из readerCount константу rwmutexMaxReaders = 1<<30, делая счётчик отрицательным, и ждём, пока уже вошедшие читатели выйдут.

Когда даёт выигрыш

  • Читателей на порядок больше писателей и критическая секция длинная (десятки микросекунд и больше): парсинг, обход структуры, сериализация под локом.
  • Не даёт выигрыша на «прочитать одно поле»: у RWMutex вдвое больше атомарных операций, чем у Mutex, и на секции в 20 нс накладные расходы съедают весь параллелизм.
  • Бывает и хуже при большом числе ядер: readerCount лежит в одной кэш-линии, и десятки ядер, инкрементящих её, устраивают cache line bouncing. В таких случаях выигрывает шардирование (свой мьютекс на каждый шард) или атомарный снимок через atomic.Pointer (copy-on-write).

Write starvation

Если пускать читателей всегда, пока readerCount > 0, сплошной поток чтений закроет писателю дорогу навсегда. Go решает это тем, что Lock сразу уводит readerCount в минус: все новые RLock уходят спать, писатель дожидается только тех, кто уже внутри. Голодания писателя нет. Обратная сторона: при плотном потоке писателей подтормаживают уже читатели, но это честная очередь.

Два способа задедлочить RWMutex
// 1. Рекурсивный RLock. Если между строками встрял писатель:
mu.RLock()
mu.RLock()      // блокируется: писатель уже увёл readerCount в минус
                // а писатель ждёт нас -> deadlock

// 2. «Апгрейд» блокировки — его не существует
mu.RLock()
mu.Lock()       // Lock ждёт выхода читателей, включая нас самих
// Правильно: RUnlock -> Lock -> ПЕРЕПРОВЕРИТЬ состояние, оно могло измениться
Практическое правило

Начинай с Mutex. Переходи на RWMutex, только когда бенчмарк показал контенцию на чтении (профиль -blockprofile или mutexprofile). Замена «на всякий случай» в половине случаев замедляет код, и на собесе за фразу «RWMutex всегда лучше, если есть чтения» снимают балл.

Суть: копия уносит снимок состояния. Скопировали залоченный — получили вечно залоченную копию; скопировали разлоченный — получили два независимых замка на одни данные, то есть отсутствие синхронизации.

Что именно ломается

  • Копия залоченного. В state стоит бит locked, но отпускать эту копию никто не будет — оригинал разблокирует себя, а не её. Первый же Lock на копии виснет навсегда.
  • Копия разлоченного. Две горутины берут разные мьютексы и спокойно лезут в одни и те же данные. Синхронизации нет, а выглядит код правильным — race detector такое поймает, а глаз обычно нет.
  • Счётчик waiters тоже копируется: у копии он говорит, что кто-то ждёт, хотя на семафоре копии не спит никто. Unlock попытается разбудить несуществующего ждущего.

Где копирование происходит незаметно

type S struct {
    mu sync.Mutex
    n  int
}

func (s S) Bad() {}            // 1) метод на значении копирует при каждом вызове
func f(s S) {}                 // 2) аргумент по значению
func g() S { return S{} }      // 3) возврат по значению (пустой можно, живой нельзя)
var a, b S; a = b              // 4) присваивание
m := map[string]S{}            // 5) значения мапы вообще неадресуемы
for _, s := range []S{{}, {}} {}  // 6) range копирует элемент
var i any = s                  // 7) упаковка в интерфейс копирует
go f(s)                        // 8) аргумент go-вызова копируется в момент запуска

go vet copylocks

Анализатор copylocks входит в базовый набор go vet, но в урезанный набор, который go test гоняет сам, не входит: его запускают явно, go vet ./... в CI. Он ищет копирование любого типа, реализующего sync.Locker или содержащего такой тип на любой глубине: Mutex, RWMutex, WaitGroup, Cond, Once, Pool, atomic.Int64 и остальные типы atomic из Go 1.19. Сообщение выглядит как call of f copies lock value: S contains sync.Mutex.

Как это работает и как повторить у себя

Механика — пустая структура noCopy с методами-заглушками Lock() и Unlock(). Она не делает ничего в рантайме, занимает 0 байт и существует ровно для того, чтобы vet увидел sync.Locker. Тот же приём доступен и тебе: объяви type noCopy struct{} с этими двумя методами, положи в свою структуру, и vet начнёт запрещать её копирование. Так помечают, например, типы с внутренними указателями на самих себя. Оговорка: это только статическая проверка, компилятор копирование не запрещает.

Правило

Тип, содержащий мьютекс, — всегда указательный: методы на *T, передача как *T, в мапах и слайсах храни *T. И держи go vet в CI: эта ошибка почти никогда не ловится на code review, зато vet находит её за секунду.

Суть: счётчик плюс семафор. Add — строго до go, Done — строго через defer, передавать — только указателем. В Go 1.25 появился wg.Go(f), который делает всё это за вас.

Как устроена

Три значения: счётчик активных задач, счётчик ждущих в Wait и семафор. Add(n) прибавляет к счётчику, Done() это Add(-1). Когда счётчик доходит до нуля, все ждущие в Wait просыпаются через runtime_Semrelease. Счётчик ушёл в минус: паника sync: negative WaitGroup counter.

Четыре классические ошибки

// 1) Add внутри горутины даёт гонку: Wait может увидеть 0 раньше старта
for _, u := range urls {
    go func() { wg.Add(1); defer wg.Done(); fetch(u) }()   // неправильно
}
wg.Wait()   // может вернуться мгновенно, ничего не дождавшись

// 2) WaitGroup скопирован: у функции своя копия счётчика
func work(wg sync.WaitGroup) { defer wg.Done() }           // неправильно
func work(wg *sync.WaitGroup) { defer wg.Done() }          // правильно

// 3) Done без defer: паника внутри горутины навсегда подвесит Wait
go func() { fetch(u); wg.Done() }()                        // неправильно

// 4) WaitGroup переиспользуют до возврата Wait
wg.Add(1); go f(); go func(){ wg.Wait() }(); wg.Add(1)     // неправильно:
// panic: WaitGroup is reused before previous Wait has returned

Правильный шаблон и Go 1.25

// До 1.25
var wg sync.WaitGroup
for _, u := range urls {
    wg.Add(1)
    go func() {
        defer wg.Done()
        fetch(u)
    }()
}
wg.Wait()

// Go 1.25: Add, go и Done в одном вызове
var wg sync.WaitGroup
for _, u := range urls {
    wg.Go(func() { fetch(u) })
}
wg.Wait()

wg.Go(f) внутри делает wg.Add(1), запускает горутину и гарантирует wg.Done() через defer — то есть ошибки 1, 3 и частично 2 становятся невозможными. Панику внутри f он не перехватывает: она по-прежнему роняет процесс, но счётчик при этом корректно уменьшается.

Чем добить
  • Начиная с Go 1.22 переменная цикла — своя на каждую итерацию, поэтому go func(){ fetch(u) }() больше не требует передачи u параметром. На старых версиях это была ошибка номер один.
  • WaitGroup умеет только «дождаться всех» — он не собирает ошибки и не умеет отменять. Как только нужны ошибки или отмена по первой из них, берут errgroup.Group.
  • Внутри Go 1.25 WaitGroup ещё и переписан на единое атомарное 64-битное состояние с интеграцией в отладчик горутин — но на поведение это не влияет.
  • Ошибку №1 теперь ловит инструмент: с Go 1.25 в go vet входит анализатор waitgroup, который на go func(){ wg.Add(1); … }() выдаёт WaitGroup.Add called from inside new goroutine. А переписать старый тройной ритуал на wg.Go автоматически умеет go fix: в Go 1.27 у него есть модернизатор waitgroupgo (в 1.26 он назывался просто waitgroup).
Суть: errgroup.Group — это WaitGroup плюс сбор первой ошибки плюс автоматическая отмена общего контекста при этой ошибке плюс ограничение параллелизма через SetLimit.
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([]Result, error) {
    g, ctx := errgroup.WithContext(ctx)   // ctx отменится при первой ошибке
    g.SetLimit(8)                          // максимум 8 одновременных горутин
    results := make([]Result, len(urls))   // пишем по индексу, гонки нет

    for i, u := range urls {
        g.Go(func() error {
            r, err := fetch(ctx, u)        // ctx уже отменяемый
            if err != nil {
                return fmt.Errorf("fetch %s: %w", u, err)
            }
            results[i] = r
            return nil
        })
    }
    if err := g.Wait(); err != nil {       // вернёт первую ошибку
        return nil, err
    }
    return results, nil
}

Что даёт сверх WaitGroup

  • Ошибки. g.Go принимает func() error, а g.Wait() возвращает первую ненулевую (остальные отбрасываются).
  • Отмена по первой ошибке. errgroup.WithContext возвращает производный контекст; как только любая функция вернула ошибку, он отменяется, и все остальные горутины должны сами это увидеть по ctx.Done().
  • SetLimit(n). Встроенный семафор на канале ёмкости n. g.Go при исчерпании лимита блокируется, пока не освободится слот; g.TryGo — неблокирующая версия, возвращает false. SetLimit нужно вызывать до первого Go.
  • Паника не перехватывается. recover() внутри errgroup нет, и паника в любой горутине группы роняет процесс — Wait до неё не доживёт. Перехват недолго существовал в x/sync v0.14–v0.15 и был откачен сознательно. Если фоновая работа может паниковать, recover пишут сами, внутри переданной в Go функции.
Что здесь легко сделать неправильно
  • Затенение контекста. g, ctx := errgroup.WithContext(ctx) — заметь: ctx переприсваивается. Если использовать внутри горутин внешний контекст, отмена по первой ошибке работать не будет.
  • Ошибка одна. Если нужны все ошибки, errgroup не подходит: собирай их сам в слайс под мьютексом (или в срез по индексу) и склеивай через errors.Join.
  • Отмена — кооперативная. Контекст отменится, но горутина, которая его не проверяет, продолжит работать. g.Wait() дождётся всех, а не вернётся сразу при первой ошибке.
  • Запись в общий слайс. Писать по разным индексам заранее выделенного слайса безопасно и это идиома. Писать в мапу или делать append — гонка, нужен мьютекс или канал.
Суть: флаг done плюс мьютекс. Быстрый путь — одно атомарное чтение без лока; медленный — мьютекс с двойной проверкой. Флаг выставляется после завершения функции, поэтому опоздавшие честно дожидаются инициализации.
type Once struct {
    done atomic.Uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {        // fast path: ~1 нс, без лока
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {        // double-check уже под локом
        defer o.done.Store(1)      // после f, а не до
        f()
    }
}

Три вопроса, которые задают следом

  • Почему done ставится после f? Если бы до, вторая горутина увидела бы done == 1 и пошла работать с наполовину проинициализированным объектом. Сейчас она блокируется на мьютексе и выходит из Do только когда f реально закончила. Это и есть гарантия «после возврата из Do инициализация завершена».
  • Что если f запаникует? done всё равно выставится (это defer), и повторный Do ничего не сделает. Логика «повторить при ошибке» через Once не выражается — нужен singleflight или собственный флаг.
  • Почему нельзя просто if !inited { init(); inited = true }? Это data race: два потока увидят false и вызовут init дважды, а без синхронизации ещё и не увидят чужой записи вообще.

Обёртки Go 1.21

// OnceFunc: просто «выполнить один раз»
initLog := sync.OnceFunc(func() { setupLogging() })

// OnceValue: ленивое значение
config := sync.OnceValue(func() *Config { return mustLoadConfig() })
cfg := config()                        // первый вызов грузит, остальные возвращают готовое

// OnceValues: значение и ошибка
loadDB := sync.OnceValues(func() (*sql.DB, error) { return sql.Open(...) })
db, err := loadDB()
Где применяют и где не стоит
  • По делу: ленивое подключение к внешнему сервису, компиляция регулярки, загрузка конфига, разовая регистрация метрик, singleton в библиотеке.
  • Не стоит: как замена нормальному конструктору. Если объект можно инициализировать явно в main и передать зависимостью — так и надо делать: Once прячет момент инициализации и её ошибки, усложняет тесты и делает порядок запуска неявным.
  • Копировать Once нельзяgo vet ругается, а скопированный «уже выполненный» Once выполнится второй раз.
Суть: кэш временных объектов, чтобы снизить давление на GC. Ключевая особенность — содержимое очищается при каждом цикле GC, с одним поколением отсрочки (victim cache). Это не пул ресурсов и не средство ограничения.

Как устроен

У каждого P — свой poolLocal с приватным слотом (доступ вообще без синхронизации) и локальной очередью, из которой другие P могут воровать. Поэтому Get/Put почти бесплатны и хорошо масштабируются по ядрам. Если ничего не нашлось — вызывается New.

Связь с GC — самое важное

  1. При старте цикла GC хук poolCleanup проходит по всем пулам.
  2. Текущие локальные наборы переезжают в victim-кэш, а старые victim выбрасываются насовсем.
  3. Get сначала смотрит в свой primary, потом ворует у соседей, потом лезет в victim, и только потом зовёт New.

Итог: объект живёт в пуле максимум два цикла GC. Victim-кэш появился в Go 1.13 именно чтобы убрать провал производительности сразу после сборки, когда пул опустошался целиком и все шли в New.

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func render(w io.Writer, data any) error {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()                       // обязательно: в пуле лежит грязный объект
    defer bufPool.Put(buf)
    if err := tmpl.Execute(buf, data); err != nil {
        return err
    }
    _, err := w.Write(buf.Bytes())
    return err
}

// Для слайсов кладут указатель, иначе каждый Put аллоцирует
var slicePool = sync.Pool{New: func() any { s := make([]byte, 0, 4096); return &s }}
Пять подводных камней
  1. Объект грязный. Get может вернуть что угодно из прошлой жизни. Не сбросили — утечка данных между запросами, вплоть до чужих персональных данных в ответе. Это реальная уязвимость, а не теория.
  2. Put после того, как отдали наружу. Если buf.Bytes() уехал в структуру, которая переживёт defer Put, ты вернул в пул память, на которую есть живая ссылка, и следующий Get перезапишет её.
  3. Класть в пул слайс (не указатель) — аллокация. Put(any) упаковывает значение в интерфейс; для не-указателя это боксинг в кучу, то есть ровно то, от чего мы уходили.
  4. Растущие объекты. Если в пул возвращать буферы после обработки гигантского запроса, пул начнёт хранить их, и память вырастет. Лечение — не класть обратно, если cap превысил порог.
  5. Это не пул соединений. Нет ограничения количества, нет времени жизни, содержимое исчезает при GC. Для соединений — database/sql, http.Transport или свой пул на канале.
Когда действительно помогает

Профиль показывает много аллокаций одинакового размера, которые живут недолго и повторяются часто: буферы сериализации, []byte под тело запроса, промежуточные структуры парсера, gzip.Writer. Именно так sync.Pool используется в encoding/json, net/http и fmt. Если аллокаций мало или объекты живут долго — пул только усложнит код. Мерять обязательно: go test -bench . -benchmem до и после.

Суть: две мапы — неизменяемая read под atomic.Pointer для чтений без лока и dirty под мьютексом для новых ключей. Оправдана в двух узких сценариях; в остальных обычная мапа под RWMutex или шардированная мапа быстрее и удобнее.

Механика

  • read — структура readOnly{m map[K]*entry, amended bool}, заменяемая целиком одним atomic.Store. Чтение существующего ключа — атомарная загрузка указателя и обычный доступ к мапе. Лока нет вообще.
  • amended = «в dirty есть ключи, которых нет в read». Если false и ключ не найден в read — можно сразу возвращать «нет», не трогая мьютекс.
  • dirty — обычная мапа под мьютексом. Содержит все актуальные ключи. Все новые ключи попадают только сюда.
  • misses — счётчик промахов мимо read. Когда misses >= len(dirty), происходит промоушен: dirty становится новой read, dirty = nil, misses = 0.
  • Следующая запись нового ключа заново копирует всю read в dirty — операция O(n) под мьютексом.
  • Delete не удаляет сразу: помечает entry как expunged, физическая уборка происходит при очередном пересоздании dirty.
  • Обновление существующего ключа идёт через CAS прямо в *entry, то есть тоже без лока — read и dirty ссылаются на один и тот же entry.

Два сценария, где она оправдана

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

Почему обычно хуже mutex+map

sync.Mapmap + RWMutexшардированная мапа
Типизацияany, нужен каст, боксингполнаяполная
lenнет, только полный Rangeестьсумма по шардам
Композитные операциитолько LoadOrStore/CASлюбые под локомлюбые под локом шарда
Много новых ключейплохо: копирование O(n)нормальнохорошо
Чтение при стабильных ключахотличносреднехорошо
Понятность кодасредняямаксимальнаясредняя
Что изменилось в Go 1.24

Внутренности sync.Map переписали с пары read/dirty на HashTrieMap — конкурентное хеш-дерево, где узлы обновляются локально и не требуют копирования всей мапы. Запись новых ключей стала кратно быстрее, а провалы на смешанной нагрузке ушли. API не изменился, нетипизированность осталась. Хороший ответ на собесе упоминает и классическое устройство (его спрашивают чаще), и то, что в актуальных версиях оно уже другое.

Как отвечать

«По умолчанию беру map под Mutex — это понятно и типизировано. Если профиль показывает контенцию и нагрузка read-heavy со стабильным набором ключей, смотрю на sync.Map. Если ключей много и они постоянно меняются — делаю шардирование по хешу на 64–256 мап. sync.Map — специализированный инструмент, а не быстрая мапа на все случаи.»

Суть: одна процессорная инструкция с блокировкой кэш-линии вместо протокола захвата лока — 5–10 нс против 15–25 нс без конкуренции и в разы лучше под нагрузкой. Но атомарна только одна операция, а не последовательность из них.

Что есть в пакете

ОперацияСмысл
Load / Storeатомарное чтение и запись целиком, без разрывов
Addприбавить и вернуть новое значение
Swapзаписать новое, вернуть старое
CompareAndSwapзаписать, только если сейчас лежит ожидаемое; основа lock-free алгоритмов
atomic.Valueатомарная подмена значения любого типа (тип должен быть один и тот же)
atomic.Pointer[T]типизированный указатель, Go 1.19, без unsafe

Типы Go 1.19 вместо функций

// Старый API: легко случайно обратиться неатомарно
var n int64
atomic.AddInt64(&n, 1)
fmt.Println(n)                 // 1, но это гонка: обычное чтение переменной,
                               // которую пишут атомарно. -race поймает

// Новый API: неатомарного доступа просто нет
var n atomic.Int64
n.Add(1)
fmt.Println(n.Load())          // 1, и прочитать можно только так

Типы atomic.Int32/Int64/Uint32/Uint64/Bool/Pointer[T]/Value закрывают сразу три дыры: нельзя обратиться к полю напрямую, гарантировано 8-байтное выравнивание (на 32-битных платформах старый API требовал ручной заботы, иначе паника), и go vet ловит копирование через встроенный noCopy.

atomic.Value: горячая перезагрузка конфига

var cfg atomic.Pointer[Config]

func reload() {                     // писатель: раз в минуту
    c := loadFromFile()
    cfg.Store(c)                    // подменяем указатель целиком
}

func handle(r *http.Request) {      // читатели: тысячи в секунду
    c := cfg.Load()                 // ~1 нс, ноль контенции
    use(c.Timeout)
}

Это copy-on-write: конфиг не мутируют на месте, а собирают новый объект и атомарно подменяют указатель. Читатели вообще не синхронизируются между собой, старый объект живёт, пока на него ссылается хоть один обработчик, и потом его забирает GC. Для read-heavy данных это быстрее любого RWMutex.

Ложное чувство безопасности
// 1) Проверка и действие — две операции. Между ними всё может измениться.
if counter.Load() < limit {
    counter.Add(1)                 // лимит будет превышен под нагрузкой
}
// Правильно: цикл CAS
for {
    old := counter.Load()
    if old >= limit { return errLimit }
    if counter.CompareAndSwap(old, old+1) { return nil }
}

// 2) Два атомарных поля — не атомарная пара
balance.Store(newBalance)
version.Store(newVersion)          // между строками кто-то прочитает несогласованное состояние
// Правильно: один atomic.Pointer на неизменяемую структуру со ВСЕМИ полями

// 3) atomic.Value с разными типами
var v atomic.Value
v.Store(&ConfigA{})
v.Store(&ConfigB{})                // panic: sync/atomic: store of inconsistently typed value
// И Store(nil) тоже паникует — в отличие от atomic.Pointer[T]
Формулировка про производительность

«Атомик быстрее не потому, что “без лока”, а потому что вся синхронизация — это одна инструкция вроде LOCK XADD, которая удерживает кэш-линию на время своего выполнения. Мьютекс в лучшем случае делает тот же CAS плюс проверки, а в худшем — парковку горутины и переключение. Но под высокой конкуренцией атомик тоже упирается в шину: десять ядер, инкрементящие одну переменную, гоняют кэш-линию между собой, и это может быть медленнее, чем шардированный счётчик или локальные счётчики с периодическим сведением.»

Суть: буферизированный канал ёмкости N — это готовый счётный семафор: отправка = Acquire, приём = Release. Для взвешенных захватов и отмены по контексту есть semaphore.Weighted.

Семафор на канале

sem := make(chan struct{}, 10)          // не больше 10 одновременно

for _, task := range tasks {
    sem <- struct{}{}                   // Acquire: блокируется, если 10 уже работают
    go func() {
        defer func() { <-sem }()        // Release только через defer
        process(task)
    }()
}
// дождаться остатка: заполнить семафор целиком
for i := 0; i < cap(sem); i++ { sem <- struct{}{} }

Ёмкость канала и есть лимит. Плюсы: ноль зависимостей, работает в select, значит легко добавить таймаут и отмену:

select {
case sem <- struct{}{}:                 // получили слот
    defer func() { <-sem }()
case <-ctx.Done():
    return ctx.Err()                    // не дождались слота, уходим
}

Важная деталь: где ставить Acquire

// Плохо: горутина создаётся сразу, лимит ограничивает только «работающих»
for _, t := range tasks {
    go func() {
        sem <- struct{}{}               // миллион горутин уже создан и спит здесь
        defer func() { <-sem }()
        process(t)
    }()
}

// Хорошо: Acquire до go, горутину не создаём, пока нет слота
for _, t := range tasks {
    sem <- struct{}{}
    go func() { defer func() { <-sem }(); process(t) }()
}

semaphore.Weighted

import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(100)       // 100 условных единиц ресурса

func handle(ctx context.Context, req Request) error {
    w := int64(req.SizeMB)              // тяжёлый запрос занимает больше
    if err := sem.Acquire(ctx, w); err != nil {
        return err                      // контекст отменён или истёк
    }
    defer sem.Release(w)
    return process(req)
}
Канал-семафорsemaphore.Weighted
Вес захватавсегда 1любой int64
Отмена по контекстувручную через selectвстроена в Acquire(ctx, n)
Неблокирующая попыткаselect/defaultTryAcquire
СправедливостьFIFO очереди каналаFIFO, но большой вес ждёт накопления слотов
Зависимостинетx/sync
На чём ловят
  • Release без defer. Паника или ранний return — и слот потерян навсегда; после N таких случаев семафор закрыт наглухо.
  • Release чужого веса в Weighted — паника semaphore: released more than held.
  • Путают семафор и worker pool. Семафор ограничивает одновременность, но горутина на каждую задачу всё равно создаётся. Worker pool ограничивает и одновременность, и число горутин. Для миллиона мелких задач нужен пул, для сотни тяжёлых — семафор проще.
  • Забывают, что errgroup.SetLimit — это тот же семафор на канале, уже написанный за тебя.
Суть: канал — когда данные передаются между горутинами; мьютекс — когда данные охраняются на месте; atomic — когда это одно число или указатель. Мантра «share memory by communicating» — про проектирование, а не про запрет мьютексов.

Критерии

Признак задачиВыбор
Передача владения данными, конвейер, очередь задачканал
Сигнал отмены/завершения, broadcastканал (через close)
Ожидание нескольких событий одновременноканал + select
Ограничение одновременностиканал-семафор
Защита состояния структуры (кэш, мапа, счётчики полей)мьютекс
Короткая критическая секция в горячем путимьютекс
Один счётчик, флаг, версия, указатель на конфигatomic
Дождаться группы горутинWaitGroup / errgroup

Что говорит официальный Go

Слоган Роба Пайка «Do not communicate by sharing memory; instead, share memory by communicating» — про то, что владение данными в каждый момент должно принадлежать одной горутине, а передавать его надо явной операцией. Но в том же Go Wiki и в комментариях к sync прямо написано: используйте мьютекс там, где он естественнее. Стандартная библиотека полна мьютексов (net/http, database/sql, runtime).

Что типично в реальных проектах

  • Мьютекс/atomic — внутри типов: кэши, пулы, метрики, реестры, состояние соединения. Их больше по количеству.
  • Канал — на границах между компонентами: очередь задач воркерам, конвейер обработки, сигналы завершения, события.
  • Гибрид — норма. Worker pool: задачи через канал, счётчики результатов через atomic, общая мапа результатов под мьютексом.
  • Цена. Канал ~25–80 нс на операцию, мьютекс ~15–25 нс, атомик ~5–10 нс. Разница важна только в горячих путях, но там она решает.
Как формулировать на собесе

«Я задаю себе один вопрос: данные едут или лежат? Если едут от горутины к горутине — канал: он переносит и данные, и синхронизацию, и сигнал конца одной конструкцией. Если лежат в структуре и к ним ходят — мьютекс, он дешевле и понятнее. Канал вокруг обычной мапы — это переусложнение: получится актор с единственной горутиной-владельцем, который упрётся в один поток и будет медленнее мьютекса.»

Антипаттерн, который любят показывать
// Счётчик «по-гошному» через канал: в разы медленнее и в разы сложнее
type Counter struct {
    ops chan func(*int)
}
func (c *Counter) Inc() { c.ops <- func(n *int) { *n++ } }

// То же самое, как надо
type Counter struct {
    n atomic.Int64
}
func (c *Counter) Inc() { c.n.Add(1) }
Суть: условная переменная — очередь горутин, ждущих изменения состояния, привязанная к мьютексу. Wait отпускает лок и засыпает, Signal/Broadcast будят одного или всех. В Go используется редко, потому что не дружит с select и context.

API и обязательный шаблон

c := sync.NewCond(&mu)

// Ждущая сторона проверяет в цикле, не через if
mu.Lock()
for !condition() {          // проверять условие в цикле обязательно
    c.Wait()                // атомарно отпускает mu и засыпает; проснувшись, снова берёт mu
}
useState()
mu.Unlock()

// Сигналящая сторона
mu.Lock()
changeState()
mu.Unlock()
c.Signal()                  // будит одного; Broadcast будит всех

Цикл вместо if нужен потому, что между пробуждением и повторным захватом мьютекса другая горутина может снова изменить состояние — проснувшийся обязан перепроверить условие сам.

Где Cond всё-таки уместен

  • Ограниченная очередь с двумя разными условиями («не пуста» для читателей, «не полна» для писателей) и множеством ждущих на каждом — каналами это выражается хуже.
  • Пул ресурсов: ждём освобождения любого из N объектов. Именно так Cond используется внутри некоторых драйверов БД.
  • Broadcast по изменению состояния, когда состояние сложное и его нельзя «передать» значением, — например, «конфигурация перезагружена».
Почему в 95% случаев берут не Cond
  • Не работает в select. Нельзя ждать «Cond или ctx.Done()» — а без отмены в проде нельзя. Обходной путь уродлив: отдельная горутина, которая по отмене контекста дёргает Broadcast, плюс проверка ctx.Err() в цикле условия.
  • Легко ошибиться: Wait без цикла, Signal без изменения состояния, Signal вместо Broadcast при нескольких разных условиях (разбудишь не того, и никто не проснётся никогда).
  • Копировать нельзя, go vet ругается.
  • Есть готовые замены: канал (сигнал), закрытие канала (broadcast), WaitGroup (дождаться всех), semaphore.Weighted (ждать ресурс с отменой), errgroup.
Как ответить, если спросят «использовал ли?»

Рабочий ответ: «В продакшн-коде — ни разу, и это нормально: практически любая задача, где хочется Cond, в Go решается каналом с отменой. Понимаю, как он работает и почему Wait обязан быть в цикле, но выбрал бы его только там, где нужно много ждущих на разных условиях под одним состоянием — например, в собственной ограниченной очереди.»

Суть: без синхронизации нет гарантии, что одна горутина вообще увидит записи другой и увидит их в том порядке, в котором они написаны. Порядок задают только рёбра happens-before: канал, мьютекс, Once, WaitGroup, atomic.

Что такое happens-before

Это частичный порядок между событиями программы. Если запись W happens-before чтение R и между ними нет других записей в ту же переменную, то R гарантированно видит W. Если такого ребра нет — R может увидеть что угодно: старое значение, новое, а на некоторых архитектурах и разорванное.

Откуда берутся рёбра

  • Внутри одной горутины — порядок исходного кода.
  • go f(): всё, что было до запуска, видно внутри новой горутины.
  • Отправка в канал happens-before завершения соответствующего приёма.
  • close(ch) happens-before приёма нулевого значения из-за закрытия.
  • n-й Unlock happens-before (n+1)-го Lock.
  • Once.Do(f): завершение f happens-before возврата из любого другого Do.
  • WaitGroup: все Done happens-before возврата Wait.
  • С Go 1.19 операции sync/atomic явно объявлены sequentially consistent — раньше их семантика была описана только «по факту реализации».

Почему bool-флаг не работает

var data int
var ready bool

// горутина A
data = 42
ready = true          // компилятор вправе поменять эти строки местами:
                      // зависимости по данным между ними нет

// горутина B
for !ready {}         // может крутиться вечно: компилятор имеет право
                      // прочитать ready один раз и закешировать в регистре
use(data)             // даже увидев ready==true, может прочитать data == 0

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

// Правильно: канал
data = 42
close(ready)      // ребро happens-before

<-ready
use(data)         // гарантированно 42
// Правильно: atomic
data = 42
flag.Store(true)

for !flag.Load() {}
use(data)         // гарантированно 42
Одной фразой на собесе

Развести горутины по времени — только половина дела. Вторая половина: вообще увидеть чужие записи и увидеть их согласованно. Именно поэтому «у меня записывает только одна горутина, а читают остальные — гонки нет» — неверное рассуждение: гонка есть, и она может проявиться как «читатель никогда не видит новое значение» или «видит новый флаг и старые данные».

Что Go гарантирует даже при гонке

В отличие от C++, где data race — undefined behavior и компилятор вправе сделать буквально что угодно, Go даёт ограниченную гарантию: гонка не разрушит целостность рантайма и не даст «значение из воздуха» для машинных слов. Но интерфейс, слайс, строка и int64 на 32-битной платформе состоят из нескольких слов, и прочитать их можно разорванными: тип от одного значения, указатель от другого. На первом же вызове метода это уронит программу. Так что практически разница с UB невелика: гонки надо чинить, а не оценивать по степени опасности.

2.5context

context передаёт вниз через десяток слоёв вызовов одну мысль: «прекращай, результат больше не нужен». Спрашивают почти всегда, потому что по ответу сразу видно, писал ли человек сервисы или только учебные задачи.

Сначала — три оборота, которые дальше идут без пояснений

Вопрос тут один и практический: как остановить работу, которая уже никому не нужна, сразу во всех местах, где она успела разбежаться. Ответ упирается в три слова, которые в статьях про context употребляют как общеизвестные. Договоримся о них сразу, дальше они встречаются на каждой странице.

1. Дерево контекстов — кто кому родитель

Контекст нельзя создать «просто так»: каждый конструктор (WithCancel, WithTimeout, WithValue) первым аргументом принимает родительский контекст и возвращает новый, привязанный к нему. Родитель запоминает ребёнка в своей карте children, ребёнок держит ссылку на родителя. Из этих связей и вырастает дерево контекстов: в корне сидит context.Background(), который не отменяется никогда, на уровень ниже по узлу на каждый входящий запрос, ещё ниже по узлу на каждый исходящий вызов внутри запроса. Дерево тут не метафора: это реальные поля в структуре cancelCtx, по которым рантайм ходит при отмене.

2. Распространение отмены — только сверху вниз

Распространение отмены (cancellation propagation) начинается, когда узел дерева отменяют: он закрывает свой канал Done() и рекурсивно отменяет всех своих детей, их детей и так далее до листьев. Направление строго одно, вниз. Отменённый ребёнок не трогает ни родителя, ни соседей, иначе таймаут одного запроса гасил бы весь сервис. Отсюда практический смысл всей конструкции: «отменить запрос целиком» значит отменить один узел, а пятнадцать горутин под ним узнают об этом сами, без единой строчки ручной передачи сигнала.

3. Request-scoped — «живёт ровно столько, сколько один запрос»

Request-scoped (устоявшегося русского перевода нет; дословно «в области видимости одного запроса») означает: данные принадлежат конкретному запросу и умирают вместе с ним. Request id, trace id, id пользователя, локаль — request-scoped. А пул соединений к БД, логгер и конфиг нет: они существовали до запроса и переживут его. Разница не косметическая, на ней стоит правило «WithValue — только для request-scoped данных, зависимости передаются явными параметрами»; почему именно так, сказано ниже, в разделе с правилами.

Зачем он нужен

Задача, ради которой context придуман, звучит так: пришёл HTTP-запрос, он породил вызов в сервисный слой, тот породил три параллельных запроса в БД и один во внешний API, а каждый из них свои горутины. Клиент отвалился через 200 мс. Кто и как остановит эти пятнадцать горутин и освободит соединения?

Без context каждый уровень пришлось бы связывать своим done-каналом и вручную пробрасывать вниз. Context и есть такой done-канал плюс дедлайн, плюс причина отмены, плюс небольшой набор request-scoped значений, оформленные в один интерфейс из четырёх методов:

type Context interface {
    Deadline() (deadline time.Time, ok bool)   // когда истечёт (если задан)
    Done() <-chan struct{}                      // закроется при отмене
    Err() error                                 // Canceled / DeadlineExceeded, nil пока жив
    Value(key any) any                          // request-scoped значение
}
context.Background() корень, никогда не отменяется req A: WithTimeout 2s cancel() вызван -- отменён req B: WithTimeout 2s живёт, ничего не заметил запрос в БД ctx.Done() закрыт вызов gRPC ctx.Done() закрыт запрос в БД работает вызов gRPC работает Отмена идёт ТОЛЬКО вниз по дереву: отменили родителя -- отменились все потомки, рекурсивно, за один проход. Вверх отмена не идёт никогда: дочерний cancel() не влияет ни на родителя, ни на соседей.
Дерево контекстов. Каждый WithCancel/WithTimeout подвешивает узел к родителю. Отмена распространяется строго вниз, и потому «отменить весь запрос одним вызовом» не задевает соседние запросы.

Что внутри

type cancelCtx struct Context встроенный родитель mu sync.Mutex защищает всё ниже done atomic.Value chan struct{} children map[canceler] кто отменится вместе со мной err error Canceled / DeadlineExceeded cause error Go 1.20: настоящая причина done создаётся лениво: если Done() не звали, канала нет вообще 1. Взять mu 2. Если err уже не nil — отменён ранее, ничего не делаем (идемпотентность) 3. Записать err и cause 4. close(done) — будит ВСЕХ, кто ждёт на ctx.Done() 5. Пройти по children и рекурсивно отменить каждого 6. Удалить себя из children родителя — иначе родитель растёт вечно Шаг 6 и есть причина правила «всегда defer cancel()»: без него узел висит в дереве до отмены родителя.
cancelCtx. Отмена сводится к close одного канала плюс рекурсивный обход детей. Остальное учёт: карта детей, идемпотентность и обязательное отцепление от родителя, ради которого и нужен defer cancel().
КонструкторЧто даётКогда применять
Background()пустой корень, не отменяетсяmain, инициализация, тесты верхнего уровня
TODO()то же самоезаглушка «сюда потом придёт настоящий ctx»; ищется грепом
WithCancel(p)отмена по явному вызовуфоновые задачи, конвейеры, остановка воркеров
WithTimeout(p, d)отмена через интервалзапрос к БД/API: «не дольше 2 секунд»
WithDeadline(p, t)отмена в момент времениобщий бюджет запроса, распределяемый между шагами
WithValue(p, k, v)request-scoped значениеrequest id, trace id, user id — и всё
WithCancelCause(p) 1.20отмена с указанием причиныкогда важно, почему отменили
AfterFunc(ctx, f) 1.21вызвать f при отменеуборка без собственной горутины-наблюдателя
WithoutCancel(p) 1.21копия значений без отменыдописать метрику/аудит после отмены запроса
Разница WithTimeout и WithDeadline на практике

WithTimeout(parent, d) разворачивается ровно в WithDeadline(parent, time.Now().Add(d)). Разница появляется, когда бюджет делится между шагами: если у тебя всего 1 секунда на весь запрос и три последовательных вызова, WithTimeout(ctx, time.Second) на каждом даст суммарно 3 секунды, а один WithDeadline сверху честно ограничит всё. И ещё: дедлайн наследуется, дочерний контекст не может иметь дедлайн позже родительского, лишний WithTimeout(ctx, time.Hour) внутри запроса с бюджетом 200 мс просто ничего не изменит.

context.Cause, AfterFunc и WithoutCancel

// Go 1.20: причина отмены. ctx.Err() скажет «что», context.Cause «почему».
ctx, cancel := context.WithCancelCause(parent)
go func() {
    if err := validate(req); err != nil {
        cancel(fmt.Errorf("невалидный запрос: %w", err))
    }
}()

<-ctx.Done()
ctx.Err()             // context.Canceled, всегда одно и то же, для логов бесполезно
context.Cause(ctx)    // "невалидный запрос: поле id пустое", это и пишем в лог

// Для дедлайнов тоже работает (Go 1.21: WithTimeoutCause / WithDeadlineCause)
ctx, cancel := context.WithTimeoutCause(parent, 2*time.Second, errDBSlow)
// при истечении: ctx.Err() == DeadlineExceeded, Cause(ctx) == errDBSlow

// Go 1.21: AfterFunc, колбэк при отмене без своей горутины
stop := context.AfterFunc(ctx, func() {
    conn.Close()          // выполнится в отдельной горутине при отмене ctx
})
defer stop()              // отменить регистрацию, если управились раньше

// Go 1.21: у WithoutCancel значения есть, отмены нет
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    doWork(ctx)
    // аудит пишем, даже если клиент отвалился
    go writeAudit(context.WithoutCancel(ctx), event)
}
Почему Cause добавили только в 1.20

Долгие годы ctx.Err() возвращал ровно два значения на всю экосистему: context.Canceled и context.DeadlineExceeded. В логах это выглядело как «context canceled» без единого намёка, кто и зачем отменил. Одна из самых частых жалоб на Go в проде. WithCancelCause закрывает вопрос: причина сохраняется в поле cause у самого верхнего отменённого узла и наследуется вниз, а ctx.Err() остаётся прежним ради обратной совместимости. В своих сервисах правило простое: отменяешь — объясняй.

Правила использования

Шесть правил, которые проверяют на ревью
  1. ctx — первый параметр, всегда с именем ctx: func Get(ctx context.Context, id int) (*User, error).
  2. Не хранить в полях структур. Контекст живёт столько же, сколько операция, а структура живёт дольше. Исключение одно и признано самим стандартом: http.Request, где контекст неотделим от запроса.
  3. Никогда не передавать nil. Если непонятно, что передать, ставь context.TODO().
  4. defer cancel() обязателен после любого WithCancel/WithTimeout/WithDeadline. Без него узел остаётся в children родителя, а таймер лежит в куче таймеров. go vet ловит это правилом lostcancel.
  5. WithValue — только для request-scoped данных: request id, trace id, user id, локаль. Не для зависимостей (логгер, БД, конфиг), их передают явно.
  6. Ключ WithValue — свой неэкспортируемый тип, а не строка: строковый ключ может совпасть с чужим и молча перетереть значение.
// Правильный ключ для WithValue
type ctxKey int
const (
    keyRequestID ctxKey = iota
    keyUserID
)

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, keyRequestID, id)
}

func RequestID(ctx context.Context) string {
    v, _ := ctx.Value(keyRequestID).(string)   // типобезопасный геттер
    return v
}

// Антипаттерн: зависимости через контекст
ctx = context.WithValue(ctx, "db", db)         // строковый ключ + зависимость = двойная ошибка
db := ctx.Value("db").(*sql.DB)                // паника при рефакторинге, ноль проверок компилятора
Бюджет запроса 500 мс распространяется вниз по слоям HTTP handler: ctx := r.Context() отменится сам, если клиент разорвал соединение или сервер закрывается service: ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) defer cancel() -- иначе таймер и узел дерева переживут запрос db.QueryContext(ctx, ...) драйвер шлёт отмену на сервер БД http.NewRequestWithContext(ctx, ...) Transport рвёт соединение при отмене Разрыв цепочки в любом месте — и отмена дальше не идёт: горутина доработает до конца, займёт соединение и вернёт результат, который уже никому не нужен. Отменяемость должна быть сквозной.
Сквозная отменяемость. Контекст полезен ровно настолько, насколько его пробросили. Один слой, принимающий context.Background() вместо переданного ctx, обнуляет всю цепочку выше.

context в net/http: с обеих сторон

В HTTP-сервисе контекст возникает в двух местах, и на собесе спрашивают про оба. На входе стоит r.Context(), который сервер отменяет сам. На выходе http.NewRequestWithContext, через который отмена уезжает к соседнему сервису. А между ними Server.Shutdown, который останавливает весь сервер.

// Вход: сервер сам отменяет r.Context(), когда
//   1) клиент разорвал соединение (HTTP/1.1) или сбросил стрим (HTTP/2, RST_STREAM);
//   2) ServeHTTP вернулся: тогда контекст отменяется в любом случае;
//   3) вызван Server.Shutdown или Server.Close.
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    res, err := slowQuery(ctx)          // отмена уедет в БД
    if err != nil {
        // клиент ушёл, писать в w уже некуда, но и паники не будет: запись просто теряется
        if errors.Is(err, context.Canceled) {
            log.Info("клиент отвалился, работа отменена")
            return
        }
        http.Error(w, "internal", 500)
        return
    }
    json.NewEncoder(w).Encode(res)
}

// Выход: контекст запроса едет в чужой сервис
func callBilling(ctx context.Context, id string) (*Bill, error) {
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, billingURL+id, nil)
    if err != nil {
        return nil, err
    }
    resp, err := httpClient.Do(req)     // при отмене Transport рвёт соединение
    if err != nil {
        return nil, fmt.Errorf("billing: %w", err)
    }
    defer resp.Body.Close()
    ...
}
Ловушка: горутина, пережившая хендлер

r.Context() отменяется, как только ServeHTTP вернул управление; обрыв клиента здесь лишь один из поводов. Поэтому «запущу фоновую задачу и отвечу 202» с go doWork(r.Context()) сломается сразу: задача получит отменённый контекст через доли миллисекунды. Правильно так: context.WithoutCancel(r.Context()) (Go 1.21) или отдельный контекст приложения с собственным таймаутом.

Остановка сервера по сигналу

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}

    // 1.16+: Notify-контекст вместо ручного канала сигналов
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatal(err)
        }
    }()

    <-ctx.Done()                        // пришёл SIGTERM
    // Go 1.26: NotifyContext отменяет через CancelCauseFunc, причина называет сигнал
    log.Info("останавливаемся", "причина", context.Cause(ctx))
    // Проверено запуском на go1.27 (программа послала SIGTERM сама себе):
    //   ctx.Err()           = context canceled      -- обезличено, как и раньше
    //   context.Cause(ctx)  = terminated signal received
    stop()                              // вернуть обработчик по умолчанию: второй Ctrl+C убьёт жёстко

    shutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()

    // Shutdown закрывает слушателя и ждёт активные запросы.
    // Контекст здесь задаёт дедлайн на ожидание, а не отменяет сами запросы.
    if err := srv.Shutdown(shutCtx); err != nil {
        log.Warn("не все запросы успели за 20с, рвём принудительно")
        srv.Close()
    }
}
Что именно делает Shutdown

Закрывает все слушающие сокеты (новых соединений сервер больше не принимает), закрывает все idle-соединения keep-alive, и ждёт, пока активные запросы доработают. Уже выполняющиеся хендлеры он не отменяет: их r.Context() отменится, только когда истечёт shutCtx. И Shutdown не ждёт перехваченные соединения (hijacked), где хендлер через http.Hijacker забрал себе сырой TCP-сокет и дальше говорит по нему сам, мимо net/http; так работают WebSocket и апгрейд протокола. Сервер про такое соединение уже ничего не знает, поэтому закрывать его приходится самому.

Go 1.26: по контексту стало видно, какой сигнал пришёл

Раньше из <-ctx.Done() было понятно только «что-то случилось»: ctx.Err() возвращал context.Canceled одинаково и для SIGTERM, и для Ctrl+C, и для собственного вызова stop(). С Go 1.26 signal.NotifyContext отменяет контекст через CancelCauseFunc, поэтому причину достаёт обычный context.Cause(ctx): он вернёт ошибку, в тексте которой назван сигнал (для SIGTERM это terminated signal received). Польза ровно одна, зато ощутимая: одна строка в логе перед остановкой, по которой потом видно, оркестратор погасил под штатно или кто-то нажал Ctrl+C руками.

Вопросы

9
Суть: context — стандартизированный способ передать вниз по стеку вызовов сигнал «прекращай» плюс дедлайн плюс несколько request-scoped значений. Контексты образуют дерево, отмена идёт строго сверху вниз и рекурсивно.

Задача, которую он решает

Один входящий запрос порождает десятки горутин: сервисный слой, три параллельных запроса к БД, вызов внешнего API, запись в кэш. Клиент отвалился через 200 мс, и вся эта работа стала бессмысленной, но продолжает жечь CPU, держать соединения из пула и место в памяти. Без общего механизма каждый слой пришлось бы связывать своим done-каналом и передавать его руками через все сигнатуры.

Context даёт ровно такой done-канал, только со стандартным интерфейсом из четырёх методов, который понимают database/sql, net/http, gRPC, драйверы Kafka и вообще вся экосистема. Ценна тут именно стандартизация: любая библиотека умеет отменяться одинаково.

Дерево

  • Корнем служит context.Background(), он никогда не отменяется и не имеет дедлайна. Это emptyCtx, буквально пустая структура.
  • Каждый WithCancel/WithTimeout/WithValue создаёт новый контекст-потомок, ссылающийся на родителя. Родитель о потомке знает через карту children (только отменяемые потомки в ней есть).
  • Отмена родителя рекурсивно отменяет всех потомков. Отмена потомка на родителя не влияет: сигнал ходит только вниз.
  • Дедлайн наследуется по минимуму: потомок не может жить дольше родителя. WithTimeout(ctx, time.Hour) внутри запроса с бюджетом 200 мс не даст ничего.

Механика отмены

Done() возвращает <-chan struct{}, который при отмене закрывается. Закрытие канала будит сразу всех, кто на нём ждёт, и повторное чтение из закрытого канала возвращает нулевое значение мгновенно. Поэтому один и тот же контекст можно проверять из любого числа горутин без всяких блокировок. Сам канал создаётся лениво: если Done() никто не звал, канала в структуре нет.

Формулировка для собеса

«Context не останавливает горутину. Он лишь сообщает, что результат больше не нужен. Остановиться — обязанность самой горутины: проверить ctx.Done() в select или передать ctx в вызов, который умеет отменяться». Кто этого не произносит, обычно и в коде забывает про ctx.Done().

Суть: первые три создают отменяемый узел (cancelCtx, у двух последних сверху таймер), четвёртый — неотменяемый узел-обёртку со значением. WithTimeout(p,d) — это буквально WithDeadline(p, time.Now().Add(d)).

Разбор по одному

  • WithCancel(parent) отменяется только по явному вызову cancel(). Для фоновых воркеров, конвейеров, «останови всё, если один упал». Возвращает cancelCtx.
  • WithTimeout(parent, d) — отмена через интервал. 95% случаев в продовом коде: «запрос к платёжке не дольше 800 мс». Внутри лежит timerCtx, то есть cancelCtx + time.Timer, который при срабатывании зовёт ту же cancel с DeadlineExceeded.
  • WithDeadline(parent, t) отменяет в абсолютный момент. Нужен, когда бюджет делится между последовательными шагами: три вызова по WithTimeout(ctx, 1s) дадут суммарно 3 секунды, а один WithDeadline сверху честно ограничит весь запрос секундой. Так обычно и делают «сквозной дедлайн», передавая его в заголовке между сервисами.
  • WithValue(parent, k, v) стоит особняком: это valueCtx, у него нет ни отмены, ни таймера, ни карты детей. Одно значение на узел; поиск Value(k) идёт линейно вверх по цепочке до корня. Отсюда правило: не наворачивать десятки значений, каждое это лишний узел и лишний шаг поиска.

Стоимость

WithValue стоит одну маленькую аллокацию, поиск идёт за O(глубины). WithCancel добавляет к аллокации запись в карту children родителя под мьютексом. WithTimeout делает то же самое плюс таймер в куче таймеров рантайма. На горячем пути с миллионом RPS это заметно, а в обычном сервисе это шум рядом с сетевым вызовом.

Что ловят

Три подвоха
  • cancel обязателен даже у WithTimeout. Многие думают: «таймер сам сработает, зачем defer cancel()». Но если функция вернулась за 10 мс, а таймаут 30 секунд, то 30 секунд узел висит в children родителя и таймер лежит в куче. При тысяче RPS это тысячи мёртвых объектов. go vet ловит правилом lostcancel.
  • cancel() идемпотентен: зови сколько угодно раз, второй вызов увидит непустой err и выйдет.
  • Отмена родителя не отличима от таймаута потомка без context.Cause: ctx.Err() вернёт Canceled в обоих случаях, если родителя отменили раньше срабатывания таймера.
Суть: ctx.Err() отвечает «что» и всегда одно из двух значений; context.Cause(ctx) отвечает «почему» — возвращает ошибку, переданную в cancel(err) у WithCancelCause, или причину дедлайна из WithTimeoutCause.

Почему это понадобилось

До Go 1.20 весь мир видел в логах ровно две строки: context canceled и context deadline exceeded. Кто отменил, на каком уровне и из-за чего, оставалось загадкой. В сервисе с errgroup и десятью параллельными вызовами это означало, что при падении одного из них девять остальных писали в лог бесполезное context canceled, а настоящая ошибка терялась.

var errTooManyRetries = errors.New("исчерпаны ретраи")

ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)                       // nil = штатное завершение

go func() {
    if err := doRetries(ctx); err != nil {
        cancel(fmt.Errorf("%w: %v", errTooManyRetries, err))
    }
}()

<-ctx.Done()
ctx.Err()                               // context.Canceled, всегда
context.Cause(ctx)                      // "исчерпаны ретраи: dial tcp: i/o timeout"
errors.Is(context.Cause(ctx), errTooManyRetries)  // true: причину можно матчить

Детали, которые отличают знающего

  • Cause наследуется вниз. Причина хранится в узле, где произошла отмена; потомки, отменённые каскадом, отдают ту же причину. Поэтому её видно из самой глубины стека.
  • На неотменённом контексте Cause возвращает nil, ровно как и Err().
  • Если причину не задали (обычный WithCancel), Cause вернёт то же, что Err(). То есть звать Cause вместо Err() в логах безопасно всегда.
  • Go 1.21 добавил WithTimeoutCause и WithDeadlineCause: при истечении Err() будет DeadlineExceeded, а Cause отдаст твою ошибку вроде errBillingSlow. Удобно, когда в цепочке несколько таймаутов и надо понять, чей сработал.
  • errgroup с 1.20 внутри использует WithCancelCause, так что причиной отмены группы становится ошибка первой упавшей горутины.
Практика

В логах и в трейсах пиши context.Cause(ctx), в бизнес-логике сравнивай ctx.Err() с context.Canceled/DeadlineExceeded. Наружу, в HTTP-ответ, причину отдавать не надо: она внутренняя.

Суть: контекст живёт ровно столько, сколько операция, поэтому он — параметр вызова, а не поле объекта; и он переносит данные о запросе, а не зависимости приложения.

Почему первым аргументом и с именем ctx

Это конвенция всей стандартной библиотеки: func (db *DB) QueryContext(ctx context.Context, query string, args ...any). Единообразие даёт дешёвый греп (grep -rn "ctx context.Context" находит все отменяемые точки), позволяет линтерам вроде contextcheck и containedctx работать, и делает обёртки-мидлвари механическими. Исключение только одно: контекст не передают в конструкторы объектов, живущих дольше вызова.

Почему не хранить в полях структуры

// Антипаттерн
type Service struct {
    ctx context.Context      // чей это контекст? какого запроса?
    db  *sql.DB
}
func (s *Service) Get(id int) (*User, error) {
    return s.db.QueryRowContext(s.ctx, ...)   // отменится, когда отменится... что-то
}

Сервис живёт весь аптайм, он синглтон. Контекст запроса живёт 200 мс. Положишь один в другой, и получишь либо вечный контекст (тогда отмена не работает вовсе), либо контекст первого запроса, отмена которого убьёт все последующие. Плюс гонка: поле пишется и читается из разных горутин. Линтер containedctx в golangci-lint ловит это правилом. Легальное исключение признано самим стандартом: http.Request, где контекст неотделим от объекта запроса и меняется через r.WithContext(ctx), создающий копию.

Почему WithValue — не для зависимостей

  • Нет типобезопасности. ctx.Value(k).(*sql.DB) приводит тип в рантайме. Убрали значение из мидлвари, и узнаешь об этом паникой в проде, а не ошибкой компиляции.
  • Нет видимости зависимостей. Сигнатура func Handle(ctx context.Context) ничего не говорит о том, что функции нужны БД, логгер и кэш. Явные параметры или поля структуры документируют себя сами.
  • Ключ обязан быть своим неэкспортируемым типом. Строковый ключ "user" может совпасть с чужим из библиотеки и молча перетереться. Потому go vet и ругается на базовые типы в ключах.
  • Что класть можно: request id, trace id / span, user id и роли после аутентификации, локаль, флаги A/B-теста, дедлайн-подсказки. То есть всё, что относится к конкретному запросу и нужно на всех уровнях, но бизнес-параметром функции не считается.
Формулировка-ловушка

Спрашивают: «а можно передать через context логгер?» Правильный ответ: логгер как зависимость нельзя, а логгер, уже обогащённый полями запроса (trace id, user id), это распространённая практика, и spec она не противоречит, потому что данные всё равно request-scoped. Тут важно объяснить границу, а не заучить запрет.

Суть: net/http для каждого соединения держит фоновую горутину connReader, которая читает сокет; увидев EOF или ошибку, она зовёт cancelCtx запроса — и r.Context().Done() закрывается.

Механика

На каждое принятое соединение сервер запускает conn.serve. Внутри, пока выполняется хендлер, работает фоновое чтение (startBackgroundRead): оно ждёт данных, чтобы поймать либо новый пайплайненный запрос, либо закрытие. Если чтение вернуло EOF или ECONNRESET, вызывается cancelCtx, и контекст запроса отменяется с context.Canceled. В HTTP/2 то же самое делает RST_STREAM или закрытие стрима.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    select {
    case res := <-doWork(ctx):
        json.NewEncoder(w).Encode(res)
    case <-ctx.Done():
        // клиент ушёл, сработал WriteTimeout или идёт Shutdown
        log.Info("отменено", "err", ctx.Err(), "cause", context.Cause(ctx))
        return          // писать в w бессмысленно, но безопасно
    }
}

Четыре причины отмены r.Context()

  1. Клиент разорвал соединение или отменил стрим.
  2. Истёк Server.WriteTimeout / сработал http.TimeoutHandler.
  3. Идёт Server.Shutdown и вышел его дедлайн, либо вызван Server.Close.
  4. ServeHTTP просто вернул управление: контекст отменяется всегда, даже при успешном ответе.
На чём ловят
  • «Запущу фон и верну 202»: go job(r.Context()), и задача умрёт сразу после ответа. Нужен context.WithoutCancel(r.Context()) (Go 1.21) или контекст приложения.
  • Раньше для этого брали CloseNotifier, но его объявили устаревшим, так что правильный ответ на собесе именно r.Context().
  • Отмена не мгновенна: до Go 1.20 фоновое чтение при HTTP/1.1 могло заметить обрыв с задержкой, а если клиент просто «замолчал» без FIN/RST, сервер узнает только по своему таймауту. Поэтому ReadHeaderTimeout и WriteTimeout ставят всегда.
  • Запись в w после отмены не паникует и не обязательно возвращает ошибку в Encode: данные просто уходят в никуда. Проверять надо ctx.Err(), а не результат записи.
Суть: всегда *Context-варианты вызовов: QueryContext/ExecContext/BeginTx для database/sql и http.NewRequestWithContext для HTTP. Отменяемость обязана быть сквозной — один слой с context.Background() обнуляет всю цепочку.

БД

// Правильно
rows, err := db.QueryContext(ctx, "select id, name from users where org = $1", orgID)
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
_, err = tx.ExecContext(ctx, "update balances set amount = amount - $1 where id = $2", sum, id)

// Неправильно: эти формы отмену вообще не видят
rows, err := db.Query(...)   // внутри это QueryContext(context.Background(), ...)

Контекст в database/sql работает на трёх уровнях. Во-первых, ожидание свободного соединения из пула прерывается по ctx.Done(): при исчерпанном пуле запрос падает с context deadline exceeded вместо бесконечного ожидания. Во-вторых, драйвер, реализующий QueryerContext, отправляет серверу отмену (в pgx/lib pq это CancelRequest, отдельное соединение с сообщением отмены, в MySQL это KILL QUERY). В-третьих, при отмене посреди чтения rows соединение возвращается в пул корректно, а не рвётся.

Транзакции и отмена

Отменится ctx, переданный в BeginTx, и транзакция откатится автоматически, а соединение вернётся в пул. Удобно, но есть сюрприз: длинная транзакция под запросным контекстом умрёт вместе с уходом клиента, даже если бизнес-логика хотела бы её докатить. Для «докатить в любом случае» берут context.WithoutCancel либо отдельный контекст с собственным таймаутом.

HTTP-клиент

req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
resp, err := client.Do(req)

При отмене Transport закрывает соединение (в HTTP/2 шлёт RST_STREAM), а Do возвращает ошибку, обёрнутую *url.Error. Проверять надо через errors.Is(err, context.DeadlineExceeded), а не сравнением строк. И отдельно: resp.Body закрывай всегда, иначе соединение не вернётся в пул и никакая отмена не поможет.

Иерархия таймаутов

  • Общий бюджет запроса ставит WithDeadline в мидлвари, например 3 с.
  • Пер-вызов: WithTimeout на каждый внешний вызов, меньше бюджета. БД 500 мс, биллинг 800 мс. Дедлайн наследуется, так что суммарно всё равно не выйдет за 3 с.
  • client.Timeout — грубая страховка на весь запрос, включая чтение тела; контекст точнее, и ставить его надо в дополнение, а не вместо.
  • Ретраи считают оставшийся бюджет: d, ok := ctx.Deadline(), и если до дедлайна меньше, чем средняя длительность вызова, ретрай не начинают.
Правило сквозной отменяемости

Любая функция, которая делает I/O или может занять больше миллисекунды, принимает ctx первым аргументом и передаёт его дальше без замены на Background(). Линтер contextcheck находит места, где цепочка рвётся.

Суть: signal.NotifyContext ловит SIGTERM/SIGINT, srv.Shutdown(ctx) закрывает слушателей и ждёт активные запросы, контекст в Shutdown — это дедлайн ожидания, после которого нужен srv.Close().

Канонический код

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()

select {
case err := <-errCh:
    if !errors.Is(err, http.ErrServerClosed) {
        log.Fatal(err)                  // порт занят и подобное
    }
case <-ctx.Done():
    log.Info("получен сигнал, останавливаемся")
}
stop()                                  // вернуть дефолтный обработчик: второй Ctrl+C убьёт сразу

shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
    log.Warn("таймаут graceful, рвём соединения")
    _ = srv.Close()
}

Что делает Shutdown по шагам

  1. Помечает сервер как закрывающийся и закрывает слушающие сокеты: новых соединений ОС уже не принимает (клиенты получат connection refused, поэтому балансировщик надо вывести из ротации ЗАРАНЕЕ).
  2. Закрывает все idle keep-alive соединения.
  3. В цикле с растущим интервалом (от 1 мс до 500 мс) проверяет, остались ли активные запросы, и ждёт, пока их не станет ноль либо не истечёт переданный контекст.
  4. Вызывает зарегистрированные RegisterOnShutdown хуки: там обычно закрывают WebSocket-соединения, которые Shutdown не видит.
Три вещи, которые Shutdown НЕ делает
  • Не отменяет выполняющиеся хендлеры. Их r.Context() отменится, только когда истечёт контекст Shutdown. Долгий хендлер без своего таймаута задержит выкатку на весь дедлайн.
  • Не ждёт перехваченные соединения (hijacked: хендлер забрал сырой сокет через http.Hijacker, это WebSocket и апгрейды протокола), у них свой механизм остановки.
  • Не останавливает твои фоновые горутины: консьюмеры Kafka, крон-задачи, воркер-пулы гасишь сам, и обычно ПОСЛЕ HTTP, чтобы доехали запросы, которые эти воркеры используют.

Порядок в реальном сервисе

Сначала readiness-проба возвращает 503 (или сервис снимают из балансировщика), потом несколько секунд ждут, чтобы трафик перестал приходить. Потом srv.Shutdown с дедлайном чуть меньше, чем terminationGracePeriodSeconds в Kubernetes (иначе прилетит SIGKILL посреди уборки). Потом останавливают консьюмеры и воркеры, потом закрывают пулы БД и Redis, последним идёт flush метрик и трейсов. Подробный разбор с кодом лежит в главе 2.7.

Суть: закрываемый done chan struct{} как сигнал «всем стоп» + мьютекс со списком детей для каскада + таймер для дедлайна + поле ошибки. Собственно, так context и устроен.

Шаг 1: сигнал

Нужен примитив «оповестить неизвестное заранее число слушателей, один раз, навсегда». Таким свойством обладает ровно одна операция с каналом: close. Закрытие будит всех ожидающих, и любое последующее чтение возвращается мгновенно. Отправка значений не годится, её прочитает ровно один получатель.

Шаг 2: иерархия

type myCtx struct {
    mu       sync.Mutex
    done     chan struct{}
    err      error
    children map[*myCtx]struct{}
    deadline time.Time
    timer    *time.Timer
}

func WithCancel(p *myCtx) (*myCtx, func()) {
    c := &myCtx{done: make(chan struct{}), children: map[*myCtx]struct{}{}}
    p.mu.Lock()
    if p.err != nil {                 // родитель уже мёртв, и ребёнок рождается мёртвым
        p.mu.Unlock()
        c.cancel(p.err)
        return c, func() {}
    }
    p.children[c] = struct{}{}
    p.mu.Unlock()
    return c, func() { c.cancel(errCanceled); p.remove(c) }
}

func (c *myCtx) cancel(err error) {
    c.mu.Lock()
    if c.err != nil {                 // идемпотентность
        c.mu.Unlock()
        return
    }
    c.err = err
    close(c.done)                     // будим всех своих слушателей
    kids := c.children
    c.children = nil
    if c.timer != nil {
        c.timer.Stop()
    }
    c.mu.Unlock()

    for k := range kids {             // каскад вниз, вне лока, иначе дедлок на детях
        k.cancel(err)
    }
}

Шаг 3: дедлайн и значения

Таймаут собирается из time.AfterFunc(d, func(){ c.cancel(errDeadline) }) плюс наследование: если у родителя дедлайн раньше, свой таймер не нужен вовсе. Значения живут в отдельном типе-обёртке с одной парой ключ/значение и рекурсивным поиском вверх; он неотменяемый и потому не участвует в дереве отмены.

Грабли, о которые спотыкаются на доске

Что проверяет интервьюер
  • close, а не send: понимает ли кандидат разницу в семантике оповещения.
  • Идемпотентность. Повторный cancel не должен паниковать на двойном close.
  • Отцепление от родителя, без него children растёт вечно и течёт память.
  • Каскад вне лока: зови cancel детей под своим мьютексом, и при обратных ссылках легко словишь дедлок.
  • Ребёнок уже отменённого родителя отменяется немедленно, а не живёт вечно.
  • Ленивое создание канала не обязательно, но за упоминание плюс: в реальном context done создаётся только при первом вызове Done(), экономя аллокацию.
Суть: контекст ничего не останавливает сам; горутина, которая заблокирована на канале или сети и не смотрит в ctx.Done(), останется висеть навсегда, удерживая свой стек и всё, на что он ссылается.

Типовой пример

// Плохо: получили ctx и не используем его
func fetch(ctx context.Context, urls []string) []Result {
    ch := make(chan Result)              // небуферизованный
    for _, u := range urls {
        go func(u string) {
            ch <- get(u)                 // встанет навсегда, если читатель ушёл
        }(u)
    }
    var out []Result
    for range urls {
        select {
        case r := <-ch:
            out = append(out, r)
        case <-ctx.Done():
            return out                   // вышли, а N горутин навсегда заперты на ch
        }
    }
    return out
}

Утечка здесь ровно в том, что отправители не знают об отмене. Чинится двумя независимыми способами: сделать канал буферизованным на len(urls), чтобы отправка никогда не блокировала, либо научить отправителя смотреть на контекст.

// Хорошо, вариант 1: буфер под все результаты, отправка никогда не блокирует
ch := make(chan Result, len(urls))

// Хорошо, вариант 2: отправитель уважает отмену
go func(u string) {
    select {
    case ch <- get(u):
    case <-ctx.Done():                   // читателя больше нет, уходим
    }
}(u)

Другие сценарии

  • Долгий цикл без проверки. В for _, row := range millionRows { heavy(row) } отмену не смотрят ни разу. Лечится select { case <-ctx.Done(): return ctx.Err(); default: } в начале итерации (это неблокирующая проверка, стоит единицы наносекунд).
  • time.Sleep вместо таймера. Спящая горутина не отменяема. Меняй на select { case <-time.After(d): case <-ctx.Done(): } или timer := time.NewTimer(d); defer timer.Stop().
  • Вызов без Context-варианта. db.Query, http.Get, conn.Read без дедлайна: контекст есть, а отменять нечего. Для голых сокетов помогает conn.SetReadDeadline плюс context.AfterFunc(ctx, func(){ conn.Close() }).
  • Фон, переживший запрос. С go audit(r.Context(), ev) проблема обратная: не утечка, а мгновенная смерть задачи. Нужен context.WithoutCancel.

Как ловить

  • Метрика runtime.NumGoroutine() в мониторинге: монотонный рост под ровной нагрузкой почти всегда означает утечку.
  • go tool pprof http://host/debug/pprof/goroutine: в профиле видно, на какой строке заблокированы тысячи одинаковых горутин.
  • go.uber.org/goleak в TestMain уронит тест, если после него остались лишние горутины. Дешевле всего ловить именно тут.
Правило приёмки на ревью

Каждый go func отвечает на вопрос «что заставит эту горутину завершиться?». Ответов допустимо три: закрылся входной канал, сработал ctx.Done(), кончилась сама работа. Ответа нет, значит код не проходит ревью. Про утечки и их диагностику подробно в следующей главе.

2.6Проблемы: гонки, дедлоки, утечки

Конкурентная программа ломается в проде тремя способами: портит данные (гонка), замирает (дедлок) или медленно съедает память горутинами, которые уже никому не нужны. Спрашивают тут не терминологию, а умение поставить диагноз по симптомам и вылечить.

Сначала — шесть слов, которыми называют поломки

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

Словарь главы: по одной фразе на термин
  • Data race (гонка данных) — две горутины обращаются к одной ячейке памяти, минимум одна из них пишет, и между обращениями нет ребра happens-before. Определение формальное, машинное: его проверяет -race.
  • Race condition (состояние гонки) — правильность результата зависит от того, в каком порядке успели выполниться конкурентные операции. Определение смысловое: что «правильно», знает только автор кода.
  • Дедлок (deadlock, взаимная блокировка) — горутины заблокированы навсегда, потому что каждая ждёт того, что может дать только другая. CPU — ноль, прогресс — ноль.
  • Livelock (живая блокировка) — горутины не спят, а активно работают: замечают конфликт, откатываются, пробуют снова, и так по кругу. CPU — сотка, прогресс — ноль.
  • Starvation (голодание) — система в целом прогрессирует, но конкретная горутина раз за разом проигрывает конкуренцию за ресурс и не продвигается. По CPU и по средним цифрам не видно ничего; видно по p99, куда улетает время ответа у самых медленных запросов (тот самый «хвост латентности»).
  • Утечка горутин — горутина, которая никогда не завершится: она не «сломана» прямо сейчас, но навсегда держит стек, соединение и всё, что захватило её замыкание.

Разводить их надо не ради терминологии: каждую ловят своим инструментом. Data race ловит -race, дедлок — дамп горутин, livelock — профиль CPU, starvation — хвост латентности, утечку — счётчик горутин и профиль goroutineleak. Перепутал диагноз, будешь смотреть не туда и ничего не найдёшь.

Race condition и data race — это разные вещи

Вопрос «в чём разница» задают почти всегда, и почти всегда слышат в ответ, что это синонимы. Никакие не синонимы: Go memory model формально определяет только data race, а race condition — это уже про логику программы.

Проще всего почувствовать разницу на бумажной ведомости с остатком 100 ₽ и двух кассирах. Первый случай: оба одновременно пишут карандашом в одну и ту же клетку — грифель ложится поверх грифеля, и в клетке получается нечитаемая клякса. Испорчена сама запись, и уже неважно, что каждый по отдельности считал правильно. Это data race. Второй случай: кассиры вежливо ждут друг друга и пишут по очереди, разборчиво. Но каждый сначала посмотрел остаток, увидел 100, и только потом списал 100. Ведомость идеально читаемая, все записи целы, а со счёта ушло 200 ₽ при остатке 100. Это race condition: испорчена не запись, а решение, принятое по устаревшим данным.

Отсюда два вывода, ради которых различие и нужно. Первый: инструмент, который смотрит на клетки памяти, второй случай не поймает никогда, там нет ни одного некорректного обращения. Второй: чинятся они по-разному. От кляксы спасает любая синхронизация вокруг записи; от списанных 200 ₽ спасает одно — сделать «посмотреть и списать» одной неделимой операцией, а не двумя аккуратными.

Data race (гонка данных)Race condition (состояние гонки)
Определение два обращения к одной ячейке памяти из разных горутин, минимум одно — запись, и между ними нет отношения happens-before корректность результата зависит от того, в каком порядке успели выполниться конкурентные операции
Уровеньпамять, определяется формально спецификациейлогика программы, определяется её смыслом
Последствияне UB, как в C, но рваные интерфейсы и слайсы, порча map и памяти, падение рантайманеверный бизнес-результат: списали дважды, потеряли заказ
Ловится инструментомда, -race находит формальнонет, машина не знает вашей бизнес-логики
Примерcounter++ из двух горутин без синхронизациипроверил баланс, потом списал — между проверкой и списанием влез другой

Каждая data race — это race condition, но не наоборот. Можно написать код, где вся память под мьютексом, детектор молчит, а логика всё равно неверна:

// Data race нет: каждое обращение под мьютексом. А race condition есть.
func (a *Account) Withdraw(sum int) error {
    a.mu.Lock()
    bal := a.balance          // проверка
    a.mu.Unlock()

    if bal < sum {            // между Unlock и Lock влезла другая горутина
        return errNotEnough
    }

    a.mu.Lock()
    a.balance -= sum          // списание по устаревшему решению
    a.mu.Unlock()
    return nil
}

// Правильно: решение и действие в одной атомарной операции
func (a *Account) Withdraw(sum int) error {
    a.mu.Lock()
    defer a.mu.Unlock()
    if a.balance < sum {
        return errNotEnough
    }
    a.balance -= sum
    return nil
}
Как это звучит на собесе

«Data race — это про память и определяется формально: конкурентные доступы без happens-before, минимум один на запись. Race condition — это про логику: результат зависит от порядка. Первое ловит -race, второе — только голова и тесты. Классический пример race condition без data race — TOCTOU (time-of-check to time-of-use, «проверил в один момент, воспользовался в другой»): check-then-act под двумя отдельными критическими секциями.» Дальше обычно просят пример — держите наготове проверку баланса или if !exists { create() }.

counter++ из N горутин: почему ломается

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

// go build -gcflags=-S на arm64: counter++ это три инструкции
//   MOVD main.counter(SB), R1
//   ADD  $1, R1, R1
//   MOVD R1, main.counter(SB)
// на amd64 одна INCQ main.counter(SB), но без LOCK и она не атомарна
var counter int

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Go(func() {            // Go 1.25: wg.Add(1) + go func + wg.Done() в одном вызове
            for j := 0; j < 1000; j++ {
                counter++          // гонка
            }
        })
    }
    wg.Wait()
    fmt.Println(counter)           // ожидали 1000000, а выйдет что угодно.
                                   // Пять запусков подряд на go1.27 дали:
                                   //   236884  224124  239573  224828  231667
}
Две горутины делают counter++ — по три машинные операции каждая время t1 t2 t3 t4 t5 t6 G1 load r1=0 add r1=1 store 1 G2 load r2=0 add r2=1 store 1 counter в памяти 0 0 0 0 1 1 Два инкремента, а counter вырос на единицу: G2 прочитала ноль до того, как G1 записала. Это lost update. Чем больше ядер, тем чаще: на 8 ядрах из миллиона теряются сотни тысяч.
Потерянное обновление. Гонка не требует «одновременности» в физическом смысле: достаточно, чтобы чтение одной горутины произошло до записи другой, а её собственная запись — после.

Три способа починить

// 1. Мьютекс: универсально
type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    c.n++
    c.mu.Unlock()
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n            // читать тоже под локом
}
// 2. Atomic: быстро для одного слова
type Counter struct {
    n atomic.Int64        // Go 1.19: типизированный
}

func (c *Counter) Inc() {
    c.n.Add(1)            // одна инструкция LOCK XADDQ
}

func (c *Counter) Value() int64 {
    return c.n.Load()
}
// Не годится, когда надо менять
// два поля согласованно.
// 3. Канал: состоянием владеет одна горутина, остальные шлют ей сообщения
type Counter struct {
    inc  chan struct{}
    read chan chan int
    done chan struct{}
}

func NewCounter() *Counter {
    c := &Counter{
        inc:  make(chan struct{}, 256),   // буфер гасит всплески, но Value может
                                          // не увидеть Inc, которые ещё лежат в буфере
        read: make(chan chan int),
        done: make(chan struct{}),
    }
    go func() {
        n := 0                            // состояние живёт только здесь
        for {
            select {
            case <-c.inc:
                n++
            case reply := <-c.read:
                reply <- n
            case <-c.done:
                return
            }
        }
    }()
    return c
}

func (c *Counter) Inc()       { c.inc <- struct{}{} }
func (c *Counter) Value() int { r := make(chan int); c.read <- r; return <-r }
func (c *Counter) Close()     { close(c.done) }
СпособЦена одного Inc (M4 Pro, замер в вопросе 1)ПлюсыМинусы
Mutexоколо 2 нс без конкуренции, около сотни нс под конкуренцией работает для любого состояния, читается всеми, легко расширить на два поля под высокой конкуренцией — парковка горутин и переключения
Atomicпочти как мьютекс без конкуренции, под конкуренцией десятки нс — борьба за кеш-линию самый быстрый, без парковки, годится для метрик и флагов только одно слово; согласованность двух полей не обеспечивает
Каналдесятки нс на сообщение, под конкуренцией — сотни состояние в одной горутине, естественно расширяется до сложной логики дороже на порядок, лишняя горутина, надо не забыть её остановить
Как выбирать

Счётчик или флаг — atomic. Структура из нескольких полей, которые меняются вместе, — Mutex. Состояние, вокруг которого есть логика и очередь событий (кеш, менеджер соединений, аккумулятор батча), — горутина-владелец с каналом. Канал ради одного счётчика — это оверинжиниринг, и на собесе за такой ответ спросят «а зачем так дорого?».

Самая частая ошибка при починке

Защитили запись и забыли чтение. Inc() под мьютексом, а return c.n без него — это по-прежнему data race, и детектор его найдёт. То же со старыми функциями atomic: пишете через AddInt64, а читаете обычным c.n (без LoadInt64) — гонка. Типизированный atomic.Int64 так прочитать не даст: это ошибка компиляции. Правило: атомарность — свойство всех обращений к переменной, а не одних записей.

Race detector: что происходит под капотом

-race — это не статический анализ и не эвристика «два потока трогают переменную». Это динамический детектор на базе ThreadSanitizer (библиотека из LLVM, в Go включена как runtime/race), который прямо на ходу строит отношение happens-before и проверяет каждый доступ к памяти на упорядоченность.

Три составные части

  • Инструментация. С флагом -race компилятор вставляет перед каждым чтением и записью памяти (кроме локальных переменных на своём стеке) вызов raceread/racewrite, для структур и массивов — их range-варианты. Плюс рантайм сам сообщает детектору обо всех точках синхронизации: Lock/Unlock, отправка и приём в канале, WaitGroup.Wait, atomic-операции, запуск и завершение горутины.
  • Векторные часы. Способ ответить на вопрос «что горутина A знает о том, как далеко продвинулась горутина B» — без всяких настоящих часов и без сравнения моментов времени. Единица измерения тут — эпоха: просто счётчик шагов, который горутина увеличивает на каждой своей точке синхронизации. У каждой горутины — вектор из таких счётчиков, по одному на каждую известную горутину. Собственная компонента растёт после каждой такой точки. При синхронизации часы передаются: Unlock сохраняет вектор в мьютексе, Lock берёт поэлементный максимум со своим. Так формально фиксируется «всё, что было до Unlock, произошло раньше всего, что после Lock».
  • Теневая память. Отдельная служебная область, в которой детектор ведёт досье на память самой программы: не значения, а «кто и на каком своём шаге к этому месту прикасался». На каждые 8 байт памяти программы заводится несколько теневых ячеек, в каждой — id горутины, её эпоха на момент доступа, размер обращения и флаг чтение/запись. Новый доступ сравнивается с тем, что лежит в теневых ячейках: если эпоха предыдущего писателя больше, чем то, что мой вектор знает про эту горутину, значит порядок между нами не установлен — гонка.
Один и тот же доступ к x: с синхронизацией и без happens-before есть — гонки нет G1: пишет x, свои часы {G1:5, G2:0} G1: mu.Unlock() — часы {5,0} кладутся в мьютекс G2: mu.Lock() — берёт max: {0,3} и {5,0} = {5,3} G2: пишет x, в тени видит «G1, эпоха 5» 5 <= моё знание о G1 (=5) — доступ упорядочен happens-before нет — DATA RACE G1: пишет x, свои часы {G1:5, G2:0} синхронизации между горутинами не было G2: часы {G1:0, G2:3} — про G1 не знает ничего G2: пишет x, в тени видит «G1, эпоха 5» 5 > моё знание о G1 (=0) — НЕ упорядочено, отчёт 8 байт памяти var x int64 G1 эпоха 5 запись, 8 Б G2 эпоха 3 чтение, 8 Б свободно свободно теневые ячейки: на каждые 8 байт данных их всего четыре Новый доступ вытесняет одну из старых ячеек — очень давние обращения детектор просто забывает. Эта теневая карта и есть причина, почему память под -race растёт в 5–10 раз.
Векторные часы и теневая память. Детектор не смотрит на «одновременность», он проверяет, установлен ли между двумя доступами порядок. Поэтому гонка находится, даже если доступы разнесены во времени на секунды.
Что из этого следует

Детектор работает через happens-before, а не через «поймал одновременность». Значит, ему не нужно, чтобы гонка реально проявилась: хватит, чтобы обе конкурирующие операции выполнились хотя бы по разу, в любом порядке и с любым разрывом во времени. Именно поэтому -race находит гонки, которые падают в проде раз в месяц. Но только если тест прошёл по обеим веткам кода.

Почему ловит не всё

  • Только выполненные пути. Не покрытая тестом ветка инструментирована, но ни разу не выполнилась, и её гонки невидимы. Значит, ценность -race прямо пропорциональна покрытию и разнообразию нагрузки.
  • Только реально запущенные горутины. Если в тесте всего один воркер, а в проде их сто, конкуренции не возникнет и сравнивать будет нечего. Поэтому конкурентные тесты пишут с -race и -count=10, с реальным числом горутин, иногда с GOMAXPROCS>1 явно.
  • Ёмкость теней и истории. Теневых ячеек на слово всего четыре, старые вытесняются. Стек прошлого доступа детектор восстанавливает из истории горутины: если между двумя доступами она успела сделать десятки тысяч других обращений к памяти, гонка молча не попадёт в отчёт, пока не поднимешь history_size. А лимита в 8128 живых горутин из старых статей с Go 1.19 (ThreadSanitizer v3) больше нет.
  • Гонки вне Go. Cgo-код, ассемблер без инструментации, память, которую трогает сторонняя C-библиотека, детектору не видны.
  • Race condition он не ловит принципиально — только data race.

Цена

РесурсМножительПочему
CPU / время×2–20, обычно ×5–10вызов в детектор перед каждым обращением к памяти плюс поиск по теням
Память×5–10теневая карта пропорциональна размеру используемой памяти
Размер бинарязаметно большеинструментация в каждой функции
Поведениетаймауты «плывут»тесты, завязанные на реальное время, начинают моргать
# стандартный набор в CI
go test -race ./...                       # обязательная ступень пайплайна
go test -race -count=5 ./internal/worker  # многократный прогон конкурентных тестов
go build -race -o app-race ./cmd/app      # бинарь для стенда, не для прода

# настройка детектора
GORACE="halt_on_error=1 history_size=4 log_path=/tmp/race" go test -race ./...
#   halt_on_error=1: падать на первой гонке (в CI удобно)
#   history_size:    сколько прошлых доступов помнит горутина, больше = меньше пропусков, дороже
Почему -race обязателен в CI

Гонка не воспроизводится по требованию: зависит от числа ядер, нагрузки, версии Go и фазы луны. В проде она вылезает не «неправильным числом», а порчей внутренних структур: fatal error: concurrent map read and map write, битый интерфейс, паника в рантайме, которую не привязать к исходной строке. Детектор превращает невоспроизводимый инцидент в детерминированный отчёт с двумя стеками, того, кто писал, и того, кто читал. Больше в Go так не умеет никто, а стоит это одного шага в пайплайне. В прод бинарь с -race не катят: цена по CPU и памяти слишком высока. Исключение — короткий эксперимент на канареечном поде.

# так выглядит отчёт: два стека доступа и где запущена каждая из двух горутин
==================
WARNING: DATA RACE
Write at 0x00c000012188 by goroutine 9:
  main.(*Counter).Inc()
      /app/counter.go:14 +0x88
  main.main.func2()
      /app/main.go:22 +0x68

Previous read at 0x00c000012188 by goroutine 7:
  main.(*Counter).Value()
      /app/counter.go:19 +0x74
  main.main.func1()
      /app/main.go:16 +0x80

Goroutine 9 (running) created at:
  main.main()
      /app/main.go:19 +0x1a4

Goroutine 7 (finished) created at:
  main.main()
      /app/main.go:14 +0x100
==================

Дедлок: определение и четыре условия

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

Классические условия Коффмана — четыре одновременно необходимых признака. Ломать дедлок можно, убрав любое из них, и в Go на практике ломают в основном четвёртое.

УсловиеЧто значитКак ломают в Go
Взаимное исключениересурс нельзя держать вдвоёмпочти никак: в этом смысл мьютекса. Разве что заменить на atomic / immutable-копию / шардирование
Удержание и ожиданиегорутина держит один лок и просит второйбрать все локи разом (один более крупный лок), либо не звать чужой код под локом
Отсутствие вытеснениялок нельзя отобрать силойTryLock + откат и повтор, таймауты, semaphore.Weighted.Acquire(ctx, n)
Циклическое ожиданиеA ждёт B, B ждёт Aглобальный порядок захвата локов — основной рабочий приём

Код, который дедлочится гарантированно

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

func main() {
    var muA, muB sync.Mutex
    var wg sync.WaitGroup

    wg.Add(2)
    go func() {                              // G1: порядок A -> B
        defer wg.Done()
        muA.Lock()
        time.Sleep(50 * time.Millisecond)    // окно: даём G2 взять muB
        muB.Lock()                           // ждёт вечно: muB у G2
        muB.Unlock()
        muA.Unlock()
    }()
    go func() {                              // G2: порядок B -> A  <-- зеркальный
        defer wg.Done()
        muB.Lock()
        time.Sleep(50 * time.Millisecond)
        muA.Lock()                           // ждёт вечно: muA у G1
        muA.Unlock()
        muB.Unlock()
    }()
    wg.Wait()
}

// Реальный вывод go1.27 (в stderr, сокращённый; exit status 2):
//
// fatal error: all goroutines are asleep - deadlock!
//
// goroutine 1 [sync.WaitGroup.Wait]:
// sync.runtime_SemacquireWaitGroup(...)
// sync.(*WaitGroup).Wait(0x...)
// main.main()
//
// goroutine 6 [sync.Mutex.Lock]:
// internal/sync.runtime_SemacquireMutex(...)
// internal/sync.(*Mutex).lockSlow(0x...)
// main.main.func1()
//         created by main.main in goroutine 1
//
// goroutine 7 [sync.Mutex.Lock]:
// main.main.func2()
//         created by main.main in goroutine 1
//
// Читать надо состояние в квадратных скобках: у main это ожидание WaitGroup
// (до Go 1.24 та же строка выглядела как [semacquire]), а у двух рабочих Mutex.Lock.
// Номера горутин не 2 и 3, потому что рантайм заводит служебные горутины раньше.

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

// 1. отправка в небуферизованный канал,
//    читателя нет
func main() {
    ch := make(chan int)
    ch <- 1
}

// 2. рекурсивный Lock: у Mutex
//    нет владельца и нет реентрантности
func main() {
    var mu sync.Mutex
    mu.Lock()
    mu.Lock()
}
// 3. Add без Done
func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    wg.Wait()
}

// 4. пустой select: вечный сон
//    без единого готового кейса
func main() {
    select {}
}
Циклическое ожидание: граф «кто кого ждёт» замкнулся G1 держит muA G2 держит muB muA занят G1 muB занят G2 владеет владеет G1 просит muB — ждёт G2 просит muA — ждёт цикл в графе ожидания = дедлок Разорвать можно любым ребром: общий порядок / TryLock / таймаут
Дедлок — это цикл в графе ожидания. Пока оба ребра «просит» направлены навстречу друг другу, никто не сдвинется. Глобальный порядок захвата (всегда сначала muA, потом muB) делает такой цикл невозможным: рёбра «просит» всегда идут в сторону возрастания номера лока.

Как рантайм детектит дедлок и когда не детектит

Знаменитое fatal error: all goroutines are asleep - deadlock! печатает функция checkdead() в планировщике. И это не детектор дедлоков. Рантайм не строит граф ожидания и не разбирает, кто кого ждёт. Он проверяет одно глобальное условие: «во всей программе не осталось ни одного потока, который мог бы когда-нибудь получить работу».

checkdead вызывается, когда последний рабочий поток M собирается уйти в сон, и считает три числа: сколько есть runnable-горутин, сколько M сидит в системных вызовах и есть ли активные таймеры. Если работы нет нигде, а живые горутины при этом заблокированы (_Gwaiting) — значит, разбудить их некому, и рантайм честно падает, печатая стеки.

Когда «all goroutines are asleep» НЕ появится
  • Частичный дедлок. Пять горутин намертво стоят на двух мьютексах, а main обслуживает HTTP. Программа жива, метрики зелёные, а часть функционала просто не работает. Это основной случай в проде, и рантайм тут молчит навсегда.
  • Есть живой таймер. Горутина спит в time.Sleep, ждёт time.After или Ticker — это потенциальная будущая работа, рантайм считает прогресс возможным. Таймер, из канала которого никто не читает, с Go 1.23 не в счёт. Отсюда фокус: добавьте в дедлочащуюся программу go func(){ for { time.Sleep(time.Second) } }() — и она просто зависнет вместо падения.
  • Кто-то крутится в цикле. for {}, busy-wait на atomic, спиннинг — горутина runnable, условие не выполнено.
  • Блокировка в syscall или netpoll. Ожидание сокета, чтение файла, вызов в cgo: рантайм считает, что данные могут прийти извне. Поэтому сервер, зависший на ответе от БД, никогда не «задедлочится» с точки зрения Go.
  • Дедлок на внешнем ресурсе. Взаимная блокировка в Postgres, распределённый лок в Redis, файловый flock — для рантайма это обычный сетевой вызов.
Формулировка, закрывающая вопрос

«Рантайм детектит не дедлок, а полное отсутствие прогресса во всей программе: нет runnable горутин, нет активных таймеров, никто не сидит в syscall. Это работает в учебных примерах и в тестах, но практически никогда не срабатывает в реальном сервисе — там всегда есть netpoll и тикеры. Реальные дедлоки в проде ловят не рантаймом, а goroutine-дампом.»

Диагностика зависания в проде

ИнструментКак снятьЧто видно
SIGQUITkill -QUIT <pid> или Ctrl+\стеки всех горутин и аварийное завершение процесса. Работает без импортов и эндпоинтов, если процесс сам не перехватил SIGQUIT через signal.Notify
pprof goroutinecurl host/debug/pprof/goroutine?debug=2то же самое, включая время ожидания у стоящих дольше минуты, но без убийства процесса
pprof, агрегат?debug=1 или go tool pprofсгруппировано по стекам: «7412 горутин стоят вот на этой строке»
goroutineleakcurl host/debug/pprof/goroutineleak?debug=1 (Go 1.27)только те горутины, которые заблокированы навсегда: примитив, на котором они стоят, уже недостижим. Отделяет утечку от «просто долго ждёт»
mutex profileruntime.SetMutexProfileFraction(5)кто держал мьютекс, пока другие ждали, и сколько они прождали — контеншен, ещё не дедлок
block profileruntime.SetBlockProfileRate(1e6)блокировки на каналах, select, WaitGroup, семафорах
метрикаruntime.NumGoroutine() в Prometheusмонотонный рост — утечка; резкая полка — что-то встало
метрики планировщикаruntime/metrics: /sched/goroutines/* и /sched/threads/total (Go 1.26), /sched/latenciesсколько горутин реально исполняется, сколько готово, сколько ждёт, сколько сидит в syscall или cgo — и сколько потоков ОС завёл рантайм
delvedlv attach <pid>, затем goroutines -tто же плюс возможность посмотреть переменные в кадре
# 1. дамп без остановки сервиса, с него и начинают
curl -s 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2' > /tmp/g.txt

# 2. кто где стоит: топ состояний
grep -oP '^goroutine \d+ \[\K[^\]]+' /tmp/g.txt | sort | uniq -c | sort -rn
#   7412 chan receive, 43 minutes     <-- утечка или зависший конвейер
#     12 sync.Mutex.Lock, 43 minutes  <-- вот он, дедлок
#      3 IO wait

# 3. SIGQUIT, когда pprof не подключён (стеки в stderr, процесс умирает)
kill -QUIT $(pgrep app)        # все горутины, включая служебные; GOTRACEBACK=all для этого не нужен
Что искать в дампе

Три сигнала. Первый — время в квадратных скобках: [chan receive, 43 minutes] означает, что горутина стоит 43 минуты; всё, что дольше разумного таймаута запроса, уже подозреваемый. Второй — много одинаковых стеков: тысячи горутин на одной строке это всегда утечка или незакрытый канал. Третий — две горутины в sync.Mutex.Lock, чьи стеки берут одни и те же локи в разном порядке: это и есть дедлок, и порядок захвата виден прямо в кадрах стека.

С Go 1.27 в дампе появились ещё две подсказки. Первая: метки горутин из runtime/pprof печатаются прямо в заголовке горутины (если в go.mod стоит go 1.27 или новее) — если вы навешиваете pprof.SetGoroutineLabels с идентификатором запроса, строка выглядит как goroutine 5 [chan receive, 43 minutes] {request_id: "abc-123", route: /orders}, и зависшая горутина сразу привязывается к конкретному запросу вместо гадания по стеку (отключается GODEBUG=tracebacklabels=0). Вторая: горутины, которые рантайм счёл утёкшими, когда снимали профиль утечек, помечены словом (leaked) в состоянии — [chan receive (leaked)].

Стратегии предотвращения

  • Глобальный порядок захвата. Все локи в системе нумеруются (например, по адресу, по имени, по слою), и захватывать их можно только по возрастанию. Циклическое ожидание становится невозможным. Это гарантия, а не снижение вероятности, и она работает даже там, где без нескольких локов сразу не обойтись.
  • Не звать чужой код под локом. Колбэк, метод интерфейса, обращение в другой сервис под мьютексом — источник дедлоков, потому что вы не знаете, какие локи возьмут внутри. Правило: под локом только работа со своими полями.
  • Один лок вместо двух. Часто два мьютекса заводят «для гранулярности», а в профиле никакого контеншена на них нет. Один более крупный лок проще и безопаснее.
  • Копировать данные наружу. Взять лок, скопировать нужное в локальную переменную, отпустить, дальше работать без лока. Критическая секция в две строки почти не может участвовать в дедлоке.
  • Таймауты вместо вечного ожидания. На каналах — select с ctx.Done(); на семафоре — semaphore.Weighted.Acquire(ctx, n); в БД — lock_timeout и statement_timeout. Дедлок превращается в ошибку, которую видно в логах и метриках.
  • TryLock (Go 1.18+) — взять лок, если свободен, иначе сразу вернуться. Позволяет откатиться и повторить. В документации стоит прямое предупреждение: правильное применение редко, и часто это признак проблемы в дизайне. Без рандомизированного бэкоффа (backoff — пауза перед повторной попыткой, которая растёт с каждым провалом; рандомизированный — значит с добавленным случайным разбросом, чтобы повторы разных горутин не совпадали) легко получить livelock вместо дедлока.
// Порядок захвата по адресу: перевод между счетами, оба под локом.
// Без сортировки Transfer(a,b) и Transfer(b,a) дедлочатся на первой же коллизии.
func Transfer(from, to *Account, sum int) error {
    if from == to {
        return nil                           // иначе второй Lock того же мьютекса — дедлок
    }
    first, second := from, to
    if uintptr(unsafe.Pointer(first)) > uintptr(unsafe.Pointer(second)) {
        first, second = second, first        // всегда меньший адрес первым
    }
    first.mu.Lock()
    defer first.mu.Unlock()
    second.mu.Lock()
    defer second.mu.Unlock()

    if from.balance < sum {
        return errNotEnough
    }
    from.balance -= sum
    to.balance += sum
    return nil
}

// Вариант с откатом: держим первый лок, второй пробуем; не вышло, отпускаем всё.
func TransferTry(from, to *Account, sum int) error {
    for attempt := 0; attempt < 100; attempt++ {
        from.mu.Lock()
        if to.mu.TryLock() {
            defer from.mu.Unlock()
            defer to.mu.Unlock()
            return apply(from, to, sum)
        }
        from.mu.Unlock()                     // перед повтором обязательно отпускаем
        // рандом здесь не для красоты: без него две горутины входят в livelock
        time.Sleep(time.Duration(rand.Intn(1<<min(attempt, 10))) * time.Microsecond) // потолок ~1 мс
    }
    return errBusy
}

Livelock и starvation

Оба слова описывают состояние «прогресса нет», но ни одно из них не равно дедлоку, и между собой они тоже разные. Livelock (дословно «живая блокировка») — горутины вовсе не спят: они замечают конфликт, вежливо откатываются, пробуют снова и попадают в тот же конфликт, потому что делают это синхронно, в такт друг с другом. Работа кипит, процессор загружен на сто процентов, полезного результата ноль. Бытовая аналогия — двое в узком коридоре, которые одновременно шагают в одну и ту же сторону, чтобы разойтись, и снова оказываются нос к носу. Аналогия честная ровно до одного места: люди через пару раз сообразят и один остановится, а код без рандомизации задержки будет повторять это до конца процесса.

Starvation (голодание) — случай мягче: система в целом отлично прогрессирует, метрики зелёные, но конкретная горутина раз за разом проигрывает конкуренцию за ресурс и не продвигается. От дедлока отличается тем, что горутина могла бы работать в любой момент — ей просто не дают; от livelock — тем, что страдает не вся система, а невезучий участник, и видно это не по CPU, а по хвосту латентности.

ДедлокLivelockStarvation
Состояние горутинзаблокированы, _Gwaitingактивно работают, жгут CPUrunnable, но не получают ресурс
Прогресснулевой навсегданулевой, но «работа кипит»есть у системы, нет у конкретной горутины
Как выглядит0% CPU, всё встало100% CPU, ничего не меняетсяхвост латентности p99 улетает в небо
Типичная причинациклическое ожидание локовTryLock+повтор в такт, вежливые уступкинесправедливая очередь, писатель под потоком читателей
Лечениепорядок локов, таймаутырандомизированный экспоненциальный бэкоффчестные очереди, ограничение читателей, приоритеты

В самом Go со starvation борется sync.Mutex: если горутина не может получить лок дольше 1 мс, мьютекс переключается в starvation mode и начинает передавать владение строго по очереди FIFO, отключив спиннинг новых претендентов. Это лечит худший случай ценой пропускной способности. У sync.RWMutex защита другая: как только писатель встал в очередь, новые RLock() блокируются — иначе бесконечный поток читателей никогда не пустил бы писателя. Побочный эффект известен: рекурсивный RLock в этот момент дедлочится.

Утечки горутин

Горутина — это объект в куче: 2 КБ стека на старте (растёт по мере надобности), запись в allgs, работа для GC при каждом цикле. Горутина, которая никогда не завершится, — это утечка памяти, а вместе с ней обычно утекает и то, что она держит: соединение, буфер, замыкание с большим объектом. Сотня таких в секунду — и через сутки процесс уходит в OOM.

СценарийКод-запахПочинка
Никто не читает из каналагорутина шлёт результат в небуферизованный канал, а получатель ушёл по таймаутубуфер размером 1 на канал результата, либо select с ctx.Done() на отправке
Забыли cancel()ctx, _ := context.WithCancel(parent)defer cancel() всегда; go vet ловит это правилом lostcancel
range по каналу, который не закрылипродюсер упал по ошибке, не дойдя до closedefer close(out) у владельца канала
Бесконечный цикл без выходаfor { select { case v := <-in: ... } } без ctx.Done()обязательная ветка case <-ctx.Done(): return
Тикер без Stopt := time.NewTicker(d) и выход из функцииdefer t.Stop(); до Go 1.23 незастопленный тикер жил вечно
Тело ответа не закрытоhttp.Get без defer resp.Body.Close()закрывать всегда, даже если тело не читаете (плюс io.Copy(io.Discard, body))
WaitGroup ждёт вечноAdd(n), а одна ветка вышла раньше Done()defer wg.Done() первой строкой; в Go 1.25 — wg.Go(...)
Подписка без отпискигорутина на канале брокера/подписчика, которого забыли отменитьвладелец подписки закрывает её в defer
// Классическая утечка: таймаут у вызывающего, вечная блокировка у воркера
func leaky(ctx context.Context) (int, error) {
    ch := make(chan int)                 // небуферизованный
    go func() {
        ch <- slowWork()                 // если ctx истёк, получателя нет и горутина стоит вечно
    }()
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err()              // ушли, горутина осталась. И так на каждый таймаут.
    }
}

// Починка 1 (самая простая): буфер 1, отправка всегда проходит, горутина завершится
func fixedBuffer(ctx context.Context) (int, error) {
    ch := make(chan int, 1)
    go func() { ch <- slowWork() }()
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}

// Починка 2 лучше: работа сама отменяется и не жжёт CPU впустую
func fixedCancellable(ctx context.Context) (int, error) {
    ch := make(chan int, 1)
    go func() { ch <- slowWorkCtx(ctx) }()   // slowWorkCtx уважает ctx.Done()
    select {
    case v := <-ch:
        return v, nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}
// В тестах: goleak падает, если после теста остались лишние горутины
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m,
        // известные вечные горутины библиотек заглушают явно, поимённо
        goleak.IgnoreTopFunction("go.opencensus.io/stats/view.(*worker).start"),
    )
}

func TestWorker(t *testing.T) {
    defer goleak.VerifyNone(t)           // проверка на уровне одного теста
    ...
}

// В проде: метрика + алерт на монотонный рост
prometheus.MustRegister(prometheus.NewGaugeFunc(
    prometheus.GaugeOpts{Name: "go_goroutines_custom"},
    func() float64 { return float64(runtime.NumGoroutine()) },
))
Go 1.27: профиль goroutineleak — утечки теперь ищет сам рантайм

Раньше утечку выводили косвенно: «горутин стало больше, чем было», «тысяча горутин застряла на одной строке — подозрительно». Отличить настоящую утечку от горутины, которая просто долго ждёт по делу, инструменты не умели. Это делал глазами человек, глядя в дамп. В Go 1.26 появился экспериментальный профиль goroutineleak, а в Go 1.27 он стал общедоступным: лежит в pprof.Profiles() рядом с goroutine, heap и block, отдаётся по /debug/pprof/goroutineleak, если импортирован net/http/pprof.

Критерий тут не эвристический, а точный. Горутина считается утёкшей, если она заблокирована на примитиве синхронизации (канал, WaitGroup, семафор), до которого уже никто не может дотянуться. Считает сборщик мусора: не осталось на этот канал ни одной ссылки ни из одной работающей горутины (и тех, кого они могут разбудить), значит разбудить ждущего некому, и он не «ждёт», а висит навсегда. Отсюда и граница применимости. Горутина, застрявшая на живом и достижимом канале (скажем, он лежит в глобальной переменной), в профиль не попадёт: для рантайма прогресс там ещё возможен.

// В коде как любой другой профиль
pprof.Lookup("goroutineleak").WriteTo(os.Stdout, 1)
// goroutineleak profile: total 2
// 1 @ 0x... 0x... 0x...
// #	0x...	main.leakChan+0x2f	/app/main.go:11

// По HTTP, если импортирован net/http/pprof:
// curl 'http://127.0.0.1:6060/debug/pprof/goroutineleak?debug=1'

Две практические детали, которые видно только при запуске. Первая: снятие профиля запускает принудительный цикл GC — в GODEBUG=gctrace=1 он так и помечен, (checking for goroutine leaks). Это не бесплатно, поэтому дёргать его на каждый скрейп метрик не надо: место такому профилю рядом с heap-профилем, по требованию или по алерту. Вторая: Count() у этого профиля до первого WriteTo возвращает ноль — анализ выполняется в момент снятия, а не ведётся постоянно. Полагаться можно только на снятый профиль. Заодно после снятия у найденных горутин и в обычном дампе появляется пометка прямо в заголовке: goroutine 35 [chan receive (leaked)].

Правило, которое стоит произнести вслух

У каждой запущенной горутины должен быть явный ответ на вопрос «что её завершит». Ответов ровно три: закроется входной канал, сработает ctx.Done(), закончится конечная работа. Не подходит ни один? Утечка. Чинить надо сейчас, а не когда вырастет график. И зеркальное правило: кто запустил горутину, тот обязан уметь её дождаться, через WaitGroup, errgroup или канал завершения.

Вопросы

10
Суть: race condition — результат зависит от порядка выполнения конкурентных операций. counter++ — это load/add/store, три шага, между которыми может влезть кто угодно. Чинится мьютексом (универсально), atomic (быстро, одно слово) или горутиной-владельцем с каналом (когда вокруг состояния есть логика).

Как отвечать по шагам

  1. Назвать проблему точно. «counter++ не атомарен: по сути это чтение из памяти, инкремент и запись обратно (на arm64 три инструкции, на amd64 одна INCQ, но без префикса LOCK и она не атомарна). Две горутины читают одно и то же значение, обе прибавляют единицу, обе записывают — одно обновление потеряно. Это lost update.»
  2. Показать масштаб. «На 1000 горутин по 1000 инкрементов вместо миллиона получится случайное число, и чем больше ядер, тем оно меньше: на двух ядрах около 64% от ожидаемого, на 14 ядрах около 22%. И оно разное от запуска к запуску.»
  3. Доказать инструментом. go run -race main.go и показать WARNING: DATA RACE с двумя стеками.
  4. Починить и объяснить выбор. Три варианта из теории выше, и главное тут критерий выбора, а не список из трёх пунктов.

Компактный сравнительный бенчмарк

// go test -bench=. -cpu=1,8 -benchmem     (M4 Pro, go1.27; канал — горутина-владелец)
// BenchmarkMutex       1.898 ns/op       BenchmarkMutex-8     122.9 ns/op
// BenchmarkAtomic      1.697 ns/op       BenchmarkAtomic-8    31.58 ns/op
// BenchmarkChannel     51.03 ns/op       BenchmarkChannel-8   163.5 ns/op
// (при -cpu=1 суффикса -1 у имени нет; 0 B/op и 0 allocs/op опущены)
//
// Порядок цифр важнее самих цифр: без конкуренции мьютекс почти не дороже atomic
// (быстрый путь Lock и Unlock — пара атомарных операций), а канал дороже на порядок.
// Под конкуренцией всё дорожает: atomic вчетверо дешевле мьютекса, канал ненамного
// дороже него. Atomic упирается в кеш-когерентность (одна кеш-линия на все ядра),
// а мьютекс в парковку/пробуждение горутин.
Ловушка «шардированный счётчик»

Если интервьюер скажет «а atomic на 64 ядрах всё равно медленный, что делать» — правильный ответ: шардировать. Массив из N счётчиков, каждый в своей кеш-линии (padding до 64 байт на x86, до 128 на Apple Silicon), горутина пишет в шард по своему индексу, Value() суммирует все. Так устроен xsync.Counter (по образцу LongAdder из Java), так же по P разложен sync.Pool внутри. Цена — Value() перестаёт быть мгновенным и точным снимком.

Чем добить ответ

Упомянуть, что «починить гонку» и «сделать логику правильной» — разные задачи. atomic.AddInt64 делает инкремент атомарным, но если бизнес-логика — «прочитать, проверить лимит, увеличить», то нужен CompareAndSwap в цикле или мьютекс: атомарность одной операции не даёт атомарности последовательности.

Суть: data race — формальное свойство памяти (два конкурентных доступа без happens-before, хотя бы один на запись). Race condition — свойство логики (результат зависит от порядка). Каждая data race порождает race condition, обратное неверно. Первое ловит -race, второе — только голова.

Два примера, которые стоит держать наготове

// A. Data race без очевидной race condition в бизнес-смысле:
//    флаг, который просто «когда-нибудь» станет true.
var done bool                        // без sync
go func() { done = true }()
for !done { }                        // может крутиться вечно

// Почему вечно: компилятор имеет право поднять чтение done из цикла в регистр,
// потому что в отсутствие синхронизации он вправе считать, что никто извне
// переменную не меняет. Go 1.27 так не делает и перечитывает done на каждом витке,
// но модель памяти Go прямо пишет, что такой цикл не обязан завершиться.
// Правильно: atomic.Bool, канал или sync.Once.

// B. Race condition без data race: вся память под мьютексом, логика всё равно неверна.
if !cache.Has(key) {                 // Has() внутри под RLock, data race нет
    v := expensiveLoad(key)          // между Has и Set влезли ещё 50 горутин
    cache.Set(key, v)                // и все 50 сделали expensiveLoad
}
// Это TOCTOU (time-of-check to time-of-use). Чинится не мьютексом,
// а объединением проверки и действия: singleflight или LoadOrStore.

Формальное определение из Go Memory Model

«Программа с гонкой данных — это программа, в которой есть обращение на запись к переменной памяти, конкурентное с другим обращением к той же переменной, и хотя бы одно из них не является атомарным.» Конкурентные — значит не упорядоченные отношением happens-before. С 1.19 модель Go явно ограничивает и поведение при гонке: чтение значения размером в машинное слово вернёт одно из значений, записанных предшествующей или конкурентной записью, а не что-то «из воздуха». Для значений больше слова (интерфейс, строка, слайс) такой гарантии нет, и гонка на них может закончиться порчей памяти. Но это всё равно не C++, где UB тотальный.

Про «рваные» значения

На 64-битных платформах запись машинного слова с выравниванием на практике атомарна, и int64 «порваться» не может. А вот интерфейс, строка и слайс — могут: это два или три машинных слова (данные + тип, данные + длина, данные + длина + ёмкость). Конкурентная запись двух разных значений в одну переменную типа error способна дать указатель на данные от одного значения и дескриптор типа от другого — и следующий вызов метода уронит процесс. Это самое убедительное объяснение, почему «ну подумаешь, гонка на присваивании» — не аргумент.

Соседний вопрос

«Гонка на map — это data race?» Да, и рантайм её ловит отдельно, без -race: у map есть флаг записи (с Go 1.24 поле writing, раньше бит hashWriting), и при конкурентной записи программа падает с fatal error: concurrent map writes (при чтении во время записи — с concurrent map read and map write). Не паника, а fatal error, и перехватить его через recover нельзя. Причина такой жёсткости: гонка на map ломает внутренние структуры, и продолжать выполнение опаснее, чем упасть.

Суть: ThreadSanitizer внутри рантайма. Компилятор вставляет вызов перед каждым доступом к памяти, детектор ведёт векторные часы на горутину и теневые ячейки на каждые 8 байт данных, и сравнивает: установлен ли happens-before между этим доступом и предыдущим. Ловит только выполненные пути, стоит ×5–10 по памяти и ×2–20 по времени, race condition не ловит принципиально.

Что говорить, если просят «в общих чертах»

«Каждая горутина носит вектор часов — по счётчику на каждую известную горутину. Синхронизация (Unlock/Lock, отправка/приём в канале, atomic) передаёт эти часы от одной горутины к другой через максимум. Каждые 8 байт памяти сопровождаются несколькими теневыми ячейками, где записано "горутина G, эпоха E, чтение/запись, размер". При новом доступе детектор сверяет эпоху из тени со своим знанием о той горутине: если моя копия часов уже "видит" ту эпоху — доступы упорядочены, всё в порядке. Если нет — это гонка, и в отчёт печатаются оба стека.»

Три следствия, которые редко проговаривают

  • Гонке не нужно проявиться. Достаточно, чтобы обе операции выполнились хоть раз, пусть даже с разрывом в минуты. Поэтому -race находит то, что в проде падает раз в квартал.
  • Ложных срабатываний практически не бывает. Отчёт детектора — это доказательство, а не подозрение. Если он что-то напечатал, это надо чинить, а не «перепроверять».
  • Он проверяет и стандартную библиотеку. Гонки внутри неё (неправильное использование http.Request, запись в http.ResponseWriter из горутины после выхода из хендлера) находятся тем же способом.

Где искать, что -race не найдёт

Не найдётПочемуЧем ловить
race condition (TOCTOU, потерянное обновление на уровне логики)машина не знает бизнес-смысларевью, тесты на инварианты, testing/synctest
гонки в непокрытом кодеинструментация работает только на выполненных путяхпокрытие, нагрузочные тесты, -count=N
гонки, требующие много горутинв тесте один воркер — конкуренции неттест с реальным числом воркеров и -cpu=8
cgo, ассемблер, unsafe-трюкивне зоны инструментациисанитайзеры на стороне C, ревью
очень давние доступыистория обращений горутины конечна: если между двумя доступами она сделала десятки тысяч других, гонка молча не попадёт в отчётGORACE=history_size=7 (дороже, но глубже)
Что говорить про практику

go test -race ./... — обязательный шаг CI, не опциональный. Конкурентные тесты гоняют с -count=5..10 и явным -cpu. В прод бинарь с -race не катят (память ×5–10, время ×2–20), исключение — канареечный под на короткий эксперимент, когда гонка не воспроизводится нигде, кроме прода. GORACE="halt_on_error=1" удобно в CI, чтобы падать на первой находке.

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

Ключ к «гарантированно»

Формулировка вопроса не случайна: без принудительного окна код с двумя мьютексами дедлочится вероятностно и на маленьком примере может отработать сто раз подряд. Правильный ответ содержит time.Sleep (или runtime.Gosched(), или синхронизацию на канале) между первым и вторым Lock — это делает совпадение окон детерминированным. Проговорить это вслух — половина оценки за вопрос.

// Вариант с каналом вместо Sleep: строгая гарантия, без гонки по времени.
func main() {
    var muA, muB sync.Mutex
    ready := make(chan struct{})          // «я взял свой первый лок»
    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        defer wg.Done()
        muA.Lock()
        ready <- struct{}{}               // сообщил
        <-ready                           // дождался встречного
        muB.Lock()                        // оба лока заняты крест-накрест
        muB.Unlock(); muA.Unlock()
    }()
    go func() {
        defer wg.Done()
        muB.Lock()
        <-ready                           // дождался G1 и только потом ответил:
        ready <- struct{}{}               // шли бы обе первыми — встали бы на канале
        muA.Lock()
        muA.Unlock(); muB.Unlock()
    }()
    wg.Wait()
}

Дедлоки, которые встречаются в реальном коде

  • Реентрантный вызов. Метод берёт лок и зовёт другой публичный метод того же типа, который берёт тот же лок. sync.Mutex не реентрантный — мгновенный самодедлок. Лечение: приватные методы xxxLocked(), которые лок не берут, и соглашение о суффиксе.
  • Колбэк под локом. Под мьютексом вызывается пользовательская функция, которая сама берёт этот же лок (например, обращается к тому же объекту).
  • RLock внутри RLock. Выглядит безобидно (читатели же не мешают друг другу), но если между ними встал писатель — дедлок, потому что RWMutex блокирует новых читателей ради писателя.
  • WaitGroup.Wait() раньше чтения. Воркеры пишут в небуферизованный results, main сначала wg.Wait(), потом читает — воркеры стоят на отправке, main стоит на Wait. Классика конвейеров.
  • Один канал на запрос и на ответ. Обе стороны шлют, обе ждут.
Что здесь легко сделать неправильно

Написать defer muA.Unlock() и defer muB.Unlock() в цикле: defer срабатывает в конце функции, а не итерации, и вторая итерация попробует взять уже удерживаемый лок и самозадедлочится. Вторая ошибка: «починить» дедлок, поменяв Lock на RLock. Он тоже блокирующий, просто реже конфликтует, и проблема вернётся под нагрузкой.

Суть: функция checkdead() в планировщике проверяет не дедлок, а полное отсутствие возможного прогресса: ни одной runnable-горутины, ни одного потока в syscall, ни одного активного таймера. Частичный дедлок, тикеры, busy-loop и сетевые ожидания это условие ломают — в проде оно почти никогда не выполняется.

Механика

Планировщик вызывает checkdead в момент, когда последний «рабочий» M собирается уснуть (в stopm, при mput). Сначала он считает неспящие потоки: все M минус простаивающие, минус простаивающие, но привязанные к горутине через LockOSThread, минус служебные вроде sysmon. Поток в системном вызове не простаивает, и на нём проверка заканчивается. Если неспящих M нет, он обходит все горутины и смотрит таймеры в кучах P. Таймеров нет, а живые горутины есть и все ждут — значит, ждут события, которое некому произвести. Рантайм печатает fatal error: all goroutines are asleep - deadlock!, дампит стеки и завершает процесс с кодом 2.

Дополнительно в дампе у частных случаев уточнённое состояние: отправка в nil-канал и приём из него дают [chan send (nil chan)] и [chan receive (nil chan)], select без единого кейса — [select (no cases)]. Это лишь подсказка, а не отдельный алгоритм.

Пять ситуаций, когда детектор молчит

СитуацияПочему условие не выполнено
Частичный дедлок — 10 горутин встали, остальные работаютесть runnable-горутины; программа «жива»
Горутина ждёт тика time.Ticker или спит в Sleepактивный таймер = будущая работа; тикер, из которого никто не читает, с Go 1.23 не в счёт
Busy-loop for {} или спиннинг на atomicгорутина в состоянии running
Ожидание сети / файла / cgoM в syscall, данные могут прийти извне
Дедлок в БД или в распределённом локес точки зрения Go это обычный сетевой вызов
// Один и тот же дедлок: с падением и без.
func main() {
    ch := make(chan int)
    <-ch                       // fatal error: all goroutines are asleep
}
// stderr, go1.27:
//   fatal error: all goroutines are asleep - deadlock!
//
//   goroutine 1 [chan receive]:
//   main.main()
//           /tmp/x/main.go:6 +0x30
//   exit status 2
// (вариант с отправкой в такой же канал отличается одним словом: [chan send])

func main() {
    go func() {                // «heartbeat», который живёт всегда
        for range time.Tick(time.Second) {
        }
    }()
    ch := make(chan int)
    <-ch                       // зависнет молча: таймер = потенциальный прогресс.
}                              // Так ведёт себя любой реальный сервис.
// вывода нет вообще, ни в stdout, ни в stderr: процесс висит, пока его не убьют

// Это fatal error, а не паника, поэтому defer с recover не поможет.
func main() {
    defer func() {
        if r := recover(); r != nil { fmt.Println("поймали:", r) }  // не выполнится никогда
    }()
    ch := make(chan int)
    <-ch
}
// stderr: fatal error: all goroutines are asleep - deadlock!  (строки «поймали:» нет,
// рантайм не разматывает стек и вообще не запускает отложенные функции)
Чем добить ответ

Сказать, что детектор рантайма бесполезен в проде и полезен в тестах. В тестах он превращает забытый Done() или незакрытый канал в громкое падение с полными стеками. Именно поэтому конкурентные юнит-тесты пишут без фоновых тикеров, а на «зависший» тест ставят -timeout: по таймауту тестовый бинарь, собранный go test, сам падает с panic и печатает стеки всех горутин — это второй способ увидеть частичный дедлок.

Суть: снять goroutine-дамп. Мягко — через /debug/pprof/goroutine?debug=2, жёстко — kill -QUIT (процесс умрёт). Дальше сгруппировать горутины по состоянию и стеку и найти те, что стоят дольше любого разумного таймаута.

Порядок действий по инциденту

  1. Посмотреть метрики. go_goroutines — полка или рост? CPU — ноль (дедлок) или сотка (livelock)? RPS упал в ноль или частично?
  2. Снять дамп, не убивая под. curl host:6060/debug/pprof/goroutine?debug=2 > dump.txt. Если pprof не подключён — снимать через kill -QUIT на поде, который не жалко, предварительно выведя его из балансировки.
  3. Сгруппировать. Состояние указано в заголовке каждой горутины, а у стоящих дольше минуты — и время: goroutine 42 [sync.Mutex.Lock, 17 minutes].
  4. Найти пару. Для дедлока на локах характерны две-три группы, чьи стеки берут одни и те же мьютексы в разном порядке. Для утечки — одна разросшаяся группа с одинаковым стеком.
  5. Проверить внешние ресурсы. Если все висят в IO wait / select вокруг database/sql — это не дедлок Go, а исчерпанный пул соединений или дедлок в самой БД.
# Минимальный набор для прода: pprof на отдельном порту, не наружу
go func() { log.Println(http.ListenAndServe("127.0.0.1:6060", nil)) }()
# import _ "net/http/pprof"

# Снять и разобрать
curl -s '127.0.0.1:6060/debug/pprof/goroutine?debug=2' > dump.txt
curl -s '127.0.0.1:6060/debug/pprof/goroutine?debug=1' | head -40   # агрегат по стекам

# Топ состояний с временем ожидания
grep -o '\[[a-z A-Z.,0-9]*\]' dump.txt | sort | uniq -c | sort -rn | head
#    5120 [chan receive, 6 minutes]
#      25 [IO wait, 6 minutes]
#       2 [sync.Mutex.Lock, 6 minutes]
#       2 [IO wait]
#       1 [sleep, 6 minutes]
#       1 [running]

# Интерактивно, с деревом вызовов
go tool pprof -http=:8080 http://127.0.0.1:6060/debug/pprof/goroutine

# Контеншен и блокировки (включать точечно, они не бесплатны)
runtime.SetMutexProfileFraction(5)     # /debug/pprof/mutex
runtime.SetBlockProfileRate(1_000_000) # /debug/pprof/block, 1 сэмпл на 1мс блокировки
Практика: держать дамп под рукой заранее

В инциденте нет времени договариваться о доступе к поду. Рабочая схема: pprof слушает на 127.0.0.1, рядом есть sidecar или kubectl port-forward, а в runbook записана готовая строка curl. Дополнительно полезен автосбор: по алерту «go_goroutines > N» хук сам складывает дамп в S3 — иначе под перезапустится раньше, чем вы к нему подключитесь, и улика исчезнет.

На что ловят

Первое: net/http/pprof регистрирует хендлеры в http.DefaultServeMux, и если основной сервер использует его же — профили окажутся в публичном интернете. Второе: SIGQUIT, если процесс не перехватил его сам, убивает процесс, поэтому в кубере под сначала выводят из балансировки. Третье: debug=2 на 500 тысячах горутин упирается в потолок 64 МБ (дальше дамп молча обрезается) и даёт заметные stop-the-world паузы на обход allgs; на большом дампе безопаснее debug=1.

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

Иерархия приёмов — от сильного к слабому

ПриёмЧто даётЦена и риски
Не брать два лока вообщедедлок невозможен по построениюиногда требует пересмотра модели данных
Глобальный порядок захватаматематическая гарантия: цикла в графе быть не можетнужна дисциплина и документация; порядок легко нарушить на ревью
Копировать данные наружукритическая секция в 2 строки почти не участвует в дедлокахкопия может устареть — иногда это race condition
Не звать чужой код под локомубирает целый класс неявных дедлоковтребует расщепления методов на xxxLocked
Таймаут / ctxдедлок становится наблюдаемой ошибкой в метрикахне лечит причину; надо решить, что делать при истечении
TryLock + откатломает «отсутствие вытеснения»без рандомного бэкоффа — livelock; в доке Go прямое предупреждение о редкости корректных применений

Как выглядит документированная иерархия локов

// Порядок захвата в пакете storage (нарушение = дедлок):
//   1. Registry.mu      (глобальный реестр)
//   2. Shard.mu         (шард)
//   3. Entry.mu         (запись)
// Захватывать только сверху вниз. Никогда не брать Registry.mu, держа Shard.mu.

type Shard struct {
    mu sync.Mutex
    m  map[string]*Entry
}

// Публичный метод берёт лок и делегирует в *Locked-версию.
func (s *Shard) Get(k string) (*Entry, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.getLocked(k)
}

// Соглашение: суффикс Locked = «лок уже держит вызывающий, сам не бери».
func (s *Shard) getLocked(k string) (*Entry, bool) {
    e, ok := s.m[k]
    return e, ok
}

Таймаут вместо вечного ожидания

// У sync.Mutex нет LockContext. Если нужен лок с таймаутом,
// берут семафор на единицу или канал-мьютекс.
import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(1)

if err := sem.Acquire(ctx, 1); err != nil {
    return fmt.Errorf("не дождались лока: %w", err)   // ctx.Err(), а не вечный сон
}
defer sem.Release(1)

// Дешёвая альтернатива без зависимостей: буферизованный канал ёмкости 1.
type ChanMutex chan struct{}

func NewChanMutex() ChanMutex { return make(ChanMutex, 1) }

func (m ChanMutex) Lock(ctx context.Context) error {
    select {
    case m <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
func (m ChanMutex) Unlock() { <-m }
// Цена: без конкуренции на порядок дороже sync.Mutex (на M4 Pro 23 нс против 2).
// Берут только там, где отменяемость важнее наносекунд.
Классическая ошибка с TryLock

Забыть отпустить уже взятый лок перед повтором. Код a.Lock(); if !b.TryLock() { continue } без a.Unlock() превращает попытку избежать дедлока в гарантированный дедлок плюс утечку. Вторая ошибка — повтор без задержки и без джиттера: две симметричные горутины входят в идеальный такт и получают livelock, который выглядит как 100% CPU и полное отсутствие прогресса.

Суть: на каналах — да, и это самый частый вид дедлока в Go. На внешних ресурсах — да, но рантайм их не видит (для него это syscall). Умышленно заблокированная горутина дедлоком не является, если существует событие, способное её разбудить; если такого события нет — это утечка, а при остановке всей программы рантайм назовёт её дедлоком.

Дедлоки на каналах — витрина случаев

// 1. Отправка без читателя (небуферизованный канал)
ch := make(chan int)
ch <- 1                              // навсегда

// 2. Приём из канала, в который никто не пошлёт и который не закроют
<-make(chan int)

// 3. Полный буфер + потребитель, который ждёт продюсера
in := make(chan int, 2)
in <- 1; in <- 2
in <- 3                              // буфер полон, потребитель ещё не стартовал

// 4. nil-канал: операция блокируется навсегда, а не паникует
var nilCh chan int
nilCh <- 1                           // [chan send (nil chan)]

// 5. select без кейсов (на одних nil-каналах встанет так же, но в дампе [select])
select {}                            // [select (no cases)]

// 6. Взаимный обмен: обе горутины сначала шлют
go func() { a <- 1; <-b }()
go func() { b <- 1; <-a }()          // оба стоят на отправке

// 7. WaitGroup.Wait() до чтения результатов
results := make(chan int)            // небуферизованный
for i := 0; i < 3; i++ {
    wg.Go(func() { results <- work() })
}
wg.Wait()                            // воркеры встали на отправке: дедлок
for r := range results { ... }
// Чинится закрытием канала в отдельной горутине:
//   go func() { wg.Wait(); close(results) }()
//   for r := range results { ... }

Внешние ресурсы

  • База данных. Две транзакции берут строки в разном порядке — классический дедлок. Postgres обнаруживает его сам (по умолчанию через deadlock_timeout, 1 с) и убивает одну транзакцию с SQLSTATE 40P01; MySQL/InnoDB — с ошибкой 1213. Задача приложения — распознать код и повторить транзакцию. Профилактика та же: единый порядок обновления строк, короткие транзакции, SELECT ... FOR UPDATE в детерминированном порядке (например, ORDER BY id).
  • Пул соединений. Отдельный, очень частый случай: хендлер держит соединение и внутри просит второе из того же пула (например, вложенный запрос под транзакцией). При SetMaxOpenConns(N) и N параллельных запросах пул исчерпан, и все ждут друг друга. Симптом — все горутины в database/sql.(*DB).conn. Лечение: не брать второе соединение под первым, передавать *sql.Tx вниз явно.
  • Распределённые локи. Redis/etcd/Zookeeper: дедлок возможен, детектора нет ни у кого. Спасает только TTL на локе и единый порядок захвата.
  • Файлы и ОС. flock, именованные семафоры, полный pipe, который никто не читает.
Умышленная блокировка: где граница

select {} в конце main, чтобы сервис не завершился, — не дедлок с точки зрения дизайна: программа делает ровно то, что задумано (работают другие горутины). Воркер, стоящий на <-jobs в ожидании следующей задачи, тоже не дедлок — событие, которое его разбудит, существует и наступит. Формальный критерий один: существует ли в принципе событие, способное разблокировать горутину. Если да — это ожидание. Если нет (канал потерян, писатель умер, отменять некому) — это утечка, даже если программа продолжает работать. А если такая горутина осталась единственной живой, рантайм назовёт это дедлоком и уронит процесс — что и происходит с select{} в пустом main.

Суть: дедлок — все спят и прогресса нет. Livelock — все активно работают, состояние меняется, но полезного прогресса нет (жгут CPU впустую). Starvation — система в целом прогрессирует, но конкретная горутина систематически не получает ресурс.

Livelock: как получается

// Две горутины «вежливо уступают» друг другу и обе не двигаются.
func transfer(from, to *Account) {
    for {
        from.mu.Lock()
        if to.mu.TryLock() {
            defer from.mu.Unlock()
            defer to.mu.Unlock()
            return                     // успех
        }
        from.mu.Unlock()               // уступил и сразу пробует снова, вот и livelock:
    }                                  // без паузы горутины легко входят в такт
}                                      // и повторяют один и тот же конфликт

// Лечится рандомизированным экспоненциальным бэкоффом. Фиксированная пауза
// спасает лишь случайно: горутины разводит разброс пробуждений таймеров.
backoff := time.Duration(rand.Int63n(int64(base))) * (1 << attempt)

Другие источники livelock в реальных системах: цикл ретраев без джиттера (все клиенты бьют в один и тот же момент — «громовое стадо») и взаимные откаты транзакций, которые тут же перезапускаются. Общий признак — высокий CPU при нулевом прогрессе, что легко отличить от дедлока по графику загрузки.

Starvation в Go: где встроена защита

МестоКак могло бы голодатьЧто делает рантайм
sync.Mutexвновь прибывшие спиннингом перехватывают лок у тех, кто давно в очередиесли ожидание > 1 мс, мьютекс уходит в starvation mode: владение передаётся строго FIFO, спиннинг выключен
sync.RWMutexбесконечный поток читателей никогда не пустит писателяпосле заявки писателя новые RLock блокируются (побочный эффект: рекурсивный RLock дедлочится)
очереди каналовкто-то ждёт вечно, пока другие проскакиваютsendq/recvq строго FIFO — голодания нет by design
планировщикгорутина в тугом цикле не отдаёт Pасинхронная вытесняющая приостановка (Go 1.14+) по сигналу примерно раз в 10 мс; плюс глобальная очередь проверяется каждый 61-й тик, чтобы её не заморили
selectвсегда выбирается один и тот же готовый кейспорядок кейсов случайный при каждом исполнении
Формулировка на собес

«Три состояния отсутствия прогресса отличаются наблюдаемо. Дедлок: CPU ноль, горутины в _Gwaiting, в дампе стоят вечно. Livelock: CPU в потолок, горутины бегают, прогресса нет — почти всегда ретраи без джиттера. Starvation: прогресс есть, но у кого-то p99 улетел — обычно несправедливая очередь или писатель под потоком читателей. Диагностика разная: на дедлок смотрят goroutine-дамп, на livelock — CPU-профиль, на starvation — гистограмму латентностей и mutex-профиль.»

Суть: утечка — горутина, которая никогда не завершится. Стоит 2 КБ стека плюс всё, что она держит в замыканиях, плюс работа GC. Типовые причины: некому читать из канала, забытый cancel, канал не закрыли, цикл без ветки ctx.Done(). Ловится goleak в тестах, метрикой NumGoroutine и pprof в проде, а с Go 1.27 — ещё и штатным профилем goroutineleak, который большой класс утечек находит точно, а не по косвенным признакам.

Три вопроса к каждому go func

  1. Что её завершит? Ровно три допустимых ответа: закроется входной канал, сработает ctx.Done(), закончится конечная работа.
  2. Кто её дождётся? WaitGroup, errgroup или канал завершения. Горутина, которую никто не ждёт, при shutdown будет убита посреди работы.
  3. Что она удерживает? Замыкание держит все захваченные переменные. Горутина, которая «просто ждёт», может удерживать в живых мегабайтный буфер запроса и сетевое соединение.

Сценарии, помимо перечисленных в теории

  • Отправка в канал результата после отмены. Даже с select на ctx.Done() в цикле, финальная отправка out <- res в конце функции — незащищённая точка утечки. Отправлять тоже надо через select.
  • time.After в цикле select. Каждая итерация создаёт новый таймер, который до Go 1.23 удерживался рантаймом до срабатывания. В горячем цикле это была утечка памяти (не горутин, но эффект тот же). Так было до 1.23; стало — недостижимый таймер собирается GC, и утечки в прежнем виде больше нет. В Go 1.27 переключатель GODEBUG=asynctimerchan, которым можно было вернуть старое поведение, удалён совсем: каналы таймеров теперь всегда небуферизованные, независимо от версии в go.mod. С тикерами то же: Ticker без Stop() соберут, когда пропадут ссылки, а тикер, из которого никто не читает, рантайм не нагружает. Утечкой остаётся горутина, которая читает из тикера в цикле без выхода, — и тикер вместе с ней.
  • Горутина, ждущая на sync.Cond.Wait(). Если никто не позовёт Broadcast, разбудить её нечем: у Cond нет отмены по контексту вообще. Одна из причин, по которой Cond редко берут.
  • Незакрытый http.Response.Body. Если тело не дочитано, держит соединение и две горутины транспорта (чтения и записи), сборщик их не освободит. С лимитом соединений на хост новые запросы встают, без лимита копятся соединения и дескрипторы.
  • Подписка на события без отписки — брокер, watch в etcd, обработчик сигналов.

Инструменты обнаружения

ГдеИнструментЧто даёт
тестыgo.uber.org/goleakпадение теста, если после него остались лишние горутины — самое дешёвое место поймать
тестыtesting/synctest (стабилен с Go 1.25)фейковое время, которое идёт, только когда все горутины пузыря стоят; «пузырь» падает, если горутины навсегда заблокированы. В Go 1.27 добавили synctest.Sleeptime.Sleep плюс synctest.Wait одним вызовом
продметрика go_goroutinesмонотонный рост при ровной нагрузке = утечка; алерт на производную
продpprof/goroutine?debug=1агрегат: «7412 горутин на этой строке» — сразу видно виновника
продpprof/goroutineleak (Go 1.27; в 1.26 эксперимент)не косвенный признак, а вердикт: горутина стоит на примитиве, до которого уже никому не дотянуться. Снятие профиля форсирует цикл GC, так что не на каждый скрейп
продruntime/metrics: /sched/goroutines/waiting:goroutines (Go 1.26)разбивка живых горутин по состояниям — running, runnable, waiting, not-in-go; растёт waiting при ровной нагрузке — почти наверняка утечка (при наплыве запросов он тоже растёт, но потом спадает)
продheap-профильрост runtime.malg косвенно подтверждает (это структуры горутин; сами стеки в heap-профиль не попадают)
CIнагрузочный прогон + сравнение NumGoroutine до и послеловит утечки, невидимые на юнит-тестах
// Ручная проверка в тесте, если тянуть goleak не хочется
func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()

    srv := New()
    srv.Start()
    doWork(srv)
    srv.Stop()

    // Горутины завершаются не мгновенно: нужен опрос с дедлайном,
    // а не единичная проверка сразу после Stop.
    deadline := time.Now().Add(2 * time.Second)
    for time.Now().Before(deadline) {
        if runtime.NumGoroutine() <= before {
            return
        }
        time.Sleep(10 * time.Millisecond)
    }
    buf := make([]byte, 1<<20)
    n := runtime.Stack(buf, true)           // all=true: стеки всех горутин
    t.Fatalf("утечка: было %d, стало %d\n%s", before, runtime.NumGoroutine(), buf[:n])
}
Что проверяет интервьюер этим вопросом

Не знание списка сценариев, а рефлекс: увидев go func в чужом коде, кандидат сразу спрашивает «а как она завершается?». В хорошем ответе обязательно прозвучит, что утекает не одна память: вместе с горутиной уходят соединения, файловые дескрипторы, места в пуле и ссылки на большие объекты. Поэтому симптом бывает не «растёт RSS», а «кончились соединения к БД» или «too many open files».

2.7Конкурентные паттерны

Единственная глава темы, где почти гарантированно попросят писать код руками: в блокноте, в шареде или на доске. Паттернов немного, и складываются они из одних и тех же четырёх кирпичей: канал как очередь, close как широковещательный сигнал, select с ctx.Done() как точка отмены и WaitGroup как «дождаться всех». Шаблон наизусть тут никого не впечатляет. Смотрят, сумеешь ли объяснить, кто владеет каналом, кто его закрывает и что произойдёт при отмене посередине.

Сначала — пять названий, которые дальше идут как есть

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

1. Worker pool — пул воркеров

Worker pool («пул воркеров», «пул обработчиков»): фиксированное число одинаковых горутин, которые разбирают задачи из одной общей очереди-канала. Всё держится на слове фиксированное. Горутина на каждую задачу не создаётся, их ровно N, и живут они, пока есть работа. Одновременных обработок не больше N, сколько бы задач ни навалилось.

2. Pipeline — конвейер

Pipeline («конвейер»): цепочка стадий, где выход одной подан на вход следующей через канал. Как на заводе: пока третий участок обрабатывает деталь №1, первый уже готовит деталь №5, и работают все сразу. У каждой стадии своя горутина и свой выходной канал.

3. Fan-out и fan-in — расхождение и схождение веером

Fan-out («расхождение веером»): из одного канала читают несколько горутин и делят между собой поток задач; так распараллеливают одну стадию конвейера. Fan-in («схождение веером») работает наоборот: несколько каналов сливаются в один, чтобы читатель видел единый поток. Функцию, которая делает fan-in, по традиции зовут merge.

4. Backpressure — обратное давление

Backpressure («обратное давление»): система умеет притормозить того, кто подаёт работу, когда потребитель не успевает. Механика в Go бытовая до неприличия. Очередь сделана каналом с ограниченной ёмкостью; буфер заполнился, отправка блокируется сама собой, продюсер встал. Неограниченная очередь ведёт себя иначе: внешне «работает», пока не съест всю память, а латентность к тому моменту уже давно бессмысленная. Отсюда практическое правило: очередь без предела просто откладывает аварию.

5. Graceful shutdown — остановка без потерь

Graceful shutdown («корректная», «плавная» остановка): процесс доводит до конца уже принятую работу и не берёт новую. Иначе он просто умирает по сигналу, а следом идут оборванные HTTP-ответы, незакоммиченные сообщения брокера, неотданные соединения. Порядок шагов и бюджет времени разберём в конце главы, но одно слово запомни сразу: graceful всегда идёт с дедлайном, потому что ждать зависшую работу вечно нельзя.

Четыре правила, из которых собирается всё остальное
  1. У канала один владелец. Это тот, кто создал канал и пишет в него. Только он его закрывает, и ровно один раз. Читатели не закрывают канал никогда.
  2. Функция возвращает канал только на чтение (<-chan T), а принимает только на запись (chan<- T). Тогда компилятор сам не даст нарушить пункт 1.
  3. Каждая блокирующая операция идёт через select с ctx.Done(): и приём, и отправка. Незащищённая отправка течёт чаще всего.
  4. Тот, кто запустил горутину, обязан её дождаться. Иначе корректный graceful shutdown не написать: непонятно, когда работа закончилась.

Worker pool

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

Worker pool: два канала, N воркеров, две разные точки закрытия producer 1 горутина jobs chan Job FIFO, буфер 0..N worker 1 worker 2 worker N results chan буфер = len(jobs) collector range по results fan-out fan-in close(jobs) — только продюсер, ровно один раз, после последней задачи или при отмене close(results) — отдельная горутина go func(){ wg.Wait(); close(results) }()
Две точки закрытия, не одна. Канал задач закрывает продюсер: он один знает, что задачи кончились. Канал результатов закроет только тот, кто дождался всех воркеров, поэтому wg.Wait() уезжает в отдельную горутину. Иначе main встанет на Wait раньше, чем начнёт читать результаты, и получится дедлок.
type Job struct {
    ID      int
    Payload string
}

type Result struct {
    JobID int
    Out   string
    Err   error
}

// Run обрабатывает jobs не более чем workers горутинами.
// Возвращает все результаты (включая ошибочные) и причину досрочного выхода.
func Run(ctx context.Context, workers int, jobs []Job) ([]Result, error) {
    jobCh := make(chan Job)
    // Буфер на все результаты: воркер никогда не заблокируется на отправке,
    // даже если коллектор отстаёт. Это снимает целый класс утечек.
    resCh := make(chan Result, len(jobs))

    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := range jobCh {          // выход ровно при close(jobCh)
                out, err := handle(ctx, j)
                select {
                case resCh <- Result{JobID: j.ID, Out: out, Err: err}:
                case <-ctx.Done():          // страховка, если буфера всё же не хватило
                    return
                }
            }
        }(w)
    }

    // Продюсер единолично владеет jobCh и сам его закрывает.
    go func() {
        defer close(jobCh)                  // defer: закроется и на раннем return
        for _, j := range jobs {
            select {
            case jobCh <- j:
            case <-ctx.Done():
                return                      // раздачу прекратили, начатое доработается
            }
        }
    }()

    // Закрыть resCh вправе только тот, кто дождался всех писателей.
    go func() {
        wg.Wait()
        close(resCh)
    }()

    out := make([]Result, 0, len(jobs))
    for r := range resCh {                  // выход по close(resCh)
        out = append(out, r)
    }
    return out, ctx.Err()                   // nil, если отмены не было
}

Разбор по строкам

  • for j := range jobCh вместо for { select { case j := <-jobCh } }. Короче и корректнее: range сам завершается при close, и не надо руками разбирать ok. Отдельная ветка на ctx.Done() в цикле воркера не нужна: отмену ловит продюсер, он просто перестаёт раздавать задачи, а «прервать посередине текущей задачи» это дело handle(ctx, j).
  • wg.Add(1) до go, а не внутри. Внутри горутины Add может выполниться уже после Wait: гонка, при которой Wait вернётся раньше времени. В Go 1.25 весь этот блок ужимается до wg.Go(func(){ ... }), который делает Add(1), запуск и Done() одним вызовом.
  • Буфер len(jobs) у resCh. Воркер не застрянет на отправке ни при каких условиях. Если задач может быть миллион, буфер берут небольшой (например, workers*2), и тогда ветка case <-ctx.Done() при отправке уже обязательна, а не страховочная.
  • Отдельная горутина под wg.Wait(); close(resCh). Напишешь wg.Wait() прямо перед range resCh, и воркеры встанут на отправке в заполненный или небуферизованный канал, а main застрянет на Wait. Взаимная блокировка.
  • Ошибки как поле Result, а не отдельный канал. Отдельный errCh тянет за собой второго читателя и вторую точку закрытия; результат с полем Err не тянет ничего.
Типичные ошибки в этом коде на собесе
  • Закрыть jobCh из воркера. Второй close даёт панику close of closed channel, и её нельзя «проверить заранее».
  • Не закрыть jobCh вообще. Воркеры навсегда останутся в range: классическая утечка ровно на N горутин.
  • Захват переменной цикла. До Go 1.22 for w := 0; ... с go func(){ use(w) }() давал всем горутинам одно значение. С 1.22 переменная своя на итерацию, но на собесе это стоит проговорить: «в вашей версии Go это уже безопасно, в 1.21 нет».
  • Небуферизованный resCh плюс return коллектора по таймауту. Все воркеры повисают на отправке навсегда.
  • panic в handle убивает процесс. В проде воркер оборачивают в defer func(){ if r := recover(); r != nil { ... } }(), иначе одна плохая задача роняет весь сервис.
Что спросит интервьюер следующим
  1. «Как выбрать N?» CPU-bound упирается в runtime.GOMAXPROCS(0) (больше не ускорит, только добавит переключений). IO-bound упирается во внешний ресурс: лимит стороннего API, MaxOpenConns у БД, число партиций. Универсального числа нет, зато есть универсальный ответ: «замерить, а потом сделать настраиваемым».
  2. «Что если задачи приходят потоком, а не слайсом?» Ничего не меняется: продюсер читает из своего источника, всё остальное как есть. Меняется только буфер resCh: по числу задач его уже не выставишь.
  3. «Как добавить ретраи?» Внутри handle, с бэкоффом и джиттером, либо отдельным каналом retryCh, но тогда заводи счётчик попыток в Job и защиту от вечного цикла.
  4. «Как остановиться по первой ошибке?» Это уже errgroup, он ниже отдельным паттерном.
  5. «А если воркеры должны быть постоянными, а не на один вызов?» Пул превращается в долгоживущий компонент со своим Start()/Stop(), и вся сложность переезжает в graceful shutdown.

Долгоживущий пул как компонент

// В реальном сервисе чаще так: пул живёт всё время работы
// приложения, задачи приходят снаружи, останавливается вместе с приложением.
type Pool struct {
    jobs chan Job
    wg   sync.WaitGroup
    once sync.Once
}

func NewPool(ctx context.Context, workers, queue int, h Handler) *Pool {
    p := &Pool{jobs: make(chan Job, queue)}
    for i := 0; i < workers; i++ {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for j := range p.jobs {
                func() {
                    defer func() {
                        if r := recover(); r != nil {
                            log.Error("паника в воркере", "job", j.ID, "panic", r,
                                "stack", string(debug.Stack()))
                        }
                    }()
                    h(ctx, j)
                }()
            }
        }()
    }
    return p
}

// Submit не блокируется навсегда: либо очередь примет, либо отмена, либо отказ.
func (p *Pool) Submit(ctx context.Context, j Job) error {
    select {
    case p.jobs <- j:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// SubmitOrDrop с backpressure: лучше отказать, чем копить очередь.
func (p *Pool) SubmitOrDrop(j Job) error {
    select {
    case p.jobs <- j:
        return nil
    default:
        return ErrQueueFull        // отдать 429 клиенту, инкрементировать метрику
    }
}

// Stop идемпотентен и дожидается доработки очереди.
func (p *Pool) Stop() {
    p.once.Do(func() { close(p.jobs) })   // once: защита от повторного close
    p.wg.Wait()
}
Почему Submit и Stop опасны в паре

После close(p.jobs) любой Submit получит панику send on closed channel. Проверить «закрыт ли канал» перед отправкой нельзя: между проверкой и отправкой остаётся окно. Рабочих решений два. Либо контракт «после Stop никто не шлёт», и держит его порядок остановки компонентов (см. graceful shutdown). Либо явный RWMutex: Submit берёт RLock, Stop берёт Lock, ставит флаг closed и только потом закрывает. Второй вариант стоит одного RLock на отправку, зато компонент переживёт любой порядок вызовов.

Pipeline: стадии через каналы

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

Pipeline: каждая стадия владеет своим выходным каналом generator источник данных chan int stage: square 1 горутина chan int stage: filter 1 горутина chan int sink range по выходу ctx.Done() — один закрытый канал, слышат все стадии каждая стадия: select на приёме И на отправке Нормальный конец: generator закрывает свой канал → range в square завершается → square закрывает свой → и так до sink. Каскад слева направо. Досрочный конец: отменяется ctx → все стадии выходят из своих select → каждая закрывает свой выход в defer → sink видит закрытый канал. Порядок здесь не важен.
Два способа завершить конвейер. Естественный: закрыть входной канал, дальше закрытие едет вправо само. Досрочный: общий сигнал отмены, который слышат все стадии сразу. Второй нужен именно потому, что при первом стадия, застрявшая на отправке в канал, который никто не читает, до своего close не дойдёт никогда.
// Стадия 0: генератор. Принимает данные, отдаёт канал.
func gen(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)                 // владелец всегда закрывает свой канал
        for _, n := range nums {
            select {
            case out <- n:               // отправка тоже через select
            case <-ctx.Done():
                return
            }
        }
    }()
    return out                           // <-chan: наружу только чтение
}

// Стадия 1: преобразование.
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {              // завершится, когда in закроют
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Стадия 2: фильтр. Сигнатура та же, стадии можно тасовать как кубики.
func odd(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if n%2 == 0 {
                continue
            }
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()                       // остановит весь конвейер при любом выходе

    for v := range odd(ctx, square(ctx, gen(ctx, 1, 2, 3, 4, 5))) {
        fmt.Println(v)
        if v > 8 {
            cancel()                     // досрочная остановка, всё свернётся само
            break
        }
    }
}

// вывод (проверено запуском), строк две, а не три:
// 1
// 9
// Конвейер выдал бы 1, 9, 25 (нечётные квадраты), но на 9 срабатывает v > 8:
// потребитель зовёт cancel() и выходит из range, а 25 до него уже не доезжает.

Разбор

  • Единая сигнатура func(ctx, <-chan T) <-chan T и есть «конвейер»: стадии складываются друг с другом, их можно переставлять и вставлять новые, не трогая соседей.
  • defer close(out) первой строкой горутины. Канал закроется на любом пути выхода, включая ранний return по отмене. Без него отмена посередине оставит следующую стадию в вечном range.
  • select на отправке нужен не для красоты. Если следующая стадия умерла (отмена, паника, ранний break у потребителя), обычная отправка out <- v заблокируется навсегда, и вся цепочка утечёт. Именно эту строку чаще всего забывают.
  • break у потребителя обязан сопровождаться cancel(). Иначе выход из range прекращает чтение, а стадии остаются на отправке. Идиома defer cancel() в вызывающем коде закрывает вопрос сама.
  • Буфер на стадии: тюнинг, а не архитектура. Буфер сглаживает разницу в скорости соседних стадий, но не лечит несбалансированный конвейер: если стадия медленнее входа, буфер лишь отложит момент, когда всё упрётся в неё.
Три подвоха конвейера
  • Ошибки некуда девать. Тип канала один. На практике по конвейеру гоняют не int, а структуру type Item struct { Val T; Err error }, и каждая стадия пропускает элементы с непустым Err дальше без обработки. Можно и отдельный errCh, но его надо кому-то читать и закрывать.
  • Пропускная способность равна самой медленной стадии. Конвейер не ускоряет обработку одного элемента, он совмещает обработку разных. Хочешь ускорить узкую стадию, делай fan-out внутри неё.
  • Стоимость. Каждая стадия стоит горутины и канала; на мелких элементах передача съест больше, чем полезная работа. Шесть стадий поверх арифметики: антипаттерн. Те же шесть поверх сетевых вызовов и парсинга: норма.
Что спросит интервьюер следующим

«Как ускорить одну медленную стадию?» Fan-out: запустить N копий стадии на одном входном канале и слить их выходы через merge. «А порядок сохранится?» Нет, и это главный побочный эффект: после fan-out элементы приходят в произвольном порядке. Если порядок важен, нумеруют элементы и пересобирают на выходе (буфер + сортировка по индексу) либо распараллеливают внутри стадии так, чтобы отдавать результаты по очереди. «Что если стадия падает?» recover внутри горутины стадии, а паника превращается в элемент с ошибкой.

Fan-out / fan-in и своя реализация merge

Fan-out: несколько горутин читают из одного канала, стадия распараллеливается. Fan-in: несколько каналов сливаются в один (merge). Написать merge руками просят особенно охотно: в двадцати строках помещаются и владение каналом, и WaitGroup, и отмена.

merge: на каждый входной канал — своя копирующая горутина in[0] in[1] in[N-1] merge for v := range in[0] out <- v for v := range in[1] out <- v for v := range in[N-1] out <- v out chan T один читатель go func() { wg.Wait(); close(out) }() — единственный, кто вправе закрыть Порядок элементов в out произвольный: кто первым отдал, тот и в очереди.
Почему без WaitGroup не обойтись. Каждая копирующая горутина знает только про свой канал и не может закрыть out: там ещё пишут остальные. Закрыть out вправе только тот, кто дождался всех, и это отдельная горутина: если вызвать wg.Wait() прямо в merge, функция не вернёт канал, пока всё не закончится, и читателя просто не будет.
// merge, каноническая реализация fan-in. Тридцать секунд на доске.
func merge[T any](ctx context.Context, chans ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup

    output := func(c <-chan T) {
        defer wg.Done()
        for v := range c {               // завершится при close(c)
            select {
            case out <- v:
            case <-ctx.Done():           // получатель ушёл, не виснем
                return
            }
        }
    }

    wg.Add(len(chans))                   // Add до запуска горутин
    for _, c := range chans {
        go output(c)
    }

    // Закрывает тот и только тот, кто дождался всех писателей.
    go func() {
        wg.Wait()
        close(out)
    }()

    return out                           // возврат немедленный, до окончания работы
}

// Fan-out поверх той же стадии: N копий читают один вход, их выходы сливаются.
func fanOut[T, R any](ctx context.Context, in <-chan T, n int,
    stage func(context.Context, <-chan T) <-chan R) <-chan R {

    outs := make([]<-chan R, 0, n)
    for i := 0; i < n; i++ {
        outs = append(outs, stage(ctx, in))   // все n читают из одного in
    }
    return merge(ctx, outs...)
}

// Применение: медленная стадия распараллелена на 8, порядок при этом теряется.
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    src := gen(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    for v := range fanOut(ctx, src, 8, slowSquare) {
        fmt.Println(v)                   // порядок произвольный
    }
}

// Три прогона подряд (значения всегда те же десять, порядок каждый раз новый):
//   36 64 1 49 9 4 25 16 100 81
//   64 49 1 4 16 36 25 9 100 81
//   64 49 36 16 9 4 25 1 100 81
// Полагаться на порядок нельзя: если он нужен, нумеруют элементы и пересобирают
// результат на выходе.

Разбор

  • Почему wg.Add(len(chans)) одним вызовом. Иначе первая горутина успеет завершиться и уронить счётчик в ноль раньше, чем добавят остальные: Wait вернётся преждевременно и закроет out под живыми писателями, а это паника send on closed channel.
  • Fan-out и есть «n читателей одного канала». Канал уже потокобезопасен и раздаёт элементы строго по одному: никакой дополнительной синхронизации не нужно. Это самая изящная часть модели каналов, и её стоит проговорить вслух.
  • Балансировка получается сама. Кто освободился, тот и забрал следующий элемент: естественный work stealing без единой строчки кода. Медленный воркер просто получит меньше задач.
  • Дженерики здесь по делу. До 1.18 merge писали на interface{} либо копипастили под каждый тип; сейчас одна функция закрывает всё.
Два способа сломать merge

Первый: закрывать out внутри output. При len(chans) > 1 это гарантированная паника двойного закрытия. Второй: вызвать wg.Wait() синхронно перед return out. Функция заблокируется до конца всей работы, читателя ещё нет (канал не вернули), писатели встанут на отправке, дедлок. Обе ошибки встречаются на собеседованиях чаще, чем правильный вариант.

Что спросит интервьюер следующим
  1. «Сохраняется ли порядок?» Нет. Если нужен, нумеруйте элементы на входе и пересобирайте на выходе через map[int]T плюс указатель на следующий ожидаемый индекс.
  2. «Как сделать merge на двух каналах без WaitGroup?» Через select с обнулением закрытых каналов: case v, ok := <-a: if !ok { a = nil; continue }. Приём из nil-канала блокируется навсегда, поэтому исчерпанная ветка просто выключается; выходим, когда оба стали nil.
  3. «А если каналов заранее неизвестное число и они добавляются на ходу?» reflect.Select (медленно и некрасиво) либо «дерево merge»: сливать попарно, каждый новый канал подмешивая ещё одним merge.
  4. «Чем это отличается от errgroup merge сливает данные, errgroup управляет жизненным циклом и ошибками. В реальном коде их обычно используют вместе.

Семафор: ограничить число одновременных запросов

Отличие от worker pool принципиальное, и его любят проверять: пул ограничивает число горутин, а семафор ограничивает число одновременно выполняемых операций при каком угодно числе горутин. Когда запросы приходят снаружи (каждый HTTP-хендлер живёт в своей горутине) и надо всего лишь не бомбить внешний API больше чем в 10 потоков, пул не годится: горутины созданы уже не вами. Годится семафор.

// Семафор на буферизованном канале: ёмкость буфера и есть лимит.
type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore { return make(Semaphore, n) }

// Acquire ждёт слот или отмену, навсегда не блокируется.
func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:              // занял слот
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// TryAcquire не ждёт: либо сразу, либо отказ (backpressure).
func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:
        return false
    }
}

func (s Semaphore) Release() { <-s }   // освободил слот

// Применение: 10 одновременных вызовов внешнего API на весь процесс.
var apiSem = NewSemaphore(10)

func CallAPI(ctx context.Context, req Request) (*Response, error) {
    if err := apiSem.Acquire(ctx); err != nil {
        return nil, fmt.Errorf("очередь к API переполнена: %w", err)
    }
    defer apiSem.Release()             // только через defer, иначе слот утечёт навсегда
    return doHTTP(ctx, req)
}

Разбор

  • Почему chan struct{}, а не chan bool. struct{} занимает ноль байт; для буфера на 1000 слотов это разница между нулём и килобайтом. Плюс сигнальная семантика читается сразу.
  • Отправка = захват, приём = освобождение. Ровно наоборот интуиции, поэтому направление стоит проговорить: слоты «складываются» в буфер, полный буфер = все слоты заняты.
  • Симметрия Acquire/Release держится на defer. Один return без Release, и лимит навсегда уменьшился на единицу. Через несколько таких утечек сервис встаёт с виду беспричинно.
  • Fairness. Очередь ожидающих на канале работает по FIFO, поэтому голодания нет: кто раньше встал, тот раньше получит слот.
Worker poolСемафор
Что ограничиваетчисло горутин-обработчиковчисло одновременных операций
Кто создаёт горутинысам пулвызывающий код (хендлеры, консьюмер брокера)
Откуда задачииз канала, который вы контролируетеприходят снаружи, потоком
Очередьбуфер канала задачгорутины, ждущие на Acquire
Стоимость ожиданиязадача лежит в буфере (дёшево)ждёт целая горутина (2 КБ стека + контекст запроса)
Типичное применениебатч-обработка, консьюмер, воркеры фоновых задачлимит на внешний API, на диск, на тяжёлую CPU-операцию
// Взвешенный вариант из стандартного расширения: разные операции стоят по-разному.
import "golang.org/x/sync/semaphore"

// Ёмкость в «единицах веса», например мегабайтах памяти.
var sem = semaphore.NewWeighted(1024)

func Process(ctx context.Context, f *File) error {
    w := int64(f.SizeMB)
    if w > 1024 {
        return ErrTooLarge             // Acquire с весом больше ёмкости висел бы вечно
    }
    if err := sem.Acquire(ctx, w); err != nil {
        return err
    }
    defer sem.Release(w)
    return decode(ctx, f)
}
// Отличия от канального: веса, честная FIFO-очередь ожидающих, TryAcquire в комплекте.
// Цена: мьютекс и список ожидающих вместо одного канала. Чуть дороже, зато гибче.
Что спросит интервьюер следующим

«А если внешний API лимитирует не одновременность, а частоту, скажем 100 запросов в секунду?» Это уже rate limiter (ограничитель частоты), и путать их нельзя: семафор ограничивает concurrency, лимитер держит rate. При среднем времени ответа 50 мс десять параллельных слотов дают 200 rps, семафором такую частоту не удержать. В проде почти всегда нужны оба: лимитер держит частоту, семафор защищает от накопления горутин, когда API начал отвечать медленно.

Rate limiter: тикер и токен-бакет

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

Токен-бакет (token bucket, «ведро с токенами») устроен ровно так, как называется: есть ведро ёмкостью N, в него с постоянной скоростью капают токены, а каждый запрос перед отправкой забирает один. Токенов нет, ждёшь, пока накапает. Если запросов долго не было, ведро наполнилось, и следующие N запросов уйдут разом; это и есть всплеск (burst). Ёмкость ведра задаёт максимальный размер всплеска, скорость капания задаёт среднюю частоту. Второй вариант почти всегда ближе к реальности: внешние API обычно разрешают короткий burst, лишь бы среднее держалось в пределах лимита.

// Вариант 1: на тикере. Ровно N событий в секунду, без всплесков.
type TickLimiter struct {
    t    *time.Ticker
    done chan struct{}
}

func NewTickLimiter(rps int) *TickLimiter {
    return &TickLimiter{
        t:    time.NewTicker(time.Second / time.Duration(rps)),
        done: make(chan struct{}),
    }
}

func (l *TickLimiter) Wait(ctx context.Context) error {
    select {
    case <-l.t.C:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    case <-l.done:
        return ErrClosed
    }
}

func (l *TickLimiter) Close() { l.t.Stop(); close(l.done) }

// Вариант 2: токен-бакет своими руками. Пополняется фоном, всплеск до burst.
type Bucket struct {
    tokens chan struct{}
}

func NewBucket(ctx context.Context, rps, burst int) *Bucket {
    b := &Bucket{tokens: make(chan struct{}, burst)}
    for i := 0; i < burst; i++ {
        b.tokens <- struct{}{}                 // бакет стартует полным
    }
    go func() {
        t := time.NewTicker(time.Second / time.Duration(rps))
        defer t.Stop()
        for {
            select {
            case <-t.C:
                select {
                case b.tokens <- struct{}{}:   // положили токен
                default:                       // бакет полон, токен «сгорает»
                }
            case <-ctx.Done():
                return                         // без этого горутина утечёт
            }
        }
    }()
    return b
}

func (b *Bucket) Wait(ctx context.Context) error {
    select {
    case <-b.tokens:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (b *Bucket) Allow() bool {                // неблокирующий вариант
    select {
    case <-b.tokens:
        return true
    default:
        return false
    }
}
В проде берут golang.org/x/time/rate
lim := rate.NewLimiter(rate.Limit(100), 20)   // 100 rps, всплеск до 20

if err := lim.Wait(ctx); err != nil { ... }   // подождать своей очереди
if !lim.Allow() { return errTooMany }         // отказать сразу
r := lim.Reserve()                            // узнать, сколько ждать, и решить
if !r.OK() { return errTooMany }
time.Sleep(r.Delay())

// Почему лучше самописного: нет фоновой горутины вообще. Токены не хранятся --
// они ВЫЧИСЛЯЮТСЯ из времени последнего обращения (lazy refill). Нечего
// останавливать, нечему утекать, точность не зависит от разрешения тикера.
Пять граблей лимитера
  • Тикер без Stop() течёт. До Go 1.23 незастопленный Ticker удерживался рантаймом вечно, даже став недостижимым.
  • time.Second / time.Duration(rps) при большом rps. На 10 000 rps интервал выходит 100 мкс, а разрешение таймеров ОС порядка 1 мс: реальная частота окажется в разы ниже. Выше нескольких тысяч rps тикер как источник не годится.
  • Один лимитер на весь сервис против лимитера на клиента. Второе нужно почти всегда, и оно тянет за собой map[key]*rate.Limiter под мьютексом плюс вытеснение по TTL, иначе карта растёт неограниченно.
  • Лимитер в распределённой системе. Локальный лимитер на 10 подах даёт 10× лимита. Спасает либо общий счётчик (Redis + Lua), либо квота, поделённая на число реплик, с оговоркой про рестарты и автоскейл.
  • Блокирующий Wait вместо отказа. Под перегрузкой он копит горутины и превращает лимитер в источник OOM. Для внешнего трафика правильнее Allow() и честный 429 с Retry-After.

Generator, or-done, tee

Три микропаттерна из «Concurrency in Go». Их спрашивают на понимание, а не на объём кода. Все три о том, как обернуть канал, чтобы им было безопасно пользоваться. Названия непрозрачные, поэтому сразу расшифруем. generator («генератор») превращает произвольный источник данных в канал. or-done (дословно «или готово») оборачивает чужой канал и добавляет ему отменяемость, чтобы читатель мог уйти, даже если владелец канала об отмене не знает. tee (по названию T-образного сантехнического тройника) раздваивает поток: один входной канал, два выходных, в оба уходит каждое значение.

// Generator превращает что угодно в канал. Ленивый, отменяемый, бесконечный.
func repeat[T any](ctx context.Context, vals ...T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for {                                  // бесконечный источник
            for _, v := range vals {
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            }
        }
    }()
    return out
}

// take ограничивает бесконечный генератор
func take[T any](ctx context.Context, in <-chan T, n int) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for i := 0; i < n; i++ {
            select {
            case v, ok := <-in:
                if !ok {
                    return
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Or-done: оборачивает чужой канал, за поведение которого не отвечаешь,
// и делает его отменяемым. Вместо select хватает простого range.
func orDone[T any](ctx context.Context, in <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for {
            select {
            case <-ctx.Done():
                return
            case v, ok := <-in:
                if !ok {
                    return                     // источник закрылся сам
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            }
        }
    }()
    return out
}
// Смысл: вместо громоздкого select в каждом потребителе пишем
//   for v := range orDone(ctx, foreignCh) { ... }
// и гарантированно не залипаем, если источник перестал закрывать канал.

// Tee раздваивает поток: один вход, два независимых выхода.
func tee[T any](ctx context.Context, in <-chan T) (<-chan T, <-chan T) {
    out1, out2 := make(chan T), make(chan T)
    go func() {
        defer close(out1)
        defer close(out2)
        for v := range orDone(ctx, in) {
            // Локальные копии: обнуляем отправленный канал, чтобы select
            // не выбрал его дважды. Пока оба не nil, крутимся.
            o1, o2 := out1, out2
            for i := 0; i < 2; i++ {
                select {
                case <-ctx.Done():
                    return
                case o1 <- v:
                    o1 = nil                   // отправка в nil блокируется навсегда,
                case o2 <- v:                  // значит этот case выключен
                    o2 = nil
                }
            }
        }
    }()
    return out1, out2
}
Почему в tee именно так, а не две отправки подряд

Наивный вариант out1 <- v; out2 <- v связывает выходы: пока первый потребитель не забрал элемент, второй его не получит, и медленный читатель тормозит быстрого. Обнуление каналов внутри select отдаёт элемент тому, кто освободился первым, а второму следом. Это то самое «выключение ветки через nil», самая полезная идиома select вообще: ею собирают merge двух каналов, приостановку чтения и переключение между источниками. Чего tee не решает: оба выхода всё равно идут в темпе медленнейшего из двух, потому что следующий элемент не читается, пока текущий не разослан обоим. Нужна развязка, ставьте буфер или промежуточную очередь.

Отмена по первой ошибке против сбора всех ошибок

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

// Режим 1: errgroup, первая ошибка отменяет остальных.
import "golang.org/x/sync/errgroup"

func loadPage(ctx context.Context, id int) (*Page, error) {
    g, ctx := errgroup.WithContext(ctx)      // ctx переприсваиваем намеренно
    var p Page

    g.Go(func() error {
        var err error
        p.User, err = fetchUser(ctx, id)     // получает уже производный ctx
        return err
    })
    g.Go(func() error {
        var err error
        p.Feed, err = fetchFeed(ctx, id)
        return err
    })
    g.Go(func() error {
        var err error
        p.Ads, err = fetchAds(ctx, id)
        return err
    })

    if err := g.Wait(); err != nil {         // вернёт первую по времени ошибку
        return nil, err
    }
    return &p, nil
}

// Ограничение параллелизма встроено:
g.SetLimit(8)                                // не больше 8 одновременно
// TryGo не блокирует: false, если лимит исчерпан
if !g.TryGo(fn) { /* очередь занята */ }

// Режим 2: собрать все ошибки. errgroup здесь не подходит.
func notifyAll(ctx context.Context, users []User) error {
    var (
        mu   sync.Mutex
        errs []error
        wg   sync.WaitGroup
    )
    sem := make(chan struct{}, 16)           // параллелизм ограничиваем сами

    for _, u := range users {
        wg.Add(1)
        go func(u User) {
            defer wg.Done()
            sem <- struct{}{}
            defer func() { <-sem }()

            if err := notify(ctx, u); err != nil {
                mu.Lock()
                errs = append(errs, fmt.Errorf("user %d: %w", u.ID, err))
                mu.Unlock()
            }
        }(u)
    }
    wg.Wait()
    return errors.Join(errs...)              // Go 1.20: nil, если срез пуст
}
errgroupWaitGroup + errors.Join
Поведение при ошибкеотменяет ctx, остальные сворачиваютсявсе доводят работу до конца
Что возвращаетпервую по времени ошибкувсе ошибки, обёрнутые в одну
Ограничение параллелизмаSetLimit из коробкируками, семафором
Совместимость с errors.Is/Asда, обычная ошибкада, Join поддерживает обход дерева
Когда братьрезультат бесполезен без всех частейчасти независимы, нужна полная картина
Три ошибки с errgroup
  • Не переприсвоить ctx. Напишешь g, _ := errgroup.WithContext(ctx), и отмены не будет: горутины получат исходный контекст, который никто не отменит. Ошибка вернётся, но работа продолжится до конца. Самый частый баг с этим пакетом.
  • Ждать, что Wait вернёт все ошибки. Он возвращает ровно одну, первую. Остальные молча теряются, даже если они интереснее.
  • Забыть, что отмена не равна остановке. Если fetchFeed не проверяет ctx, он доработает до конца, и g.Wait() будет ждать его. errgroup отменяет контекст, а не горутины.
Что спросит интервьюер следующим

«А если нужно и то и другое: отменить остальных, но вернуть все ошибки, которые успели случиться?» Такое пишется руками за десять строк: errgroup для отмены плюс свой срез под мьютексом для накопления, либо WaitGroup плюс собственный context.WithCancel, который вызывается при первой ошибке. И встречный вопрос про панику: errgroup панику не перехватывает, она уронит процесс. Если задачи выполняют чужой код, оборачивайте тело g.Go в recover и превращайте панику в ошибку.

Периодические задачи и корректная остановка

// Канонический периодический воркер.
func (s *Service) runPeriodic(ctx context.Context, every time.Duration) {
    t := time.NewTicker(every)
    defer t.Stop()                       // до Go 1.23 без этого тикер жил вечно

    s.tick(ctx)                          // часто нужен сразу первый прогон,
                                         // Ticker выдаёт первый тик только через every
    for {
        select {
        case <-t.C:
            s.tick(ctx)                  // tick должен уважать ctx и иметь свой таймаут
        case <-ctx.Done():
            return                       // выход по общей отмене
        }
    }
}

func (s *Service) tick(ctx context.Context) {
    // Свой дедлайн на итерацию: иначе одна залипшая итерация останавливает всё.
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    defer func() {                       // паника в фоновой задаче не должна ронять сервис
        if r := recover(); r != nil {
            log.Error("паника в периодической задаче", "panic", r,
                "stack", string(debug.Stack()))
        }
    }()

    if err := s.doWork(ctx); err != nil {
        log.Warn("итерация не удалась", "err", err)   // не return из runPeriodic:
    }                                                 // упавшая итерация не должна
}                                                     // убивать расписание
Что здесь легко сделать неправильно
  • time.Tick(d) вместо NewTicker. У него нет Stop(), и тикер живёт до конца процесса. Документация прямо говорит: только для программ, которые работают всю жизнь процесса.
  • Долгая итерация и «догоняющие» тики. У Ticker буфер канала на 1, и пропущенные тики отбрасываются, а не копятся. Это спасает от лавины, но при итерации длиннее периода фактическая частота падает. Нужно «ровно каждые 5 секунд между окончанием и началом», берите time.After/Timer и перезапускайте после работы.
  • Все реплики тикают синхронно. Десять подов с периодом в минуту создают всплеск раз в минуту. Лечится джиттером: случайная задержка старта плюс небольшой разброс периода.
  • return из цикла при ошибке итерации. Одна сетевая ошибка, и периодическая задача молча перестала выполняться до следующего рестарта.
  • Итерация без своего таймаута. Зависший HTTP-вызов внутри tick останавливает расписание навсегда, и в дампе это выглядит как одна невинная горутина.
Что изменилось в Go 1.23

До 1.23 Timer и Ticker, у которых не вызвали Stop, удерживались внутренней структурой рантайма и не собирались GC до срабатывания; отсюда правило «time.After в горячем цикле течёт». С 1.23 (при GODEBUG=asynctimerchan=0, то есть в модулях с go >= 1.23) недостижимый таймер собирается сборщиком, а канал стал небуферизованным, что убрало и вторую тонкость: устаревшее значение, лежащее в буфере после Reset. В Go 1.27 этот GODEBUG удалён: старое поведение вернуть больше нельзя, каналы таймеров всегда небуферизованные (программа с GODEBUG=asynctimerchan=1 на 1.27 просто не стартует). Практический вывод: defer t.Stop() по-прежнему пишут всегда, тикер тикает, пока на него есть ссылка, а вот time.After в select перестал быть грехом.

Graceful shutdown приложения целиком

Самый «взрослый» вопрос главы. Знание API тут вторично: проверяют, понимаешь ли ты, что сервис собран из компонентов с зависимостями и останавливать их надо в строго определённом порядке, снаружи внутрь. Сначала перестаём принимать новую работу, потом доделываем начатую, и только в самом конце закрываем то, что этой работе нужно. Перевернёшь порядок, получишь гарантированные ошибки «connection closed» в последних запросах.

Порядок остановки: снаружи внутрь, по одному шагу 1. SIGTERM / SIGINT — signal.NotifyContext, корневой ctx отменён 2. readiness = false, пауза 5–15 с: балансировщик выводит под из ротации 3. srv.Shutdown(ctx) — новые соединения не принимаются, текущие дорабатывают 4. consumer.Stop() — перестаём читать из брокера, коммитим оффсеты 5. close(jobs); wg.Wait() — воркер-пулы и фоновые задачи доработали 6. flush: батчи логов, метрики, трейсы, аккумуляторы 7. db.Close(), redis.Close(), conn.Close() — ресурсы больше никому не нужны 8. return из main — или hard deadline: os.Exit(1) по общему таймауту Каждый шаг блокирующий и начинается только после предыдущего. Общий бюджет должен быть меньше terminationGracePeriodSeconds (в Kubernetes по умолчанию 30 с) — иначе прилетит SIGKILL.
Почему шаг 2 существует. В Kubernetes SIGTERM и удаление эндпоинта из сервиса происходят параллельно, а не последовательно. Если закрыть слушающий сокет сразу, часть трафика ещё летит на этот под и получит connection refused. Пауза с readiness=false остаётся единственным способом дождаться, пока правила балансировки разъедутся по узлам.
func main() {
    // 1. Корневой контекст, отменяемый по сигналу. Второй SIGINT вернёт
    //    поведение по умолчанию, то есть немедленное убийство процесса.
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // Инициализация: обратный порядок закрытия задаётся здесь.
    db := mustOpenDB(ctx)
    cache := mustOpenRedis(ctx)
    pool := NewPool(ctx, 16, 1024, handleJob)
    consumer := NewKafkaConsumer(cfg, pool)
    srv := &http.Server{Addr: ":8080", Handler: newRouter(db, cache, pool)}

    var ready atomic.Bool
    ready.Store(true)
    http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        if !ready.Load() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Error("сервер упал", "err", err)
            stop()                                  // роняем всё приложение штатно
        }
    }()
    go consumer.Run(ctx)

    <-ctx.Done()                                    // ждём сигнала
    stop()                                          // вернуть обработчик по умолчанию:
    log.Info("получен сигнал, останавливаемся")     // второй Ctrl+C убьёт сразу

    // 2. Общий бюджет на всю остановку. Он должен быть меньше
    //    terminationGracePeriodSeconds, иначе процесс добьют SIGKILL.
    shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()

    // 3. Снимаем себя с балансировки и ждём, пока правила разъедутся.
    ready.Store(false)
    select {
    case <-time.After(7 * time.Second):
    case <-shutCtx.Done():
    }

    // 4. HTTP: новые соединения не принимаем, текущие дорабатывают.
    if err := srv.Shutdown(shutCtx); err != nil {
        log.Warn("не все запросы успели, рвём принудительно", "err", err)
        _ = srv.Close()
    }

    // 5. Консьюмер: перестаём брать новые сообщения, коммитим оффсеты.
    consumer.Stop(shutCtx)

    // 6. Фоновые воркеры: очередь дорабатывается, новые задачи не принимаются.
    done := make(chan struct{})
    go func() { pool.Stop(); close(done) }()
    select {
    case <-done:
    case <-shutCtx.Done():
        log.Warn("воркеры не успели за отведённое время")
    }

    // 7. Флашим телеметрию до закрытия соединений, но после остановки писателей.
    _ = tracerProvider.Shutdown(shutCtx)
    metrics.Flush(shutCtx)

    // 8. Ресурсы закрываем последними, в порядке, обратном открытию.
    _ = cache.Close()
    _ = db.Close()

    log.Info("остановлено штатно")
}

Разбор ключевых решений

  • signal.NotifyContext вместо signal.Notify + канала. Сразу даёт отменяемый ctx, который можно раздать всем компонентам, и корректно снимает обработчик через stop().
  • Вызов stop() сразу после <-ctx.Done(). Возвращает сигналам поведение по умолчанию: если оператор нервничает и жмёт Ctrl+C второй раз, процесс умрёт немедленно, а не проигнорирует сигнал.
  • Отдельный shutCtx от context.Background(). Корневой ctx уже отменён: возьмёшь его, и все шаги остановки завершатся мгновенно с ошибкой, никакого graceful. Это ошибка номер один у тех, кто пишет shutdown впервые.
  • Общий бюджет, а не таймаут на каждый шаг. Один shutCtx на всё гарантирует, что суммарное время не превысит договорённость с оркестратором.
  • Порядок закрытия ресурсов обратен порядку открытия. БД открыли первой, закрываем последней: от неё зависят все остальные.
  • pool.Stop() обёрнут в горутину с select. Сам Stop блокирующий и контекста не принимает; без обёртки зависший воркер сорвёт весь бюджет и дождётся SIGKILL.
Что ломается в проде чаще всего
  • Нет паузы на readiness. Каждый деплой даёт всплеск 502/504 в графиках. Самая частая и самая незаметная ошибка.
  • Бюджет больше terminationGracePeriodSeconds. Kubernetes присылает SIGKILL по истечении своего таймера, и весь аккуратный shutdown обрывается на середине.
  • Shutdown не отменяет уже выполняющиеся хендлеры. Он ждёт их. Если хендлер не смотрит на r.Context(), он будет работать до конца, и его никак не поторопить. Плюс Shutdown не ждёт хайджекнутые соединения: WebSocket и SSE закрывай сам.
  • Забытый os.Exit в обработчике сигнала. Мгновенная смерть без единого шанса доработать: defer не выполняются, буферы не сбрасываются.
  • Компонент, у которого нет Stop. Библиотека запустила горутину и не дала способа её остановить: процесс не завершится, придётся ждать SIGKILL. Проверять это надо при выборе зависимости, а не при инциденте.
Что спросит интервьюер следующим

«А если во время остановки пришёл ещё один запрос?» Не придёт: сокет уже не слушает, соединение отвергнет ядро, балансировщик перенаправит на другой под. «Что с in-flight сообщениями Kafka?» Консьюмер обязан дообработать взятый батч и закоммитить оффсет, иначе после рестарта будет повторная обработка (вот откуда требование идемпотентности). «Как проверить, что shutdown работает?» Интеграционным тестом: поднять сервис, послать долгий запрос, послать SIGTERM, убедиться, что запрос завершился успешно, а процесс вышел с кодом 0 за отведённое время.

Вопросы

12
Суть: канал задач + N горутин с for range jobs + канал результатов с полем Err + WaitGroup. Продюсер закрывает jobs, отдельная горутина закрывает results после wg.Wait(). Полный код — в теории выше.

Как писать это на доске, чтобы не запутаться

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

  1. Сколько каналов и кто их владелец? Два: jobs (владеет продюсер) и results (владеют воркеры, коллективно).
  2. Кто и когда их закрывает? jobs закрывает продюсер после последней задачи. results закрывает отдельная горутина go func(){ wg.Wait(); close(results) }(): ни один воркер не знает, закончили ли остальные.
  3. Что произойдёт при отмене посередине? Продюсер перестаёт раздавать, воркеры доделывают текущее, коллектор дочитывает то, что успели положить.
  4. Что если получателя результатов не станет? Либо буфер на все результаты, либо select с ctx.Done() на отправке. Иначе утечёт ровно N горутин.

Минимальный скелет, если просят «за две минуты»

jobs := make(chan Job)
res  := make(chan Result, len(input))
var wg sync.WaitGroup

for i := 0; i < n; i++ {
    wg.Go(func() {                       // Go 1.25; до неё: wg.Add(1) + go func + defer wg.Done()
        for j := range jobs {
            out, err := handle(ctx, j)
            res <- Result{j.ID, out, err}
        }
    })
}
go func() { defer close(jobs); for _, j := range input { jobs <- j } }()
go func() { wg.Wait(); close(res) }()

for r := range res { collect(r) }
Что здесь обязательно проговорить вслух

Что буфер len(input) взят как осознанное упрощение: он снимает вопрос блокировки на отправке, но при потоке задач вместо слайса не годится. Что wg.Add стоит до go, а не внутри. Что N выбирают по природе нагрузки: CPU-bound пляшет от GOMAXPROCS, IO-bound от лимитов внешнего ресурса. Кандидат, который написал верный код молча, и кандидат, который проговорил эти три вещи, получают разные оценки.

Суть: закрывает только отправитель и только владелец, ровно один раз. Если отправителей несколько — закрывает тот, кто дождался всех (обычно отдельная горутина после wg.Wait()). Читатель не закрывает никогда. Закрывать канал вообще не обязательно — только если получателю нужен сигнал «данных больше не будет».

Почему именно так

  • Отправка в закрытый канал роняет панику, и проверкой её не предотвратить: между «проверил, что не закрыт» и «отправил» есть окно. Значит, безопасный протокол один: закрывать со стороны отправителя, когда он точно знает, что больше не пошлёт.
  • Повторный close тоже паника. Отсюда либо один владелец, либо sync.Once, либо флаг под мьютексом.
  • Приём из закрытого канала безопасен и мгновенен: возвращает нулевое значение и ok == false. Поэтому close и работает как широковещательный сигнал для любого числа получателей: именно на этом построен context.Done().

Три конфигурации и решение для каждой

Кто пишетКто закрываетКак
один отправительон самdefer close(out) первой строкой его горутины
несколько отправителейотдельная горутинаgo func(){ wg.Wait(); close(out) }()
надо остановить писателей извненикто не закрывает outотдельный ctx.Done() / done-канал, закрываемый управляющим кодом
// Если без закрытия извне никак, вот безопасный враппер.
type SafeChan[T any] struct {
    mu     sync.RWMutex
    closed bool
    ch     chan T
}

func (s *SafeChan[T]) Send(v T) error {
    s.mu.RLock()                         // читатели-отправители не мешают друг другу
    defer s.mu.RUnlock()
    if s.closed {
        return ErrClosed
    }
    s.ch <- v                            // под RLock: Close не сможет вклиниться
    return nil
}

func (s *SafeChan[T]) Close() {
    s.mu.Lock()                          // ждёт, пока все Send завершатся
    defer s.mu.Unlock()
    if !s.closed {
        s.closed = true
        close(s.ch)
    }
}
// Цена: RLock на каждую отправку и риск задержки Close, если канал полон,
// а читателя нет. Поэтому это запасной вариант, а не основной подход.
Дисциплина владения на уровне сигнатур

Функция, создающая канал, возвращает его как <-chan T. Функция, принимающая канал для записи, объявляет параметр как chan<- T. Тогда компилятор физически не даст получателю закрыть чужой канал (invalid operation: cannot close receive-only channel), а владение оказывается задокументировано прямо в сигнатуре. Самый дешёвый способ сделать конкурентный код правильным по построению.

Суть: цепочка функций вида func(ctx, <-chan T) <-chan R, каждая со своей горутиной и своим выходным каналом. Штатная остановка — каскад закрытий слева направо. Досрочная — общий ctx, который слышат все стадии, каждая закрывает свой выход в defer.

Два механизма остановки и почему нужны оба

  • Каскад закрытий работает только при нормальном исчерпании источника. Стадия дочитала вход, вышла из range, сработал defer close(out), следующая стадия увидела закрытие. Так конвейер сворачивается сам, без единой строчки управляющего кода.
  • Общая отмена нужна для всего остального: потребитель ушёл по таймауту, сделал break, случилась ошибка. Без неё стадия, застрявшая на отправке в канал, который никто не читает, никогда не дойдёт до своего close, и каскад не запустится. Отсюда правило: отправка внутри стадии всегда через select с ctx.Done().

Куда девать ошибки

// Практичный вариант: ошибка едет по конвейеру вместе с данными.
type Item[T any] struct {
    Val T
    Err error
}

func stage[T, R any](ctx context.Context, in <-chan Item[T],
    f func(context.Context, T) (R, error)) <-chan Item[R] {

    out := make(chan Item[R])
    go func() {
        defer close(out)
        for it := range in {
            var res Item[R]
            if it.Err != nil {
                res = Item[R]{Err: it.Err}     // ошибку пропускаем дальше как есть
            } else {
                v, err := f(ctx, it.Val)
                res = Item[R]{Val: v, Err: err}
            }
            select {
            case out <- res:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
// Плюс: один канал, одна точка закрытия, ошибки не теряются и видно, на каком
// элементе сломалось. Минус: каждая стадия обязана проверять it.Err первой строкой.
Скрытая проблема любого конвейера

Пропускная способность равна пропускной способности самой медленной стадии, и никакая буферизация этого не меняет: буфер лишь сдвигает момент, когда всё упрётся. Диагностика простая: буферы перед стадией всегда полны, а после всегда пусты, вот и узкое место. Дальше делают fan-out именно этой стадии, и сразу за ним всплывает вопрос порядка (следующий вопрос).

Суть: на каждый входной канал — своя горутина, копирующая элементы в общий out; WaitGroup считает копировщиков; отдельная горутина делает wg.Wait(); close(out). Полная реализация — в теории выше.

Вариант без WaitGroup — на select с обнулением каналов

Просят как «а можете иначе?» или когда каналов ровно два. Идиома основана на том, что операция с nil-каналом блокируется навсегда, а значит, ветку select с nil-каналом никогда не выберут. Так ветку и «выключают».

func merge2[T any](ctx context.Context, a, b <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for a != nil || b != nil {           // пока жив хоть один источник
            select {
            case v, ok := <-a:
                if !ok {
                    a = nil                  // выключаем ветку навсегда
                    continue
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            case v, ok := <-b:
                if !ok {
                    b = nil
                    continue
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
// Почти все забывают обнулить закрытый канал.
// Закрытый канал всегда готов к приёму, select начнёт выбирать его
// в бесконечном цикле: busy-loop на 100% CPU вместо аккуратного слияния.
Два способа сломать merge на N каналов

Первый: close(out) внутри копирующей горутины, при двух и более входах это паника двойного закрытия. Второй: синхронный wg.Wait() перед return out, функция не вернёт канал, пока работа не кончится, читателя не будет, писатели заблокируются, дедлок. Обе ошибки на собеседованиях встречаются чаще, чем правильный вариант, так что стоит проговорить, почему Wait уезжает в отдельную горутину.

Соседний вопрос

«А как слить каналы, число которых меняется в рантайме?» Три ответа: reflect.Select (работает, но медленно и без типов), дерево merge (каждый новый канал подмешивается ещё одним merge2: логарифмическая глубина, зато обычный код), либо «канал каналов» chan (<-chan T): один диспетчер читает новые источники и запускает на каждый копировщик, увеличивая WaitGroup. Последний вариант и используют в динамических конвейерах.

Суть: нумеровать элементы на входе и пересобирать на выходе. Два способа: буфер-переупорядочиватель (map[int]T плюс указатель на следующий ожидаемый индекс) или — если элементов конечное число — просто слайс результатов по индексу, без канала вообще.

Способ 1: слайс по индексу (когда вход — слайс)

// Самый простой и самый недооценённый вариант: канал результатов не нужен вообще.
// Каждая горутина пишет в свою ячейку, гонки нет, синхронизация не нужна.
func mapParallel[T, R any](ctx context.Context, in []T, limit int,
    f func(context.Context, T) (R, error)) ([]R, error) {

    out := make([]R, len(in))
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(limit)

    for i, v := range in {
        g.Go(func() error {
            r, err := f(ctx, v)
            if err != nil {
                return fmt.Errorf("элемент %d: %w", i, err)
            }
            out[i] = r                       // своя ячейка, без синхронизации
            return nil
        })
    }
    if err := g.Wait(); err != nil {         // Wait как барьер: после него слайс виден целиком
        return nil, err
    }
    return out, nil
}
// Почему запись в разные элементы слайса безопасна: это разные адреса памяти.
// Слайс не перевыделяется (длина фиксирована), заголовок никто не меняет.
// А g.Wait() создаёт happens-before между записями и последующим чтением.

Способ 2: переупорядочивание потока

// Когда на входе бесконечный поток и слайс не построить.
type Seq[T any] struct {
    Idx int
    Val T
}

func reorder[T any](ctx context.Context, in <-chan Seq[T]) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        buf  := make(map[int]T)
        next := 0
        for it := range in {
            buf[it.Idx] = it.Val
            for {                            // отдаём всё, что стало непрерывным
                v, ok := buf[next]
                if !ok {
                    break
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
                delete(buf, next)
                next++
            }
        }
    }()
    return out
}
// Цена честного порядка: один застрявший элемент держит в памяти всё,
// что обогнало его. Обязательно ограничивать размер buf и падать/тормозить
// при переполнении, иначе медленный элемент превращается в OOM.
Как отвечать

Сначала спросить встречно: «а порядок вообще нужен?» Очень часто оказывается, что нет, и вся сложность отпадает. Если нужен, уточнить, конечен ли вход: для конечного правильный ответ «слайс по индексу, канал не нужен», и это сильный ответ, потому что он проще. Переупорядочивающий буфер берут только для потоков, и обязательно с оговоркой про ограничение памяти.

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

Реализация целиком — четыре метода

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore { return make(Semaphore, n) }

func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:
        return false
    }
}
func (s Semaphore) Release() { <-s }
func (s Semaphore) InUse() int { return len(s) }   // готовая метрика насыщения

На что обратить внимание в ответе

  • Отправка и есть захват. Слоты складываются в буфер; полный буфер значит «все заняты». Направление контринтуитивно, поэтому его проговаривают явно.
  • defer Release() сразу после успешного Acquire, и никогда до проверки ошибки: Release без Acquire читает из пустого канала и блокируется навсегда.
  • Ожидающие стоят в FIFO-очереди канала, голодания нет by design.
  • len(s) даёт бесплатную метрику: экспортируйте её, и насыщение лимита будет видно на графике раньше, чем начнутся таймауты.
  • Стоимость ожидания выше, чем у пула. В пуле ждёт задача (несколько байт в буфере), в семафоре целая горутина со стеком и всем контекстом запроса. Поэтому у семафора почти всегда нужен либо таймаут, либо TryAcquire с честным отказом.
Чем добить ответ

Упомянуть golang.org/x/sync/semaphore, взвешенный вариант для случая, когда операции стоят по-разному (например, вес = размер файла в мегабайтах): у него честная очередь и Acquire(ctx, n). И сразу оговорить грабли: Acquire с весом больше ёмкости заблокируется навсегда, поэтому вес проверяют до вызова. И ещё: семафор и rate limiter решают разные задачи, первый про одновременность, второй про частоту, и в проде обычно нужны оба.

Суть: тикер даёт жёстко равномерный поток без всплесков и прост как палка. Токен-бакет держит среднюю частоту, но разрешает burst — это поведение реальных API. В проде берут golang.org/x/time/rate: там нет фоновой горутины вообще, токены вычисляются из времени.

Сравнение

ТикерТокен-бакет (свой)x/time/rate
Всплескиневозможныдо burstдо burst
Фоновая горутинанет (тикер — таймер рантайма)есть, надо останавливатьнет: lazy refill по времени
Точность на высоких rpsплохая: упирается в разрешение таймеров (~1 мс)та же проблемахорошая: считается арифметикой
Неблокирующий режимчерез select с defaultAllow()Allow(), Reserve(), Wait(ctx)
Когда братьпростые фоновые задачи, «раз в N»учебный пример, нет зависимостейвсё остальное
// Лимитер на клиента: то, что нужно в 90% случаев.
type PerKey struct {
    mu   sync.Mutex
    lim  map[string]*entry
    r    rate.Limit
    b    int
}
type entry struct {
    l    *rate.Limiter
    seen time.Time
}

func (p *PerKey) Allow(key string) bool {
    p.mu.Lock()
    e, ok := p.lim[key]
    if !ok {
        e = &entry{l: rate.NewLimiter(p.r, p.b)}
        p.lim[key] = e
    }
    e.seen = time.Now()
    p.mu.Unlock()
    return e.l.Allow()
}

// Без вытеснения карта растёт вечно: это утечка памяти, а не «мелочь».
func (p *PerKey) gc(ctx context.Context, ttl time.Duration) {
    t := time.NewTicker(ttl)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            p.mu.Lock()
            for k, e := range p.lim {
                if time.Since(e.seen) > ttl {
                    delete(p.lim, k)
                }
            }
            p.mu.Unlock()
        case <-ctx.Done():
            return
        }
    }
}
Где ошибаются
  • Блокирующий Wait на входящем трафике. Под перегрузкой копит горутины и превращает лимитер в источник OOM. Для внешних клиентов берите Allow() и честный 429 с Retry-After.
  • Локальный лимитер в распределённой системе. Десять подов по 100 rps дают 1000 rps на бэкенд. Нужен общий счётчик (Redis + Lua-скрипт) либо деление квоты на число реплик с поправкой на автоскейл.
  • Забытый Stop() у тикера пополнения в самописном бакете: утечка горутины на каждый созданный лимитер.
  • Один общий лимитер вместо лимитера на ключ. Один шумный клиент выедает квоту всех остальных.
Суть: generator превращает любой источник в ленивый отменяемый канал. or-done оборачивает чужой канал, за поведение которого вы не отвечаете, и делает его отменяемым — чтобы писать простой range. tee раздваивает поток на два независимых выхода.

Зачем нужен or-done — главный смысл

Когда вы читаете канал, который вернула чужая библиотека, у вас нет гарантии, что его когда-нибудь закроют. Обычный for v := range foreign в этом случае даёт потенциально вечную горутину. Писать вместо него select с ctx.Done() в каждом месте использования утомительно, да и забывается легко. orDone прячет эту защиту внутрь один раз:

// Было: в каждом потребителе шум, который легко забыть.
loop:
for {
    select {
    case v, ok := <-foreign:
        if !ok {
            break loop
        }
        use(v)
    case <-ctx.Done():
        break loop
    }
}

// Стало: защита один раз, потребители тривиальны.
for v := range orDone(ctx, foreign) {
    use(v)
}

Ключевая идиома внутри tee

Обнуление локальной копии канала внутри select: после отправки в o1 присваиваем o1 = nil, и эта ветка перестаёт выбираться, потому что операция с nil-каналом блокируется навсегда. Так элемент уходит сначала тому получателю, кто освободился первым. Ту же идиому применяют в merge2, при переключении между источниками и при временной приостановке чтения.

Где применяют и где не стоит

Эти паттерны пришли из книги «Concurrency in Go», и на собеседовании их спрашивают как проверку на «читал ли». В проде orDone действительно полезен на границе с чужим кодом, generator после появления iter.Seq (Go 1.23) чаще заменяется обычным итератором без горутин и каналов вообще, а tee встречается редко: у него неприятное свойство, оба выхода идут в темпе медленнейшего получателя. Честный ответ «знаю, но в проде вместо generator обычно range-over-func, а вместо tee берут очередь с двумя подписчиками» звучит сильнее, чем заученное определение.

Суть: errgroup.Group — это WaitGroup плюс sync.Once для первой ошибки плюс (в варианте WithContext) cancel, который дёргается при этой ошибке. Wait возвращает первую ошибку. Чтобы собрать все — нужен свой срез под мьютексом и errors.Join.

Что внутри — по сути весь пакет

type Group struct {
    cancel  func(error)
    wg      sync.WaitGroup
    sem     chan token      // появляется после SetLimit
    errOnce sync.Once       // «первая ошибка» фиксируется ровно один раз
    err     error
}

func (g *Group) Go(f func() error) {
    if g.sem != nil {
        g.sem <- token{}    // SetLimit сводится к семафору на канале
    }
    g.wg.Add(1)
    go func() {
        defer g.done()
        if err := f(); err != nil {
            g.errOnce.Do(func() {
                g.err = err
                if g.cancel != nil {
                    g.cancel(g.err)   // отменяем производный ctx, остальные сворачиваются
                }
            })
        }
    }()
}

Сравнение трёх инструментов

WaitGrouperrgrouperrgroup + Join вручную
Ошибкиникак, собирать самомупервая по временивсе
Отмена остальныхнетда, через производный ctxда
Лимит параллелизмарукамиSetLimitSetLimit
Паника в задачероняет процессроняет процессроняет процесс
Когда братьошибок нет или они не важнырезультат бесполезен без всех частейнезависимые задачи, нужна полная картина
Самая частая ошибка
g, _ := errgroup.WithContext(ctx)   // ПОТЕРЯЛИ производный ctx
g.Go(func() error { return fetch(ctx, a) })   // получает ИСХОДНЫЙ ctx
// Ошибка вернётся, но остальные горутины доработают до конца:
// отменять-то нечего, производный контекст выброшен.

g, ctx := errgroup.WithContext(ctx)  // ПРАВИЛЬНО: затеняем ctx
g.Go(func() error { return fetch(ctx, a) })
Чем добить

Сказать, что errgroup отменяет контекст, а не горутины: если задача не проверяет ctx, она доработает, и Wait её дождётся; «быстрого» выхода не будет. И что паника внутри g.Go не перехватывается: если задачи выполняют чужой код, тело оборачивают в recover и превращают панику в ошибку, иначе один плохой ответ внешнего API роняет весь процесс.

Суть: t := time.NewTicker(d) + defer t.Stop() + for { select { case <-t.C: ...; case <-ctx.Done(): return } }. Плюс три обязательных детали: свой таймаут на итерацию, recover внутри итерации, и не выходить из цикла при ошибке итерации.

Восемь вещей, которые проверяют этим вопросом

  1. NewTicker, а не time.Tick: у последнего нет Stop(), и тикер живёт до конца процесса.
  2. defer t.Stop() сразу после создания.
  3. Ветка ctx.Done(): без неё горутина вечная.
  4. Первый прогон до цикла, если работа нужна сразу: Ticker выдаёт первый тик только через период.
  5. Свой дедлайн на итерацию. Зависшая итерация останавливает расписание навсегда и в дампе выглядит как одна невинная горутина.
  6. recover внутри итерации. Паника в фоновой задаче не должна ронять сервис.
  7. Ошибка итерации логируется, но не выходит из цикла. Одна сетевая ошибка не должна убивать расписание до рестарта.
  8. Джиттер. Десять реплик с периодом в минуту создают синхронный всплеск; случайная задержка старта размазывает нагрузку.

Когда Ticker не подходит

// Ticker меряет период между тиками. Если нужен фиксированный интервал
// между окончанием и началом (работа длится непредсказуемо долго), берут Timer.
func runFixedGap(ctx context.Context, gap time.Duration, f func(context.Context)) {
    for {
        f(ctx)
        select {
        case <-time.After(gap):     // с Go 1.23 таймер соберётся GC, утечки нет
        case <-ctx.Done():
            return
        }
    }
}

// Ticker отбрасывает пропущенные тики, а не копит их.
// Это защита от лавины: если итерация длилась 10 периодов, то после неё
// придёт один тик, а не 10 подряд. Но и фактическая частота упадёт:
// иногда это ровно то, что нужно, иногда это скрытая деградация.
Что изменилось в Go 1.23

Раньше незастопленные Timer/Ticker удерживались рантаймом до срабатывания и не собирались GC; отсюда правило «time.After в горячем цикле течёт». С 1.23 (в модулях с go >= 1.23) недостижимый таймер собирается сборщиком, а канал стал небуферизованным, что заодно убрало проблему устаревшего значения в буфере после Reset; в Go 1.27 GODEBUG=asynctimerchan удалён, так что новое поведение действует безусловно. Правило defer t.Stop() осталось: пока на тикер есть ссылка, он продолжает тикать и будить рантайм.

Суть: останавливать снаружи внутрь. Сигнал → readiness=false и пауза → srv.Shutdown → консьюмеры → воркеры (wg.Wait) → флаш телеметрии → закрытие БД и кэша → выход. Отдельный shutCtx от Background() с общим бюджетом меньше terminationGracePeriodSeconds.

Почему порядок именно такой

Правило одно: компонент можно останавливать только после того, как остановлены все, кто им пользуется. HTTP-сервер пользуется воркерами и БД, воркеры пользуются БД, значит порядок жёстко задан: сервер → воркеры → БД. Закроете БД первой, получите шквал ошибок в последних, самых важных запросах, тех самых, которые как раз пытались доработать.

Три вещи, которые различают уровни кандидата

  • Пауза на readiness. В Kubernetes SIGTERM и удаление эндпоинта происходят параллельно: часть трафика ещё летит на под. Без паузы в 5–15 секунд каждый деплой даёт всплеск 502. Про это помнят те, кто реально катал сервисы.
  • shutCtx от context.Background(), а не от корневого. Корневой уже отменён сигналом; если взять его, все шаги остановки завершатся мгновенно с ошибкой.
  • Бюджет меньше grace period оркестратора. 25 секунд при terminationGracePeriodSeconds: 30, иначе SIGKILL оборвёт всё на середине, и аккуратный код не поможет.
Пять подводных камней
  • srv.Shutdown не отменяет выполняющиеся хендлеры, он их ждёт. Хендлер, игнорирующий r.Context(), доработает до конца.
  • Shutdown не ждёт хайджекнутые соединения (WebSocket, SSE): их надо закрывать вручную, ведя свой реестр.
  • os.Exit в обработчике сигнала убивает всё мгновенно: defer не выполнятся, буферы не сбросятся.
  • Библиотека без Stop(), запустившая свою горутину, не даст процессу завершиться штатно. Это критерий выбора зависимости.
  • Второй SIGTERM должен убивать немедленно: для этого сразу после <-ctx.Done() вызывают stop(), возвращая обработчик по умолчанию.
Как проверить, что shutdown работает

Интеграционным тестом, а не глазами: поднять процесс, послать запрос, который заведомо выполняется 5 секунд, через секунду послать SIGTERM и убедиться, что (а) запрос вернул 200, (б) новые соединения отвергаются, (в) процесс вышел с кодом 0, (г) уложился в бюджет. Такой тест ловит регрессии порядка остановки, которые иначе всплывают только на графике 5xx после деплоя.

Суть: три стратегии — блокировать продюсера (естественный backpressure), отказывать сразу (select с default и 429), отбрасывать старое (drop-oldest). Большая очередь — не решение, а способ отложить проблему и заодно испортить латентность.

Три стратегии и когда какая

СтратегияКакКогда
Блокировать продюсераобычная отправка в каналпродюсер — ваш код и его торможение допустимо: чтение файла, консьюмер брокера (он просто перестанет забирать)
Отказать сразуselect { case ch <- j: default: return ErrBusy }внешний трафик: честный 429 с Retry-After лучше, чем таймаут через 30 секунд
Отбросить староепрочитать один элемент из канала и положить новыйтелеметрия, метрики, «последнее состояние»: свежие данные ценнее старых
Отбросить новоето же, что «отказать», но молчатолько там, где потеря допустима и её видно в метрике
// Drop-oldest: в очереди всегда самое свежее.
func (q *Queue) PushLatest(v Item) {
    for {
        select {
        case q.ch <- v:
            return
        default:
            select {
            case <-q.ch:            // выбросили самый старый
                q.dropped.Add(1)    // метрика обязательна: без неё потери невидимы
            default:                // кто-то успел забрать, пробуем снова
            }
        }
    }
}

Как выбирать размер буфера

  • 0 (небуферизованный): жёсткая синхронизация, продюсер идёт в темпе консьюмера. Самая честная и самая наблюдаемая схема.
  • Маленький (1..2×N воркеров) сглаживает микровсплески и джиттер планировщика, не искажая картину. Разумный дефолт.
  • Большой (тысячи) почти всегда ошибка: он маскирует нехватку мощности, добавляет минуты к латентности хвоста и превращает падение процесса в потерю всей очереди. Очередь должна переживать рестарт, значит это уже не канал, а брокер или БД.
Что проговорить обязательно

Что очередь не лечит производительность, она гасит всплески. Если средняя скорость поступления выше средней скорости обработки, никакой размер буфера не поможет: он заполнится и всё равно упрётся, просто позже и с худшей латентностью. Реагировать надо иначе: либо наращивать мощность (N, шардирование, ускорение обработки), либо честно ограничить вход. И в любом случае экспортировать len(ch) метрикой: заполненность очереди даёт самый ранний сигнал приближающейся деградации.