voidlearn
 

2.7Конкурентные паттерны

Соберём worker pool и pipeline с ограниченной нагрузкой, отменой и понятным владельцем закрытия каналов.

Из чего собираются паттерны

Четыре правила, из которых собирается всё остальное
  1. У канала один владелец. Это тот, кто создал канал и пишет в него. Только он его закрывает, и ровно один раз. Читатели не закрывают канал никогда.
  2. Функция возвращает канал только на чтение (<-chan T), а принимает только на запись (chan<- T). Тогда компилятор сам не даст нарушить пункт 1.
  3. Каждая блокирующая операция идёт через select с ctx.Done(): и приём, и отправка. Незащищённая отправка течёт чаще всего.
  4. Тот, кто запустил горутину, обязан её дождаться. Иначе корректный graceful shutdown не написать: непонятно, когда работа закончилась.

Worker pool

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

Worker pool: два канала, N воркеров, две разные точки закрытия producer 1 горутина jobs chan Job FIFO, буфер 0..N worker 1 worker 2 worker N results chan буфер = len(jobs) collector range по results fan-out fan-in close(jobs) - только продюсер, ровно один раз, после последней задачи или при отмене close(results) - отдельная горутина go func(){ wg.Wait(); close(results) }()
Две точки закрытия, не одна. Канал задач закрывает продюсер: он один знает, что задачи кончились. Канал результатов закроет только тот, кто дождался всех воркеров, поэтому wg.Wait() уезжает в отдельную горутину. Иначе main встанет на Wait раньше, чем начнёт читать результаты, и получится дедлок.
type Job struct {
    ID      int
    Payload string
}

type Result struct {
    JobID int
    Out   string
    Err   error
}

// Run обрабатывает jobs не более чем workers горутинами.
// Возвращает все результаты (включая ошибочные) и причину досрочного выхода.
func Run(ctx context.Context, workers int, jobs []Job) ([]Result, error) {
    jobCh := make(chan Job)
    // Буфер на все результаты: воркер никогда не заблокируется на отправке,
    // даже если коллектор отстаёт. Это снимает целый класс утечек.
    resCh := make(chan Result, len(jobs))

    var wg sync.WaitGroup
    for w := 0; w < workers; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := range jobCh {          // выход ровно при close(jobCh)
                out, err := handle(ctx, j)
                select {
                case resCh <- Result{JobID: j.ID, Out: out, Err: err}:
                case <-ctx.Done():          // страховка, если буфера всё же не хватило
                    return
                }
            }
        }(w)
    }

    // Продюсер единолично владеет jobCh и сам его закрывает.
    go func() {
        defer close(jobCh)                  // defer: закроется и на раннем return
        for _, j := range jobs {
            select {
            case jobCh <- j:
            case <-ctx.Done():
                return                      // раздачу прекратили, начатое доработается
            }
        }
    }()

    // Закрыть resCh вправе только тот, кто дождался всех писателей.
    go func() {
        wg.Wait()
        close(resCh)
    }()

    out := make([]Result, 0, len(jobs))
    for r := range resCh {                  // выход по close(resCh)
        out = append(out, r)
    }
    return out, ctx.Err()                   // nil, если отмены не было
}

Разбор по строкам

  • for j := range jobCh вместо for { select { case j := <-jobCh } }. Короче и корректнее: range сам завершается при close, и не надо руками разбирать ok. Отдельная ветка на ctx.Done() в цикле воркера не нужна: отмену ловит продюсер, он просто перестаёт раздавать задачи, а «прервать посередине текущей задачи» это дело handle(ctx, j).
  • wg.Add(1) до go, а не внутри. Внутри горутины Add может выполниться уже после Wait: гонка, при которой Wait вернётся раньше времени. В Go 1.25 весь этот блок ужимается до wg.Go(func(){ ... }), который делает Add(1), запуск и Done() одним вызовом.
  • Буфер len(jobs) у resCh. Воркер не застрянет на отправке ни при каких условиях. Если задач может быть миллион, буфер берут небольшой (например, workers*2), и тогда ветка case <-ctx.Done() при отправке уже обязательна, а не страховочная.
  • Отдельная горутина под wg.Wait(); close(resCh). Напишешь wg.Wait() прямо перед range resCh, и воркеры встанут на отправке в заполненный или небуферизованный канал, а main застрянет на Wait. Взаимная блокировка.
  • Ошибки как поле Result, а не отдельный канал. Отдельный errCh тянет за собой второго читателя и вторую точку закрытия; результат с полем Err не тянет ничего.
Типичные ошибки в этом коде на собесе
  • Закрыть jobCh из воркера. Второй close даёт панику close of closed channel, и её нельзя «проверить заранее».
  • Не закрыть jobCh вообще. Воркеры навсегда останутся в range: классическая утечка ровно на N горутин.
  • Захват переменной цикла. До Go 1.22 for w := 0; ... с go func(){ use(w) }() давал всем горутинам одно значение. С 1.22 переменная своя на итерацию, но на собесе это стоит проговорить: «в вашей версии Go это уже безопасно, в 1.21 нет».
  • Небуферизованный resCh плюс return коллектора по таймауту. Все воркеры повисают на отправке навсегда.
  • panic в handle убивает процесс. В проде воркер оборачивают в defer func(){ if r := recover(); r != nil { ... } }(), иначе одна плохая задача роняет весь сервис.
Что спросит интервьюер следующим
  1. «Как выбрать N?» CPU-bound упирается в runtime.GOMAXPROCS(0) (больше не ускорит, только добавит переключений). IO-bound упирается во внешний ресурс: лимит стороннего API, MaxOpenConns у БД, число партиций. Универсального числа нет, зато есть универсальный ответ: «замерить, а потом сделать настраиваемым».
  2. «Что если задачи приходят потоком, а не слайсом?» Ничего не меняется: продюсер читает из своего источника, всё остальное как есть. Меняется только буфер resCh: по числу задач его уже не выставишь.
  3. «Как добавить ретраи?» Внутри handle, с бэкоффом и джиттером, либо отдельным каналом retryCh, но тогда заводи счётчик попыток в Job и защиту от вечного цикла.
  4. «Как остановиться по первой ошибке?» Это уже errgroup, он ниже отдельным паттерном.
  5. «А если воркеры должны быть постоянными, а не на один вызов?» Пул превращается в долгоживущий компонент со своим Start()/Stop(), и вся сложность переезжает в graceful shutdown.

Долгоживущий пул как компонент

// В реальном сервисе чаще так: пул живёт всё время работы
// приложения, задачи приходят снаружи, останавливается вместе с приложением.
type Pool struct {
    jobs chan Job
    wg   sync.WaitGroup
    once sync.Once
}

func NewPool(ctx context.Context, workers, queue int, h Handler) *Pool {
    p := &Pool{jobs: make(chan Job, queue)}
    for i := 0; i < workers; i++ {
        p.wg.Add(1)
        go func() {
            defer p.wg.Done()
            for j := range p.jobs {
                func() {
                    defer func() {
                        if r := recover(); r != nil {
                            log.Error("паника в воркере", "job", j.ID, "panic", r,
                                "stack", string(debug.Stack()))
                        }
                    }()
                    h(ctx, j)
                }()
            }
        }()
    }
    return p
}

// Submit не блокируется навсегда: либо очередь примет, либо отмена, либо отказ.
func (p *Pool) Submit(ctx context.Context, j Job) error {
    select {
    case p.jobs <- j:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// SubmitOrDrop с backpressure: лучше отказать, чем копить очередь.
func (p *Pool) SubmitOrDrop(j Job) error {
    select {
    case p.jobs <- j:
        return nil
    default:
        return ErrQueueFull        // отдать 429 клиенту, инкрементировать метрику
    }
}

// Stop идемпотентен и дожидается доработки очереди.
func (p *Pool) Stop() {
    p.once.Do(func() { close(p.jobs) })   // once: защита от повторного close
    p.wg.Wait()
}
Почему Submit и Stop опасны в паре

После close(p.jobs) любой Submit получит панику send on closed channel. Проверить «закрыт ли канал» перед отправкой нельзя: между проверкой и отправкой остаётся окно. Рабочих решений два. Либо контракт «после Stop никто не шлёт», и держит его порядок остановки компонентов (см. graceful shutdown). Либо явный RWMutex: Submit берёт RLock, Stop берёт Lock, ставит флаг closed и только потом закрывает. Второй вариант стоит одного RLock на отправку, зато компонент переживёт любой порядок вызовов.

Pipeline: стадии через каналы

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

Pipeline: каждая стадия владеет своим выходным каналом generator источник данных chan int stage: square 1 горутина chan int stage: filter 1 горутина chan int sink range по выходу ctx.Done() - один закрытый канал, слышат все стадии каждая стадия: select на приёме И на отправке Нормальный конец: generator закрывает свой канал → range в square завершается → square закрывает свой → и так до sink. Каскад слева направо. Досрочный конец: отменяется ctx → все стадии выходят из своих select → каждая закрывает свой выход в defer → sink видит закрытый канал. Порядок здесь не важен.
Два способа завершить конвейер. Естественный: закрыть входной канал, дальше закрытие едет вправо само. Досрочный: общий сигнал отмены, который слышат все стадии сразу. Второй нужен именно потому, что при первом стадия, застрявшая на отправке в канал, который никто не читает, до своего close не дойдёт никогда.
// Стадия 0: генератор. Принимает данные, отдаёт канал.
func gen(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)                 // владелец всегда закрывает свой канал
        for _, n := range nums {
            select {
            case out <- n:               // отправка тоже через select
            case <-ctx.Done():
                return
            }
        }
    }()
    return out                           // <-chan: наружу только чтение
}

// Стадия 1: преобразование.
func square(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {              // завершится, когда in закроют
            select {
            case out <- n * n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Стадия 2: фильтр. Сигнатура та же, стадии можно тасовать как кубики.
func odd(ctx context.Context, in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            if n%2 == 0 {
                continue
            }
            select {
            case out <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()                       // остановит весь конвейер при любом выходе

    for v := range odd(ctx, square(ctx, gen(ctx, 1, 2, 3, 4, 5))) {
        fmt.Println(v)
        if v > 8 {
            cancel()                     // досрочная остановка, всё свернётся само
            break
        }
    }
}

// вывод (проверено запуском), строк две, а не три:
// 1
// 9
// Конвейер выдал бы 1, 9, 25 (нечётные квадраты), но на 9 срабатывает v > 8:
// потребитель зовёт cancel() и выходит из range, а 25 до него уже не доезжает.

Разбор

  • Единая сигнатура func(ctx, <-chan T) <-chan T и есть «конвейер»: стадии складываются друг с другом, их можно переставлять и вставлять новые, не трогая соседей.
  • defer close(out) первой строкой горутины. Канал закроется на любом пути выхода, включая ранний return по отмене. Без него отмена посередине оставит следующую стадию в вечном range.
  • select на отправке нужен не для красоты. Если следующая стадия умерла (отмена, паника, ранний break у потребителя), обычная отправка out <- v заблокируется навсегда, и вся цепочка утечёт. Именно эту строку чаще всего забывают.
  • break у потребителя обязан сопровождаться cancel(). Иначе выход из range прекращает чтение, а стадии остаются на отправке. Идиома defer cancel() в вызывающем коде закрывает вопрос сама.
  • Буфер на стадии: тюнинг, а не архитектура. Буфер сглаживает разницу в скорости соседних стадий, но не лечит несбалансированный конвейер: если стадия медленнее входа, буфер лишь отложит момент, когда всё упрётся в неё.
Три подвоха конвейера
  • Ошибки некуда девать. Тип канала один. На практике по конвейеру гоняют не int, а структуру type Item struct { Val T; Err error }, и каждая стадия пропускает элементы с непустым Err дальше без обработки. Можно и отдельный errCh, но его надо кому-то читать и закрывать.
  • Пропускная способность равна самой медленной стадии. Конвейер не ускоряет обработку одного элемента, он совмещает обработку разных. Хочешь ускорить узкую стадию, делай fan-out внутри неё.
  • Стоимость. Каждая стадия стоит горутины и канала; на мелких элементах передача съест больше, чем полезная работа. Шесть стадий поверх арифметики: антипаттерн. Те же шесть поверх сетевых вызовов и парсинга: норма.
Что спросит интервьюер следующим

«Как ускорить одну медленную стадию?» Fan-out: запустить N копий стадии на одном входном канале и слить их выходы через merge. «А порядок сохранится?» Нет, и это главный побочный эффект: после fan-out элементы приходят в произвольном порядке. Если порядок важен, нумеруют элементы и пересобирают на выходе (буфер + сортировка по индексу) либо распараллеливают внутри стадии так, чтобы отдавать результаты по очереди. «Что если стадия падает?» recover внутри горутины стадии, а паника превращается в элемент с ошибкой.

Fan-out / fan-in и своя реализация merge

Fan-out: несколько горутин читают из одного канала, стадия распараллеливается. Fan-in: несколько каналов сливаются в один (merge). Написать merge руками просят особенно охотно: в двадцати строках помещаются и владение каналом, и WaitGroup, и отмена.

merge: на каждый входной канал - своя копирующая горутина in[0] in[1] in[N-1] merge for v := range in[0] out <- v for v := range in[1] out <- v for v := range in[N-1] out <- v out chan T один читатель go func() { wg.Wait(); close(out) }() - единственный, кто вправе закрыть Порядок элементов в out произвольный: кто первым отдал, тот и в очереди.
Почему без WaitGroup не обойтись. Каждая копирующая горутина знает только про свой канал и не может закрыть out: там ещё пишут остальные. Закрыть out вправе только тот, кто дождался всех, и это отдельная горутина: если вызвать wg.Wait() прямо в merge, функция не вернёт канал, пока всё не закончится, и читателя просто не будет.
// merge, каноническая реализация fan-in. Тридцать секунд на доске.
func merge[T any](ctx context.Context, chans ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup

    output := func(c <-chan T) {
        defer wg.Done()
        for v := range c {               // завершится при close(c)
            select {
            case out <- v:
            case <-ctx.Done():           // получатель ушёл, не виснем
                return
            }
        }
    }

    wg.Add(len(chans))                   // Add до запуска горутин
    for _, c := range chans {
        go output(c)
    }

    // Закрывает тот и только тот, кто дождался всех писателей.
    go func() {
        wg.Wait()
        close(out)
    }()

    return out                           // возврат немедленный, до окончания работы
}

// Fan-out поверх той же стадии: N копий читают один вход, их выходы сливаются.
func fanOut[T, R any](ctx context.Context, in <-chan T, n int,
    stage func(context.Context, <-chan T) <-chan R) <-chan R {

    outs := make([]<-chan R, 0, n)
    for i := 0; i < n; i++ {
        outs = append(outs, stage(ctx, in))   // все n читают из одного in
    }
    return merge(ctx, outs...)
}

// Применение: медленная стадия распараллелена на 8, порядок при этом теряется.
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    src := gen(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    for v := range fanOut(ctx, src, 8, slowSquare) {
        fmt.Println(v)                   // порядок произвольный
    }
}

// Три прогона подряд (значения всегда те же десять, порядок каждый раз новый):
//   36 64 1 49 9 4 25 16 100 81
//   64 49 1 4 16 36 25 9 100 81
//   64 49 36 16 9 4 25 1 100 81
// Полагаться на порядок нельзя: если он нужен, нумеруют элементы и пересобирают
// результат на выходе.

Разбор

  • Почему wg.Add(len(chans)) одним вызовом. Иначе первая горутина успеет завершиться и уронить счётчик в ноль раньше, чем добавят остальные: Wait вернётся преждевременно и закроет out под живыми писателями, а это паника send on closed channel.
  • Fan-out и есть «n читателей одного канала». Канал уже потокобезопасен и раздаёт элементы строго по одному: никакой дополнительной синхронизации не нужно. Это самая изящная часть модели каналов, и её стоит проговорить вслух.
  • Балансировка получается сама. Кто освободился, тот и забрал следующий элемент: естественный work stealing без единой строчки кода. Медленный воркер просто получит меньше задач.
  • Дженерики здесь по делу. До 1.18 merge писали на interface{} либо копипастили под каждый тип; сейчас одна функция закрывает всё.
Два способа сломать merge

Первый: закрывать out внутри output. При len(chans) > 1 это гарантированная паника двойного закрытия. Второй: вызвать wg.Wait() синхронно перед return out. Функция заблокируется до конца всей работы, читателя ещё нет (канал не вернули), писатели встанут на отправке, дедлок. Обе ошибки встречаются на собеседованиях чаще, чем правильный вариант.

Что спросит интервьюер следующим
  1. «Сохраняется ли порядок?» Нет. Если нужен, нумеруйте элементы на входе и пересобирайте на выходе через map[int]T плюс указатель на следующий ожидаемый индекс.
  2. «Как сделать merge на двух каналах без WaitGroup?» Через select с обнулением закрытых каналов: case v, ok := <-a: if !ok { a = nil; continue }. Приём из nil-канала блокируется навсегда, поэтому исчерпанная ветка просто выключается; выходим, когда оба стали nil.
  3. «А если каналов заранее неизвестное число и они добавляются на ходу?» reflect.Select (медленно и некрасиво) либо «дерево merge»: сливать попарно, каждый новый канал подмешивая ещё одним merge.
  4. «Чем это отличается от errgroup?» merge сливает данные, errgroup управляет жизненным циклом и ошибками. В реальном коде их обычно используют вместе.

Семафор: ограничить число одновременных запросов

Отличие от worker pool принципиальное, и его любят проверять: пул ограничивает число горутин, а семафор ограничивает число одновременно выполняемых операций при каком угодно числе горутин. Когда запросы приходят снаружи (каждый HTTP-хендлер живёт в своей горутине) и надо всего лишь не бомбить внешний API больше чем в 10 потоков, пул не годится: горутины созданы уже не вами. Годится семафор.

// Семафор на буферизованном канале: ёмкость буфера и есть лимит.
type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore { return make(Semaphore, n) }

// Acquire ждёт слот или отмену, навсегда не блокируется.
func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:              // занял слот
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

// TryAcquire не ждёт: либо сразу, либо отказ (backpressure).
func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:
        return false
    }
}

func (s Semaphore) Release() { <-s }   // освободил слот

// Применение: 10 одновременных вызовов внешнего API на весь процесс.
var apiSem = NewSemaphore(10)

func CallAPI(ctx context.Context, req Request) (*Response, error) {
    if err := apiSem.Acquire(ctx); err != nil {
        return nil, fmt.Errorf("очередь к API переполнена: %w", err)
    }
    defer apiSem.Release()             // только через defer, иначе слот утечёт навсегда
    return doHTTP(ctx, req)
}

Разбор

  • Почему chan struct{}, а не chan bool. struct{} занимает ноль байт; для буфера на 1000 слотов это разница между нулём и килобайтом. Плюс сигнальная семантика читается сразу.
  • Отправка = захват, приём = освобождение. Ровно наоборот интуиции, поэтому направление стоит проговорить: слоты «складываются» в буфер, полный буфер = все слоты заняты.
  • Симметрия Acquire/Release держится на defer. Один return без Release, и лимит навсегда уменьшился на единицу. Через несколько таких утечек сервис встаёт с виду беспричинно.
  • Fairness. Очередь ожидающих на канале работает по FIFO, поэтому голодания нет: кто раньше встал, тот раньше получит слот.
Worker poolСемафор
Что ограничиваетчисло горутин-обработчиковчисло одновременных операций
Кто создаёт горутинысам пулвызывающий код (хендлеры, консьюмер брокера)
Откуда задачииз канала, который вы контролируетеприходят снаружи, потоком
Очередьбуфер канала задачгорутины, ждущие на Acquire
Стоимость ожиданиязадача лежит в буфере (дёшево)ждёт целая горутина (от 2 КБ стека + контекст запроса)
Типичное применениебатч-обработка, консьюмер, воркеры фоновых задачлимит на внешний API, на диск, на тяжёлую CPU-операцию
// Взвешенный вариант из стандартного расширения: разные операции стоят по-разному.
import "golang.org/x/sync/semaphore"

// Ёмкость в «единицах веса», например мегабайтах памяти.
var sem = semaphore.NewWeighted(1024)

func Process(ctx context.Context, f *File) error {
    w := int64(f.SizeMB)
    if w > 1024 {
        return ErrTooLarge             // Acquire с весом больше ёмкости только ждал бы отмены 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, если срез пуст
}
errgroupWaitGroup + errors.Join
Поведение при ошибкеотменяет ctx, остальные сворачиваютсявсе доводят работу до конца
Что возвращаетпервую по времени ошибкувсе ошибки, обёрнутые в одну
Ограничение параллелизмаSetLimit из коробкируками, семафором
Совместимость с errors.Is/Asда, обычная ошибкада, Join поддерживает обход дерева
Когда братьрезультат бесполезен без всех частейчасти независимы, нужна полная картина
Три ошибки с errgroup
  • Не переприсвоить ctx. Напишешь g, _ := errgroup.WithContext(ctx), и отмены не будет: горутины получат исходный контекст, который никто не отменит. Ошибка вернётся, но работа продолжится до конца. Самый частый баг с этим пакетом.
  • Ждать, что Wait вернёт все ошибки. Он возвращает ровно одну, первую. Остальные молча теряются, даже если они интереснее.
  • Забыть, что отмена не равна остановке. Если fetchFeed не проверяет ctx, он доработает до конца, и g.Wait() будет ждать его. errgroup отменяет контекст, а не горутины.
Что спросит интервьюер следующим

«А если нужно и то и другое: отменить остальных, но вернуть все ошибки, которые успели случиться?» Такое пишется руками за десять строк: errgroup для отмены плюс свой срез под мьютексом для накопления, либо WaitGroup плюс собственный context.WithCancel, который вызывается при первой ошибке. И встречный вопрос про панику: errgroup панику не перехватывает, она уронит процесс. Если задачи выполняют чужой код, оборачивайте тело g.Go в recover и превращайте панику в ошибку.

Периодические задачи и корректная остановка

// Канонический периодический воркер.
func (s *Service) runPeriodic(ctx context.Context, every time.Duration) {
    t := time.NewTicker(every)
    defer t.Stop()                       // до Go 1.23 без этого тикер жил вечно

    s.tick(ctx)                          // часто нужен сразу первый прогон,
                                         // Ticker выдаёт первый тик только через every
    for {
        select {
        case <-t.C:
            s.tick(ctx)                  // tick должен уважать ctx и иметь свой таймаут
        case <-ctx.Done():
            return                       // выход по общей отмене
        }
    }
}

func (s *Service) tick(ctx context.Context) {
    // Свой дедлайн на итерацию: иначе одна залипшая итерация останавливает всё.
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    defer cancel()

    defer func() {                       // паника в фоновой задаче не должна ронять сервис
        if r := recover(); r != nil {
            log.Error("паника в периодической задаче", "panic", r,
                "stack", string(debug.Stack()))
        }
    }()

    if err := s.doWork(ctx); err != nil {
        log.Warn("итерация не удалась", "err", err)   // не return из runPeriodic:
    }                                                 // упавшая итерация не должна
}                                                     // убивать расписание
Что здесь легко сделать неправильно
  • time.Tick(d) вместо NewTicker. До Go 1.23 это была утечка: у него нет Stop(), и тикер жил до конца процесса. С 1.23 недостижимый тикер собирает GC, и документация прямо пишет, что причин предпочитать NewTicker больше нет. Осталось одно отличие: Tick нельзя ни остановить, ни перенастроить.
  • Долгая итерация и «догоняющие» тики. Ticker держит не больше одного непрочитанного тика, а пропущенные отбрасывает, а не копит. Это спасает от лавины, но при итерации длиннее периода фактическая частота падает. Нужно «ровно каждые 5 секунд между окончанием и началом», берите time.After/Timer и перезапускайте после работы.
  • Все реплики тикают синхронно. Десять подов с периодом в минуту создают всплеск раз в минуту. Лечится джиттером: случайная задержка старта плюс небольшой разброс периода.
  • return из цикла при ошибке итерации. Одна сетевая ошибка, и периодическая задача молча перестала выполняться до следующего рестарта.
  • Итерация без своего таймаута. Зависший HTTP-вызов внутри tick останавливает расписание навсегда, и в дампе это выглядит как одна невинная горутина.
Что изменилось в Go 1.23

До 1.23 Timer и Ticker, у которых не вызвали Stop, удерживались внутренней структурой рантайма и не собирались GC до срабатывания; отсюда правило «time.After в горячем цикле течёт». С 1.23 (при GODEBUG=asynctimerchan=0, то есть в модулях с go >= 1.23) недостижимый таймер собирается сборщиком, а канал стал небуферизованным, что убрало и вторую тонкость: устаревшее значение, лежащее в буфере после Reset. В Go 1.27 этот GODEBUG удалён: старое поведение вернуть больше нельзя, каналы таймеров всегда небуферизованные (программа с GODEBUG=asynctimerchan=1 на 1.27 просто не стартует). Практический вывод: defer t.Stop() по-прежнему пишут всегда, тикер тикает, пока на него есть ссылка, а вот time.After в select перестал быть грехом.

Graceful shutdown приложения целиком

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

Порядок остановки: снаружи внутрь, по одному шагу 1. SIGTERM / SIGINT - signal.NotifyContext, корневой ctx отменён 2. readiness = false, пауза 5–15 с: балансировщик выводит под из ротации 3. srv.Shutdown(ctx) - новые соединения не принимаются, текущие дорабатывают 4. consumer.Stop() - перестаём читать из брокера, коммитим оффсеты 5. close(jobs); wg.Wait() - воркер-пулы и фоновые задачи доработали 6. flush: батчи логов, метрики, трейсы, аккумуляторы 7. db.Close(), redis.Close(), conn.Close() - ресурсы больше никому не нужны 8. return из main - или hard deadline: os.Exit(1) по общему таймауту Каждый шаг блокирующий и начинается только после предыдущего. Общий бюджет должен быть меньше terminationGracePeriodSeconds (в Kubernetes по умолчанию 30 с) - иначе прилетит SIGKILL.
Почему шаг 2 существует. В Kubernetes SIGTERM и удаление эндпоинта из сервиса происходят параллельно, а не последовательно. Если закрыть слушающий сокет сразу, часть трафика ещё летит на этот под и получит connection refused. Пауза с readiness=false остаётся единственным способом дождаться, пока правила балансировки разъедутся по узлам.
func main() {
    // 1. Корневой контекст, отменяемый по сигналу. Второй SIGINT вернёт
    //    поведение по умолчанию, то есть немедленное убийство процесса.
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // Инициализация: обратный порядок закрытия задаётся здесь.
    db := mustOpenDB(ctx)
    cache := mustOpenRedis(ctx)
    pool := NewPool(ctx, 16, 1024, handleJob)
    consumer := NewKafkaConsumer(cfg, pool)
    srv := &http.Server{Addr: ":8080", Handler: newRouter(db, cache, pool)}

    var ready atomic.Bool
    ready.Store(true)
    http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        if !ready.Load() {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Error("сервер упал", "err", err)
            stop()                                  // роняем всё приложение штатно
        }
    }()
    go consumer.Run(ctx)

    <-ctx.Done()                                    // ждём сигнала
    stop()                                          // вернуть обработчик по умолчанию:
    log.Info("получен сигнал, останавливаемся")     // второй Ctrl+C убьёт сразу

    // 2. Общий бюджет на всю остановку. Он должен быть меньше
    //    terminationGracePeriodSeconds, иначе процесс добьют SIGKILL.
    shutCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()

    // 3. Снимаем себя с балансировки и ждём, пока правила разъедутся.
    ready.Store(false)
    select {
    case <-time.After(7 * time.Second):
    case <-shutCtx.Done():
    }

    // 4. HTTP: новые соединения не принимаем, текущие дорабатывают.
    if err := srv.Shutdown(shutCtx); err != nil {
        log.Warn("не все запросы успели, рвём принудительно", "err", err)
        _ = srv.Close()
    }

    // 5. Консьюмер: перестаём брать новые сообщения, коммитим оффсеты.
    consumer.Stop(shutCtx)

    // 6. Фоновые воркеры: очередь дорабатывается, новые задачи не принимаются.
    done := make(chan struct{})
    go func() { pool.Stop(); close(done) }()
    select {
    case <-done:
    case <-shutCtx.Done():
        log.Warn("воркеры не успели за отведённое время")
    }

    // 7. Флашим телеметрию до закрытия соединений, но после остановки писателей.
    _ = tracerProvider.Shutdown(shutCtx)
    metrics.Flush(shutCtx)

    // 8. Ресурсы закрываем последними, в порядке, обратном открытию.
    _ = cache.Close()
    _ = db.Close()

    log.Info("остановлено штатно")
}

Разбор ключевых решений

  • signal.NotifyContext вместо signal.Notify + канала. Сразу даёт отменяемый ctx, который можно раздать всем компонентам, и корректно снимает обработчик через stop().
  • Вызов stop() сразу после <-ctx.Done(). Возвращает сигналам поведение по умолчанию: если оператор нервничает и жмёт Ctrl+C второй раз, процесс умрёт немедленно, а не проигнорирует сигнал.
  • Отдельный shutCtx от context.Background(). Корневой ctx уже отменён: возьмёшь его, и все шаги остановки завершатся мгновенно с ошибкой, никакого graceful. Это ошибка номер один у тех, кто пишет shutdown впервые.
  • Общий бюджет, а не таймаут на каждый шаг. Один shutCtx на всё гарантирует, что суммарное время не превысит договорённость с оркестратором.
  • Порядок закрытия ресурсов обратен порядку открытия. БД открыли первой, закрываем последней: от неё зависят все остальные.
  • pool.Stop() обёрнут в горутину с select. Сам Stop блокирующий и контекста не принимает; без обёртки зависший воркер сорвёт весь бюджет и дождётся SIGKILL.
Что ломается в проде чаще всего
  • Нет паузы на readiness. Каждый деплой даёт всплеск 502/504 в графиках. Самая частая и самая незаметная ошибка.
  • Бюджет больше terminationGracePeriodSeconds. Kubernetes присылает SIGKILL по истечении своего таймера, и весь аккуратный shutdown обрывается на середине.
  • Shutdown не отменяет уже выполняющиеся хендлеры. Он ждёт их. Если хендлер не смотрит на r.Context(), он будет работать до конца, и его никак не поторопить. Плюс Shutdown не ждёт хайджекнутые соединения вроде WebSocket, а SSE, наоборот, продержит до дедлайна: это обычный ответ, который хендлер не завершает. И те и другие закрывай сам.
  • Забытый os.Exit в обработчике сигнала. Мгновенная смерть без единого шанса доработать: defer не выполняются, буферы не сбрасываются.
  • Компонент, у которого нет Stop. Библиотека запустила горутину и не дала способа её остановить: процесс не завершится, придётся ждать SIGKILL. Проверять это надо при выборе зависимости, а не при инциденте.
Что спросит интервьюер следующим

«А если во время остановки пришёл ещё один запрос?» Не придёт: сокет уже не слушает, соединение отвергнет ядро, балансировщик перенаправит на другой под. «Что с in-flight сообщениями Kafka?» Консьюмер обязан дообработать взятый батч и закоммитить оффсет, иначе после рестарта будет повторная обработка (вот откуда требование идемпотентности). «Как проверить, что shutdown работает?» Интеграционным тестом: поднять сервис, послать долгий запрос, послать SIGTERM, убедиться, что запрос завершился успешно, а процесс вышел с кодом 0 за отведённое время.

Вопросы

12
Суть: канал задач + N горутин с for range jobs + канал результатов с полем Err + WaitGroup. Продюсер закрывает jobs, отдельная горутина закрывает results после wg.Wait(). Полный код - в теории выше.

Как писать это на доске, чтобы не запутаться

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

  1. Сколько каналов и кто их владелец? Два: jobs (владеет продюсер) и results (владеют воркеры, коллективно).
  2. Кто и когда их закрывает? jobs закрывает продюсер после последней задачи. results закрывает отдельная горутина go func(){ wg.Wait(); close(results) }(): ни один воркер не знает, закончили ли остальные.
  3. Что произойдёт при отмене посередине? Продюсер перестаёт раздавать, воркеры доделывают текущее, коллектор дочитывает то, что успели положить.
  4. Что если получателя результатов не станет? Либо буфер на все результаты, либо select с ctx.Done() на отправке. Иначе утечёт ровно N горутин.

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

jobs := make(chan Job)
res  := make(chan Result, len(input))
var wg sync.WaitGroup

for i := 0; i < n; i++ {
    wg.Go(func() {                       // Go 1.25; до неё: wg.Add(1) + go func + defer wg.Done()
        for j := range jobs {
            out, err := handle(ctx, j)
            res <- Result{j.ID, out, err}
        }
    })
}
go func() { defer close(jobs); for _, j := range input { jobs <- j } }()
go func() { wg.Wait(); close(res) }()

for r := range res { collect(r) }
Что здесь обязательно проговорить вслух

Что буфер len(input) взят как осознанное упрощение: он снимает вопрос блокировки на отправке, но при потоке задач вместо слайса не годится. Что wg.Add стоит до go, а не внутри. Что N выбирают по природе нагрузки: CPU-bound пляшет от GOMAXPROCS, IO-bound от лимитов внешнего ресурса. Кандидат, который написал верный код молча, и кандидат, который проговорил эти три вещи, получают разные оценки.

Суть: закрывает только отправитель и только владелец, ровно один раз. Если отправителей несколько - закрывает тот, кто дождался всех (обычно отдельная горутина после wg.Wait()). Читатель не закрывает никогда. Закрывать канал вообще не обязательно - только если получателю нужен сигнал «данных больше не будет».

Почему именно так

  • Отправка в закрытый канал роняет панику, и проверкой её не предотвратить: между «проверил, что не закрыт» и «отправил» есть окно. Значит, безопасный протокол один: закрывать со стороны отправителя, когда он точно знает, что больше не пошлёт.
  • Повторный close тоже паника. Отсюда либо один владелец, либо sync.Once, либо флаг под мьютексом.
  • Приём из закрытого канала безопасен и мгновенен: возвращает нулевое значение и ok == false. Поэтому close и работает как широковещательный сигнал для любого числа получателей: именно на этом построен context.Done().

Три конфигурации и решение для каждой

Кто пишетКто закрываетКак
один отправительон самdefer close(out) первой строкой его горутины
несколько отправителейотдельная горутинаgo func(){ wg.Wait(); close(out) }()
надо остановить писателей извненикто не закрывает outотдельный ctx.Done() / done-канал, закрываемый управляющим кодом
// Если без закрытия извне никак, вот безопасный враппер.
type SafeChan[T any] struct {
    mu     sync.RWMutex
    closed bool
    ch     chan T
}

func (s *SafeChan[T]) Send(v T) error {
    s.mu.RLock()                         // читатели-отправители не мешают друг другу
    defer s.mu.RUnlock()
    if s.closed {
        return ErrClosed
    }
    s.ch <- v                            // под RLock: Close не сможет вклиниться
    return nil
}

func (s *SafeChan[T]) Close() {
    s.mu.Lock()                          // ждёт, пока все Send завершатся
    defer s.mu.Unlock()
    if !s.closed {
        s.closed = true
        close(s.ch)
    }
}
// Цена: RLock на каждую отправку и риск задержки Close, если канал полон,
// а читателя нет. Поэтому это запасной вариант, а не основной подход.
Дисциплина владения на уровне сигнатур

Функция, создающая канал, возвращает его как <-chan T. Функция, принимающая канал для записи, объявляет параметр как chan<- T. Тогда компилятор физически не даст получателю закрыть чужой канал (invalid operation: cannot close receive-only channel), а владение оказывается задокументировано прямо в сигнатуре. Самый дешёвый способ сделать конкурентный код правильным по построению.

Суть: цепочка функций вида func(ctx, <-chan T) <-chan R, каждая со своей горутиной и своим выходным каналом. Штатная остановка - каскад закрытий слева направо. Досрочная - общий ctx, который слышат все стадии, каждая закрывает свой выход в defer.

Два механизма остановки и почему нужны оба

  • Каскад закрытий работает только при нормальном исчерпании источника. Стадия дочитала вход, вышла из range, сработал defer close(out), следующая стадия увидела закрытие. Так конвейер сворачивается сам, без единой строчки управляющего кода.
  • Общая отмена нужна для всего остального: потребитель ушёл по таймауту, сделал break, случилась ошибка. Без неё стадия, застрявшая на отправке в канал, который никто не читает, никогда не дойдёт до своего close, и каскад не запустится. Отсюда правило: отправка внутри стадии всегда через select с ctx.Done().

Куда девать ошибки

// Практичный вариант: ошибка едет по конвейеру вместе с данными.
type Item[T any] struct {
    Val T
    Err error
}

func stage[T, R any](ctx context.Context, in <-chan Item[T],
    f func(context.Context, T) (R, error)) <-chan Item[R] {

    out := make(chan Item[R])
    go func() {
        defer close(out)
        for it := range in {
            var res Item[R]
            if it.Err != nil {
                res = Item[R]{Err: it.Err}     // ошибку пропускаем дальше как есть
            } else {
                v, err := f(ctx, it.Val)
                res = Item[R]{Val: v, Err: err}
            }
            select {
            case out <- res:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
// Плюс: один канал, одна точка закрытия, ошибки не теряются и видно, на каком
// элементе сломалось. Минус: каждая стадия обязана проверять it.Err первой строкой.
Скрытая проблема любого конвейера

Пропускная способность равна пропускной способности самой медленной стадии, и никакая буферизация этого не меняет: буфер лишь сдвигает момент, когда всё упрётся. Диагностика простая: буферы перед стадией всегда полны, а после всегда пусты, вот и узкое место. Дальше делают fan-out именно этой стадии, и сразу за ним всплывает вопрос порядка (следующий вопрос).

Суть: на каждый входной канал - своя горутина, копирующая элементы в общий out; WaitGroup считает копировщиков; отдельная горутина делает wg.Wait(); close(out). Полная реализация - в теории выше.

Вариант без WaitGroup - на select с обнулением каналов

Просят как «а можете иначе?» или когда каналов ровно два. Идиома основана на том, что операция с nil-каналом блокируется навсегда, а значит, ветку select с nil-каналом никогда не выберут. Так ветку и «выключают».

func merge2[T any](ctx context.Context, a, b <-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        for a != nil || b != nil {           // пока жив хоть один источник
            select {
            case v, ok := <-a:
                if !ok {
                    a = nil                  // выключаем ветку навсегда
                    continue
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            case v, ok := <-b:
                if !ok {
                    b = nil
                    continue
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}
// Почти все забывают обнулить закрытый канал.
// Закрытый канал всегда готов к приёму, select начнёт выбирать его
// в бесконечном цикле: busy-loop на 100% CPU вместо аккуратного слияния.
Два способа сломать merge на N каналов

Первый: close(out) внутри копирующей горутины, при двух и более входах это паника двойного закрытия. Второй: синхронный wg.Wait() перед return out, функция не вернёт канал, пока работа не кончится, читателя не будет, писатели заблокируются, дедлок. Обе ошибки на собеседованиях встречаются чаще, чем правильный вариант, так что стоит проговорить, почему Wait уезжает в отдельную горутину.

Соседний вопрос

«А как слить каналы, число которых меняется в рантайме?» Три ответа: reflect.Select (работает, но медленно и без типов), дерево merge (каждый новый канал подмешивается ещё одним merge2: логарифмическая глубина, зато обычный код), либо «канал каналов» chan (<-chan T): один диспетчер читает новые источники и запускает на каждый копировщик, увеличивая WaitGroup. Последний вариант и используют в динамических конвейерах.

Суть: нумеровать элементы на входе и пересобирать на выходе. Два способа: буфер-переупорядочиватель (map[int]T плюс указатель на следующий ожидаемый индекс) или - если элементов конечное число - просто слайс результатов по индексу, без канала вообще.

Способ 1: слайс по индексу (когда вход - слайс)

// Самый простой и самый недооценённый вариант: канал результатов не нужен вообще.
// Каждая горутина пишет в свою ячейку, гонки нет, синхронизация не нужна.
func mapParallel[T, R any](ctx context.Context, in []T, limit int,
    f func(context.Context, T) (R, error)) ([]R, error) {

    out := make([]R, len(in))
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(limit)

    for i, v := range in {
        g.Go(func() error {
            r, err := f(ctx, v)
            if err != nil {
                return fmt.Errorf("элемент %d: %w", i, err)
            }
            out[i] = r                       // своя ячейка, без синхронизации
            return nil
        })
    }
    if err := g.Wait(); err != nil {         // Wait как барьер: после него слайс виден целиком
        return nil, err
    }
    return out, nil
}
// Почему запись в разные элементы слайса безопасна: это разные адреса памяти.
// Слайс не перевыделяется (длина фиксирована), заголовок никто не меняет.
// А g.Wait() создаёт happens-before между записями и последующим чтением.

Способ 2: переупорядочивание потока

// Когда на входе бесконечный поток и слайс не построить.
type Seq[T any] struct {
    Idx int
    Val T
}

func reorder[T any](ctx context.Context, in <-chan Seq[T]) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        buf  := make(map[int]T)
        next := 0
        for it := range in {
            buf[it.Idx] = it.Val
            for {                            // отдаём всё, что стало непрерывным
                v, ok := buf[next]
                if !ok {
                    break
                }
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
                delete(buf, next)
                next++
            }
        }
    }()
    return out
}
// Цена честного порядка: один застрявший элемент держит в памяти всё,
// что обогнало его. Обязательно ограничивать размер buf и падать/тормозить
// при переполнении, иначе медленный элемент превращается в OOM.
Как отвечать

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

Суть: пул ограничивает число горутин, семафор - число одновременных операций при произвольном числе горутин. Когда горутины создаёт не ваш код (HTTP-хендлеры, консьюмер), ограничить их числом нельзя - можно только пропускать через семафор.

Реализация целиком - четыре метода

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore { return make(Semaphore, n) }

func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}
func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:
        return false
    }
}
func (s Semaphore) Release() { <-s }
func (s Semaphore) InUse() int { return len(s) }   // готовая метрика насыщения

На что обратить внимание в ответе

  • Отправка и есть захват. Слоты складываются в буфер; полный буфер значит «все заняты». Направление контринтуитивно, поэтому его проговаривают явно.
  • defer Release() сразу после успешного Acquire, и никогда до проверки ошибки: Release без Acquire читает из пустого канала и блокируется навсегда.
  • Ожидающие стоят в FIFO-очереди канала, голодания нет by design.
  • len(s) даёт бесплатную метрику: экспортируйте её, и насыщение лимита будет видно на графике раньше, чем начнутся таймауты.
  • Стоимость ожидания выше, чем у пула. В пуле ждёт задача (несколько байт в буфере), в семафоре целая горутина со стеком и всем контекстом запроса. Поэтому у семафора почти всегда нужен либо таймаут, либо TryAcquire с честным отказом.
Чем добить ответ

Упомянуть golang.org/x/sync/semaphore, взвешенный вариант для случая, когда операции стоят по-разному (например, вес = размер файла в мегабайтах): у него честная очередь и Acquire(ctx, n). И сразу оговорить грабли: Acquire с весом больше ёмкости не выполнится никогда: он проспит до отмены ctx и вернёт ctx.Err(), а с контекстом без отмены повиснет навсегда. Поэтому вес проверяют до вызова. И ещё: семафор и rate limiter решают разные задачи, первый про одновременность, второй про частоту, и в проде обычно нужны оба.

Суть: тикер даёт жёстко равномерный поток без всплесков и прост как палка. Токен-бакет держит среднюю частоту, но разрешает burst - это поведение реальных API. В проде берут golang.org/x/time/rate: там нет фоновой горутины вообще, токены вычисляются из времени.

Сравнение

ТикерТокен-бакет (свой)x/time/rate
Всплескиневозможныдо burstдо burst
Фоновая горутинанет (тикер - таймер рантайма)есть, надо останавливатьнет: lazy refill по времени
Точность на высоких rpsплохая: упирается в разрешение таймеров (~1 мс)та же проблемахорошая: считается арифметикой
Неблокирующий режимчерез select с defaultAllow()Allow(), Reserve(), Wait(ctx)
Когда братьпростые фоновые задачи, «раз в N»учебный пример, нет зависимостейвсё остальное
// Лимитер на клиента: то, что нужно в 90% случаев.
type PerKey struct {
    mu   sync.Mutex
    lim  map[string]*entry
    r    rate.Limit
    b    int
}
type entry struct {
    l    *rate.Limiter
    seen time.Time
}

func (p *PerKey) Allow(key string) bool {
    p.mu.Lock()
    e, ok := p.lim[key]
    if !ok {
        e = &entry{l: rate.NewLimiter(p.r, p.b)}
        p.lim[key] = e
    }
    e.seen = time.Now()
    p.mu.Unlock()
    return e.l.Allow()
}

// Без вытеснения карта растёт вечно: это утечка памяти, а не «мелочь».
func (p *PerKey) gc(ctx context.Context, ttl time.Duration) {
    t := time.NewTicker(ttl)
    defer t.Stop()
    for {
        select {
        case <-t.C:
            p.mu.Lock()
            for k, e := range p.lim {
                if time.Since(e.seen) > ttl {
                    delete(p.lim, k)
                }
            }
            p.mu.Unlock()
        case <-ctx.Done():
            return
        }
    }
}
Где ошибаются
  • Блокирующий Wait на входящем трафике. Под перегрузкой копит горутины и превращает лимитер в источник OOM. Для внешних клиентов берите Allow() и честный 429 с Retry-After.
  • Локальный лимитер в распределённой системе. Десять подов по 100 rps дают 1000 rps на бэкенд. Нужен общий счётчик (Redis + Lua-скрипт) либо деление квоты на число реплик с поправкой на автоскейл.
  • Забытый Stop() у тикера пополнения в самописном бакете: утечка горутины на каждый созданный лимитер.
  • Один общий лимитер вместо лимитера на ключ. Один шумный клиент выедает квоту всех остальных.
Суть: generator превращает любой источник в ленивый отменяемый канал. or-done оборачивает чужой канал, за поведение которого вы не отвечаете, и делает его отменяемым - чтобы писать простой range. tee раздваивает поток на два независимых выхода.

Зачем нужен or-done - главный смысл

Когда вы читаете канал, который вернула чужая библиотека, у вас нет гарантии, что его когда-нибудь закроют. Обычный for v := range foreign в этом случае даёт потенциально вечную горутину. Писать вместо него select с ctx.Done() в каждом месте использования утомительно, да и забывается легко. orDone прячет эту защиту внутрь один раз:

// Было: в каждом потребителе шум, который легко забыть.
loop:
for {
    select {
    case v, ok := <-foreign:
        if !ok {
            break loop
        }
        use(v)
    case <-ctx.Done():
        break loop
    }
}

// Стало: защита один раз, потребители тривиальны.
for v := range orDone(ctx, foreign) {
    use(v)
}

Ключевая идиома внутри tee

Обнуление локальной копии канала внутри select: после отправки в o1 присваиваем o1 = nil, и эта ветка перестаёт выбираться, потому что операция с nil-каналом блокируется навсегда. Так элемент уходит сначала тому получателю, кто освободился первым. Ту же идиому применяют в merge2, при переключении между источниками и при временной приостановке чтения.

Где применяют и где не стоит

Эти паттерны пришли из книги «Concurrency in Go», и на собеседовании их спрашивают как проверку на «читал ли». В проде orDone действительно полезен на границе с чужим кодом, generator после появления iter.Seq (Go 1.23) чаще заменяется обычным итератором без горутин и каналов вообще, а tee встречается редко: у него неприятное свойство, оба выхода идут в темпе медленнейшего получателя. Честный ответ «знаю, но в проде вместо generator обычно range-over-func, а вместо tee берут очередь с двумя подписчиками» звучит сильнее, чем заученное определение.

Суть: errgroup.Group - это WaitGroup плюс sync.Once для первой ошибки плюс (в варианте WithContext) cancel, который дёргается при этой ошибке. Wait возвращает первую ошибку. Чтобы собрать все - нужен свой срез под мьютексом и errors.Join.

Что внутри - по сути весь пакет

type Group struct {
    cancel  func(error)
    wg      sync.WaitGroup
    sem     chan token      // появляется после SetLimit
    errOnce sync.Once       // «первая ошибка» фиксируется ровно один раз
    err     error
}

func (g *Group) Go(f func() error) {
    if g.sem != nil {
        g.sem <- token{}    // SetLimit сводится к семафору на канале
    }
    g.wg.Add(1)
    go func() {
        defer g.done()
        if err := f(); err != nil {
            g.errOnce.Do(func() {
                g.err = err
                if g.cancel != nil {
                    g.cancel(g.err)   // отменяем производный ctx, остальные сворачиваются
                }
            })
        }
    }()
}

Сравнение трёх инструментов

WaitGrouperrgrouperrgroup + Join вручную
Ошибкиникак, собирать самомупервая по временивсе
Отмена остальныхнетда, через производный ctxда
Лимит параллелизмарукамиSetLimitSetLimit
Паника в задачероняет процессроняет процессроняет процесс
Когда братьошибок нет или они не важнырезультат бесполезен без всех частейнезависимые задачи, нужна полная картина
Самая частая ошибка
g, _ := errgroup.WithContext(ctx)   // ПОТЕРЯЛИ производный ctx
g.Go(func() error { return fetch(ctx, a) })   // получает ИСХОДНЫЙ ctx
// Ошибка вернётся, но остальные горутины доработают до конца:
// отменять-то нечего, производный контекст выброшен.

g, ctx := errgroup.WithContext(ctx)  // ПРАВИЛЬНО: ctx теперь производный
g.Go(func() error { return fetch(ctx, a) })
Чем добить

Сказать, что errgroup отменяет контекст, а не горутины: если задача не проверяет ctx, она доработает, и Wait её дождётся; «быстрого» выхода не будет. И что паника внутри g.Go не перехватывается: если задачи выполняют чужой код, тело оборачивают в recover и превращают панику в ошибку, иначе один плохой ответ внешнего API роняет весь процесс.

Суть: t := time.NewTicker(d) + defer t.Stop() + for { select { case <-t.C: ...; case <-ctx.Done(): return } }. Плюс три обязательных детали: свой таймаут на итерацию, recover внутри итерации, и не выходить из цикла при ошибке итерации.

Восемь вещей, которые проверяют этим вопросом

  1. NewTicker, а не time.Tick, если тикер надо останавливать или перенастраивать: у Tick нет ни Stop(), ни Reset(). Утечкой он был только до Go 1.23, теперь недостижимый тикер собирает GC.
  2. defer t.Stop() сразу после создания.
  3. Ветка ctx.Done(): без неё горутина вечная.
  4. Первый прогон до цикла, если работа нужна сразу: Ticker выдаёт первый тик только через период.
  5. Свой дедлайн на итерацию. Зависшая итерация останавливает расписание навсегда и в дампе выглядит как одна невинная горутина.
  6. recover внутри итерации. Паника в фоновой задаче не должна ронять сервис.
  7. Ошибка итерации логируется, но не выходит из цикла. Одна сетевая ошибка не должна убивать расписание до рестарта.
  8. Джиттер. Десять реплик с периодом в минуту создают синхронный всплеск; случайная задержка старта размазывает нагрузку.

Когда Ticker не подходит

// Ticker меряет период между тиками. Если нужен фиксированный интервал
// между окончанием и началом (работа длится непредсказуемо долго), берут Timer.
func runFixedGap(ctx context.Context, gap time.Duration, f func(context.Context)) {
    for {
        f(ctx)
        select {
        case <-time.After(gap):     // с Go 1.23 таймер соберётся GC, утечки нет
        case <-ctx.Done():
            return
        }
    }
}

// Ticker отбрасывает пропущенные тики, а не копит их.
// Это защита от лавины: если итерация длилась 10 периодов, то после неё
// придёт один тик, а не 10 подряд. Но и фактическая частота упадёт:
// иногда это ровно то, что нужно, иногда это скрытая деградация.
Что изменилось в Go 1.23

Раньше незастопленные Timer/Ticker удерживались рантаймом до срабатывания и не собирались GC; отсюда правило «time.After в горячем цикле течёт». С 1.23 (в модулях с go >= 1.23) недостижимый таймер собирается сборщиком, а канал стал небуферизованным, что заодно убрало проблему устаревшего значения в буфере после Reset; в Go 1.27 GODEBUG=asynctimerchan удалён, так что новое поведение действует безусловно. Правило defer t.Stop() осталось: пока на тикер есть ссылка, он продолжает тикать и будить рантайм.

Суть: останавливать снаружи внутрь. Сигнал → readiness=false и пауза → srv.Shutdown → консьюмеры → воркеры (wg.Wait) → флаш телеметрии → закрытие БД и кэша → выход. Отдельный shutCtx от Background() с общим бюджетом меньше terminationGracePeriodSeconds.

Почему порядок именно такой

Правило одно: компонент можно останавливать только после того, как остановлены все, кто им пользуется. HTTP-сервер пользуется воркерами и БД, воркеры пользуются БД, значит порядок жёстко задан: сервер → воркеры → БД. Закроете БД первой, получите шквал ошибок в последних, самых важных запросах, тех самых, которые как раз пытались доработать.

Три вещи, которые различают уровни кандидата

  • Пауза на readiness. В Kubernetes SIGTERM и удаление эндпоинта происходят параллельно: часть трафика ещё летит на под. Без паузы в 5–15 секунд каждый деплой даёт всплеск 502. Про это помнят те, кто реально катал сервисы.
  • shutCtx от context.Background(), а не от корневого. Корневой уже отменён сигналом; если взять его, все шаги остановки завершатся мгновенно с ошибкой.
  • Бюджет меньше grace period оркестратора. 25 секунд при terminationGracePeriodSeconds: 30, иначе SIGKILL оборвёт всё на середине, и аккуратный код не поможет.
Пять подводных камней
  • srv.Shutdown не отменяет выполняющиеся хендлеры, он их ждёт. Хендлер, игнорирующий r.Context(), доработает до конца.
  • Shutdown не ждёт хайджекнутые соединения (WebSocket), а SSE-поток, который хендлер держит открытым, наоборот, будет ждать до дедлайна. И те и другие закрывают вручную, ведя свой реестр.
  • os.Exit в обработчике сигнала убивает всё мгновенно: defer не выполнятся, буферы не сбросятся.
  • Библиотека без Stop(), запустившая свою горутину, не даст процессу завершиться штатно. Это критерий выбора зависимости.
  • Второй SIGTERM должен убивать немедленно: для этого сразу после <-ctx.Done() вызывают stop(), возвращая обработчик по умолчанию.
Как проверить, что shutdown работает

Интеграционным тестом, а не глазами: поднять процесс, послать запрос, который заведомо выполняется 5 секунд, через секунду послать SIGTERM и убедиться, что (а) запрос вернул 200, (б) новые соединения отвергаются, (в) процесс вышел с кодом 0, (г) уложился в бюджет. Такой тест ловит регрессии порядка остановки, которые иначе всплывают только на графике 5xx после деплоя.

Суть: три стратегии - блокировать продюсера (естественный backpressure), отказывать сразу (select с default и 429), отбрасывать старое (drop-oldest). Большая очередь - не решение, а способ отложить проблему и заодно испортить латентность.

Три стратегии и когда какая

СтратегияКакКогда
Блокировать продюсераобычная отправка в каналпродюсер - ваш код и его торможение допустимо: чтение файла, консьюмер брокера (он просто перестанет забирать)
Отказать сразуselect { case ch <- j: default: return ErrBusy }внешний трафик: честный 429 с Retry-After лучше, чем таймаут через 30 секунд
Отбросить староепрочитать один элемент из канала и положить новыйтелеметрия, метрики, «последнее состояние»: свежие данные ценнее старых
Отбросить новоето же, что «отказать», но молчатолько там, где потеря допустима и её видно в метрике
// Drop-oldest: в очереди всегда самое свежее.
func (q *Queue) PushLatest(v Item) {
    for {
        select {
        case q.ch <- v:
            return
        default:
            select {
            case <-q.ch:            // выбросили самый старый
                q.dropped.Add(1)    // метрика обязательна: без неё потери невидимы
            default:                // кто-то успел забрать, пробуем снова
            }
        }
    }
}

Как выбирать размер буфера

  • 0 (небуферизованный): жёсткая синхронизация, продюсер идёт в темпе консьюмера. Самая честная и самая наблюдаемая схема.
  • Маленький (1..2×N воркеров) сглаживает микровсплески и джиттер планировщика, не искажая картину. Разумный дефолт.
  • Большой (тысячи) почти всегда ошибка: он маскирует нехватку мощности, добавляет минуты к латентности хвоста и превращает падение процесса в потерю всей очереди. Очередь должна переживать рестарт, значит это уже не канал, а брокер или БД.
Что проговорить обязательно

Что очередь не лечит производительность, она гасит всплески. Если средняя скорость поступления выше средней скорости обработки, никакой размер буфера не поможет: он заполнится и всё равно упрётся, просто позже и с худшей латентностью. Реагировать надо иначе: либо наращивать мощность (N, шардирование, ускорение обработки), либо честно ограничить вход. И в любом случае экспортировать len(ch) метрикой: заполненность очереди даёт самый ранний сигнал приближающейся деградации.

Что стоит запомнить

  • 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 ждёт все стадии.