Горутина представляет собой функцию, которая выполняется конкурентно под управлением собственного планировщика рантайма Go: она стартует с маленьким стеком, который растёт по необходимости, а рантайм мультиплексирует множество горутин на несколько потоков ОС, поэтому запуск дёшев и обходится без системного вызова.
Запуск: go f(x): аргументы вычисляются сразу, а возвращаемое значение отбрасывается.
Стек: у потока фиксированный стек в несколько МБ; горутина стартует с малым и растёт по необходимости.
Планирование: потоки переключает ядро; горутины переключает планировщик Go в пользовательском пространстве (модель G-M-P). Код Go одновременно исполняют не больше GOMAXPROCS потоков.
Идентичность: у горутины нет публичного ID и приоритета, и снаружи её нельзя убить. Она останавливается только возвратом, поэтому способ её остановки проектируете вы.
GoMiddle
Горутины и каналы
Каналы, select, пулы воркеров, утечки горутин, errgroup
Закрыто вопросов
0 / 25
- 0Повторить
- 9Часто спрашивают
Вопросы
Конкурентность описывает устройство программы как набора задач, которые продвигаются независимо; параллелизм означает одновременное выполнение нескольких из них на разных ядрах. Конкурентность касается архитектуры, параллелизм касается исполнения.
Корректная конкурентная программа на Go работает и на одном ядре: с GOMAXPROCS=1 горутины чередуются, но никогда не выполняются одновременно.
Для параллелизма нужны несколько ядер и GOMAXPROCS больше 1. По умолчанию это число доступных CPU.
Конкурентность выгодна и без параллелизма, когда задачи в основном ждут: тысяча одновременных HTTP-запросов на одном ядре закончится гораздо раньше, чем те же запросы по очереди. CPU-bound работа ускоряется только за счёт параллелизма.
Когда main возвращается, процесс завершается, и рантайм обрывает все остальные горутины на полпути, не выполняя их отложенные вызовы; чтобы их дождаться, используйте sync.WaitGroup (или канал).
go f(x) возвращается сразу и ничего не обещает о том, когда запустится f; без ожидания main может выйти ещё до её старта.
Стандартным ожиданием служит sync.WaitGroup: wg.Add(1) до go, defer wg.Done() внутри, wg.Wait() перед выходом. Начиная с Go 1.25 wg.Go(f) заменяет тройку Add/go/Done одним вызовом.
Если нужны ещё и результаты или ошибки, ждите на канале или используйте errgroup.
time.Sleep в main не является ожиданием: он лишь прячет гонку до тех пор, пока машина не окажется медленнее.
Верните результат через то, чего ждёт вызывающий код: канал, заранее выделенный слайс вместе с sync.WaitGroup или errgroup.Group, когда важны и ошибки.
Канал: горутина отправляет, вызывающий код принимает; приём одновременно и ожидание, и передача. Для единственного результата берите make(chan T, 1), чтобы отправитель смог завершиться, даже если читать уже некому.
**Слайс + WaitGroup**: каждая горутина пишет только в свой элемент results[i], поэтому блокировка не нужна и порядок сохраняется. Читайте слайс только после wg.Wait().
**errgroup**: тот же приём со слайсом плюс первая ошибка из g.Wait().
Запись в общую переменную без ожидания или блокировки создаёт гонку данных (data race), даже если в тестах всё как будто работает.
Небуферизованный канал (make(chan T)) работает как передача из рук в руки: отправка блокируется, пока другая горутина не примет значение, а приём блокируется, пока кто-то не отправит. Буферизованный (make(chan T, n)) блокирует отправку, только когда все n слотов заняты, а приём блокирует, только когда буфер пуст.
Завершившаяся отправка в небуферизованный канал заодно сообщает отправителю, что получатель уже взял значение; это точка синхронизации.
Буфер развязывает отправителя и получателя на величину до n элементов. Он сглаживает всплески, но медленного потребителя быстрее не делает.
len(ch) показывает, сколько элементов лежит в буфере прямо сейчас, cap(ch) возвращает размер буфера. len устаревает в момент чтения, поэтому никогда не решайте по нему, заблокируется ли отправка.
Если в итоге заблокированы все горутины, рантайм останавливает программу с fatal error: all goroutines are asleep - deadlock!.
Приём из закрытого канала отдаёт оставшиеся в буфере значения, а потом бесконечно возвращает нулевое значение; отправка и повторный close паникуют. На nil-канале отправка и приём блокируются навсегда, а close паникует.
v, ok := <-ch отличает настоящий ноль от «закрыт»: ok становится false, только когда канал закрыт и опустошён. for v := range ch делает эту проверку за вас и останавливается.
close ничего не выбрасывает: значения из буфера всё равно доставляются, прежде чем получатели начнут получать нули.
Закрытый канал всегда готов, поэтому голый v := <-ch в бесконечном for после закрытия крутит ядро CPU вхолостую.
nil-канал никогда не готов, и это полезно: присвоив переменной канала nil, вы выключаете его case внутри select.
Сообщения паник: send on closed channel, close of closed channel и close of nil channel.
select ждёт, пока одна из его операций с каналами сможет пройти, и выполняет этот кейс, а если готовы несколько, выбирает случайно; default делает его неблокирующим, а таймаут задаётся ещё одним кейсом case <-time.After(d) или case <-ctx.Done().
Если ни один кейс не готов и default нет, горутина блокируется. С default select не блокируется никогда; так пишут неблокирующую отправку или приём.
Для таймаута добавьте case <-time.After(d):, а лучше case <-ctx.Done():, чтобы дедлайном управлял вызывающий код.
Все выражения каналов и все отправляемые значения вычисляются один раз при входе, даже для кейсов, которые не будут выбраны.
select {} без кейсов блокируется навсегда.
Если какой-то кейс обязан выигрывать (например, остановиться прежде, чем брать новые задачи), проверьте его сначала в отдельном select с default. Обычно чище просто закрыть канал задач.
Она напечатает 1, 2, 3 и упадёт с fatal error: all goroutines are asleep - deadlock!: range ждёт четвёртое значение, потому что ch никто не закрыл, а отправить его некому. Детектор срабатывает, только когда заблокированы все горутины, поэтому живой сервер его не вызовет.
Исправляет это close(ch) после цикла отправки: тогда range прочитает буфер до конца, закончится, и done напечатается.
В сервере всегда жив слушатель, тикер или обработчик сигналов, поэтому настоящий дедлок выглядит как зависшие запросы и растущее число горутин, а не как падение.
Чтобы найти его в продакшне, снимите дамп горутин: /debug/pprof/goroutine?debug=2 печатает каждый стек с состоянием ожидания и его длительностью, например [chan receive, 12 minutes]. kill -QUIT печатает то же самое, но заодно убивает процесс.
Типичные дедлоки на каналах: небуферизованная отправка без получателя, range по каналу, который никто не закрывает, две горутины, каждая из которых ждёт, чтобы отправить другой.
Закрывает отправитель (та горутина, которая знает, что значений больше не будет) и никогда не получатель. Направленные типы позволяют компилятору это проверить: в <-chan T нельзя отправить и его нельзя закрыть, а из chan<- T нельзя читать.
Закрывать нужно, только когда получатели должны узнать о конце потока (range, ok == false). Канал, который никто не закрыл, просто соберёт сборщик мусора.
Если отправителей несколько, не закрывает никто из них: это делает отдельная горутина после wg.Wait(), иначе какой-нибудь отправитель запаникует на закрытом канале.
Обычный chan T неявно приводится к любому направлению при передаче, поэтому сужение ничего не стоит в рантайме.
Закрытие служит ещё и широковещательным сигналом: все горутины, которые ждут на канале done, просыпаются разом. Это стандартный способ остановить много горутин, и ctx.Done() является именно таким каналом.
Типичная сигнатура стадии: принять in <-chan T, создать out внутри, сделать defer close(out) в отправляющей горутине и вернуть его как <-chan T.
Число меньше 1000, которое меняется от запуска к запуску: count++ состоит из чтения, сложения и записи, поэтому две горутины могут прочитать одно и то же значение, и один инкремент потеряется.
Гонка данных: обе горутины читают n и обе записывают n+1. go run -race выдаёт на строке count++ WARNING: DATA RACE.
Исправления: atomic.Int64 для одного числа, sync.Mutex, когда вместе меняются несколько полей, или одна горутина, которая суммирует значения, полученные из канала.
Гонку находит детектор гонок, а по выводу её не увидеть: запуск может напечатать 1000 случайно.
wg.Add(1) вызывается внутри новой горутины, поэтому wg.Wait() может увидеть нулевой счётчик ещё до старта горутин и сразу вернуться; fetchAll отдаёт срез, который воркеры ещё заполняют.
go func() лишь планирует горутину. Цикл обычно успевает закончиться и дойти до Wait раньше, чем выполнится первый Add, поэтому большая часть элементов обычно приходит пустой.
Это ещё и гонка данных (data race): вызывающий код читает bodies, пока воркеры в него пишут, и go run -race это показывает.
Исправление: вызывать wg.Add(1) в родительской горутине до go, тогда к моменту Wait счётчик уже верный. Начиная с Go 1.25 wg.Go(func() { ... }) сам делает Add и Done и убирает эту ловушку.
Захват i и u в замыкании здесь ни при чём: с Go 1.22 у каждой итерации свои переменные цикла.
go vet в Go 1.25 ловит это проверкой waitgroup: «WaitGroup.Add called from inside new goroutine».
Канал представляет собой указатель на рантайм-структуру hchan: блокировка, необязательный кольцевой буфер и две очереди припаркованных горутин, sendq и recvq. Заблокированный отправитель паркуется в sendq, а его поток ОС переходит к другим горутинам.
Получатель уже ждёт в recvq: значение копируется прямо на его стек, а сам он становится готовым к запуску; буфер не трогается. Именно так и работает небуферизованная передача.
В буфере есть место: значение копируется в buf[sendx], sendx сдвигается по кольцу, qcount растёт.
Ни то ни другое: отправитель заворачивает себя в sudog (горутина плюс указатель на значение), встаёт в sendq и вызывает gopark. Пока он ждёт, CPU он не тратит.
Приём устроен зеркально. Если на полном буфере ждут отправители, он забирает самое старое значение и переносит значение первого ждущего отправителя в освободившийся слот, так что порядок FIFO сохраняется.
close ставит флаг closed и будит все припаркованные горутины: получатели получают нулевые значения, отправители паникуют.
Каждая операция берёт блокировку канала, поэтому канал дороже голого sync.Mutex, а один горячий канал, общий для многих ядер, становится точкой конкуренции.
make возвращает указатель, поэтому передача канала в функцию копирует ссылку, а не очередь.
Канал нужен, когда владение данными переходит от горутины к горутине или когда надо ждать сразу несколько событий; мьютекс нужен, когда горутины разделяют состояние с коротким критическим участком. Поговорка написана про передачу данных, а не про защиту поля.
Канал подходит: задача воркеру, результат обратно, сигнал завершения или отмены, select по нескольким источникам с таймаутом. У мьютекса аналога select нет.
Мьютекс подходит: кэш, счётчик, map сессий. «Горутина-владелец», которая обслуживает тот же кэш через каналы, платит за каждый Get аллокацией канала для ответа и двумя передачами через планировщик и сериализует читателей, которых sync.RWMutex пустил бы одновременно; в бенчмарках такая версия заметно медленнее.
Скрытые издержки каналов: каждая операция берёт блокировку и может припарковать горутину, а правила владения (кто закрывает, кто читает до конца) добавляют риски утечек и паник, которых у мьютекса нет.
Одно число ещё дешевле защитить через sync/atomic.
Стандартная библиотека сама полна мьютексов, а внутри каждого канала тоже есть блокировка.
Утечкой горутины называют горутину, которая никогда не вернётся, потому что ждёт того, что никогда не случится; она держит живыми свой стек и всё, на что ссылается, поэтому память и число горутин растут вместе с трафиком.
Заблокированная отправка: воркер отправляет результат в небуферизованный канал, когда вызывающий код уже ушёл по таймауту. Для одного результата берите make(chan T, 1); для потока значений каждую отправку делайте через select с ctx.Done().
Заблокированный приём: range по каналу, который никто не закрывает, или ожидание на канале, в который никто никогда не отправит.
Нет способа остановиться: цикл на тикере без кейса ctx.Done() или сетевой вызов без контекста и таймаута.
В продакшне: выведите runtime.NumGoroutine() на график; профиль горутин (/debug/pprof/goroutine?debug=1) группирует одинаковые стеки, и тысячи горутин, припаркованных на одной строке с chan send, сразу бросаются в глаза. В Go 1.26 появился экспериментальный профиль goroutineleak (GOEXPERIMENT=goroutineleakprofile), который показывает горутины, заблокированные на каналах или блокировках, до которых не может дотянуться ни один работающий код.
В тестах: defer goleak.VerifyNone(t) из go.uber.org/goleak роняет тест, который оставил после себя горутины. -race утечек не видит.
time.After(d) возвращает канал, который сработает один раз; NewTimer делает то же, но его можно остановить и перезапустить; NewTicker срабатывает многократно; AfterFunc вместо этого запускает функцию в отдельной горутине. С Go 1.23 забытые таймеры больше не утекают.
До Go 1.23 таймер жил, пока не сработает, поэтому case <-time.After(5 * time.Minute) в нагруженном цикле копил таймеры за пять минут, а тикер из time.Tick не освобождался никогда.
С Go 1.23 таймеры и тикеры, на которые никто не ссылается, собирает сборщик мусора, даже если их не остановили, а каналы таймеров небуферизованные, так что после возврата из Stop или Reset устаревший тик прийти не может. Старая идиома «прочитать канал до конца перед Reset» больше не нужна.
Поведение определяет строка go в go.mod главного модуля, а не версия тулчейна: модуль, где всё ещё go 1.22, сохраняет старую семантику. Вернуть её можно и через GODEBUG=asynctimerchan=1.
В горячем цикле один NewTimer с Reset на каждом витке по-прежнему лучше time.After: одна аллокация вместо одной на сообщение.
defer t.Stop() для тикеров всё равно пишите; Stop у AfterFunc возвращает false, если функция уже запустилась.
Запустите N горутин, которые делают range по одному каналу задач и отправляют в один канал результатов; продюсер закрывает канал задач, когда закончил, а отдельная горутина закрывает канал результатов после wg.Wait(), и это завершает range по результатам у вызывающего кода.
N подбирают по узкому месту, а не по объёму входа: размер пула соединений базы для вставок, runtime.GOMAXPROCS(0) для вычислений. Именно горутина на каждую строку приводит к ответу Postgres sorry, too many clients already.
Небуферизованный канал задач бесплатно даёт обратное давление (backpressure): продюсер блокируется, пока все воркеры заняты, и вход никогда не оказывается в памяти целиком.
close(results) нельзя поставить после range по результатам (эта строка недостижима), и ни один воркер не вправе закрыть канал, пока в него пишут остальные.
Для отмены каждая блокирующая отправка дополнительно ждёт ctx.Done() в select, чтобы отменённый вызов не оставлял воркеров висеть.
Если точно известно, сколько придёт результатов, примите ровно столько и не закрывайте канал результатов вовсе.
Используйте буферизованный канал как счётный семафор (sem := make(chan struct{}, 8): отправка перед работой, приём после неё) или errgroup.Group с SetLimit(8). В отличие от пула, каждый элемент получает свою короткоживущую горутину; лимит лишь ограничивает их число.
Захватывайте слот в цикле, до go: тогда ждёт сам цикл, и горутин не больше восьми. Если захватывать внутри горутины, она всё равно создаётся на каждый элемент, и они просто выстраиваются в очередь на семафоре.
Освобождайте через defer, чтобы ранний return или паника не потеряли слот навсегда.
Голую отправку нельзя отменить. Оберните её в select с ctx.Done() или возьмите golang.org/x/sync/semaphore: его Acquire(ctx, n) принимает контекст и поддерживает слоты с весом.
Семафор подходит, когда работа уже приходит циклом по независимым элементам. Пул воркеров подходит, когда воркеры держат дорогое состояние (соединение, буфер) или нужен фиксированный набор долгоживущих горутин.
Запустите по горутине на каждый вход, которая копирует его значения в общий выход, и ещё одну, которая закрывает выход, когда WaitGroup сообщит, что все копировщики закончили. Если какой-то вход не закроется, не закроется и выход, поэтому слияние должно останавливаться и по ctx.Done().
Каждый копировщик ждёт ctx.Done() в select и на приёме, и на отправке; иначе застрявший вход или потребитель, который перестал читать, оставят копировщиков заблокированными навсегда.
out закрывает только закрывающая горутина: копировщиков несколько, и им закрывать нельзя.
Порядок между входами не сохраняется; порядок внутри одного входа сохраняется.
Для ровно двух входов подойдёт и один цикл с select: когда вход закрылся, присвойте его переменной nil; nil-канал никогда не готов, и его кейс выпадает. Без этого закрытый канал готов на каждом проходе и засыпает out нулями.
Дайте каждой горутине свой небуферизованный канал как жетон хода: она ждёт свой жетон, печатает число и передаёт жетон другой. Ход в каждый момент только у одной горутины, поэтому вывод строго 1, 2, 3 и так далее.
Небуферизованный канал здесь и есть передача из рук в руки: отправка завершается, только когда другая горутина готова принять ход.
Та, что печатает последнее число, не должна передавать ход: принять его уже некому, и такая отправка заблокируется навсегда.
main начинает игру, выдавая первый жетон, и ждёт через WaitGroup; без ожидания он выйдет раньше, чем что-то напечатается.
То же самое можно сделать на sync.Mutex, sync.Cond и общей переменной turn, но вариант с каналами короче и обходится без общего состояния.
Используйте errgroup.WithContext из golang.org/x/sync/errgroup: каждая задача, запущенная через g.Go, работает с общим контекстом, первая ошибка, не равная nil его отменяет, а g.Wait() дожидается всех задач и возвращает эту первую ошибку.
Отмена кооперативная: контекст отменяется, но задача, которая ctx игнорирует, доработает до конца, и Wait её дождётся.
Wait возвращает только первую ошибку. Если нужны все, собирайте их сами под мьютексом и возвращайте errors.Join(errs...).
g.SetLimit(n) ограничивает параллельность: g.Go блокируется, пока работают n задач. Вызывайте его один раз до первого Go; изменение лимита при активных задачах паникует. g.TryGo служит неблокирующим вариантом.
Результаты пишите в заранее выделенный слайс по индексу; разные элементы блокировки не требуют.
Производный ctx отменяется, как только Wait вернулся, поэтому не используйте его для работы после группы.
Паника в задаче группой не перехватывается: она роняет весь процесс, как в любой горутине. Если нужна ошибка, делайте recover внутри задачи.
Используйте token bucket: токены пополняются с фиксированной скоростью до размера всплеска (burst), каждый запрос забирает один, а пустое ведро означает «ждать» или «отказать». В реальном коде это rate.NewLimiter из golang.org/x/time/rate, который уже потокобезопасен.
Состояние (число токенов и время последнего пополнения) живёт в одной структуре под sync.Mutex. Пополняйте лениво при каждом вызове по прошедшему времени, а не горутиной на каждый лимитер.
Allow() отвечает сразу; для HTTP API это 429 Too Many Requests, желательно с Retry-After. Wait(ctx) блокируется, пока не освободится токен, и возвращает ошибку, если контекст закончился раньше.
Тикер, наполняющий буферизованный канал, годится как быстрый ответ на лайвкодинге, но он тратит горутину на каждый лимитер и без дополнительного кода не умеет «отказать сразу».
Храните map записей со временем истечения под sync.RWMutex, при чтении считайте просроченные записи отсутствующими, а удаляйте их одной горутиной-уборщиком на кэш, которая проходит по map на тикере и останавливается по Close.
time.AfterFunc или горутина на каждый ключ стоят таймер и замыкание на запись, и память растёт вместе с числом записей, а не живых ключей.
Проверка срока при чтении сохраняет корректность, даже когда уборщик опаздывает; уборщик только освобождает память.
Уборщику нужен путь остановки (Close или ctx.Done()), иначе каждый созданный в тестах кэш оставляет утёкшую горутину.
Get может оставаться на RLock, потому что ничего не удаляет; для удаления нужна блокировка на запись, поэтому его оставляют уборщику.
Cache stampede: при промахе используйте singleflight.Group.Do(key, fn), чтобы загружала одна горутина; ловушку с контекстом см. в вопросе про Redis-кэш.
Каждая стадия принимает входной канал, запускает горутину, которая его читает, и возвращает выходной канал, который закроет по окончании. Нормальное завершение идёт вниз по течению через close; досрочное должно идти вверх, через общий ctx.Done(), который проверяет каждая отправка в select.
Вниз по течению: close
Когда у источника кончаются данные, он закрывает свой выход; range следующей стадии завершается, срабатывает её defer close(out), и цепочка разматывается стадия за стадией.
Каждая стадия закрывает только тот канал, которым владеет.
Вверх по течению: отмена
Канал не несёт сигнал обратно отправителю. Если потребитель делает break после десяти значений, последняя стадия навсегда паркуется на отправке, предыдущая тоже: по утёкшей горутине на стадию.
Поэтому каждая отправка пишется как select { case out <- v: case <-ctx.Done(): return }, а потребитель вызывает cancel() (через defer), когда останавливается.
Ошибки идут вместе с данными (struct{ v T; err error }) или через errgroup, который заодно отменяет остальные стадии.
Большие входы
Файл на 1 ТБ обрабатывают потоком: читают кусками через bufio.Reader, передают куски между стадиями и держат каналы маленькими. Тогда память ограничена числом стадий × буфер × размер куска, а не размером файла.
Медленную стадию ускоряют, запуская в ней несколько горутин и сливая их выходы; если важен порядок, сначала помечайте элементы их позицией.
Тест, который читает всё до конца, ничего не говорит об утечках: проверьте ранний break с goleak.VerifyNone. -race заблокированные горутины не находит.
Backpressure (обратное давление) означает, что медленный потребитель притормаживает продюсера: блокирующая отправка в полный канал заставляет продюсера ждать, и работа не копится быстрее, чем обрабатывается. Буфер лишь поглощает короткие всплески и пропускную способность не меняет.
Что даёт буфер
Он развязывает две стадии, скорости которых колеблются вокруг одного среднего, чтобы ни одна не вставала на каждой заминке. Размер подбирают под ожидаемый всплеск или пачку, а не «побольше на всякий случай».
Если потребитель стабильно медленнее, любой буфер заполнится, и вы снова упрётесь в блокировку, только позже, с большим расходом памяти и большей задержкой на элемент.
Огромный буфер прячет проблему: задержка тихо растёт, а рестарт теряет всё, что лежало в очереди в памяти.
Когда потребитель стабильно медленнее
Пусть блокируется и передаёт давление выше: HTTP-клиенту (таймауты, 503), в лаг консьюмера очереди, в собственный темп продюсера.
Добавьте потребителей в медленную стадию, если узкое место не один общий ресурс.
Пачки или схлопывание: вставлять по 500 строк за запрос вместо одной или хранить только последнее значение по ключу.
Сбрасывайте нагрузку, которую не жалко потерять (метрики, отладочные логи), неблокирующей отправкой: select { case ch <- v: default: dropped.Add(1) }. Счётчик потерь выводите в метрики, иначе данные молча врут.
Выгружайте в надёжное хранилище (очередь сообщений, диск), когда важен каждый элемент, а всплески долгие.
Никогда не ставьте блокирующую отправку в необязательный приёмник вроде метрик на пути запроса: когда коллектор встанет, вместе с ним встанут все обработчики.
Зафиксируйте место каждого элемента до того, как работа разойдётся по воркерам: единственный диспетчер даёт каждому элементу свой канал результата на один слот и кладёт эти каналы в FIFO-очередь в порядке входа; потребитель читает очередь и по очереди ждёт каждый результат.
Вход обходит один диспетчер, поэтому порядок очереди совпадает с порядком входа. На каждый элемент он делает res := make(chan Result, 1), отправляет res в chan chan Result и отдаёт воркеру элемент вместе с res.
Потребитель устроен как for res := range queue { out <- <-res }. Он ждёт элемент k, пока следующие уже посчитаны и лежат в своих буферах, так что параллельность не страдает.
Буфер на один слот держит воркер свободным: без него воркер ждал бы отстающего потребителя.
Ёмкость очереди задаёт предел по памяти: когда она полна, диспетчер блокируется, и в памяти порядка cap(queue) результатов (плюс те, что в обработке), а не весь файл.
Альтернатива: пометить элементы порядковым номером и держать буфер переупорядочивания (map или min-heap), который выдаёт следующий ожидаемый номер; ему тоже нужно ограничение окна, иначе один медленный элемент даёт буферу расти без предела.