Перейти к содержанию

Лекция 1. Конкурентность: горутины, каналы, select

Конкурентность vs параллелизм

В лекции про Python (тема 10, лекция 4) мы обсуждали GIL: в CPython одновременно выполняется только одна Python-инструкция. Настоящий параллелизм там доступен только через процессы. Для I/O-bound задач достаточно asyncio или потоков, но CPU-bound — это multiprocessing со всеми его издержками.

В Go всё устроено иначе. Конкурентность — встроена в язык. Можно запустить миллион горутин на одном процессе, рантайм Go сам распределит их по доступным ядрам OS. GIL нет. GOMAXPROCS определяет, сколько одновременно выполняется горутин на CPU-ядрах (по умолчанию = runtime.NumCPU()).

Авторы Go любят повторять цитату Роба Пайка: «Concurrency is not parallelism». Конкурентность — это структура программы (несколько потоков логики идут параллельно во времени). Параллелизм — это физическое выполнение на нескольких ядрах. Go-программа может быть конкурентной даже на одном ядре.

Горутины

Горутина — это легковесный поток, управляемый рантаймом Go. Стек начинается с ~2 КБ (в Java thread — ~1 МБ) и растёт по мере надобности. Создание горутины — добавление слова go перед вызовом функции:

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

Несколько важных моментов:

  1. Главная горутина — это main. Когда она завершается, программа выходит, даже если другие горутины ещё работают.
  2. Между горутинами нет «родительских» отношений. Если горутина X запустила Y, и X завершилась — Y продолжает жить.
  3. Не существует API «убить горутину снаружи». Горутина останавливается только сама, когда возвращается из своей функции или паникует. Обычно её просят остановиться через context.Context (см. лекцию 2) или закрытый канал.
  4. Паника в горутине, не пойманная recover, рушит всю программу.

Простейший пример

package main

import (
    "fmt"
    "time"
)

func say(msg string) {
    for i := 0; i < 3; i++ {
        fmt.Println(msg, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go say("hello")
    go say("world")
    time.Sleep(500 * time.Millisecond)
}

Если убрать time.Sleep в main — программа завершится мгновенно, и горутины ничего не напечатают.

Правильный способ дождаться завершения: sync.WaitGroup

time.Sleep для синхронизации — это анти-паттерн. Правильно:

import "sync"

var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Println("worker", id)
    }(i)
}
wg.Wait()

WaitGroup — счётчик. Add(n) увеличивает, Done() уменьшает, Wait() блокирует, пока не станет 0.

Важно: wg.Add(1) нужно вызывать до go, а не внутри горутины. Иначе возможна гонка: main доберётся до Wait() раньше, чем горутина успеет добавить себя в счётчик.

Каналы

Каналы — способ передавать данные между горутинами без shared memory + мьютекса. Цитата Роба Пайка: «Don't communicate by sharing memory; share memory by communicating».

ch := make(chan int)         // unbuffered channel
ch := make(chan int, 5)      // buffered channel ёмкости 5

ch <- 42                      // отправить
value := <-ch                 // получить
value, ok := <-ch             // получить + флаг «канал ещё открыт»
close(ch)                     // закрыть канал

Unbuffered vs buffered

Unbuffered (make(chan T)):

  • Отправка блокируется, пока кто-то не начнёт получать.
  • Получение блокируется, пока кто-то не начнёт отправлять.
  • Это синхронизационная точка — обе горутины «встречаются».

Buffered (make(chan T, N)):

  • Отправка блокируется, только когда буфер полон.
  • Получение блокируется, только когда буфер пуст.
  • Полезен для «producer быстрее consumer на короткие пики».

Используйте unbuffered по умолчанию — он заставляет думать про синхронизацию. Buffered применяйте обдуманно (известная ёмкость = известный лимит).

Закрытие канала

ch := make(chan int)
go func() {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}()

for v := range ch {
    fmt.Println(v)
}
// после close + опустошения цикл for-range завершится сам

Правила:

  1. Закрывает только отправитель. Получатель не знает, ждать ли ещё.
  2. Отправка в закрытый канал — panic.
  3. Получение из закрытого канала возвращает zero-value мгновенно. Идиома v, ok := <-ch: ok == false — канал закрыт и пуст.

close нужен только если получатели зависят от знака «больше ничего не будет» (типичный случай — for-range по каналу). В большинстве сценариев каналы просто живут пока живёт горутина-владелец, и close не нужен.

Направленные каналы

В сигнатурах функций можно сузить канал до send-only или receive-only:

func producer(out chan<- int) { out <- 42 }
func consumer(in <-chan int)  { fmt.Println(<-in) }

Это документирует намерение и не даёт случайно сделать обратное. Внутри тип канала может быть двунаправленным.

select — мультиплексор каналов

select — это switch для операций с каналами. Он выполняет ту ветку, чья операция может произойти первой.

select {
case v := <-ch1:
    fmt.Println("из ch1:", v)
case v := <-ch2:
    fmt.Println("из ch2:", v)
case ch3 <- 42:
    fmt.Println("отправил в ch3")
case <-time.After(1 * time.Second):
    fmt.Println("таймаут")
default:
    fmt.Println("ничего не готово")
}
  • Если готово несколько случаев — выбирается случайный (это намеренно — против голода).
  • Если ничего не готово и есть default — он выполняется немедленно (non-blocking операция).
  • Если ничего не готово и default нет — select блокируется до первого готового.

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

Каналы как способ остановить горутину

Идиоматичный «сигнал к остановке» — закрытый канал:

func worker(stop <-chan struct{}) {
    for {
        select {
        case <-stop:
            return
        default:
            // полезная работа
        }
    }
}

stop := make(chan struct{})
go worker(stop)
// ... позже
close(stop)

<-stop сработает мгновенно, как только канал закрыт (получит zero value, флаг ok == false). struct{} — нулевой размер, отправлять ничего не надо, нужен только сам факт закрытия.

Это базовая идиома, но в реальных проектах для остановки используют context.Context (см. лекцию 2).

Гонки и sync

Если несколько горутин одновременно пишут в одну переменную без синхронизации — это гонка данных (data race), поведение не определено.

var counter int
for i := 0; i < 1000; i++ {
    go func() { counter++ }()  // ГОНКА
}

Запустите такое с go run -race main.go — рантайм поймает гонку и сообщит. Включайте -race в тестах CI, это бесплатная страховка.

sync.Mutex — взаимное исключение

var (
    mu      sync.Mutex
    counter int
)

func inc() {
    mu.Lock()
    defer mu.Unlock()
    counter++
}

sync.RWMutex — читателей много, писатель один. RLock/RUnlock для чтения, Lock/Unlock для записи.

sync.Once — однократная инициализация

var (
    once     sync.Once
    instance *Config
)

func Get() *Config {
    once.Do(func() {
        instance = loadConfig()
    })
    return instance
}

Гарантирует, что переданная функция выполнится ровно один раз, даже если Get() вызывается из множества горутин одновременно. Аналог Python lock + double-checked flag.

sync/atomic — атомарные счётчики

Для счётчиков и флагов мьютекс — overkill. Используйте атомарные операции:

import "sync/atomic"

var counter atomic.Int64

counter.Add(1)
counter.Load()
counter.Store(0)

(atomic.Int64, atomic.Bool, atomic.Pointer[T] — типобезопасные обёртки появились в Go 1.19.)

Паттерны

Worker pool

Фиксированное число воркеров обрабатывают задания из очереди:

func main() {
    jobs := make(chan int, 100)
    results := make(chan int, 100)

    var wg sync.WaitGroup
    for w := 1; w <= 5; w++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for j := range jobs {
                results <- j * j
            }
        }(w)
    }

    for j := 1; j <= 20; j++ {
        jobs <- j
    }
    close(jobs)

    go func() {
        wg.Wait()
        close(results)
    }()

    for r := range results {
        fmt.Println(r)
    }
}

Fan-out / Fan-in

Fan-out — один источник, несколько горутин разбирают:

in := make(chan int)
for i := 0; i < N; i++ {
    go worker(in)   // все читают из одного in
}

Fan-in — несколько источников, одно слияние:

func merge(chans ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, c := range chans {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(c)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

Pipeline

Несколько стадий, соединённых каналами:

nums := generate(1, 2, 3, 4)
squares := square(nums)
for v := range squares {
    fmt.Println(v)
}

Каждая стадия — горутина, читает из входного канала и пишет в выходной. Удобно для обработки потоков.

Параллель с Python

Python Go
threading.Thread (OS-поток, GIL) горутина (легковесная, миллионы на машину)
multiprocessing.Process (нет аналога, конкурентность в одном процессе)
asyncio (event loop) горутины + каналы (рантайм планирует сам)
await coroutine() горутина блокируется на канале / I/O автоматически
queue.Queue chan T (buffered)
threading.Lock sync.Mutex
threading.RLock sync.RWMutex (для read-heavy сценариев)
threading.Event закрытый канал chan struct{}
asyncio.wait_for(coro, timeout=...) select + time.After
GIL нет (есть GOMAXPROCS)

Что важно запомнить

  1. Старт горутины — go f(), остановка только изнутри.
  2. main завершается — программа завершается.
  3. Передавайте данные через каналы, а не через shared memory.
  4. select — мультиплексор каналов с таймаутом и default.
  5. Гонки реально существуют — запускайте go test -race.
  6. sync.Mutex для shared state, sync/atomic для счётчиков, sync.Once для однократной инициализации.
  7. Не делайте больше синхронизации, чем нужно. Часто канал + for-range понятнее, чем мьютекс + общий слайс.

В следующей лекции — как корректно отменять и таймаутить операции через context.Context.