Лекция 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 перед вызовом функции:
Несколько важных моментов:
- Главная горутина — это
main. Когда она завершается, программа выходит, даже если другие горутины ещё работают. - Между горутинами нет «родительских» отношений. Если горутина X запустила Y, и X завершилась — Y продолжает жить.
- Не существует API «убить горутину снаружи». Горутина останавливается только сама, когда возвращается из своей функции или паникует. Обычно её просят остановиться через
context.Context(см. лекцию 2) или закрытый канал. - Паника в горутине, не пойманная
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 завершится сам
Правила:
- Закрывает только отправитель. Получатель не знает, ждать ли ещё.
- Отправка в закрытый канал —
panic. - Получение из закрытого канала возвращает zero-value мгновенно. Идиома
v, ok := <-ch:ok == false— канал закрыт и пуст.
close нужен только если получатели зависят от знака «больше ничего не будет» (типичный случай — for-range по каналу). В большинстве сценариев каналы просто живут пока живёт горутина-владелец, и close не нужен.
Направленные каналы¶
В сигнатурах функций можно сузить канал до send-only или receive-only:
Это документирует намерение и не даёт случайно сделать обратное. Внутри тип канала может быть двунаправленным.
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), поведение не определено.
Запустите такое с go run -race main.go — рантайм поймает гонку и сообщит. Включайте -race в тестах CI, это бесплатная страховка.
sync.Mutex — взаимное исключение¶
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. Используйте атомарные операции:
(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 — один источник, несколько горутин разбирают:
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¶
Несколько стадий, соединённых каналами:
Каждая стадия — горутина, читает из входного канала и пишет в выходной. Удобно для обработки потоков.
Параллель с 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) |
Что важно запомнить¶
- Старт горутины —
go f(), остановка только изнутри. mainзавершается — программа завершается.- Передавайте данные через каналы, а не через shared memory.
select— мультиплексор каналов с таймаутом и default.- Гонки реально существуют — запускайте
go test -race. sync.Mutexдля shared state,sync/atomicдля счётчиков,sync.Onceдля однократной инициализации.- Не делайте больше синхронизации, чем нужно. Часто канал +
for-rangeпонятнее, чем мьютекс + общий слайс.
В следующей лекции — как корректно отменять и таймаутить операции через context.Context.