MEASUREMENT
bench/gochan/behaviour.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/go/concurrency/channels
- Run on
- go1.24.7 linux/amd64, Intel Xeon 2.10GHz, GOMAXPROCS = 2
- How to run it
go run bench/gochan/behaviour.go # работает и из корня go run bench/gochan/leakdetect.go cd bench/gochan go test -run '^$' -bench . -benchmem -count=3 .
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для статьи «Канал в Go: прямая передача, честный select и цена буфера»
| Файл | Что делает |
|---|---|
behaviour.go |
восемь наблюдений без единого замера времени: канал как указатель на структуру с мьютексом, прямая передача мимо буфера, равномерность select и отсутствие приоритета у отмены, закрытый канал, nil-канал, незакрываемая утечка горутин, буфер как отсрочка, порядок значений |
cost_test.go |
цена: что даёт буфер, счётчик через atomic/мьютекс/канал, цена лишней ветки select, канал против мьютекса без парковки, размер элемента |
leakdetect.go |
чем обнаруживают утечку горутин: runtime.NumGoroutine, профиль goroutine из runtime/pprof, проверка «до/после» в тесте; и проверка того, что go test -race утечку не ловит. Ни одного замера времени |
Каталог — отдельный модуль Go, поэтому тесты запускаются из него:
go run bench/gochan/behaviour.go # работает и из корня
go run bench/gochan/leakdetect.go
cd bench/gochan
go test -run '^$' -bench . -benchmem -count=3 .
-count=3 не украшение: все числа ниже — медианы трёх прогонов, и разбросы
тоже из них. Один прогон не даёт ни того, ни другого.
behaviour.go помечен //go:build ignore — он package main, а рядом лежит
тест пакета gochan, и без метки go test ./... спотыкался бы о два пакета в
одном каталоге.
Что здесь важно прочитать правильно
Главное — не наносекунды. Оно в behaviour.go: значение при готовом
получателе не проходит через буфер; select перемешивает ветки по-настоящему,
и поэтому отменённый контекст выигрывает выбор только в половине случаев;
заблокированная навсегда горутина не собирается никогда. Это утверждения о
поведении, и проверяются они счётчиками и сравнением, а не секундомером.
По времени сопоставляются только строки внутри одного блока cost_test.go.
В каждом блоке все строки дают одинаковый результат, отличается лишь способ.
Блоки друг с другом не сопоставляются — и здесь это не формальность, а главная
ловушка темы: канал стоит принципиально по-разному в зависимости от того,
приходится ли парковать и будить горутину. В блоках 1 и 2 приходится (там
работают две горутины), в блоках 3–5 нет (всё происходит в одной). Числа из
них несопоставимы, хотя измеряют «канал».
Одна и та же работа нарочно повторена в блоках 3 и 4: BenchmarkSelectNone
и BenchmarkNoParkChannel кладут значение в буфер и забирают обратно,
отличаясь только размером буфера (1 против 2). Так у каждого блока есть своя
точка отсчёта внутри него, и ни одно сравнение не приходится вести через
границу блока. Близость их чисел (49,85 против 50,48) — заодно и признак, что
замер повторяем.
Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.10GHz, GOMAXPROCS = 2)
Устройство
unsafe.Sizeof(chan int) = 8 байт: в переменной лежит один указатель на
hchan в рантайме. Для сравнения: срез — 24 байта, карта — 8, строка — 16.
Внутри hchan — кольцевой буфер, две очереди ожидающих горутин и обычный
mutex. Канал не «без блокировок»: каждая отправка и каждый приём берут этот
мьютекс.
Прямая передача
Проверяется через len(ch) у канала с буфером на 4:
| что сделали | len(ch) после отправки |
|---|---|
| получатель уже ждал | 0 |
| получателя не было | 1 |
Если бы значение легло в буфер, длина стала бы 1. Она осталась нулём — значение ушло мимо буфера, прямо в стек получателя.
select
30 000 выборов из трёх готовых каналов:
| ветка | выбрана | доля | ожидание |
|---|---|---|---|
| a | 10 144 | 33,8 % | 33,3 % |
| b | 9 867 | 32,9 % | 33,3 % |
| d | 9 989 | 33,3 % | 33,3 % |
Перемешивание настоящее — в отличие от порядка обхода карты, где «случайность» оказывается сдвигом одной последовательности.
30 000 заходов, где контекст уже отменён, а работа готова:
| выбрана | раз | доля |
|---|---|---|
| отмена | 14 992 | 50,0 % |
| работа | 15 008 | 50,0 % |
Отмена не имеет приоритета. Цикл, который «на отмене сразу выходит», при непрерывно готовой работе выходит с вероятностью 1/2 на каждом заходе.
Генератор здесь не засевается, поэтому точные счётчики от запуска к запуску меняются (в контрольном перезапуске вышло 15 011 против 14 989). Повторяется не число, а доля.
Цена
Блок 1 — передать N значений из одной горутины в другую, отличается только размер буфера:
| буфер | ns/op | |
|---|---|---|
| 0 | 208,0 | |
| 1 | 163,2 | |
| 8 | 89,65 | |
| 64 | 57,91 | лучшая |
| 1024 | 78,97 | хуже, чем при 64 |
Зависимость не монотонна: из пяти замеренных размеров лучший — не самый большой. Буфер на 1024 значения проигрывает буферу на 64 в 1,36 раза.
Почему после 64 становится хуже, замер НЕ показывает. Правдоподобная догадка — кэш-след кольца (8 КБ против 512 байт), но 8 КБ помещаются и в L1d, так что это гипотеза, а не измерение, и здесь она не проверялась.
Блок 2 — увеличить общий счётчик N раз:
| способ | ns/op | к atomic |
|---|---|---|
atomic.AddInt64 |
6,553 | ×1,00 |
| мьютекс | 15,80 | ×2,41 |
| канал | 67,19 | ×10,25 |
Блок 3 — положить значение в буферизованный канал и забрать обратно; отличается
только число веток select, из которых готова ровно одна:
| как написан приём | ns/op | к предыдущей строке |
|---|---|---|
без select |
49,85 | — |
select из 1 ветки |
51,57 | +1,7 |
select из 2 веток |
87,17 | +35,6 |
select из 4 веток |
158,7 | +71,5 |
select из 8 веток |
391,4 | +232,7 |
Одна ветка почти бесплатна — и не потому, что select дёшев, а потому, что
его там нет: select с одной веткой и без default компилятор разворачивает
в обычную операцию. В cmd/compile/internal/walk/select.go это записано
комментарием «optimization: one-case select: single op», и selectgo не
вызывается вовсе. Настоящий select начинается со второй ветки, и первое же
его появление стоит +35,6 нс — почти три четверти цены самого приёма (49,85).
До четырёх веток каждая следующая стоит примерно те же 36 нс (+71,5 на две), а
на восьми цена ветки вырастает более чем в полтора раза: +232,7 на четыре, то
есть по 58 против 36.
Механизм в runtime/select.go: на каждом входе ветки перемешиваются, затем
сортируются по адресу канала — комментарий там гласит: «simple heap sort, to
guarantee n log n time» — и блокируются все каналы, а не только тот, что
сработает (sellock пропускает повтор, поэтому «восемь захватов» верно для
восьми РАЗНЫХ каналов, как здесь). Ничего между заходами не сохраняется.
Блок 4 — то же значение положить и забрать без переключения горутин:
| чем защищено | ns/op | |
|---|---|---|
| мьютекс | 32,36 | ×1,00 |
| канал | 50,48 | ×1,56 |
select с default |
52,83 | ×1,63 |
Даже когда парковать некого, канал дороже мьютекса в 1,56 раза: он и есть
мьютекс плюс очередь и кольцевой буфер. Строку с default надо читать с
поправкой: select с одним случаем и default компилятор тоже разворачивает —
«optimization: two-case select but one is default: single non-blocking op», —
то есть она меряет selectnbrecv, а не selectgo.
Блок 5 — прогнать значение через буфер, отличается тип элемента:
| элемент | размер | ns/op |
|---|---|---|
struct{} |
0 б | 46,66 |
int64 |
8 б | 49,79 |
[128]byte |
128 б | 59,96 |
*payload |
8 б | 49,97 |
Значение копируется дважды — в буфер и из него. 128 байт обходятся на 20 %
дороже восьми; указатель стоит ровно столько же, сколько int64.
От прогона к прогону (три прогона по 1 с): блок 1 — 207,0–214,8 / 158,1–166,5 /
85,63–94,02 / 57,51–59,00 / 77,68–79,84; блок 2 — 6,512–7,052 / 15,79–15,95 /
67,14–67,71; блок 3 — 49,82–50,69 / 51,56–52,15 / 86,68–87,97 / 157,8–159,6 /
390,9–397,5; блок 4 — 32,26–32,95 / 50,42–50,99 / 52,60–53,86; блок 5 —
46,54–46,88 / 49,69–50,84 / 59,87–61,92 / 49,87–50,47. Диапазоны буфера 8 и
буфера 64 не пересекаются, буфера 64 и буфера 1024 — тоже. Столбцы B/op и
allocs/op везде нулевые.
Наблюдения behaviour.go
- Канал — указатель на структуру с мьютексом. Восемь байт в переменной.
- Если получатель уже ждёт, значение минует буфер — видно по
len(ch). selectперемешивает ветки по-настоящему, и у отмены нет приоритета.- Закрытый канал: приём отдаёт остаток, потом нули с
ok = false; отправка и повторныйcloseпаникуют. Отсюда правило «закрывает отправитель». - nil-канал блокирует навсегда, и это рабочий приём: присвоение
nilвыключает веткуselect.close(nil)паникует. - Заблокированная горутина не собирается никогда: 100 горутин, ждущих на nil-канале, пережили две сборки мусора.
- Буфер не делает отправку асинхронной — он отсрочка на фиксированное число значений.
- Порядок сохраняется у каждого отправителя по отдельности, между отправителями его нет.
Что не подтвердилось
«Канал — это про производительность», «канал вместо мьютекса». Канал — это про владение значением и про то, кого будить; по цене он проигрывает мьютексу всегда, и на переключении горутин (×4,25 в блоке 2), и без него (×1,56 в блоке 4). Выбирают его не за скорость.
«Буфер побольше — надёжнее». Замер показывает обратное: из пяти
замеренных размеров лучший — не самый большой, и после 64 значений становится
хуже. И behaviour.go показывает, почему в принципе: буфер —
не развязка, а отсрочка. Как только он полон, отправитель блокируется ровно
так же, как без буфера; большой буфер лишь отодвигает момент и прячет
несоответствие скоростей.
«select попробует ветки сверху вниз» и производное от него
«case <-ctx.Done() первым — значит, приоритетнее». Замерено: 14 992 против
15 008.
«Канал без буфера — это синхронно, с буфером — асинхронно». Синхронность у канала без буфера и правда есть: отправка не завершится, пока получатель не возьмёт. Асинхронности у буферизованного нет — есть ограниченная отсрочка.
Оговорка к самим числам: две горутины на двух ядрах (GOMAXPROCS = 2).
Стоимость передачи между горутинами зависит от того, попали ли они на разные
процессоры и приходится ли планировщику будить поток; на машине с другим числом
ядер соотношения в блоке 1 будут другими. Блоки 3–5 работают в одной горутине и
от этого почти не зависят.
Канал как ребро синхронизации: happens-before (цитаты, без замера)
Запускаемого примера здесь нет и не нужно: это утверждения о том, что ГАРАНТИРОВАНО, а не о том, что происходит на одном прогоне. Прогон не умеет отличить «гарантировано» от «сегодня совпало» — на то и модель памяти.
Первоисточник — The Go Memory Model, раздел Channel communication. Он начинается с рамки:
Channel communication is the main method of synchronization between goroutines. Each send on a particular channel is matched to a corresponding receive from that channel, usually in a different goroutine.
(пер.: «Обмен по каналам — основной способ синхронизации между горутинами. Каждая отправка в конкретный канал сопоставлена соответствующему приёму из этого канала, обычно в другой горутине».)
Дальше три правила. Это и есть то, чем канал отличается от очереди.
Правило 1 — отправка.
A send on a channel is synchronized before the completion of the corresponding receive from that channel.
(пер.: «Отправка в канал синхронизирована раньше завершения соответствующего приёма из этого канала».)
Спецификация тут же разбирает следствие на примере: запись в a идёт до
отправки в c, отправка синхронизирована раньше завершения приёма, приём — до
print, и поэтому программа гарантированно печатает hello, world.
Цепочка строится из двух разных отношений: sequenced before внутри одной
горутины и synchronized before через канал.
Правило 2 — закрытие.
The closing of a channel is synchronized before a receive that returns a zero value because the channel is closed.
(пер.: «Закрытие канала синхронизировано раньше приёма, который возвращает нулевое значение из-за того, что канал закрыт».)
Оговорка в конце существенна: ребро даёт не любой приём из закрытого канала, а именно тот, который вернул НУЛЬ ПОТОМУ ЧТО канал закрыт. Приём, забравший реально отправленное значение, работает по правилу 1.
Отсюда же и то, почему close — полноценный способ оповещения: в примере
спецификации замена c <- 0 на close(c) даёт программу с той же гарантией.
Правило 3 — приём из небуферизованного канала.
A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel.
(пер.: «Приём из небуферизованного канала синхронизирован раньше завершения соответствующей отправки в этот канал».)
Это правило смотрит в ОБРАТНУЮ сторону по сравнению с правилом 1, и оно есть
только у небуферизованного канала. Практический смысл: отправитель, вернувшись
из ch <- v, знает, что получатель уже забрал значение. Модель памяти
подчёркивает границу прямо: если сделать канал буферизованным
(c = make(chan int, 1)), та же программа гарантии уже не даёт — «It might
print the empty string, crash, or do something else».
Правило 4 — обобщение на буфер.
The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.
(пер.: «k-й приём из канала ёмкостью C синхронизирован раньше завершения k+C-й отправки в этот канал».)
Это правило в статье уже процитировано и разобрано (буфер как разрешённое отставание ровно на C значений).
Чем это отличается от «канала как очереди». Очередь отвечает на вопрос
«какие значения и в каком порядке». Все четыре правила выше — не про значения
вообще: в них ни слова о том, ЧТО передано. Они про то, какие записи в ПАМЯТИ,
сделанные до отправки, обязаны быть видны после приёма. Канал, передавший
struct{}{}, не передал ни одного бита полезных данных и при этом сделал всю
работу: установил ребро, после которого чужие записи стали видимыми. Именно
поэтому канал заменяет мьютекс, а очередь — нет.
Расхождение в тексте, которое стоит знать
Формулировка правила 4 в go.dev/ref/mem и в копии, которая едет вместе с
go1.24.7 ($(go env GOROOT)/doc/go_mem.html), РАЗНАЯ.
Онлайн-версия (и то, что процитировано в статье):
The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel.
Локальная копия go1.24.7:
The kth receive on a channel with capacity C is synchronized before the completion of the k+Cth send from that channel completes.
Предлоги переставлены (on/from вместо from/on), и в конце лишнее
completes, из-за которого предложение перестаёт сходиться грамматически.
Вышестоящий текст, судя по всему, поправили позже. Правила 1–3 в обеих копиях
совпадают дословно.
Практический вывод: цитату в статье менять не надо. Она приведена со
ссылкой на go.dev/ref/mem и этому источнику соответствует точно. Но если
кто-то будет сверять с $(go env GOROOT)/doc/go_mem.html, он увидит другой
текст, и это не ошибка статьи.
Обнаружение утечек горутин (leakdetect.go)
Утечка в статье уже показана (behaviour.go, наблюдение про nil-канал:
100 горутин пережили две сборки мусора). Здесь другой вопрос — чем её
ОБНАРУЖИВАЮТ.
Чего в стандартной поставке нет: готового «детектора утечек горутин»,
аналогичного -race. Ни флага, ни пакета. Всё, что есть, — счётчик и профиль,
то есть наблюдение, а не диагноз.
Что есть:
| средство | что показывает |
|---|---|
runtime.NumGoroutine() |
сколько горутин всего. Что утечка ЕСТЬ — да; где — нет |
профиль goroutine (runtime/pprof) |
стеки всех горутин, сгруппированные по месту, с числом в каждой группе. Отвечает «сколько И ГДЕ» |
net/http/pprof |
тот же профиль по HTTP в работающей службе, без пересборки |
runtime.Stack(buf, true) |
дамп стеков всех горутин в память программы |
GOTRACEBACK=all + SIGQUIT |
дамп всех горутин при падении |
Документация профиля (runtime/pprof, тип Profile) описывает его одной
строкой: goroutine - stack traces of all current goroutines (пер.:
«goroutine — трассы стека всех текущих горутин»).
Прогон целиком:
go version: go version go1.24.7 linux/amd64
=== 1. runtime.NumGoroutine: утечка ЕСТЬ, но где — не видно ===
до запуска: NumGoroutine = 1
после 100 утечек: NumGoroutine = 101 (прирост 100)
после двух runtime.GC(): NumGoroutine = 101
сборщик их не трогает: горутина — не мусор, она живая и просто ждёт.
=== 2. Профиль goroutine: видно ГДЕ ===
pprof.Lookup("goroutine").Count() = 101
первые строки дампа (debug=1); адреса меняются от запуска к запуску:
| goroutine profile: total 101
| 100 @ 0x46ddae 0x40c5a5 0x40c152 0x4e94b9 0x474c61
| # 0x4e94b8 main.leak.func1+0x18 /home/claude/deep-engineering-app/bench/gochan/leakdetect.go:67
|
| 1 @ 0x433151 0x46d19d 0x4d5c91 0x4d5ac5 0x4d28eb 0x4e998c 0x43d1ab 0x474c61
| # 0x4d5c90 runtime/pprof.writeRuntimeProfile+0xb0 /usr/local/go1.24.7/src/runtime/pprof/pprof.go:796
| # 0x4d5ac4 runtime/pprof.writeGoroutine+0x44 /usr/local/go1.24.7/src/runtime/pprof/pprof.go:755
| # 0x4d28ea runtime/pprof.(*Profile).WriteTo+0x14a /usr/local/go1.24.7/src/runtime/pprof/pprof.go:377
...
строка с местом утечки:
| 100 @ 0x46ddae 0x40c5a5 0x40c152 0x4e94b9 0x474c61
| # 0x4e94b8 main.leak.func1+0x18 /home/claude/deep-engineering-app/bench/gochan/leakdetect.go:67
число 100 в начале группы — это и есть счётчик застрявших
горутин на ОДНОМ стеке. Именно это отличает профиль от
NumGoroutine: он отвечает не «сколько всего», а «сколько и где».
=== 3. go test -race утечку НЕ ловит ===
тест с утечкой под -race: ПРОШЁЛ
| ok leaky 1.011s
детектор гонок ищет несинхронизированный доступ к памяти.
Заблокированная навсегда горутина ни к чему не обращается,
поэтому ловить ему нечего — и это не недоработка, а другая задача.
=== 4. Проверка до/после на одном runtime: ловит и валит тест ===
тест со сверкой NumGoroutine: УПАЛ
| --- FAIL: TestLeaks (0.51s)
| leak_test.go:27: утекло горутин: было 2, стало 12
| FAIL
| FAIL leaky 0.516s
| FAIL
двадцать строк на стандартной библиотеке — и утечка валит сборку.
Ровно это автоматизируют библиотеки экосистемы.
=== 5. Контроль: та же программа без утечки ===
тест, где канал закрывают: ПРОШЁЛ
| ok leaky 0.012s
Что показал прогон:
NumGoroutineфиксирует факт. 1 → 101 после ста утечек, и после двухruntime.GC()по-прежнему 101. Сборщик их не трогает: горутина не мусор, она живая и просто ждёт.- Профиль показывает МЕСТО. В дампе
debug=1строка100 @ 0x46ddae ..., а под ней —main.leak.func1+0x18 .../leakdetect.go:67. Число в начале группы — это счётчик застрявших на ОДНОМ стеке. Именно это отличает профиль от счётчика: он отвечает не «сколько всего», а «сколько и где». Для поиска утечки нужен именно он. go test -raceутечку НЕ ловит. Тест с десятью навсегда заблокированными горутинами проходит:ok leaky 1.011s. И это не недоработка: детектор гонок ищет несинхронизированный доступ к памяти, а заблокированная навсегда горутина ни к чему не обращается. Ловить ему нечего.- Проверка «до/после» на одном
runtimeработает и валит тест. Двадцать строк без единой зависимости дают--- FAIL: TestLeaks (0.51s) ... утекло горутин: было 2, стало 12. Контрольный прогон сclose(ch)проходит за 0,012 с.
Важная деталь проверки №4, без которой она даёт ложные срабатывания: между
запуском горутин и сравнением счётчика нужен цикл ожидания. Горутины, которые
ЗАВЕРШАЮТСЯ, должны успеть это сделать — планировщик не обязан довести их до
конца к моменту сравнения. В leakdetect.go это цикл до 50 попыток по 10 мс.
Экосистема. go.uber.org/goleak — самая известная библиотека для этой
задачи; по документации она сверяет набор горутин до и после теста и валит
тест на «лишних», то есть автоматизирует ровно проверку №4, добавляя к ней
список известных служебных горутин, которые не считаются утечкой. Здесь она
не запускалась: в контейнере нет доступа к прокси модулей, поставить и
проверить её было не на чем. Всё, что в прогоне выше, — только стандартная
поставка.
Источники
- Спецификация Go, разделы Channel types, Send statements, Receive operator, Select statements, Close — https://go.dev/ref/spec
runtime/chan.go— структураhchan,sendDirect, поведение nil-канала и паникиclose— https://go.dev/src/runtime/chan.goruntime/select.go— перемешиваниеpollorder, сортировкаlockorder,sellock— https://go.dev/src/runtime/select.go- The Go Memory Model, раздел Channel communication — четыре правила синхронизации: отправка, закрытие, приём из небуферизованного, обобщение на буфер — https://go.dev/ref/mem
$(go env GOROOT)/doc/go_mem.html— копия модели памяти, едущая вместе с go1.24.7; формулировка правила проk-й приём в ней отличается от онлайн-версииruntime/pprof, описание профиляgoroutine— https://go.dev/pkg/runtime/pprof/net/http/pprof— https://go.dev/pkg/net/http/pprof/- Data Race Detector — что детектор гонок ищет и чего не ищет — https://go.dev/doc/articles/race_detector
- Effective Go, раздел Channels — https://go.dev/doc/effective_go
- Go Concurrency Patterns: Pipelines and cancellation — «Goroutines are not garbage collected; they must exit on their own» — https://go.dev/blog/pipelines
Script
388 lines//go:build ignore
// Наблюдения над каналом: что печатает сам Go, а не что про канал рассказывают.
//
// Здесь нет ни одного измерения времени. Всё, что нужно доказать про канал, —
// про то, куда попадает значение, что бывает с закрытым и с nil, и как select
// выбирает из нескольких готовых. Это проверяется счётчиками и сравнением, а
// не секундомером.
//
// ЗАПУСК:
//
// go run bench/gochan/behaviour.go
//
// Файл помечен `//go:build ignore`: он package main, а рядом лежит тест пакета
// gochan, и без метки `go test ./...` спотыкался бы о два пакета в одном
// каталоге. На `go run` с явным именем файла метка не влияет.
//
// Снято на go1.24.7 linux/amd64.
package main
import (
"context"
"fmt"
"runtime"
"sort"
"strings"
"sync"
"time"
"unsafe"
)
func head(n int, title string) {
fmt.Printf("\n%s\n%d. %s\n%s\n", strings.Repeat("=", 72), n, title, strings.Repeat("=", 72))
}
// ---------------------------------------------------------------------------
func sectionHeader() {
head(1, "Канал — это указатель на структуру с мьютексом")
var ch chan int
fmt.Printf(" unsafe.Sizeof(chan int) = %d байт\n", unsafe.Sizeof(ch))
fmt.Println(" Одно слово: указатель на hchan в рантайме. Там лежат кольцевой")
fmt.Println(" буфер, две очереди ожидающих горутин — и обычный мьютекс:")
fmt.Println()
fmt.Println(" type hchan struct {")
fmt.Println(" qcount uint // сколько сейчас в буфере")
fmt.Println(" dataqsiz uint // размер буфера")
fmt.Println(" buf unsafe.Pointer // сам буфер")
fmt.Println(" ...")
fmt.Println(" recvq waitq // кто ждёт приёма")
fmt.Println(" sendq waitq // кто ждёт отправки")
fmt.Println(" lock mutex")
fmt.Println(" }")
fmt.Println()
fmt.Println(" Канал не «без блокировок». Каждая отправка и каждый приём берут")
fmt.Println(" этот мьютекс. Отсюда и цена, и то, что канал не быстрее мьютекса")
fmt.Println(" никогда — он и есть мьютекс плюс очередь.")
}
// ---------------------------------------------------------------------------
func sectionDirect() {
head(2, "Если получатель уже ждёт, значение минует буфер")
fmt.Println(" Проверяется через len(ch): если бы значение легло в буфер,")
fmt.Println(" длина стала бы 1.\n")
// Получатель встал в очередь ДО отправки.
c := make(chan int, 4)
var wg sync.WaitGroup
wg.Add(1)
ready := make(chan struct{})
go func() {
defer wg.Done()
close(ready)
<-c
}()
<-ready
time.Sleep(20 * time.Millisecond) // дать получателю дойти до gopark
c <- 1
fmt.Printf(" получатель ждал: len(ch) = %d cap(ch) = %d\n", len(c), cap(c))
wg.Wait()
// Получателя нет — значение обязано лечь в буфер.
c2 := make(chan int, 4)
c2 <- 1
fmt.Printf(" получателя не было: len(ch) = %d cap(ch) = %d\n", len(c2), cap(c2))
fmt.Println("\n В рантайме это отдельная функция sendDirect, и комментарий к ней")
fmt.Println(" объясняет, куда именно копируется значение: «src is on our stack,")
fmt.Println(" dst is a slot on another stack» — из стека отправителя прямо в")
fmt.Println(" стек получателя, минуя и буфер, и кучу.")
fmt.Println()
fmt.Println(" Практический смысл: буфер нужен НЕ для скорости передачи, когда")
fmt.Println(" обе стороны успевают. Он нужен, чтобы отправитель не блокировался,")
fmt.Println(" когда получатель отстал.")
}
// ---------------------------------------------------------------------------
func sectionSelect() {
head(3, "select выбирает из готовых равномерно — и это настоящая случайность")
a, b, d := make(chan int, 1), make(chan int, 1), make(chan int, 1)
counts := map[string]int{}
const runs = 30000
for i := 0; i < runs; i++ {
a <- 1
b <- 1
d <- 1
select {
case <-a:
counts["a"]++
case <-b:
counts["b"]++
case <-d:
counts["d"]++
}
drain(a)
drain(b)
drain(d)
}
fmt.Printf(" %d выборов из трёх готовых каналов:\n", runs)
for _, k := range []string{"a", "b", "d"} {
fmt.Printf(" %s: %5d (%.1f %%, ожидание 33,3 %%)\n",
k, counts[k], 100*float64(counts[k])/runs)
}
// Следствие, которое стоит целой статьи об отменах. Если и работа готова,
// и контекст уже отменён, select НЕ отдаёт предпочтение отмене: он выберет
// равномерно. Код, который «на отмене сразу выходит», при готовой работе
// выходит примерно в половине случаев.
ctx, cancel := context.WithCancel(context.Background())
cancel() // отменён ЗАРАНЕЕ: <-ctx.Done() готов на каждом заходе
work := make(chan int, 1)
var tookWork, tookCancel int
for i := 0; i < runs; i++ {
work <- 1
select {
case <-ctx.Done():
tookCancel++
case <-work:
tookWork++
}
drain(work)
}
fmt.Printf("\n %d заходов, где контекст УЖЕ отменён, а работа готова:\n", runs)
fmt.Printf(" выбрана отмена: %5d (%.1f %%)\n",
tookCancel, 100*float64(tookCancel)/runs)
fmt.Printf(" выбрана работа: %5d (%.1f %%)\n",
tookWork, 100*float64(tookWork)/runs)
fmt.Println(" Отмена не имеет приоритета. Чтобы выйти сразу, отмену надо")
fmt.Println(" проверять отдельным select с default ДО основного выбора.")
fmt.Println("\n Порядок опроса перемешивается на каждом входе в select —")
fmt.Println(" тасованием Фишера-Йетса в runtime/select.go:")
fmt.Println()
fmt.Println(" j := cheaprandn(uint32(norder + 1))")
fmt.Println(" pollorder[norder] = pollorder[j]")
fmt.Println(" pollorder[j] = uint16(i)")
fmt.Println()
fmt.Println(" Это стоит сравнить с картой: там «случайный» порядок обхода —")
fmt.Println(" всего лишь сдвиг одной последовательности, и различных порядков")
fmt.Println(" столько же, сколько записей. Здесь перемешивание настоящее.")
}
func drain(c chan int) {
for len(c) > 0 {
<-c
}
}
// ---------------------------------------------------------------------------
func sectionClosed() {
head(4, "Закрытый канал: что можно и что паникует")
c := make(chan int, 2)
c <- 7
close(c)
v, ok := <-c
fmt.Printf(" приём из закрытого с остатком: v = %d, ok = %v\n", v, ok)
v, ok = <-c
fmt.Printf(" приём из закрытого пустого: v = %d, ok = %v\n", v, ok)
v, ok = <-c
fmt.Printf(" и ещё раз: v = %d, ok = %v\n", v, ok)
n := 0
for range c {
n++
}
fmt.Printf(" range по закрытому пустому: %d итераций, цикл завершился\n", n)
fmt.Println()
try("отправка в закрытый", func() { c <- 1 })
try("close закрытого", func() { close(c) })
fmt.Println("\n Отсюда правило, которое обычно формулируют как договорённость:")
fmt.Println(" закрывает тот, кто отправляет. Не из вежливости — у получателя")
fmt.Println(" нет способа закрыть канал безопасно: отправитель об этом не")
fmt.Println(" узнает и упадёт на следующей отправке.")
}
func try(name string, fn func()) {
defer func() {
if r := recover(); r != nil {
fmt.Printf(" %-24s -> паника: %v\n", name, r)
return
}
fmt.Printf(" %-24s -> прошло без паники\n", name)
}()
fn()
}
// ---------------------------------------------------------------------------
func sectionNil() {
head(5, "nil-канал блокирует навсегда — и это рабочий приём")
var c chan int
fmt.Printf(" var c chan int -> c == nil: %v, len = %d, cap = %d\n", c == nil, len(c), cap(c))
try("close(nil)", func() { close(c) })
select {
case <-c:
fmt.Println(" из nil что-то пришло — этого не бывает")
default:
fmt.Println(" select с nil и default: выбран default")
}
fmt.Println("\n Отправка и приём по nil-каналу блокируются навсегда: в рантайме")
fmt.Println(" это gopark с причиной waitReasonChanSendNilChan при отправке и\n waitReasonChanReceiveNilChan при приёме. Ошибкой это не")
fmt.Println(" считается — на этом построен приём «выключить ветку select»:")
fmt.Println()
fmt.Println(" for in != nil || out != nil {")
fmt.Println(" select {")
fmt.Println(" case v, ok := <-in:")
fmt.Println(" if !ok { in = nil; continue } // ветка выключена")
fmt.Println(" ...")
fmt.Println(" case out <- next:")
fmt.Println(" ...")
fmt.Println(" }")
fmt.Println(" }")
fmt.Println()
fmt.Println(" Присвоение nil исключает случай из выбора, не ломая сам select.")
}
// ---------------------------------------------------------------------------
func sectionLeak() {
head(6, "Горутина, заблокированная на канале, не собирается никогда")
var never chan int
before := runtime.NumGoroutine()
for i := 0; i < 100; i++ {
go func() { <-never }()
}
time.Sleep(50 * time.Millisecond)
runtime.GC()
runtime.GC()
after := runtime.NumGoroutine()
fmt.Printf(" было горутин: %d\n", before)
fmt.Printf(" запущено 100, ждут на nil-канале\n")
fmt.Printf(" после двух сборок мусора: %d\n", after)
fmt.Println("\n Сборщик мусора собирает объекты, а не горутины. Горутина,")
fmt.Println(" заблокированная навсегда, остаётся жить вместе со своим стеком")
fmt.Println(" и всем, на что этот стек ссылается.")
fmt.Println()
fmt.Println(" Самый частый способ это устроить — вернуться из функции раньше,")
fmt.Println(" чем прочитать из канала, в который пишет запущенная ею горутина:")
fmt.Println()
fmt.Println(" ch := make(chan result) // без буфера")
fmt.Println(" go func() { ch <- work() }() // отправитель встанет тут")
fmt.Println(" select {")
fmt.Println(" case r := <-ch: return r")
fmt.Println(" case <-ctx.Done(): return ctx.Err() // и горутина осталась")
fmt.Println(" }")
fmt.Println()
fmt.Println(" Лечится буфером на одну позицию: тогда отправителю есть куда")
fmt.Println(" положить результат, и он завершится, даже если его никто не ждёт.")
}
// ---------------------------------------------------------------------------
func sectionBuffer() {
head(7, "Буфер не делает отправку асинхронной")
c := make(chan int, 3)
for i := 1; i <= 3; i++ {
c <- i
}
fmt.Printf(" буфер на 3, отправили 3: len = %d, cap = %d\n", len(c), cap(c))
select {
case c <- 4:
fmt.Println(" четвёртая отправка прошла — этого не бывает")
default:
fmt.Println(" четвёртая отправка: буфер полон, отправитель заблокировался бы")
}
<-c
select {
case c <- 4:
fmt.Printf(" после одного приёма отправка прошла: len = %d\n", len(c))
default:
fmt.Println(" всё ещё полон — этого не бывает")
}
fmt.Println("\n Буфер — это отсрочка на фиксированное число значений, а не")
fmt.Println(" развязка. Как только он полон, отправитель блокируется ровно так")
fmt.Println(" же, как на канале без буфера. Поэтому размер буфера — не «чем")
fmt.Println(" больше, тем лучше», а ответ на вопрос: на сколько значений")
fmt.Println(" получателю позволено отстать.")
}
// ---------------------------------------------------------------------------
func sectionOrder() {
head(8, "Порядок значений канал сохраняет, порядок горутин — нет")
const senders = 4
const each = 5
c := make(chan string, senders*each)
var wg sync.WaitGroup
for s := 0; s < senders; s++ {
wg.Add(1)
go func(s int) {
defer wg.Done()
for i := 0; i < each; i++ {
c <- fmt.Sprintf("%d.%d", s, i)
}
}(s)
}
wg.Wait()
close(c)
var got []string
for v := range c {
got = append(got, v)
}
// Проверяем: внутри одного отправителя порядок обязан сохраниться.
perSender := map[string][]string{}
for _, v := range got {
s := strings.Split(v, ".")[0]
perSender[s] = append(perSender[s], v)
}
keys := make([]string, 0, len(perSender))
for k := range perSender {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Printf(" получено %d значений от %d отправителей\n", len(got), senders)
fmt.Printf(" первые десять: %v\n", got[:10])
fmt.Println()
ok := true
for _, k := range keys {
seq := perSender[k]
sorted := append([]string(nil), seq...)
sort.Strings(sorted)
same := fmt.Sprint(seq) == fmt.Sprint(sorted)
ok = ok && same
fmt.Printf(" отправитель %s: %v — порядок сохранён: %v\n", k, seq, same)
}
fmt.Printf("\n порядок внутри каждого отправителя сохранён у всех: %v\n", ok)
fmt.Println(" А между отправителями его нет и быть не может: канал —")
fmt.Println(" очередь, но кто в неё встанет раньше, решает планировщик.")
}
// ---------------------------------------------------------------------------
func main() {
fmt.Printf("Go %s, GOMAXPROCS = %d\n", runtime.Version(), runtime.GOMAXPROCS(0))
sectionHeader()
sectionDirect()
sectionSelect()
sectionClosed()
sectionNil()
sectionLeak()
sectionBuffer()
sectionOrder()
fmt.Println()
}