Горутины и каналы в Go: основы конкурентности

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

1. Модель конкурентности Go и запуск горутин

Модель конкурентности Go и запуск горутин

Зачем Go нужна конкурентность

Современные программы почти всегда делают несколько дел одновременно: обслуживают сеть, читают файлы, обрабатывают запросы, ждут таймеры. Go спроектирован так, чтобы конкурентность была обычной частью кода, а не сложным «низкоуровневым» трюком.

Важно различать два понятия:

  • Конкурентность — умение программы вести несколько задач одновременно, переключаясь между ними.
  • Параллелизм — физическое выполнение задач в одно и то же время на нескольких ядрах CPU.
  • Go делает конкурентность удобной через горутины, а параллелизм достигается за счёт планировщика и доступных ресурсов.

    > “Concurrency is not parallelism.” — Rob Pike (Concurrency is not parallelism)

    Что такое горутина

    Горутина — это лёгкая единица выполнения в Go, управляемая рантаймом Go, а не напрямую операционной системой.

    Ключевые свойства горутин:

  • Запуск горутины дешёвый по сравнению с OS-тредами.
  • Горутины мультиплексируются на меньшее количество OS-тредов.
  • Планировщик Go автоматически распределяет выполнение горутин.
  • Горутина запускается с помощью оператора go.

    Оператор go и запуск функции

    Синтаксис:

    Это означает:

  • f() будет выполнена в отдельной горутине.
  • текущая функция не ждёт завершения f() и продолжает выполняться.
  • Важно: вы можете запускать не только именованные функции, но и анонимные.

    В этом примере строка из горутины может не успеть напечататься. Почему — разберём дальше.

    Официальная спецификация: The Go Programming Language Specification — Go statements

    Что происходит, когда завершается main

    В Go программа завершается, когда завершается функция main в пакете main. При этом:

  • все остальные горутины прерываются (программа просто заканчивается)
  • никакого «автоматического ожидания» незавершённых горутин не происходит
  • Это одна из самых частых ошибок новичков: «я запустил горутину, но она ничего не сделала». На самом деле она просто не успела.

    Модель выполнения Go: планировщик и сущности G, M, P

    Чтобы понять поведение горутин, полезно знать упрощённую модель планировщика Go:

  • G (goroutine) — сама горутина: функция + её стек + состояние.
  • M (machine) — OS-тред (поток операционной системы), на котором реально исполняется код.
  • P (processor) — логический ресурс рантайма, который “даёт право” M выполнять Go-код.
  • Планировщик сопоставляет множество G с меньшим количеством M, используя P как «квоты на исполнение».

    !Упрощённая схема, как горутины распределяются по потокам через сущности G-M-P

    Сколько Go-кода может выполняться параллельно

    За параллелизм отвечает лимит, связанный с GOMAXPROCS:

  • GOMAXPROCS задаёт, сколько P доступно рантайму
  • примерно это означает, сколько OS-тредов одновременно могут исполнять Go-код
  • По умолчанию Go ставит GOMAXPROCS равным числу доступных CPU.

    Документация: runtime.GOMAXPROCS

    Почему горутины «лёгкие»

    Горутины дешевле потоков ОС по нескольким причинам:

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

    Как корректно дождаться завершения горутины

    Так как main не ждёт другие горутины, ожидание нужно организовывать явно.

    Ожидание через sync.WaitGroup

    sync.WaitGroup — базовый инструмент, чтобы дождаться завершения группы горутин.

    Правила, которые стоит запомнить:

  • wg.Add(n) вызывайте до запуска горутин, чтобы не поймать гонку по счётчику.
  • wg.Done() удобно ставить через defer, чтобы не забыть при раннем выходе.
  • Документация: sync.WaitGroup

    Типичные ошибки при запуске горутин

    Захват переменной цикла в замыкании

    Одна из самых известных ловушек — запуск горутин внутри цикла и использование переменной цикла внутри анонимной функции.

    Плохой вариант:

    Исправления:

  • передать значение параметром
  • или создать новую переменную в теле цикла
  • time.Sleep как “ожидание”

    Иногда встречается подход: “после запуска горутины посплю, чтобы она успела”. Это ненадёжно:

  • время выполнения зависит от планировщика, нагрузки, I/O
  • на медленной машине может не успеть
  • на быстрой машине вы просто теряете время
  • Для ожидания используйте WaitGroup, каналы или другие синхронизационные примитивы (каналы подробно пойдут в следующих статьях).

    Гонки данных

    Если несколько горутин читают и пишут в общие переменные без синхронизации, возникает гонка данных. Итоги:

  • непредсказуемое поведение
  • ошибки, которые сложно воспроизводятся
  • Пока достаточно запомнить правило:

  • если данные общие и их меняют разные горутины, нужна синхронизация (через каналы или примитивы sync)
  • Когда стоит запускать горутину

    Запуск горутины — это не “ускоритель по умолчанию”. Имеет смысл, когда:

  • задача может ждать (I/O, сеть, таймеры), а вы хотите обслуживать другие задачи
  • есть независимые части работы, которые можно выполнять конкурентно
  • вы строите конвейер обработки (producer → worker → consumer)
  • Не стоит бездумно запускать горутину на каждый чих, если:

  • их количество растёт без контроля
  • задачи держат ресурсы (память, дескрипторы) и не завершаются
  • Что дальше в курсе

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

    В следующей статье перейдём к каналам: как они работают, как блокируют и синхронизируют выполнение, и как строить на них простой и надёжный обмен данными.

    2. Каналы: создание, буферизация и закрытие

    Каналы: создание, буферизация и закрытие

    Зачем нужны каналы, если уже есть горутины

    В прошлой статье мы научились запускать горутины и дожидаться их завершения (например, через sync.WaitGroup). Следующий шаг в конкурентном коде — безопасно передавать данные между горутинами и синхронизировать их работу.

    Канал (channel) в Go — это типизированная “труба” для передачи значений между горутинами. Каналы поддерживаются языком: операции отправки и получения встроены в синтаксис, а блокировки происходят автоматически.

    Ключевая идея Go часто формулируется так:

    > “Do not communicate by sharing memory; instead, share memory by communicating.” — Rob Pike (Go Proverbs)

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

    Что такое канал

    Канал имеет:

  • тип элементов, например chan int, chan string, chan *http.Request
  • направление (может быть двунаправленным или только на чтение/запись)
  • возможную буферизацию (ёмкость)
  • Канал можно представить как очередь, через которую одна горутина отправляет значения, а другая получает.

    Документация по каналам в спецификации: The Go Programming Language Specification — Channel types

    Создание канала

    Каналы создаются функцией make.

  • make(chan T) создаёт небуферизованный канал.
  • make(chan T, n) создаёт буферизованный канал с ёмкостью n.
  • Важно:

  • var ch chan int объявляет нулевой канал (nil), он ещё не готов к использованию.
  • канал — это ссылка на структуру в рантайме, копирование переменной канала копирует ссылку, а не “данные канала”.
  • Документация: Built-in function make

    Отправка и получение значений

    Синтаксис:

  • отправка: ch <- v
  • получение: v := <-ch
  • Пример “передай число и распечатай его”:

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

    Спецификация: The Go Programming Language Specification — Send statements

    Небуферизованные каналы

    Небуферизованный канал — это передача “из рук в руки”.

  • отправитель блокируется, пока кто-то не начнёт получать
  • получатель блокируется, пока кто-то не начнёт отправлять
  • Это похоже на “встречу” двух горутин в точке обмена данными.

    !Сравнение небуферизованного и буферизованного канала и моменты блокировок

    Когда удобно:

  • нужна жёсткая синхронизация “шаг-в-шаг”
  • нужно гарантировать, что значение точно принято в момент отправки
  • Буферизованные каналы

    Буферизованный канал имеет внутреннюю очередь фиксированного размера.

  • отправка блокируется, когда буфер заполнен
  • получение блокируется, когда буфер пуст
  • Можно узнать состояние буфера:

    Когда буфер полезен

    Буфер помогает, если скорости производителя и потребителя отличаются.

  • производитель может “убежать вперёд”, пока в буфере есть место
  • потребитель может “догонять”, разгребая очередь
  • Но буфер — не замена архитектуре:

  • слишком большой буфер может скрыть проблему (например, потребитель не успевает, очередь растёт)
  • слишком маленький буфер приводит к лишним блокировкам
  • Небуферизованный и буферизованный канал

    | Свойство | Небуферизованный make(chan T) | Буферизованный make(chan T, n) | |---|---|---| | Очередь | нет | есть, размер n | | send блокируется | пока нет receive | когда буфер полон | | receive блокируется | пока нет send | когда буфер пуст | | Типичный смысл | синхронизация | сглаживание нагрузки |

    Направленные каналы

    Чтобы выразить намерение в API, используют направленные каналы:

  • chan<- T — только отправка
  • <-chan T — только получение
  • Пример: функция-производитель возвращает канал только на чтение:

    Это делает код понятнее и помогает компилятору ловить ошибки (например, попытку отправить в канал только-на-чтение).

    Закрытие канала

    close(ch) означает: значений больше не будет. Это сигнал получателям, а не “уничтожение” канала.

    Документация: Built-in function close

    Кто должен закрывать канал

    Практическое правило:

  • канал закрывает отправитель (или “владелец канала”), то есть тот, кто гарантированно знает, что отправок больше не будет
  • Почему это важно:

  • отправка в закрытый канал вызывает panic
  • если закрывать канал “со стороны получателя”, легко получить гонку: кто-то ещё пытается отправить
  • Как ведёт себя получение из закрытого канала

    Если канал закрыт и в нём больше нет значений:

  • получение не блокируется
  • возвращается нулевое значение типа и флаг ok = false
  • Идиома “comma ok”:

    Важно: нулевое значение может быть вполне “легальным” (например, 0 для int), поэтому если вам нужно отличать “0 пришёл как данные” от “канал закрыт”, используйте ok.

    Перебор канала через range

    Очень распространённый паттерн: получатель читает из канала до закрытия.

    range удобен тем, что автоматически заканчивается при закрытии канала.

    Чего close не делает

    close(ch):

  • не “разблокирует навсегда” любые сценарии (например, отправка в закрытый канал всё равно аварийна)
  • не освобождает ресурс немедленно “как free” (память управляется сборщиком мусора)
  • Особые случаи: nil канал

    nil канал — это канал, который не создан через make.

    Поведение:

  • отправка в nil канал блокируется навсегда
  • получение из nil канала блокируется навсегда
  • close(nil) вызывает panic
  • Это полезно помнить при проектировании структур, где канал может быть не инициализирован.

    Типичные ошибки и как их избегать

  • Deadlock (взаимная блокировка): все горутины заблокированы на операциях с каналами, и некому продолжать.
  • Закрытие “не тем”: закрыли канал со стороны получателя, а отправитель ещё пишет.
  • Ожидание, что закрытие “остановит” отправителя: закрытие — это сигнал, но отправитель должен сам прекратить отправку.
  • Игнорирование ok: если нужно различать “данные” и “канал закрыт”, используйте v, ok := <-ch.
  • Мини-пример: производитель и потребитель

    Что здесь происходит:

  • буфер 2 позволяет отправителю положить два значения без ожидания
  • третья отправка ("c") заблокируется, пока получатель не прочитает хотя бы одно значение
  • close(ch) завершит цикл range, когда значения закончатся
  • Что дальше в курсе

    В этой статье мы разобрали базовую механику каналов: создание, блокирующую семантику, буферизацию и закрытие. Следующий важный шаг — научиться комбинировать каналы в более сложных схемах (например, ожидание нескольких событий и отмена работы), где обычно применяется select.

    3. Синхронизация: select, WaitGroup, mutex и контекст

    Синхронизация: select, WaitGroup, mutex и контекст

    Как эта тема связана с предыдущими статьями

    В прошлых статьях курса мы разобрали:

  • как запускать горутины и почему main не ждёт их завершения
  • как каналы передают данные и синхронизируют выполнение за счёт блокировок, буфера и close
  • Теперь соберём инструменты синхронизации в набор практических приёмов:

  • select — ждать одно из нескольких событий на каналах
  • sync.WaitGroup — дождаться завершения набора горутин
  • sync.Mutex — защитить общие данные при конкурентном доступе
  • context — отмена, дедлайны и перенос “сигнала остановки” через вызовы
  • Важно: в Go обычно стараются синхронизировать через каналы, но примитивы sync и context остаются необходимыми, особенно при работе с разделяемым состоянием и I/O.

    select: ожидание нескольких событий

    select выбирает один из готовых к выполнению кейсов:

  • получение из канала
  • отправка в канал
  • default (если ничего не готово)
  • Если готовых кейсов несколько, выбор происходит псевдослучайно, чтобы избегать голодания.

    Спецификация: Select statements

    Базовый пример: “ждём результат или таймаут”

    Что важно понимать:

  • time.After(d) возвращает канал, который “придёт” через d
  • обе ветки — это канальные операции, а select ждёт, какая станет возможной первой
  • Документация: time.After

    default: неблокирующая проверка

    default выполняется, если прямо сейчас ни один кейс не готов.

    Полезно для:

  • периодической проверки без ожидания
  • попытки отправить в канал, не блокируясь
  • Риск: легко сделать активное ожидание (busy loop), если крутить select { default: } без пауз.

    “Выключение” кейса через nil канал

    Приём: операция с nil каналом блокируется навсегда, поэтому можно динамически убирать кейс из select.

    Это особенно удобно в сложных циклах, где часть каналов временно “неактивна”.

    !Наглядно показывает, что select выбирает один готовый кейс среди нескольких

    sync.WaitGroup: ожидание завершения горутин

    sync.WaitGroup — это счётчик, который позволяет подождать, пока группа горутин завершит работу.

    Документация: sync.WaitGroup

    Канонический шаблон

    Правила безопасного использования:

  • wg.Add(n) делайте до запуска горутин, чтобы не получить гонку по счётчику
  • в горутине вызывайте wg.Done() ровно один раз на каждый “учтённый” Add(1)
  • wg.Wait() блокируется, пока счётчик не станет равен нулю
  • Типичная ошибка: копирование WaitGroup

    WaitGroup нельзя копировать после начала использования.

    Плохо:

    Хорошо:

    sync.Mutex: защита общей памяти

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

    sync.Mutex обеспечивает взаимное исключение:

  • Lock() — войти в критическую секцию
  • Unlock() — выйти
  • Документация: sync.Mutex

    Пример: конкурентный инкремент счётчика

    Здесь Mutex нужен, потому что операция x++ не является “атомарной” на уровне программы: чтение, увеличение и запись могут перемешаться между горутинами.

    defer mu.Unlock() и “узкие” критические секции

    Частый стиль:

    Практическое правило: держите блокировку как можно меньше.

  • не делайте сетевые запросы под Lock()
  • не зовите долгие функции под Lock() без необходимости
  • Deadlock и порядок блокировок

    Mutex легко привести к взаимной блокировке.

    Причины:

  • забыли Unlock()
  • взяли несколько mutex’ов в разном порядке в разных местах
  • Если вам нужно несколько блокировок, придерживайтесь одного фиксированного порядка их захвата во всей программе.

    RWMutex: много читателей, мало писателей

    Если состояние часто читают и редко пишут, полезен sync.RWMutex:

  • RLock()/RUnlock() — параллельные читатели
  • Lock()/Unlock() — эксклюзивная запись
  • Документация: sync.RWMutex

    context: отмена, дедлайны и распространение “сигнала остановки”

    context — стандартный способ:

  • отменять работу по запросу пользователя или при shutdown
  • ограничивать время операций (deadline/timeout)
  • прокидывать запрос-скоуп данные по стеку вызовов
  • Документация: context package

    Базовые понятия

    Контекст обычно передают первым аргументом:

    У контекста есть:

  • Done() — канал, который закрывается при отмене или по дедлайну
  • Err() — причина завершения (context.Canceled или context.DeadlineExceeded)
  • Отмена через WithCancel

    Что происходит:

  • cancel() закрывает ctx.Done()
  • все горутины, которые выбирают <-ctx.Done(), получают сигнал остановки
  • Таймаут через WithTimeout

    Практическое правило: всегда вызывайте cancel() (обычно через defer), даже если ожидаете, что таймаут “сам всё завершит”. Это освобождает связанные ресурсы раньше.

    context и select: каноническая связка

    Частый шаблон в цикле воркера:

    Смысл:

  • остановка по отмене — всегда в приоритете как отдельная ветка
  • закрытие канала заданий — ещё один легальный способ завершить воркер
  • !Показывает, как один cancel останавливает несколько горутин через канал Done

    Как выбирать инструмент синхронизации

    Ниже — практическая таблица, которая помогает принять решение.

    | Задача | Подходящий инструмент | Почему | |---|---|---| | Дождаться завершения N горутин | sync.WaitGroup | простой и прямой механизм “подождать всех” | | Передать данные между горутинами и синхронизировать шаги | каналы + select | читаемо выражает “события” и блокировки | | Защитить общую структуру данных | sync.Mutex / sync.RWMutex | минимальная стоимость и удобный доступ к состоянию | | Остановить работу по сигналу, таймауту или дедлайну | context + select | стандартная модель отмены, хорошо интегрируется с библиотеками |

    Полезное правило проектирования:

  • если вы передаёте владение данными — каналы часто дают более понятный дизайн
  • если вы разделяете доступ к одному состоянию — mutex часто проще
  • WaitGroup и context почти всегда идут рядом с любым из подходов как “жизненный цикл” и “остановка”
  • Мини-сборка: воркеры, ожидание, отмена и select

    Ниже пример, где одновременно используются все инструменты:

  • jobs — канал работ
  • WaitGroup — дождаться завершения воркеров
  • context — остановить всё по таймауту
  • select — выбирать между работой и отменой
  • Обратите внимание:

  • отправитель работ тоже слушает ctx.Done(), чтобы не зависнуть на отправке
  • воркеры завершаются либо по отмене, либо по закрытию jobs
  • Что дальше

    Теперь у вас есть базовый “набор выживания” для конкурентного Go-кода: select для ожидания событий, WaitGroup для жизненного цикла горутин, Mutex для защиты состояния и context для отмены и дедлайнов. Дальше эти инструменты будут комбинироваться в более прикладных паттернах: воркер-пулы, fan-in/fan-out, ограничение параллелизма и корректный shutdown.

    4. Паттерны: worker pool, fan-in/fan-out, pipeline

    Паттерны: worker pool, fan-in/fan-out, pipeline

    Как эта тема продолжает курс

    Ранее мы разобрали:

  • как запускаются горутины и почему важно управлять их завершением
  • как каналы блокируют, буферизуются и закрываются
  • как select, WaitGroup, Mutex и context помогают синхронизировать и останавливать работу
  • Теперь соберём эти инструменты в три практических конкурентных паттерна, которые встречаются в большинстве Go-сервисов:

  • worker pool — ограничиваем параллелизм и стабилизируем нагрузку
  • fan-out/fan-in — распараллеливаем обработку и затем объединяем результаты
  • pipeline — строим конвейер из стадий, связанных каналами
  • !Общая картина worker pool: производитель задач, несколько воркеров и сборщик результатов

    Общие принципы для всех паттернов

    Перед шаблонами полезно зафиксировать несколько правил, которые предотвращают deadlock и утечки горутин:

  • Канал закрывает отправитель, то есть тот, кто гарантированно знает, что значений больше не будет.
  • Закрытие канала — это сигнал “данных больше не будет”, а не команда “остановись прямо сейчас”. Остановка обычно делается через context.
  • Любая горутина должна иметь путь завершения: по закрытию входного канала, по <-ctx.Done(), по ошибке, по таймауту.
  • Ограничивайте параллелизм: “горутина на каждую задачу” легко превращается в неконтролируемый расход памяти и дескрипторов.
  • Полезная формулировка идеи Go:

    > “Do not communicate by sharing memory; instead, share memory by communicating.” — Rob Pike (Go Proverbs)

    Worker pool

    Worker pool — это фиксированное (или управляемое) число воркеров, которые читают задания из канала jobs и пишут результаты в канал results.

    Зачем нужен worker pool

  • Чтобы ограничить параллелизм (например, максимум 10 запросов к внешнему API одновременно).
  • Чтобы сгладить нагрузку: производитель может “набросать” задания в буфер, а воркеры разберут их с нужной скоростью.
  • Чтобы упростить lifecycle: воркеры — долгоживущие горутины, а не тысячи краткоживущих.
  • Базовый шаблон

    Ниже пример, где:

  • producer закрывает jobs
  • воркеры читают jobs до закрытия или отмены контекста
  • отдельная горутина закрывает results, когда все воркеры завершились
  • Типичные ошибки в worker pool

  • Закрыть results в воркере: если воркеров несколько, второй закрывающий вызовет panic.
  • Забыть закрыть results: потребитель range results будет ждать вечно.
  • Не слушать ctx.Done() у отправителя: производитель может навсегда зависнуть на jobs <- ..., если потребителей больше нет.
  • Как выбрать размер пула

    Обычно стартуют с простого:

  • для CPU-bound задач: около числа доступных CPU (часто достаточно runtime.GOMAXPROCS(0) как ориентира)
  • для I/O-bound задач: больше, но с контролем (и с учётом лимитов внешних систем)
  • Важно: это не “формула”, а настройка под профиль нагрузки и ограничения.

    Fan-out и fan-in

    Эти термины часто используют вместе.

  • fan-out: один поток задач распределяется на несколько параллельных обработчиков
  • fan-in: результаты из нескольких источников объединяются в один канал
  • Worker pool можно рассматривать как частный случай fan-out, но fan-in добавляет важную часть: корректное объединение нескольких каналов в один.

    !Разветвление обработки и обратное объединение результатов

    Fan-out: распараллеливание обработки

    Варианты реализации fan-out:

  • несколько горутин читают из одного jobs (как в worker pool)
  • диспетчер читает jobs и отправляет в разные каналы (например, по ключу шардинга)
  • Чаще всего достаточно первого варианта: несколько потребителей на одном канале.

    Fan-in: слияние нескольких каналов

    Ниже — каноническая функция merge, которая:

  • читает из каждого входного канала
  • пишет всё в общий out
  • закрывает out, когда все входы закрылись
  • Заметьте два момента:

  • мы не закрываем out из читающих горутин, потому что их несколько
  • wg.Wait() и close(out) вынесены в отдельную горутину, чтобы merge сразу вернул out
  • Что делать с отменой

    Если вам нужно прекращать fan-in по сигналу остановки, добавляйте context в merge и проверяйте <-ctx.Done() внутри циклов чтения и записи.

    Альтернатива для задач, где нужно “прервать всё при первой ошибке”: пакет errgroup.

  • Документация: golang.org/x/sync/errgroup
  • Pipeline

    Pipeline (конвейер) — это несколько стадий обработки, соединённых каналами.

  • каждая стадия читает из входного канала
  • преобразует данные
  • пишет в выходной канал
  • закрывает выходной канал, когда входной закрыт и все данные обработаны
  • Pipeline удобен, когда обработку естественно разбить на шаги: парсинг → валидация → обогащение → запись.

    !Конвейерная обработка данных: каждая стадия преобразует поток

    Минимальный пример конвейера

    Сделаем 3 стадии:

  • gen: генерирует числа
  • square: возводит в квадрат
  • format: превращает в строку
  • Здесь важно, что каждая стадия:

  • завершится по закрытию входа (range in закончится)
  • завершится по отмене (ctx.Done())
  • закроет свой out, чтобы следующая стадия тоже корректно завершилась
  • Буферизация и backpressure

    Pipeline естественно создаёт обратное давление:

  • если последняя стадия не успевает, то предыдущая блокируется на отправке
  • дальше блокируется ещё предыдущая, и так до источника
  • Это часто полезно: система сама “тормозит”, а не раздувает память.

    Буфер между стадиями:

  • помогает сгладить небольшие колебания скорости
  • может скрыть проблему, если буфер слишком большой
  • Практический подход:

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

    Есть несколько подходов:

  • передавать структуру вида struct{ V T; Err error } по каналу
  • иметь отдельный канал ошибок и останавливать всё через context
  • использовать errgroup для конкурентных частей, где “первая ошибка останавливает остальных”
  • Выбор зависит от того, что вам нужно:

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

    | Задача | Паттерн | Признак, что он подходит | |---|---|---| | Ограничить число одновременных операций | worker pool | есть внешний лимит или дорогие ресурсы | | Ускорить обработку независимых элементов | fan-out | элементы не зависят друг от друга | | Собрать результаты из нескольких источников | fan-in | нужно объединить потоки в один | | Разложить обработку на этапы | pipeline | данные проходят последовательные преобразования |

    На практике эти решения комбинируют:

  • pipeline, где одна из стадий — worker pool (ограниченная параллельная обработка)
  • fan-out для распараллеливания тяжёлого шага и fan-in для объединения
  • Чек-лист надёжного конкурентного кода

  • У каждой горутины есть явный путь завершения.
  • Каналы закрывает владелец отправки.
  • Там, где есть ожидание нескольких событий, используется select.
  • Там, где нужно ждать группу горутин, используется WaitGroup или errgroup.
  • Отмена и таймауты передаются через context.
  • Эти паттерны — строительные блоки. Когда вы уверенно используете их, становится проще проектировать конкурентные системы: предсказуемые, управляемые и без “висящих” горутин.

    5. Ошибки и отладка: дедлоки, гонки, утечки горутин

    Ошибки и отладка: дедлоки, гонки, утечки горутин

    Зачем отдельная тема про ошибки конкурентности

    В предыдущих статьях мы научились:

  • запускать горутины и управлять их жизненным циклом
  • передавать данные через каналы, понимать буферизацию и закрытие
  • применять select, WaitGroup, Mutex и context
  • собирать из этого worker pool, fan-in/fan-out и pipeline
  • На практике почти все проблемы конкурентного Go-кода сводятся к трём классам:

  • дедлоки (все ждут и никто не может продолжить)
  • гонки данных (несогласованные чтение и запись общей памяти)
  • утечки горутин (горутины не завершаются и накапливаются)
  • Главная сложность в том, что эти баги часто:

  • проявляются не всегда
  • зависят от времени, нагрузки и планировщика
  • исчезают при добавлении логов
  • Цель статьи: научиться узнавать симптомы, понимать причины и применять инструменты диагностики.

    Дедлоки

    Что такое дедлок в Go

    Дедлок — состояние, когда прогресс невозможен: горутины заблокированы на синхронизации (каналы, mutex’ы, ожидание), и ни одна не может “разблокировать” другие.

    В простейшем случае рантайм Go детектирует ситуацию и аварийно завершает программу с сообщением вида:

  • fatal error: all goroutines are asleep - deadlock!
  • Важно: дедлоки бывают и частичными (не “все горутины спят”), например когда зависла одна важная горутина, а остальные продолжают крутиться.

    Классический дедлок на канале

    Пример: отправка в небуферизованный канал без получателя.

    Почему так:

  • make(chan int) создаёт небуферизованный канал
  • операция ch <- 1 требует одновременного получателя
  • получателя нет, значит main блокируется
  • других горутин нет, значит прогресс невозможен
  • Исправления зависят от смысла:

  • запустить получателя в горутине
  • или сделать буфер и гарантировать чтение позже
  • или пересмотреть архитектуру (часто это симптом “не тем каналом управляют”)
  • Дедлок из-за range по каналу без close

    Очень частая ошибка в пайплайнах и fan-in:

    Что происходит:

  • получатель range ch ждёт закрытия канала
  • отправитель завершился, но канал не закрыт
  • новых значений нет, range блокируется навсегда
  • Правило из прошлых статей остаётся ключевым:

  • канал закрывает отправитель (владелец отправки)
  • Исправление:

    Дедлоки с sync.Mutex

    Типовой сценарий: забыли Unlock() на одном из путей.

    Решение:

  • использовать defer mu.Unlock() сразу после Lock()
  • держать критическую секцию узкой и простой
  • Другой сценарий: взаимная блокировка при нескольких mutex’ах, если в разных местах берут их в разном порядке.

    Практика:

  • если нужно несколько mutex’ов, зафиксируйте единый порядок захвата во всём коде
  • Как быстро локализовать дедлок

  • Читайте текст паники: рантайм часто печатает стек всех горутин.
  • Ищите “точки блокировки”:
  • <-ch или ch <- v
  • wg.Wait()
  • mu.Lock()
  • select без default, где ни один кейс не может стать готовым
  • Добавьте “путь завершения”:
  • закрытие каналов владельцем
  • ветку <-ctx.Done() в select
  • таймаут для ожидания (на уровне бизнес-логики или тестов)
  • Гонки данных

    Что такое гонка данных

    Гонка данных (data race) возникает, когда:

  • две или более горутины обращаются к одной и той же памяти одновременно
  • хотя бы одно обращение — запись
  • нет синхронизации (mutex, каналы, атомики)
  • Последствия:

  • непредсказуемое поведение
  • ошибки, которые сложно воспроизвести
  • “случайно работает” до первой серьёзной нагрузки
  • Пример гонки

    x++ выглядит как одна операция, но логически это “прочитать, увеличить, записать”, и эти шаги могут перемешиваться.

    Детектор гонок: -race

    В Go есть встроенный детектор гонок.

  • для тестов: go test -race ./...
  • для программы: go run -race .
  • Документация: Race detector

    Что вы получите:

  • предупреждение с указанием адреса памяти
  • стеки горутин, которые одновременно обращались к данным
  • Это один из самых полезных инструментов в конкурентном Go.

    Как исправлять гонки

    Базовые варианты:

  • защитить общие данные mutex’ом
  • перестроить на модель владения через каналы
  • Пример с mutex:

    Пример с “владельцем состояния” через канал: одна горутина владеет x и принимает команды “увеличить” по каналу. Остальные горутины не трогают x напрямую.

    Выбор зависит от архитектуры:

  • если состояние “общее и простое” и доступ к нему частый, mutex часто проще
  • если вы строите поток обработки, каналы дают более ясную модель “кто чем владеет”
  • Утечки горутин

    Что такое утечка горутины

    Утечка горутины — ситуация, когда горутина не завершается, хотя должна.

    Это не “утечка памяти” в прямом смысле, но последствия похожи:

  • растёт runtime.NumGoroutine()
  • увеличивается потребление памяти
  • копятся открытые ресурсы (таймеры, соединения)
  • система деградирует под нагрузкой
  • Типовой источник утечек: блокировка на канале

    Горутина может навсегда зависнуть на:

  • отправке ch <- v, если нет получателя
  • получении <-ch, если никто не отправит и канал не будет закрыт
  • Особенно опасно, когда горутина запускается “на каждый запрос”, а зависание случается редко.

    Пример утечки: воркер без пути остановки

    Проблема:

  • воркер завершится только когда закроют jobs
  • jobs никто не закрывает
  • воркер висит вечно
  • Исправления:

  • закрывать jobs владельцем отправки
  • или добавить отмену через context
  • Каноническое решение: context + select

    Здесь есть два легальных пути завершения:

  • закрытие канала работ
  • отмена контекста
  • !Сравнение горутины без пути остановки и воркера с отменой через context

    Частая ловушка: time.After в цикле

    time.After(d) создаёт таймер и возвращает канал. Если делать так в горячем цикле, вы создаёте много таймеров.

    Плохой стиль (особенно в бесконечных циклах):

    Предпочтительнее time.NewTicker и defer ticker.Stop().

    Документация: time.NewTicker

    Как заметить утечки

    Полезные методы:

  • проверять runtime.NumGoroutine() на стендах и в тестах
  • снимать goroutine-профиль через pprof
  • смотреть трассировку через go tool trace
  • Ссылки на инструменты:

  • net/http/pprof
  • runtime/pprof
  • runtime/trace
  • Diagnostics
  • Инструменты отладки конкурентности

    go test -race

    Лучший первый шаг при подозрении на гонки:

  • запускайте на CI
  • запускайте локально на ключевых пакетах
  • добавляйте конкурентные тесты на критичные компоненты
  • Документация: Race detector

    pprof: профили и горутины

    Если у вас сервис, удобно подключить pprof-эндпоинты:

    И поднять HTTP-сервер (внутренний, защищённый). Через /debug/pprof/goroutine можно увидеть стеки горутин и понять, где они зависают.

    Документация: net/http/pprof

    trace: планировщик и события

    Трассировка полезна, когда нужно понять:

  • почему горутины не выполняются
  • где происходят блокировки
  • как распределяется выполнение между горутинами
  • Документация: runtime/trace

    Практический чек-лист: как писать код, который проще отлаживать

  • У каждой горутины есть путь завершения.
  • Каналы закрывает владелец отправки.
  • Любое ожидание потенциально бесконечного события имеет “выход”:
  • <-ctx.Done()
  • таймаут (где он логически уместен)
  • WaitGroup используется только для ожидания завершения, но не как “сигнал остановки”.
  • Общие данные либо защищены Mutex, либо имеют единственного владельца и обновляются через каналы.
  • Конкурентные тесты прогоняются с -race.
  • Связь с паттернами курса

    Проблемы из этой статьи чаще всего возникают именно в паттернах:

  • worker pool: забыли закрыть results, или производитель завис на отправке без ctx.Done()
  • fan-in: попытались закрыть выходной канал из нескольких горутин
  • pipeline: стадия не закрывает свой выходной канал или не реагирует на отмену
  • Если вы придерживаетесь правил:

  • закрывает канал только владелец
  • каждая стадия пайплайна закрывает свой out
  • все блокирующие операции обёрнуты в select с <-ctx.Done()
  • то дедлоки и утечки становятся редкими, а отладка существенно проще.