Deep Engineering
Продвинутый·Опубликовано·45 МИН

Канал в Go: буфер, мьютекс и две очереди — и что из них работает в каждый момент

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

Полное техническое изложение

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; как их повторить — в последнем разделе.

Шаг первый: что лежит в переменной

GO
unsafe.Sizeof(make(chan int))   // 8 — один указатель
unsafe.Sizeof(map[int]int{})    // 8
unsafe.Sizeof([]int{})          // 24

Восемь байт: указатель на hchan в рантайме. Отсюда сразу два следствия. Канал, переданный в функцию, — тот же самый канал (копируется указатель). И nil у канала — это настоящий нулевой указатель, а не «пустой канал»: за ним нет ни буфера, ни очередей, и потому класть значение некуда и будить некого. Что из этого выходит, будет ниже, и это не то, чего ждёшь.

Внутри hchan — то, что обычно и рисуют, плюс одна строка, которую обычно не рисуют:

GO
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 — слот на другом стеке.

runtime/chan.go, функция sendDirect

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

GO
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.

перевод

Отправка по небуферизованному каналу может состояться, если готов получатель. Отправка по буферизованному каналу может состояться, если в буфере есть место.

Спецификация Go, Send statements

А модель памяти — как точную величину допустимого отставания:

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-й отправки в этот канал.

The Go Memory Model, Channel communication

То есть буфер на C значений разрешает отправителю уйти вперёд ровно на C и ни на одно больше. Это не асинхронность, а отсрочка с известной границей.

Следствие второе (работает буфер): больше — не значит быстрее

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

Прежде чем читать числа, одно правило: вкладки между собой не сопоставляются. Канал стоит принципиально по-разному в зависимости от того, приходится ли парковать и будить горутину. В первых двух вкладках приходится — там работают две горутины; в остальных трёх всё происходит в одной. Разница на порядок. Складывать их в одну шкалу значило бы сравнивать разный объём работы.

Первая вкладка — про буфер:

буфернс/оп
0208,0
1163,2
889,65
6457,91
102478,97

Буфер экономит переключения между горутинами: пока в нём есть значения, получателю не надо парковаться, а отправителю — будить. До какого-то размера это выигрыш. Дальше выигрывать уже нечего — переключений и так почти нет, — и становится хуже. Диапазоны трёх прогонов для 64 и 1024 не пересекаются: 57,51–59,00 против 77,68–79,84.

Почему становится хуже, замер не показывает, и додумывать тут нечего: правдоподобная догадка про кэш-след кольца (8 КБ против 512 байт) разбивается о то, что 8 КБ помещаются и в L1d. Это гипотеза, а не результат.

Число 64 не надо запоминать: оно зависит от размера элемента, машины и того, насколько отстаёт получатель. Запоминать надо, что зависимость не монотонна: из пяти замеренных размеров лучший — не самый большой. «Поставлю буфер побольше, чтобы точно не блокировалось» — не бесплатная предосторожность, и вдобавок она не делает того, ради чего её ставят: буфер полнится ровно до тех пор, пока отправитель быстрее.

Следствие третье (работает один мьютекс): канал и есть мьютекс плюс учёт

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

способнс/опк atomic
atomic.AddInt646,553×1,00
мьютекс15,80×2,41
канал67,19×10,25

Все три строки увеличивают один и тот же счётчик и дают одно и то же число. «Не общайтесь через разделяемую память» читают как «канал вместо мьютекса всегда» — и вот цена этого прочтения там, где нужен именно счётчик.

Это не довод против каналов. У канала другая работа: передать владение значением и разбудить того, кто ждёт. Мьютекс ни того, ни другого не делает. Довод здесь ровно один: за передачу владения платят, и когда передавать нечего, платят зря.

Сколько стоит само значение. Оно копируется дважды — в буфер и из буфера:

элементразмернс/оп
struct{}0 б46,66
int648 б49,79
[128]byte128 б59,96
*payload8 б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.

перевод

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

Спецификация Go, Select statements

Интересно не это, а то, что из этого следует в самом обычном цикле с отменой:

GO
for {
    select {
    case <-ctx.Done():        // «стоит первым — значит, приоритетнее»
        return ctx.Err()
    case v := <-work:
        handle(v)
    }
}

Замерено: контекст отменён до цикла, то есть его ветка готова на каждом заходе. За 30 000 заходов она выиграла 14 992 раза. Ровно половину. Генератор не засевается, так что у вас счётчики будут другими — повторяется доля, а не число.

Если работа поступает непрерывно, такой цикл после отмены сделает в среднем ещё одну итерацию, а с вероятностью 1/1024 — ещё десять. На тесте с одним сообщением это не воспроизводится никогда. В работающей системе под нагрузкой — каждый раз.

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

GO
for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:                   // не готово — идём дальше, не блокируемся
    }
    select {
    case <-ctx.Done():
        return ctx.Err()
    case v := <-work:
        handle(v)
    }
}

Механизм в исходнике — три строки, и главное в них то, что они выполняются на каждом входе в select:

GO
j := cheaprandn(uint32(norder + 1))
pollorder[norder] = pollorder[j]
pollorder[j] = uint16(i)

Тасование Фишера-Йетса. Ничего не кэшируется между заходами.

Стоит поставить это рядом с картой. Там порядок обхода тоже называют случайным, но различных порядков у карты до 896 записей оказывается ровно столько, сколько записей: случаен только сдвиг. Здесь перемешивание настоящее. Два «рандомизировано» в одном языке означают разные вещи, и разница проверяется счётчиком.

И поэтому же цена растёт с числом веток

как написан приёмнс/опк предыдущей
без select49,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 и постоянный расход стека

runtime/select.go, комментарий к построению lockorder

И затем берёт мьютекс на всех каналах сразу, а не только на том, который в итоге сработает. Восемь веток на восьми разных каналах — восемь захватов и восемь освобождений, плюс перемешивание, плюс сортировка. (Повтор sellock пропускает: две ветки на одном канале дадут один захват.) Ничего между заходами не сохраняется, потому что набор каналов у select может меняться от захода к заходу.

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

Практический вывод скромнее, чем кажется: select из двух-трёх веток стоит десятки наносекунд и не заслуживает внимания рядом с любой настоящей работой. Но select из восьми веток в горячем цикле — уже статья расходов, и её видно в профиле как runtime.selectgo.

Состояния той же структуры: закрытый и nil

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

Закрытый канал

GO
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-канала указателя нет, а значит, нет ни буфера, ни очередей — положить значение некуда и разбудить некого:

GO
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.

GO
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.

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.

перевод

Горутины не собираются сборщиком мусора; они должны завершиться сами.

Go Concurrency Patterns: Pipelines and cancellation

Заблокированная навсегда горутина остаётся жить вместе со своим стеком и всем, на что этот стек ссылается:

This is a resource leak: goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected.

перевод

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

Go Concurrency Patterns: Pipelines and cancellation

Самый частый способ устроить это не нарочно — вернуться раньше, чем прочитать из канала, в который пишет запущенная вами горутина:

GO
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(). Под нагрузкой с таймаутами это утечка, растущая линейно по числу запросов.

Лечится одним символом:

GO
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 — трассировки стека всех текущих горутин.

runtime/pprof — Profile

Взять его можно изнутри программы (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 Go Memory Model — Channel communication

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

Второе — про закрытие. Оно даёт ту же гарантию, не передав ни одного значения:

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) остаётся нулём, а без получателя становится единицей. В рантайме за этот случай отвечает отдельная функция 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. Вместе с горутиной остаётся жить её стек и всё, на что он ссылается. Самый частый способ устроить это не нарочно — вернуться по таймауту раньше, чем прочитать из небуферизованного канала, в который пишет запущенная вами горутина. Лечится буфером на одну позицию.

Утверждение

Канал сохраняет порядок значений

На самом деле

Только в пределах одного отправителя. Замерено на четырёх отправителях по пять значений: у каждого его пять пришли строго по порядку, а между отправителями порядок произволен — первыми в выводе оказались значения четвёртого. Канал действительно очередь, но кто встанет в неё раньше, решает планировщик. Если порядок между источниками важен, его надо задавать на входе или нести номер в самом значении.

Проверьте себя

Вопрос 1 из 5

У канала буфер на 4. Получатель уже ждёт на приёме. Отправитель выполняет ch <- 1. Чему равен len(ch) сразу после этого?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Спецификация 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. runtime/pprof — профиль goroutineИсходный код Go. Что именно показывает профиль, которым в статье находят место утечки: «goroutine - stack traces of all current goroutines» (goroutine — трассировки стека всех текущих горутин). В отличие от `runtime.NumGoroutine`, профиль группирует горутины по стеку и ставит перед каждой группой счётчик, поэтому отвечает не «сколько», а «сколько и где».https://pkg.go.dev/runtime/pprof#Profile