voidlearn
 

1.3Конкурентный лайвкодинг на Go

Спроектируешь worker pool и fan-in с явным владельцем каждого канала. Проверишь отмену context, передачу ошибок и случай, когда потребитель завершился раньше производителя.

Что интервьюер оценивает на самом деле

Работающий на happy path код пишут почти все. Отсев идёт по четырём пунктам, и о них почти никогда не говорят вслух - их просто проверяют глазами по твоему коду.

КритерийЧто смотрит интервьюерКак выглядит провал
Корректное завершение без утечек Каждая запущенная горутина гарантированно заканчивается. Каждый канал закрывает ровно один владелец - тот, кто в него пишет. Потребитель дренирует канал до конца. Горутина навсегда висит на results <- r, потому что читатель вышел по таймауту. Классическая утечка, её ищут первым делом.
Обработка ошибок Ошибка воркера не теряется и не «съедает» остальные. Понятно, что возвращаем: первую ошибку, все ошибки, или отменяем всё остальное. _ = err внутри горутины. Или запись в общую переменную err из нескольких горутин без мьютекса - это гонка.
Отмена по context ctx первым аргументом, select с <-ctx.Done() на каждой блокирующей операции, ctx.Err() в результате. ctx принят, но нигде не проверяется. Или проверяется только в начале цикла, а внутри - блокирующая запись в канал без select.
Отсутствие гонок Общее состояние либо под мьютексом, либо вообще не общее (каждая горутина пишет в свой слот слайса). Готовность сказать «я бы прогнал go test -race». results = append(results, r) из нескольких горутин. Выглядит невинно, ломает слайс на первой же реаллокации.
Правило владения каналом, вокруг которого крутится половина добивок

Закрывает канал тот, кто в него пишет, и только если писатель один. Из этого правила выводится всё остальное. Если писателей несколько, закрывают не они, а отдельный «сторож»: go func(){ wg.Wait(); close(ch) }(). Закроет читатель, и писатель гарантированно словит панику «send on closed channel». Хочешь со стороны читателя сказать писателям «хватит», бери не close канала данных, а отдельный done-канал или context. Проговори это правило вслух до того, как начнёшь писать - интервьюер сразу поймёт, что ты это уже делал.

Типовой каркас: 90% задач - это он с вариациями

Выучи этот скелет до автоматизма. Он закрывает worker pool, pipeline, семафор и «N запросов параллельно» - меняются только детали. Цифры из комментариев разобраны ниже.

func process(ctx context.Context, items []Item, workers int) ([]Result, error) {
    tasks := make(chan Item)                 // 1
    results := make(chan Result)             // 2

    var wg sync.WaitGroup                    // 3 - считаем только воркеров
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for it := range tasks {          // 4 - выход по закрытию tasks
                r, err := do(ctx, it)
                select {
                case results <- Result{Val: r, Err: err}:   // 5
                case <-ctx.Done():
                    return
                }
            }
        }()
    }

    go func() {                              // 6 - продюсер, единственный владелец tasks
        defer close(tasks)
        for _, it := range items {
            select {
            case tasks <- it:
            case <-ctx.Done():
                return
            }
        }
    }()

    go func() {                              // 7 - сторож закрывает results
        wg.Wait()
        close(results)
    }()

    out := make([]Result, 0, len(items))
    for r := range results {                 // 8 - дренаж до закрытия
        out = append(out, r)
    }
    return out, ctx.Err()
}
  1. Небуферизованный канал задач. Буфер тут не нужен: он не ускоряет, а лишь позволяет продюсеру убежать вперёд. На собесе скажи «буфер оптимизирует под конкретный профиль нагрузки, по умолчанию беру 0, потому что так проще рассуждать о завершении».
  2. Канал результатов отдельный. Совмещать вход и выход в один канал почти всегда ошибка проектирования.
  3. wg.Add(workers) до цикла, а не wg.Add(1) внутри горутины. Внутри горутины получается гонка: wg.Wait() может успеть отработать раньше первого Add.
  4. for range по каналу. Самый лаконичный способ выйти по закрытию. Не нужен ни ok, ни лишний select.
  5. Запись в results под select с ctx.Done(). Это и есть та самая деталь, которую ищут. Без неё воркер повиснет навсегда, если потребитель ушёл по таймауту.
  6. Продюсер в отдельной горутине. Если писать в tasks из основной, то при небуферизованном канале и медленных воркерах основная встанет - а нам ещё читать results. Дедлок.
  7. Сторож. Писателей в results много, поэтому закрывает не воркер, а отдельная горутина после wg.Wait(). Единственная законная причина для этой горутины.
  8. Дренаж. Читаем до закрытия канала. Именно это гарантирует, что ни один воркер не останется висеть на записи.
Worker pool: у каждого канала ровно один владелец, который его закрывает producer close(tasks) tasks chan Item worker 1 worker 2 worker 3 for it := range tasks все читают ОДИН канал - это и есть распределение нагрузки results chan Result range results собирает всё wg.Wait() close(results) воркер вышел → wg.Done() ctx.Done() виден всем троим: продюсеру, воркерам и записи в results - везде через select
Worker pool. Два канала и три роли. Продюсер пишет в tasks один, поэтому закрывает его сам. Писателей в results трое, поэтому закрывает отдельный сторож после wg.Wait(). Результаты собирает основная горутина: конкурентного доступа там нет, значит нет и гонки.
Как проговаривать решение вслух: сценарий на 60 секунд

Не начинай печатать сразу. Три-четыре фразы до кода стоят дороже, чем идеальный код без слов. Работающая последовательность:

  1. Контракт. «На входе слайс задач и число воркеров, на выходе все результаты и ошибка. Уточню: нужно ли остановиться на первой ошибке или собрать все? Порядок результатов важен?» Это два вопроса, которые интервьюер ждёт, и почти никто их не задаёт.
  2. Схема. «Делаю канал задач, N воркеров читают из него, пишут в канал результатов. Продюсер закрывает задачи, отдельная горутина после wg.Wait() закрывает результаты.» Одной фразой ты уже показал, что знаешь про владение каналом.
  3. Опасные места, до того как их найдут. «Запись в результаты обязательно под select с ctx.Done(), иначе при раннем выходе читателя воркеры утекут.»
  4. Пишешь код и комментируешь только неочевидное. Не озвучивай for i := 0; i < n; i++. Скажи лучше, почему wg.Add стоит до цикла.
  5. После кода проверь себя вслух. «Проверю на утечки: продюсер выходит по ctx или по концу слайса, воркеры по закрытию tasks, сторож по wg.Wait(). Прогнал бы go test -race и тест с goleak.»
Семь способов провалить конкурентную задачу за 30 секунд
  • wg.Add(1) внутри горутины. Гонка между Add и Wait; Wait может вернуться до старта воркеров.
  • Забыть close(tasks). Воркеры навсегда висят в range, wg.Wait() не вернётся, программа зависнет насмерть.
  • close(results) из воркера. Второй воркер тут же получит панику «send on closed channel».
  • wg.Wait() в основной горутине до чтения результатов при небуферизованном канале. Воркеры блокируются на записи, Wait никогда не вернётся - дедлок в чистом виде.
  • Запись в общий слайс через append из воркеров. Гонка. Либо канал, либо мьютекс, либо каждый пишет в out[i] по своему индексу - последнее, кстати, безопасно и без синхронизации, потому что элементы слайса не пересекаются.
  • Захват переменной цикла до Go 1.22. for _, t := range tasks { go func(){ use(t) }() }: до 1.22 все горутины увидят последнюю задачу.
  • Паника в воркере. Без recover она валит весь процесс. Если задача про продакшн-код, скажи, что в воркере стоял бы defer func(){ if r := recover(); r != nil { ... } }().
Глубже, чем спросят: почему errgroup правильный ответ, но не первый

В реальном коде почти всё из этой главы пишут через golang.org/x/sync/errgroup: g, ctx := errgroup.WithContext(ctx), g.SetLimit(n), g.Go(func() error{...}), g.Wait(). Он сам собирает первую ошибку, сам отменяет производный ctx, а SetLimit (появился в v0.1.0 модуля пакета) заменяет семафор. Но на лайвкодинге тебя просят написать механику руками. Предложишь errgroup сразу, попросят «а теперь без него». Стратегия такая: пишешь руками, а в конце добавляешь фразу «в проде я бы взял errgroup с SetLimit, потому что это ровно этот код, но оттестированный». Так закрыты обе стороны: и «умеет руками», и «не изобретает велосипед на работе».

Вопросы

11
Суть: один канал задач, N читающих его горутин, один канал результатов. Продюсер закрывает задачи, сторож после wg.Wait() закрывает результаты. Ошибки - это данные: они едут в том же канале результатов, а не в общей переменной. Стратегий две - «собрать все» и «упасть на первой», и выбирать её должен вызывающий, а не библиотека.

Полная реализация: собрать все результаты и все ошибки

type Job struct {
    ID  int
    Arg string
}

type Result struct {
    JobID int
    Val   string
    Err   error
}

// Pool обрабатывает jobs не более чем workers горутинами.
// Возвращает результаты в порядке завершения и объединённую ошибку.
func Pool(
    ctx context.Context,
    jobs []Job,
    workers int,
    do func(context.Context, Job) (string, error),
) ([]Result, error) {

    if workers <= 0 {
        workers = 1
    }
    if workers > len(jobs) {
        workers = len(jobs)          // не плодим горутины без работы
    }
    if len(jobs) == 0 {
        return nil, nil              // ранний выход: без задач и работать не над чем
    }

    tasks := make(chan Job)
    results := make(chan Result)

    var wg sync.WaitGroup
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for j := range tasks {
                val, err := do(ctx, j)
                select {
                case results <- Result{JobID: j.ID, Val: val, Err: err}:
                case <-ctx.Done():
                    return           // читателя больше нет - уходим, а не виснем
                }
            }
        }()
    }

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

    // Сторож: писателей в results много, закрыть должен кто-то один.
    go func() {
        wg.Wait()
        close(results)
    }()

    out := make([]Result, 0, len(jobs))
    var errs []error
    for r := range results {         // дочитываем до конца, иначе утекут горутины
        out = append(out, r)
        if r.Err != nil {
            errs = append(errs, fmt.Errorf("job %d: %w", r.JobID, r.Err))
        }
    }
    if err := ctx.Err(); err != nil {
        errs = append(errs, err)     // отменили на середине, это тоже результат
    }
    return out, errors.Join(errs...) // Join вернёт nil, если errs пуст
}
// jobs = 5 штук, workers = 3, третья задача возвращает ошибку.
// Характерный прогон:
//   {3  сломался}
//   {2 B <nil>}
//   {1 A <nil>}
//   {5 E <nil>}
//   {4 "D" <nil>}
//   err: job 3: сломался
// Порядок строк меняется от запуска к запуску, это результаты в порядке завершения.
// Стабильно только одно: все 5 результатов на месте и err не nil.

Вариант «fail-fast»: первая ошибка отменяет остальное

Меняется ровно одна вещь: заводим производный ctx с cancel и дёргаем его на первой ошибке. Воркеры увидят отмену в своём select и в do(ctx, ...), продюсер перестанет подавать задачи.

func PoolFailFast(ctx context.Context, jobs []Job, workers int,
    do func(context.Context, Job) (string, error)) ([]Result, error) {

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                   // иначе утечёт сам context

    // ... тот же запуск воркеров и продюсера с новым ctx ...

    var out []Result
    var firstErr error
    for r := range results {
        if r.Err != nil && firstErr == nil {
            firstErr = r.Err
            cancel()                 // сигнал всем остальным: сворачиваемся
            continue                 // но канал дочитываем до конца
        }
        out = append(out, r)
    }
    return out, firstErr
}
Деталь fail-fast, которую почти всегда пропускают

После cancel() нельзя делать break из цикла чтения. Воркеры, которые уже стоят на results <- r, выйдут по ctx.Done() - но только те, у кого select написан правильно. Дочитать канал до закрытия дешевле и надёжнее: гарантия работает независимо от того, как написан воркер. Формулировка на собесе: «я всегда дренирую канал до close, а не выхожу по break, тогда отсутствие утечки следует из структуры, а не из аккуратности».

Если нужен порядок результатов - не сортируй, пиши по индексу

// Результаты строго в порядке входного слайса, без канала результатов вообще.
out := make([]Result, len(jobs))     // длина фиксирована, реаллокаций не будет
var wg sync.WaitGroup
sem := make(chan struct{}, workers)  // ограничитель параллельности

for i, j := range jobs {
    wg.Add(1)
    go func(i int, j Job) {          // до Go 1.22 параметры обязательны
        defer wg.Done()
        sem <- struct{}{}
        defer func() { <-sem }()
        val, err := do(ctx, j)
        out[i] = Result{JobID: j.ID, Val: val, Err: err}  // свой слот, гонки нет
    }(i, j)
}
wg.Wait()
// Те же 5 задач и 3 воркера, но результат теперь детерминирован по порядку:
// [{1 A <nil>} {2 B <nil>} {3  сломался} {4 D <nil>} {5 E <nil>}]
// Сколько раз ни запусти, порядок тот же: каждый пишет в свой out[i].

Запись в out[i] из разных горутин безопасна: элементы слайса не пересекаются в памяти, а сам заголовок слайса никто не меняет (длина зафиксирована при make). Это одно из немногих мест, где «общая переменная без мьютекса» работает корректно, и объяснить это вслух дорогого стоит. И ни одного append: он меняет заголовок и превращает всё в гонку.

Где обычно ошибаются

  • wg.Add(1) внутри горутины. Планировщик может не успеть запустить горутину до wg.Wait(), и Wait вернётся на пустом счётчике. Add всегда зови в вызывающей горутине, до go.
  • Забыт ранний выход при len(jobs) == 0. Тогда workers = 0, wg.Wait() возвращается сразу, канал закрывается, так что тут всё сойдётся. А вот если workers не обрезали и он отрицательный, wg.Add паникует: sync: negative WaitGroup counter. А при нуле воркеров функция молча вернёт пустой результат, и продюсер повиснет навсегда. Границы проверить всё равно придётся.
  • Общая переменная err вместо канала. if err != nil { globalErr = err } из нескольких горутин даёт гонку, которую -race поймает мгновенно. Ошибка должна ехать как поле результата.
  • defer cancel() забыт в fail-fast варианте. context.WithCancel регистрирует дочерний контекст у родителя; без cancel он висит там до завершения родителя. go vet это ловит (lostcancel).
  • Буферизованный results «чтобы не блокировались». Это маскировка проблемы, а не решение. Буфер размера len(jobs) действительно уберёт блокировку, но съест память и спрячет то, что select с ctx не написан. Интервьюер это заметит.

Добивки, которые задаст интервьюер

  • «А если задачи приходят потоком, а не слайсом?» Сигнатура меняется на jobs <-chan Job, продюсер не нужен вообще - воркеры читают внешний канал напрямую. Закрывать его будет вызывающий, а мы просто выйдем из range.
  • «Как ограничить не число воркеров, а RPS?» Это уже rate limiter, а не pool: добавляем <-limiter.C перед вызовом do. Пул ограничивает одновременность, лимитер бьёт по частоте. Оси разные, и путают их постоянно: типичная ошибка проектирования.
  • «Что если воркер паникует?» Без recover падает весь процесс. В воркере ставим defer func(){ if r := recover(); r != nil { results <- Result{Err: fmt.Errorf("panic: %v", r)} } }() - и обязательно тоже под select с ctx, иначе паника превратится в утечку.
  • «Сколько воркеров брать?» Для CPU-bound бери runtime.GOMAXPROCS(0). Для IO-bound сильно больше, ограничение диктует не CPU, а внешний ресурс: пул соединений БД, лимит стороннего API. Ответ «зависит от того, во что упирается задача» лучше любого числа.
  • «Как протестируешь, что горутины не текут?» go.uber.org/goleak в TestMain, плюс go test -race. Ещё можно сравнить runtime.NumGoroutine() до и после с небольшим ожиданием.
  • «Перепиши на errgroup». g, ctx := errgroup.WithContext(ctx), g.SetLimit(workers), в цикле g.Go(...), в конце g.Wait(). Он даст ровно fail-fast семантику и первую ошибку.
Суть: по горутине на каждый вход, все пишут в общий out, отдельный сторож закрывает out после wg.Wait(). Закрыть его из горутины-читателя нельзя - остальные получат панику. Если читатель уходит раньше, спасает только select с ctx.Done() на записи в out.

Каноническая реализация

// Merge сливает произвольное число входных каналов в один.
// Закрывается, когда закрылись все входы. Отмена ctx спасает пишущего,
// но не читающего: range по входу отмену не видит.
func Merge[T any](ctx context.Context, ins ...<-chan T) <-chan T {
    out := make(chan T)

    var wg sync.WaitGroup
    wg.Add(len(ins))

    for _, in := range ins {
        go func(in <-chan T) {       // параметр нужен до Go 1.22
            defer wg.Done()
            for v := range in {      // выходим, когда вход закрыт
                select {
                case out <- v:
                case <-ctx.Done():   // читатель ушёл, не виснем
                    return
                }
            }
        }(in)
    }

    go func() {                      // единственный, кто имеет право на close
        wg.Wait()
        close(out)
    }()

    return out
}
// Merge(ctx, src(1,2,3), src(10,20,30), src(100,200)) - шесть прогонов подряд:
//   1 2 100 10 3 20 200 30
//   1 10 100 2 20 200 3 30
//   10 100 1 20 200 2 30 3
//   1 2 10 100 3 20 200 30
//   100 10 1 200 20 2 30 3
//   1 2 100 3 10 200 20 30
// Гарантируется только состав (все 8 значений) и порядок внутри каждого входа.
// Как входы чередуются между собой, решает планировщик.

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

Fan-in: по горутине на вход, один общий выход, один сторож на close входы in[0] in[1] in[2] go: range in[0] go: range in[1] go: range in[2] out chan T select { out <- v | <-ctx.Done() } потребитель: range out wg.Wait() close(out) каждая горутина: wg.Done() Порядок элементов в out не определён: он зависит от того, какая горутина первой доехала до записи.
Fan-in. Число писателей в out равно числу входов, поэтому закрывает отдельная горутина: только эта конструкция корректно отвечает на вопрос «кто закрывает канал, когда писателей много».

Вариант без горутины на канал: reflect.Select

Если N известно только в рантайме и горутин жалко, есть reflect.Select - он делает динамический select по слайсу SelectCase. Одна горутина вместо N. На собесе это отличная «добивка сверху», но в проде почти не нужна: горутина стоит ~2–8 КБ стека, а рефлексия обходится примерно вдвое дороже на каждой операции.

func MergeReflect[T any](ins ...<-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        cases := make([]reflect.SelectCase, len(ins))
        for i, in := range ins {
            cases[i] = reflect.SelectCase{
                Dir:  reflect.SelectRecv,
                Chan: reflect.ValueOf(in),
            }
        }
        for len(cases) > 0 {
            i, v, ok := reflect.Select(cases)
            if !ok {                          // канал закрыт, убираем из набора
                cases = append(cases[:i], cases[i+1:]...)
                continue
            }
            out <- v.Interface().(T)
        }
    }()
    return out
}

Без строки, которая удаляет кейс, закрытый канал будет отдавать нулевое значение с ok == false в бесконечном цикле и займёт ядро на 100%. Это классическая ловушка select по закрытому каналу, и в статическом select она лечится присваиванием ch = nil: nil-канал в select блокируется навсегда и тем самым «выключает» ветку. Приём с ch = nil стоит назвать, даже если тебя не спрашивали: он выдаёт человека, который писал реальные pipeline.

Где обычно ошибаются

  • close(out) внутри горутины-читателя входа. Первая же закрывшаяся ветка закроет общий канал, остальные упадут с паникой на записи.
  • defer close(out) в Merge напрямую. Функция возвращает управление сразу, канал закроется до того, как что-то в него попадёт.
  • Нет select с ctx.Done() на записи. Работает, пока потребитель читает всё. Как только он выходит по первому найденному элементу - N горутин зависают навсегда. Это самая частая утечка в реальных кодовых базах.
  • Захват in без параметра до Go 1.22. Все горутины читают последний канал, остальные не читает никто: данные оттуда пропадают, а -race ещё и показывает гонку на самой переменной цикла.
  • Ожидание порядка. Fan-in ничего не гарантирует про порядок. Нужен порядок, значит это уже не fan-in, а слияние с приоритетом (куча по ключу) или нумерация элементов.

Добивки, которые задаст интервьюер

  • «А если один вход завис навсегда и не закрывается?» Merge никогда не закроет out. И ctx с таймаутом тут не поможет, пока приём не обёрнут в select: горутина стоит в range и отмену не видит. Признать эту зависимость важнее, чем героически её обходить.
  • «Как сделать fan-out обратно?» Fan-out получается, когда N горутин читают из одного канала (ровно наш worker pool). Симметрия «fan-out → работа → fan-in» и есть базовый конкурентный паттерн Go.
  • «Что если входов тысячи?» Тысячу горутин рантайм переварит (несколько мегабайт), но на десятках тысяч уже стоит подумать про reflect.Select или про иерархическое слияние: сливать по 8 каналов в дерево глубины log₈ N.
  • «Сделай так, чтобы out закрывался при отмене ctx». В этом виде не закроется: ctx.Done() стоит только в ветке записи, а горутина висит на range in и отмену не видит. Чтобы закрывался, приём тоже надо развернуть в select с ctx.Done() - вот тогда wg.Wait() вернётся и сторож закроет канал.
  • «Зачем возвращать <-chan, а не chan?» Компилятор запретит вызывающему писать и закрывать. Владение выражено типом, а не комментарием.
Суть: буферизованный канал ёмкости K - это и есть семафор. Запись в него - Acquire, чтение - Release. Отличие от пула: пул создаёт K горутин и раздаёт им задачи, семафор создаёт горутину на каждую задачу, но пропускает внутрь только K. Пул экономит горутины, семафор проще вписывается в существующий код.

Минимальная версия, которую пишут на доске

sem := make(chan struct{}, K)        // ёмкость = число разрешений

for _, u := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        sem <- struct{}{}            // Acquire: блокируется, когда занято K мест
        defer func() { <-sem }()     // Release строго через defer
        call(u)
    }(u)
}
wg.Wait()

struct{}{}, а не bool: пустая структура занимает ноль байт, и весь буфер канала на K разрешений не стоит ничего. Это мелочь, но её замечают.

Нормальный семафор: с context, TryAcquire и весами

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()             // слот не занят, Release звать нельзя
    }
}

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

func (s Semaphore) Release() { <-s }

// Занято прямо сейчас / всего слотов.
func (s Semaphore) InUse() int { return len(s) }
func (s Semaphore) Cap() int   { return cap(s) }

Как это выглядит в реальном клиенте внешнего API

type Client struct {
    http *http.Client
    sem  Semaphore                   // не более K одновременных запросов
}

func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
    if err := c.sem.Acquire(ctx); err != nil {
        return nil, fmt.Errorf("ожидание слота: %w", err)
    }
    defer c.sem.Release()            // defer сразу после успешного Acquire, иначе утечёт слот

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.http.Do(req)
    ...
}
Правило, из-за которого семафор «протекает»

Release зови только если Acquire вернул nil. Если Acquire вышел по ctx.Done(), слот не занят, и лишний <-sem заберёт чужое разрешение. Через несколько таких ошибок семафор начинает пропускать K+1, K+2… одновременных вызовов - и ловится это мучительно, потому что всё «почти работает». Отсюда шаблон: if err := Acquire(); err != nil { return err }; defer Release(), где defer стоит строго ниже проверки ошибки.

Семафор vs worker pool - в чём реально разница

Worker poolСемафор
Горутин создаётсяровно K, живут всё времяпо одной на задачу, N штук
Стоимость на 1M задачK стеков1M создаваний горутин (дёшево, но не бесплатно)
Порядок задачзадачи разбираются из общего каналакто первый дошёл до Acquire
Встраивание в чужой коднужна перестройка потока управлениядве строки внутрь существующей функции
Backpressure - тормозит ли медленный обработчик самого производителя задачесть: небуферизованный канал задач тормозит егонет: продюсер плодит горутины, они копятся на Acquire
Когда лучшепоток задач, long-running обработкаограничить конкретный вызов внутри уже готового кода

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

Где обычно ошибаются

  • Release не через defer. Любой ранний return или паника - и слот потерян навсегда. Через несколько ошибок пропускная способность падает до нуля, и программа «зависает» без единой явной блокировки.
  • Acquire без context. Тогда при перегрузке внешнего API горутины будут ждать вечно, и отменить их нечем.
  • Release после неуспешного Acquire. Семафор начинает пропускать больше K.
  • len(sem) для проверки «есть ли место» перед записью. Классический check-then-act: между проверкой и записью состояние изменится. Правильно и неблокирующе это делает select с default.
  • Путать семафор с rate limiter. Семафор ограничивает сколько одновременно, лимитер считает, сколько в секунду. Если API говорит «100 rps», семафор на 100 не даст гарантии: при времени ответа 10 мс это будет 10 000 rps.

Добивки, которые задаст интервьюер

  • «Сделай взвешенный семафор» (одна задача занимает n слотов). На канале это неудобно: Acquire(n) по n записей не атомарен и даёт дедлоки при конкуренции. Нужен sync.Cond или готовый golang.org/x/sync/semaphore.Weighted - внутри у него мьютекс и список ожидающих.
  • «Справедлив ли такой семафор?» Нет. Go не гарантирует FIFO среди горутин, заблокированных на канале - на практике планировщик обслуживает их близко к FIFO, но это деталь реализации, а не контракт. Нужна честность, заводи очередь заявок явно.
  • «Как узнать текущую загрузку?» len(sem) покажет занятые слоты, cap(sem) все. Годится для метрики, не годится для решения (гонка).
  • «Можно ли то же самое одной строкой?» errgroup.Group.SetLimit(K), внутри у него такой же буферизованный канал.
  • «Что если K нужно менять на лету?» Ёмкость канала неизменна. Либо пересоздавать семафор под мьютексом, либо переходить на sync.Cond с обычным счётчиком.
Суть: каждая стадия - функция <-chan In → <-chan Out, которая сама создаёт свой выходной канал, запускает горутину и закрывает канал через defer. Завершение идёт каскадом слева направо от закрытия первого канала. Досрочная остановка - только через ctx, который видит каждая стадия.

Полный рабочий pipeline

// Стадия 1: генератор. Единственная стадия без входного канала.
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:
            case <-ctx.Done():
                return               // defer всё равно закроет out
            }
        }
    }()
    return out
}

// Стадия 2: фильтр. Сигнатура один-в-один как у любой другой стадии.
func filter(ctx context.Context, in <-chan int, pred func(int) bool) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {          // каскад: закрылся вход, вышли
            if !pred(v) {
                continue
            }
            select {
            case out <- v:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Стадия 3: трансформация.
func mapStage[T, R any](ctx context.Context, in <-chan T, f func(T) R) <-chan R {
    out := make(chan R)
    go func() {
        defer close(out)
        for v := range in {
            select {
            case out <- f(v):
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Сборка: стадии просто вкладываются друг в друга.
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    nums := gen(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    even := filter(ctx, nums, func(n int) bool { return n%2 == 0 })
    sq   := mapStage(ctx, even, func(n int) int { return n * n })

    for v := range sq {
        fmt.Println(v)               // 4, потом 16, потом 36; на 36 сработает break
        if v > 30 {
            cancel()                 // уходим раньше: отменяем все стадии
            break
        }
    }
    // После cancel() каждая стадия выйдет по ctx.Done() и закроет свой out.
}

// вывод, ровно три строки:
// 4
// 16
// 36
// Стадии умеют выдать 4 16 36 64 100, но потребитель уходит на первом же значении > 30.
// 64 и 100 не напечатаются никогда, и поэтому нужен ctx: без него
// три горутины навсегда зависли бы на записи в канал, который больше никто не читает.

Три свойства, которые делают это правильным pipeline

  1. Каждая стадия владеет только своим выходным каналом. Она его создаёт, пишет в него и закрывает. Входной канал она только читает и никогда не закрывает. Поэтому завершение идёт само: закрылся вход - вышел range - сработал defer close(out) - закрылся выход следующей стадии, и так до конца.
  2. Однородная сигнатура. func(ctx, <-chan In, ...) <-chan Out. Стадии свободно складываются друг с другом: их можно переставлять, добавлять, оборачивать в Merge, чтобы распараллелить одну стадию.
  3. ctx в каждой стадии. Каскад закрытия работает слева направо, но останавливаться досрочно приходится справа налево - потребитель решил, что хватит. Канала данных для этого нет, нужен отдельный сигнал: ctx (раньше писали done chan struct{}: та же идея, только ctx удобнее).
Что случится без ctx: канонический пример утечки

Убери из кода выше все select с ctx.Done(), оставив просто out <- v. Программа напечатает те же числа и корректно завершится, потому что main дочитывает канал. А теперь верни break после v > 30: main уходит, стадия возведения в квадрат виснет на записи 64, фильтр - на записи 10, а генератор успевает закрыть свой канал и выйти. Две горутины и их каналы живут до конца процесса. Ни паники, ни ошибки, ни единого симптома - кроме медленно растущей памяти в проде. Именно этот сценарий проверяют вопросом «а если потребитель ушёл раньше?».

Параллельная стадия: fan-out + fan-in внутри pipeline

Если одна стадия стала узким местом (например, ходит в сеть), её распараллеливают, не ломая сигнатуру: запускаем M копий стадии на одном входе и сливаем их выходы через Merge из предыдущего вопроса.

func parallel[T, R any](ctx context.Context, in <-chan T, m int, f func(T) R) <-chan R {
    outs := make([]<-chan R, m)
    for i := 0; i < m; i++ {
        outs[i] = mapStage(ctx, in, f)   // все M читают один in: это fan-out
    }
    return Merge(ctx, outs...)           // и сливаются обратно: это fan-in
}

Порядок элементов после такой стадии теряется. Если он важен, либо не распараллеливай стадию, либо тащи порядковый номер внутри элемента и пересобирай на выходе.

Где обычно ошибаются

  • Стадия закрывает входной канал. Предыдущая стадия тут же паникует на записи. Правило: закрываю только то, что создал.
  • close(out) не через defer, а в конце горутины. Любой ранний return (по ctx, по ошибке) оставит канал незакрытым, и следующая стадия зависнет в range навсегда.
  • Общий буферизованный канал «на всякий случай». Буфер сдвигает момент блокировки, но утечку не убирает - она просто проявится на другом объёме данных.
  • cancel() без дренажа. Технически стадии выйдут сами, но если стадия держит ресурс (открытый файл, соединение), освобождать его надо в defer той же горутины, а не надеяться на GC.
  • Ошибки некуда девать. Стадия возвращает один канал значений, а error деть некуда. Решения два: канал struct{ Val T; Err error } (просто, работает) или отдельный errCh (сложнее в завершении). На собесе назови первый.

Добивки, которые задаст интервьюер

  • «Как передать ошибку через pipeline?» Тип элемента становится Result[T]{Val T; Err error}. Стадии пропускают элементы с ошибкой дальше без обработки, и ошибка доезжает до потребителя, не ломая структуру.
  • «Зачем done chan struct{}, если есть context?» Это исторический вариант из статьи «Go Concurrency Patterns: Pipelines» (2014), написанной до появления context в стандартной библиотеке. Механика идентична: закрытый канал делает <-done мгновенно готовым для всех читателей. context добавляет к этому дедлайны, значения и иерархию отмены.
  • «Сколько буферизовать между стадиями?» По умолчанию ноль. Буфер имеет смысл, если стадии неравномерны по времени и есть всплески: он сглаживает jitter. Но буфер = задержка в очереди и память; размер подбирай замером, а не на глаз.
  • «Что если стадия должна отдавать несколько элементов на один входной?» Ничего не меняется - просто for внутри for, каждая запись под select. Число элементов на входе и выходе стадии никак не связано.
  • «Как это тестировать?» Стадия работает как чистая функция от канала к каналу: подаёшь на вход закрытый канал с известными значениями, читаешь выход в слайс, сравниваешь. Плюс тест на отмену: cancel() сразу, проверяешь, что выходной канал закрылся, и goleak в TestMain.
Суть: начинаем с map под sync.Mutex - это правильный дефолт. RWMutex помогает только при долгих чтениях и подавляющем перевесе Get. sync.Map хорош ровно в двух сценариях (write-once-read-many и раздельные ключи по горутинам). Шардирование - единственное, что масштабируется линейно по ядрам, и именно его ждут в финале разговора.

Шаг 1. Базовый вариант: map + Mutex

type Cache[K comparable, V any] struct {
    mu sync.Mutex
    m  map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{m: make(map[K]V)}
}

func (c *Cache[K, V]) Get(k K) (V, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache[K, V]) Set(k K, v V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

func (c *Cache[K, V]) Delete(k K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.m, k)
}

// GetOrSet: атомарно «прочитать, а если нет, записать».
// Без неё кэш неправильный: Get+Set снаружи не атомарны.
func (c *Cache[K, V]) GetOrSet(k K, v V) (V, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if old, ok := c.m[k]; ok {
        return old, true             // true = уже было
    }
    c.m[k] = v
    return v, false
}
// c := New[string, int]()
// c.Get("a")          → 0, false
// c.Set("a", 1)
// c.Get("a")          → 1, true
// c.GetOrSet("a", 99) → 1, true    ← 99 не записалось, вернулось старое
// c.GetOrSet("b", 2)  → 2, false   ← false = «положили новое»
// c.Delete("a"); c.Get("a") → 0, false
Почему GetOrSet обязателен

Кэш с одними Get и Set формально потокобезопасен - гонки в нём нет. Но вызывающий напишет if _, ok := c.Get(k); !ok { c.Set(k, compute()) }, и это уже логическая гонка: между Get и Set вклинится другая горутина. Потокобезопасность отдельных методов не даёт потокобезопасности их комбинации - эту фразу на собесе стоит произнести дословно. Атомарной должна быть та операция, которая имеет смысл для вызывающего.

Шаг 2. RWMutex - и почему он не бесплатный

type RWCache[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}

func (c *RWCache[K, V]) Get(k K) (V, bool) {
    c.mu.RLock()                     // много читателей одновременно
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *RWCache[K, V]) Set(k K, v V) {
    c.mu.Lock()                      // писатель один
    defer c.mu.Unlock()
    c.m[k] = v
}

Интуиция «читателей много, значит RWMutex быстрее» верна не всегда. RLock атомарно инкрементит общий счётчик читателей, то есть пишет в одну и ту же кэш-линию из всех ядер. Получается cache line contention: строка постоянно перегоняется между ядрами в состоянии Modified. Если критическая секция короткая (а m[k] укладывается в десятки наносекунд), накладные расходы на этот пинг-понг сопоставимы с самой работой, и RWMutex проигрывает обычному Mutex.

СитуацияЧто быстрееПочему
Короткое чтение (m[k]), 2–4 ядраMutexменьше атомарных операций на вызов
Короткое чтение, 16+ ядеробычно всё ещё Mutex, а лучше - шардыRLock упирается в ту же кэш-линию
Долгое чтение (обход структуры, сериализация)RWMutexпараллельность окупает накладные расходы
Запись чаще ~10% операцийMutexписатель всё равно всех блокирует

Отвечай на собесе не «RWMutex быстрее», а «надо померить бенчмарком с -cpu=1,2,4,8, потому что выигрыш зависит от длины критической секции и числа ядер; для однострочного чтения из мапы я бы по умолчанию оставил обычный Mutex». Это отличает человека, который читал про RWMutex, от человека, который его профилировал.

Шаг 3. sync.Map - ровно два сценария

var m sync.Map                       // нулевое значение готово к работе

m.Store("k", 42)
v, ok := m.Load("k")                 // v имеет тип any, нужен type assertion
actual, loaded := m.LoadOrStore("k", 100)  // атомарный GetOrSet
m.Delete("k")
m.Range(func(k, v any) bool {        // снимок, не консистентный срез
    return true                      // false прерывает обход
})
// Что напечатает такая последовательность:
// m.Load("k")            → 42, true
// m.LoadOrStore("k", 100) → 42, true   ← ключ уже есть, 100 не записалось
// после m.Delete("k"):
// m.Load("k")            → <nil>, false

До Go 1.24 внутри sync.Map лежали две мапы: read (атомарная, только на чтение) и dirty под мьютексом, а промахи по первой копили счётчик, после которого вторая «повышалась» в первую. С Go 1.24 внутри другое - HashTrieMap, дерево из атомарно подменяемых узлов, - но профиль применимости остался прежним:

  • Выигрывает: ключ записали один раз и потом читают миллионы раз (реестры, метаданные, конфиг). Или так: разные горутины работают с непересекающимися наборами ключей - тогда contention неоткуда взяться.
  • Проигрывает: частые записи новых ключей без конкуренции - каждая выделяет новый узел дерева и упаковывает ключ и значение в any, то есть несколько аллокаций на запись. В одну горутину sync.Map медленнее обычной мапы под мьютексом: на чтении примерно вдвое, на записи новых ключей в разы - там нет конкуренции, и платить за неё незачем. Зато когда новые ключи пишут сразу несколько горутин, она обгоняет мапу под одним мьютексом: блокировка берётся на узел дерева, а не на всю мапу.
  • Минусы по эргономике: ключ и значение имеют тип any, то есть interface{}-боксинг, аллокации и приведение типов на каждом обращении; нет Len(); Range не даёт консистентного снимка.

Готовая формулировка: «sync.Map не "быстрая мапа", а структура под конкретный профиль. В документации так и написано: она оптимизирована под append-only и под disjoint key sets. Во всех остальных случаях я беру обычную мапу под мьютексом, а если упираюсь в блокировку - шардирую.»

Шаг 4. Шардирование - то, ради чего задавался весь вопрос

const shards = 256                   // степень двойки: маска вместо деления

type shard[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
    _  [32]byte                      // padding до 64 байт: шарды не делят кэш-линию
}

type Sharded[K comparable, V any] struct {
    s    [shards]*shard[K, V]
    hash func(K) uint64
}

func NewSharded[K comparable, V any](hash func(K) uint64) *Sharded[K, V] {
    c := &Sharded[K, V]{hash: hash}
    for i := range c.s {
        c.s[i] = &shard[K, V]{m: make(map[K]V)}
    }
    return c
}

func (c *Sharded[K, V]) shardFor(k K) *shard[K, V] {
    return c.s[c.hash(k)&(shards-1)]  // & вместо %: маска дешевле деления
}

func (c *Sharded[K, V]) Get(k K) (V, bool) {
    sh := c.shardFor(k)
    sh.mu.RLock()
    defer sh.mu.RUnlock()
    v, ok := sh.m[k]
    return v, ok
}

func (c *Sharded[K, V]) Set(k K, v V) {
    sh := c.shardFor(k)
    sh.mu.Lock()
    defer sh.mu.Unlock()
    sh.m[k] = v
}

// Len приходится собирать по всем шардам, и срез не атомарный.
func (c *Sharded[K, V]) Len() int {
    n := 0
    for _, sh := range c.s {
        sh.mu.RLock()
        n += len(sh.m)
        sh.mu.RUnlock()
    }
    return n
}
Один мьютекс на всю мапу vs шардирование: где стоят горутины Один Mutex: 4 горутины, работает одна, три спят G1 работает G2 ждёт G3 ждёт G4 ждёт sync.Mutex → map[K]V пропускная способность не растёт с числом ядер 4 шарда: ключи разошлись по хешу, все четверо работают G1 работает G2 работает G3 работает G4 работает shard[0] shard[1] shard[2] shard[3] каждый шард - свой мьютекс и своя кэш-линия Как ключ попадает в шард key "user:42" hash(key) = 0x…A7 h & (256-1) = 0xA7 = 167 shard[167] Шардов - степень двойки, поэтому индекс берётся маской &. Для беззнакового хеша компилятор и % свёл бы к ней сам. Цена: нет атомарного Len() и нет консистентного обхода - снимок «всего кэша» больше не бесплатен.
Шардирование. Идея одна: заменить одну точку сериализации на N независимых. Пока ключи распределены равномерно, вероятность попасть в один шард равна 1/N, и конкуренция падает во столько же раз. Платим глобальными операциями: Len, полный обход и «атомарный снимок» перестают быть дешёвыми и консистентными.

Сводная таблица: когда что выигрывает

РеализацияСильнаСлабаБрать, когда
map + Mutex простота, предсказуемость, любые составные операции одна точка сериализации на все ядра дефолт; до тех пор, пока профиль не показал contention
map + RWMutex параллельные долгие чтения атомарный счётчик читателей - общая кэш-линия чтение реально долгое и писателей мало
sync.Map read-mostly по существующим ключам, disjoint keys, параллельная запись новых ключей any-боксинг, нет Len, в одну горутину медленнее map + Mutex реестр/конфиг: записали один раз, читают всегда
шардирование линейный рост с числом ядер, работает и на записи нет глобальных атомарных операций, больше кода и памяти профиль показал, что мьютекс - узкое место

Где обычно ошибаются

  • Нет атомарного GetOrSet. Самая частая содержательная ошибка: кэш безопасен, а пользоваться им небезопасно.
  • defer забыт при раннем return. Мьютекс остаётся захваченным навсегда, и следующая же операция вешает всю программу.
  • Возврат внутреннего значения по ссылке. Если под V прячется *User, []byte или мапа, вызывающий получит доступ к тем же данным и изменит их вне мьютекса. Либо возвращать копию, либо документировать «значение read-only».
  • Мьютекс копируется вместе со структурой. Методы обязаны быть на указателе *Cache; при значении go vet ругнётся, а копия получит свой собственный мьютекс и всю защиту потеряет.
  • RWMutex «на всякий случай». Без бенчмарка это карго-культ; часто медленнее обычного мьютекса.
  • Шардирование по len(key) или первой букве. Неравномерный хеш даёт горячие шарды, и вся затея теряет смысл. Нужен нормальный хеш: maphash, xxhash, fnv. Короче всего maphash.Comparable(seed, key): дженерик-функция, хеширующая любой сравнимый ключ в uint64, без ручной сериализации в байты. В Go 1.27 в том же пакете появились maphash.Hasher и ComparableHasher - пара «хеш + равенство» для случаев, когда своей хеш-таблице нужно нестандартное сравнение ключей (например, строки без учёта регистра).
  • Вытеснения нет. Кэш без TTL и без ограничения размера течёт, просто не сразу. Спроси у интервьюера про политику вытеснения сам, до того как спросят тебя.

Добивки, которые задаст интервьюер

  • «Добавь TTL». Хранить struct{ val V; exp time.Time }, проверять срок на чтении (ленивое вытеснение) плюс фоновый time.Ticker, который раз в минуту чистит просроченное (активное). Без активной чистки память не освобождается для ключей, которые больше не запрашивают; без ленивой проверки читатель успеет увидеть протухшее.
  • «Добавь ограничение размера». Это уже LRU: мапа + двусвязный список, Get двигает элемент в голову, при переполнении выкидываем хвост. Разбирали в главе 1.2, там же ловушка с обновлением при Get.
  • «Как избежать cache stampede?» Когда популярный ключ протух, сотня горутин одновременно пойдёт его пересчитывать. Лечится singleflight (следующий вопрос) - все, кроме одной, ждут её результат.
  • «Сколько шардов брать?» Порядка 4–8 × GOMAXPROCS, округлённое вверх до степени двойки; на практике берут 128 или 256. Возьмёшь меньше, останется конкуренция; возьмёшь больше, вырастет память под мапы и просядет локальность.
  • «Зачем padding в структуре шарда?» False sharing: два мьютекса, попавшие в одну 64-байтную кэш-линию, заставляют ядра гонять эту линию друг у друга, даже когда шарды логически независимы. Padding до размера кэш-линии убирает эффект. Это ровно та деталь, после которой разговор о кэше обычно заканчивается в твою пользу.
  • «Кэш с фоновой горутиной - как ловить утечку?» Кэш с TTL заводит горутину-уборщика, и если у структуры нет Close(), эта горутина переживёт сам кэш. С Go 1.27 для таких случаев есть штатный профиль goroutineleak (в 1.26 он был за GOEXPERIMENT=goroutineleakprofile): он показывает горутины, заблокированные на примитивах, до которых из живого кода уже не дотянуться, то есть разбудить их некому. Именно так выглядит уборщик, который ждёт <-c.stop у выброшенного кэша, или горутина, пишущая результат в канал, читателя у которого больше нет. Берётся он как обычный профиль: curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'. Оговорка, которая покажет, что ты им пользовался: горутину, висящую на достижимом канале или на time.Ticker, профиль не покажет - формально её ещё можно разбудить; такие случаи по-прежнему ловятся глазами по /debug/pprof/goroutine?debug=2 и через go.uber.org/goleak в тестах.
  • «А в Go 1.24 sync.Map же переписали?» Да, реализацию заменили на HashTrieMap - на многих профилях она заметно быстрее старой, особенно на записи. Но общий совет не поменялся: типизированная мапа под мьютексом (или шарды) остаётся дефолтом, а sync.Map берут под её заявленные сценарии.
Суть: ведро ёмкости burst, в которое с частотой rate капают токены. Запрос забирает токен; нет токена - ждёт или получает отказ. Ведро копит «неиспользованное разрешение», поэтому пропускает всплески - этим token bucket и отличается от простого time.Sleep, который жёстко размазывает запросы во времени.
Token bucket: rate = 2 токена/сек, burst = 4 t 0 c 1 c 2 c 3 c 4 c 5 c токеновв ведре 4 - ведро полно, капать некуда пришли 3 запроса разом - забрали 3 токена, все прошли мгновенно за секунду накапало 2 → снова 3, к 3 с ведро полно запросы burst из 3 - прошёл сразу четыре забрали все токены, пятый ждёт 0.5 с капает 1 токен / 0.5 с Пока ведро не полно, каждые 1/rate секунд добавляется токен. Полное ведро переполнить нельзя - лишние токены отбрасываются, поэтому «накопить сутки простоя и выстрелить» не получится. Средняя скорость на длинной дистанции = rate. Мгновенная - до burst. Это и есть весь алгоритм.
Token bucket. Два параметра: rate задаёт устойчивую скорость, burst говорит, насколько сильно разрешено «выстрелить» после простоя. Потолок накопления (burst) обязателен: без него лимитер после долгой паузы пропустит любой залп и уничтожит защищаемый сервис.

Версия 1: на time.Ticker - то, что пишут на доске за две минуты

type TickerLimiter struct {
    tokens chan struct{}
    stop   chan struct{}
}

func NewTickerLimiter(rate int, burst int) *TickerLimiter {
    l := &TickerLimiter{
        tokens: make(chan struct{}, burst),  // ёмкость канала = burst
        stop:   make(chan struct{}),
    }
    for i := 0; i < burst; i++ {             // стартуем с полным ведром
        l.tokens <- struct{}{}
    }

    go func() {
        t := time.NewTicker(time.Second / time.Duration(rate))
        defer t.Stop()
        for {
            select {
            case <-t.C:
                select {
                case l.tokens <- struct{}{}: // капнули токен
                default:                     // ведро полно, токен теряется, и это правильно
                }
            case <-l.stop:
                return
            }
        }
    }()
    return l
}

func (l *TickerLimiter) Wait(ctx context.Context) error {
    select {
    case <-l.tokens:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (l *TickerLimiter) Allow() bool {        // неблокирующий вариант
    select {
    case <-l.tokens:
        return true
    default:
        return false
    }
}

func (l *TickerLimiter) Close() { close(l.stop) }
// l := NewTickerLimiter(5, 3)   // 5 токенов в секунду, ведро на 3
// Allow() пять раз подряд:  true true true false false  ← burst израсходован
// затем Wait() три раза:    прошли на 200ms, 400ms, 600ms, ровно 1/rate между токенами

Всё держится на вложенном select с default. Без него горутина тикера заблокируется на полном ведре, перестанет читать t.C, и весь лимитер встанет. Именно тут валятся чаще всего.

Слабое место версии на тикере: она держит горутину на каждый лимитер и не умеет в дробный rate («1 запрос в 3 секунды» ещё можно, а «2.5 запроса в секунду» уже неудобно). Для сотни лимитеров (по одному на клиента) это сто горутин, которые тикают, даже когда трафика нет.

Версия 2: ленивое пополнение - без горутины и без тикера

type Limiter struct {
    mu       sync.Mutex
    tokens   float64        // дробные токены: держим любой rate
    max      float64        // burst
    rate     float64        // токенов в секунду
    last     time.Time      // когда последний раз пересчитывали
}

func NewLimiter(rate float64, burst int) *Limiter {
    return &Limiter{
        tokens: float64(burst),
        max:    float64(burst),
        rate:   rate,
        last:   time.Now(),
    }
}

// refill вызывается под мьютексом: докапывает токены за прошедшее время.
func (l *Limiter) refill(now time.Time) {
    if now.After(l.last) {
        l.tokens += now.Sub(l.last).Seconds() * l.rate
        if l.tokens > l.max {
            l.tokens = l.max                 // ведро не переполняется
        }
        l.last = now
    }
}

// Allow: неблокирующая проверка, есть токен или нет.
func (l *Limiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()
    l.refill(time.Now())
    if l.tokens >= 1 {
        l.tokens--
        return true
    }
    return false
}

// Wait ждёт токен или отмены контекста.
func (l *Limiter) Wait(ctx context.Context) error {
    for {
        l.mu.Lock()
        l.refill(time.Now())
        if l.tokens >= 1 {
            l.tokens--
            l.mu.Unlock()
            return nil
        }
        // сколько ждать, пока капнет токен
        need := (1 - l.tokens) / l.rate
        l.mu.Unlock()

        t := time.NewTimer(time.Duration(need * float64(time.Second)))
        select {
        case <-t.C:                          // проснулись, пробуем ещё раз
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        }
    }
}
// l := NewLimiter(5, 3)   // 5 запросов в секунду, burst 3
// Allow() шесть раз подряд: true true true false false false
// затем Wait() три раза:    прошли на 200ms, 400ms, 600ms
// Числа устойчивы: в отличие от порядка горутин, тайминги здесь задаёт арифметика токенов.

Фокус в том, чтобы не хранить токены, а вычислять их по времени. Состояние укладывается в три числа, горутин нет вообще, лимитеров можно завести миллион (по одному на пользователя). Именно так устроен golang.org/x/time/rate, только там ещё резервирование (Reserve) и корректная работа с прошлым временем.

Цикл for в Wait стоит там не случайно: после сна нельзя просто выдать токен - за время сна его мог забрать кто-то другой. Проверять надо заново. Этот цикл забывают, и это вторая по частоте ошибка в задаче.

Token bucket vs leaky bucket vs sliding window

АлгоритмКак ведёт себяВсплескиГде применяют
Token bucketкопит разрешения, тратит по запросуразрешает до burstклиентские лимиты, API-квоты, x/time/rate
Leaky bucketочередь вытекает с постоянной скоростьюсглаживает: на выходе всегда ровный потокзащита downstream, шейпинг трафика
Fixed windowсчётчик на календарное окнодвойной залп на границе оконпростые счётчики в Redis (INCR + EXPIRE)
Sliding window logхранит метки времени запросовточный, но O(n) памяти на клиентатам, где точность важнее памяти

Разница token vs leaky в одной фразе: token bucket ограничивает вход и допускает всплеск, leaky bucket выравнивает выход и всплеск размазывает. Защищаешь чужой сервис с жёстким лимитом - бери token bucket с burst, равным его допуску. Свой медленный ресурс, которому вредны пики, прикрывай leaky.

Где обычно ошибаются

  • Нет default при пополнении. Тикер блокируется на полном ведре, лимитер умирает.
  • Нет ограничения burst. Ведро копит токены неограниченно; после часа простоя лимитер пропустит тысячи запросов залпом.
  • Нет цикла в Wait. После сна токен выдают без повторной проверки, и при конкуренции лимит нарушается.
  • time.Sleep вместо select с ctx. Запрос, который клиент уже отменил, продолжает занимать место в очереди.
  • Утечка тикера. time.NewTicker без defer t.Stop() живёт вечно вместе с горутиной. Для time.Timer в цикле та же история.
  • time.Now() вместо монотонного времени. В Go time.Now() содержит и монотонную часть, и Sub использует именно её, так что перевод системных часов ничего не сломает - но об этом стоит сказать вслух, знают немногие.
  • Один глобальный лимитер там, где нужен per-key. За «100 rps на пользователя» стоит мапа лимитеров с вытеснением по неактивности, иначе она растёт бесконечно.

Добивки, которые задаст интервьюер

  • «Сделай лимитер на каждого клиента». map[string]*Limiter под мьютексом (или шардированный кэш из прошлого вопроса) плюс фоновая чистка тех, кто не обращался N минут. Без чистки получишь утечку памяти на все IP интернета.
  • «А распределённо, на несколько инстансов?» Redis: INCR с EXPIRE для fixed window или Lua-скрипт для token bucket (нужна атомарность чтения и записи). Из готового есть redis-cell. Обязательно упомяни, что появляется сетевой round-trip на каждый запрос и вопрос «что делать, если Redis недоступен» (fail-open или fail-closed - решает продукт).
  • «Что вернуть клиенту при отказе?» HTTP 429 Too Many Requests плюс заголовок Retry-After, и по-хорошему X-RateLimit-Limit / -Remaining / -Reset. Ответ про заголовки сразу показывает практический опыт.
  • «Как это протестировать без реального ожидания?» Вынести источник времени в интерфейс (now func() time.Time) и подменить в тесте. Тесты, зависящие от time.Sleep, всегда флакают на CI.
  • «Чем твой лимитер отличается от x/time/rate?» Тем, что там есть Reserve() - резервирование токена из будущего с возможностью отмены, AllowN/WaitN для «тяжёлых» запросов и аккуратная обработка изменения rate на лету. В проде я бы взял его.
Суть: три разные семантики на одном каркасе. «Первый успешный» - буферизованный канал на N и cancel() после первого успеха. «Все результаты» - запись в out[i] по индексу и wg.Wait(). «Таймаут» - context.WithTimeout плюс возврат ctx.Err(). Общая деталь всех трёх: буферизованный канал ёмкости N, чтобы проигравшие горутины не зависли на записи.

(а) Первый успешный - паттерн «or»

type res struct {
    val string
    err error
}

// First возвращает результат первого успешного вызова.
// Если все упали, возвращает объединение ошибок.
func First(ctx context.Context, urls []string,
    do func(context.Context, string) (string, error)) (string, error) {

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                       // отменяет всех отставших при любом выходе

    ch := make(chan res, len(urls))      // буфер на всех: никто не зависнет на записи

    for _, u := range urls {
        go func(u string) {
            v, err := do(ctx, u)
            ch <- res{v, err}            // запись не блокируется: буфер вмещает всё
        }(u)
    }

    var errs []error
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-ch:
            if r.err == nil {
                return r.val, nil        // defer cancel() отменит остальных
            }
            errs = append(errs, r.err)
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
    return "", errors.Join(errs...)      // все N упали
}
// do: "a" падает через 30 мс, "b" отвечает через 10 мс, "c" через 50 мс.
// First(ctx, []string{"a","b","c"}, do) → "ответ от b", <nil>
//   Побеждает не первый в списке, а первый успевший, так что результат зависит
//   от таймингов, а не от порядка urls.
// Если падают все трое, errors.Join печатает их по одной на строку:
//   упал b
//   упал a
//   упал c
//   Это порядок прихода в канал: здесь он задан таймингами, но полагаться на него нельзя.
Почему именно make(chan res, len(urls))

Проверяют обычно именно эту строку. При небуферизованном канале функция вернётся после первого успеха, читателя не останется, и оставшиеся N−1 горутин навсегда зависнут на ch <- res{...}. Буфер на N дешевле и надёжнее всего: каждая горутина запишет результат и завершится, читает её кто-нибудь или нет. Можно вместо этого обернуть запись в select с ctx.Done(), но буфер проще и не зависит от аккуратности внутри горутины. Именно здесь интервьюер обычно спрашивает: «а если убрать буфер?»

(б) Все результаты, с сохранением порядка

func All(ctx context.Context, urls []string,
    do func(context.Context, string) (string, error)) ([]string, error) {

    out := make([]string, len(urls))     // длина фиксирована, реаллокаций нет
    errs := make([]error, len(urls))     // каждая горутина пишет в свой индекс

    var wg sync.WaitGroup
    wg.Add(len(urls))
    for i, u := range urls {
        go func(i int, u string) {
            defer wg.Done()
            out[i], errs[i] = do(ctx, u) // разные слоты, гонки нет, мьютекс не нужен
        }(i, u)
    }
    wg.Wait()                            // здесь happens-before: всё записанное видно

    return out, errors.Join(errs...)     // nil, если в errs одни nil
}
// do: "b" возвращает ошибку 503, остальные «ответ от ...»
// All(ctx, []string{"a","b","c"}, do) → ["ответ от a" "" "ответ от c"], 503
//   Слот упавшего остался нулевым, но соседи на месте: в этом и смысл «всех».
// Если ошибок нет:                   → ["ответ от a" "ответ от b" "ответ от c"], <nil>
// Порядок out всегда совпадает с порядком urls: каждый пишет в свой индекс.

wg.Wait() закрывает вопрос с видимостью: всё, что горутина записала до wg.Done(), гарантированно видно после wg.Wait() по модели памяти Go. Читать out после Wait безопасно, и мьютекс тут не нужен. Проговори это вслух: вопрос «а точно ли тут нет гонки?» прилетает почти всегда.

(в) Таймаут на всю операцию

func AllWithTimeout(parent context.Context, urls []string, d time.Duration,
    do func(context.Context, string) (string, error)) ([]string, error) {

    ctx, cancel := context.WithTimeout(parent, d)
    defer cancel()

    type indexed struct {
        i   int
        val string
        err error
    }
    ch := make(chan indexed, len(urls))  // снова буфер на всех

    for i, u := range urls {
        go func(i int, u string) {
            v, err := do(ctx, u)         // ctx уезжает внутрь: HTTP-клиент оборвёт запрос сам
            ch <- indexed{i, v, err}
        }(i, u)
    }

    out := make([]string, len(urls))
    var errs []error
    for got := 0; got < len(urls); got++ {
        select {
        case r := <-ch:
            if r.err != nil {
                errs = append(errs, fmt.Errorf("%s: %w", urls[r.i], r.err))
                continue
            }
            out[r.i] = r.val
        case <-ctx.Done():
            // Вышли по дедлайну. Горутины допишут в буфер и завершатся сами.
            return out, fmt.Errorf("получено %d из %d: %w", got, len(urls), ctx.Err())
        }
    }
    return out, errors.Join(errs...)
}

В комментарии не зря сказано: при выходе по таймауту мы не дренируем канал, и это корректно ровно потому, что он буферизован. Горутины допишут результаты в буфер, завершатся, а сам канал и буфер соберёт GC, когда на них не останется ссылок. Будь канал небуферизован, такой return означал бы N зависших горутин.

Всё то же самое на errgroup - как выглядит прод-версия

// (б) + (в) одной конструкцией: первая ошибка отменяет остальных, есть таймаут и лимит.
func AllEG(ctx context.Context, urls []string, d time.Duration,
    do func(context.Context, string) (string, error)) ([]string, error) {

    ctx, cancel := context.WithTimeout(ctx, d)
    defer cancel()

    g, ctx := errgroup.WithContext(ctx)  // ctx отменится при первой ошибке
    g.SetLimit(8)                        // не более 8 одновременно

    out := make([]string, len(urls))
    for i, u := range urls {
        i, u := i, u                     // до Go 1.22 обязательно
        g.Go(func() error {
            v, err := do(ctx, u)
            if err != nil {
                return fmt.Errorf("%s: %w", u, err)
            }
            out[i] = v
            return nil
        })
    }
    return out, g.Wait()                 // g.Wait() вернёт первую ошибку
}

Где обычно ошибаются

  • Небуферизованный канал в варианте «первый успешный». Главная ловушка задачи: N−1 горутин зависают навсегда.
  • return сразу после первого элемента без проверки err. Задача про первый успешный, а не первый пришедший. Если первым придёт мгновенная ошибка «connection refused», наивная версия вернёт её и не дождётся успеха.
  • Забыт cancel(). После возврата первого успеха остальные запросы продолжают жечь соединения и трафик. defer cancel() обязателен.
  • append в общий слайс из горутин. Гонка. Либо канал, либо запись по индексу.
  • Таймаут через time.After вместо context. Функция вернётся, а сами HTTP-запросы продолжат выполняться: никто их не оборвал. ctx прерывает работу, time.After лишь прекращает ожидание, и это совсем не одно и то же.
  • Возврат nil результатов при частичном успехе. Часто правильнее вернуть и то, что успели, и ошибку, а вызывающий решит сам. Спроси у интервьюера, какая семантика нужна: этот вопрос сам по себе засчитывается в плюс.

Добивки, которые задаст интервьюер

  • «А если нужен не первый успешный, а кворум - 2 из 3?» Тот же цикл, но считаем успехи и выходим при ok == quorum. Это quorum read из распределённых систем.
  • «Сделай hedged request». Отправляем первый запрос, ждём p95; если не ответил, шлём дубль в другую реплику и берём то, что придёт раньше. Приём из статьи «The Tail at Scale», и сам термин на собеседовании работает хорошо.
  • «Что если N - миллион?» Нельзя запускать миллион горутин: нужен SetLimit/семафор, иначе память и число открытых сокетов кончатся раньше результата.
  • «Как отличить таймаут от отмены клиентом?» errors.Is(err, context.DeadlineExceeded) против context.Canceled. Для HTTP-сервера это разные коды ответа: 504 и, как правило, 499/ничего.
  • «Гарантирует ли wg.Wait() видимость записей?» Да, это прописано в модели памяти Go: Done happens-before возврата из Wait. То же верно для отправки/приёма по каналу и для Unlock/Lock.
Суть: нужна не «синхронизация», а передача права хода - как эстафетная палочка. Два небуферизованных канала: odd и even. Каждая горутина ждёт свой канал, печатает, отдаёт ход в чужой. Единственная сложная часть задачи - корректно завершиться и не оставить вторую горутину висеть на пустом канале.

Решение на двух каналах

func printAlternate(n int) {
    odd := make(chan struct{})       // разрешение печатать нечётное
    even := make(chan struct{})      // разрешение печатать чётное
    done := make(chan struct{})      // сигнал «всё, закончили»

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {                      // нечётные
        defer wg.Done()
        for i := 1; i <= n; i += 2 {
            <-odd                    // жду свой ход
            fmt.Println(i)
            if i+1 > n {             // следующего чётного не будет
                close(done)          // будим соседа на выход
                return
            }
            even <- struct{}{}       // передаю ход
        }
    }()

    go func() {                      // чётные
        defer wg.Done()
        for i := 2; i <= n; i += 2 {
            select {
            case <-even:
            case <-done:             // сосед закончил: выходим, а не виснем
                return
            }
            fmt.Println(i)
            if i+1 > n {
                close(done)
                return
            }
            odd <- struct{}{}
        }
    }()

    odd <- struct{}{}                // даём старт: первый ход у нечётных
    wg.Wait()
}
// printAlternate(5) → 1 2 3 4 5 (каждое с новой строки)
// printAlternate(6) → 1 2 3 4 5 6
// Порядок здесь детерминирован, и в этом весь смысл: право хода передаётся явно,
// планировщику выбирать не из чего.

Тот же ответ короче: один канал с самим значением

Если разрешено передавать по каналу не «палочку», а само число, решение становится короче, а завершение через close выглядит естественно.

func printAlternate2(n int) {
    odd, even := make(chan int), make(chan int)
    var wg sync.WaitGroup
    wg.Add(2)

    worker := func(mine, next chan int, wantOdd bool) {
        defer wg.Done()
        for v := range mine {        // выходим по close: одна точка завершения
            fmt.Println(v)
            if v == n {
                close(next)          // закрываем свой выход: сосед выйдет из range
                return
            }
            next <- v + 1
        }
    }

    go worker(odd, even, true)
    go worker(even, odd, false)

    odd <- 1
    wg.Wait()
}
// printAlternate2(5) → 1 2 3 4 5;  printAlternate2(6) → 1 2 3 4 5 6
// Тот же строгий порядок, что и в варианте на «палочках».

Здесь close(next) формально нарушает правило «закрывает писатель», но тут это осознанный протокол между двумя строго чередующимися сторонами: в момент close сосед гарантированно стоит в range и в канал не пишет. Такой нюанс полезно проговорить: он показывает, что ты понимаешь, почему существует правило, а не просто его помнишь.

Вариант на sync.Cond - если спросят «а без каналов?»

func printCond(n int) {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    turn := 1                        // чей ход: 1 - нечётные, 0 - чётные
    var wg sync.WaitGroup
    wg.Add(2)

    run := func(parity int) {
        defer wg.Done()
        for i := parity; i <= n; i += 2 {
            if i == 0 {
                continue
            }
            mu.Lock()
            for turn != parity%2 {   // всегда for, не if: возможны ложные пробуждения
                cond.Wait()
            }
            fmt.Println(i)
            turn = 1 - turn
            cond.Broadcast()         // будим соседа
            mu.Unlock()
        }
        mu.Lock()                    // финальный Broadcast, чтобы сосед не остался в Wait
        cond.Broadcast()
        mu.Unlock()
    }

    go run(1)
    go run(2)
    wg.Wait()
}
// printCond(5) → 1 2 3 4 5;  printCond(6) → 1 2 3 4 5 6
// Порядок снова строгий: его держит переменная turn, а не планировщик.

for вместо if вокруг cond.Wait() требуется в любом языке с условными переменными. Wait может вернуться без изменения условия, поэтому условие перепроверяют в цикле.

Где обычно ошибаются

  • Забыт «стартовый толчок». Обе горутины ждут своего канала, никто не пишет, дедлок с первой же секунды: fatal error: all goroutines are asleep.
  • Нет корректного завершения. Самая частая ошибка: числа печатаются правильно, а потом программа падает с deadlock, потому что вторая горутина осталась висеть на <-even. Проверяют здесь именно завершение, остальное тривиально.
  • Буферизованные каналы. С буфером чередование ломается: горутина может уйти вперёд на несколько шагов. Здесь буфер обязан быть нулевым: он и есть механизм синхронизации, а не оптимизация.
  • Общий счётчик под мьютексом вместо очереди ходов. Мьютекс гарантирует взаимоисключение, но не порядок: одна и та же горутина может захватить его два раза подряд, и получится «1 3 5 … 2 4 6».
  • if вместо for вокруг cond.Wait(). Работает «почти всегда», ломается редко и невоспроизводимо.

Добивки, которые задаст интервьюер

  • «Сделай для K горутин: печатают 1..N по кругу». Кольцо из K каналов: горутина i читает ch[i], пишет в ch[(i+1)%K]. Красивое обобщение: покажи его сразу, и половина добивок отпадёт.
  • «Сколько раз произойдёт переключение контекста?» Порядка N: каждая передача хода через небуферизованный канал паркует одну горутину и будит другую. Это дорого, но задача учебная: в проде такой код не нужен.
  • «А через atomic и busy-wait?» Технически да: for atomic.LoadInt32(&turn) != mine { runtime.Gosched() }. Но это спин, который жжёт CPU. Назови такой вариант и сразу объясни, почему так не делают.
  • «Что произойдёт, если убрать wg.Wait()?» main завершится и убьёт процесс вместе с горутинами; вывод оборвётся на случайном числе. Наглядно видно: горутины не удерживают программу от выхода.
Суть: Mutex - канал ёмкости 1 (взять элемент = захватить). Once - канал ёмкости 1 с одним токеном плюс канал-сигнал «готово». WaitGroup - счётчик под мьютексом-каналом и канал, который закрывается при достижении нуля: закрытый канал делает <-done готовым сразу для всех ожидающих. Последнее - главный приём: close как broadcast.

Mutex на канале

type Mutex chan struct{}

func NewMutex() Mutex { return make(Mutex, 1) }   // ёмкость 1 = одно разрешение

func (m Mutex) Lock()   { m <- struct{}{} }       // занял слот, вошёл
func (m Mutex) Unlock() { <-m }                   // освободил слот

// Бонус, которого нет у sync.Mutex: неблокирующий захват и захват с таймаутом.
func (m Mutex) TryLock() bool {
    select {
    case m <- struct{}{}:
        return true
    default:
        return false
    }
}

func (m Mutex) LockCtx(ctx context.Context) error {
    select {
    case m <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Канальный мьютекс умеет больше (таймаут, отмена, TryLock до Go 1.18), но заметно медленнее. sync.Mutex в неконкурентном случае обходится одной атомарной CAS-операцией без системных вызовов; канал тащит за собой мьютекс рантайма и очередь ожидающих. Разница обычно в разы. Отсюда правило: канальный мьютекс берут ради семантики (отмена), а не ради скорости.

Once на каналах

type Once struct {
    token chan struct{}   // ровно один токен: достанется одному вызывающему
    done  chan struct{}   // закрывается после выполнения f
}

func NewOnce() *Once {
    o := &Once{
        token: make(chan struct{}, 1),
        done:  make(chan struct{}),
    }
    o.token <- struct{}{}         // единственный токен кладём заранее
    return o
}

func (o *Once) Do(f func()) {
    select {
    case <-o.token:               // выиграл гонку, выполняю
        defer close(o.done)       // defer: даже при панике в f ожидающие проснутся
        f()
    case <-o.done:                // уже выполнено, выходим сразу
    }
}
Тонкость, ради которой задача и задаётся

Контракт sync.Once сильнее, чем «выполнить один раз»: все вызовы Do возвращаются только после завершения f. То есть второй вызывающий обязан подождать, а не проскочить мимо. Наивная реализация через if atomic.CompareAndSwap(...) { f() } этот контракт нарушает: проигравший уходит немедленно и может обратиться к ещё не проинициализированному объекту. В решении выше за это отвечает ветка <-o.done: канал не закрыт, пока f не отработала, поэтому проигравший ждёт. Проговори это вслух: именно эту идею и проверяют.

WaitGroup на каналах

type WaitGroup struct {
    mu   chan struct{}   // мьютекс-канал вокруг счётчика
    n    int
    done chan struct{}   // закрывается, когда n достигает нуля
}

func NewWaitGroup() *WaitGroup {
    wg := &WaitGroup{
        mu:   make(chan struct{}, 1),
        done: make(chan struct{}),
    }
    return wg
}

func (wg *WaitGroup) Add(delta int) {
    wg.mu <- struct{}{}
    defer func() { <-wg.mu }()

    wg.n += delta
    if wg.n < 0 {
        panic("negative WaitGroup counter")   // как в оригинале
    }
    if wg.n == 0 {
        close(wg.done)                        // broadcast: будим всех ожидающих сразу
    }
}

func (wg *WaitGroup) Done() { wg.Add(-1) }

func (wg *WaitGroup) Wait() { <-wg.done }     // на закрытом канале приём мгновенный

func (wg *WaitGroup) WaitCtx(ctx context.Context) error {
    select {
    case <-wg.done:
        return nil
    case <-ctx.Done():
        return ctx.Err()                      // чего sync.WaitGroup не умеет
    }
}

Закрытие канала как broadcast стоит запомнить отдельно от этой задачи. Отправка в канал будит одного ожидающего, закрытие - всех. Именно поэтому done chan struct{} и ctx.Done() устроены через закрытие, а не через отправку значений. Спросят на собесе «как разбудить N горутин одним действием», отвечай: «закрыть канал».

У этой реализации есть ограничение: она одноразовая. Настоящий sync.WaitGroup можно переиспользовать после Wait (при аккуратном Add), а наш done закрыт навсегда: повторный Add до 1 и снова до 0 даст «close of closed channel». Назови ограничение сам, и станет видно, что ты сравнил свою реализацию с контрактом оригинала, а не просто написал что-то работающее.

Где обычно ошибаются

  • Once без ожидания. Проигравший гонку уходит до того, как отработает f. Самая содержательная ошибка в задаче.
  • close(done) без defer в Once. Паника внутри f оставит все остальные вызовы Do заблокированными навсегда. Кстати, реальный sync.Once ведёт себя так же в части «второй раз f не запустится»: паника считается выполнением.
  • Счётчик WaitGroup без защиты. wg.n += delta из нескольких горутин даёт гонку. Нужен либо канал-мьютекс, либо atomic.
  • Отправка в done вместо закрытия. Разбудит ровно одного ожидающего, остальные повиснут.
  • Мьютекс на структуре-значении. type Mutex chan struct{} работает и по значению, потому что канал это указатель на внутреннюю структуру рантайма. А вот struct { mu chan struct{}; n int } копировать уже нельзя: счётчик продублируется. Отсюда *WaitGroup в методах.

Добивки, которые задаст интервьюер

  • «Что быстрее - твой мьютекс или sync.Mutex?» sync.Mutex, почти на порядок на неконкурентном пути: там fast path сводится к одной атомарной операции без обращения к рантайму каналов.
  • «Сделай RWMutex на каналах». Заметно сложнее: нужен счётчик читателей, отдельный канал для писателя и правило, которое не даёт писателю голодать. Так и отвечай: «сделаю, но это уже 40 строк, и я бы взял sync.RWMutex».
  • «Почему sync.Mutex нельзя копировать?» Внутри лежит состояние (state, sema). Копия получает своё состояние, и два «мьютекса» перестают защищать общее. go vet ловит это правилом copylocks. И ещё: заблокированный мьютекс не привязан к горутине, Unlock может звать другая горутина, в отличие от, скажем, Java-синхронизации.
  • «Зачем тогда вообще каналы, если sync быстрее?» Каналы дают композицию: их можно положить в select с таймаутом и отменой. Формула «share memory by communicating» про передачу владения данными, а sync про защиту общего состояния. Это два разных инструмента, а не конкуренты.
  • «Что вернёт len() и cap() от твоего мьютекса?» cap всегда 1, len равен 1, если захвачен, и 0, если свободен. Забавный побочный эффект, полезный для метрик и бесполезный для логики (гонка).
Суть: merge sort идеально рекурсивно распараллеливается - две половины независимы. Но если запускать горутину на каждый рекурсивный вызов, их будет 2n, и накладные расходы на создание и планирование съедят весь выигрыш. Лечится двумя строками: порог по размеру (ниже него - последовательно) и ограничение глубины распараллеливания примерно до log₂(GOMAXPROCS).

Наивная версия - та, которую пишут первой и которая медленнее последовательной

func mergeSortNaive(a []int) []int {
    if len(a) <= 1 {
        return a
    }
    mid := len(a) / 2

    var left, right []int
    var wg sync.WaitGroup
    wg.Add(2)
    go func() { defer wg.Done(); left = mergeSortNaive(a[:mid]) }()   // горутина
    go func() { defer wg.Done(); right = mergeSortNaive(a[mid:]) }()  // и ещё одна
    wg.Wait()

    return merge(left, right)
}

Для слайса на 1 000 000 элементов это около двух миллионов горутин. Каждая берёт минимум 2 КБ стека (растущего), её надо зарегистрировать в планировщике, запарковать на wg.Wait() и разбудить. На нижних уровнях рекурсии горутина создаётся ради сортировки двух элементов: полезной работы наносекунды, накладных расходов сотни. Результат стабильно хуже обычного sort.Ints, иногда в разы.

Правильная версия: порог + ограничение глубины

const seqThreshold = 1 << 12    // ниже 4096 элементов только последовательно

func MergeSort(a []int) []int {
    // Глубина, до которой имеет смысл ветвиться: log2 от числа ядер, +1 про запас.
    maxDepth := bits.Len(uint(runtime.GOMAXPROCS(0)))
    buf := make([]int, len(a))   // один общий буфер на всю сортировку
    copy(buf, a)
    sortInto(buf, a, 0, maxDepth)
    return a
}

// sortInto сортирует src в dst; buf и dst меняются ролями на каждом уровне.
func sortInto(src, dst []int, depth, maxDepth int) {
    n := len(src)
    if n <= 1 {
        return
    }
    if n < seqThreshold || depth >= maxDepth {
        insertionOrStdSort(dst)   // мелкий кусок: без горутин и без рекурсии
        return
    }

    mid := n / 2
    if depth < maxDepth {
        var wg sync.WaitGroup
        wg.Add(1)
        go func() {                                   // одна новая горутина
            defer wg.Done()
            sortInto(dst[:mid], src[:mid], depth+1, maxDepth)
        }()
        sortInto(dst[mid:], src[mid:], depth+1, maxDepth)  // вторую половину в текущей
        wg.Wait()
    } else {
        sortInto(dst[:mid], src[:mid], depth+1, maxDepth)
        sortInto(dst[mid:], src[mid:], depth+1, maxDepth)
    }

    mergeInto(src[:mid], src[mid:], dst)
}

func mergeInto(l, r, dst []int) {
    i, j, k := 0, 0, 0
    for i < len(l) && j < len(r) {
        if l[i] <= r[j] {         // <= сохраняет стабильность сортировки
            dst[k] = l[i]
            i++
        } else {
            dst[k] = r[j]
            j++
        }
        k++
    }
    k += copy(dst[k:], l[i:])
    copy(dst[k:], r[j:])
}

Три отличия от наивной версии, каждое даёт заметный вклад:

  1. Одна горутина вместо двух на уровень: вторую половину сортирует текущая горутина. Это вдвое меньше горутин и, что важнее, работа остаётся на «горячем» ядре.
  2. Порог seqThreshold. Ниже него не ветвимся вообще. Тот же приём, что в pdqsort из стандартной библиотеки: там порог 12 элементов для insertion sort.
  3. Один общий буфер. Наивная версия аллоцирует новый слайс на каждом merge. Слияний ровно n−1, значит и аллокаций n−1: линейно, а не O(n log n), замер на n = 65 536 даёт 65 536 вызовов аллокатора. O(n log n) здесь про суммарный объём: 8,9 МБ на том же входе, и вот он и грузит GC. Версия с двумя буферами, которые меняются ролями, делает ровно одну аллокацию на всю сортировку.
Глубже, чем спросят: почему выигрыш всё равно далёк от ×N

Даже идеальный параллельный merge sort не даёт ускорения в число ядер. Причины три. Закон Амдала: последний merge обрабатывает весь массив и по природе последователен, уже он один ограничивает ускорение. Память: сортировка миллиона int упирается в пропускную способность памяти, а не в ALU; восемь ядер делят одну шину и один L3. Планировщик: work stealing в Go не бесплатен, и на мелких задачах горутина чаще мигрирует между P, теряя тёплый кэш. На 8 ядрах реально получить ×2.5–4, и это нормальный результат, а не провал. Фраза «параллельная сортировка упирается в память, а не в CPU, поэтому масштабируется сублинейно» звучит уверенно.

Где обычно ошибаются

  • Горутина на каждый рекурсивный вызов. Главная ошибка задачи; ответ «нужен порог» и есть то, что проверяют.
  • Аллокация на каждом слиянии. result := make([]int, 0, len(l)+len(r)) внутри merge выглядит невинно, но даёт n−1 аллокацию и O(n log n) выделенных байт: профиль превращается в сплошной GC.
  • Гонка на общем слайсе. Две горутины пишут в a[:mid] и a[mid:], и это безопасно (диапазоны не пересекаются). А вот запись в общий result без разделения диапазонов даёт гонку. -race её поймает.
  • Захват переменной цикла при попытке распараллелить итеративный вариант (bottom-up merge sort): до Go 1.22 все горутины получат последний диапазон.
  • Нестабильность. l[i] < r[j] вместо <= ломает стабильность сортировки. Мелочь, но интервьюер, который просил стабильную, это заметит.
  • Замер на маленьком массиве. На 1000 элементах параллельная версия проиграет всегда. Порог окупаемости обычно десятки-сотни тысяч элементов, и его надо назвать вслух.

Добивки, которые задаст интервьюер

  • «С какого размера параллельная версия начинает выигрывать?» Отвечай так: «замерил бы go test -bench с -cpu=1,2,4,8; по опыту порядка 10⁵ элементов, но зависит от типа элемента и от того, дорого ли сравнение».
  • «Как то же самое сделать для quicksort?» Так же: рекурсивно параллелим, но quicksort хуже балансируется (разбиение может быть неравным), поэтому там особенно важен порог. Зато quicksort работает in-place, ему не нужен буфер, и на практике он быстрее.
  • «Что в стандартной библиотеке?» sort.Slice и slices.Sort последовательны и используют pdqsort (с Go 1.19). Параллельной сортировки в stdlib нет: если нужна, берут внешние библиотеки или пишут сами.
  • «А если данные не влезают в память?» Внешняя сортировка: режем на куски по объёму RAM, сортируем каждый и пишем на диск, затем k-way merge через кучу. Тот же merge, только источниками служат файлы.
  • «Как распараллелить сам merge?» Есть параллельный merge через бинарный поиск медианы: делим оба отсортированных массива на пары кусков, которые можно сливать независимо. Сложно, но это правильный ответ на «а как обойти закон Амдала здесь».
Суть: мапа «ключ → идущий сейчас вызов» под мьютексом. Первый создаёт запись с done chan struct{} и идёт выполнять; остальные находят запись и ждут закрытия done, после чего читают уже готовый результат. Закрытие канала здесь снова работает как broadcast на всех ожидающих. Решает cache stampede.

Полная реализация

// call - один «полёт»: сколько бы горутин ни попросило ключ, вызов будет один.
type call struct {
    done chan struct{}    // закрывается, когда результат готов
    val  any
    err  error
    dups int              // сколько вызывающих присоединилось (для метрик)
}

type Group struct {
    mu sync.Mutex
    m  map[string]*call   // ленивая инициализация
}

// Do гарантирует: для одного key в один момент времени fn выполняется ровно один раз.
// Возвращает результат этого выполнения всем ожидающим.
func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
    g.mu.Lock()
    if g.m == nil {
        g.m = make(map[string]*call)
    }
    if c, ok := g.m[key]; ok {          // полёт уже идёт - присоединяемся
        c.dups++
        g.mu.Unlock()
        <-c.done                        // ждём закрытия канала
        return c.val, c.err, true       // читаем результат ПОСЛЕ закрытия - гонки нет
    }

    c := &call{done: make(chan struct{})}
    g.m[key] = c                        // публикуем полёт под тем же мьютексом
    g.mu.Unlock()

    g.doCall(c, key, fn)
    return c.val, c.err, c.dups > 0
}

func (g *Group) doCall(c *call, key string, fn func() (any, error)) {
    defer func() {
        if r := recover(); r != nil {   // паника не должна оставить ожидающих висеть
            c.err = fmt.Errorf("singleflight: panic: %v", r)
        }
        g.mu.Lock()
        if g.m[key] == c {              // после Forget тут может лежать уже чужой полёт
            delete(g.m, key)            // снимаем свой полёт до закрытия канала
        }
        g.mu.Unlock()
        close(c.done)                   // broadcast: будим всех ожидающих разом
    }()
    c.val, c.err = fn()
}

// Forget убирает ключ из мапы, не дожидаясь завершения:
// следующий вызывающий начнёт новый полёт, а текущий доработает для уже ждущих.
func (g *Group) Forget(key string) {
    g.mu.Lock()
    delete(g.m, key)
    g.mu.Unlock()
}
// 10 горутин одновременно зовут g.Do("top", load), где load спит 50 мс:
//   горутина 10: из БД err=<nil> shared=true
//   горутина  8: из БД err=<nil> shared=true
//   ... (все десять, порядок строк каждый раз разный)
//   реальных вызовов load: 1
// Вот это «1» и есть весь смысл singleflight. Обрати внимание: shared=true даже
// у той горутины, которая реально выполняла вызов - к моменту возврата dups уже 9.
Три детали, которые отличают правильную реализацию от «работает у меня»
  • Мьютекс отпускается до fn(). Если держать его на время вызова, никакой конкурентности не будет вообще: все ключи выстроятся в очередь за одним мьютексом. Запись в мапу и сам вызов живут в разных критических секциях.
  • Запись читается только после <-c.done. Это и есть синхронизация: закрытие канала happens-before возврата из приёма, значит c.val и c.err, записанные до close, гарантированно видны. Без этого -race сразу подсветит гонку на полях структуры.
  • delete до close, и оба - в defer. Если fn паникует, ожидающие обязаны проснуться, иначе они висят навсегда. А если удалить ключ после close, между этими операциями пришедшая горутина присоединится к уже завершённому полёту и получит устаревший результат.

Версия с context и DoChan

У базового Do есть неприятное свойство: ожидающий не может отменить ожидание. Если fn зависла на 30 секунд, все присоединившиеся зависли вместе с ней, даже если их собственные ctx уже отменены. Настоящий golang.org/x/sync/singleflight решает это через DoChan, который возвращает канал вместо значения.

type Result struct {
    Val    any
    Err    error
    Shared bool
}

func (g *Group) DoChan(key string, fn func() (any, error)) <-chan Result {
    ch := make(chan Result, 1)          // буфер 1: отправитель никогда не блокируется
    go func() {
        v, err, shared := g.Do(key, fn)
        ch <- Result{v, err, shared}
    }()
    return ch
}

// Теперь вызывающий может ждать с отменой:
func fetch(ctx context.Context, g *Group, key string) (any, error) {
    select {
    case r := <-g.DoChan(key, func() (any, error) { return loadFromDB(key) }):
        return r.Val, r.Err
    case <-ctx.Done():
        return nil, ctx.Err()           // мы ушли, но полёт продолжается для остальных
    }
}

Буфер 1 в ch - обязателен. Без него горутина-обёртка зависнет навсегда, если вызывающий ушёл по ctx.Done(). Та же логика, что в задаче «первый успешный»: буфер снимает зависимость от того, читает ли кто-нибудь результат.

Зачем это нужно на самом деле: cache stampede

Ключ "top_products" лежит в Redis с TTL 60 секунд и запрашивается 5000 раз в секунду. В момент истечения TTL все 5000 запросов одновременно промахиваются и идут в PostgreSQL за одним и тем же тяжёлым агрегатом. База ложится, ответы замедляются, промахов становится ещё больше - классический каскад. Singleflight превращает эти 5000 обращений в одно: 4999 горутин просто ждут результата первой и получают его копию.

func (s *Service) TopProducts(ctx context.Context) ([]Product, error) {
    if v, ok := s.cache.Get("top_products"); ok {
        return v.([]Product), nil
    }
    v, err, _ := s.sf.Do("top_products", func() (any, error) {
        ps, err := s.db.QueryTopProducts(ctx)   // в БД идёт ровно один запрос
        if err != nil {
            return nil, err
        }
        s.cache.Set("top_products", ps, time.Minute)
        return ps, nil
    })
    if err != nil {
        return nil, err
    }
    return v.([]Product), nil
}

Второй классический приём против stampede - вероятностное раннее обновление: обновлять значение не в момент истечения TTL, а чуть раньше, с вероятностью, растущей по мере приближения к сроку. Тогда лавина не собирается в одну точку времени. Упомянуть оба подхода (singleflight + jitter/early recompute) - сильный ответ.

Где обычно ошибаются

  • Мьютекс удерживается на время fn(). Полная сериализация всех ключей - именно то, чего мы избегали.
  • Нет defer вокруг delete+close. Паника или ранний return в fn оставляют ключ в мапе навсегда: все последующие запросы этого ключа виснут вечно. Отладить такое в проде крайне неприятно.
  • Чтение c.val до <-c.done. Гонка.
  • Ключ не удаляется после завершения. Тогда singleflight превращается в кэш без TTL - и первый результат, включая ошибку, навсегда прилипает к ключу.
  • Забыт буфер в DoChan. Утечка горутины на каждый отменённый вызов.
  • Одна ошибка отдаётся всем. Это осознанный компромисс: если fn упала, все 5000 ожидающих получат один и тот же err. Обычно это желаемое поведение (не добивать упавшую базу), но сказать об этом вслух надо - иначе прозвучит вопрос «а справедливо ли это?».

Добивки, которые задаст интервьюер

  • «Чем это отличается от мьютекса на ключ?» Мьютекс на ключ сериализует N вызовов: они выполнятся по очереди, N раз. Singleflight выполняет один вызов и раздаёт результат - это принципиально другая семантика.
  • «Что если fn зависла?» Все присоединившиеся зависли с ней. Отсюда DoChan + ctx у вызывающего и обязательный таймаут внутри самой fn.
  • «Зачем нужен Forget?» Для случаев, когда полёт «залип» или когда нужно принудительно обновить данные, не дожидаясь текущего вызова. В x/sync он тоже есть.
  • «А работает ли это между инстансами сервиса?» Нет, singleflight - процессная дедупликация. Для распределённого варианта нужен распределённый лок (Redis SET NX с TTL) - и вместе с ним весь набор проблем: истёкший лок, split-brain, необходимость fencing token.
  • «Есть ли тут утечка памяти?» Мапа растёт только на время активных полётов и сама очищается через delete. Если ключей много, но полёты короткие - мапа остаётся маленькой. Утечка возможна только при незакрытом полёте, что мы закрыли через defer.

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

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

Частые ошибки

  • Нельзя обещать стабильный порядок результатов от параллельных worker без дополнительной сортировки.
  • Закрытие входного канала не отменяет автоматически горутины, заблокированные на другом канале.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай worker pool

Какой набор значений выдаст программа? Обязан ли порядок быть 2, 4, 6, 8?

package main
import ("fmt"; "sync")
func main() {
 jobs := make(chan int, 4); results := make(chan int, 4)
 var wg sync.WaitGroup
 for w:=0; w<2; w++ { wg.Add(1); go func(){ defer wg.Done(); for n:=range jobs { results <- n*2 } }() }
 for _,n:=range []int{1,2,3,4} { jobs <- n }; close(jobs)
 go func(){ wg.Wait(); close(results) }()
 for n:=range results { fmt.Println(n) }
}
Ответ и разбор

Программа выдаст значения 2, 4, 6 и 8 ровно по одному разу, но порядок между worker не определён: он зависит от планирования горутин. Канал jobs закрывает producer после отправки, results закрывается после завершения обоих worker, а потребитель читает до закрытия.

Код-ревью

2. Не оставляй worker заблокированным

Если потребитель отменил работу и перестал читать results, worker может навсегда зависнуть на отправке. Добавь обработку ctx cancellation.

results <- value
Ответ и разбор

Обычная отправка блокируется, если в канале нет места и читателя больше нет. Используй select: select { case results <- value: case <-ctx.Done(): return }. Отмена должна доходить до worker, а вызывающий код - дождаться их завершения, иначе утечка просто переместится в другой участок.

Применение

3. Определи владельца close

Producer отправляет задания, два worker читают jobs. Кто закрывает jobs и кто закрывает results, если каждый worker может отправить несколько результатов?

Ответ и разбор

Producer закрывает jobs после последней отправки, потому что он знает, что новых заданий не будет. Ни один worker в одиночку не может безопасно закрыть results, пока другие ещё пишут. Координатор ждёт общий WaitGroup и только затем закрывает results.

Применение

4. Сохрани ошибку и останови работу

Один worker получил фатальную ошибку, остальные продолжают читать задания. Назови последовательность действий, которая отменит работу и дождётся завершения всех горутин.

Ответ и разбор

Запиши первую ошибку под синхронизацией, вызови cancel общего context, заставь producer и workers проверять Done на отправке/получении и при работе, затем дождись WaitGroup. После этого закрой результирующий канал владельцем-координатором и верни сохранённую ошибку. Выбранную политику ошибок зафиксируй: здесь отменяем весь pool при первой фатальной.