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

Как Go запускает тысячи конкурентных задач и как они передают друг другу данные. Внутри — схемы, которые можно трогать.

Листайте стрелками ← → на клавиатуре, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

Горутина — функция, запущенная конкурентно

Слово go перед вызовом запускает функцию и сразу возвращает управление. Вызывающий код не ждёт её завершения.


func worker(id int, wg *sync.WaitGroup) {
    defer wg.Done()
    work(id)
}

func main() {
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go worker(i, &wg)
    }
    wg.Wait() // ждём, пока все вызовут Done
}

Когда main возвращается, программа завершается вместе со всеми горутинами, даже недоделанными. Поэтому их нужно дождаться: через sync.WaitGroup, канал или errgroup.

Почему горутин может быть миллион

  • Стек горутины начинается с 2 КБ и растёт по мере надобности: рантайм копирует его в больший. Поток ОС сразу резервирует стек фиксированного размера, обычно 1–8 МБ.
  • Переключение между горутинами делает рантайм Go в пространстве пользователя, без перехода в ядро.
  • Горутины не привязаны к потокам: рантайм раскладывает их по небольшому числу потоков ОС.

Планировщик: G, M и P

G
Горутина: её стек, текущая инструкция и состояние.
M
Поток ОС (machine). Именно он исполняет код.
P
Процессор — право исполнять Go-код. Их ровно GOMAXPROCS, по умолчанию по числу ядер. У каждого P своя очередь готовых горутин.

Если у P кончилась работа, он берёт горутину из глобальной очереди или крадёт половину чужой очереди (work stealing). Нажмите «Системный вызов»: горутина уходит в ядро вместе со своим M, а P сразу получает другой M и продолжает работу.

Канал без буфера — точка встречи


ch := make(chan int)

go func() { ch <- 42 }() // отправка
v := <-ch                // получение

Отправка блокирует горутину, пока кто-нибудь не придёт за значением. Получение блокирует, пока кто-нибудь не отправит.

Передача происходит, только когда встретились оба. Поэтому небуферизованный канал не только передаёт данные, но и синхронизирует горутины. Попробуйте нажимать кнопки в разном порядке.

Буферизованный канал — очередь с лимитом

Пока в буфере есть место, отправитель не ждёт. Буфер полон — отправитель засыпает. Буфер пуст — засыпает получатель.

Под схемой — настоящие поля структуры канала из рантайма Go. Буфер кольцевой: индексы sendx и recvx бегают по кругу. Каждое нажатие — новая горутина, которая выполняет операцию.

Под капотом: runtime.hchan

  • make(chan T) выделяет hchan в куче и возвращает указатель. Копия переменной канала указывает на тот же канал.
  • Каждая операция берёт lock. Канал — это очередь под мьютексом плюс тесная работа с планировщиком.
  • Заблокированная горутина встаёт в sendq или recvq и снимается с P (gopark). Она не крутится в цикле и не тратит CPU.
  • Если получатель уже ждёт, отправитель копирует значение прямо в его стек, минуя буфер, и будит его (goready).

type hchan struct {
    qcount   uint           // элементов в буфере
    dataqsiz uint           // cap(ch)
    buf      unsafe.Pointer // кольцевой буфер
    elemsize uint16
    closed   uint32
    elemtype *_type
    sendx    uint           // куда писать
    recvx    uint           // откуда читать
    recvq    waitq          // спящие получатели
    sendq    waitq          // спящие отправители
    lock     mutex
}

nil, открытый, закрытый

Что делает каждая операция в зависимости от состояния канала. Нажмите на клетку таблицы.

Закрывает канал отправитель, и только когда отправлять больше нечего. Получатель узнаёт о закрытии через v, ok := <-ch или когда завершается цикл for v := range ch.

select — ждать несколько каналов сразу

  • select блокируется, пока хотя бы один case не сможет выполниться.
  • Если готовы несколько, выбирается случайный, чтобы ни один канал не голодал.
  • default делает select неблокирующим, а time.After добавляет таймаут.

Включайте и выключайте готовность каналов. Если запустить select, когда ничего не готово, а потом включить канал, select проснётся.

Пул воркеров

Несколько горутин читают задачи из одного канала. Каждую задачу получит ровно один воркер — раздачу работы делает сам канал.


func worker(jobs <-chan int, results chan<- int) {
    for j := range jobs { // до close(jobs)
        results <- process(j)
    }
}

jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < n; w++ {
    go worker(jobs, results)
}
for j := 1; j <= 12; j++ {
    jobs <- j
}
close(jobs)

Что выведет программа?


func main() {
    ch := make(chan int)
    ch <- 1
    fmt.Println(<-ch)
}

Что выведет программа?


ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)

for v := range ch {
    fmt.Print(v)
}
Закрытие не стирает буфер. Из закрытого канала можно читать, пока в нём остались данные; после этого range видит ok = false и завершает цикл. Паники при чтении из закрытого канала не бывает — паникует только отправка.

Что не так с этим кодом?


func first(urls []string) string {
    ch := make(chan string)
    for _, u := range urls {
        go func() { ch <- fetch(u) }()
    }
    return <-ch // берём самый быстрый ответ
}
Мы читаем из канала один раз. Остальные горутины навсегда заблокированы на ch <- …: это утечка, и сборщик мусора их не уберёт. Исправление — буфер на все ответы: make(chan string, len(urls)), или отмена через context. Гонки на u нет: с Go 1.22 у каждой итерации своя переменная.

Что выведет программа?


var ch chan int // nil

select {
case v := <-ch:
    fmt.Println(v)
default:
    fmt.Println("пусто")
}
Чтение из nil-канала никогда не бывает готово, поэтому срабатывает default. Без default горутина заснула бы навсегда. Этим пользуются на практике: присвоить каналу nil — значит выключить его case в select.

Коротко

  • Горутина дешёвая, но не бесплатная. Всегда знайте, как и когда она завершится.
  • Канал без буфера синхронизирует, канал с буфером сглаживает всплески. Буфер не лечит дедлок, а только откладывает его.
  • Закрывает канал отправитель. Отправка в закрытый канал и повторный close — паника.
  • nil-канал блокирует навсегда. Это удобно, чтобы выключить case в select.
  • select выбирает случайный из готовых case; default делает его неблокирующим.
  • Для отмены и таймаутов — context.Context. Чтобы защитить общий счётчик, sync.Mutex проще канала.
Не общайтесь через разделяемую память — разделяйте память, общаясь.Go Proverbs, Роб Пайк

К списку презентаций