Глиф Учебники

Урок 9. Горутины и каналы

Курс «Go» · урок 9 из 10 · 4 минуты чтения

Сервер обслуживает сотни клиентов сразу, программа скачивает десять файлов одновременно — для таких задач в Go есть горутины. Горутина — функция, которая выполняется одновременно с остальной программой. Она гораздо легче потока операционной системы (обычного способа выполнять части программы одновременно): программа на Go спокойно держит десятки тысяч горутин.

Запуск горутины

Горутину запускает слово go перед вызовом функции. Но есть ловушка: программа заканчивается, когда заканчивается main, и не ждёт остальные горутины. В такой программе горутина, скорее всего, не успеет ничего напечатать:

Go
package main

import "fmt"

func main() {
    go fmt.Println("из горутины")
    fmt.Println("из main")
}

Выведется «из main», а «из горутины» — как повезёт. Ждать горутины нужно явно.

Ожидание: sync.WaitGroup

sync.WaitGroup — счётчик запущенных задач. Add(1) перед запуском увеличивает его, Done() в конце горутины уменьшает, Wait() ждёт, пока счётчик не станет нулём:

Go
package main

import (
    "fmt"
    "sync"
)

func square(n int) int {
    return n * n
}

func main() {
    nums := []int{2, 3, 4, 5}
    results := make([]int, len(nums))

    var wg sync.WaitGroup
    for i, n := range nums {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results[i] = square(n)
        }()
    }
    wg.Wait()
    fmt.Println(results)
}
Вывод
[4 9 16 25]
без ожидания main go "из main" горутина main закончилась — закончилась и программа, горутина — как повезёт с sync.WaitGroup main Add(1), go ×4 wg.Wait() [4 9 16 25] square(2) square(3) square(4) square(5) горутины 4 3 2 1 0 Done(): счётчик − 1 при 0 Wait() отпускает main
Без ожидания программа заканчивается вместе с main, и горутина может не успеть; с WaitGroup main ждёт в wg.Wait(), пока каждая из четырёх горутин не вызовет Done и счётчик не станет 0.

С Go 1.22 переменные цикла i и n создаются заново на каждом шаге, и каждая горутина видит свои. В старых версиях (и если в go.mod указана версия ниже 1.22) все горутины видели одну переменную, и результат был неверным — в старом коде поэтому встречается i, n := i, n в начале цикла.

Каналы

Канал — труба, по которой горутины передают друг другу значения. Создаётся через make(chan тип); отправка — ch <- v, получение — v := <-ch.

Go
package main

import "fmt"

func main() {
    ch := make(chan int)
    go func() {
        for i := 1; i <= 3; i++ {
            ch <- i
        }
        close(ch)
    }()

    for v := range ch {
        fmt.Println("получено", v)
    }
    fmt.Println("канал закрыт")
}
Вывод
получено 1
получено 2
получено 3
канал закрыт

Буферизованный канал и сбор результатов

make(chan int, 5) — канал с буфером на 5 значений: отправка не ждёт получателя, пока в буфере есть место. Удобно собирать результаты нескольких горутин:

Go
package main

import (
    "fmt"
    "sync"
)

func square(n int) int {
    return n * n
}

func main() {
    jobs := []int{1, 2, 3, 4, 5}
    results := make(chan int, len(jobs))

    var wg sync.WaitGroup
    for _, n := range jobs {
        wg.Add(1)
        go func() {
            defer wg.Done()
            results <- square(n)
        }()
    }
    wg.Wait()
    close(results)

    sum := 0
    for r := range results {
        sum += r
    }
    fmt.Println("сумма квадратов:", sum)
}
Вывод
сумма квадратов: 55

Квадраты приходят в канал в любом порядке, но сумма от порядка не зависит. Буфера хватает на все результаты, поэтому горутины не застревают на отправке, пока main ждёт в Wait. Канал закрывают после Wait — когда точно никто больше не отправит.

ch := make(chan int) — без буфера горутина отправляет main: for v := range ch ch <- 1 1 получено 1 ch <- 2 2 получено 2 ch <- 3 3 получено 3 close(ch) канал закрыт отправка ждёт, пока значение не примут; после close цикл range заканчивается results := make(chan int, len(jobs)) — буфер на 5 results <- square(n) 5 горутин 4 1 16 25 9 пока есть место — отправка не ждёт сумма квадратов: 55 после close и range
В канале без буфера отправка ждёт приёма, а после close цикл range заканчивается; в канал с буфером на 5 значений горутины кладут результаты, не дожидаясь получателя.

select

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

Go
select {
case res := <-results:
    fmt.Println("результат:", res)
case <-time.After(2 * time.Second):
    fmt.Println("слишком долго")
}

time.After возвращает канал, в который через заданное время придёт значение. Если результат не успел за две секунды, сработает вторая ветка.

Гонка данных и sync.Mutex

Если несколько горутин одновременно меняют одну переменную, получается гонка данных (data race): count++ — это «прочитать, прибавить, записать», и горутины затирают результаты друг друга. Итог каждый раз разный. Защищают общие данные мьютексом — замком, который в каждый момент держит только одна горутина:

Go
package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex
    var wg sync.WaitGroup
    count := 0

    for range 1000 {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()
            count++
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println(count)
}
Вывод
1000

Без mu.Lock() и mu.Unlock() программа печатала бы разные числа меньше 1000. Найти гонки помогает встроенный детектор: запустите программу командой go run -race . — при гонке он напечатает WARNING: DATA RACE и места в коде.

без мьютекса две горутины делают count++, count = 0 горутина A горутина B читает 0 читает 0 пишет 1 пишет 1 count = 1 — одно ++ потеряно с мьютексом mu замок держит одна горутина горутина A горутина B mu.Lock() count: 0 → 1 mu.Unlock() ждёт mu.Lock() count: 1 → 2 mu.Unlock() count = 2 — оба ++ на месте
Без мьютекса две горутины читают одно и то же значение 0 и обе пишут 1 — одно прибавление теряется; с мьютексом вторая горутина ждёт, пока первая отпустит замок, и count становится 2.

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

Deadlock

Deadlock — взаимная блокировка: все горутины ждут друг друга, и ни одна не может продолжить. Тогда Go останавливает программу:

Текст
fatal error: all goroutines are asleep - deadlock!

Чаще всего это отправка в канал без буфера, когда никто не принимает, или range по каналу, который забыли закрыть. Разбор причин — в справке all goroutines are asleep — deadlock.

Коротко

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

1. Что произойдёт с запущенными горутинами, когда функция main закончится?
2. Где правильно вызывать wg.Add(1)?
3. Что выведет программа?
Go
package main

import "fmt"

func main() {
    ch := make(chan string, 2)
    ch <- "a"
    ch <- "b"
    close(ch)
    for s := range ch {
        fmt.Print(s)
    }
    fmt.Println()
}
4. Почему count++ из тысячи горутин без мьютекса даёт разный результат?