Конкурентность
Секция, ради которой 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 |
| Сколько потянет машина | тысячи | тысячи–десятки тысяч | сотни тысяч–миллионы |
| Есть ли id | PID | TID | есть 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 именно ради локальных очередей: до этого была одна глобальная очередь
под одним мьютексом, и планировщик не масштабировался.
Как планировщик выбирает следующую горутину
Цикл schedule() вызывает findRunnable(), и порядок поиска там строго
определён. На собесе его просят назвать по шагам:
- Каждый 61-й тик планировщика: заглянуть в глобальную очередь и взять оттуда одну горутину. Магическое число нужно, чтобы эта очередь не голодала, пока локальная бесконечно пополняет сама себя.
runnextтекущего P, слот на одну горутину. Туда попадает та, которую только что породилgoили разбудил канал: расчёт на локальность кэша, «мы только что её создали, данные горячие».- Локальная очередь P (
runqget), lock-free кольцевой буфер на 256 элементов. - Глобальная очередь: берём сразу пачку (
len/GOMAXPROCS + 1), чтобы не ходить под глобальный лок на каждую горутину. - netpoll в неблокирующем режиме: забрать горутины, чьи сокеты стали готовы. Netpoller, часть рантайма, следит за готовностью сетевых дескрипторов и держит у себя список горутин, ждущих каждый из них; подробности ниже, в разделе про блокировки.
- При work stealing («воровство работы») простаивающий
исполнитель сам забирает часть очереди у загруженного, вместо того чтобы кто-то сверху
раздавал задачи поровну. Здесь это до четырёх попыток украсть у случайно выбранного P
половину его локальной очереди. На последней попытке разрешено красть и
runnext, и таймеры. - Не нашлось ничего: M отдаёт P в список простаивающих и паркуется (
stopm), пока его кто-нибудь не разбудит.
Отдельный M, который крутится вне модели GMP (ему P не нужен) и спит от 20 мкс до 10 мс. Он делает четыре вещи: retake отбирает P у потока, застрявшего в syscall дольше 20 мкс, и помечает на вытеснение горутину, которая крутится дольше 10 мс; netpoll, если сеть никто не опрашивал больше 10 мс; forcegc, если GC не запускался 2 минуты; и scavenger возвращает неиспользуемую память ОС. Без sysmon Go-программа с одной бесконечно считающей горутиной вешала бы GC.
Блокировки: syscall, netpoller, gopark
Спрашивают это так: «горутина заблокировалась, что с потоком?». Ответ зависит от вида блокировки, и вариантов ровно три.
netpoller тонко оборачивает epoll (Linux),
kqueue (BSD/macOS), IOCP (Windows) и io_uring-подобные механизмы.
Когда ты открываешь сокет через net, рантайм ставит дескриптору
O_NONBLOCK и регистрирует его в поллере. Твой conn.Read(buf) выглядит
синхронным, но внутри: попытка read → EAGAIN → gopark
горутины со ссылкой на её g в структуре pollDesc. Когда
epoll_wait (его дёргает планировщик в findRunnable и sysmon) сообщает
о готовности, netpollready достаёт g и переводит её в
_Grunnable. Это и есть «асинхронный I/O с синхронным кодом», ради которого в
других языках пишут async/await.
Обычные файлы на 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 стеки копирующие.
g.stackguard0;
если места не хватает, уходит в morestack и copystack. Тот же механизм
приспособили под кооперативное вытеснение: планировщик просто пишет в
stackguard0 значение stackPreempt.Вопросы
12runtime.g в куче плюс маленький
растущий стек; её планирует рантайм Go в user space и мультиплексирует на потоки ОС в модели M:N.У процесса своё изолированное адресное пространство и набор ресурсов ядра. Внутри процесса планируются потоки: у каждого свой стек и регистры, а память общая; переключение между потоками стоит входа в ядро и порядка микросекунды. Горутину планирует сам рантайм, и ядро о ней не знает вообще.
Три источника дешевизны
- Стек. Поток резервирует фиксированный (1–8 МБ виртуально, страницы подтягиваются по обращению). Горутина стартует с 2 КБ реальной памяти и растёт копированием. Миллион потоков занимает терабайты виртуального адресного пространства, а миллион горутин обходится несколькими гигабайтами реальной памяти.
- Переключение. Сменить горутину значит сохранить SP, PC и указатель на
gвgobufи загрузить другие. Не нужно менять привилегии, не нужно сбрасывать TLB, кэши остаются тёплыми. ~50–200 нс против 1–2 мкс. - Кто планирует. Планировщик Go знает семантику: он видит, что горутина встала на канале, и переключает мгновенно, а не ждёт истечения кванта. Ядро такого не знает.
Чем горутина хуже потока
- Нет приоритетов, нет affinity к ядру, нет real-time гарантий.
- Нельзя убить извне (см. отдельный вопрос).
- Настоящий блокирующий syscall всё равно займёт целый поток ОС.
- Нет изоляции: паника в любой горутине без
recoverроняет весь процесс.
У горутины есть числовой goid, его видно в панике и в
runtime.Stack. Но в публичном API его нет намеренно: команда Go боялась, что
появятся goroutine-local storage и «привязка контекста к id», как с ThreadLocal в Java,
а это ломает всю модель явной передачи контекста. Достают его грязным парсингом
runtime.Stack, но на ревью такое не проходит.
Механика
Компилятор вставляет в пролог почти каждой функции сравнение указателя стека с
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 это | горутины, каналы, select | GOMAXPROCS и число ядер |
Цитата Роба Пайка «Concurrency is not parallelism» сама по себе ничего не показывает, её называют все. Показывает продолжение: «конкурентность про то, как разложить задачу на независимо продвигающиеся части; параллелизм про то, как эти части исполнить. Go даёт первое как языковую конструкцию, второе как настройку рантайма.»
GOMAXPROCS.Зачем понадобился P
В Go 1.0 модель была GM: одна глобальная очередь под одним мьютексом. На многоядерных
машинах это упиралось в contention, а ещё убивало локальность: горутина могла уехать на
любой поток, и кэш процессора обнулялся. В Go 1.1 Дмитрий Вьюков добавил P: теперь у каждого
P своя очередь на 256 слотов (lock-free кольцевой буфер) и свой mcache, так что
обычные аллокации и планирование идут без единого атомарного конфликта.
Порядок поиска работы (findRunnable)
- раз в 61 тик: одна горутина из глобальной очереди (чтобы та не голодала);
runnext: слот на одну «только что созданную/разбуженную» горутину, ради локальности кэша;- локальная очередь P;
- глобальная очередь: пачкой
len/GOMAXPROCS + 1; - неблокирующий
netpoll; - work stealing: до 4 попыток украсть половину очереди у случайного P;
- ничего нет, и тогда P в idle-список, M паркуется.
Где рождается горутина
go f() кладёт новую G в runnext текущего P. Если локальная очередь
переполнена (256 штук), половина её вместе с новой горутиной уезжает в глобальную очередь
одним батчем. Это и есть «перелив»: одному P не дают накопить всё.
runnext ломает FIFO: последняя созданная горутина запускается первой.
Размен осознанный: локальность кэша против справедливости. Чтобы горутину в
runnext не выбил мгновенно следующий go, у неё есть
«наследование» кванта: если её сразу выбьют, старая уедет в начало локальной очереди.
И красть runnext у соседа разрешено только на последней попытке стилинга,
да и то с задержкой в 3 мкс, чтобы дать паре «отправитель-получатель» доработать.
Цепочка такая: 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
- Перед вызовом рантайм зовёт
entersyscall: сохраняет контекст горутины и переводит P в состояние_Psyscall. - Если syscall возвращается быстро,
exitsyscallподхватывает тот же P: дешёвый быстрый путь. - Если нет, sysmon через ~20 мкс видит зависший
_Psyscallи делаетhandoffp: отбирает P и отдаёт другому M (взяв свободный или создав новый). Работа на этом ядре продолжается. - Когда syscall наконец вернётся, «осиротевший» M попробует получить любой свободный P; если не выйдет, положит горутину в глобальную очередь и припаркуется в пуле потоков.
netpoller
Всё, что открыто через пакет net (и os.Pipe, и таймеры на
части платформ), рантайм переводит в O_NONBLOCK и регистрирует в поллере.
Вызов conn.Read внутри выглядит так: попытка read →
EAGAIN → gopark горутины со ссылкой на её g в
pollDesc. Горутина снята с потока, поток пошёл выполнять следующую.
Поллер дёргают в трёх местах: в findRunnable (неблокирующий
netpoll), перед парковкой последнего M (блокирующий, иначе некому будет
проснуться) и в sysmon, если сеть не опрашивали больше 10 мс. Готовые горутины
переводят в _Grunnable и кладут в очередь.
- Файловый I/O через netpoller не идёт (на Linux epoll для обычных файлов
бесполезен). Под
os.File.Readлежит честный блокирующий syscall. - cgo-вызов ведёт себя так же: занимает поток на всё время, вытеснить нельзя, и вдобавок стоит ~50–100 нс на переход туда-обратно.
- Отсюда рецепт: файловые операции и cgo гоняем через ограниченный пул (семафор на буферизированном канале), а не «горутина на каждый файл».
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-код максимум в 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–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 в цикле.»
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)
}
sudog. Из этой
картинки и выводится, почему отправка в закрытый канал паникует, а чтение отдаёт zero value.Механика отправки: chansend
Порядок проверок в runtime.chansend стоит уметь рассказать наизусть. Обрати
внимание на пункт 3: если кто-то уже ждёт, значение не проходит через буфер вообще,
оно копируется прямо в стек получателя. Так экономится одно копирование, а данные остаются
горячими в кэше.
Небуферизированный канал: рандеву
У небуферизированного канала dataqsiz == 0, и ветка «положить в буфер» не срабатывает
никогда. Значит, отправить получится только тогда, когда получатель уже готов, а принять,
когда готов отправитель. Это и называют рандеву (rendezvous): операция
завершается в момент встречи, значение переезжает атомарно, минуя
любое промежуточное хранилище.
Аксиомы канала — полная таблица
| Операция | 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 | |
- Почему 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, после всех
}
- У канала ровно один владелец: тот, кто его создал, пишет в него и закрывает.
- Владелец отдаёт наружу направленный тип:
<-chan Tдля читателей. Тогда закрыть его физически невозможно:closeна receive-only канале не компилируется. - Если писателей несколько, они не закрывают ничего; закрывает координатор после
wg.Wait(). - Сигнал «остановись» идёт отдельным каналом (
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 выглядит
пугающе, но на нём стоит паттерн «канал ответа внутри запроса»: клиент кладёт в общий
канал запросов структуру, где одним из полей лежит его личный канал для ответа.
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() // клиент ушёл, но воркер всё равно допишет в буфер и не зависнет
}
}
Сделаешь 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
}
}
До 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 байта заголовка, но массив под ним общий: два владельца одного буфера. Самый частый источник «невидимых» гонок в конвейерах.
Вопросы
20hchan в куче:
кольцевой буфер, обычный мьютекс и две 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.
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-before | send и recv взаимно синхронизируют обе горутины | send i-го значения происходит до завершения recv i-го; и recv i-го — до завершения send (i+N)-го |
| Копий значения | 1 (стек → стек) | 2 (стек → буфер → стек), если никто не ждал |
| Задержка | выше: почти всегда парковка | ниже: пока есть место, send не паркуется |
| Что скрывает | ничего | всплеск нагрузки — и задержку обнаружения того, что потребитель умер |
Практический критерий выбора
- Буфер 0 берут, когда нужна синхронизация и обратное давление (backpressure). Продюсер физически не может обогнать консьюмера. Это дефолт, с которого стоит начинать.
- Буфер 1 работает как «почтовый ящик»: отправитель кладёт и уходит, гарантированно не зависая. Канал ответа в RPC, канал ошибки, сигнальный канал.
- Буфер N сглаживает рывки, когда средняя скорость потребителя выше средней скорости продюсера, а пики короткие. Число надо обосновать (например, «столько же, сколько воркеров»), а не «поставим 100, пусть будет».
Буфер маскирует проблему: система выглядит работающей, пока он не переполнится, а потом
падает резко. Плюс всё, что лежит в буфере, при аварийной остановке теряется: персистентной
очереди тут нет. И плюс задержка: значение может пролежать в буфере секунды,
а отправитель уже отчитался «отправлено». Нужна надёжная очередь, бери Kafka или
RabbitMQ, а не chan.
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 channel | panic: close of closed channel |
len/cap | 0 / 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 берёт из очереди следующую горутину и работает
дальше. Процесс встаёт только если ждать начали все горутины разом.Пошагово, что делает рантайм
chansendберёт лок канала, видит пустуюrecvqиdataqsiz == 0.- Достаёт
sudogиз кэша, кладёт в него указатель на отправляемое значение (значение остаётся в стеке отправителя, копии пока нет) и ставит его вsendq. - Вызывает
gopark: статус_Gwaiting, причинаchan send, лок канала отпускается. - Управление уходит в
schedule(). Поток M не блокируется, он подбирает следующую runnable-горутину и продолжает жечь процессор. - Когда придёт получатель,
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.
recvq: тот, кто встал в очередь раньше. Дублирования не бывает
никогда.
recvq устроен как обычный двусвязный список, а dequeue() берёт голову.
Значит, при отправке одного значения его получит горутина, которая раньше всех
припарковалась на этом канале. Отсюда канал и работает распределителем работы:
N воркеров в for job := range jobs разберут задачи без дублей и без
дополнительной синхронизации.
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
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 компилируется в selectnbsend →
chansend(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
}
}
Правило закрытия и почему оно именно такое
Закрытие утверждает: «данных больше не будет». Знать это может только тот, кто данные производит. Если получатель закроет канал, любой продолжающий писать отправитель получит панику. Отсюда полный свод:
- Писатель один: он и закрывает, обычно
defer close(ch). - Писателей много: никто из них не закрывает, закрывает координатор после
wg.Wait()в отдельной горутине. - Надо остановить писателя: шли отдельный канал
done/ctxв обратную сторону, а не закрывай канал данных получателем. - Канал вообще можно не закрывать, если он больше не нужен: GC соберёт его вместе с
hchan. Закрытие нужно только как сигнал.
Почему проверка не работает
// Так не работает: между проверкой и отправкой гонка.
if !isClosed(ch) {
ch <- v // между if и этой строкой канал могли закрыть -> panic
}
Более того, встроенного isClosed вообще нет. Узнать о закрытости можно
единственным способом: попробовать прочитать, а это разрушающая операция.
Работающие подходы, по возрастанию надёжности
- Дисциплина владения (правильный ответ). У канала один владелец: он создаёт,
пишет и закрывает. Наружу отдаётся
<-chan T, так что чужой код физически не может ни писать, ни закрывать. 90% проблем исчезает на этом шаге. - Отдельный done-канал. Сигнал остановки идёт навстречу потоку данных.
Отправитель проверяет его в том же
select, что и отправку:
Запомни: закрываетсяselect { case ch <- v: case <-done: // нам сказали остановиться: выходим, не закрывая ch return }done, а неch. Канал данных вообще может остаться незакрытым, его соберёт GC. sync.Onceна close. Если по архитектуре закрыть канал может несколько мест, остаётся один корректный вариант: обернуть закрытие:
Это убирает панику «close of closed channel», но не убирает панику «send on closed channel»: отправитель по-прежнему может писать в уже закрытый канал.type SafeChan struct { ch chan int once sync.Once } func (s *SafeChan) Close() { s.once.Do(func() { close(s.ch) }) }- Мьютекс вокруг отправки и закрытия. Полностью безопасно, но убивает саму идею
канала:
RWMutex, отправка подRLock, закрытие подLockс флагом. Годится как аварийный вариант в чужом легаси. 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 // хвост не успели: потерю принимаем осознанно
}
}
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), добавить консьюмеров или вынести очередь во внешнюю
систему с персистентностью. «Резиновый канал» уместен только там, где всплеск заведомо
конечен и известен по размеру.
| Значение | Указатель | |
|---|---|---|
| Что копируется | 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 безопасен, потому что строка иммутабельна.
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
}
}
Три штатные замены
- Один
time.Timerна цикл соStop/Reset, если таймаут отсчитывается заново после каждого события (idle timeout). context.WithTimeoutберут, когда дедлайн общий на всю операцию и должен пробрасываться вглубь.ctx.Done()вselect, одинdefer cancel(), никаких лишних таймеров:cancelчестно останавливает внутренний таймер.time.Tickerнужен для периодического такта, а не для однократного дедлайна. Обязательноdefer ticker.Stop(): неостановленный тикер будит рантайм, пока на него есть ссылка, а до 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.
Полный алгоритм включается с двух и более каналов.
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, ни возможности вернуть код выхода.
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(), включая отправку.
- Есть ветка
<-ctx.Done(), если цикл вообще должен уметь останавливаться. - Принимаешь с
, ok, если канал могут закрыть, и выходишь по явной ветке на!ok. - Никаких
time.Afterв теле цикла: только заранее созданныйTimer/TickerсоStop. defaultесть, только если ты сознательно готов потерять значение;defaultвнутриforбез блокирующей ветки даёт busy-loop на 100% CPU.- Наружу отправляешь тоже через
selectс отменой.
Вопросы
6select — вызов runtime.selectgo:
залочить все каналы в порядке адресов, обойти case в случайном порядке, при готовом —
выполнить и выйти; иначе default; иначе встать sudogом во все
очереди и заснуть, а после пробуждения сняться с остальных.Три прохода
- Проход 1 — пробуем без блокировки. Каналы залочены в
lockorder(сортировка по адресу), обход идёт вpollorder(случайная перестановка). Готовый case — это непустая противоположная очередь ожидающих, либо место/данные в буфере, либо закрытый канал на приёме. Нашли: обмен, разлочить всё, вернуть индекс. - Проход 2 — встаём в очереди. Если готовых нет и
defaultнет, на каждый case заводится свойsudogи встаёт вsendqилиrecvqнужного канала. Потомgopark: горутина уходит в_Gwaiting, все локи отпускаются, поток ОС продолжает работать с другими горутинами. - Проход 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 нс на быстром пути.
Что было бы при проверке сверху вниз
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 завершится и утащит за собой весь процесс вместе со всеми
горутинами, а фоновые должны работать дальше. В реальном сервисе это всё равно почти всегда
плохое решение.
- Нет 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 {}
паркуется честно и не ест ничего.
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)
Закрытый канал в 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) внешняя отмена
}
}
}
- Два независимых способа завершиться: закрытие входного канала (данные кончились) и отмена контекста (нас просят прекратить). Их нельзя смешивать.
- Вложенный
selectна отправке. Без него отмена работает ровно до того момента, пока воркер не соберётся отдать результат, а дальше он висит намертво. defer tick.Stop(): неостановленныйTickerпродолжает тикать и держать ресурсы.- Возврат
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.
Ни владельца, ни счётчика рекурсии, ни привязки к горутине. Отсюда и растёт половина граблей.
Что происходит при Lock, по шагам
- Fast path.
CAS(state, 0, mutexLocked). Если получилось — всё, лок наш, стоимость ~15–25 нс. Так заканчивается подавляющее большинство вызовов. - Spin. Если лок занят, но мьютекс в normal mode, очередь коротка и есть свободные P,
горутина крутится активно: до 4 итераций по 30 инструкций
PAUSE(procyield). Расчёт простой: владелец отпустит лок через десятки наносекунд, и это дешевле парковки с пробуждением. - Парковка. Спин не помог — увеличиваем счётчик
waitersи вызываемruntime_SemacquireMutex. Это семафор рантайма: горутина уходит в_Gwaiting, поток ОС свободен. Никакогоfutexна уровне потока здесь нет — блокируется именно горутина. - Пробуждение. Проснувшись, горутина смотрит режим: в normal — пробует захватить
снова (и может проиграть новичку), в starvation — получает лок сразу, потому что
Unlockпередал его напрямую. - Переключение режима. Если горутина в момент захвата обнаружила, что ждала дольше
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) и, если значение неотрицательное,
сразу возвращается.
| Сценарий | Mutex | RWMutex | Комментарий |
|---|---|---|---|
| Короткая критическая секция (чтение поля) | быстрее | медленнее | у RWMutex вдвое больше атомарных операций; выигрыш не окупается |
| Много читателей, редкие записи, секция > ~1 мкс | узкое место | быстрее | читатели идут параллельно |
| 50/50 чтение и запись | быстрее | медленнее | писатель всё равно всех выстраивает в очередь |
| Много ядер, очень много читателей | — | осторожно | readerCount — одна кэш-линия, все ядра дерутся за неё (cache line bouncing) |
Наивный RWMutex пускает читателей всегда, пока их счётчик больше нуля: при плотном потоке чтений писатель не пройдёт никогда. В Go это закрыто: Lock
писателя вычитает из readerCount константу rwmutexMaxReaders
(1<<30), делая счётчик отрицательным. Все новые RLock видят
отрицательное значение и уходят спать. Писатель ждёт только тех читателей, которые уже
внутри. То есть писатель блокирует новых читателей, но не наоборот, и голодания писателя не будет. Зато работает обратное: плотный поток писателей подтормаживает читателей.
- Вложенный
RLockиз-подRLockможет задедлочить. Если между ними успел встать писатель, внутреннийRLockзаблокируется, а внешний не отпустится — классический deadlock, документированный в исходниках. - Апгрейда
RLock→Lockнет. Нужно отпустить чтение, взять запись и перепроверить состояние: за время между ними мир изменился. 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() }
Анализатор 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()
Addвнутри горутины.go func(){ wg.Add(1); ... }()— гонка:Waitможет увидеть счётчик 0 и вернуться до того, как горутина успеет стартовать.Addстрого доgo.- Копирование
WaitGroup. Передали по значению в функцию — там своя копия счётчика,Waitв оригинале ждёт вечно. Только*sync.WaitGroupили замыкание. - Забытый
Doneпри панике или раннемreturn. Толькоdefer wg.Done()первой строкой горутины. - Переиспользование до завершения
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
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 для быстрых
чтений без лока и dirty под мьютексом для всего остального. Выигрыш есть только
там, где ключи стабильны, а чтений сильно больше записей.| Операция | Что происходит | Стоимость |
|---|---|---|
Load, ключ в read | atomic-загрузка указателя + чтение мапы | очень дёшево, лока нет |
Load, ключа в read нет, amended | лок, поиск в dirty, misses++ | дорого |
Store существующего ключа | CAS прямо в *entry | дёшево, лока нет |
Store нового ключа | лок, при пустой dirty — копирование всей read | очень дорого, O(n) |
Delete | помечает entry как expunged, память не освобождает сразу | средне |
Range | при amended сначала промоутит dirty в read | дорого, снимок не консистентен |
- Ключ пишется один раз, читается много раз — кэши только на дозапись.
- Несколько горутин читают, пишут и перезаписывают непересекающиеся наборы ключей — шардирование по горутинам.
- Нет типизации до дженериков:
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 всё
зелено, а в проде на другой архитектуре раз в сутки прилетает ноль вместо значения.
- Внутри одной горутины всё выполняется так, будто по порядку исходника (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 + map | sync.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
}
- Не работает с
select. Нельзя ждать «Cond или отмену контекста» — а это нужно почти всегда. Единственный обходной путь — отдельная горутина, которая по отмене дёргаетBroadcast. - Не отменяем. Горутина в
Waitждёт до победного. - Легко ошибиться:
Waitобязан стоять в цикле проверки условия,Signal/Broadcast— рядом с изменением состояния, копироватьCondнельзя. - Есть замена: канал (для сигнала),
WaitGroup(для «дождаться всех»),errgroup,semaphore.Weighted. Реальные примененияCond— очередь с ограниченной ёмкостью и множеством ждущих разных условий, например внутри пула соединений.
Вопросы
13state int32 с битами
locked/woken/starving/waiters и семафор рантайма. Fast path — один CAS. Мьютекс
не реентерабельный: повторный Lock из той же горутины блокирует её
навсегда.Путь захвата
- CAS(state, 0, locked) — успех, ~15–25 нс, дальше ничего не происходит.
- Спин. Занят, но normal mode и есть свободные ядра — крутимся до 4 раз по 30
инструкций
PAUSE. Дешевле, чем парковка, если владелец вот-вот отпустит. - Парковка.
runtime_SemacquireMutex: горутина в_Gwaiting, поток ОС свободен и берёт другую работу. Это семафор рантайма, а не futex потока. - Пробуждение. В 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 уходят спать, писатель дожидается только тех, кто уже внутри.
Голодания писателя нет. Обратная сторона: при плотном потоке писателей подтормаживают уже читатели, но это честная очередь.
// 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/syncv0.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 выполнится второй раз.
Как устроен
У каждого P — свой poolLocal с приватным слотом (доступ вообще без
синхронизации) и локальной очередью, из которой другие P могут воровать. Поэтому
Get/Put почти бесплатны и хорошо масштабируются по ядрам.
Если ничего не нашлось — вызывается New.
Связь с GC — самое важное
- При старте цикла GC хук
poolCleanupпроходит по всем пулам. - Текущие локальные наборы переезжают в victim-кэш, а старые victim выбрасываются насовсем.
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 }}
- Объект грязный.
Getможет вернуть что угодно из прошлой жизни. Не сбросили — утечка данных между запросами, вплоть до чужих персональных данных в ответе. Это реальная уязвимость, а не теория. - Put после того, как отдали наружу. Если
buf.Bytes()уехал в структуру, которая переживётdefer Put, ты вернул в пул память, на которую есть живая ссылка, и следующийGetперезапишет её. - Класть в пул слайс (не указатель) — аллокация.
Put(any)упаковывает значение в интерфейс; для не-указателя это боксинг в кучу, то есть ровно то, от чего мы уходили. - Растущие объекты. Если в пул возвращать буферы после обработки гигантского
запроса, пул начнёт хранить их, и память вырастет. Лечение —
не класть обратно, если
capпревысил порог. - Это не пул соединений. Нет ограничения количества, нет времени жизни,
содержимое исчезает при 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.
Два сценария, где она оправдана
- Ключ записывается один раз, читается много раз — кэш, который только растёт: скомпилированные шаблоны, метаданные типов, справочники.
- Разные горутины работают с непересекающимися наборами ключей — тогда промахи
редки, а
readу каждой своя по факту доступа.
Почему обычно хуже mutex+map
| sync.Map | map + RWMutex | шардированная мапа | |
|---|---|---|---|
| Типизация | any, нужен каст, боксинг | полная | полная |
len | нет, только полный Range | есть | сумма по шардам |
| Композитные операции | только LoadOrStore/CAS | любые под локом | любые под локом шарда |
| Много новых ключей | плохо: копирование O(n) | нормально | хорошо |
| Чтение при стабильных ключах | отлично | средне | хорошо |
| Понятность кода | средняя | максимальная | средняя |
Внутренности sync.Map переписали с пары read/dirty на
HashTrieMap — конкурентное хеш-дерево, где узлы обновляются локально и не
требуют копирования всей мапы. Запись новых ключей стала кратно быстрее, а провалы
на смешанной нагрузке ушли. API не изменился, нетипизированность осталась. Хороший
ответ на собесе упоминает и классическое устройство (его спрашивают чаще), и то, что
в актуальных версиях оно уже другое.
«По умолчанию беру map под Mutex — это понятно и типизировано.
Если профиль показывает контенцию и нагрузка read-heavy со стабильным набором ключей,
смотрю на sync.Map. Если ключей много и они постоянно меняются — делаю
шардирование по хешу на 64–256 мап. sync.Map — специализированный
инструмент, а не быстрая мапа на все случаи.»
Что есть в пакете
| Операция | Смысл |
|---|---|
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 плюс проверки, а в худшем —
парковку горутины и переключение. Но под высокой конкуренцией атомик тоже упирается
в шину: десять ядер, инкрементящие одну переменную, гоняют кэш-линию между собой,
и это может быть медленнее, чем шардированный счётчик или локальные счётчики с
периодическим сведением.»
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/default | TryAcquire |
| Справедливость | FIFO очереди канала | FIFO, но большой вес ждёт накопления слотов |
| Зависимости | нет | x/sync |
- Release без defer. Паника или ранний
return— и слот потерян навсегда; после N таких случаев семафор закрыт наглухо. - Release чужого веса в
Weighted— паникаsemaphore: released more than held. - Путают семафор и worker pool. Семафор ограничивает одновременность, но горутина на каждую задачу всё равно создаётся. Worker pool ограничивает и одновременность, и число горутин. Для миллиона мелких задач нужен пул, для сотни тяжёлых — семафор проще.
- Забывают, что
errgroup.SetLimit— это тот же семафор на канале, уже написанный за тебя.
Критерии
| Признак задачи | Выбор |
|---|---|
| Передача владения данными, конвейер, очередь задач | канал |
| Сигнал отмены/завершения, 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 по изменению состояния, когда состояние сложное и его нельзя «передать» значением, — например, «конфигурация перезагружена».
- Не работает в
select. Нельзя ждать «Cond илиctx.Done()» — а без отмены в проде нельзя. Обходной путь уродлив: отдельная горутина, которая по отмене контекста дёргаетBroadcast, плюс проверкаctx.Err()в цикле условия. - Легко ошибиться:
Waitбез цикла,Signalбез изменения состояния,SignalвместоBroadcastпри нескольких разных условиях (разбудишь не того, и никто не проснётся никогда). - Копировать нельзя,
go vetругается. - Есть готовые замены: канал (сигнал), закрытие канала (broadcast),
WaitGroup(дождаться всех),semaphore.Weighted(ждать ресурс с отменой),errgroup.
Рабочий ответ: «В продакшн-коде — ни разу, и это нормально: практически любая
задача, где хочется Cond, в Go решается каналом с отменой. Понимаю, как он
работает и почему Wait обязан быть в цикле, но выбрал бы его только там,
где нужно много ждущих на разных условиях под одним состоянием — например, в
собственной ограниченной очереди.»
Once, WaitGroup,
atomic.Что такое happens-before
Это частичный порядок между событиями программы. Если запись W happens-before чтение R и между ними нет других записей в ту же переменную, то R гарантированно видит W. Если такого ребра нет — R может увидеть что угодно: старое значение, новое, а на некоторых архитектурах и разорванное.
Откуда берутся рёбра
- Внутри одной горутины — порядок исходного кода.
go f(): всё, что было до запуска, видно внутри новой горутины.- Отправка в канал happens-before завершения соответствующего приёма.
close(ch)happens-before приёма нулевого значения из-за закрытия.- n-й
Unlockhappens-before (n+1)-гоLock. Once.Do(f): завершениеfhappens-before возврата из любого другогоDo.WaitGroup: всеDonehappens-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
Развести горутины по времени — только половина дела. Вторая половина: вообще увидеть чужие записи и увидеть их согласованно. Именно поэтому «у меня записывает только одна горутина, а читают остальные — гонки нет» — неверное рассуждение: гонка есть, и она может проявиться как «читатель никогда не видит новое значение» или «видит новый флаг и старые данные».
В отличие от 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 значение
}
WithCancel/WithTimeout подвешивает
узел к родителю. Отмена распространяется строго вниз, и потому «отменить весь запрос одним
вызовом» не задевает соседние запросы.Что внутри
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(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)
}
Долгие годы ctx.Err() возвращал ровно два значения на всю экосистему:
context.Canceled и context.DeadlineExceeded. В логах это выглядело
как «context canceled» без единого намёка, кто и зачем отменил. Одна из самых частых жалоб
на Go в проде. WithCancelCause закрывает вопрос: причина сохраняется в поле
cause у самого верхнего отменённого узла и наследуется вниз, а
ctx.Err() остаётся прежним ради обратной совместимости. В своих сервисах
правило простое: отменяешь — объясняй.
Правила использования
ctx— первый параметр, всегда с именемctx:func Get(ctx context.Context, id int) (*User, error).- Не хранить в полях структур. Контекст живёт столько же, сколько операция, а структура
живёт дольше. Исключение одно и признано самим стандартом:
http.Request, где контекст неотделим от запроса. - Никогда не передавать
nil. Если непонятно, что передать, ставьcontext.TODO(). defer cancel()обязателен после любогоWithCancel/WithTimeout/WithDeadline. Без него узел остаётся вchildrenродителя, а таймер лежит в куче таймеров.go vetловит это правиломlostcancel.WithValue— только для request-scoped данных: request id, trace id, user id, локаль. Не для зависимостей (логгер, БД, конфиг), их передают явно.- Ключ
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) // паника при рефакторинге, ноль проверок компилятора
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()
}
}
Закрывает все слушающие сокеты (новых соединений сервер больше не принимает), закрывает все
idle-соединения keep-alive, и ждёт, пока активные запросы доработают. Уже выполняющиеся
хендлеры он не отменяет: их r.Context() отменится, только когда истечёт
shutCtx. И Shutdown не ждёт перехваченные соединения
(hijacked), где хендлер через http.Hijacker забрал себе сырой TCP-сокет
и дальше говорит по нему сам, мимо net/http; так работают WebSocket и апгрейд
протокола. Сервер про такое соединение уже ничего не знает, поэтому закрывать его приходится
самому.
Раньше из <-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Задача, которую он решает
Один входящий запрос порождает десятки горутин: сервисный слой, три параллельных запроса к
БД, вызов внешнего 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()
- Клиент разорвал соединение или отменил стрим.
- Истёк
Server.WriteTimeout/ сработалhttp.TimeoutHandler. - Идёт
Server.Shutdownи вышел его дедлайн, либо вызванServer.Close. 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 по шагам
- Помечает сервер как закрывающийся и закрывает слушающие сокеты: новых соединений ОС уже не принимает (клиенты получат connection refused, поэтому балансировщик надо вывести из ротации ЗАРАНЕЕ).
- Закрывает все idle keep-alive соединения.
- В цикле с растущим интервалом (от 1 мс до 500 мс) проверяет, остались ли активные запросы, и ждёт, пока их не станет ноль либо не истечёт переданный контекст.
- Вызывает зарегистрированные
RegisterOnShutdownхуки: там обычно закрывают WebSocket-соединения, которые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
}
Три способа починить
// 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 горутины, её эпоха на момент доступа, размер обращения и флаг чтение/запись. Новый доступ сравнивается с тем, что лежит в теневых ячейках: если эпоха предыдущего писателя больше, чем то, что мой вектор знает про эту горутину, значит порядок между нами не установлен — гонка.
Детектор работает через 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: сколько прошлых доступов помнит горутина, больше = меньше пропусков, дороже
Гонка не воспроизводится по требованию: зависит от числа ядер, нагрузки, версии 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 {}
}
Как рантайм детектит дедлок и когда не детектит
Знаменитое fatal error: all goroutines are asleep - deadlock! печатает
функция checkdead() в планировщике. И это не детектор дедлоков. Рантайм
не строит граф ожидания и не разбирает, кто кого ждёт. Он проверяет одно глобальное
условие: «во всей программе не осталось ни одного потока, который мог бы когда-нибудь
получить работу».
checkdead вызывается, когда последний рабочий поток M собирается
уйти в сон, и считает три числа: сколько есть runnable-горутин, сколько
M сидит в системных вызовах и есть ли активные таймеры. Если работы нет
нигде, а живые горутины при этом заблокированы (_Gwaiting) — значит,
разбудить их некому, и рантайм честно падает, печатая стеки.
- Частичный дедлок. Пять горутин намертво стоят на двух мьютексах, а
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-дампом.»
Диагностика зависания в проде
| Инструмент | Как снять | Что видно |
|---|---|---|
| SIGQUIT | kill -QUIT <pid> или Ctrl+\ | стеки всех горутин и аварийное завершение процесса. Работает без импортов и эндпоинтов, если процесс сам не перехватил SIGQUIT через signal.Notify |
| pprof goroutine | curl host/debug/pprof/goroutine?debug=2 | то же самое, включая время ожидания у стоящих дольше минуты, но без убийства процесса |
| pprof, агрегат | ?debug=1 или go tool pprof | сгруппировано по стекам: «7412 горутин стоят вот на этой строке» |
| goroutineleak | curl host/debug/pprof/goroutineleak?debug=1 (Go 1.27) | только те горутины, которые заблокированы навсегда: примитив, на котором они стоят, уже недостижим. Отделяет утечку от «просто долго ждёт» |
| mutex profile | runtime.SetMutexProfileFraction(5) | кто держал мьютекс, пока другие ждали, и сколько они прождали — контеншен, ещё не дедлок |
| block profile | runtime.SetBlockProfileRate(1e6) | блокировки на каналах, select, WaitGroup, семафорах |
| метрика | runtime.NumGoroutine() в Prometheus | монотонный рост — утечка; резкая полка — что-то встало |
| метрики планировщика | runtime/metrics: /sched/goroutines/* и /sched/threads/total (Go 1.26), /sched/latencies | сколько горутин реально исполняется, сколько готово, сколько ждёт, сколько сидит в syscall или cgo — и сколько потоков ОС завёл рантайм |
| delve | dlv 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, а по хвосту латентности.
| Дедлок | Livelock | Starvation | |
|---|---|---|---|
| Состояние горутин | заблокированы, _Gwaiting | активно работают, жгут CPU | runnable, но не получают ресурс |
| Прогресс | нулевой навсегда | нулевой, но «работа кипит» | есть у системы, нет у конкретной горутины |
| Как выглядит | 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 по каналу, который не закрыли | продюсер упал по ошибке, не дойдя до close | defer close(out) у владельца канала |
| Бесконечный цикл без выхода | for { select { case v := <-in: ... } } без ctx.Done() | обязательная ветка case <-ctx.Done(): return |
| Тикер без Stop | t := 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()) },
))
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 или канал завершения.
Вопросы
10counter++ — это load/add/store, три шага, между
которыми может влезть кто угодно. Чинится мьютексом (универсально), atomic (быстро,
одно слово) или горутиной-владельцем с каналом (когда вокруг состояния есть логика).Как отвечать по шагам
- Назвать проблему точно. «
counter++не атомарен: по сути это чтение из памяти, инкремент и запись обратно (на arm64 три инструкции, на amd64 одна INCQ, но без префикса LOCK и она не атомарна). Две горутины читают одно и то же значение, обе прибавляют единицу, обе записывают — одно обновление потеряно. Это lost update.» - Показать масштаб. «На 1000 горутин по 1000 инкрементов вместо миллиона получится случайное число, и чем больше ядер, тем оно меньше: на двух ядрах около 64% от ожидаемого, на 14 ядрах около 22%. И оно разное от запуска к запуску.»
- Доказать инструментом.
go run -race main.goи показатьWARNING: DATA RACEс двумя стеками. - Починить и объяснить выбор. Три варианта из теории выше, и главное тут критерий выбора, а не список из трёх пунктов.
Компактный сравнительный бенчмарк
// 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 в цикле
или мьютекс: атомарность одной операции не даёт атомарности последовательности.
-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 ломает внутренние структуры,
и продолжать выполнение опаснее, чем упасть.
Что говорить, если просят «в общих чертах»
«Каждая горутина носит вектор часов — по счётчику на каждую известную горутину.
Синхронизация (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 |
| Ожидание сети / файла / cgo | M в 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 и печатает стеки всех горутин —
это второй способ увидеть
частичный дедлок.
/debug/pprof/goroutine?debug=2, жёстко — kill -QUIT
(процесс умрёт). Дальше сгруппировать горутины по состоянию и стеку и найти те,
что стоят дольше любого разумного таймаута.Порядок действий по инциденту
- Посмотреть метрики.
go_goroutines— полка или рост? CPU — ноль (дедлок) или сотка (livelock)? RPS упал в ноль или частично? - Снять дамп, не убивая под.
curl host:6060/debug/pprof/goroutine?debug=2 > dump.txt. Если pprof не подключён — снимать черезkill -QUITна поде, который не жалко, предварительно выведя его из балансировки. - Сгруппировать. Состояние указано в заголовке каждой горутины, а у стоящих
дольше минуты — и время:
goroutine 42 [sync.Mutex.Lock, 17 minutes]. - Найти пару. Для дедлока на локах характерны две-три группы, чьи стеки берут одни и те же мьютексы в разном порядке. Для утечки — одна разросшаяся группа с одинаковым стеком.
- Проверить внешние ресурсы. Если все висят в
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).
// Берут только там, где отменяемость важнее наносекунд.
Забыть отпустить уже взятый лок перед повтором. Код
a.Lock(); if !b.TryLock() { continue } без a.Unlock()
превращает попытку избежать дедлока в гарантированный дедлок плюс утечку.
Вторая ошибка — повтор без задержки и без джиттера: две симметричные горутины
входят в идеальный такт и получают livelock, который выглядит как 100% CPU
и полное отсутствие прогресса.
Дедлоки на каналах — витрина случаев
// 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 с) и убивает одну транзакцию с SQLSTATE40P01; 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: как получается
// Две горутины «вежливо уступают» друг другу и обе не двигаются.
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-профиль.»
cancel, канал не закрыли,
цикл без ветки ctx.Done(). Ловится goleak в тестах,
метрикой NumGoroutine и pprof в проде, а с Go 1.27 — ещё и штатным профилем
goroutineleak, который большой класс утечек находит точно, а не по
косвенным признакам.Три вопроса к каждому go func
- Что её завершит? Ровно три допустимых ответа: закроется входной канал,
сработает
ctx.Done(), закончится конечная работа. - Кто её дождётся?
WaitGroup,errgroupили канал завершения. Горутина, которую никто не ждёт, при shutdown будет убита посреди работы. - Что она удерживает? Замыкание держит все захваченные переменные. Горутина, которая «просто ждёт», может удерживать в живых мегабайтный буфер запроса и сетевое соединение.
Сценарии, помимо перечисленных в теории
- Отправка в канал результата после отмены. Даже с
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.Sleep — time.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 всегда идёт с дедлайном, потому что ждать зависшую работу вечно нельзя.
- У канала один владелец. Это тот, кто создал канал и пишет в него. Только он его закрывает, и ровно один раз. Читатели не закрывают канал никогда.
- Функция возвращает канал только на чтение (
<-chan T), а принимает только на запись (chan<- T). Тогда компилятор сам не даст нарушить пункт 1. - Каждая блокирующая операция идёт через
selectсctx.Done(): и приём, и отправка. Незащищённая отправка течёт чаще всего. - Тот, кто запустил горутину, обязан её дождаться. Иначе корректный graceful shutdown не написать: непонятно, когда работа закончилась.
Worker pool
Задача: обработать M элементов не более чем N параллельными
обработчиками, собрать результаты и ошибки, уметь остановиться по сигналу в любой момент.
Самый частый запрос на лайвкодинге в Go. Спотыкаются почти все в трёх местах:
кто закрывает канал задач, кто закрывает канал результатов и что происходит
с отправкой результата, если получатель уже ушёл.
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 { ... } }(), иначе одна плохая задача роняет весь сервис.
- «Как выбрать N?» CPU-bound упирается в
runtime.GOMAXPROCS(0)(больше не ускорит, только добавит переключений). IO-bound упирается во внешний ресурс: лимит стороннего API,MaxOpenConnsу БД, число партиций. Универсального числа нет, зато есть универсальный ответ: «замерить, а потом сделать настраиваемым». - «Что если задачи приходят потоком, а не слайсом?» Ничего не меняется: продюсер
читает из своего источника, всё остальное как есть. Меняется только буфер
resCh: по числу задач его уже не выставишь. - «Как добавить ретраи?» Внутри
handle, с бэкоффом и джиттером, либо отдельным каналомretryCh, но тогда заводи счётчик попыток вJobи защиту от вечного цикла. - «Как остановиться по первой ошибке?» Это уже
errgroup, он ниже отдельным паттерном. - «А если воркеры должны быть постоянными, а не на один вызов?» Пул превращается
в долгоживущий компонент со своим
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. Держится всё на двух вещах: каждая стадия владеет только своим выходным каналом и закрывает только его, а сигнал остановки идёт во все стадии сразу и распространяется каскадом.
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, и отмена.
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{}либо копипастили под каждый тип; сейчас одна функция закрывает всё.
Первый: закрывать out внутри output. При len(chans) > 1
это гарантированная паника двойного закрытия. Второй: вызвать wg.Wait()
синхронно перед return out. Функция заблокируется до конца всей работы,
читателя ещё нет (канал не вернули), писатели встанут на отправке, дедлок.
Обе ошибки встречаются на собеседованиях чаще, чем правильный вариант.
- «Сохраняется ли порядок?» Нет. Если нужен, нумеруйте элементы на входе и
пересобирайте на выходе через
map[int]Tплюс указатель на следующий ожидаемый индекс. - «Как сделать merge на двух каналах без WaitGroup?» Через
selectс обнулением закрытых каналов:case v, ok := <-a: if !ok { a = nil; continue }. Приём изnil-канала блокируется навсегда, поэтому исчерпанная ветка просто выключается; выходим, когда оба сталиnil. - «А если каналов заранее неизвестное число и они добавляются на ходу?»
reflect.Select(медленно и некрасиво) либо «дерево merge»: сливать попарно, каждый новый канал подмешивая ещё одним merge. - «Чем это отличается от
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, если срез пуст
}
errgroup | WaitGroup + errors.Join | |
|---|---|---|
| Поведение при ошибке | отменяет ctx, остальные сворачиваются | все доводят работу до конца |
| Что возвращает | первую по времени ошибку | все ошибки, обёрнутые в одну |
| Ограничение параллелизма | SetLimit из коробки | руками, семафором |
Совместимость с errors.Is/As | да, обычная ошибка | да, Join поддерживает обход дерева |
| Когда брать | результат бесполезен без всех частей | части независимы, нужна полная картина |
- Не переприсвоить
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останавливает расписание навсегда, и в дампе это выглядит как одна невинная горутина.
До 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» в последних запросах.
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 за отведённое время.
Вопросы
12N горутин с
for range jobs + канал результатов с полем Err +
WaitGroup. Продюсер закрывает jobs, отдельная горутина
закрывает results после wg.Wait(). Полный код — в теории выше.Как писать это на доске, чтобы не запутаться
Не начинайте с кода. Начните с четырёх вопросов вслух. Интервьюер оценивает именно это, а код потом ложится сам:
- Сколько каналов и кто их владелец? Два:
jobs(владеет продюсер) иresults(владеют воркеры, коллективно). - Кто и когда их закрывает?
jobsзакрывает продюсер после последней задачи.resultsзакрывает отдельная горутинаgo func(){ wg.Wait(); close(results) }(): ни один воркер не знает, закончили ли остальные. - Что произойдёт при отмене посередине? Продюсер перестаёт раздавать, воркеры доделывают текущее, коллектор дочитывает то, что успели положить.
- Что если получателя результатов не станет? Либо буфер на все результаты,
либо
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 вместо аккуратного слияния.
Первый: 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.
Сначала спросить встречно: «а порядок вообще нужен?» Очень часто оказывается, что нет, и вся сложность отпадает. Если нужен, уточнить, конечен ли вход: для конечного правильный ответ «слайс по индексу, канал не нужен», и это сильный ответ, потому что он проще. Переупорядочивающий буфер берут только для потоков, и обязательно с оговоркой про ограничение памяти.
Реализация целиком — четыре метода
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 решают разные задачи,
первый про одновременность, второй про частоту, и в проде обычно нужны оба.
golang.org/x/time/rate: там нет
фоновой горутины вообще, токены вычисляются из времени.Сравнение
| Тикер | Токен-бакет (свой) | x/time/rate | |
|---|---|---|---|
| Всплески | невозможны | до burst | до burst |
| Фоновая горутина | нет (тикер — таймер рантайма) | есть, надо останавливать | нет: lazy refill по времени |
| Точность на высоких rps | плохая: упирается в разрешение таймеров (~1 мс) | та же проблема | хорошая: считается арифметикой |
| Неблокирующий режим | через select с default | Allow() | 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()у тикера пополнения в самописном бакете: утечка горутины на каждый созданный лимитер. - Один общий лимитер вместо лимитера на ключ. Один шумный клиент выедает квоту всех остальных.
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, остальные сворачиваются
}
})
}
}()
}
Сравнение трёх инструментов
WaitGroup | errgroup | errgroup + Join вручную | |
|---|---|---|---|
| Ошибки | никак, собирать самому | первая по времени | все |
| Отмена остальных | нет | да, через производный ctx | да |
| Лимит параллелизма | руками | SetLimit | SetLimit |
| Паника в задаче | роняет процесс | роняет процесс | роняет процесс |
| Когда брать | ошибок нет или они не важны | результат бесполезен без всех частей | независимые задачи, нужна полная картина |
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 внутри
итерации, и не выходить из цикла при ошибке итерации.Восемь вещей, которые проверяют этим вопросом
NewTicker, а неtime.Tick: у последнего нетStop(), и тикер живёт до конца процесса.defer t.Stop()сразу после создания.- Ветка
ctx.Done(): без неё горутина вечная. - Первый прогон до цикла, если работа нужна сразу:
Tickerвыдаёт первый тик только через период. - Свой дедлайн на итерацию. Зависшая итерация останавливает расписание навсегда и в дампе выглядит как одна невинная горутина.
recoverвнутри итерации. Паника в фоновой задаче не должна ронять сервис.- Ошибка итерации логируется, но не выходит из цикла. Одна сетевая ошибка не должна убивать расписание до рестарта.
- Джиттер. Десять реплик с периодом в минуту создают синхронный всплеск; случайная задержка старта размазывает нагрузку.
Когда 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 подряд. Но и фактическая частота упадёт:
// иногда это ровно то, что нужно, иногда это скрытая деградация.
Раньше незастопленные Timer/Ticker удерживались рантаймом
до срабатывания и не собирались GC; отсюда правило «time.After в
горячем цикле течёт». С 1.23 (в модулях с go >= 1.23) недостижимый
таймер собирается сборщиком, а канал стал небуферизованным, что заодно убрало
проблему устаревшего значения в буфере после Reset; в Go 1.27
GODEBUG=asynctimerchan удалён, так что новое поведение действует
безусловно. Правило
defer t.Stop() осталось: пока на тикер есть ссылка, он продолжает
тикать и будить рантайм.
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(), возвращая обработчик по умолчанию.
Интеграционным тестом, а не глазами: поднять процесс, послать запрос, который
заведомо выполняется 5 секунд, через секунду послать SIGTERM и
убедиться, что (а) запрос вернул 200, (б) новые соединения отвергаются,
(в) процесс вышел с кодом 0, (г) уложился в бюджет. Такой тест ловит регрессии
порядка остановки, которые иначе всплывают только на графике 5xx после
деплоя.
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) метрикой: заполненность
очереди даёт самый ранний сигнал приближающейся деградации.