2.7Конкурентные паттерны
Соберём worker pool и pipeline с ограниченной нагрузкой, отменой и понятным владельцем закрытия каналов.
Из чего собираются паттерны
- У канала один владелец. Это тот, кто создал канал и пишет в него. Только он его закрывает, и ровно один раз. Читатели не закрывают канал никогда.
- Функция возвращает канал только на чтение (
<-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 с весом больше ёмкости только ждал бы отмены ctx
}
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: тикер и токен-бакет
Два способа. На тикере: жёсткий равномерный интервал, всплески невозможны в принципе. На токенах: средняя частота держится, но накопленные токены разрешают короткий всплеск.
Токен-бакет («ведро с токенами») устроен ровно так, как называется: есть ведро ёмкостью 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удерживался рантаймом вечно, даже став недостижимым. Теперь его соберёт GC, но пока на него есть ссылка, он тикает, так чтоdefer t.Stop()всё равно пишут. 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. До Go 1.23 это была утечка: у него нетStop(), и тикер жил до конца процесса. С 1.23 недостижимый тикер собирает GC, и документация прямо пишет, что причин предпочитатьNewTickerбольше нет. Осталось одно отличие:Tickнельзя ни остановить, ни перенастроить.- Долгая итерация и «догоняющие» тики.
Tickerдержит не больше одного непрочитанного тика, а пропущенные отбрасывает, а не копит. Это спасает от лавины, но при итерации длиннее периода фактическая частота падает. Нужно «ровно каждые 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 с весом больше ёмкости не выполнится никогда: он проспит до
отмены ctx и вернёт ctx.Err(), а с контекстом без отмены
повиснет навсегда. Поэтому вес проверяют до вызова. И ещё: семафор и 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, если тикер надо останавливать или перенастраивать: уTickнет ниStop(), ниReset(). Утечкой он был только до Go 1.23, теперь недостижимый тикер собирает GC.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) метрикой: заполненность
очереди даёт самый ранний сигнал приближающейся деградации.
Что стоит запомнить
- Worker pool ограничивает количество параллельных обработчиков.
- Закрывать output можно после завершения всех sender.
- Semaphore ограничивает concurrency, limiter - частоту.
Частые ошибки
- Consumer cancellation должна доходить до заблокированного producer.
- Не оставляй бесконечное число созданных goroutine ожидать лимит.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи выполнение
Worker pool обрабатывает все входящие числа, а закрытие jobs завершает workers. Почему нельзя закрывать results сразу после close(jobs)?
jobs := make(chan int); results := make(chan int)
var wg sync.WaitGroup
for i:=0;i<2;i++ { wg.Add(1); go func(){ defer wg.Done(); for n := range jobs { results <- n*n } }() }
go func(){ wg.Wait(); close(results) }()
go func(){ for _, n := range []int{2,3} { jobs <- n }; close(jobs) }()
for r := range results { fmt.Println(r) }
Ответ и разбор
Если закрыть results вместе с jobs, workers могут ещё отправлять результаты и получат panic. В примере отдельный closer ждёт WaitGroup, поэтому results закрывается только после окончания всех отправителей; порядок 4 и 9 не гарантирован.
Код-ревью
2. Найди проблему и исправь
Число goroutine не ограничено входом, а канал results может заблокировать отправителей после остановки потребителя. Исправь обе проблемы.
for _, job := range jobs { go func() { results <- work(job) }() }
Ответ и разбор
Используй фиксированное число workers, ограниченную очередь и отмену при чтении/записи. Например:
jobsCh := make(chan Job, 32)
results := make(chan Result, 32)
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobsCh {
result := work(job)
select { case results <- result: case <-ctx.Done(): return }
}
}()
}
go func() { wg.Wait(); close(results) }()
producer:
for _, job := range input {
select { case jobsCh <- job: case <-ctx.Done(): break producer }
}
close(jobsCh)Consumer должен отменить ctx при прекращении чтения. Отменённый producer выходит из цикла по метке, затем закрывает jobsCh ровно один раз. Отдельный closer закрывает results после выхода всех отправителей. Координатор также должен дождаться producer перед возвратом.
Применение
3. Разбери рабочий сценарий
Сервис отправляет до 50 запросов одновременно, но средняя частота должна быть не более 10 запросов в секунду. Почему одного semaphore недостаточно и что выбрать?
Ответ и разбор
Semaphore ограничивает число одновременно выполняемых запросов, но быстрые вызовы могут дать частоту значительно выше 10/с. Добавь rate limiter с заданным темпом, например token bucket, и оставь отдельный semaphore для лимита in-flight. Уточни допустимые burst и поведение при отмене/перегрузке.
Применение
4. Разбери граничный сценарий
Pipeline имеет три стадии и вторая стадия завершилась ошибкой. Как не оставить первую стадию заблокированной при отправке?
Ответ и разбор
Общий context передай во все стадии. При ошибке отмени его; send делай через select между выходным каналом и ctx.Done(). Каждый владелец закрывает свой output после последней отправки, а coordinator ждёт все стадии.