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()
}
- Небуферизованный канал задач. Буфер тут не нужен: он не ускоряет, а лишь позволяет продюсеру убежать вперёд. На собесе скажи «буфер оптимизирует под конкретный профиль нагрузки, по умолчанию беру 0, потому что так проще рассуждать о завершении».
- Канал результатов отдельный. Совмещать вход и выход в один канал почти всегда ошибка проектирования.
wg.Add(workers)до цикла, а неwg.Add(1)внутри горутины. Внутри горутины получается гонка:wg.Wait()может успеть отработать раньше первогоAdd.for rangeпо каналу. Самый лаконичный способ выйти по закрытию. Не нужен ниok, ни лишнийselect.- Запись в
resultsподselectсctx.Done(). Это и есть та самая деталь, которую ищут. Без неё воркер повиснет навсегда, если потребитель ушёл по таймауту. - Продюсер в отдельной горутине. Если писать в
tasksиз основной, то при небуферизованном канале и медленных воркерах основная встанет - а нам ещё читатьresults. Дедлок. - Сторож. Писателей в
resultsмного, поэтому закрывает не воркер, а отдельная горутина послеwg.Wait(). Единственная законная причина для этой горутины. - Дренаж. Читаем до закрытия канала. Именно это гарантирует, что ни один воркер не останется висеть на записи.
tasks один, поэтому закрывает его сам. Писателей в results трое, поэтому
закрывает отдельный сторож после wg.Wait(). Результаты собирает основная горутина:
конкурентного доступа там нет, значит нет и гонки.Не начинай печатать сразу. Три-четыре фразы до кода стоят дороже, чем идеальный код без слов. Работающая последовательность:
- Контракт. «На входе слайс задач и число воркеров, на выходе все результаты и ошибка. Уточню: нужно ли остановиться на первой ошибке или собрать все? Порядок результатов важен?» Это два вопроса, которые интервьюер ждёт, и почти никто их не задаёт.
- Схема. «Делаю канал задач, N воркеров читают из него, пишут в канал результатов.
Продюсер закрывает задачи, отдельная горутина после
wg.Wait()закрывает результаты.» Одной фразой ты уже показал, что знаешь про владение каналом. - Опасные места, до того как их найдут. «Запись в результаты обязательно под
selectсctx.Done(), иначе при раннем выходе читателя воркеры утекут.» - Пишешь код и комментируешь только неочевидное. Не озвучивай
for i := 0; i < n; i++. Скажи лучше, почемуwg.Addстоит до цикла. - После кода проверь себя вслух. «Проверю на утечки: продюсер выходит по
ctxили по концу слайса, воркеры по закрытиюtasks, сторож поwg.Wait(). Прогнал быgo test -raceи тест сgoleak.»
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, потому что это ровно этот код, но оттестированный». Так закрыты обе
стороны: и «умеет руками», и «не изобретает велосипед на работе».
Вопросы
11wg.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
}
После 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. Это не косметика:
направленный тип на уровне компилятора запрещает вызывающему писать в канал и закрывать его.
Половина проблем с «кто закрыл канал» решается именно типом. Скажи это вслух: деталь
уровня «писал такое в проде».
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?» Компилятор запретит вызывающему писать и закрывать. Владение выражено типом, а не комментарием.
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
- Каждая стадия владеет только своим выходным каналом. Она его создаёт, пишет в
него и закрывает. Входной канал она только читает и никогда не закрывает. Поэтому
завершение идёт само: закрылся вход - вышел
range- сработалdefer close(out)- закрылся выход следующей стадии, и так до конца. - Однородная сигнатура.
func(ctx, <-chan In, ...) <-chan Out. Стадии свободно складываются друг с другом: их можно переставлять, добавлять, оборачивать вMerge, чтобы распараллелить одну стадию. ctxв каждой стадии. Каскад закрытия работает слева направо, но останавливаться досрочно приходится справа налево - потребитель решил, что хватит. Канала данных для этого нет, нужен отдельный сигнал:ctx(раньше писалиdone chan struct{}: та же идея, только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
}
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, который жёстко размазывает запросы во времени.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()вместо монотонного времени. В Gotime.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на лету. В проде я бы взял его.
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:Donehappens-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, если свободен. Забавный побочный эффект, полезный для метрик и бесполезный для логики (гонка).
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:])
}
Три отличия от наивной версии, каждое даёт заметный вклад:
- Одна горутина вместо двух на уровень: вторую половину сортирует текущая горутина. Это вдвое меньше горутин и, что важнее, работа остаётся на «горячем» ядре.
- Порог
seqThreshold. Ниже него не ветвимся вообще. Тот же приём, что вpdqsortиз стандартной библиотеки: там порог 12 элементов для insertion sort. - Один общий буфер. Наивная версия аллоцирует новый слайс на каждом
merge. Слияний ровно n−1, значит и аллокаций n−1: линейно, а не O(n log n), замер на n = 65 536 даёт 65 536 вызовов аллокатора. O(n log n) здесь про суммарный объём: 8,9 МБ на том же входе, и вот он и грузит GC. Версия с двумя буферами, которые меняются ролями, делает ровно одну аллокацию на всю сортировку.
Даже идеальный параллельный 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 при первой фатальной.