2.2Каналы
Проследим send, receive и close для buffered и unbuffered каналов. Отдельно определим, какая сторона знает, когда отправок больше не будет.
Канал - очередь, которая умеет усыплять
Практических вопросов тут ровно два: кто из двух горутин заблокируется, и что разблокирует его обратно, и что происходит с обеими сторонами при закрытии канала. Все «а что будет, если…» с собеса вырастают из ответов на них.
Если выбросить всё остальное, останется очередь фиксированной вместимости плюс одно правило: когда операцию выполнить нельзя, вызывающая горутина не получает ошибку и не крутится в цикле, а засыпает ровно до того момента, когда операция станет возможной. Взять из пустой очереди нельзя: читатель спит, пока кто-нибудь не положит. В полную не положишь, и писатель спит, пока кто-нибудь не заберёт.
У небуферизированного канала (make(chan T)) вместимость ноль, очередь та же.
«Положить и уйти» в неё нельзя никогда, поэтому передача идёт только из рук в руки, когда
обе стороны одновременно оказались на месте.
Заснувшая горутина не держит поток ОС: её ставят в одну из двух очередей ожидания внутри самого канала. В одной ждут значения, в другой свободного места.
Ради трёх вещей, которые иначе приходится зубрить наизусть. Первая: предсказывать
блокировки. Если помнить, что отправка сначала заглядывает в очередь ожидающих читателей
и только потом в буфер, сразу понятно, почему default в
select на небуферизированном канале почти всегда промахивается.
Вторая: оценивать цену. Каждая операция с каналом берёт мьютекс и делает одно-два
копирования значения, отсюда и «канал в 3–5 раз дороже мьютекса», и совет не гонять через
канал крупные структуры. И третья, отвечать на «а что если»: паника при отправке
в закрытый канал, нулевое значение при чтении из него, вечное ожидание на nil-канале
следуют из одной и той же картинки, а не из трёх отдельных правил.
Что такое канал по сути
Канал работает как типизированная потокобезопасная очередь с блокировкой и встроенной точкой
синхронизации. Переменная типа chan T хранит указатель на hchan
в куче (поэтому канал и «работает» при передаче в функцию по значению: копируется указатель).
make(chan T, n) вызывает runtime.makechan, который выделяет заголовок hchan и, если n > 0, массив на n элементов. Когда в элементах нет указателей, это один кусок и массив лежит сразу за заголовком; иначе буфер выделяется отдельно.
// runtime/chan.go, упрощённо, но поля настоящие
type hchan struct {
qcount uint // сколько элементов сейчас в буфере
dataqsiz uint // размер буфера (0 для небуферизированного)
buf unsafe.Pointer // указатель на кольцевой массив из dataqsiz элементов
elemsize uint16 // размер одного элемента
closed uint32 // 0 или 1
elemtype *_type // тип элемента, нужен для копирования и для GC
sendx uint // индекс, куда писать следующий элемент
recvx uint // индекс, откуда читать следующий
recvq waitq // список горутин, ждущих приёма (sudog)
sendq waitq // список горутин, ждущих отправки (sudog)
lock mutex // обычный мьютекс рантайма: да, канал внутри лочится
}
// sudog описывает горутину в очереди: G + место, куда/откуда копировать
type sudog struct {
g *g
next *sudog
prev *sudog
elem unsafe.Pointer // адрес переменной в стеке этой горутины
c *hchan // на каком канале ждёт
isSelect bool // ждёт в составе select
success bool // получила значение (true) или канал закрыли (false)
}
sudog. Из этой
картинки и выводится, почему отправка в закрытый канал паникует, а чтение отдаёт zero value.Механика отправки: chansend
Порядок проверок в runtime.chansend стоит уметь рассказать наизусть. Обрати
внимание на пункт 3: если кто-то уже ждёт, значение не проходит через буфер вообще,
оно копируется прямо в стек получателя. Так экономится одно копирование, а данные остаются
горячими в кэше.
Небуферизированный канал: рандеву
У небуферизированного канала dataqsiz == 0, и ветка «положить в буфер» не срабатывает
никогда. Значит, отправить получится только тогда, когда получатель уже готов, а принять,
когда готов отправитель. Это и называют рандеву: операция
завершается в момент встречи, значение переезжает атомарно, минуя
любое промежуточное хранилище.
Аксиомы канала - полная таблица
| Операция | nil-канал | открытый, пустой | открытый, есть данные/место | закрытый |
|---|---|---|---|---|
ch <- v |
блокировка навсегда | блокировка до читателя (или пока не появится место) |
успех | panic: send on closed channel |
v := <-ch |
блокировка навсегда | блокировка до писателя | успех | zero value, сразу, бесконечно много раз |
v, ok := <-ch |
блокировка навсегда | блокировка | v, true |
zero, false - только после того, как буфер вычерпан |
close(ch) |
panic: close of nil channel | успех, будит всех ждущих | panic: close of closed channel | |
for v := range ch |
блокировка навсегда | итерирует, блокируясь между значениями | дочитывает буфер и выходит из цикла | |
len(ch) / cap(ch) |
0 / 0 |
qcount / dataqsiz - читаются без лока, значение мгновенно устаревает | ||
select с этим каналом |
case никогда не сработает (так «выключают» ветку) |
участвует в выборе, когда готов | приём всегда готов - вернёт zero, false | |
- Почему nil-канал блокирует, а не паникует.
chansend/chanrecvявно проверяютc == nilи вызываютgoparkс причинойwaitReasonChanSendNilChan. Сделано это специально: вselectветку выключают, обнуляя канал. - Почему close будит всех.
closechanпроходит поrecvqиsendqцеликом, каждомуsudogобнуляетelem, ставитsuccess = falseи вызываетgoready. Отсюда и паттерн broadcast, и то, что ждущие отправители при close получат панику. - Почему из закрытого канала можно дочитать буфер.
chanrecvпроверяет закрытость вместе сqcount == 0: пока в буфере что-то есть, он отдаёт значение иok = true.
Кто закрывает канал и как не словить панику
// Правило: закрывает тот, кто пишет, и только если писатель один.
func producer(out chan<- int) {
defer close(out) // эта функция владеет каналом
for i := 0; i < 10; i++ {
out <- i
}
}
// Плохо: писателей несколько, и второй close паникует
func bad(out chan int, wg *sync.WaitGroup) {
defer wg.Done()
defer close(out) // panic: close of closed channel
out <- 1
}
// Хорошо: закрывает координатор, когда все писатели закончили
func good(out chan int) {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() { defer wg.Done(); out <- i }()
}
go func() { wg.Wait(); close(out) }() // ровно один close, после всех
}
- У канала ровно один владелец: тот, кто его создал, пишет в него и закрывает.
- Владелец отдаёт наружу направленный тип:
<-chan Tдля читателей. Тогда закрыть его физически невозможно:closeна receive-only канале не компилируется. - Если писателей несколько, они не закрывают ничего; закрывает координатор после
wg.Wait(). - Сигнал «остановись» идёт отдельным каналом (
doneилиctx.Done()) в обратную сторону, а не через закрытие канала данных.
Стоимость операций
| Операция | Порядок стоимости | Почему |
|---|---|---|
| send/recv, обе стороны готовы | ~25–80 нс | lock, memmove, unlock, goready |
| send/recv с парковкой | ~200–800 нс | плюс gopark, попадание в планировщик, пробуждение |
mu.Lock() без конкуренции | единицы нс (с Unlock около 2 нс на M4 Pro, go1.27) | один CAS |
atomic.AddInt64 | единицы нс (около 2 нс на M4 Pro, go1.27) | одна инструкция с блокировкой шины/кэш-линии |
select с 2 case | ~50–150 нс | lock всех каналов в порядке адресов, случайная перестановка |
Вывод для собеса: канал дороже мьютекса и сильно дороже атомика. Берут его за другое: он тянет данные, синхронизацию и сигнал завершения одной конструкцией. Для «просто счётчика» канал плохой выбор.
Направленные каналы, chan struct{} и канал внутри канала
Тип канала сужается до одного направления: chan<- T только отправляет,
<-chan T только принимает. Преобразование одностороннее и бесплатное: двунаправленный
канал неявно приводится к направленному, обратно никогда. Выигрыша в скорости тут нет, есть контракт,
проверяемый компилятором: функция, получившая <-chan T, физически не может
ни записать в него, ни закрыть его. Отсюда идиома конструктора конвейера:
// Возвращаем receive-only: снаружи канал не испортить.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // закрывает только владелец
for _, n := range nums {
out <- n
}
}()
return out
}
// Потребитель принимает receive-only и обязан вернуть тоже receive-only.
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
По chan struct{} едет факт, а не значение.
struct{}{} занимает ноль байт, все такие значения ссылаются на один и тот же
адрес runtime.zerobase, так что memmove элемента копирует 0 байт.
С chan bool по скорости разница мизерная, а по смыслу решающая: тип
сам говорит «здесь нет данных, здесь только сигнал», и читателю не приходится гадать, что
значит пришедший false. Поэтому у ctx.Done() и тип
<-chan struct{}.
Канал остаётся обычным значением (указатель на hchan), поэтому его кладут в другой
канал, в структуру, в мапу, передают в функцию. Тип chan chan int выглядит
пугающе, но на нём стоит паттерн «канал ответа внутри запроса»: клиент кладёт в общий
канал запросов структуру, где одним из полей лежит его личный канал для ответа.
type Job struct {
Arg int
Reply chan<- Result // обратный адрес; тип направленный: воркер может только писать
}
type Result struct {
Val int
Err error
}
func worker(jobs <-chan Job) {
for j := range jobs {
v, err := do(j.Arg)
j.Reply <- Result{v, err} // буфер 1 у reply, чтобы воркер не завис на брошенном клиенте
}
}
func call(jobs chan<- Job, arg int, ctx context.Context) (int, error) {
reply := make(chan Result, 1) // буфер 1 обязателен
select {
case jobs <- Job{arg, reply}:
case <-ctx.Done():
return 0, ctx.Err()
}
select {
case r := <-reply:
return r.Val, r.Err
case <-ctx.Done():
return 0, ctx.Err() // клиент ушёл, но воркер всё равно допишет в буфер и не зависнет
}
}
Сделаешь make(chan Result) без буфера, клиент уйдёт по таймауту, и воркер
навсегда встанет на j.Reply <- ...: рандеву не с кем провести. Классическая
утечка горутин в самодельных RPC. Буфер 1 даёт воркеру «положить и уйти»: значение никого не
ждёт, а брошенный канал соберёт GC. Так же, с буфером на 1, до Go 1.23 был устроен канал time.Timer: рантайм клал в него значение и не ждал читателя.
Broadcast, неблокирующая отправка и таймаут
Канал раздаёт по принципу «один получатель на одно значение»: если из канала читают трое, а ты
отправил одно значение, его заберёт ровно один, и рантайм выберет того, кто дольше
всех ждёт в recvq (очередь FIFO). Разослать всем через отправку невозможно
в принципе. А через закрытие можно: closechan будит всю
очередь recvq целиком, и дальше любое чтение из закрытого канала возвращается
мгновенно.
// Broadcast: один close будит разом всех N ждущих.
done := make(chan struct{})
for i := 0; i < 5; i++ {
go func(id int) {
<-done // все пятеро стоят в recvq
fmt.Println(id, "поехали")
}(i)
}
close(done) // единственный способ уведомить всех
// вывод (пять строк, порядок меняется от запуска к запуску):
// 4 поехали 4 поехали 0 поехали
// 0 поехали 2 поехали 4 поехали
// 1 поехали → 3 поехали → 1 поехали - три прогона подряд
// 2 поехали 0 поехали 3 поехали
// 3 поехали 1 поехали 2 поехали
// close будит всю recvq разом, но кто первым доберётся до Println, решает планировщик.
// (В самом фрагменте main завершится раньше горутин; чтобы увидеть вывод, нужен WaitGroup.)
// Неблокирующая отправка: не встанем, даже если некому взять.
select {
case ch <- v:
// отправлено
default:
// канал полон (или небуферизированный и получателя сейчас нет): дропаем
dropped.Add(1)
}
// Неблокирующий приём:
select {
case v := <-ch:
use(v)
default:
// пусто
}
// Таймаут 5 секунд на операцию с каналом.
select {
case v := <-ch:
use(v)
case <-time.After(5 * time.Second):
return errors.New("timeout")
}
defaultна небуферизированном канале почти всегда промахивается. Отправка сработает, только если вrecvqпрямо в этот момент кто-то стоит. Если получатель ещё не дошёл до<-ch, уйдём вdefaultи потеряем значение. На буферизированном жеdefaultозначает честное «буфер полон» и работает предсказуемо.time.Afterв цикле плодит таймеры. Каждый вызов создаёт новый*time.Timer. До Go 1.23 он жил в куче таймеров рантайма до своего срабатывания и не отменялся, даже еслиselectушёл в другую ветку: цикл сtime.After(time.Minute)и тысячей итераций в секунду держал 60 000 живых таймеров. С 1.23 брошенный таймер собирает GC, но аллокация на каждой итерации осталась. Правильно делать так: один переиспользуемыйtime.TimerсоStop()/Reset()илиcontext.WithTimeout.- Таймаут не отменяет саму операцию. Горутина, которая пишет в
ch, после нашего выхода по таймауту всё ещё стоит и ждёт получателя. Таймаут спасает вызывающего, но не исполнителя: тому нужен отдельный сигнал отмены.
// Плохо: новый таймер на каждой итерации; до Go 1.23 каждый ещё и жил до истечения минуты
for {
select {
case v := <-ch:
use(v)
case <-time.After(time.Minute):
return
}
}
// Хорошо: один таймер на весь цикл
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case v := <-ch:
use(v)
if !t.Stop() {
<-t.C // до Go 1.23 обязательно: осушить канал перед Reset
}
t.Reset(time.Minute)
case <-t.C:
return
}
}
До 1.23 у time.Timer был буферизированный канал на 1 элемент, поэтому
«протухшее» значение могло остаться в нём после Stop и всплыть после
Reset. Отсюда и ритуал с осушением. В Go 1.23 канал таймера стал
небуферизированным и особым: Stop/Reset гарантированно
выбрасывают несработавшее значение, а таймер, на который никто не ссылается, сборщик мусора соберёт и без Stop (раньше неостановленный таймер удерживался кучей рантайма).
Осушать больше не нужно, хотя код с осушением остался корректным. На собесе про это
почти не спрашивают, но упомянуть стоит: сильный ход.
len(ch), «резиновый» канал и что передавать внутрь
len(ch) и cap(ch) читают qcount и
dataqsiz без взятия мьютекса канала. Формально гонки данных тут нет
(чтение выровненного слова атомарно), но логически это состояние гонки: между твоим
if len(ch) < cap(ch) и следующей строкой ch <- v буфер могут
заполнить другие горутины, и ты всё равно заблокируешься. Значение len устаревает
ровно в момент возврата. Спросить «готов ли канал» корректно можно единственным способом:
select с default, там проверка и операция идут
атомарно под локом канала.
Размер буфера задают один раз, в make, и поменять его нельзя: dataqsiz
зашит в структуру, а буфер выделен ровно под этот размер. «Резиновый» канал собирают вручную
из горутины-посредника со слайсом внутри:
// Канал с неограниченным буфером: горутина-посредник и слайс как очередь.
func elastic[T any](in <-chan T) <-chan T {
out := make(chan T)
go func() {
defer close(out)
var q []T
for {
var send chan T // nil, пока очередь пуста
var head T
if len(q) > 0 {
send, head = out, q[0] // включаем ветку отправки
}
select {
case v, ok := <-in:
if !ok {
for _, v := range q { // дослать остаток и выйти
out <- v
}
return
}
q = append(q, v)
case send <- head: // при nil-канале этот case выключен
q = q[1:]
}
}
}()
return out
}
Канал всегда копирует elemtype.size байт. Отправил структуру на 200 байт,
получил memmove на 200 байт (при рандеву один раз, прямо из стека в стек; через буфер два: в буфер и из него).
Указатель обойдётся в 8 байт, но объект обязан лежать в куче, значит escape analysis
отправит его туда и добавит работы GC.
- Значение идёт по умолчанию. Даёт естественную изоляцию: получатель работает со своей копией, гонки невозможны. Для структур до ~100 байт копия дешевле аллокации.
- Указатель берут, когда объект большой, когда получатель должен его мутировать, когда
это уже указатель (например
*http.Request). Цена: владение придётся передавать дисциплинированно. Отправитель послеch <- pкpбольше не прикасается, иначе получится гонка, которую race-детектор поймает не всегда. - Слайсы, мапы и строки уже ссылочные заголовки. Отправка
[]byteкопирует 24 байта заголовка, но массив под ним общий: два владельца одного буфера. Самый частый источник «невидимых» гонок в конвейерах.
Вопросы
20hchan в куче:
кольцевой буфер, обычный мьютекс и две FIFO-очереди припаркованных горутин. Вся «магия»
сводится к «взять лок, скопировать байты, разбудить кого надо».Поля hchan
- В
qcountиdataqsizлежат число элементов и вместимость буфера (это и естьlen/cap). bufуказывает на массивdataqsiz * elemsizeбайт; у небуферизированного канала массива нет, но иnilтам не лежит:bufуказывает на собственное поле, этот адрес нужен race-детектору.sendxиrecvx: индексы записи и чтения в кольце.recvqиsendqдержат двусвязные спискиsudog: горутины, стоящие на приёме и на отправке.- А в
lock mutexлежит обычныйruntime.mutex. Канал не lock-free. closed uint32,elemtype,elemsize.
Внутри sudog лежит всё про ожидание: указатель на g,
указатель elem на место, откуда взять или куда положить значение, флаг
success, ссылки на соседей. У одной горутины бывает несколько
sudog сразу, ровно это происходит в select, где она
встаёт в очереди сразу нескольких каналов. Кэшируются sudog в P и в
глобальном пуле, чтобы не аллоцировать на каждой блокировке.
Главная оптимизация - прямая передача
Если при отправке в recvq кто-то есть, значение не кладётся в буфер:
send вызывает sendDirect и копирует значение прямо в стек
получателя, минуя буфер вообще. Это экономит одну копию и, что важнее, оставляет данные
горячими в кэше. Симметрично работает приём с непустой sendq.
sendDirect пишет по указателю в стек другой горутины, которая в этот момент
припаркована. Это нарушает обычное правило «стек горутины трогает только она сама»,
поэтому там стоит специальный write barrier (вместо обычного typedmemmove - явный typeBitsBulkBarrier и следом голый memmove), а copystack отдельно умеет чинить
указатели sudog.elem, если стек припаркованной горутины переехал.
Тут проверяют не память, а умение разложить механизм. Отвечай по нарастающей: сначала наивная версия, потом настоящая.
Шаг 1: наивная версия
Структура: mutex, слайс-очередь, sync.Cond для отправителей и
второй для получателей. Send: лок, пока очередь полна висим на cond.Wait(),
кладём, condRecv.Signal(), анлок. Это рабочий канал, его правда
можно написать на собесе за пять минут. Минусы: два лишних пробуждения на каждую передачу,
нет прямой передачи, и select по нескольким каналам так не сделать.
Шаг 2: что добавляет рантайм
- Свои очереди вместо Cond. Нужен явный список ожидающих (
sudog), чтобы будить конкретную горутину, а не всех, и чтобы соблюдать FIFO-справедливость. - gopark/goready вместо futex. Блокировать надо горутину, а не поток: поток должен уйти выполнять другую работу. Обычной библиотеке такое недоступно, отсюда и вывод, что каналы обязаны жить в рантайме.
- Прямая передача. Если ждущий уже есть, копируем ему в стек и не трогаем буфер.
- Поддержка select. Горутина должна уметь стоять в нескольких очередях сразу,
и ровно один из каналов должен её «выиграть»: это делается через
g.selectDoneс CAS. - Кольцевой буфер вместо слайса: фиксированный размер, никаких реаллокаций, индексы вместо сдвигов.
«Канал нельзя написать библиотекой не потому, что он сложный, а потому что ему нужны примитивы
планировщика, gopark и goready. Всё остальное в
hchan обычный код: мьютекс, кольцо и два списка.»
make(chan T) | make(chan T, N) | |
|---|---|---|
| Когда send разблокируется | когда получатель забрал | когда есть место в буфере |
| Гарантия отправителю | «это принято» | «это поставлено в очередь» |
| happens-before | send и recv взаимно синхронизируют обе горутины | send i-го значения происходит до завершения recv i-го; и recv i-го - до завершения send (i+N)-го |
| Копий значения | 1 (стек → стек) | 2 (стек → буфер → стек), если никто не ждал |
| Задержка | выше: почти всегда парковка | ниже: пока есть место, send не паркуется |
| Что скрывает | ничего | всплеск нагрузки - и задержку обнаружения того, что потребитель умер |
Практический критерий выбора
- Буфер 0 берут, когда нужна синхронизация и обратное давление. Продюсер физически не может обогнать консьюмера. Это дефолт, с которого стоит начинать.
- Буфер 1 работает как «почтовый ящик»: отправитель кладёт и уходит, гарантированно не зависая. Канал ответа в RPC, канал ошибки, сигнальный канал.
- Буфер N сглаживает рывки, когда средняя скорость потребителя выше средней скорости продюсера, а пики короткие. Число надо обосновать (например, «столько же, сколько воркеров»), а не «поставим 100, пусть будет».
Буфер маскирует проблему: система выглядит работающей, пока он не переполнится, а потом
падает резко. Плюс всё, что лежит в буфере, при аварийной остановке теряется: персистентной
очереди тут нет. И плюс задержка: значение может пролежать в буфере секунды,
а отправитель уже отчитался «отправлено». Нужна надёжная очередь, бери Kafka или
RabbitMQ, а не chan.
close - там паника. Закрытый канал: чтение отдаёт zero/false бесконечно,
запись и повторный close - паника.| Операция | nil-канал | закрытый канал |
|---|---|---|
ch <- v | блокировка навсегда | panic: send on closed channel |
<-ch | блокировка навсегда | zero value, мгновенно, сколько угодно раз |
v, ok := <-ch | блокировка навсегда | ok == false после того, как буфер вычерпан |
close(ch) | panic: close of nil channel | panic: close of closed channel |
len/cap | 0 / 0 | реальные значения |
range | блокировка навсегда | дочитает буфер и выйдет |
Почему именно так - логика, а не произвол
- nil блокирует, а не паникует, чтобы в
selectможно было отключать ветку присваиваниемch = nil. Свойство спроектированное, а не побочный эффект. - close паникует на nil, потому что закрывает владелец, а владелец обязан знать, что канал создан. Молчаливое «ничего не делаем» скрыло бы ошибку.
- Send в закрытый паникует, потому что закрытие означает «данных больше не будет». Тихо проглотить значение значит потерять данные, а это хуже паники.
- Recv из закрытого не паникует, потому что получателей может быть много, они узнают о конце асинхронно, и это нормальный путь завершения, а не ошибка.
- Повторный close паникует: значит, владельцев у канала несколько, то есть где-то ошибка в архитектуре.
«А если горутина стоит в ch <- v, и другая делает close(ch)?»
Ответ: closechan пройдёт по sendq, разбудит отправителя, и тот,
проснувшись, увидит c.closed != 0 и запаникует. То есть паника
«send on closed channel» может прилететь не в момент close, а в момент
пробуждения уже стоявшего отправителя. Отсюда правило: закрывать может только писатель,
и только если он один.
close не выбрасывает буфер; читатель сначала
вычерпает всё, что лежит, и только потом начнёт получать zero, false.ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)
v, ok := <-ch // 1, true
v, ok = <-ch // 2, true
v, ok = <-ch // 0, false: буфер пуст, канал закрыт
v, ok = <-ch // 0, false, и так бесконечно
// range делает то же самое и выходит сам, но здесь канал уже вычерпан
// четырьмя строками выше, поэтому цикл ничего не напечатает и сразу завершится:
for v := range ch { fmt.Println(v) } // ни одной строки
// А вот если бы range шёл сразу после close, не читая вручную:
ch2 := make(chan int, 3)
ch2 <- 1
ch2 <- 2
close(ch2)
for v := range ch2 { fmt.Println(v) } // 1
// 2
Механика
В chanrecv проверка выглядит так: «если канал закрыт и
qcount == 0, вернуть zero и false». Оба условия вместе.
Пока в кольцевом буфере что-то есть, работает обычная ветка: скопировать из
buf[recvx], сдвинуть recvx, уменьшить qcount.
closechan при этом трогает только флаг closed и очереди
ожидающих, а буфер не очищает.
Из-за этого свойства конвейер завершают так: закрыть канал и
дождаться, пока range сам выйдет. Не нужно ни счётчиков оставшихся
элементов, ни отдельного «стоп-сообщения»: close одновременно и говорит
«данных больше не будет», и разрешает забрать всё, что уже отправлено. Этим
Go-конвейер и отличается от очереди с sentinel-значением, где сигнал завершения можно
потерять, если получателей несколько.
_Gwaiting, её M берёт из очереди следующую горутину и работает
дальше. Процесс встаёт только если ждать начали все горутины разом.Пошагово, что делает рантайм
chansendберёт лок канала, видит пустуюrecvqиdataqsiz == 0.- Достаёт
sudogиз кэша, кладёт в него указатель на отправляемое значение (значение остаётся в стеке отправителя, копии пока нет) и ставит его вsendq. - Вызывает
gopark: статус_Gwaiting, причинаchan send, лок канала отпускается. - Управление уходит в
schedule(). Поток M не блокируется, он подбирает следующую runnable-горутину и продолжает жечь процессор. - Когда придёт получатель,
chanrecvнайдёт нашsudog, скопирует значение напрямую из нашего стека в свой и вызоветgoready.
«Блокировка по существу»
Так называют настоящую, семантическую блокировку: горутина не может продолжить, потому что
логически ждёт события. Есть ещё «блокировка по реализации», это когда встал поток ОС.
В Go на канале, мьютексе, WaitGroup и сетевом I/O блокируется
только горутина: планировщик её снимает и переиспользует поток. А вот блокирующий syscall
к диску, вызов через cgo или runtime.LockOSThread блокируют настоящий поток:
P у него отберёт sysmon, но сам M будет стоять.
fatal error: all goroutines are asleep - deadlock! печатается, только если
каждая горутина ждёт события, которое может дать исключительно
другая горутина Go. Если параллельно живёт хоть один time.Sleep, открытый
сетевой сокет или горутина в syscall, рантайм считает, что кто-то ещё может проснуться,
и молчит. Поэтому в реальном сервисе зависшая горутина ничего не роняет, а тихо
течёт, и ловится только через pprof.
recvq: тот, кто встал в очередь раньше. Дублирования не бывает
никогда.
recvq устроен как обычный двусвязный список, а dequeue() берёт голову.
Значит, при отправке одного значения его получит горутина, которая раньше всех
припарковалась на этом канале. Отсюда канал и работает распределителем работы:
N воркеров в for job := range jobs разберут задачи без дублей и без
дополнительной синхронизации.
FIFO соблюдается в очереди ожидания канала, но задачи от этого не расходятся
по воркерам равномерно и предсказуемо. Если воркер не успел встать
в recvq и в этот момент пришло значение через непустой буфер, порядок
будет другим. Спецификация языка гарантирует только «одно значение, один получатель»;
какой именно получатель его заберёт, решает рантайм, и полагаться на это нельзя.
И select с несколькими готовыми каналами вообще выбирает случайно.
jobs := make(chan int)
for w := 1; w <= 3; w++ {
go func(id int) {
for j := range jobs { // трое читают из одного канала
fmt.Println("worker", id, "job", j)
}
}(w)
}
jobs <- 42 // ровно одну строку напечатает какой-то один worker
close(jobs) // а вот это увидят все трое: broadcast через close
close(ch) (одноразовый сигнал всем) либо явным fan-out: по каналу на подписчика.Вариант 1: close - сигнал «всем, один раз»
done := make(chan struct{})
// N горутин делают <-done и все просыпаются от одного close
close(done)
Работает мгновенно и для тех, кто ещё не успел подписаться: чтение из закрытого канала
возвращается сразу. Упирается всё в одноразовость: повторно «раззакрыть» нельзя,
данные передать нельзя (только факт). Именно так устроен ctx.Done().
Чтобы передать вместе с сигналом ещё и значение, используют пару: сначала записать
значение в поле под мьютексом, потом закрыть канал. Читатель после пробуждения
гарантированно увидит запись (close happens-before приём из закрытого канала).
Вариант 2: fan-out - по каналу на подписчика
type Broker[T any] struct {
mu sync.RWMutex
subs map[chan T]struct{}
}
func (b *Broker[T]) Subscribe() (<-chan T, func()) {
ch := make(chan T, 16) // буфер, чтобы медленный подписчик не тормозил всех
b.mu.Lock()
b.subs[ch] = struct{}{}
b.mu.Unlock()
return ch, func() { // отписка
b.mu.Lock()
delete(b.subs, ch)
b.mu.Unlock()
close(ch)
}
}
func (b *Broker[T]) Publish(v T) {
b.mu.RLock()
defer b.mu.RUnlock()
for ch := range b.subs {
select {
case ch <- v: // отправили
default: // подписчик не успевает: дропаем, а не блокируем всех
}
}
}
- Блокирующая рассылка: если писать
ch <- vбезselect/default, один залипший подписчик остановит публикацию всем остальным. Нужно решить политику явно: дропать, копить в личной очереди или отключать медленного. - Кто закрывает канал подписчика: закрывать должен брокер, и только после
удаления из мапы под тем же локом, иначе
Publishнапишет в уже закрытый канал и получит панику. sync.Cond.Broadcastподойдёт, если подписчики ждут изменения состояния, а не получают сообщения. Но в 95% случаев в Go выбирают каналы, потому чтоCondне дружит сselectи сcontext.
select с default - единственный
корректный способ. Проверка через len(ch) < cap(ch) - гонка.select {
case ch <- v:
// приняли
default:
// не приняли: дропаем, считаем метрику, кладём в overflow
}
Почему select, а не len
select с default компилируется в selectnbsend →
chansend(c, v, block=false): рантайм берёт лок канала и в одной критической
секции и проверяет готовность, и выполняет отправку. Между «проверил» и «отправил» ничего
вклиниться не может. Вариант if len(ch) < cap(ch) { ch <- v } распадается на две
разные операции: между ними другая горутина заполнит буфер, и ты заблокируешься именно
там, где обещал не блокироваться.
| Небуферизированный | Буферизированный | |
|---|---|---|
| Когда case сработает | только если получатель прямо сейчас стоит в recvq | если есть свободное место в буфере или ждёт получатель |
| Насколько предсказуемо | плохо: зависит от гонки планировщика | хорошо: default честно значит «полно» |
| Типичный смысл default | «никто не слушает» | «очередь переполнена» |
| Где применяют | почти нигде - обычно ошибка проектирования | метрики, логи, телеметрия, notify-каналы |
Неблокирующая отправка в небуферизированный канал в цикле «отправим, если кто-то
читает» почти всегда молча теряет данные: получатель готов взять значение
через сто наносекунд, но в момент проверки он ещё не встал в очередь, и ты уходишь в
default. Нужно «не потерять, но и не ждать вечно», бери буфер 1
или таймаут вместо default. Идиома с default оправдана
только там, где потерю значения выбрали сознательно (сэмплирование метрик,
сигнал «есть новая работа», где важен факт, а не число сигналов).
select с time.After
или, лучше, с ctx.Done(). Для чужой блокирующей функции - запустить её в
отдельной горутине и ждать её результат в select, помня, что сама функция
так не отменится.Таймаут на операцию с каналом
select {
case v := <-ch:
return v, nil
case <-time.After(5 * time.Second):
return zero, context.DeadlineExceeded
}
// В сервисе почти всегда лучше наследовать таймаут от вызывающего:
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return zero, ctx.Err()
}
Обёртка над функцией, которая не умеет отменяться
func withTimeout[T any](ctx context.Context, f func() (T, error)) (T, error) {
type res struct {
v T
err error
}
done := make(chan res, 1) // буфер 1: горутина не зависнет, если мы ушли
go func() {
v, err := f()
done <- res{v, err}
}()
select {
case r := <-done:
return r.v, r.err
case <-ctx.Done():
var zero T
return zero, ctx.Err() // а f() продолжает работать в фоне
}
}
- Буфер 1 у
done, иначе горутина навсегда встанет на отправке, когда мы уже вернулись по таймауту. Это и есть утечка горутин номер один в проде. - Таймаут не отменяет
f(). Она доработает, съест свои ресурсы, возможно, откроет соединение и запишет в БД. Мы просто перестали её ждать. Настоящая отмена возможна, только если функция сама принимаетctxили у неё естьSetDeadline/Close. - Ресурсы под контролем. Если такие обёртки висят на каждом запросе, а
f()тормозит по 30 секунд, число живых горутин растёт линейно с нагрузкой. Значит, нужен ещё и семафор, ограничивающий количество одновременных вызовов. - Для сети таймаут делается правильно, через
conn.SetReadDeadlineилиhttp.Client.Timeout: там отменяется сама операция, а не одно лишь ожидание.
range завершается только когда канал закрыт
и буфер вычерпан. Закрывает всегда отправитель - получатель не имеет права, потому что не
знает, закончил ли писатель.
Цикл for v := range ch компилятор разворачивает ровно в
for { v, ok := <-ch; if !ok { break }; ... }. Никаких других способов выйти
у него нет: ни len(ch) == 0, ни таймаут его не завершат. Отсюда два
практических следствия.
- Если канал не закрыть, горутина с
rangeостанется навсегда. Типовая утечка, и часто её не замечают, потому что программа продолжает работать. - Если выходить надо ещё и по отмене контекста,
rangeне подходит, нуженselect.
// range: выход только по close
for v := range ch {
process(v)
}
// сюда попадём, лишь когда
// кто-то сделает close(ch)
// select: выход по close или по отмене
for {
select {
case v, ok := <-ch:
if !ok { return }
process(v)
case <-ctx.Done():
return
}
}
Правило закрытия и почему оно именно такое
Закрытие утверждает: «данных больше не будет». Знать это может только тот, кто данные производит. Если получатель закроет канал, любой продолжающий писать отправитель получит панику. Отсюда полный свод:
- Писатель один: он и закрывает, обычно
defer close(ch). - Писателей много: никто из них не закрывает, закрывает координатор после
wg.Wait()в отдельной горутине. - Надо остановить писателя: шли отдельный канал
done/ctxв обратную сторону, а не закрывай канал данных получателем. - Канал вообще можно не закрывать, если он больше не нужен: GC соберёт его вместе с
hchan. Закрытие нужно только как сигнал.
Почему проверка не работает
// Так не работает: между проверкой и отправкой гонка.
if !isClosed(ch) {
ch <- v // между if и этой строкой канал могли закрыть -> panic
}
Более того, встроенного isClosed вообще нет. Узнать о закрытости можно
единственным способом: попробовать прочитать, а это разрушающая операция.
Работающие подходы, по возрастанию надёжности
- Дисциплина владения (правильный ответ). У канала один владелец: он создаёт,
пишет и закрывает. Наружу отдаётся
<-chan T, так что чужой код физически не может ни писать, ни закрывать. 90% проблем исчезает на этом шаге. - Отдельный done-канал. Сигнал остановки идёт навстречу потоку данных.
Отправитель проверяет его в том же
select, что и отправку:
Запомни: закрываетсяselect { case ch <- v: case <-done: // нам сказали остановиться: выходим, не закрывая ch return }done, а неch. Канал данных вообще может остаться незакрытым, его соберёт GC. sync.Onceна close. Если по архитектуре закрыть канал может несколько мест, остаётся один корректный вариант: обернуть закрытие:
Это убирает панику «close of closed channel», но не убирает панику «send on closed channel»: отправитель по-прежнему может писать в уже закрытый канал.type SafeChan struct { ch chan int once sync.Once } func (s *SafeChan) Close() { s.once.Do(func() { close(s.ch) }) }- Мьютекс вокруг отправки и закрытия. Полностью безопасно, но убивает саму идею
канала:
RWMutex, отправка подRLock, закрытие подLockс флагом. Годится как аварийный вариант в чужом легаси. recoverв отправителе. Работает, но это признание в проигрыше: паника уже разрушила инварианты, значение потеряно, и такой код не проходит ревью.
«Вопрос “как безопасно писать в канал, который могут закрыть” выдаёт размазанное владение каналом. Лечится он не защитой на отправке, а перепроектированием: канал данных закрывает только его писатель, а сигнал остановки идёт отдельным каналом в обратную сторону.»
len/cap годятся для метрик и логов, для управления потоком - нет.
Атомарная проверка - только select с default.Что делают len и cap
len(ch) читает поле qcount, cap(ch) читает
dataqsiz, и оба без взятия c.lock. Data race по букве
memory model тут нет (это выровненное чтение одного слова, и рантайм читает его
напрямую), но есть race condition на уровне логики: между твоей проверкой и
действием состояние канала меняют другие горутины.
// Опасно: TOCTOU, классическая race condition
if len(ch) < cap(ch) {
ch <- v // здесь буфер уже могли забить, заблокируемся
}
// Правильно: проверка и отправка атомарны под локом канала
select {
case ch <- v:
default:
}
// Допустимо: метрики, где неточность не критична
metrics.QueueDepth.Set(float64(len(ch)))
Как дочитать канал до конца перед завершением (drain)
// 1. Отправитель закрыл канал, лучший случай:
for v := range ch { // сам выйдет, когда буфер опустеет
handle(v)
}
// 2. Канал не закрыт, забираем только то, что уже лежит:
for {
select {
case v := <-ch:
handle(v)
default:
return // готового больше нет, уходим
}
}
// 3. Дренаж с дедлайном: забрать хвост, но не ждать вечно
t := time.NewTimer(2 * time.Second)
defer t.Stop()
for {
select {
case v, ok := <-ch:
if !ok { return }
handle(v)
case <-t.C:
return // хвост не успели: потерю принимаем осознанно
}
}
default означает «в этот момент готового значения нет», а не «отправитель
закончил». Если продюсер ещё жив и просто чуть отстал, ты выйдешь из дренажа и потеряешь
то, что он отправит через микросекунду. Детерминированный дренаж бывает один: когда
отправитель закрыл канал. Тогда range гарантированно заберёт всё, что
было отправлено, и завершится. Отсюда типовой порядок graceful shutdown:
остановить продюсеров → закрыть канал → дождаться, пока консьюмеры допереварят.
chan Tдвунаправленный, можно всё, включаяclose.chan<- Tумеет отправку иclose. Чтение не компилируется.<-chan T: только приём. Ни отправка, ниcloseне компилируются:invalid operation: cannot close receive-only channel.
Приведение работает только в сторону сужения и делается неявно при передаче аргумента.
Обратно не выйдет даже через явный каст: chan<- int в
chan int не приводится. Это и делает гарантию настоящей: получив
<-chan T, вернуть себе право писать нельзя.
// Сигнатура сама документирует роль каждой стороны.
func produce(out chan<- Job) { defer close(out); ... } // может закрыть
func consume(in <-chan Job) { for j := range in { ... } } // не может испортить
func pipe(in <-chan int) <-chan int { ... } // стадия конвейера
ch := make(chan Job)
go produce(ch) // ch неявно сузился до chan<- Job
consume(ch) // и до <-chan Job
В параметрах функций всегда указывай направление: это бесплатная документация
и проверка. В полях структур по вкусу, но если структура отдаёт канал наружу через
геттер, геттер должен возвращать <-chan T. Внутри одной функции, где
канал и создаётся, и используется, направление не нужно. И запомни побочный эффект:
<-chan T нельзя передать туда, где ждут chan T. Иногда
это ломает подстановку в чужой API, и тогда придётся держать двунаправленный канал
внутри и сужать только на выходе.
chan struct{} - канал чистого
сигнала: элемент занимает 0 байт, и тип сам говорит «данных здесь нет».Канал в канале
Тип chan chan T абсолютно легален, и на нём стоят два рабочих паттерна:
«канал ответа внутри запроса» (см. схему выше) и «канал подтверждения остановки».
Второй выглядит так:
// Просим воркер остановиться и ждём подтверждения, что он закончил.
type stopReq struct{ ack chan struct{} }
stop := make(chan stopReq)
go func() { // воркер
for {
select {
case r := <-stop:
cleanup()
close(r.ack) // «я всё, можешь идти дальше»
return
case j := <-jobs:
handle(j)
}
}
}()
ack := make(chan struct{})
stop <- stopReq{ack}
<-ack // синхронная остановка с подтверждением
Почему struct{}, а не bool
unsafe.Sizeof(struct{}{}) == 0. В буферизированном канале на 1000 элементов буфер займёт 0 байт полезных данных вместо 1000; все нулевые значения указывают наruntime.zerobase, иmemmoveкопирует 0 байт. Экономия честная, но в 99% случаев неважная.- Важнее семантика.
chan boolзаставляет читателя гадать: а что значитfalse? «Остановись» или «не останавливайся»? Сchan struct{}вопроса не возникает: значение неинформативно по типу, информативен только факт получения (или закрытия). - Это идиома стандартной библиотеки:
ctx.Done() <-chan struct{},http.Server.Shutdown, все внутренние сигнальные каналы рантайма.map[T]struct{}как множество работает на той же идее.
close(done) даёт одноразовый broadcast всем: любой, кто читает, узнаёт
мгновенно, даже если подписался позже. done <- struct{}{} шлёт адресный
сигнал одному, и его можно повторять. Первое берут для отмены и завершения, второе годится
для тактов и подтверждений. Перепутаешь, получишь баг «сигнал получил только один
воркер из пяти».
dataqsiz задаётся в makechan
и неизменяем, буфер выделен один раз ровно под этот размер. «Резиновый» канал
делается горутиной-посредником со слайсом-очередью внутри.Почему нельзя
makechan считает размер один раз: если elemsize == 0 или
size == 0, выделяется только hchan; иначе hchan
и буфер size * elemsize: если в элементах нет указателей, одной аллокацией подряд в памяти, иначе двумя.
Поменять размер значило бы перевыделить буфер, переложить элементы, поправить
sendx/recvx под локом и заодно испортить указатели
sudog.elem. Команда Go сознательно оставила канал примитивом с фиксированной
ёмкостью: неограниченная очередь прячет отсутствие backpressure, а это чаще баг,
чем фича.
Если очень нужно
Два входных и выходных канала плюс горутина-диспетчер со слайсом. Весь трюк в том, чтобы выключать ветку отправки через nil-канал, когда очередь пуста (код выше, в теории главы). Свойства такой конструкции:
inникогда не блокирует отправителя, очередь растёт в куче;- порядок FIFO сохраняется;
- цена такая: лишняя горутина, лишняя передача через канал и неограниченный рост памяти, если потребитель медленнее продюсера.
Неограниченный буфер откладывает OOM, а не отменяет его. Если продюсер стабильно быстрее
консьюмера, никакая очередь не спасёт: она лишь переносит момент падения. На
«канал переполняется» правильно отвечать так: ограничить продюсера (backpressure), дропать с метрикой
(select/default), добавить консьюмеров или вынести очередь во внешнюю
систему с персистентностью. «Резиновый канал» уместен только там, где всплеск заведомо
конечен и известен по размеру.
| Значение | Указатель | |
|---|---|---|
| Что копируется | elemsize байт (при рандеву - один раз стек→стек) | 8 байт |
| Аллокация | обычно нет, объект живёт в стеке | объект обязан уехать в кучу |
| Нагрузка на GC | нулевая | +1 объект для сканирования |
| Гонки | невозможны: у каждого своя копия | возможны, если отправитель продолжит трогать объект |
| Мутация получателем | не видна отправителю | видна - иногда это и нужно |
| Порог выгоды | примерно 64–128 байт: ниже - значение дешевле, выше - начинает выигрывать указатель | |
Что важнее размера - владение
Отправляя указатель, ты отдаёшь владение объектом. Контракт должен быть явным:
после ch <- p отправитель к *p не прикасается ни на чтение,
ни на запись. Нарушишь, получишь гонку, которую race-детектор поймает, только если оба доступа
реально совпали по времени в тестовом прогоне. То есть в проде она всплывёт через полгода.
Со значением такого класса ошибок не существует физически.
// Опасно: буфер переиспользуется после отправки
buf := make([]byte, 4096)
for {
n, _ := r.Read(buf)
ch <- buf[:n] // отправили заголовок слайса, массив общий
// следующая итерация перезапишет то, что читает получатель
}
// Безопасно: копия на каждую отправку
for {
n, _ := r.Read(buf)
chunk := make([]byte, n)
copy(chunk, buf[:n])
ch <- chunk
}
// Безопасно и без аллокаций: буфер возвращается по второму каналу (пул)
free := make(chan []byte, 8)
b := <-free
n, _ := r.Read(b)
ch <- b[:n] // получатель, закончив, вернёт b в free
Формально это «передача по значению», но копируется только заголовок: 24 байта для
слайса, 16 для строки, 8 для мапы. Массив под слайсом и бакеты мапы остаются общими.
Поэтому chan []byte де-факто работает каналом указателей со всеми их рисками,
а chan string безопасен, потому что строка иммутабельна.
type Request struct {
Payload int
Reply chan<- Response // направленный: воркер только пишет
}
type Response struct {
Result int
Err error
}
type Pool struct {
reqs chan Request
wg sync.WaitGroup
}
func NewPool(n int) *Pool {
p := &Pool{reqs: make(chan Request)}
p.wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer p.wg.Done()
for r := range p.reqs { // выйдут все n, когда закроем reqs
v, err := handle(r.Payload)
r.Reply <- Response{v, err} // не заблокируется: буфер 1
}
}()
}
return p
}
// Для вызывающего Call синхронный, внутри асинхронный.
func (p *Pool) Call(ctx context.Context, v int) (int, error) {
reply := make(chan Response, 1) // всё держится на буфере 1
select {
case p.reqs <- Request{v, reply}:
case <-ctx.Done():
return 0, ctx.Err() // не смогли даже поставить задачу
}
select {
case r := <-reply:
return r.Result, r.Err
case <-ctx.Done():
return 0, ctx.Err() // ушли, воркер допишет в буфер и освободится
}
}
func (p *Pool) Close() { close(p.reqs); p.wg.Wait() }
Что здесь принципиально
- Буфер 1 у канала ответа. Без него брошенный по таймауту запрос навсегда подвешивает воркер. С ним воркер кладёт ответ в буфер, идёт за следующей задачей, а осиротевший канал забирает GC.
- Отмена на обоих select. Клиент может не успеть ни поставить задачу, ни дождаться ответа, поэтому отменяемы должны быть обе точки.
- Направленный тип
chan<- Responseв структуре: воркер физически не может закрыть чужой канал ответа. - Закрытие
reqsкак сигнал остановки всем воркерам сразу, аwg.Wait()как гарантия, что все доработали.
Можно завести один общий канал ответов и map[requestID]chan Response под
мьютексом. Работает, но тащит за собой разделяемое состояние, лок на каждый запрос и
чистку мапы при таймаутах, иначе она течёт. Паттерн «канал в сообщении»
обходится вообще без общего состояния: время жизни канала ответа равно времени жизни
запроса, и убирать за собой не нужно.
select приоритетов нет - выбор среди
готовых case случайный, и это гарантировано спецификацией. Приоритет строится вручную:
вложенный select с default.// high приоритетнее low: сначала пытаемся забрать из high неблокирующе.
for {
select {
case v := <-high:
handle(v)
case v := <-low:
// прежде чем обработать low, ещё раз проверим high
select {
case h := <-high:
handle(h)
// low-значение уже забрано из канала, «вернуть» его нельзя,
// поэтому либо обработать позже, либо держать свою очередь
handle(v)
default:
handle(v)
}
case <-ctx.Done():
return
}
}
Как только внешний select выбрал ветку low, значение
уже извлечено из канала, «положить обратно» нельзя. Поэтому честный приоритет
требует, чтобы низкоприоритетное значение можно было отложить: либо обработать после
разбора очереди high, либо буферизовать в своём слайсе. Кто просто пишет
вложенный select и думает, что low «не заберётся», ошибается.
Более чистый вариант - сначала дренаж high
for {
// 1) выгребаем весь high, пока он не опустеет
for {
select {
case v := <-high:
handle(v)
continue
default:
}
break
}
// 2) high пуст, блокируемся на всём сразу
select {
case v := <-high:
handle(v)
case v := <-low:
handle(v)
case <-ctx.Done():
return
}
}
- Риск голодания: при постоянном потоке high-задач low не обработается никогда. В проде обычно вводят квоту: «после K high-задач обязательно берём одну low». Это weighted round-robin, а не строгий приоритет.
- Почему выбор случайный. Если бы
selectпроверял case сверху вниз, первый постоянно готовый канал заглушил бы все остальные, а порядок написания веток стал бы семантически значимым. Случайность делает голодание невозможным «по умолчанию» и заставляет писать приоритет явно, если он действительно нужен. - Иногда правильнее вообще не делать приоритет в select, а завести две отдельные группы воркеров с разными квотами: так проще рассуждать и наблюдать.
time.After создаёт новый таймер. До Go 1.23 он жил в куче таймеров рантайма до самого срабатывания, и в цикле копились сотни тысяч живых таймеров; сейчас брошенный таймер собирает GC, но аллокация на каждой итерации осталась. Замена - переиспользуемый
time.Timer или context.WithTimeout.Что именно текло
time.After(d) = NewTimer(d).C. Наружу отдают только канал, ссылки
на сам *Timer у тебя нет, значит Stop() вызвать невозможно.
До Go 1.23 таймер лежал в per-P куче таймеров рантайма и удерживал канал до истечения d. Если select ушёл в другую ветку, таймер всё равно досиживал свой срок. При цикле в 10 000 итераций в секунду и time.After(time.Minute) в пике жило до 600 000 таймеров, а это больше сотни мегабайт (замер на go1.23, arm64, в режиме старых таймеров: ~150 МБ) и заметный рост стоимости операций с кучей таймеров. С Go 1.23 таймер с каналом стоит в куче, только пока на его канале кто-то ждёт: select ушёл в другую ветку, и таймер из кучи убирается, дальше это обычный мусор. Остаются лишние аллокации на каждой итерации (на go1.27 - три, около 250 байт).
// Плохо
for {
select {
case v := <-ch:
handle(v)
case <-time.After(time.Minute):
return
}
}
// Хорошо
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case v := <-ch:
handle(v)
if !t.Stop() { <-t.C }
t.Reset(time.Minute)
case <-t.C:
return
}
}
Три штатные замены
- Один
time.Timerна цикл соStop/Reset, если таймаут отсчитывается заново после каждого события (idle timeout). context.WithTimeoutберут, когда дедлайн общий на всю операцию и должен пробрасываться вглубь.ctx.Done()вselect, одинdefer cancel(), никаких лишних таймеров:cancelчестно останавливает внутренний таймер.time.Tickerнужен для периодического такта, а не для однократного дедлайна. Привычкаdefer ticker.Stop()осталась с тех времён, когда неостановленный тикер будил рантайм вечно (до Go 1.23); теперь он будит рантайм, только пока его канал кто-то ждёт, но код соStopкорректен на любой версии.
До 1.23 неостановленный Timer/Ticker оставался достижимым и не собирался, пока не сработает: рантайм держал на него ссылку из кучи таймеров. Именно поэтому time.After в цикле считался утечкой в строгом смысле слова. В Go 1.23 таймеры стали обычными объектами кучи (в 1.23–1.26 при go-директиве 1.23+ в go.mod, а с Go 1.27 всегда: переключатель asynctimerchan убрали): несработавший time.After, на который никто не ссылается, GC теперь соберёт досрочно, а канал таймера стал небуферизированным, так что ритуал «осушить перед Reset» больше не нужен. Но совет не поменялся: каждая итерация по-прежнему аллоцирует новый таймер с каналом и нагружает GC, и явный Timer дешевле и предсказуемее. На собесе стоит сказать обе половины: и про классическую утечку, и про то, что с 1.23 её больше нет.
Что стоит запомнить
- Unbuffered send синхронизируется с receive.
- Закрытый buffered channel отдаёт накопленные значения.
- Канал закрывает координатор после последней отправки.
Частые ошибки
- Чтение закрытого пустого канала возвращает zero value.
- Отправка в закрытый канал паникует.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи выполнение
После отправки и закрытия buffered channel прочитай три раза. Определи значения и ok.
ch := make(chan int, 2)
ch <- 4; ch <- 8; close(ch)
a, ok1 := <-ch; b, ok2 := <-ch; c, ok3 := <-ch
fmt.Println(a, ok1, b, ok2, c, ok3)
Ответ и разбор
Вывод 4 true 8 true 0 false: закрытие не удаляет буфер. После чтения двух значений канал закрыт и пуст, поэтому третье чтение возвращает zero value и ok=false.
Код-ревью
2. Найди проблему и исправь
Функцию могут вызвать несколько sender. Исправь ответственность за закрытие.
func send(ch chan int, v int) { ch <- v; close(ch) }
Ответ и разбор
Удали close из send. Закрывает канал единый координатор после завершения всех sender; send не должен закрывать общий канал.
Применение
3. Разбери рабочий сценарий
Consumer закрывает входной канал, чтобы сообщить отправителям, что он больше не читает. Отправитель после этого продолжает отправлять. Исправь протокол завершения и объясни, кто закрывает data channel.
Ответ и разбор
Получатель не закрывает общий data channel: отправка после close вызовет panic. Закрытие означает, что отправители больше не пришлют значений, поэтому его делает владелец отправок после их завершения. Для сигнала «consumer больше не читает» используй отдельный context.Context или done-channel; отправитель делает select между ch <- value и <-ctx.Done() и прекращает работу при отмене.
Применение
4. Разбери граничный сценарий
Поток событий завершается по контексту, но потребитель также должен отличать нормальное окончание входа. Как представить оба сигнала?
Ответ и разбор
Закрытие канала входа означает, что новых значений больше не будет; ctx.Done означает отмену/прерывание. В цикле выбирай между receive и Done, обрабатывай ok=false как конец потока и возвращай ctx.Err при отмене.