MBL
Go / demo / Горутины и каналы
Go средний

Горутины и каналы

concurrencychannels

Горутина — это функция, запущенная в собственном потоке выполнения планировщиком Go, а не операционной системой напрямую. Создать её стоит go func(){ ... }(): несколько байт стека вместо мегабайта у ОС-потока, поэтому в проде нормально держать десятки тысяч горутин одновременно.

Канал — это типизированная очередь для обмена данными между горутинами вместо общей памяти под мьютексом. Небуферизированный канал make(chan int) синхронный: отправка блокируется, пока кто-то не примет значение. Буферизированный make(chan int, 3) блокируется только когда буфер полон.

func worker(jobs <-chan int, results chan<- int) {
    for j := range jobs {
        results <- j * j
    }
}

Здесь <-chan int и chan<- int — однонаправленные типы каналов: компилятор не даст воркеру случайно записать в jobs или прочитать из results. Это не рантайм-проверка, а статическая гарантия на уровне сигнатуры функции.

Утечка горутины через незакрытый канал

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

func leaky() <-chan int {
    ch := make(chan int)
    go func() {
        ch <- expensiveCompute() // если получателя не будет — зависнет тут навсегда
    }()
    return ch
}

func main() {
    leaky() // результат никто не читает — горутина живёт до конца программы
}

Горутина из примера выше никогда не завершится и никогда не будет собрана GC — в отличие от обычных объектов, заблокированная горутина не мусор, планировщик считает её живой. При частом вызове leaky() в цикле это неограниченный рост числа горутин, которые видно в pprof как висящие на chan send.

Чинится это честным контекстом с отменой — вместо голой отправки в канал, select между отправкой и ctx.Done():

func leaky(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        select {
        case ch <- expensiveCompute():
        case <-ctx.Done():
        }
    }()
    return ch
}

Запусти сам и убедись: без WaitGroup программа почти наверняка завершится раньше, чем горутина успеет напечатать что-либо — main не ждёт.

на play.golang.org

Убери wg.Add(1)/wg.Wait() и посмотри, что выведется — скорее всего, ничего из горутин: main завершится первым.

select с default — неблокирующая проверка

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

select {
case v := <-ch:
    fmt.Println("получено:", v)
default:
    fmt.Println("канал пуст, ждать не буду")
}

nil-канал — не паника, а вечная блокировка

Чтение или запись в nil-канал (необъявленный, только var ch chan int) не паникует — она блокируется навсегда, потому что рантайм трактует "неинициализированный канал" как канал без единого готового к обмену конца. Это отличает каналы от, например, nil-мапы: чтение из nil-мапы работает и возвращает zero value, а вот запись в неё уже паникует. У каналов nil ведёт себя иначе, чем у обеих операций с мапой — и именно поэтому nil-канал иногда осознанно используют внутри select, чтобы навсегда выключить один из кейсов, не удаляя его из кода.

Самопроверка 0 / 3
Могу объяснить, почему незакрытый канал приводит к утечке горутины
Знаю, как select с default превращает блокирующий приём в неблокирующий
Понимаю, почему чтение из nil-канала блокируется навсегда, а не паникует
Как усвоено?