Перейти к основному содержимому

5.10. Асинхронность

Разработчику Архитектору

Асинхронность

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

В языке Go асинхронность реализована как часть языковой модели выполнения. Это не дополнительный инструмент, а фундаментальный способ организации кода. Реализация асинхронности в Go опирается на три взаимосвязанных компонента: горутины, каналы и планировщик. В совокупности они образуют кооперативную многозадачность на уровне пользователя, при этом сохраняя прозрачность для разработчика и масштабируемость до десятков и сотен тысяч одновременно существующих задач.

Ключевое различие между асинхронностью в Go и, например, в JavaScript (на основе event loop и промисов) или в C# (на основе async/await и state machines) заключается в том, что в Go нет необходимости явно размечать функции как «асинхронные», не требуется вручную управлять цепочками ожидания, и не вводится дополнительный уровень абстракции над вызовами. Горутина — это минимальная исполняемая единица внутри виртуальной машины Go (точнее, внутри её runtime), и её запуск является языковой конструкцией первого класса.


Горутины

Горутина — это независимый поток выполнения внутри адресного пространства одного процесса Go. В отличие от потоков операционной системы (OS-threads), горутины:

  • имеют значительно меньший размер стека по умолчанию (в ранних версиях — 4 КБ, в современных — динамически изменяемый, начиная с ~2 КБ);
  • создаются и уничтожаются крайне быстро (создание требует лишь нескольких десятков тактов процессора и аллокации памяти);
  • не привязаны один-к-одному к потокам ядра: одна OS-нить может последовательно или параллельно выполнять множество горутин;
  • не требуют явной синхронизации при запуске — они «отпочковываются» от текущей горутины и продолжают выполнение независимо.

Синтаксис запуска горутины предельно прост: перед вызовом функции ставится ключевое слово go:

go processRequest(req)

Эта конструкция не блокирует текущую горутину. Контроль немедленно возвращается вызывающему коду, а функция processRequest начинает выполняться — либо сразу, либо при следующей возможности, предоставляемой планировщиком.

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


Планировщик Go

Планировщик выполнения (Go scheduler) — это компонент runtime, отвечающий за распределение горутин по OS-нитям (называемым M — machine), логическим процессорам (P — processor) и самим горутинам (G — goroutine). Эта модель известна как M:N-модель: M потоков ядра выполняют N горутин, при этом N может значительно превышать M.

Центральный элемент — структура P (processor), которая представляет собой логический контекст выполнения, привязанный к одному ядру ЦПУ (CPU core). Каждый P управляет собственной локальной очередью горутин (run queue). Когда одна горутина блокируется (например, при операции ввода-вывода или при попытке получить блокировку), планировщик может немедленно передать управление другой готовой к выполнению горутине из той же очереди без переключения в ядро операционной системы.

Переключение контекста между горутинами в Go — кооперативное. Это означает, что горутина продолжает выполнение до тех пор, пока:

  • не завершится явно (возврат из функции);
  • не выполнит блокирующую операцию (чтение из канала, time.Sleep, системный вызов ввода-вывода);
  • не достигнет точки preemption (начиная с Go 1.14, введена асинхронная возможность прерывания циклов без явных точек останова для предотвращения «захвата» процессора одной горутиной).

Эта схема обеспечивает высокую эффективность: издержки переключения между горутинами составляют порядка 100–300 тактов, что на два-три порядка меньше, чем переключение OS-потоков (2–5 микросекунд, или thousands of cycles).

Планировщик также обеспечивает балансировку нагрузки: если локальная очередь одного P опустела, а другие P перегружены, горутины могут быть «украдены» (work stealing) для равномерного распределения нагрузки. Этот механизм прозрачен для разработчика и позволяет автоматически использовать всю доступную параллельность аппаратуры.


Каналы

Если горутины — это вычислительные единицы, то каналы (channels) — это единицы взаимодействия между ними. В Go реализовано известное из CSP (Communicating Sequential Processes, Хоар, 1978) правило:

Do not communicate by sharing memory; instead, share memory by communicating.

То есть взаимодействие между асинхронными задачами должно происходить через обмен сообщениями по каналам. Это рекомендованная практика, и в подавляющем большин случаев она приводит к более надёжному, анализируемому и масштабируемому коду.

Канал в Go — это тип первого класса. Он объявляется с указанием типа передаваемого значения:

ch := make(chan int)

Каналы могут быть буферизованными или небуферизованными. Небуферизованный канал (make(chan T)) требует одновременной готовности как отправителя, так и получателя: операция отправки блокирует текущую горутину до тех пор, пока другая горутина не выполнит соответствующую операцию приёма — и наоборот. Это обеспечивает встроенную синхронизацию.

Буферизованный канал (make(chan T, N)) допускает отправку до N значений без блокировки. Когда буфер заполнен, попытка отправки блокируется; когда буфер пуст, попытка приёма блокируется. Размер буфера — важный параметр проектирования: нулевой буфер — синхронный обмен, ненулевой — асинхронная очередь с ограниченной ёмкостью.

Операции над каналами:

  • ch <- value — отправка значения в канал;
  • x := <-ch — приём значения из канала;
  • close(ch) — закрытие канала (только отправитель может закрывать; получатель может обнаружить закрытие с помощью двухаргументной формы: x, ok := <-ch);
  • for v := range ch — итерация по каналу до его закрытия.

Каналы также могут быть направленными: можно объявить параметр функции как chan<- T (только отправка) или <-chan T (только приём), что улучшает типобезопасность и документирует намерения.

Каналы не являются очередями в общем смысле. Они не поддерживают произвольный доступ, не позволяют «подглядывать» за следующим элементом, не реализуют приоритетов. Это синхронизированные конвейеры передачи данных. Если требуется сложная семантика очереди (отложенные задачи, повторные попытки, приоритезация), её следует строить поверх каналов, а не ожидать от них таких свойств «из коробки».


Паттерны асинхронного взаимодействия

1. Producer–Consumer (производитель–потребитель)

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

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

func fetchFromSource(id int, out chan<- Result) {
res, err := http.Get(fmt.Sprintf("https://api.example.com/data/%d", id))
if err != nil {
out <- Result{Err: err}
return
}
// ... обработка ответа
out <- Result{Data: data}
}

func main() {
ch := make(chan Result, 10)
for i := 1; i <= 5; i++ {
go fetchFromSource(i, ch)
}

var results []Result
for i := 0; i < 5; i++ {
results = append(results, <-ch)
}
close(ch)
// анализ results
}

Здесь буферизация канала (10) обеспечивает независимость производителей от скорости потребителя (главной горутины), но не устраняет необходимость знать количество задач — в данном случае, 5. При неизвестном числе задач применяется закрытие канала как сигнал завершения.

2. Fan-in / Fan-out («веерное» слияние и разветвление)

  • Fan-out — одна горутина-источник отправляет данные в несколько горутин-обработчиков. Реализуется через мультиплексирование одного канала на несколько получателей. Обычно используется для параллельной обработки независимых элементов потока.

  • Fan-in — несколько горутин-источников отправляют данные в один канал-агрегатор. Требует осторожности: если несколько горутин пишут в один канал без координации, возможны гонки при закрытии. Решение — использовать посредника (multiplexer) или sync.WaitGroup + закрытие после ожидания всех отправителей.

Пример fan-in через функцию-мультиплексор:

func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup

output := func(c <-chan int) {
defer wg.Done()
for n := range c {
out <- n
}
}

wg.Add(len(cs))
for _, c := range cs {
go output(c)
}

go func() {
wg.Wait()
close(out)
}()

return out
}

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

3. Worker Pool (пул обработчиков)

Архитектура, при которой фиксированное число «рабочих» горутин забирает задачи из общей очереди (канала) и выполняет их. Это обеспечивает управляемую параллельность и предотвращает взрывное создание горутин при высокой нагрузке.

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

type Job struct { /* ... */ }
type Result struct { /* ... */ }

func worker(id int, jobs <-chan Job, results chan<- Result) {
for job := range jobs {
// выполнение job
results <- Result{ /* ... */ }
}
}

func main() {
const numWorkers = 8
jobs := make(chan Job, 100)
results := make(chan Result, 100)

for w := 1; w <= numWorkers; w++ {
go worker(w, jobs, results)
}

// отправка задач
for _, j := range taskList {
jobs <- j
}
close(jobs) // сигнал для всех worker'ов: больше задач не будет

// сбор результатов
for i := 0; i < len(taskList); i++ {
<-results
}
}

Заметим: закрытие jobs гарантирует, что все worker завершатся после обработки оставшихся задач. Канал results не закрывается в этом примере — потому что все потребители находятся в той же горутине и завершают приём явно по счётчику. В распределённых системах, где потребитель отдельный, закрытие results должно быть управляемым (например, через sync.Once после WaitGroup).


Отмена и таймауты

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

Стандартный механизм — context.Context. Контекст — это неуничтожаемый (immutable) объект, передаваемый вниз по дереву вызовов. Он может нести:

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

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

func process(ctx context.Context, id string) error {
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example/"+id, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // включая ctx.Err() при отмене
}
defer resp.Body.Close()

// дальнейшая обработка
return nil
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // освобождение ресурсов, даже если горутина не стартовала

err := process(ctx, "123")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("таймаут")
} else if errors.Is(err, context.Canceled) {
log.Println("отменено")
}
return
}
}

Ключевые принципы:

  • Контекст всегда передаётся первым аргументом функции (по соглашению).
  • Никогда не передавайте nil в качестве контекста — используйте context.Background() или context.TODO().
  • Не храните контекст в структурах — только передавайте по цепочке вызовов.
  • При создании дочернего контекста (WithCancel, WithTimeout, WithValue) не забывайте вызывать функцию отмены (cancel()), даже если отмена не произошла — иначе произойдёт утечка горутин (внутри context создаётся служебная горутина для таймера).

Для асинхронных операций в горутинах контекст следует проверять внутри циклов и перед длительными операциями. Например:

for {
select {
case <-ctx.Done():
return ctx.Err()
case data := <-input:
// обработка
}
}

Это обеспечивает отзывчивость: даже если input "молчит", горутина корректно завершится при отмене.


Интеграция с системными ресурсами

Горутины не "освобождают" поток ОС при блокировке в системном вызове — но runtime Go реализует оптимизацию: если горутина выполняет блокирующий системный вызов (например, чтение из сокета без неблокирующего режима), runtime отвязывает текущую OS-нить (M) от логического процессора (P) и позволяет другим горутинам выполняться на этом P с помощью другой M. После возврата из системного вызова либо используется уже освободившаяся M, либо создаётся новая.

Однако для максимальной эффективности Go runtime переводит сетевые операции в неблокирующий режим на уровне ОС и управляет ожиданием внутри себя (через netpoller, основанный на epoll/kqueue/IOCP). Это означает, что:

  • Чтение/запись в TCP-соединение, HTTP-запросы, работа с net.Listener — блокируют только горутину.
  • Системные вызовы, которые runtime не может "обернуть" (например, os.ReadFile, вызовы к C-библиотекам через cgo, exec.Command), могут заблокировать OS-нить. Для таких случаев runtime автоматически создаёт дополнительные OS-нити (см. GOMAXPROCS ниже), но это дороже по ресурсам.

GOMAXPROCS: управление параллелизмом

Переменная окружения или функция runtime.GOMAXPROCS(n) задаёт максимальное число OS-нитей, которые могут одновременно выполнять Go-код. По умолчанию — число логических ядер процессора.

GOMAXPROCS ограничиваеттолько число одновременно исполняющихся на CPU. Даже при GOMAXPROCS=1 возможна высокая асинхронность: горутины будут переключаться на одном ядре по мере блокировок (I/O, каналы, time.Sleep). Но CPU-ограниченные задачи не получат выигрыша от параллелизма.

Рекомендации:

  • Не уменьшайте GOMAXPROCS без веской причины (например, для тестирования или при жёстком ограничении CPU в контейнере).
  • В CPU-bound нагрузках значение GOMAXPROCS обычно равно числу доступных ядер (или min(ядра, лимит Kubernetes)).
  • Для I/O-bound приложений значение GOMAXPROCS менее критично — даже 2–4 нити могут обслуживать тысячи горутин.

Синхронизация доступа к разделяемому состоянию

Несмотря на рекомендацию «communicate by sharing memory», в реальных системах часто возникает необходимость в разделяемом изменяемом состоянии: кэшах, счётчиках, реестрах, глобальных конфигурациях. В таких случаях требуется явная синхронизация. В Go для этого предоставляется несколько уровней инструментов — от высокоуровневых до низкоуровневых.

sync.Mutex и sync.RWMutex

Мьютекс (sync.Mutex) — примитив взаимного исключения. Он гарантирует, что в критической секции (блоке кода между Lock() и Unlock()) окажется не более одной горутины. Это самая простая и часто используемая форма защиты.

type Cache struct {
mu sync.Mutex
m map[string]interface{}
}

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

func (c *Cache) Set(key string, val interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[key] = val
}

Рекомендации по использованию:

  • Всегда используйте defer Unlock() — это исключает утечки блокировок при панике или раннем возврате.
  • Избегайте вызова внешних функций (особенно неизвестных) внутри критической секции — это может привести к взаимной блокировке (deadlock) или к неожиданно долгому удержанию мьютекса.
  • Не копируйте структуры, содержащие Mutex, по значению — это создаёт независимые блокировки и ломает семантику. Для предотвращения лучше объявлять Mutex как неэкспортируемое поле или использовать *Mutex.

sync.RWMutex расширяет Mutex, добавляя две операции: RLock()/RUnlock() для множественного чтения и Lock()/Unlock() для единовременной записи. Это полезно, когда чтений значительно больше, чем записей (например, кэш с редкой инвалидацией). Однако злоупотребление RLock может привести к голоданию писателя: если читатели постоянно захватывают блокировку, запись может бесконечно ждать своей очереди.

Атомарные операции (sync/atomic)

Для простых типов (целые, указатели, bool) можно использовать атомарные операции: AddInt64, LoadPointer, CompareAndSwap, и т.д. Они компилируются в инструкции процессора с гарантией атомарности (например, LOCK XADD на x86-64) и не требуют блокировок.

Преимущества:

  • Нулевые аллокации;
  • Минимальные задержки (nanoseconds против microseconds у мьютекса);
  • Отсутствие риска deadlock.

Ограничения:

  • Только для базовых типов;
  • Нет составных операций (например, нельзя атомарно обновить два поля);
  • Легко ошибиться в семантике (например, Load + CompareAndSwap не эквивалентны блокировке — возможна ABA-проблема).

Пример корректного применения — счётчик запросов:

var requestCount int64

func handleRequest() {
atomic.AddInt64(&requestCount, 1)
// обработка
}

func getCount() int64 {
return atomic.LoadInt64(&requestCount)
}

sync.Map

Стандартный map в Go не является потокобезопасным. Одновременная запись из нескольких горутин приводит к панике времени выполнения (concurrent map writes). sync.Map — специализированная реализация map, оптимизированная для случаев:

  • Низкой частоты записей;
  • Высокой частоты чтений;
  • Известного набора ключей (например, кэш по ID сессии).

Она использует read-mostly стратегию: чтения выполняются без блокировок, записи — с блокировкой, но структура перестраивается лениво (copy-on-write). Однако sync.Map не заменяет обычный map. Она тяжелее по памяти, не поддерживает итерацию с гарантиями согласованности, и не поддерживает пользовательские типы ключей без дополнительных преобразований (interface{}).

Использование оправдано, только если профилирование показывает, что Mutex + map — узкое место, и нагрузка соответствует сценарию «чтение >> запись».


Инструменты отладки и профилирования асинхронного кода

pprof

Встроенный инструмент профилирования (net/http/pprof или runtime/pprof) позволяет анализировать:

  • CPU profile — какие функции потребляют процессорное время;
  • Heap profile — аллокации и утечки памяти;
  • Goroutine profileтекущее состояние всех горутин (стек вызовов, статус: running, syscall, chan send, select, IO wait);
  • Block profile — места, где горутины чаще всего блокируются (каналы, мьютексы, sync.Cond);
  • Mutex profile — конкуренция за мьютексы.

Профиль горутин особенно ценен при диагностике зависаний: он показывает сколько горутин и почему они ждут. Например, если 1000 горутин находятся в состоянии chan send, это указывает на переполнение буфера канала или отсутствие потребителя.

go tool trace

Ещё более мощный инструмент — трассировка выполнения (runtime/trace). Он записывает события уровня runtime: создание/завершение горутин, блокировки, системные вызовы, переключения между M, P, G. Визуализация (go tool trace trace.out) показывает временную диаграмму выполнения, включая:

  • Простоящие OS-нити;
  • Дисбаланс загрузки между P;
  • Долгие GC-паузы;
  • «Прилипание» горутин к одной M при использовании cgo или блокирующих системных вызовов.

Это незаменимо при отладке масштабируемости и латентности.

GODEBUG

Переменная окружения GODEBUG включает диагностические режимы runtime:

  • GODEBUG=schedtrace=1000 — каждые 1000 мс выводит сводку по планировщику: число горутин, статусы M/P, очередь задач.
  • GODEBUG=gctrace=1 — детальный лог сборщика мусора.
  • GODEBUG=asyncpreemptoff=1 — отключение асинхронной прерываемости (для воспроизведения проблем с «захватом» CPU).

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


Сравнение с другими языками

КритерийGoJavaScript (Node.js)C# (async/await)
МодельГорутины + каналы (M:N)Event loop + микрозадачи (1:N)State machine + ThreadPool (M:N)
Уровень абстракцииЯзыковая конструкцияБиблиотечная (Promise, async)Языковая (async/await) + библиотечная (Task)
ПереключениеКооперативное (+ асинхронная прерываемость)Кооперативное (по точкам ожидания)Кооперативное (на await)
СинхронизацияКаналы (CSP), мьютексы, атомикиPromise-цепочки, async/awaitTask.WhenAll, SemaphoreSlim, lock, async-локи
Отменаcontext.Context (кооперативная)AbortController (частично)CancellationToken (кооперативная)
Масштабируемость10⁵–10⁶ горутин на машине10⁴–10⁵ соединений (ограничено FD и памятью)10⁴–10⁵ задач (ограничено пулом потоков и GC)
Сложность отладкиСредняя (стеки горутин, pprof, trace)Высокая (потерянные промисы, «тихие» исключения)Средняя (асинхронные стеки, AsyncLocal)

Ключевое отличие Go — единая модель для CPU- и I/O-bound задач. В JavaScript и C# асинхронность в первую очередь решает проблему не блокировать основной поток, тогда как в Go она служит универсальной основой исполнения. Это упрощает архитектуру, но требует дисциплины в управлении ресурсами (например, ограничение числа горутин через semaphore при неограниченном входящем трафике).


Асинхронность как системное свойство

Асинхронность в Go — свойство всей системы выполнения. Она влияет на:

  • Проектирование API: функции возвращают результаты через каналы или context-управляемые вызовы, а не через блокирующие возвраты;
  • Архитектуру: приложения строятся как сети взаимодействующих горутин, а не как иерархия синхронных вызовов;
  • Отказоустойчивость: сбой одной горутины (паника) не останавливает весь процесс — но требует явного recover() внутри горутины;
  • Масштабирование: горизонтальное (дополнительные инстансы) и вертикальное (увеличение GOMAXPROCS, оптимизация каналов) достигаются одними и теми же инструментами.

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


Методология проектирования асинхронных систем на Go

1. Границы ответственности: где запускать горутину?

Запуск горутины — операция с побочными эффектами. Поэтому ключевой вопрос при проектировании: кто отвечает за жизненный цикл горутины? Рекомендуется придерживаться правила:

Горутина должна запускаться в том же компоненте, который отвечает за её завершение.

Это исключает «потерянные» горутины — фоновые задачи, которые продолжают работать после уничтожения их владельца (например, после закрытия HTTP-хэндлера или остановки сервера).

Пример плохой практики:

func HandleRequest(w http.ResponseWriter, r *http.Request) {
go sendAnalytics(r) // ❌ горутина живёт дольше запроса
w.WriteHeader(200)
}

Если sendAnalytics выполняет HTTP-вызов к аналитике, и клиент разорвёт соединение до его завершения, системные ресурсы (сокет, память) остаются занятыми. Корректно — передать r.Context() и уважать его отмену:

func HandleRequest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
go func() {
if err := sendAnalytics(ctx, r); err != nil {
log.Printf("analytics failed: %v", err)
}
}()
w.WriteHeader(200)
}

…но даже здесь остаётся риск: если sendAnalytics игнорирует контекст, эффекта не будет. Поэтому надёжнее — использовать службу с управляемым жизненным циклом:

type AnalyticsService struct {
queue chan *http.Request
done chan struct{}
}

func (s *AnalyticsService) Start() {
for i := 0; i < 4; i++ {
go s.worker()
}
}

func (s *AnalyticsService) Enqueue(r *http.Request) {
select {
case s.queue <- r:
default:
// очередь переполнена — отбрасываем или логируем
}
}

func (s *AnalyticsService) Stop() {
close(s.queue)
<-s.done
}

Такой подход делает горутины управляемыми, тестируемыми и наблюдаемыми.

2. Управление параллелизмом

Нет смысла запускать 10 000 горутин для обработки 100 задач, если доступно 8 ядер. Чрезмерный параллелизм ведёт к:

  • Увеличению времени переключения контекста;
  • Конкуренции за кэш процессора (cache thrashing);
  • Росту потребления памяти (стеки, буферы);
  • Снижению предсказуемости задержек (tail latency).

Рекомендуется явно ограничивать степень параллелизма:

  • Для I/O-bound задач — через пул рабочих (worker pool);
  • Для CPU-bound задач — через семафор на основе буферизованного канала:
sem := make(chan struct{}, runtime.GOMAXPROCS(0)) // или фиксированное число

func process(item Data) {
sem <- struct{}{} // захват слота
defer func() { <-sem }() // освобождение

// CPU-тяжёлая работа
}

Это гарантирует, что в любой момент выполняется не более N задач, и планировщик не тратит ресурсы на переключение «лишних» горутин.

3. Потоки данных как основа архитектуры

Вместо «запустить функцию асинхронно» стоит думать: «по какому каналу потечёт результат?». Хорошая асинхронная система на Go — это конвейер:

source → transform₁ → transform₂ → sink

Каждый этап — отдельная горутина (или пул), соединённая каналом с соседями. Преимущества:

  • Чёткое разделение ответственности;
  • Естественное буферирование на стыках;
  • Возможность масштабировать каждый этап независимо;
  • Прозрачность для тестирования (можно подменить любой канал моком).

Пример: обработка логов в реальном времени:

logs := tailLogFile("/var/log/app.log")      // <-chan string
parsed := parseLogs(logs) // <-chan LogEntry
filtered := filterErrors(parsed) // <-chan LogEntry
aggregated := aggregateByMinute(filtered) // <-chan MinuteStats
sink := writeStatsToDB(aggregated) // chan<- error

Каждая функция — это горутина (или несколько), и поток данных виден в сигнатурах. Это делает систему читаемой как документ.


Типичные ошибки и их диагностика

Ошибка 1. Забытое close() канала → deadlock при range

ch := make(chan int)
go func() {
ch <- 1
ch <- 2
// close(ch) — забыли!
}()
for v := range ch { /* зависает после 2 */ }

Диагностика:

  • GODEBUG=schedtrace=1000 покажет горутину в состоянии chan receive;
  • pprof goroutine — стек с runtime.gopark в chanrecv2.

Решение:

  • Закрывать канал только отправителем;
  • Использовать sync.WaitGroup, если отправителей несколько;
  • Рассмотреть альтернативу: передачу количества сообщений или использование context для отмены.

Ошибка 2. Запись в закрытый канал → паника

ch := make(chan int)
close(ch)
ch <- 42 // panic: send on closed channel

Причина: в Go только отправитель может закрывать канал. Если несколько горутин пишут в один канал, ни одна из них не должна закрывать его напрямую.

Решение:

  • Ввести посредника-мультиплексора (см. паттерн merge);
  • Использовать sync.Once для закрытия;
  • Перейти на модель «один отправитель — один канал».

Ошибка 3. Утечка горутин через замыкания

for _, url := range urls {
go func() {
resp, _ := http.Get(url) // ❌ все горутины читают одну переменную url
// ...
}()
}

Причина: переменная url захватывается по ссылке, и к моменту выполнения горутин цикл завершён — url равен последнему значению.

Решение:

  • Передать значение как параметр: go func(u string) { ... }(url)
  • Или объявить локальную переменную внутри цикла: u := url.

Ошибка 4. Игнорирование context.Done() в циклах

for {
data := fetchFromAPI() // блокирующий вызов без контекста
process(data)
}

Такая горутина не реагирует на отмену — даже если родительский контекст завершён. Это ломает graceful shutdown.

Решение:

  • Все блокирующие вызовы должны принимать context.Context;
  • В цикле использовать select с <-ctx.Done().