voidlearn
 

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)
}
hchan qcount элементов в буфере dataqsiz размер буфера buf кольцевой массив elemsize размер элемента closed 0 или 1 elemtypeтип элемента sendx индекс записи recvx индекс чтения recvq кто ждёт чтения sendq кто ждёт записи lock мьютекс рантайма recvx - откуда читать sendx - куда писать 0 1 v12 v23 v34 5 буфер кольцевой: индексы считаются по модулю dataqsiz здесь dataqsiz = 6, qcount = 3, recvx = 2, sendx = 5 Приём берёт buf[recvx] и двигает recvx. Отправка кладёт в buf[sendx] и двигает sendx. recvq - горутины, ждущие значения sudog g = G4 elem = &x sudog g = G9 elem = &y sudog g = G11 elem = &z sudog хранит: g - какая горутина ждёт elem - адрес значения next/prev - связный список success - было ли значение sendq пусто (есть читатели - значит писателей нет) Обе очереди непусты одновременно быть не могут: если кто-то ждёт значения, отправитель отдаст его напрямую и не встанет в sendq. Исключение - горутины, ждущие в select сразу на нескольких каналах, и момент закрытия канала.
hchan. Всё, что делает канал каналом, здесь: мьютекс на все операции, кольцевой буфер с двумя индексами и два связных списка sudog. Из этой картинки и выводится, почему отправка в закрытый канал паникует, а чтение отдаёт zero value.

Механика отправки: chansend

Порядок проверок в runtime.chansend стоит уметь рассказать наизусть. Обрати внимание на пункт 3: если кто-то уже ждёт, значение не проходит через буфер вообще, оно копируется прямо в стек получателя. Так экономится одно копирование, а данные остаются горячими в кэше.

ch <- v ch == nil ? gopark без единого шанса на пробуждение - горутина висит вечно (в main это deadlock-паника рантайма) нет closed == 1 ? panic: send on closed channel Это не ошибка, а паника - восстановить можно только recover нет recvq непуста ? ПРЯМАЯ ПЕРЕДАЧА: memmove в стек ждущей горутины по sudog.elem, затем goready(g). Буфер не используется вообще нет qcount < dataqsiz ? memmove в buf[sendx]; sendx = (sendx+1) % dataqsiz; qcount++ Отправитель не блокируется и идёт дальше нет места нет sudog{g, elem: &v} кладётся в sendq, затем gopark. Разбудит получатель, забрав значение прямо из sudog Приём симметричен: сначала смотрим sendq (взять у ждущего отправителя), потом буфер, потом сами паркуемся в recvq. Тонкость: если буфер полон и есть ждущий отправитель, приём берёт из буфера, а sudog отправителя дописывает своё значение в освободившийся слот.
chansend. Пять веток, и любое «а что если» сводится к выбору одной из них. Заметь: закрытость проверяется до всего остального, поэтому даже буферизированный канал со свободным местом внутри всё равно паникует.

Небуферизированный канал: рандеву

У небуферизированного канала dataqsiz == 0, и ветка «положить в буфер» не срабатывает никогда. Значит, отправить получится только тогда, когда получатель уже готов, а принять, когда готов отправитель. Это и называют рандеву: операция завершается в момент встречи, значение переезжает атомарно, минуя любое промежуточное хранилище.

отправитель получатель ch <- 1 <-ch работает gopark: sudog в sendq, горутина спит разбужена, идёт дальше занят своими делами забирает значение получил 1 memmove напрямую: стек отправителя в стек получателя goready: отправитель снова runnable Отправка и приём в небуферизированном канале - одно событие. Кто пришёл первым, тот и ждёт; второй завершает обе операции разом. Отсюда гарантия: вернулись из ch <- v - значит получатель уже забрал значение. У буферизированного канала такой гарантии нет.
Рандеву. Значение никогда не «лежит в канале»: оно переезжает из стека в стек в момент встречи. Поэтому небуферизированный канал передаёт данные и заодно синхронизирует обе стороны.

Аксиомы канала - полная таблица

Операция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
Три вещи, которые выводятся из hchan, а не заучиваются
  • Почему 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, после всех
}
Дисциплина владения каналом
  1. У канала ровно один владелец: тот, кто его создал, пишет в него и закрывает.
  2. Владелец отдаёт наружу направленный тип: <-chan T для читателей. Тогда закрыть его физически невозможно: close на receive-only канале не компилируется.
  3. Если писателей несколько, они не закрывают ничего; закрывает координатор после wg.Wait().
  4. Сигнал «остановись» идёт отдельным каналом (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 выглядит пугающе, но на нём стоит паттерн «канал ответа внутри запроса»: клиент кладёт в общий канал запросов структуру, где одним из полей лежит его личный канал для ответа.

Клиенты Общий канал запросов Пул воркеров client A reply chan int client B reply chan int client C reply chan int requests chan Job Job{ Arg: 7, Reply: chanA } адрес личного канала едет внутри Job{ Arg: 3, Reply: chanB } воркер не знает, кто клиент свободные слоты буфера worker 1 берёт Job worker 2 считает worker 3 j.Reply <- res ответ идёт напрямую в личный канал клиента, минуя общую очередь
RPC поверх каналов. Общий канал раздаёт работу, а обратный адрес едет внутри самого сообщения. Воркер не знает клиента, клиент не знает воркера, но каждый получает ровно свой ответ, и никакой мапы «id → результат» не нужно.
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()         // клиент ушёл, но воркер всё равно допишет в буфер и не зависнет
    }
}
Почему у канала ответа буфер ровно 1

Сделаешь 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
    }
}
Go 1.23 переписал таймеры

До 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 байта заголовка, но массив под ним общий: два владельца одного буфера. Самый частый источник «невидимых» гонок в конвейерах.

Вопросы

20
Суть: канал - это указатель на структуру hchan в куче: кольцевой буфер, обычный мьютекс и две 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.

Единственное место в Go, где пишут в чужой стек

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-beforesend и recv взаимно синхронизируют обе горутиныsend i-го значения происходит до завершения recv i-го; и recv i-го - до завершения send (i+N)-го
Копий значения1 (стек → стек)2 (стек → буфер → стек), если никто не ждал
Задержкавыше: почти всегда парковканиже: пока есть место, send не паркуется
Что скрываетничеговсплеск нагрузки - и задержку обнаружения того, что потребитель умер

Практический критерий выбора

  • Буфер 0 берут, когда нужна синхронизация и обратное давление. Продюсер физически не может обогнать консьюмера. Это дефолт, с которого стоит начинать.
  • Буфер 1 работает как «почтовый ящик»: отправитель кладёт и уходит, гарантированно не зависая. Канал ответа в RPC, канал ошибки, сигнальный канал.
  • Буфер N сглаживает рывки, когда средняя скорость потребителя выше средней скорости продюсера, а пики короткие. Число надо обосновать (например, «столько же, сколько воркеров»), а не «поставим 100, пусть будет».
Чем большой буфер вреден

Буфер маскирует проблему: система выглядит работающей, пока он не переполнится, а потом падает резко. Плюс всё, что лежит в буфере, при аварийной остановке теряется: персистентной очереди тут нет. И плюс задержка: значение может пролежать в буфере секунды, а отправитель уже отчитался «отправлено». Нужна надёжная очередь, бери Kafka или RabbitMQ, а не chan.

Суть: nil-канал блокирует навсегда во всех операциях, кроме 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 channelpanic: close of closed channel
len/cap0 / 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 берёт из очереди следующую горутину и работает дальше. Процесс встаёт только если ждать начали все горутины разом.

Пошагово, что делает рантайм

  1. chansend берёт лок канала, видит пустую recvq и dataqsiz == 0.
  2. Достаёт sudog из кэша, кладёт в него указатель на отправляемое значение (значение остаётся в стеке отправителя, копии пока нет) и ставит его в sendq.
  3. Вызывает gopark: статус _Gwaiting, причина chan send, лок канала отпускается.
  4. Управление уходит в schedule(). Поток M не блокируется, он подбирает следующую runnable-горутину и продолжает жечь процессор.
  5. Когда придёт получатель, 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.

Суть: ровно одна, и это гарантия языка. Какая именно - определяется порядком FIFO в recvq: тот, кто встал в очередь раньше. Дублирования не бывает никогда.

recvq устроен как обычный двусвязный список, а dequeue() берёт голову. Значит, при отправке одного значения его получит горутина, которая раньше всех припарковалась на этом канале. Отсюда канал и работает распределителем работы: N воркеров в for job := range jobs разберут задачи без дублей и без дополнительной синхронизации.

Осторожно с формулировкой «FIFO»

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
Суть: отправкой - никак, значение заберёт один. Broadcast делается 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
    }
}

Правило закрытия и почему оно именно такое

Закрытие утверждает: «данных больше не будет». Знать это может только тот, кто данные производит. Если получатель закроет канал, любой продолжающий писать отправитель получит панику. Отсюда полный свод:

  1. Писатель один: он и закрывает, обычно defer close(ch).
  2. Писателей много: никто из них не закрывает, закрывает координатор после wg.Wait() в отдельной горутине.
  3. Надо остановить писателя: шли отдельный канал done/ctx в обратную сторону, а не закрывай канал данных получателем.
  4. Канал вообще можно не закрывать, если он больше не нужен: GC соберёт его вместе с hchan. Закрытие нужно только как сигнал.
Суть: в языке нет атомарной «отправь, если не закрыт» - проверить закрытость и отправить одной операцией невозможно. Значит, проблема решается не проверкой, а дисциплиной владения.

Почему проверка не работает

// Так не работает: между проверкой и отправкой гонка.
if !isClosed(ch) {
    ch <- v      // между if и этой строкой канал могли закрыть -> panic
}

Более того, встроенного isClosed вообще нет. Узнать о закрытости можно единственным способом: попробовать прочитать, а это разрушающая операция.

Работающие подходы, по возрастанию надёжности

  1. Дисциплина владения (правильный ответ). У канала один владелец: он создаёт, пишет и закрывает. Наружу отдаётся <-chan T, так что чужой код физически не может ни писать, ни закрывать. 90% проблем исчезает на этом шаге.
  2. Отдельный done-канал. Сигнал остановки идёт навстречу потоку данных. Отправитель проверяет его в том же select, что и отправку:
    select {
    case ch <- v:
    case <-done:      // нам сказали остановиться: выходим, не закрывая ch
        return
    }
    Запомни: закрывается done, а не ch. Канал данных вообще может остаться незакрытым, его соберёт GC.
  3. sync.Once на close. Если по архитектуре закрыть канал может несколько мест, остаётся один корректный вариант: обернуть закрытие:
    type SafeChan struct {
        ch   chan int
        once sync.Once
    }
    func (s *SafeChan) Close() { s.once.Do(func() { close(s.ch) }) }
    Это убирает панику «close of closed channel», но не убирает панику «send on closed channel»: отправитель по-прежнему может писать в уже закрытый канал.
  4. Мьютекс вокруг отправки и закрытия. Полностью безопасно, но убивает саму идею канала: RWMutex, отправка под RLock, закрытие под Lock с флагом. Годится как аварийный вариант в чужом легаси.
  5. 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              // хвост не успели: потерю принимаем осознанно
    }
}
Подвох варианта 2

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), добавить консьюмеров или вынести очередь во внешнюю систему с персистентностью. «Резиновый канал» уместен только там, где всплеск заведомо конечен и известен по размеру.

Суть: по умолчанию значение - оно даёт изоляцию и не грузит GC. Указатель - когда объект большой (сотни байт и больше), нужен мутируемый общий объект, или это уже указатель по природе.
ЗначениеУказатель
Что копируется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 безопасен, потому что строка иммутабельна.

Суть: «обратный адрес в конверте» - в структуру запроса кладётся личный канал ответа с буфером 1. Воркеру не нужно знать клиента, клиенту не нужно знать воркера, и никакой мапы «id → результат» не требуется.
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
    }
}

Три штатные замены

  1. Один time.Timer на цикл со Stop/Reset, если таймаут отсчитывается заново после каждого события (idle timeout).
  2. context.WithTimeout берут, когда дедлайн общий на всю операцию и должен пробрасываться вглубь. ctx.Done() в select, один defer cancel(), никаких лишних таймеров: cancel честно останавливает внутренний таймер.
  3. time.Ticker нужен для периодического такта, а не для однократного дедлайна. Привычка defer ticker.Stop() осталась с тех времён, когда неостановленный тикер будил рантайм вечно (до Go 1.23); теперь он будит рантайм, только пока его канал кто-то ждёт, но код со Stop корректен на любой версии.
Что изменилось в Go 1.23

До 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 при отмене.