10.7 Инженерная практика и межъязыковое сравнение
В предыдущих разделах мы разобрали внутреннее устройство канала, пути отправки и получения
данных, а также реализацию select на самом низком уровне. Разобравшись в механизме,
закономерно встаёт более трудный и практически ориентированный вопрос: когда следует
использовать канал, а когда — нет. Слоган Go «не общайтесь через разделяемую память;
вместо этого делитесь памятью через общение» легко прочитать как «любое разделяемое
состояние должно проходить через канал», но это не то, что имел в виду его автор. Данный
раздел сводит этот слоган к конкретному практическому правилу, сопоставляет его с более
лёгкими инструментами из главы о примитивах синхронизации (Глава 11),
а затем помещает выбор Go в контекст семейства CSP, чтобы понять его конкретные координаты
в пространстве проектных решений.
10.7.1 Канал — не универсальный инструмент
На конференции GopherCon 2018 Брайан К. Миллс из команды Go представил доклад «Rethinking
Classical Concurrency Patterns» («Переосмысление классических паттернов конкурентности»).
Он последовательно разобрал идиомы конкурентного программирования, типичные для учебников,
и пришёл к выводу, что значительная их часть при реализации через каналы оказывается
сложнее в правильном написании и медленнее, чем при использовании примитивов пакета sync.
Первый пример — «эмуляция условной переменной через канал». Распространённый подход — использовать буферизованный канал как «слот сигнала», где отправка означает уведомление, а получение — ожидание:
|
|
Это работает при условии «ровно один ожидающий и ровно один уведомитель», но как только
число ожидающих становится неопределённым, возникают проблемы: Signal блокируется или
теряет сигнал, если никто не ждёт; Broadcast (пробуждение всех ожидающих) невозможно
выразить; повторная проверка условного предиката (после пробуждения вызывающий должен
снова проверить, выполнено ли условие) не имеет места для реализации.
Правильным инструментом является sync.Cond (11.4): его Wait
возвращает вызывающего обратно в цикл для повторной проверки предиката после пробуждения,
а Broadcast одновременно будит всех ожидающих — семантика, которую канал не предоставляет
нативно.
Второй пример — «неправильная реализация пула воркеров». Учебники нередко представляют
пул, который раздаёт задачи через один канал и собирает результаты через другой, однако
многие реализации упускают два момента: «как отменить оставшихся воркеров после ошибки
одного из них» и «как не допустить вечной блокировки воркера на отправке, если основная
горутина завершилась раньше», — что порождает утечки и дедлоки. Совет Миллса таков: когда
всё, что нужно, — это «дождаться завершения группы конкурентных задач», сочетание
sync.WaitGroup (11.5) и отмены через context
(11.8) значительно яснее, чем самодельный пул на каналах:
|
|
10.7.2 Краткое объяснение на уровне механизма
«Каналы медленнее мьютексов» — расхожее мнение, широко распространённое в сообществе Go. Оно не выдумано на пустом месте, однако нередко преувеличивается; небольшое зерно истины в нём заслуживает чёткого изложения.
Вернёмся к реализации, рассмотренной в 10.2: каждая отправка или получение
через канал, независимо от того, попадает ли операция в буфер, должна сначала захватить
hchan.lock — мьютекс, — а затем работать с кольцевым буфером или очередями ожидания;
когда же другая сторона оказывается заблокированной, необходимо дополнительно извлечь
sudog партнёра, разбудить его и инициировать передачу управления шедулеру
(Глава 9). Иными словами, быстрый путь канала уже содержит
встроенный мьютекс; никакого «быстрого пути без блокировки» не существует. Напротив,
sync.Mutex (11.2) и sync/atomic
(11.3) при отсутствии конкуренции способны выполнить захват
и освобождение блокировки одной инструкцией CAS, не заходя в рантайм вовсе.
Таким образом, разрыв на уровне механизма становится очевиден: если цель — лишь «защитить небольшой фрагмент разделяемого состояния по месту», использование канала означает оборачивание этого состояния в дополнительный слой блокировки с возможной передачей управления шедулеру, и стоимость такого решения закономерно выше, чем прямое обращение к атомарным операциям или мьютексу. Важно подчеркнуть, что этот вывод справедлив только в сценарии «защиты небольшого состояния» и не должен обобщаться до «каналы всегда медленные». Официальный FAQ формулирует этот вопрос сдержанно: не приводит единственного числа из бенчмарка, а советует принимать решение исходя из выразительности — использовать тот вариант, который точнее и проще выражает намерение, оставляя производительность для измерения в настоящих горячих точках.
10.7.3 Разграничивающее правило
Изложенное выше можно свести к легко запоминаемому правилу:
- Используйте канал, когда суть задачи — коммуникация: передача владения данными
между горутинами (передать фрагмент данных и больше его не трогать), организация стадий
конвейера, широковещательная рассылка сигнала или распространение отмены, а также
выражение выбора «кто из нескольких событий наступит первым» (
select). - Используйте мьютекс / атомарные операции, когда суть задачи — защита небольшого фрагмента разделяемого состояния по месту: счётчик, словарь, читаемый и записываемый несколькими горутинами, фрагмент конфигурации, требующий атомарных обновлений. В этих сценариях канал не даёт преимуществ и работает медленнее.
Одним предложением: канал управляет «потоком данных и передачей владения», а мьютекс — «защитой состояния по месту». Они не конкурируют, у каждого своя область применения. Когда эта граница проведена чётко, почти всякое колебание «что использовать» исчезает.
10.7.4 Две канальные идиомы, стоящие на твёрдой почве
После того как граница проведена, немногочисленные идиомы, в которых каналы действительно превосходны, заслуживают особого внимания.
Буферизованный канал как семафор. Буферизованный канал ёмкостью — это по своей природе счётный семафор: отправка занимает слот, получение освобождает слот, а когда буфер заполнен, отправитель блокируется, ограничивая конкурентность значением . Этот приём уже встречался при обсуждении буферной семантики в 10.6; вот его наиболее распространённая форма для ограничения числа одновременно выполняемых горутин:
|
|
errgroup + context для структурированной конкурентности. Когда группа горутин не
просто «должна завершиться вся», но и требует «отменить всех при ошибке любого и вернуть
первую ошибку», golang.org/x/sync/errgroup (часть библиотеки расширений x/sync,
не входящей в стандартную библиотеку) объединяет WaitGroup, распространение ошибок
и отмену через context. Именно так структурированная конкурентность реализуется в Go:
время жизни дочерних горутин ограничено лексической областью одного Wait и не может
утечь в виде бесхозных горутин.
|
|
Обратите внимание, что канал здесь ушёл за кулисы: сигнал отмены context сам
передаётся через канал done (11.8), а errgroup инкапсулирует
логику select «кто ошибётся первым», так что пользователь видит лишь два действия —
g.Go и g.Wait. Это и есть разграничивающее правило в действии: коммуникация идёт
через канал, агрегация и защита — через примитивы синхронизации, каждый на своём месте.
10.7.5 Межъязыковое сравнение
Канал Go возник не на пустом месте. Его непосредственный предок — CSP Хоара 1978 года, и встраивание коммуникационного примитива CSP в язык общего назначения с дополнением конструкцией выбора — путь, пройденный не раз. Сопоставление смежных систем делает координаты Go в пространстве проектных решений значительно более наглядными. Интересны несколько измерений: является ли поведение по умолчанию синхронным (происходит ли рандеву при отправке и получении через небуферизованный канал), есть ли встроенная конструкция многостороннего выбора, являются ли каналы статически типизированными, и какова топология коммуникации (точка-точка или почтовый ящик).
| Система | Синхронность по умолчанию | Конструкция выбора | Типизация | Топология |
|---|---|---|---|---|
Go chan |
синхронный при отсутствии буфера (рандеву) | select |
да (chan T) |
точка-точка, множественные отправители и получатели |
| occam (CSP) | полностью синхронный, без буфера | ALT |
да | точка-точка |
| Erlang | асинхронный почтовый ящик | receive с сопоставлением по образцу |
нет (динамическая) | почтовый ящик на процесс |
Rust std::sync::mpsc |
асинхронный по умолчанию (channel() — неограниченный); рандеву только через sync_channel(0) |
отсутствует в стандартной библиотеке; требует select! из crossbeam или tokio |
да | множественные отправители, один получатель (MPSC) |
| Clojure core.async | небуферизованный по умолчанию (синхронный) | alt! / alts! |
нет (динамическая) | точка-точка |
Kotlin Channel |
RENDEZVOUS по умолчанию (ёмкость 0, синхронный) |
выражение select |
да | точка-точка |
Несколько пунктов заслуживают развёрнутого комментария. occam — наиболее чистый
потомок CSP, в котором коммуникация единообразно синхронна и небуферизована; небуферизованный
канал Go принадлежит именно этой ветви. Erlang выбирает иной путь: процессы общаются
через асинхронные почтовые ящики, и отправка никогда не блокируется — в противоположность
умолчанию Go «без буфера означает рандеву», что отражает различие между моделью акторов
и моделью CSP в вопросе «с кем синхронизироваться». Стандартный mpsc Rust асинхронен
по умолчанию и допускает лишь одного получателя; что более важно, в нём нет встроенной
конструкции выбора: для одновременного ожидания на нескольких каналах необходимо
прибегать к select! из crossbeam-channel или к асинхронному рантайму — разительное
отличие от Go, где select является встроенным ключевым словом языка. Clojure core.async
был представлен Ричем Хики в 2013 году и явно смоделирован по образцу Go — вплоть до
наименования макроса go и оператора alt!, несущих следы оммажа; отличие в том, что
он построен на платформе JVM, реализует преобразование сопрограмм через макросы, а его
каналы не являются статически типизированными. Kotlin Channel по умолчанию работает
в режиме рандеву — согласованно с Go — и предоставляет выражение select, которое можно
рассматривать как повторную реализацию дизайна Go в языке на основе сопрограмм.
Из анализа таблицы проступает закономерность: сочетание встроенных, статически типизированных, синхронных по умолчанию каналов с конструкцией выбора как элементом первого класса — это именно тот набор компромиссов, который представляет Go. Он отказывается от развязанности почтового ящика Erlang, где отправка никогда не блокируется, в обмен на явную семантику синхронизации отправителя и получателя в точке рандеву и статическую проверку типов. Компромиссы в производительности никогда не бывают бесплатными — как и компромиссы в проектировании.
10.7.6 Нерешённый вопрос: структурированная конкурентность
Обращаясь к горизонту проектных решений, мы обнаруживаем область, которую Go ещё не
закрыл полностью. То, что предоставляет errgroup, — это структурированная конкурентность
«на уровне библиотеки», опирающаяся на соглашение, а не на языковое принуждение:
программист может написать go f() вне g.Go, запустив горутину, не ограниченную
никаким Wait, — и рантайм не остановит его, а компилятор не предупредит. Иными
словами, ключевое слово go само по себе «неструктурировано», и время жизни горутины
может произвольно выходить за пределы запустившей её функции.
Именно это критиковал Натаниэль Дж. Смит в широко разошедшейся статье 2018 года.
Опираясь на библиотеку Trio для Python, он предложил концепцию «питомника» (nursery):
все дочерние задачи должны запускаться внутри единого лексического блока, и до выхода
из этого блока родительская задача блокируется в ожидании завершения всех дочерних,
так что время жизни горутины строго соответствует лексической структуре кода, а утечки
исключаются на уровне языка. Эта идея впоследствии повлияла на coroutineScope в Kotlin,
async let в Swift и StructuredTaskScope в Java 21 (JEP 453/480). Сообщество Go
неоднократно обсуждало введение аналогичного структурного ограничения для go, однако,
поскольку это изменило бы фундаментальную модель лёгких горутин — главную отличительную
черту языка, — вопрос по сей день остаётся открытым компромиссом без окончательного
решения. Для тех, кто пишет на Go сегодня, вывод сугубо практический: сделайте errgroup
своим выбором по умолчанию, оставив голый go для немногочисленных случаев, когда
действительно есть основание «запустить и забыть», компенсируя дисциплиной то, что язык
пока не обеспечивает принудительно.
Дополнительная литература
- Bryan C. Mills. «Rethinking Classical Concurrency Patterns.» GopherCon 2018. https://www.youtube.com/watch?v=5zXAHh5tJqQ (повторный анализ таких идиом, как условные переменные и пулы воркеров)
- The Go Authors. Frequently Asked Questions (FAQ): «Why are there no untagged unions…», «Should I define methods on values or pointers?» и записи о компромиссе между мьютексом и каналом. https://go.dev/doc/faq
- Andrew Gerrand. «Share Memory By Communicating.» The Go Blog, 2010. https://go.dev/blog/codelab-share
- Rich Hickey. «Clojure core.async Channels.» clojure.org news, 2013. https://clojure.org/news/2013/06/28/clojure-core-async-channels (объявление core.async, в котором прямо указывается, что библиотека смоделирована по образцу Go)
- The Rust Project. Module
std::sync::mpsc. https://doc.rust-lang.org/std/sync/mpsc/ (асинхронный неограниченныйchannelvs рандевуsync_channel) - C. A. R. Hoare. «Communicating Sequential Processes.» Communications of the ACM, 21(8), 1978. https://doi.org/10.1145/359576.359585 (теоретический источник канала и ALT)
- Nathaniel J. Smith. «Notes on structured concurrency, or: Go statement considered harmful.» 2018. https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ (основополагающее изложение концепции nursery и структурированной конкурентности)
- Эта книга: 11.2 Мьютекс, 11.3 Атомарные операции, 11.5 WaitGroup, 11.8 Context.