---
title: "Асинхронные операции"
description: "Асинхронные endpoint-ы: 202 Accepted и ID задачи, очередь на канале, worker pool, backpressure, идемпотентность и дренаж пула при остановке."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/21-async/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Асинхронные операции

*Курс «go-kit: микросервисный тулкит», урок 21 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/21-async/*

До этого урока каждый вызов в stringsvc жил по одному закону: пришёл вопрос — верни ответ, — и закон был честным: Uppercase из урока 03 и Count успевают за миллисекунды. Но сервисы делают и долгую работу: импорт тысячи строк, экспорт отчёта, пересчёт кэша. Синхронный ответ на такую работу ломается о три вещи: таймаут клиента из урока 04, дренаж из урока 19, у которого p99 вдруг стал минутным, и ретраи lb.Retry из урока 11, множащие нагрузку. Асинхронность go kit не приносит из коробки — и не должна: библиотека даёт endpoint'ы и middleware, а очередь и пул — канонные конструкции Go, которые ты сегодня поставишь рядом с сервисом. Асинхронный endpoint — это контракт, а не горутина: принять запрос, поставить задачу, вернуть 202 Accepted с ID и уйти опрашивать статус. Фоновые задачи go-сервиса получат дом: буферизованный канал, worker pool с N воркерами, backpressure с честным 503 и дренаж, вписанный в секвенцию остановки из урока 19.

### Почему долгая работа ломает синхронный ответ

Посчитай на пальцах, что происходит с операцией на две минуты, оставленной в синхронном endpoint'е. Клиент держит соединение: его таймаут из урока 04 истечёт первым — ответ уйдёт в никуда, работа продолжится зря. Балансировщик посчитает инстанс живым, но занятым: соединения копятся, файловые дескрипторы тают, за пределом — ошибка accept. lb.Retry из урока 11 увидит таймаут и отправит вызов соседу — второй инстанс начнёт ту же двухминутную работу: ретраи, созданные для дребезга, умножают тяжёлую работу. Graceful shutdown из урока 19 будет ждать этой работы до своего бюджета — GracePeriod в конфигурации рассчитан на p99 в секундах, а не на минуты. Правило простое: если операция дольше нескольких секунд или результат нужен не немедленно, синхронный контракт не подходит — не потому, что HTTP так не умеет, а потому, что каждый механизм надёжности из курса работает против длинного ответа. Асинхронный контракт разрубает узел: быстрый ответ «принято», работа отдельно, результат по готовности.

### Канон: задача вместо результата

Канон stringsvc не ломается: Uppercase остаётся мгновенным, Count — дешёвым. Рядом появляется операция, честно названная долгой: UppercaseAll — верхним регистром целую пачку строк, с прогрессом и результатом, на который нельзя сослаться до готовности. Контракт асинхронного вызова — тройка договорённостей. Первая: ответ 202 Accepted — «работа принята, но не сделана»; не 200, потому что результата в ответе нет, и не 201, потому что нового ресурса пока тоже нет. Вторая: в ответе ID задачи и путь статуса — Location: /jobs/{id}, чтобы клиент ушёл опрашивать туда. Третья: Retry-After, когда очередь занята или когда хочется вежливо подсказать интервал опроса. Клиент, который не понял 202 и ждёт результат в теле, — признак плохого API-дока, а не плохого кода. И ещё одна честность: 202 — обещание «поставил», а не «сделаю»: ошибка исполнения всплывёт в статусе failed, и API статуса обязана уметь об этом сказать.

### Endpoint, который принимает работу

```go
// endpoint-слой из урока 17: тот же кит, другой контракт
func makeUppercaseAllEndpoint(svc StringService, queue *Queue) endpoint.Endpoint {
        return func(ctx context.Context, request interface{}) (interface{}, error) {
                req := request.(uppercaseAllRequest)
                if req.Input == "" {
                        return nil, ErrEmpty // канон ошибки из урока 03
                }
                job := newJob(req.Input)
                if err := queue.Put(ctx, job); err != nil {
                        return nil, err // ErrBusy уйдёт в 503 — блок ниже
                }
                return uppercaseAllResponse{
                        JobID:   job.ID,
                        Status:  "queued",
                        PollURL: "/jobs/" + job.ID,
                }, nil
        }
}

// transport-слой: 202 Accepted вместо 200
func encodeUppercaseAllResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
        w.Header().Set("Location", response.(uppercaseAllResponse).PollURL)
        w.Header().Set("Retry-After", "2")
        w.WriteHeader(http.StatusAccepted) // 202 — работа принята
        return json.NewEncoder(w).Encode(response)
}
```

Endpoint-слой не изменился по форме: та же функция endpoint.Endpoint, тот же кит kithttp.NewServer из урока 04 — изменился контракт: вместо результата возвращается ссылка на будущее. Put — единственная дорога к очереди: endpoint не знает ни про канал, ни про воркеров, он знает про интерфейс Queue — это позволит в тестах из урока 16 подставить фейковую очередь. Порядок кода ответа задаётся в encode-функции: сначала заголовки, потом WriteHeader(202), потом тело — после WriteHeader заголовки уже не изменить.

### Очередь в процессе: буферизованный канал

Первая очередь, которую стоит построить, — уже в языке: make(chan *Job, capacity). Буферизованный канал — честная очередь с вместимостью: отправители кладут, пока есть свободные слоты, воркеры разбирают. Capacity — не магия, а поле Config из урока 18: он определяет, сколько всплеска готов сервис поглотить, и напрямую связан с памятью процесса — каждая задача в буфере живёт в куче целиком. Право отправки имеет только endpoint через Pool.Put — канал приватный, владелец один, и это убивает половину ошибок с гонками. Поиск «go-kit async» не выдаст очереди — асинхронность go kit не решается библиотекой сознательно: kit даёт endpoint'ы и middleware, исполнение задач — стандартный Go: канал, select, sync.WaitGroup. Такую конструкцию проще всего недооценить: in-memory очередь живёт в процессе и умирает с процессом — для задач, потерю которых пережить нельзя, дорога ведёт к внешнему брокеру, но до этого места — канала, пула и секвенции остановки — канал закрывает большинство потребностей.

### Pool: воркеры, range и дренаж

```go
type Pool struct {
        mu        sync.Mutex
        accepting bool
        jobs      chan *Job
        wg        sync.WaitGroup
        status    *StatusStore
}

func NewPool(workers, capacity int, status *StatusStore) *Pool {
        p := &Pool{
                accepting: true,
                jobs:      make(chan *Job, capacity),
                status:    status,
        }
        for i := 0; i < workers; i++ {
                p.wg.Add(1)
                go func() { // воркер живёт, пока канал не закрыт и буфер не пуст
                        defer p.wg.Done()
                        for job := range p.jobs {
                                p.status.Run(job) // queued -> running
                                p.status.Finish(job, run(job))
                        }
                }()
        }
        return p
}
```

Пул — это N одинаковых горутин над одним каналом, и больше ничего. Горутина-воркер одна на N задач, а не одна на запрос: ограничение параллелизма встроено в конструкцию, всплеск запросов не плодит горутины. range по каналу — готовый дренаж: когда канал закрыт, цикл добирает всё, что осталось в буфере, и только тогда выходит; то же поведение мы использовали в уроке 19 для батчера. defer p.wg.Done() первым делом горутины — счётчик уйдёт в ноль при любом выходе, включая панику; саму панику внутри run() всё равно перехватывает recover — warn-блок ниже.

### Put и Close: очередь под защитой мьютекса

```go
var ErrBusy = errors.New("queue full")
var ErrShuttingDown = errors.New("queue closed for new jobs")

func (p *Pool) Put(_ context.Context, j *Job) error {
        p.mu.Lock()
        defer p.mu.Unlock()
        if !p.accepting {
                return ErrShuttingDown // остановка началась — новых не берём
        }
        select {
        case p.jobs <- j:
                p.status.Queued(j)
                return nil
        default:
                return ErrBusy // очередь полна: backpressure, блок ниже
        }
}

func (p *Pool) Close() {
        p.mu.Lock()
        p.accepting = false
        close(p.jobs) // после последнего Put: воркеры доторкнут буфер
        p.mu.Unlock()
        p.wg.Wait() // заблокирует, пока последний воркер не выйдет
}
```

Мьютекс делает Put и Close атомарными относительно друг друга: либо Put успел отправить до close — и никакой паники, либо Close сработал первым — и Put видит accepting=false. Убери мьютекс — получишь классическую гонку «проверил флаг, а канал уже закрыт»: панику send on closed channel в лучшем запросе дня. Select с default — детектор переполнения: send в полный канал без default блокировался бы, превращая endpoint в точку затора; с default он мгновенно возвращает ErrBusy. Close повторяет секвенцию урока 19 в миниатюре: сначала запрет входа, потом close, потом wg.Wait — «новое не принимать, старое доделать».

### Backpressure: что делать, когда очередь полна

Очередь полна — это не авария, а честный ответ системы о её границе. Три стратегии, от честной к лживой. Отказ: Put вернул ErrBusy, endpoint транслирует его в 503 с Retry-After — клиент знает, что делать: подождать и повторить; код ошибки закрепи в codeFrom из урока 04, чтобы транспорт не превращал её в 500. Ожидание: send с таймаутом — запрос ждёт свободный слот ограниченное время; это сглаживает короткие всплески, но держит соединения — и работает только вместе с дедлайнами из урока 04, иначе снова копим висяки. Молчаливое отбрасывание: 202 при выброшенной задаче — худший вариант, обещание, которое не исполнят; допустимо только для фактов, чья потеря естественна (сэмплы, метрики), но не для пользовательских задач. Связи с курсом: rate limiter из урока 09 — первый шлагбаум, он отклоняет избыточный вход раньше очереди; circuit breaker из урока 10 защищает уже воркеров от падающей зависимости. Capacity из конфигурации — буфер на всплеск, а не склад бесконечности.

### Статусы задач и идемпотентность

Задача без статуса — контракта нет: клиент получил ID и теперь требует правду. StatusStore — map с RWMutex и четырьмя состояниями: queued (в буфере), running (воркер взял), done (результат готов), failed (ошибка с текстом). GET /jobs/{id} — обычный endpoint того же сервиса: makeStatusEndpoint, тот же kithttp.NewServer, тот же mux из урока 17; по готовности — результат или ссылка на него. Время жизни завершённых задач ограничено: джанитор раз в час вычищает старьё, иначе map станет утечкой памяти, которую найдут не сразу. Вторая половина контракта — идемпотентность принятия. Клиент не узнал, дошёл ли 202 (обрыв сети), повторил запрос — и получил вторую задачу с двойной работой. Лечится ключом идемпотентности: клиент присылает Idempotency-Key, endpoint ищет задачу с таким ключом и возвращает существующую вместо новой. Для повторных отправок 202 становится скучно безопасным: хоть сто раз — одна работа.

### Фоновые задачи без запроса

Не всякая фоновая задача отвечает чужому запросу: джанитор статусов из блока выше, прогрев кэша по расписанию, флаш батчей из урока 19, обновление статистики для /readyz из урока 20, ротация токенов. Механика одна и та же — и канонически минимальная: горутина-тикер с time.NewTicker, select из двух каналов — тик и остановка, — завершение по close с sync.WaitGroup. Три дисциплины. Одна горутина на одну периодическую задачу: не плодим планировщик-фреймворк там, где нужен один тикер. Паники перехватываем: падение тикера не должно ронять процесс — defer с recover и логом из урока 06. И работа тикера — тоже оценка для readiness: если джанитор перестал бежать, глубина map растёт, и это видно в метриках из урока 07 раньше, чем OOM. Если периодических задач становится много с разными расписаниями — вот тут первый раз оправдан cron-пакет вроде robfig/cron; но его горутины останавливаются тем же close, что и самодельный тикер.

### Остановка: где дренаж пула в секвенции

Пул встраивается в секвенцию урока 19 как ещё одна ступень между дренажом транспорта и закрытием зависимостей — и порядок не случаен. Пока srv.Shutdown дожидается активных запросов, клиентские 202 всё ещё законны: endpoint может класть задачи в очередь, значит, очередь закрывать рано. После дренажа HTTP новых входов нет — закрываем вход очереди: Pool.Close() ставит accepting=false, делает close(jobs), и воркеры добирают остаток буфера; wg.Wait() не пускает процесс дальше, пока последняя задача не найдёт свой статус. Только после этого — tracer.Close() и repo.Close(): задачи, доработавшие в дренаже, успеют записать результаты и спаны. Честное ограничение: если бюджет дренажа истёк, а буфер велик — недоработанные задачи пропадут, in-memory честно at-most-once; если потеря недопустима, интерфейс Queue из блока выше — то место, где in-memory реализация заменяется персистентной, а весь код endpoint'ов не меняется ни на строку.

### Пул в секвенции остановки

```go
case <-ctx.Done(): // сигнал из урока 19
        logger.Log("during", "shutdown", "msg", "signal received")

        registrar.Deregister() // 1. из ротации (урок 12)
        shutCtx, cancel := context.WithTimeout(context.Background(), cfg.GracePeriod)
        defer cancel()
        if err := srv.Shutdown(shutCtx); err != nil { // 2. дренаж HTTP
                logger.Log("during", "shutdown", "transport", "http", "err", err)
        }

        queue.Close() // 3. вход очереди закрыт, буфер доторкнут воркерами

        tracer.Close() // 4. спаны доработавших задач
        repo.Close()
        return nil
```

Ступень queue.Close() стоит между дренажом транспорта и телеметрией — и у этой позиции два объяснения. До HTTP-дренажа закрывать нельзя: активные запросы ещё ставят задачи, получишь либо панику, либо отказ на законном вызове. После tracer.Close() закрывать поздно: задачи, доторкавшие в дренаже, не оставят следов. Заметь, что таймаут GracePeriod теперь работает и на очередь: длинный буфер при коротком бюджете — дренаж оборвётся по DeadlineExceeded из srv.Shutdown, а queue.Close() всё равно добьёт остаток без таймаута; если это плохо, рядом с Close придумай бюджет и для воркеров.

**Вопрос:** Очередь полна, задача от клиента законна. Что должен вернуть endpoint?

- 202 и выбросить задачу: клиент всё равно не проверит
- ✅ 503 с Retry-After: клиент знает, что подождать и повторить
- 200 без тела: пусть клиент решает сам
- Блокироваться навечно, пока не освободится слот

Backpressure обязан быть видимым: 503 с Retry-After — честный контракт «сейчас перегружен, вернись через N». codeFrom из урока 04 переводит ErrBusy в этот ответ; выше по входу работает rate limiter из урока 09, но лимитер — про входящий темп, а очередь — про исполнение: обе границы нужны. Молчаливый 202 — обещание без исполнения: худшее, что может выдать API.

**Вопрос:** Пул из 8 воркеров; в очереди 50 задач; параллельно пришло 30 запросов на постановку новых. Сколько горутин занято исполнением задач?

- 88: по одной на воркера и по одной на задачу
- ✅ 8: всегда N воркеров, сколько бы задач ни ждало
- 38: по одной на каждую задачу в буфере
- 0: горутины спят, пока очередь непуста

В этом смысл пула: ровно N горутин-воркеров исполняют любую работу — 50 задач в буфере или 5000, параллелизм ограничен конструкцией, а не нагрузкой. Горутины запросов умирают сразу после 202: они не держат работу. Ограничение снизу вверх — память и CPU предсказуемы; поднять N — решение из конфигурации урока 18, а не следствие всплеска.

**Вопрос:** Что делают воркеры после close(jobs), если в буфере ещё 12 задач?

- Немедленно выйдут: close рвёт канал
- ✅ Доработают все 12 и выйдут: range добирает буфер до пустого
- Вернут задачи отправителю по ошибке
- Вызовут панику при чтении из закрытого канала

Чтение из закрытого канала легально и возвращает нулевое значение, когда буфер пуст; range построен на этом: сначала вычитывает остаток буфера, затем завершается. Это и есть дренаж: close — не разрыв, а сигнал «больше не будет», и мы использовали его в уроке 19 для батчера. Паника случилась бы при обратной операции — отправке в закрытый канал; от неё Put защищён мьютексом.

### Гонка Put и Close

```go
// Вариант Put БЕЗ мьютекса — только проверка флага:
func (p *Pool) Put(_ context.Context, j *Job) error {
        if !p.accepting {
                return ErrShuttingDown
        }
        select {
        case p.jobs <- j:
                return nil
        default:
                return ErrBusy
        }
}

// Хронология двух горутин:
// HTTP-дренаж ещё идёт: горутина запроса прошла проверку accepting == true
// Пришёл SIGTERM, таймер истёк, pool.Close() успел:
// accepting = false; close(jobs)
// Запрос выполняет p.jobs <- j
```

**Что выведет программа?**

- Send сработает: задача попадёт в буфер, всё хорошо
- ✅ Паника send on closed channel: процесс упадёт в момент остановки
- Send вернёт ErrBusy: select спасёт от закрытого канала
- Задача тихо отбросится: close проглотит отправку

Проверка-потом-действие — классическая гонка: между if и send вклинился close. Select с default не проверяет закрытие канала — он проверяет лишь готовность буфера, так что спасения нет. Мьютекс из блока выше делает проверку и отправку атомарными: либо Put дошёл до close, либо увидел accepting=false. Правильный порядок секвенции — Close после дренажа HTTP — сужает окно, но не закрывает его; закрывает его только инвариант под замком.

### Собери воркера пула

Восстанови четыре пропуска горутины-воркера: учёт WaitGroup, цикл с дренажем и переходы статусов.

```go
p.wg.___(1)
go func() {
        defer p.wg.___()
        for job := ___ p.jobs { // выйдет после close и опустевшего буфера
                p.status.___(job) // queued -> running
                p.status.Finish(job, run(job))
        }
}()
```

Подсказка: Первые два — пара из sync.WaitGroup из урока 19; третий — ключевое слово, которое читает канал до закрытия и опустевшего буфера; четвёртый — метод StatusStore, отмечающий взятие задачи в работу.

Правильные ответы в пропусках: `Add`, `Done`, `range`, `Run`

### Путь асинхронной задачи

Расставь этапы от запроса до статуса done.

Правильный порядок строк:

1. `POST /uppercase-all: HTTP-горутина декодирует пачку строк`
2. `endpoint вызвал queue.Put: задача легла в буферизованный канал`
3. `encodeResponse выставил 202, Location: /jobs/{id} и Retry-After`
4. `клиент получил 202 и ушёл опрашивать статус`
5. `воркер вычел задачу из канала: статус running`
6. `run(job) исполнил работу, воркер вызвал status.Finish`
7. `GET /jobs/{id} вернул done и результат`
8. `SIGTERM: дренаж HTTP, queue.Close(), воркеры доторкнули буфер, wg.Wait`

### Синхронно против асинхронно

| Критерий | Синхронный endpoint | Асинхронный endpoint |
| --- | --- | --- |
| HTTP-ответ | 200 с результатом | 202 с ID задачи и Location |
| Время ответа | равно времени работы | миллисекунды, работа идёт после |
| Таймаут клиента (урок 04) | риск для длинных операций | к самой работе не применим |
| Ретраи lb.Retry (урок 11) | безопасны для чтения | нужен ключ идемпотентности |
| Параллелизм | горутина на запрос | ровно N воркеров пула |
| Перегрузка | лимитер из урока 09 отклоняет вход | 503 с Retry-After из очереди |
| Отказ операции | код ошибки в ответе | статус failed и текст в StatusStore |
| Наблюдаемость | трейс запроса (урок 08) | глубина очереди, возраст, статусы |
| Остановка | дренаж сервера (урок 19) | дренаж сервера плюс дренаж очереди |

> **Не спавни горутину на каждый запрос.** Искус искушений: отправил задачу — go func() { work() }() и вернул 202. Дёшево, пока не посчитаешь: горутина на запрос без потолка — всплеск в тысячу запросов рождает тысячу горутин с их стеками; память прощает, а планировщик — до поры. Хуже: паника в босой горутине валит весь процесс — recover по умолчанию никто не ставит, и ручное обслуживание 202 превращается в падение сервиса. Пул из этого урока закрывает обе проблемы: параллелизм ограничен N, а перехват паник — в одном месте.

> **Метрики очереди.** Очередь без метрик — чёрный ящик с тайной жизнью. Три счётчика решают: глубина буфера (сколько задач ждёт), возраст старейшей задачи (когда очередь не разбирают), число ошибок ErrBusy (когда backpressure реально бил). Хочешь тоньше — гистограмма ожидания от постановки до взятия воркером: она покажет, где клиентское Retry-After врёт. Всё это — счётчики и гистограммы из урока 07, а пороговые алерты на глубину поставят тебя в известность раньше, чем первый клиент напишет в поддержку.

> **Честно о запуске кода.** Очередь и пул — часть реального процесса, в песочнице курса они не исполняются, поэтому интерактивы построены на чтении кода. Локально всё оживёт: собери пул из урока, подави нагрузку генератором запросов и посмотри на глубину очереди, а затем Ctrl+C и прочитай по логам секвенцию дренажа до exit 0.

### Термины урока

- **202 Accepted** — Ответ «работа принята, но не сделана»: ID задачи и Location для опроса статуса; не 200 — результата нет, и не 201 — ресурс ещё не появился.
- **Worker pool** — N воркеров над одним каналом: параллелизм ограничен конструкцией; range по каналу даёт бесплатный дренаж при close; wg.Wait() не выпускает процесс до выхода воркеров.
- **Буферизованный канал** — make(chan *Job, capacity): очередь в процессе; capacity из Config — буфер на всплеск; полнота проверяется select с default, а не блокировкой.
- **Backpressure** — Честная перегрузка: ErrBusy → 503 с Retry-After; альтернативы — ожидание с таймаутом и запретное молчаливое отбрасывание; лимитер из урока 09 — первый шлагбаум.
- **Идемпотентность принятия** — Idempotency-Key в запросе: повторная отправка возвращает существующую задачу вместо новой; обязателен, ведь судьба 202 клиенту не видна.
- **Дренаж пула** — accepting=false → close(jobs) → range доработает буфер → wg.Wait(); ступень между дренажом HTTP и tracer.Close() в секвенции урока 19.

### Что дальше

Сервис научился принимать работу вне запроса: 202, очередь, пул, статусы, дренаж — и у асинхронности остался последний неотвеченный вопрос: как о фактах узнать тем, кто не спрашивал? Клиент опрашивает /jobs/{id} — а аудитор, кэш и соседние сервисы тоже хотят знать, что UppercaseAll завершён, без поллинга и без связности кода. Урок 22-sobytiya введёт события: конверт факта, шину на каналах, middleware-издателя поверх канона урока 15 и честную таблицу гарантий доставки — от in-memory до NATS и Kafka. Карта курса — на [странице курса](/courses/go-kit/); таймауты, против которых направлена асинхронность, — в [уроке 04-http-transport](/courses/go-kit/lessons/04-http-transport/); метрики очереди — в [уроке 07-metriki](/courses/go-kit/lessons/07-metriki/); ретраи lb.Retry, множащие длинную работу, — в [уроке 11-lb-proxy](/courses/go-kit/lessons/11-lb-proxy/); секвенция остановки — в [уроке 19-graceful](/courses/go-kit/lessons/19-graceful/).

## Коротко: что запомнить

- Асинхронный endpoint — контракт: принять работу, вернуть 202 Accepted с ID и Location, исполнить отдельно.
- Очередь в процессе — буферизованный канал с capacity из Config; владелец один, отправка через Pool.Put под мьютексом.
- Worker pool — N горутин над одним каналом: параллелизм ограничен конструкцией; range даёт дренаж при close.
- Backpressure — 503 с Retry-After по ErrBusy; select с default ловит переполнение без блокировки.
- Статусы: queued/running/done/failed в StatusStore, GET /jobs/{id}; идемпотентность принятия — по Idempotency-Key.
- Put и Close защищены одним мьютексом — иначе гонка с паникой send on closed channel.
- Дренаж пула — accepting=false, close(jobs), wg.Wait(): ступень между дренажем HTTP и закрытием зависимостей.
- In-memory очередь честно at-most-once: для неприемлемых потерь интерфейс Queue заменяется на персистентную реализацию.

## Домашнее задание

Поставь асинхронность в stringsvc. Первое: добавь UppercaseAll — endpoint с 202, Location и Retry-After, очередь на канале с capacity из Config и пул из N воркеров; параметризуй N и capacity флагами из урока 18. Второе: собери StatusStore с четырьмя статусами и GET /jobs/{id}; проверь опросом, что задача проходит queued → running → done. Третье: добавь Idempotency-Key: повторная отправка с тем же ключом возвращает тот же jobID и не создаёт работу. Четвёртое: подави очередь (capacity 2, воркеры спят через time.Sleep в run) и убедись, что третий запрос получает 503 с Retry-After, а не висит. Пятое: запусти остановку с двадцатью задачами в буфере и GracePeriod из урока 19 — по логам секвенция должна показать дренаж HTTP, потом queue.Close() и wg.Wait, и все двадцать задач должны доторкнуть статусы; затем сравни с kill -9 — часть задач не получит done, и это честная цена in-memory.

## Вопросы и ответы по уроку

### Почему бы не запускать горутину прямо в endpoint'е и не городить пул?

Горутина на запрос без потолка превращает всплеск в неконтролируемый рост памяти и планировщика, а паника в босой горутине валит процесс. Пул ограничивает параллелизм конструкцией — N воркеров, сколько бы запросов ни пришло, — и собирает перехват паник в одном месте. Кроме того, очередь между принятием и исполнением даёт наблюдаемость: глубину и возраст задач видно в метриках из урока 07, а горутины — нет.

### Задачи теряются при падении процесса — зачем тогда вообще in-memory очередь?

Потеря — цена скорости и простоты: канал не пишет на диск, дренаж при штатной остановке спасает буфер, но kill -9 не спасёт ничто. Для задач, чья потеря терпима (предрасчёты, сэмплы, прогревы), это правильный инструмент. Для неприемлемых потерь интерфейс Queue из урока — шов: под ним подменяется персистентная реализация (брокер или таблица задач в БД), а endpoint'ы и статусы не меняются; переезд на брокер — это замена одного типа, а не ревизия сервиса.

### Какой размер очереди и сколько воркеров выбирать?

Из измерений, а не из вкуса. Воркеры — исходя из природы работы: CPU-задачи — около числа ядер, IO-задачи — по пропускной способности зависимости; N задаётся флагом из урока 18 и проверяется под нагрузкой по p99. Capacity — буфер на длительность всплеска: глубина очереди на графике из урока 07 подскажет, сколько задач накапливается между пиками; буфер, который никогда не наполняется, — трата памяти, буфер, который всегда полон, — сигнал масштабировать воркеров или вводить лимитер из урока 09.
