Канал в Go: буфер, мьютекс и две очереди — и что из них работает в каждый момент
В канале три части: кольцевой буфер, обычный мьютекс и две очереди ожидающих горутин. Почти всё удивительное в нём — про то, какая из трёх работает в конкретный момент. Когда очередь: значение идёт мимо буфера, и len(ch) остаётся нулём. Когда буфер: больше — не значит быстрее, лучший из замеренных размеров не самый большой. Когда только мьютекс: канал вдесятеро дороже atomic, потому что он и есть мьютекс плюс учёт.
Полное техническое изложение
TL;DR
- Канал — 8 байт: указатель на структуру, внутри которой буфер, две очереди ожидающих и обычный мьютекс.
- Если получатель уже ждёт, значение минует буфер:
len(ch)после отправки остаётся нулём. - У отмены нет приоритета: отменённый контекст выигрывает
select14 992 раза из 30 000. - Буфер побольше — не быстрее: 64 значения — 57,91 нс, 1024 — 78,97.
- Канал дороже мьютекса даже без переключения горутин: 50,48 против 32,36.
- Заблокированная горутина не собирается никогда.
В переменной — указатель
unsafe.Sizeof(make(chan int)) // 8 — одно словоВнутри, в рантайме, — кольцевой буфер, очередь ждущих отправителей, очередь
ждущих получателей и lock mutex. Последнее важнее всего: канал не «без
блокировок», каждая отправка и каждый приём берут этот мьютекс.
Значение не всегда проходит через буфер
Если получатель уже стоит в очереди, отправитель копирует значение прямо в его стек, минуя буфер. Проверяется из обычной программы:
ch := make(chan int, 4)
// получатель уже ждёт
ch <- 1
fmt.Println(len(ch)) // 0 — через буфер значение не проходилоОтсюда ответ на вопрос «зачем буфер». Не для скорости: когда обе стороны успевают, буфер не участвует. Буфер — это разрешение получателю отстать на заданное число значений. Как только он полон, отправитель блокируется ровно так же, как без буфера.
select не смотрит ветки сверху вниз
Ветка case <-ctx.Done(), стоящая в исходнике первой, не имеет приоритета.
Замерено: контекст отменён ещё до цикла, работа готова на каждом заходе —
отмена выигрывает ровно половину заходов.
Чтобы выйти сразу, отмену надо проверять отдельным select с default
перед основным:
select {
case <-ctx.Done():
return ctx.Err()
default:
}Порядок опроса перемешивается тасованием Фишера-Йетса на каждом входе в
select. Между заходами не сохраняется ничего.
Что канал стоит
Вкладки между собой не сопоставляются: в первых двух работают две горутины и между ними происходит переключение, в остальных трёх всё происходит в одной.
Два вывода расходятся с расхожим. Буфер на 1024 значения медленнее буфера на 64 — зависимость не монотонна, и лучший из пяти замеренных размеров не самый большой. И канал проигрывает мьютексу даже там, где никого не надо будить (50,48 против 32,36 нс): он и есть мьютекс плюс очередь. Выбирают канал не за скорость, а за то, что он передаёт владение значением.
Закрытый и nil
| что | результат |
|---|---|
| приём из закрытого с остатком | отдаёт остаток, ok = true |
| приём из закрытого пустого | нулевое значение, ok = false |
| отправка в закрытый | паника send on closed channel |
close дважды | паника close of closed channel |
close(nil) | паника close of nil channel |
| отправка/приём по nil | блокируется навсегда, без паники |
Отсюда правило «закрывает тот, кто отправляет»: у получателя нет безопасного способа закрыть канал — отправитель об этом не узнает и упадёт.
А блокировка на nil-канале — рабочий приём: присвоение nil выключает ветку
select, не ломая сам select.
if !ok {
in = nil // источник кончился — ветка больше никогда не готова
continue
}Утечка горутин
ch := make(chan result) // БЕЗ буфера
go func() { ch <- work() }()
select {
case r := <-ch: return r
case <-ctx.Done(): return ctx.Err() // и горутина осталась навсегда
}Горутины не собираются сборщиком мусора — они должны завершиться сами. По таймауту функция вернулась, канал никто не читает, горутина стоит на отправке до конца процесса вместе со всем, что держит её стек.
Лечится одним символом: make(chan result, 1). Отправителю есть куда положить
результат, и он завершится, даже если результат уже никому не нужен.
TL;DR
Вся статья держится на одном: в канале три части — кольцевой буфер, обычный мьютекс и две очереди ожидающих горутин. Что именно из трёх работает в конкретный момент, и объясняет всё остальное.
Работает очередь — получатель уже стоит в recvq. Тогда значение идёт
мимо буфера, копируется прямо в его стек, и это видно из программы:
len(ch) после отправки остаётся нулём.
Работает буфер — получателя нет. Тогда буфер не убирает блокировку, а отсрочивает её ровно на свою вместимость. И больше не значит быстрее: 64 значения — 57,91 нс, 1024 — 78,97. Кривая не монотонна.
Работает один мьютекс — будить некого. Тогда видно, чем канал является на
самом деле: 67,19 нс против 15,80 у мьютекса и 6,553 у atomic — ×10,25.
Канал и есть мьютекс плюс учёт очередей.
select берёт мьютексы всех каналов сразу и перемешивает ветки на каждом
заходе. Отсюда две вещи: у отмены нет приоритета — её ветка выигрывает
14 992 раза из 30 000, ровно половину, — и цена растёт с числом веток: вторая
добавляет 35,6 нс, восемь веток стоят 391,4 против 49,85.
Очередь, из которой некому забрать, — это утечка. Заблокированная горутина
не собирается никогда: сто горутин на nil-канале пережили две сборки мусора.
И -race их не находит.
А ради чего всё это — в предпоследнем разделе: канал переносит не значения, а видимость записей.
Канал обычно рисуют трубой: отправитель кладёт в очередь, получатель забирает.
Картинка удобная и в самом частом случае неверная — а из-за неё потом
непонятно, почему буфер побольше не ускоряет, почему select не смотрит ветки
сверху вниз и почему горутина, которую никто не читает, живёт до конца
процесса.
Все эти вопросы имеют один ответ, и он в устройстве. Поэтому статья устроена
так: сначала три части структуры, потом по одному следствию на каждую часть,
потом состояния той же структуры — закрытый и nil-канал, — потом утечка как
её прямое продолжение. И в конце то, ради чего канал вообще нужен: гарантии
видимости памяти. Числа сняты на go1.24.7; как их повторить — в последнем
разделе.
Шаг первый: что лежит в переменной
unsafe.Sizeof(make(chan int)) // 8 — один указатель
unsafe.Sizeof(map[int]int{}) // 8
unsafe.Sizeof([]int{}) // 24Восемь байт: указатель на hchan в рантайме. Отсюда сразу два следствия.
Канал, переданный в функцию, — тот же самый канал (копируется указатель). И
nil у канала — это настоящий нулевой указатель, а не «пустой канал»: за ним
нет ни буфера, ни очередей, и потому класть значение некуда и будить некого.
Что из этого выходит, будет ниже, и это не то, чего ждёшь.
Внутри hchan — то, что обычно и рисуют, плюс одна строка, которую обычно не
рисуют:
type hchan struct {
qcount uint // сколько сейчас в буфере
dataqsiz uint // размер буфера
buf unsafe.Pointer // сам буфер
// ...
recvq waitq // кто ждёт приёма
sendq waitq // кто ждёт отправки
lock mutex
}Вот эти три части и есть вся статья:
- буфер (
buf,qcount,dataqsiz) — место, куда значение кладут, когда забрать его некому; - две очереди (
recvq,sendq) — горутины, которые стоят и ждут; - мьютекс (
lock) — то, что берётся на каждой операции.
Последнее стоит проговорить отдельно, потому что его чаще всего и пропускают. Канал не «без блокировок» и не «лёгкий примитив»: каждая отправка и каждый приём берут этот мьютекс. Всё, что будет дальше про цену, растёт отсюда.
Теперь по частям — по одному следствию на каждую.
Следствие первое (работает очередь): значение идёт мимо буфера
Начнём с самого частого случая: получатель уже пришёл и стоит в recvq.
Тогда отправитель не кладёт значение в буфер. Он копирует его прямо в стек ждущей горутины и будит её. В рантайме за это отвечает отдельная функция, и её комментарий говорит, откуда куда:
src is on our stack, dst is a slot on another stack
src — на нашем стеке, dst — слот на другом стеке.
Это не деталь реализации, о которой можно не знать: она проверяется из обычной программы, без всякого рантайма.
ch := make(chan int, 4)
// Сценарий 1: получатель успел встать в очередь первым.
ready := make(chan struct{})
go func() { close(ready); <-ch }()
<-ready
time.Sleep(10 * time.Millisecond) // дать ему запарковаться
ch <- 1
fmt.Println(len(ch)) // 0
// Сценарий 2: получателя нет.
ch2 := make(chan int, 4)
ch2 <- 1
fmt.Println(len(ch2)) // 1Ноль. Значение дошло, отправка завершилась — а в буфере пусто, потому что через буфер оно не проходило. Буфер на четыре значения при этом был: он просто не понадобился.
Отсюда ответ на вопрос, который обычно решают на глазок: зачем буфер. Не затем, чтобы передача была быстрее, — когда обе стороны успевают, буфер вообще не участвует. Он затем, чтобы отправитель не вставал, когда получатель отстал. Размер буфера — это не «чем больше, тем лучше», а ответ на вопрос: на сколько значений получателю позволено отстать.
Спецификация формулирует то же самое как условие, при котором отправка может состояться:
A send on an unbuffered channel can proceed if a receiver is ready. A send on
a buffered channel can proceed if there is room in the buffer.
Отправка по небуферизованному каналу может состояться, если готов получатель. Отправка по буферизованному каналу может состояться, если в буфере есть место.
А модель памяти — как точную величину допустимого отставания:
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 значений разрешает отправителю уйти вперёд ровно на C и
ни на одно больше. Это не асинхронность, а отсрочка с известной границей.
Следствие второе (работает буфер): больше — не значит быстрее
Раз буфер нужен на случай отставания, напрашивается вывод «поставлю побольше, чтобы точно не блокировалось». Замер его не подтверждает.
Прежде чем читать числа, одно правило: вкладки между собой не сопоставляются. Канал стоит принципиально по-разному в зависимости от того, приходится ли парковать и будить горутину. В первых двух вкладках приходится — там работают две горутины; в остальных трёх всё происходит в одной. Разница на порядок. Складывать их в одну шкалу значило бы сравнивать разный объём работы.
Первая вкладка — про буфер:
| буфер | нс/оп |
|---|---|
| 0 | 208,0 |
| 1 | 163,2 |
| 8 | 89,65 |
| 64 | 57,91 |
| 1024 | 78,97 |
Буфер экономит переключения между горутинами: пока в нём есть значения, получателю не надо парковаться, а отправителю — будить. До какого-то размера это выигрыш. Дальше выигрывать уже нечего — переключений и так почти нет, — и становится хуже. Диапазоны трёх прогонов для 64 и 1024 не пересекаются: 57,51–59,00 против 77,68–79,84.
Почему становится хуже, замер не показывает, и додумывать тут нечего: правдоподобная догадка про кэш-след кольца (8 КБ против 512 байт) разбивается о то, что 8 КБ помещаются и в L1d. Это гипотеза, а не результат.
Число 64 не надо запоминать: оно зависит от размера элемента, машины и того, насколько отстаёт получатель. Запоминать надо, что зависимость не монотонна: из пяти замеренных размеров лучший — не самый большой. «Поставлю буфер побольше, чтобы точно не блокировалось» — не бесплатная предосторожность, и вдобавок она не делает того, ради чего её ставят: буфер полнится ровно до тех пор, пока отправитель быстрее.
Следствие третье (работает один мьютекс): канал и есть мьютекс плюс учёт
Теперь случай, где ни очередь, ни буфер не работают: обе стороны в одной горутине, будить некого. Здесь видно, чем канал является в основании.
| способ | нс/оп | к atomic |
|---|---|---|
atomic.AddInt64 | 6,553 | ×1,00 |
| мьютекс | 15,80 | ×2,41 |
| канал | 67,19 | ×10,25 |
Все три строки увеличивают один и тот же счётчик и дают одно и то же число. «Не общайтесь через разделяемую память» читают как «канал вместо мьютекса всегда» — и вот цена этого прочтения там, где нужен именно счётчик.
Это не довод против каналов. У канала другая работа: передать владение значением и разбудить того, кто ждёт. Мьютекс ни того, ни другого не делает. Довод здесь ровно один: за передачу владения платят, и когда передавать нечего, платят зря.
Сколько стоит само значение. Оно копируется дважды — в буфер и из буфера:
| элемент | размер | нс/оп |
|---|---|---|
struct{} | 0 б | 46,66 |
int64 | 8 б | 49,79 |
[128]byte | 128 б | 59,96 |
*payload | 8 б | 49,97 |
Структура на 128 байт стоит на 20 % дороже восьми байт. Указатель стоит ровно
столько же, сколько int64, — и это ровно та строка, где надо остановиться.
Отправив указатель, вы отправили не значение, а доступ к общей памяти. Обе
горутины теперь смотрят на один объект, и всё, ради чего канал брали,
кончилось. Иногда это осознанный выбор: большая структура, которую получатель
только читает. Но выбор, а не оптимизация — гонку данных компилятор здесь не
найдёт, а go test -race найдёт, только если она случится на прогоне.
Следствие четвёртое (select берёт мьютексы всех каналов сразу)
select работает не с одним каналом, а со всеми сразу — и отсюда обе его
неожиданности: отсутствие приоритета и цена, растущая с числом веток.
У отмены нет приоритета
Три равноправные ветки выбираются равномерно — это ровно то, что обещает спецификация:
If one or more of the communications can proceed, a single one that can
proceed is chosen via a uniform pseudo-random selection.
Если состояться могут одна или несколько операций обмена, то одна из них выбирается равномерным псевдослучайным выбором.
Интересно не это, а то, что из этого следует в самом обычном цикле с отменой:
for {
select {
case <-ctx.Done(): // «стоит первым — значит, приоритетнее»
return ctx.Err()
case v := <-work:
handle(v)
}
}Замерено: контекст отменён до цикла, то есть его ветка готова на каждом заходе. За 30 000 заходов она выиграла 14 992 раза. Ровно половину. Генератор не засевается, так что у вас счётчики будут другими — повторяется доля, а не число.
Если работа поступает непрерывно, такой цикл после отмены сделает в среднем ещё одну итерацию, а с вероятностью 1/1024 — ещё десять. На тесте с одним сообщением это не воспроизводится никогда. В работающей системе под нагрузкой — каждый раз.
Приоритет в select выражается не порядком веток, а отдельным выбором,
выполненным раньше:
for {
select {
case <-ctx.Done():
return ctx.Err()
default: // не готово — идём дальше, не блокируемся
}
select {
case <-ctx.Done():
return ctx.Err()
case v := <-work:
handle(v)
}
}Механизм в исходнике — три строки, и главное в них то, что они выполняются на
каждом входе в select:
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)Тасование Фишера-Йетса. Ничего не кэшируется между заходами.
Стоит поставить это рядом с картой. Там порядок обхода тоже называют случайным, но различных порядков у карты до 896 записей оказывается ровно столько, сколько записей: случаен только сдвиг. Здесь перемешивание настоящее. Два «рандомизировано» в одном языке означают разные вещи, и разница проверяется счётчиком.
И поэтому же цена растёт с числом веток
| как написан приём | нс/оп | к предыдущей |
|---|---|---|
без 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
(оптимизация: select из одного случая — одна операция).
selectgo в этом случае не вызывается вовсе.
Настоящий select начинается со второй ветки, и первое же его появление стоит
35,6 нс — почти три четверти цены самого приёма. Дальше до четырёх веток
каждая следующая стоит примерно те же 36 нс (+71,5 на две), а на восьми цена
ветки вырастает более чем в полтора раза: +232,7 на четыре, то есть по 58 на
ветку против 36.
Причина — в том, что selectgo делает на каждом входе. Перемешивает ветки —
те самые три строки выше. Потом сортирует их по адресу канала, чтобы блокировки
всегда брались в одном порядке и не случилось взаимной:
simple heap sort, to guarantee n log n time and constant stack footprint
простая пирамидальная сортировка, гарантирующая время n log n и постоянный расход стека
И затем берёт мьютекс на всех каналах сразу, а не только на том, который в
итоге сработает. Восемь веток на восьми разных каналах — восемь захватов и
восемь освобождений, плюс перемешивание, плюс сортировка. (Повтор sellock
пропускает: две ветки на одном канале дадут один захват.) Ничего между заходами
не сохраняется, потому что набор каналов у select может меняться от захода к
заходу.
Вот здесь и сходятся обе половины раздела: перемешивание и захват всех мьютексов — это одна и та же работа на каждом входе. Из первого следует отсутствие приоритета, из второго — цена ветки.
Практический вывод скромнее, чем кажется: select из двух-трёх веток стоит
десятки наносекунд и не заслуживает внимания рядом с любой настоящей работой.
Но select из восьми веток в горячем цикле — уже статья расходов, и её видно в
профиле как runtime.selectgo.
Состояния той же структуры: закрытый и nil
Четыре следствия из трёх частей разобраны. Теперь два состояния, в которых те же три части ведут себя иначе.
Закрытый канал
ch := make(chan int, 4)
ch <- 7
close(ch)
v, ok := <-ch // 7, true — остаток отдаётся
v, ok = <-ch // 0, false — и дальше всегда так
v, ok = <-ch // 0, false
for range ch {} // 0 итераций, цикл завершаетсяЗакрытие не выбрасывает то, что в буфере: приём сначала отдаёт остаток и лишь
потом начинает возвращать нули. Именно поэтому for range ch — корректный
способ дочитать канал до конца.
Три вещи паникуют, и тексты паник разные:
| что | паника |
|---|---|
ch <- 1 в закрытый | send on closed channel |
close(ch) дважды | close of closed channel |
close(nil) | close of nil channel |
Отсюда правило «закрывает тот, кто отправляет», которое обычно подают как
договорённость о вкусе. Это не вкус. У получателя нет безопасного способа
закрыть канал: отправитель об этом не узнает и упадёт на следующей отправке, а
проверить «закрыт ли» перед отправкой нельзя — между проверкой и отправкой
канал успеет закрыться. Когда отправителей несколько, закрывать не может уже
никто из них поодиночке: нужен либо отдельный канал done, либо
sync.WaitGroup и закрытие после Wait.
nil-канал: не ошибка, а инструмент
У nil-канала указателя нет, а значит, нет ни буфера, ни очередей — положить
значение некуда и разбудить некого:
var c chan int // nil
c == nil // true
len(c), cap(c) // 0, 0
c <- 1 // блокируется НАВСЕГДА
<-c // блокируется НАВСЕГДА
close(c) // паникаОтправка и приём по nil-каналу не паникуют и не возвращают ошибку — они паркуют
горутину навсегда. В рантайме это gopark с причиной
waitReasonChanSendNilChan при отправке и waitReasonChanReceiveNilChan при
приёме, и никто эту горутину не разбудит.
Звучит как ловушка, и в цикле for { <-c } это она и есть. Но у той же
особенности есть штатное применение, которое без неё не пишется: nil-канал
выключает ветку select.
for in != nil || out != nil {
select {
case v, ok := <-in:
if !ok {
in = nil // источник кончился — ветка выключена
continue
}
pending = append(pending, v)
out = downstream
case out <- pending[0]:
pending = pending[1:]
if len(pending) == 0 {
out = nil // отдавать нечего — ветка выключена
}
}
}Присвоение nil исключает случай из выбора, не ломая сам select и не требуя
ни флагов, ни второй копии цикла с другим набором веток. Ветка остаётся в
исходнике и просто никогда не готова.
Очередь, из которой некому забрать: утечка горутин
nil-канал из предыдущего раздела — крайний случай общего правила: горутина,
вставшая в очередь, из которой её некому забрать, стоит там до конца процесса.
И это самая обычная утечка в Go.
before := runtime.NumGoroutine() // 1
for i := 0; i < 100; i++ {
go func() { var c chan int; <-c }()
}
runtime.GC()
runtime.GC()
runtime.NumGoroutine() // 101Сборщик мусора собирает объекты, а не горутины:
Goroutines are not garbage collected; they must exit on their own.
Горутины не собираются сборщиком мусора; они должны завершиться сами.
Заблокированная навсегда горутина остаётся жить вместе со своим стеком и всем, на что этот стек ссылается:
This is a resource leak: goroutines consume memory and runtime resources, and
heap references in goroutine stacks keep data from being garbage collected.
Это утечка ресурса: горутины расходуют память и ресурсы рантайма, а ссылки на кучу в стеках горутин не дают собрать данные.
Самый частый способ устроить это не нарочно — вернуться раньше, чем прочитать из канала, в который пишет запущенная вами горутина:
func fetch(ctx context.Context) (result, error) {
ch := make(chan result) // БЕЗ буфера
go func() { ch <- work() }() // отправитель встанет здесь
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return result{}, ctx.Err() // и горутина осталась навсегда
}
}По таймауту функция вернулась, ch больше никто не читает, а горутина внутри
стоит на отправке до конца процесса — вместе со всем, что держит work(). Под
нагрузкой с таймаутами это утечка, растущая линейно по числу запросов.
Лечится одним символом:
ch := make(chan result, 1) // отправителю есть куда положить и уйтиОтправитель кладёт значение в буфер и завершается, даже если результат уже никому не нужен; буфер соберётся вместе с каналом. Это третья причина ставить буфер — не ради скорости и не ради отставания получателя, а ради того, чтобы отправитель мог закончиться.
Чем эту утечку видно
Готового «детектора утечек горутин» в стандартной поставке нет: ни флага,
аналогичного -race, ни пакета. Есть счётчик и профиль — то есть наблюдение, а
не диагноз. Разница между ними видна на той же сотне утёкших горутин
(bench/gochan/leakdetect.go).
runtime.NumGoroutine отвечает «сколько», но не «где». Именно его счёт
приведён выше — 1 до, 101 после, и две сборки мусора ничего не меняют. Одно
число, и по нему непонятно, какая строка виновата.
Профиль goroutine отвечает «где». Он группирует горутины по стеку и
ставит перед каждой группой счётчик:
goroutine profile: total 101
100 @ 0x46ddae 0x40c5a5 0x40c152 0x4e94b9 0x474c61
# 0x4e94b8 main.leak.func1+0x18 bench/gochan/leakdetect.go:67
Адреса свои у каждого запуска, путь к файлу здесь сокращён до пути в репозитории. Сотня на одном стеке — это и есть адрес утечки, с точностью до строки. Что именно показывает этот профиль, сказано в документации пакета:
goroutine — stack traces of all current goroutines.
goroutine — трассировки стека всех текущих горутин.
Взять его можно изнутри программы (pprof.Lookup("goroutine")), а в работающей
службе — по HTTP через net/http/pprof, без пересборки. Рядом стоят
runtime.Stack(buf, true) для дампа в память программы и GOTRACEBACK=all —
для дампа всех горутин при падении.
-race утечку не ловит, и это не недоработка. Проверено: тест с явной
утечкой проходит под -race без единого замечания.
ok leaky 1.011s
Секунды здесь — настенное время конкретного прогона; у вас будут свои. Важно
слово ok.
Детектор гонок ищет несинхронизированный доступ к памяти. Заблокированная навсегда горутина ни к чему не обращается, поэтому ловить ему нечего: это другая задача, а не пропущенная.
Что действительно валит тест — сверка «до и после» в нём самом. Два десятка строк, и ничего, кроме стандартной библиотеки:
--- FAIL: TestLeaks (0.51s)
leak_test.go:27: утекло горутин: было 2, стало 12
FAIL
FAIL leaky 0.516s
Контрольный прогон той же программы, где канал закрывают, проходит. Ровно эту
проверку автоматизируют библиотеки экосистемы; самая известная из них —
go.uber.org/goleak. Запуском она здесь не проверялась, поэтому что она делает
и как — смотрите в её документации: измерено в этом разделе только то, что даёт
стандартная поставка.
Порядок: внутри отправителя цел, между отправителями его нет
Канал — очередь, и у очереди есть порядок. Но очередь одна, а встают в неё несколько горутин, и решает это планировщик. Что именно из этого повторяется, а что нет, видно из прогона:
получено 20 значений от 4 отправителей
первые десять: [3.0 3.1 3.2 3.3 3.4 0.0 0.1 0.2 0.3 0.4]
отправитель 0: [0.0 0.1 0.2 0.3 0.4] — порядок сохранён
отправитель 1: [1.0 1.1 1.2 1.3 1.4] — порядок сохранён
отправитель 2: [2.0 2.1 2.2 2.3 2.4] — порядок сохранён
отправитель 3: [3.0 3.1 3.2 3.3 3.4] — порядок сохранён
Какой отправитель окажется в выводе первым, решает планировщик, и при перезапуске строка будет другой. Повторяется не она, а то, что внутри каждого отправителя порядок цел.
Практический вывод: сортировать выход канала с несколькими отправителями бессмысленно — упорядочивать надо на входе или нести порядковый номер в самом значении.
Ради чего всё это: канал переносит видимость записей
И последнее — то, ради чего мьютекс и очереди в структуре вообще нужны. Канал переносит не только значения, но и видимость записей, и это отдельная гарантия. В модели памяти она записана четырьмя правилами.
Первое — то, ради чего канал чаще всего и берут:
A send on a channel is synchronized before the completion of the corresponding
receive from that channel.
Отправка в канал синхронизирована раньше завершения соответствующего приёма из этого канала.
Всё, что горутина записала до отправки, получатель после приёма обязан увидеть. Отсюда, кстати, и цена отправки указателя, о которой сказано выше: гарантия покрывает записи, сделанные до отправки, и ни одну — из сделанных после. Отправитель, продолжающий писать в переданный объект, остаётся с гонкой, и канал от неё не защищает.
Второе — про закрытие. Оно даёт ту же гарантию, не передав ни одного значения:
The closing of a channel is synchronized before a receive that returns a zero
value because the channel is closed.
Закрытие канала синхронизировано раньше приёма, который возвращает нулевое значение из-за того, что канал закрыт.
Вот почему идиома done := make(chan struct{}) + close(done) — не просто
удобный сигнал: она переносит память, а не данные.
Третье переворачивает направление, и только без буфера:
A receive from an unbuffered channel is synchronized before the completion of
the corresponding send on that channel.
Приём из небуферизованного канала синхронизирован раньше завершения соответствующей отправки в этот канал.
Здесь гарантия идёт от получателя к отправителю. Модель памяти тут же
оговаривает границу: замените канал на make(chan int, 1) — и тот же пример уже
ничего не гарантирует.
Четвёртое — то самое правило про k-й приём и (k + C)-ю отправку, которым в первом разделе измерялась допустимая величина отставания. У него есть и второе чтение: из него, а не из практики, растёт семафор на буферизованном канале — число значений в канале есть число занятых мест, ёмкость есть предел одновременности.
Одна оговорка про источник, важная, если вы будете цитировать. Формулировка
четвёртого правила на go.dev/ref/mem и в копии, которая приезжает вместе с
go1.24.7 ($GOROOT/doc/go_mem.html), различается текстуально: на сайте
The kth receive from a channel with capacity C … the k+Cth send on that channel,
в поставке The kth receive on a channel with capacity C … the k+Cth send from that channel completes. Смысл один; дословно цитировать надо ту копию, которая
открыта у вас.
Что должно повториться, а что нет
Числа этой статьи сняты на go1.24.7 linux/amd64, Intel Xeon 2,10 ГГц,
GOMAXPROCS = 2. Скрипты и записи прогонов — с командами запуска и разбросами
трёх прогонов — открываются отсюда: bench/gochan/cost_test.go и
bench/gochan/behaviour.go. Времени не меряет вовсе
bench/gochan/leakdetect.go: он печатает счётчик горутин, профиль и результаты
тестов с утечкой и без неё.
Совпадения ваших чисел с этими ждать не надо. Ждать надо совпадения соотношений внутри блока, и в первую очередь того, что минимум по буферу существует: лучший из замеренных размеров — не самый большой.
Оговорка к первой вкладке отдельно: две горутины на двух ядрах. Цена передачи
зависит от того, попали ли они на разные процессоры и надо ли планировщику
будить поток; на машине с другим числом ядер соотношения будут другими. Блоки
про select, мьютекс и размер элемента работают в одной горутине и от этого
почти не зависят.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Вся статья держится на одном: в канале три части — кольцевой буфер, обычный мьютекс и две очереди ожидающих горутин. Что именно из трёх работает в конкретный момент, и объясняет всё остальное.
- Работает очередь — получатель уже стоит в
recvq. Тогда значение идёт мимо буфера, копируется прямо в его стек, и это видно из программы:len(ch)после отправки остаётся нулём. - Работает буфер — получателя нет. Тогда буфер не убирает блокировку, а отсрочивает её ровно на свою вместимость. И больше не значит быстрее: 64 значения — 57,91 нс, 1024 — 78,97. Кривая не монотонна.
- Работает один мьютекс — будить некого. Тогда видно, чем канал является на самом деле: 67,19 нс против 15,80 у мьютекса и 6,553 у
atomic— ×10,25. Канал и есть мьютекс плюс учёт очередей. selectберёт мьютексы всех каналов сразу и перемешивает ветки на каждом заходе. Отсюда две вещи: у отмены нет приоритета — её ветка выигрывает 14 992 раза из 30 000, ровно половину, — и цена растёт с числом веток: вторая добавляет 35,6 нс, восемь веток стоят 391,4 против 49,85.- Очередь, из которой некому забрать, — это утечка. Заблокированная горутина не собирается никогда: сто горутин на
nil-канале пережили две сборки мусора. И-raceих не находит. - А ради чего всё это — в предпоследнем разделе: канал переносит не значения, а видимость записей.
На самом деле
- Только если получателя ещё нет. Если он уже стоит в очереди
recvq, значение копируется из стека отправителя прямо в стек получателя, минуя и буфер, и кучу. Это видно из обычной программы: у канала с буфером на четыре значения после успешной отправки ждущему получателюlen(ch)остаётся нулём, а без получателя становится единицей. В рантайме за этот случай отвечает отдельная функцияsendDirect, и её комментарий говорит, откуда куда: src is on our stack, dst is a slot on another stack. - Порядок веток в исходнике не значит ничего. Замерено: контекст отменён ДО цикла, то есть его ветка готова на каждом заходе и стоит первой; за 30 000 заходов она выиграла 14 992 раза — ровно половину. Спецификация говорит то же самое словами: If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection. Приоритет выражается только отдельным
selectсdefault, выполненным раньше основного. - Зависимость не монотонна: из пяти замеренных размеров лучший — не самый большой. Замерено на передаче значения из горутины в горутину: без буфера 208,0 нс, буфер 1 — 163,2, буфер 8 — 89,65, буфер 64 — 57,91, буфер 1024 — 78,97. Диапазоны трёх прогонов у 64 и 1024 не пересекаются: 57,51–59,00 против 77,68–79,84. Буфер экономит переключения между горутинами; ПОЧЕМУ после 64 становится хуже, замер не показывает — догадка про кэш-след кольца остаётся догадкой. И надёжности он не добавляет: модель памяти определяет буфер как разрешённое отставание ровно на C значений — The kth receive from a channel with capacity C is synchronized before the completion of the k+Cth send on that channel. Как только буфер полон, отправитель блокируется так же, как без буфера.
- Первая половина верна, вторая — нет. У канала без буфера отправка действительно не завершится, пока получатель не возьмёт значение. Но буфер даёт не асинхронность, а ограниченную отсрочку: отправителю позволено уйти вперёд ровно на ёмкость буфера и ни на одно значение больше. Асинхронной отправки в Go нет вообще — есть
selectсdefault, то есть отказ от отправки, если положить некуда. - Канал и есть мьютекс — плюс кольцевой буфер и две очереди ожидающих; в структуре
hchanстоит обычныйlock mutex. Замерено там, где парковать и будить никого не надо: положить и забрать значение стоит 50,48 нс против 32,36 у пары захватов мьютекса — в 1,56 раза дороже. А на общем счётчике, где переключение горутин уже участвует, канал стоит 67,19 нс против 15,80 у мьютекса и 6,553 уatomic.AddInt64. Это не довод против каналов: у канала другая работа — передать владение значением и разбудить того, кто ждёт. Это довод против того, чтобы брать канал там, где нужен счётчик. - Первая ветка почти бесплатна (51,57 нс против 49,85 у приёма без
select) — но не потому, чтоselectдёшев, а потому, что его там нет: с одной веткой и безdefaultкомпилятор разворачивает его в обычную операцию (optimization: one-case select: single op), иselectgoне вызывается. Настоящийselectначинается со второй ветки и сразу добавляет 35,6 нс — почти три четверти цены самого приёма. До четырёх веток каждая следующая стоит примерно те же 36 нс (158,7 на четырёх), а на восьми цена ветки вырастает более чем в полтора раза (58 против 36): 391,4. Причина в том, чтоselectgoделает на КАЖДОМ входе: перемешивает ветки, сортирует их по адресу канала (simple heap sort, to guarantee n log n time and constant stack footprint) и берёт мьютекс на всех каналах сразу, а не только на сработавшем. Между заходами не кэшируется ничего. - Паникует только
close(nil). Отправка и приём по nil-каналу блокируются навсегда, и это штатное поведение, на котором построен приём «выключить веткуselect»: присвоив каналуnil, вы исключаете случай из выбора, не трогая самselect. Так пишется цикл, у которого источник и приёмник кончаются в разное время. Опасна не эта возможность, а её случайное срабатывание:var ch chan intбезmakeдаёт nil-канал, и цикл по нему встанет молча. - Не будет никогда: Goroutines are not garbage collected; they must exit on their own. Замерено: 100 горутин, ждущих на nil-канале, пережили две сборки мусора —
NumGoroutineбыл 1, стал 101 и после двух сборок остался 101. Вместе с горутиной остаётся жить её стек и всё, на что он ссылается. Самый частый способ устроить это не нарочно — вернуться по таймауту раньше, чем прочитать из небуферизованного канала, в который пишет запущенная вами горутина. Лечится буфером на одну позицию. - Только в пределах одного отправителя. Замерено на четырёх отправителях по пять значений: у каждого его пять пришли строго по порядку, а между отправителями порядок произволен — первыми в выводе оказались значения четвёртого. Канал действительно очередь, но кто встанет в неё раньше, решает планировщик. Если порядок между источниками важен, его надо задавать на входе или нести номер в самом значении.
Что разобрано
- Шаг первый: что лежит в переменной
- Следствие первое (работает очередь): значение идёт мимо буфера
- Следствие второе (работает буфер): больше — не значит быстрее
- Следствие третье (работает один мьютекс): канал и есть мьютекс плюс учёт
- Следствие четвёртое (select берёт мьютексы всех каналов сразу)
- Состояния той же структуры: закрытый и nil
- Очередь, из которой некому забрать: утечка горутин
- Порядок: внутри отправителя цел, между отправителями его нет
- Ради чего всё это: канал переносит видимость записей
- Что должно повториться, а что нет
Расхожие заблуждения
Отправленное значение сначала попадает в буфер, а получатель забирает его оттуда
Только если получателя ещё нет. Если он уже стоит в очереди recvq, значение копируется из стека отправителя прямо в стек получателя, минуя и буфер, и кучу. Это видно из обычной программы: у канала с буфером на четыре значения после успешной отправки ждущему получателю len(ch) остаётся нулём, а без получателя становится единицей. В рантайме за этот случай отвечает отдельная функция sendDirect, и её комментарий говорит, откуда куда: src is on our stack, dst is a slot on another stack
(src — на нашем стеке, dst — слот на другом стеке).
Ветка case <-ctx.Done(), стоящая первой, срабатывает раньше остальных
Порядок веток в исходнике не значит ничего. Замерено: контекст отменён ДО цикла, то есть его ветка готова на каждом заходе и стоит первой; за 30 000 заходов она выиграла 14 992 раза — ровно половину. Спецификация говорит то же самое словами: If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection
(Если состояться могут одна или несколько операций обмена, то одна из них выбирается равномерным псевдослучайным выбором). Приоритет выражается только отдельным select с default, выполненным раньше основного.
Буфер побольше — быстрее и надёжнее
Зависимость не монотонна: из пяти замеренных размеров лучший — не самый большой. Замерено на передаче значения из горутины в горутину: без буфера 208,0 нс, буфер 1 — 163,2, буфер 8 — 89,65, буфер 64 — 57,91, буфер 1024 — 78,97. Диапазоны трёх прогонов у 64 и 1024 не пересекаются: 57,51–59,00 против 77,68–79,84. Буфер экономит переключения между горутинами; ПОЧЕМУ после 64 становится хуже, замер не показывает — догадка про кэш-след кольца остаётся догадкой. И надёжности он не добавляет: модель памяти определяет буфер как разрешённое отставание ровно на C значений — 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-й отправки в этот канал). Как только буфер полон, отправитель блокируется так же, как без буфера.
Канал без буфера — синхронный, с буфером — асинхронный
Первая половина верна, вторая — нет. У канала без буфера отправка действительно не завершится, пока получатель не возьмёт значение. Но буфер даёт не асинхронность, а ограниченную отсрочку: отправителю позволено уйти вперёд ровно на ёмкость буфера и ни на одно значение больше. Асинхронной отправки в Go нет вообще — есть select с default, то есть отказ от отправки, если положить некуда.
Канал — лёгкий примитив, дешевле мьютекса
Канал и есть мьютекс — плюс кольцевой буфер и две очереди ожидающих; в структуре hchan стоит обычный lock mutex. Замерено там, где парковать и будить никого не надо: положить и забрать значение стоит 50,48 нс против 32,36 у пары захватов мьютекса — в 1,56 раза дороже. А на общем счётчике, где переключение горутин уже участвует, канал стоит 67,19 нс против 15,80 у мьютекса и 6,553 у atomic.AddInt64. Это не довод против каналов: у канала другая работа — передать владение значением и разбудить того, кто ждёт. Это довод против того, чтобы брать канал там, где нужен счётчик.
select — синтаксический сахар, лишняя ветка ничего не стоит
Первая ветка почти бесплатна (51,57 нс против 49,85 у приёма без select) — но не потому, что select дёшев, а потому, что его там нет: с одной веткой и без default компилятор разворачивает его в обычную операцию (optimization: one-case select: single op
(оптимизация: select из одного случая — одна операция)), и selectgo не вызывается. Настоящий select начинается со второй ветки и сразу добавляет 35,6 нс — почти три четверти цены самого приёма. До четырёх веток каждая следующая стоит примерно те же 36 нс (158,7 на четырёх), а на восьми цена ветки вырастает более чем в полтора раза (58 против 36): 391,4. Причина в том, что selectgo делает на КАЖДОМ входе: перемешивает ветки, сортирует их по адресу канала (simple heap sort, to guarantee n log n time and constant stack footprint
(простая пирамидальная сортировка, гарантирующая время n log n и постоянный расход стека)) и берёт мьютекс на всех каналах сразу, а не только на сработавшем. Между заходами не кэшируется ничего.
Отправка или приём по nil-каналу — ошибка
Паникует только close(nil). Отправка и приём по nil-каналу блокируются навсегда, и это штатное поведение, на котором построен приём «выключить ветку select»: присвоив каналу nil, вы исключаете случай из выбора, не трогая сам select. Так пишется цикл, у которого источник и приёмник кончаются в разное время. Опасна не эта возможность, а её случайное срабатывание: var ch chan int без make даёт nil-канал, и цикл по нему встанет молча.
Горутина, заблокированная на канале, будет собрана сборщиком мусора
Не будет никогда: Goroutines are not garbage collected; they must exit on their own
(Горутины не собираются сборщиком мусора; они должны завершиться сами). Замерено: 100 горутин, ждущих на nil-канале, пережили две сборки мусора — NumGoroutine был 1, стал 101 и после двух сборок остался 101. Вместе с горутиной остаётся жить её стек и всё, на что он ссылается. Самый частый способ устроить это не нарочно — вернуться по таймауту раньше, чем прочитать из небуферизованного канала, в который пишет запущенная вами горутина. Лечится буфером на одну позицию.
Канал сохраняет порядок значений
Только в пределах одного отправителя. Замерено на четырёх отправителях по пять значений: у каждого его пять пришли строго по порядку, а между отправителями порядок произволен — первыми в выводе оказались значения четвёртого. Канал действительно очередь, но кто встанет в неё раньше, решает планировщик. Если порядок между источниками важен, его надо задавать на входе или нести номер в самом значении.
Проверьте себя
У канала буфер на 4. Получатель уже ждёт на приёме. Отправитель выполняет ch <- 1. Чему равен len(ch) сразу после этого?
Источники и что читать дальше
6 ИСТОЧНИКОВ
- Спецификация Go — Channel types, Send statements, Receive operator, Select statementsОфициальная документация. Определение канала: «A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type» (Канал даёт механизм, которым параллельно исполняющиеся функции общаются, отправляя и принимая значения указанного типа элемента). Ёмкость определена как размер буфера: «The capacity, in number of elements, sets the size of the buffer in the channel» (Ёмкость, в числе элементов, задаёт размер буфера в канале), и там же — единственное место, где сказано, когда отправка не блокирует: «A send on an unbuffered channel can proceed if a receiver is ready. A send on a buffered channel can proceed if there is room in the buffer» (Отправка по небуферизованному каналу может состояться, если готов получатель. Отправка по буферизованному каналу может состояться, если в буфере есть место). Про nil прямо: «A send on a nil channel blocks forever» (Отправка по nil-каналу блокирует навсегда). И правило выбора, которое в этой статье измерено: «If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection» (Если состояться могут одна или несколько операций обмена, то одна из них выбирается равномерным псевдослучайным выбором).https://go.dev/ref/spec
- runtime/chan.go — hchan, sendDirect, поведение nil-каналаИсходный код Go. Структура канала со всеми полями, включая обычный мьютекс: `lock mutex` — канал не «без блокировок». Отдельная функция для случая, когда получатель уже ждёт, и комментарий к ней, объясняющий, откуда куда идёт значение: «src is on our stack, dst is a slot on another stack» (src — на нашем стеке, dst — слот на другом стеке). Там же три паники, различающиеся текстом: `send on closed channel`, `close of closed channel`, `close of nil channel`.https://go.dev/src/runtime/chan.go
- runtime/select.go — pollorder, lockorder, sellockИсходный код Go. Три строки, из которых следует всё поведение select: `j := cheaprandn(uint32(norder + 1))`, затем обмен элементов `pollorder` — тасование Фишера-Йетса, выполняемое на каждом входе в select. Рядом комментарий про вторую половину работы: «sort the cases by Hchan address to get the locking order» (отсортировать случаи по адресу Hchan, чтобы получить порядок захвата блокировок). И следом о том, какой ценой: «simple heap sort, to guarantee n log n time and constant stack footprint» (простая пирамидальная сортировка, гарантирующая время n log n и постоянный расход стека). Это объясняет замеренный рост цены с числом веток.https://go.dev/src/runtime/select.go
- The Go Memory Model — Channel communicationОфициальная документация. Формальное определение того, что буфер — это не асинхронность, а разрешённое отставание ровно на C значений: «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-й отправки в этот канал). И два правила, которыми канал заменяет мьютекс: «A send on a channel is synchronized before the completion of the corresponding receive from that channel» (Отправка в канал синхронизирована раньше завершения соответствующего приёма из этого канала) и «A receive from an unbuffered channel is synchronized before the completion of the corresponding send on that channel» (Приём из небуферизованного канала синхронизирован раньше завершения соответствующей отправки в этот канал).https://go.dev/ref/mem
- Go Concurrency Patterns: Pipelines and cancellationОфициальная документация. Источник, где утечка горутин названа прямо и объяснена: «Goroutines are not garbage collected; they must exit on their own» (Горутины не собираются сборщиком мусора; они должны завершиться сами). И почему это не безобидно: «This is a resource leak: goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected» (Это утечка ресурса: горутины расходуют память и ресурсы рантайма, а ссылки на кучу в стеках горутин не дают собрать данные).https://go.dev/blog/pipelines
- runtime/pprof — профиль goroutineИсходный код Go. Что именно показывает профиль, которым в статье находят место утечки: «goroutine - stack traces of all current goroutines» (goroutine — трассировки стека всех текущих горутин). В отличие от `runtime.NumGoroutine`, профиль группирует горутины по стеку и ставит перед каждой группой счётчик, поэтому отвечает не «сколько», а «сколько и где».https://pkg.go.dev/runtime/pprof#Profile