Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~30 мин +60 XP

Итоговый проект: конкурентный обработчик задач

Запрос «конкурентный проект go» набирают в тот момент, когда учебные примеры с одной горутиной уже прочитаны, а настоящего приложения ещё нет, — и этот урок закрывает разрыв. Двадцать пятый, финальный урок курса собирает из деталей, которые вы оттачивали двадцать четыре урока, цельную машину: конкурентный обработчик задач с воркер-пулом, очередью jobs, обратной трубой results, WaitGroup-барьером из [седьмого урока](/courses/go-advanced/lessons/07-waitgroup/), отменой и дедлайном через context из [девятого](/courses/go-advanced/lessons/09-context/) и graceful shutdown — штатным, безопасным, без дедлоков и потерянных задач. Стиль держат идиомы [двадцать четвёртого урока](/courses/go-advanced/lessons/24-idiomy/): у каждого канала — владелец, у каждой горутины — отчёт в defer. Мы пройдём путь настоящего инженера: техническое задание, типы-контракты, мини-пул на два воркера, канон graceful shutdown, отмена на половине очереди, дедлайн на весь прогон — и финальная сборка с сортировкой итога и честной обработкой ошибок. Каждый ранимый пример исполнен основным движком песочницы (yaegi, настоящий Go) и прогнан трижды; все программы детерминированы — вывод собирается из счётчиков и слотов, а не из часов. Это последний урок [курса](/courses/go-advanced/): после него вы умеете не «примеры про горутины», а конкурентную архитектуру.

Содержание урока · 16 разделов

Конкурентный проект go: двадцать четыре урока — одно приложение#

Посмотрите на оглавление курса глазами сборщика: почти каждый урок давал деталь, которая сама по себе кажется игрушкой. Замыкания — как элегантно прячется состояние. Каналы — как две стороны передают значение. Select — как ждать сразу на нескольких трубах. WaitGroup — как дождаться бригады. Контекст — как сказать «хватит» сразу всему дереву горутин. Каждая деталь разобрана до винтиков, но Engineering начинается в момент, когда детали соединяются в систему с контрактом, ошибками и честным завершением. Такой системой и будет наш проект: конкурентный обработчик задач — шаблон, под которым в продакшне работает добрая половина Go-инфраструктуры: краулеры, ретраи, обработчики очередей, пулы соединений, парсеры файлов.

По запросу «воркер пул проект» вы найдёте ровно тот скелет, который мы и соберём: фиксированное число горутин-воркеров разбирает задачи из общего канала и складывает результаты в другой, а координатор дожидается конца и сворачивает работу чисто. Почему это важно уметь руками? Потому что в реальных системах воркер-пул — это не «оптимизация», а управление ресурсами: тысяча задач не должна означать тысячу открытых соединений, файлов или транзакций. Пул держит параллелизм на честном слове: три воркера — значит, в работе не больше трёх тяжёлых операций, какая бы очередь ни копилась.

У этого урока две цели. Первая — собрать работающее приложение и увидеть, как детали курса защёлкиваются друг в друга. Вторая — проговорить инженерный язык: graceful shutdown, отмены, барьеры, слоты — слова, которые звучат на собеседованиях и в код-ревью. Мы будем честны и про инструменты: все ранимые примеры урока содержат пакеты sync и context, которых нет в резервном движке песочницы, поэтому исполняются они основным движком (yaegi — компилированный настоящий Go в браузере) и каждый прогнан трижды с посимвольной сверкой вывода.

Техническое задание: что должен уметь обработчик#

Прежде чем писать код, сформулируем требования — так сделал бы архитектор. Наш конкурентный обработчик задач принимает на вход очередь из задач; задача — это значение с номером и «весом», имитирующим сложность. Фиксированная бригада воркеров (мы будем гонять два и три) разбирает очередь: никто не простаивает, пока есть работа, и никто не плодит неограниченных горутин. Каждый воркер кладёт результат в общий канал results — обратную трубу, по которой итоги текут к координатору.

Дальше — взрослая часть. Работа должна уметь останавливаться, и не одним способом, а тремя штатными сценариями. Первый: очередь кончилась — воркеры узнают об этом через закрытие канала jobs, координатор дожидается бригады WaitGroup-ом и закрывает results; в седьмом уроке этот порядок назван каноном, здесь он становится каркасом всего проекта. Второй: отмена на середине — заказчик передумал, и контекст с WithCancel гасит воркер ровно на той задаче, на которой его застала отмена; всё, что сделано до неё, остаётся сделанным. Третий: дедлайн — WithTimeout на весь прогон; если работа не успела, воркер обязан увидеть не вечное зависание, а ошибку DeadlineExceeded и честно отчитаться нулём обработанных.

И последнее требование, которое отличает проект от примера: детерминизм наблюдений. Параллельная программа по определению не гарантирует, какой воркер возьмёт какую задачу, — значит, правильный итог должен быть собран так, чтобы от этой лотереи не зависеть. Мы решим это трижды: слотами (каждый воркер пишет в свою ячейку по индексу задачи), агрегатами (сумма и счётчик не зависят от жеребьёвки) и сортировкой (порядок прихода результатов наводим сортировкой по номеру задачи перед печатью). Каждая программа урока печатает один и тот же вывод при каждом запуске — и это не удача, а дизайн.

Карта проекта: компонент и урок-поставщик#

Компонент проектаЧто берёмОткуда
Замыкания-воркерыгорутина-литерал, захват переменных параметром-копиейпервый и четвёртый уроки
defer-гигиенаdefer wg.Done(), defer cancel() — до строк, умеющих паниковатьвторой урок
Отчёт при паденииrecover в defer, чтобы бригада не вешала Waitтретий урок
Очередь jobsканал, make с буфером, close, range до закрытияпятый урок
Ветвление ожиданияselect с default — не виснуть на пустой очередишестой урок
WaitGroup-барьерAdd до запуска, Done через defer, Wait как точка сборкиседьмой урок
Общий счётчик (альтернатива)sync.Mutex, если агрегат нельзя разнести по слотамвосьмой урок
Отмена и дедлайнcontext.WithCancel/WithTimeout, ctx.Done(), ctx.Err()девятый урок
Ошибки задачпара (значение, error), fmt.Errorf с %w, errors.Isпятнадцатый урок
Типы-контрактыструктуры Task и Result, методы, границы владениядесятый урок
Сортировка итогаsort.Slice и sort.Ints — навести порядок после пуластандартная библиотека
Идиомы и стильвладелец канала, struct{}-сигналы, clear is better than cleverдвадцать четвёртый урок

Типы Task и Result: контракт пула#

Любая архитектура начинается с двух типов, и торопиться здесь нельзя — они и есть договор между заказчиком, бригадой и координатором. Task — то, что кладут в очередь: идентификатор и вес. Идентификатор живёт по одной причине: пул перемешивает порядок выполнения, и без номера задачу в итоговом отчёте не опознать. Вес имитирует сложность работы: в продакшне это было бы число байтов, URL для загрузки или идентификатор записи в базе; у нас вес превращается в арифметику — сумма чисел от единицы до веса. Имитация счётчиком, а не time.Sleep, — принципиальное решение: сон делает вывод зависимым от планировщика и таймингов, счётчик делает его абсолютным. Это тот же приём честной детерминации, которым мы пользовались со времён урока про select.

Result — то, что возвращается по обратной трубе: номер задачи, значение и ошибка. Поле Err — это связка с пятнадцатым уроком, поднятая на уровень архитектуры: задача имеет право быть испорченной (отрицательный вес, битые данные, недоступный ресурс), и воркер не паникует и не молчит — он возвращает ошибку как значение, упакованное в Result. Координатор на другом конце канала рассортирует итоги на успешные и отказавшие и посчитает обе группы. Обратите внимание на идиому владения из двадцать четвёртого урока: Task создаёт и отправляет заказчик, Result создаёт и отправляет воркер, читает координатор — у каждого значения один автор и один потребитель, данные не шарятся, а передаются.

Пара слов о том, чего в типах сознательно нет. Нет указателей: задачи и результаты — маленькие значения, копирование дёшево, а разделение памяти между горутинами через указатели вернуло бы нас к communicate by sharing memory — тому, от чего каналы нас спасают. Нет времени: дедлайном управляет контекст снаружи, задача про него не знает — так проще тестировать process в изоляции.

Task, Result и process: имитация работы счётчиком

GO
package main

import "fmt"

// Task — единица работы, которую разбирает пул.
type Task struct {
        ID     int
        Weight int // вес имитирует сложность: считаем сумму чисел
}

// Result — то, что воркер кладёт в обратную трубу.
type Result struct {
        ID    int
        Value int
        Err   error
}

// process — имитация работы счётчиком, а не time.Sleep: вывод детерминирован.
func process(t Task) Result {
        if t.Weight < 0 { // испорченная задача — ошибка как значение
                return Result{ID: t.ID, Err: fmt.Errorf("вес %d не бывает", t.Weight)}
        }
        v := 0
        for i := 1; i <= t.Weight; i++ {
                v += i
        }
        return Result{ID: t.ID, Value: v}
}

func main() {
        // Две задачи вразнобой: одна честная, одна испорченная.
        for _, t := range []Task{{ID: 1, Weight: 4}, {ID: 2, Weight: -1}} {
                r := process(t)
                if r.Err != nil {
                        fmt.Println("отказ:", r.Err)
                        continue
                }
                fmt.Println("задача", r.ID, "дала", r.Value)
        }
}

Контракт из двух структур и одной функции — минимальная единица проекта; весь остальной код урока не меняет process, а обвешивает его пулом и контекстом. Guard-ветвь на отрицательном весе возвращает ошибку-значение (пятнадцатый урок), успешный путь считается счётчиком — без единого сна. В резервном движке песочницы пара (значение, error) в структуре не исполняется как ранимый блок, но основной движок (yaegi) печатает ровно две строки — «задача 1 дала 10» и «отказ: вес -1 не бывает» — и нативная проба урока подтвердила этот вывод трижды.

Очередь jobs: буфер, close и владелец#

Очередь задач — это канал, и два его свойства нужно выбрать осознанно. Первое — буфер. make(chan Task, len(queue)) с ёмкостью по числу задач означает: заказчик может выложить всю очередь в канал мгновенно, ни разу не заблокировавшись, — воркеры разберут её в своём темпе. Буфер здесь — не про скорость, а про развязку ролей: производство задач и потребление живут по независимым расписаниям. В седьмом уроке мы уже видели обратную сторону: буфер на results страхует воркеров от рандеву с медленным читателем. Маленький буфер — честнее модели (пул реально «упирается» в потребителя), большой — проще для рассуждений; для учебного проекта берём ёмкость по размеру очереди и проговариваем цену выбора.

Второе свойство — закрытие. Правило «канал закрывает отправитель» из пятого урока здесь работает дважды: close(jobs) объявляет воркерам, что новых задач не будет, и range внутри воркеров завершается сам; close(results) объявляет координатору, что отчётов больше не будет, и его range тоже заканчивается. Кто вызывает close — вопрос владения: jobs закрывает заказчик, results — тот, кто отвечает за бригаду (об этом ниже, это отдельный сюжет). Двойное закрытие — паника, закрытие со стороны читателя — нарушение владения; оба запрета мы уже разбили о скалы в седьмом уроке, здесь просто соблюдаем.

И ещё одна деталь, которую легко проглядеть: очередь в наших программах заполняется ДО старта воркеров, и close(jobs) идёт до Wait. Порядок «выложить, закрыть, дождаться» — не случайность, а схема безопасного финала: воркеры видят закрытый канал и заканчивают range ровно тогда, когда разобрали всё, что было. Если закрыть jobs до того, как выложены все задачи, — остаток очереди отправится в закрытый канал, и программа запаникует: send on closed channel.

Мини-пул: два воркера, четыре задачи, слоты

Нативный раннер финального урока: в коде sync.WaitGroup, которого в резервном движке нет (пакет "sync" недоступен), поэтому запускайте основным движком — yaegi исполняет настоящий Go. Вывод детерминирован невзирая на конкуренцию: какой бы воркер ни взял задачу, слот с индексом задачи заполняется одним и тем же текстом, а wg.Wait гарантирует, что печать идёт после того, как бригада закончила. Прогнано трижды — вывод посимвольный. Эксперименты: уберите wg.Wait() — слоты напечатаются пустыми (main не дождался бригады); уберите close(jobs) — range никогда не кончится и пул зависнет; добавьте третий воркер — вывод не изменится ни на символ, и это лучший тест на то, что итог не зависит от жеребьёвки.

Вывод

Запустите код, чтобы увидеть вывод.

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

В раннере выше два воркера пишут в один слайс results — и это не гонка, хотя без объяснения выглядит как грех из восьмого урока. Разбор: воркеры пишут в РАЗНЫЕ индексы (результат задачи j идёт в слот j-1, а каждая задача достаётся ровно одному воркеру), значит, ни одна ячейка не имеет двух авторов. Гонка — это одновременный доступ к одной ячейке, где хотя бы один доступ — запись. Здесь её нет по построению, мьютекс не нужен, и это первое инженерное умение проекта: разносить общие данные так, чтобы синхронизация стала ненужной.

Второй защитник — барьер. main пишет в слоты? Нет, main только читает — но когда? Ответ даёт WaitGroup: wg.Wait() возвращает управление только после того, как каждый воркер исполнил defer wg.Done(), а Done в седьмом уроке мы называли точкой сборки: всё, что сделано горутинами до Done, видимо коду после Wait — это гарантируется отношением happens-before, которое WaitGroup создаёт между Done и Wait. Поэтому чтение слайса после Wait честно видит все записи. Переставьте Wait после печати — и программа начнёт печатать нулевые слоты иногда, при неудачном планировании; именно такие «иногда» и есть определение гонки.

Заметьте и дисциплину счётчика: wg.Add(1) — в родителе, до go; wg.Done() — только в defer внутри горутины. Три правила седьмого урока не изменились — они переехали в проект целиком. Отсюда растёт и квиз-вопрос, который на собеседованиях ловит новичков стабильно: сколько раз вызывать Add — по числу задач или по числу воркеров?

Проверьте себя: счётчик бригады

Воркер-пул разбирает очередь из семи задач бригадой из трёх воркеров. Сколько вызовов wg.Add(1) должно быть в коде и почему?

Graceful shutdown: три лица честного завершения#

Термин graceful shutdown — «вежливое завершение» — звучит в каждой второй вакансии Go-разработчика, и это не про вежливость, а про инвариант: программа останавливается так, что ничего не теряется и ничего не виснет. В контексте нашего проекта вежливость раскладывается на три конкретных сценария, и у каждого — свой механизм из курса.

Сценарий первый — штатное окончание: очередь закончилась. Механизм: close(jobs) от отправителя, range в воркерах доходит до конца, воркеры выходят через defer Done, closer-горутина дожидается wg.Wait() и закрывает results, координатор дочитывает все результаты до конца range. Ни одна задача не потеряна, ни один результат не выброшен — программа закончилась потому, что закончилась работа, а не потому, что её убили.

Сценарий второй — отмена на середине: заказчик передумал после N-го результата. Механизм — context.WithCancel из девятого урока: cancel() закрывает канал Done, воркеры, стоящие в select между «взять задачу» и «слышать отмену», выбирают отмену и выходят с отчётом. Важная тонкость, которую мы покажем кодом: всё, что воркер успел обработать ДО отмены, остаётся обработанным — отмена не откатывает сделанное, она прекращает прирост.

Сценарий третий — дедлайн на весь прогон: WithTimeout ставит таймер, и если работа не кончилась к сроку, Done закрывается сам, а ctx.Err() возвращает context.DeadlineExceeded — различимую причину, которую проверяют errors.Is. Три сценария — одна философия: прекращение работы это часть контракта функции, а не авария. Первый сценарий вы соберёте сами — заданием-порядком ниже, второй и третий — живыми раннерами.

Closer-горутина: почему закрывает третье лицо#

Самая обсуждаемая строчка канона — горутина, которая делает wg.Wait() и close(results). Новички спрашивают: зачем посредник, почему не закрыть results прямо в main после Wait? Ответ спрятан в форме канона: main в этот момент занят range по results — он стоит в цикле чтения и физически не может дойти ни до какой строки после Wait, потому что Wait в его коде просто отсутствует до конца цикла. Получается круг: чтобы закрыть канал, надо дождаться бригады; чтобы дождаться бригады, надо выйти из range; чтобы выйти из range, надо закрыть канал. Разрубает круг только участник, который ничего не читает: closer стоит на Wait (это безопасно — воркеры рано или поздно выйдут), а закрыв results, снимает с range последний барьер.

Слово «канон» здесь не украшение: тот же паттерн вы встретите в исходниках пулов и конвейеров стандартной библиотеки и почти каждой Go-кодовой базы — горутина-уборщик, дожидующаяся бригады и закрывающая общий канал. Владение соблюдено: results создан для воркеров-отправителей, значит, закрывать его должен кто-то от их имени — closer именно «от их имени» и действует, он не читатель.

Но существует и вторая, последовательная форма завершения — без closer: если main сначала дожидается бригады (wg.Wait) и только ПОТОМ начинает читать results, он может закрыть канал сам, прямо между Wait и циклом чтения. Блокировки нет, потому что в момент close никто не стоит в range. Эта форма проще читается, но она меняет конвейер: результаты копятся в буфере всё время работы, значит, буфер обязан вместить всё. Обе формы честные; канонична первая, потому что не требует «буфер на всё» и работает при любом размере очереди. Мы закрепим вторую форму предиктом чуть ниже — а сейчас соберите скелет финала руками.

Проверьте себя: кто закрывает results

В канонической форме main стоит в цикле for r := range results, а close(results) делает отдельная горутина после wg.Wait(). Почему нельзя вызвать close(results) в main сразу после раздачи close(jobs)?

Соберите скелет чистого завершения

Восстановите четыре ключевых слова graceful shutdown: закрытие очереди отправителем, тип счётчика, отчёт воркера и барьер в main.

package main import ( "fmt" "sync" ) func main() { jobs := make(chan int, 2) jobs <- 1 jobs <- 2 (jobs) // очередь собрана — канал закрывает отправитель var wg sync. wg.Add(1) go func() { defer wg.() // воркер отчитался for range jobs { } }() wg.() // барьер: дожидаемся бригады fmt.Println("пул закрыт чисто") }

Соберите конвейер с closer-горутиной

Соберите программу: один воркер разбирает три задачи и шлёт отчёты, closer дожидается бригады и закрывает results, main дочитывает канал. Проверьте себя: вывод — «готово 4», «готово 5», «готово 6».

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Отмена halfway: контекст в цикле воркера#

Штатное окончание — самый простой сценарий, потому что воркеры узнают о нём сами, из закрытого канала. Отмена на середине сложнее: воркер стоит в цикле и должен одновременно уметь брать следующую задачу и слышать команду «стоп». Одновременно — ключевое слово: любой однопоточный выбор («сначала возьми задачу, потом проверь отмену») добавляет задержку реакции, а плохо расставленный select вносит недетерминизм. Каноническая форма воркера, отзывчивого к отмене, — это select из шестого урока с двумя ветками: <-ctx.Done() и приём из jobs. Если готова только отмена — воркер выходит; если только задача — работает; если обе (отмена и задача пришли разом) — select честно бросает жребий, и это допустимо: выход на отмене с задачей «в руках» означает лишь, что задача останется необработанной, что и требовалось.

Наш сценарий — halfway, «на полпути»: заказчик шлёт задачи по одной, дожидается результата каждой и после пятого вызывает cancel(). Рукопожатие «задача — результат» делает сценарий проверяемым: ровно пять задач обработано, ровно на пятой отмене стоять — и вывод программы одинаков при каждом запуске. Это тот же приём детерминации, что и слоты: недетерминизм планировщика отсекается не молитвой, а конструкцией — рукопожатием, в котором у каждой стороны ровно один ход.

Обратите внимание, что воркер печатает ctx.Err() при выходе: context canceled — различимая причина, а не «просто вышел». В продакшне на этом месте пишут в лог, освобождают ресурсы и, если задача взята в работу, решают судьбу полуобработанной единицы — откатить, пометить, вернуть в очередь. У нас полуобработанных нет: select прерывает воркера МЕЖДУ задачами — отмена аккуратна ровно потому, что точки проверки выбраны на границах единиц работы.

Отмена halfway: пять задач и стоп

Раннер для основного движка: в коде context и sync — фолбэк не знает ни того, ни другого. Рукопожатие «задача-результат» делает вывод детерминированным: пять строк «задача N готова», затем «воркер вышел: context canceled» и «обработано задач: 5». Прогнано трижды, вывод посимвольный. Эксперименты: перенесите cancel() после десятой задачи — счётчик станет десять; уберите defer cancel() — программы с контекстом обязаны отменять его на выходе (утечка таймера и слушателей); замените select на простой приём <-jobs — воркер перестанет слышать отмену между задачами и сценарий half-way сломается.

Вывод

Запустите код, чтобы увидеть вывод.

Дедлайн на весь прогон: WithTimeout и DeadlineExceeded#

Третий сценарий — самый жизненный: заказчик готов ждать не «пока-не-закончится», а «не дольше, чем». context.WithTimeout(parent, d) строит ребёнка с таймером: к моменту d канал Done закроется сам, без вызова cancel, и ctx.Err() вернёт context.DeadlineExceeded — константу, с которой сравнивают errors.Is-ом (пятнадцатый урок). Отмену по дедлайну не нужно программировать построчно — контекст делает её сама; ваша обязанность другая: воркер обязан СЛУШАТЬ Done, иначе дедлайн пройдёт мимо него, как поезд мимо станции без платформы.

В раннере ниже воркер «опаздывает» принципиально: симуляция тяжёлой задачи спит двести миллисекунд, а дедлайн стоит на тридцати. Воркер просыпается, встаёт на <-ctx.Done() — и канал уже закрыт: чтение мгновенно (свойство закрытого канала из девятого урока), после чего программа печатает причину и проверяет её errors.Is. Вывод снова детерминирован: запас между сном и дедлайном двенадцатикратный — никакое планирование не сделает сон быстрее таймера, счётчик обработанных честный ноль.

Здесь уместно сказать о «дедлайне на весь прогон», как того требует архитектура настоящего обработчика: WithTimeout ставится ОДИН раз в координаторе на всю партию, и этот же ctx передаётся всем воркерам. Дерево отмены из девятого урока — родитель, дети, внуки — здесь вырождается в плоскую конфигурацию «один контекст на бригаду», но расширяется обратно легко: добавьте per-task контекст с меньшим таймаутом, и получите каскад «прогон горит — задача горит ещё сильнее». И не забудьте defer cancel(): даже при штатном финале до дедлайна таймер обязан быть отпущен — это гигиена из девятого урока.

Таймаут: путь DeadlineExceeded

Раннер для основного движка (context + time): фолбэку недоступен и он. Дедлайн 30 мс против сна 200 мс — запас двенадцатикратный, поэтому вывод одинаков при каждом запуске: «воркер увидел: context deadline exceeded», «это DeadlineExceeded? true», «обработано задач: 0». Прогнано трижды. Эксперименты: поменяйте местами 30 мс и 200 мс — воркер успеет напечатать отчёт до дедлайна и получит nil вместо ошибки; замените errors.Is на == — для чистого DeadlineExceeded сработает и это, но сломается на завёрнутой ошибке; поставьте Sleep в 50 мс — вывод перестанет быть надёжным: запас в полтора раза не является детерминизмом.

Вывод

Запустите код, чтобы увидеть вывод.

Вторая форма финала: Wait до close

Прочитайте код и выберите, что он выведет:

package main

import (
        "fmt"
        "sync"
)

func main() {
        jobs := make(chan int)
        results := make(chan int, 8)
        var wg sync.WaitGroup

        for w := 1; w <= 2; w++ {
                wg.Add(1)
                go func(id int) {
                        defer wg.Done()
                        for j := range jobs {
                                results <- j * 5
                        }
                }(w)
        }

        for i := 1; i <= 4; i++ {
                jobs <- i
        }
        close(jobs)
        wg.Wait()      // main ещё не читает results — блокировка безопасна
        close(results) // поэтому закрыть можно прямо отсюда

        n := 0
        for range results {
                n++
        }
        fmt.Println("обработано:", n)
}

Финальная сборка: сортировка, ошибки и итог#

Теперь соединяем всё. Архитектура финальной сборки такова: координатор строит очередь из семи задач (одна — намеренно испорченная, с отрицательным весом), ставит WithTimeout на пять секунд на весь прогон (запас против счётчиковой работы — тысячекратный, дедлайн не сработает, но страховка стоит), заводит буферизованные jobs и results, поднимает трёх воркеров с Add(3), каждый воркер — range по jobs с проверкой отмены через select-default перед задачей. Closer-горутина дожидается бригады и закрывает results; main раздаёт очередь, закрывает jobs и дочитывает результаты до конца.

Дальше — два финальных штриха взрослого кода. Первый: сортировка. Результаты приходят в порядке готовности, то есть в порядке планировщика, — и печатать их в этом порядке значит делать вывод недетерминированным. sort.Slice по полю ID наводит порядок после хаоса: программа печатает итог строго по номерам задач, при любом раскладе планирования. Это общий принцип: конкурентная обработка и детерминированный отчёт совместимы, если отчёт строится ПОСЛЕ барьера и сортируется по устойчивому ключу. Второй штрих: ошибки. Испорченная задача №4 вернётся в Result с Err != nil; координатор при печати разводит ветки — «задача 4 -> ошибка: вес -1 не бывает» — и в конце считает обе группы: успешных шесть, ошибок одна. Ноль паник, ноль потерь, ноль недосчитанных задач.

Отдельно порадуйтесь, чего в коде сборки НЕТ: мьютексов (агрегация через канал), ручных таймеров (контекст), опроса флагов (Done-канал), магических констант времени в логике (вес — счётчик). Пять пакетов — context, fmt, sort, sync, time — и ни одного импорта из внешних зависимостей: конкурентная архитектура в Go это стандартная библиотека целиком.

Финальная сборка: пул + контекст + shutdown + сортировка

Главный раннер курса — запускайте основным движком (yaegi): здесь context, sync и sort.Slice. Все механизмы урока в одном файле: WithTimeout на прогон, буферизованные jobs/results, воркер-пул из трёх горутин с select-проверкой отмены, closer-горутина, сбор через range, sort.Slice по ID и развод ошибок. Вывод детерминирован: семь строк по номерам задач (четвёртая — «ошибка: вес -1 не бывает») и строка «успешных: 6 ошибок: 1». Прогнано трижды — посимвольно. Эксперименты: уберите sort.Slice — порядок строк станет лотереей планировщика; добавьте cancel() сразу после раздачи — воркеры увидят отмену, closer закроет пустой results, и вы получите «успешных: 0 ошибок: 0» — тот же каркас честно отработал отмену.

Вывод

Запустите код, чтобы увидеть вывод.

Итоговый проект как экзамен: что вы доказали#

Проект сложился, и полезно увидеть в нём весь курс сразу — не потому что «повторение мать учения», а потому что в продакшн-коде эти темы существуют только вместе. Конкурентность началась не с горутин, а с замыканий первого урока: воркеры — литералы-функции, захватывающие каналы и локальный счётчик. Гигиена завершения — это defer второго урока в каждой горутине: Done и cancel исполняются даже при панике, а третий урок добавил страховку — recover в defer воркера не даст одной сломанной задаче повесить чужой wg.Wait. Пятый урок дал трубы и правило close, шестой — select, без которого воркер глух к отмене, седьмой — WaitGroup и канон, девятый — язык отмены и дедлайнов. Ошибки пятнадцатого урока стали полем Result.Err; идиомы двадцать четвёртого держат стиль: владелец у каждого канала, struct{} в сигнальных каналах вроде done, имена без излишеств.

За рамками урока осталась ровно одна ступень — тесты и бенчмарки: шестнадцатый и семнадцатый уроки дают всё, чтобы обернуть этот пул в table-driven тесты с race-детектором и замерить масштабирование по числу воркеров. Домашнее задание ниже именно это и предлагает. Что до собеседования — вы теперь можете на белой доске за десять минут нарисовать то, что рисуют не все: воркер-пул с отменой, дедлайном и graceful shutdown, где у каждого канала владелец, у каждой горутины отчёт в defer, а у каждой программы с контекстом — defer cancel.

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

Чек-лист выпускника: конкурентная архитектура#

  • Различаю параллелизм и конкуренцию и знаю, зачем воркер-пул ограничивает число горутин
  • Выбираю буфер канала осознанно: развязка ролей против честного давления на потребителя
  • Соблюдаю владение каналом: close делает отправитель, читатель не закрывает никогда
  • WaitGroup: Add в родителе до go, Done только в defer, Wait — точка сборки, счётчик по воркерам
  • Пишу воркера, отзывчивого к отмене: select между <-ctx.Done() и приёмом задачи
  • Ставлю WithCancel/WithTimeout у владельца и отпускаю его defer cancel()
  • Различаю context.Canceled и context.DeadlineExceeded и проверяю их errors.Is
  • Собираю graceful shutdown по канону: close(jobs) — Wait — close(results) через closer
  • Знаю вторую форму финала: Wait до чтения — тогда close можно сделать из main
  • Не создаю гонок: слоты по индексам, агрегаты через канал, мьютекс — когда без него никак
  • Возвращаю ошибки задач как значения в Result.Err и разводю итог на успешные и отказавшие
  • Делаю отчёт детерминированным: сортировка по устойчивому ключу после барьера
  • Имитирую работу счётчиками, а не сном, если вывод важно воспроизводить
  • Диагностирую дедлок: каждый Wait имеет парный Done, каждый range — будущий close

Карточки финального урока

Термин

graceful shutdown

нажмите, чтобы перевернуть
Определение

Штатное завершение без потерь и зависаний: очередь закрыта отправителем, бригада дожидается WaitGroup-ом, обратный канал закрыт после Wait (closer-горутина), результаты дочитаны до конца range.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Итоговый проект курса — конкурентный обработчик задач: очередь задач, воркер-пул, обратная труба результатов и graceful shutdown собраны из идиом двадцати четырёх уроков.
  • Типы Task и Result — контракт пула: номер задачи нужен потому, что пул перемешивает порядок; поле Err делает ошибки задач значениями (пятнадцатый урок).
  • Имитация работы счётчиком, а не time.Sleep, делает вывод программы детерминированным: итог собирается из счётчиков, слотов и сортировки, не из часов.
  • WaitGroup считает бригаду, а не задачи: Add(1) на воркера в родителе, defer Done, Wait как барьер, дающий happens-before между записями горутин и чтением в main.
  • Слоты (запись в ячейку по индексу задачи) убирают гонку по построению — мьютекс в пуле не нужен, если данные разнесены.
  • Канон graceful shutdown: close(jobs) — воркеры выходят из range — closer-горутина делает wg.Wait() и close(results) — main дочитывает канал.
  • Closer нужен, когда main стоит в range: он не может дойти до Wait; вторая форма — Wait ДО чтения — позволяет закрыть results прямо из main.
  • Отмена halfway: select между <-ctx.Done() и приёмом задачи; рукопожатие «задача-результат» делает счётчик обработанных точным — 5 из 5 и стоп.
  • WithTimeout на весь прогон: дедлайн закрывает Done сам, ctx.Err() различает context canceled и context.DeadlineExceeded, проверка — errors.Is.
  • Финальная сборка: WithTimeout(5s), три воркера с select-проверкой отмены, closer, sort.Slice по ID после барьера, развод успешных и ошибок — вывод стабилен при любом планировании.
  • Песочница: ранимые блоки урока содержат sync и context, которых нет в резервном движке, — они исполняются основным движком (yaegi) и прогнаны трижды с посимвольной сверкой; sort и fmt работают и в фолбэке.
  • Курс go-advanced завершён: от замыканий до пула с отменой, дедлайном и честным завершением — каркас, готовый к реальной работе и собеседованию.

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

Соберите свой вариант обработчика по каркасу финального раннера и расширьте его в пять шагов. Шаг первый — параметризация: число воркеров и ёмкости каналов задайте константами, объясните в комментарии, что изменится при буфере 1. Шаг второй — отмена halfway: добавьте вторую горутину-заказчика, который после третьего результата зовёт cancel(); убедитесь, что итог печатает и обработанные, и «потерянные» задачи. Шаг третий — ошибки: добавьте задачи с отрицательным весом и переведите печать на два списка, успешные и отказавшие, каждый со своим счётчиком. Шаг четвёртый — дедлайн: поставьте WithTimeout таким, чтобы часть прогонов успевала, и обработайте DeadlineExceeded отдельной строкой отчёта через errors.Is. Шаг пятый — тесты: оберните process и пул в table-driven тесты из шестнадцатого урока и прогоните go test -race — объясните каждой строкой комментария, что именно защищает race-детектор. Комментарии в коде — по-русски.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Итоговый проект: конкурентный обработчик задач)

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

Что такое конкурентный обработчик задач и почему это канонический проект на Go?

Это программа, которая принимает поток задач и обрабатывает их фиксированной бригадой горутин: канал jobs служит очередью, воркеры разбирают задачи range-ом, результаты уходят в канал results, WaitGroup дожидается бригады, контекст управляет отменой и дедлайном. Проект канонический потому, что под этот скелет ложится реальная инфраструктура — краулеры, обработчики очередей, пулы соединений, ретраи; и потому, что он концентрирует главные идиомы языка: каналы, select, WaitGroup, context, ошибки как значения.

Чем graceful shutdown отличается от простого завершения main?

Возврат main убивает все горутины мгновенно: недочитанные задачи и несобранные результаты теряются, внешние ресурсы остаются неотпущенными. Graceful shutdown — это протокол остановки: отправитель закрывает канал задач, воркеры дообрабатывают и выходят через defer Done, координатор дожидается бригады wg.Wait(), закрывает канал результатов через closer-горутину и дочитывает всё до конца. При отмене по контексту — то же самое плюс различимая причина (context canceled или DeadlineExceeded) в отчёте.

Почему порядок результатов воркер-пула неопределён и как получить стабильный вывод?

Какой воркер какую задачу возьмёт и какой раньше закончит — решает планировщик; результаты приходят в порядке готовности. Три способа сделать вывод детерминированным: слоты (писать в ячейку по индексу задачи — у каждой ячейки один автор), агрегаты (сумма и счётчик не зависят от жеребьёвки) и сортировка (sort.Slice по устойчивому ключу после WaitGroup-барьера, как в финальном раннере урока). Спать в коде ради «стабильности» — антипаттерн: вывод от часов не бывает воспроизводимым.

Работают ли раннеры этого урока в песочнице самоучителя?

Да, но только на основном движке — yaegi, который исполняет настоящий Go: во всех ранимых блоках урока есть пакеты sync и context, а резервный движок их не знает и отвечает «пакет "sync" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)» — то же самое для context; close он тоже не знает. Основной движок исполняет пул, контекст и сортировку честно: каждый раннер, предикт и собранные fill/order прогнаны трижды, вывод совпал посимвольно. sort и fmt доступны в обоих движках.

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

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией