Горутины: основы
Горутина — это не поток
В других языках конкурентность строится на потоках ОС. В Go — на горутинах. Разница принципиальная:
| Поток ОС | Горутина | |
|---|---|---|
| Начальный стек | ~2 MB | ~2 KB |
| Создание | ~1 мс | ~1 мкс |
| Переключение | ядро ОС | планировщик Go |
| Типичное кол-во | сотни | сотни тысяч |
Горутины — это зелёные потоки, которые Go-планировщик мультиплексирует на несколько OS-потоков. Модель называется M:N — M горутин на N потоков.
Запуск горутины: ключевое слово go
Добавь go перед вызовом функции — она запустится в отдельной горутине:
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("горутина") // запускается конкурентно
say("main") // выполняется в main-горутине
}Важно: если main завершается — все горутины убиваются, даже если не закончили работу. Вот почему здесь say("main") выступает как "заглушка" — она держит программу живой.
Анонимная горутина:
go func(n int) {
fmt.Println("горутина получила:", n)
}(42) // аргументы передаём сразуПроблема: гонка завершения
func main() {
go fmt.Println("привет из горутины")
// main завершается — горутина может не успеть напечатать
}Вывод непредсказуем — иногда строка печатается, иногда нет. Нужна синхронизация.
sync.WaitGroup: ждём горутины
WaitGroup — счётчик: добавляем горутины, каждая сигнализирует о завершении, Wait блокирует пока счётчик не обнулится:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // уменьшает счётчик при выходе
fmt.Printf("worker %d начал работу\n", id)
// ... работа ...
fmt.Printf("worker %d завершил\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // увеличиваем счётчик перед запуском
go worker(i, &wg) // передаём указатель!
}
wg.Wait() // ждём всех
fmt.Println("все workers завершены")
}Три правила WaitGroup:
Add(n)вызывать до запуска горутины, не внутри неё- Передавать только по указателю (
*sync.WaitGroup) Done()всегда черезdefer— чтобы не забыть при панике
Планировщик Go: модель G-M-P
Понимание планировщика помогает писать эффективный конкурентный код.
G — Goroutine (горутина, единица работы)
M — Machine (OS-поток, реальный поток)
P — Processor (логический процессор, очередь задач)
Каждый P имеет локальную очередь горутин (G). Каждый M привязан к одному P и выполняет горутины из его очереди. Количество P = GOMAXPROCS (по умолчанию = число CPU).
P1 (очередь: G3, G5) P2 (очередь: G4, G6)
| |
M1 (выполняет G1) M2 (выполняет G2)
Work stealing: если очередь P1 пуста, он "крадёт" горутины из очереди другого P. Это обеспечивает равномерную нагрузку без ручного управления.
GOMAXPROCS: параллелизм
import "runtime"
func main() {
// сколько CPU доступно
fmt.Println(runtime.NumCPU()) // например, 8
// сколько P используется сейчас
fmt.Println(runtime.GOMAXPROCS(0)) // 0 = читать, не менять
// установить вручную
runtime.GOMAXPROCS(4)
}Или через переменную окружения:
GOMAXPROCS=1 go run main.go # однопоточный режим — полезно для отладки гонокТипичный паттерн: пул workers
func main() {
jobs := make(chan int, 100)
var wg sync.WaitGroup
// запускаем 3 worker-горутины
for w := 1; w <= 3; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range jobs { // читаем из канала пока он открыт
fmt.Printf("worker %d обрабатывает задачу %d\n", id, j)
}
}(w)
}
// отправляем 9 задач
for j := 1; j <= 9; j++ {
jobs <- j
}
close(jobs) // сигнал: задач больше нет
wg.Wait()
}Каналы (chan) — главный способ общения между горутинами. О них — в следующем уроке.
Горутины и замыкания: классическая ловушка
// Плохо: все горутины захватывают одну переменную i
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // скорее всего выведет 3 3 3
}()
}
// Хорошо: передаём значение как аргумент
for i := 0; i < 3; i++ {
go func(n int) {
fmt.Println(n) // 0 1 2 (в любом порядке)
}(i)
}
// Или в Go 1.22+: переменная цикла создаётся заново на каждой итерации
for i := range 3 {
go func() {
fmt.Println(i) // безопасно с Go 1.22
}()
}