Что такое горутина?
Горутина (goroutine) -- это легковесная единица конкурентного выполнения в Go. Горутины часто называют "зелёными потоками", но это не совсем точно: они реализованы через модель M:N, где M горутин мультиплексируются на N потоков ОС. Горутина -- это не поток ОС. Она значительно дешевле по памяти и времени создания.
Потоки ОС (Thread): ~1-8 MB стека, ~1ms создание, kernel context switch ~1-10μs
Горутины (Goroutine): ~2 KB стека, ~0.3μs создание, runtime context switch ~0.2μs
Горутина -- это функция, запущенная конкурентно с помощью ключевого слова go. Каждая программа на Go начинается с одной горутины -- main goroutine, которая выполняет функцию main().
Запуск горутин: ключевое слово go
Для запуска функции как горутины достаточно добавить ключевое слово go перед вызовом:
package main
import (
"fmt"
"time"
)
// greet prints a greeting message
func greet(name string) {
fmt.Printf("Hello, %s!\n", name)
}
func main() {
// Launch goroutine — runs concurrently
go greet("World")
// Launch anonymous goroutine
go func() {
fmt.Println("From anonymous goroutine")
}()
// Without this sleep, main() may exit before goroutines complete
time.Sleep(100 * time.Millisecond)
}
Важно: Использование
time.Sleepдля ожидания горутин -- это антипаттерн. В продакшен-коде используйтеsync.WaitGroup, каналы или контекст.
Жизненный цикл горутины
Горутина проходит через несколько состояний:
┌─────────────┐
go func() ───► │ Runnable │ ◄──── I/O complete, chan ready
│ (в очереди) │
└──────┬──────┘
│
scheduler picks
│
┌──────▼──────┐
│ Running │ ──── выполняется на потоке ОС (M)
│ (на потоке) │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌──────▼──────┐ ┌──▼───┐ ┌────▼─────┐
│ Waiting │ │ Dead │ │ Preempted│
│(I/O, chan, │ │ │ │(async) │
│ mutex, sleep)│ └──────┘ └────┬─────┘
└──────┬──────┘ │
│ │
└─────── back to ─────────┘
Runnable
Состояния:
- Runnable -- горутина готова к выполнению, ждёт своей очереди
- Running -- горутина выполняется на потоке ОС
- Waiting -- горутина заблокирована (I/O, канал, мьютекс, sleep)
- Dead -- горутина завершилась (return или паника)
- Preempted -- горутина принудительно снята с потока (с Go 1.14 -- асинхронная вытеснённость)
Планировщик G-M-P
Планировщик Go реализует модель G-M-P (Goroutine - Machine - Processor):
┌──────────────────────────────────────┐
│ Go Runtime Scheduler │
└──────────────────────────────────────┘
┌─────────┐ ┌─────────┐ ┌─────────┐
│ P0 │ │ P1 │ │ P2 │ P = Processor (logical)
│ Local Q │ │ Local Q │ │ Local Q │ GOMAXPROCS = количество P
│ [G][G] │ │ [G][G] │ │ [G] │
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│ M0 │ │ M1 │ │ M2 │ M = Machine (OS thread)
│ (thread)│ │ (thread)│ │ (thread)│
└─────────┘ └─────────┘ └─────────┘
┌───────────────────────────────────────────┐
│ Global Run Queue │ Overflow горутин
│ [G] [G] [G] [G] │ (когда локальная полна)
└───────────────────────────────────────────┘
G = Goroutine M = OS Thread P = Processor (logical CPU)
Компоненты модели
G (Goroutine) -- горутина. Содержит стек, указатель на инструкцию (PC), ссылку на функцию и другие метаданные.
M (Machine) -- поток ОС. Привязывается к P для выполнения горутин. Может быть заблокирован на системном вызове.
P (Processor) -- логический процессор. Содержит локальную очередь горутин (до 256 штук). Количество P определяется GOMAXPROCS.
Как работает планирование
- Новая горутина помещается в локальную очередь текущего P
- Если локальная очередь полна (256), половина горутин переносится в глобальную очередь
- M берёт горутину из локальной очереди своего P
- Если очередь пуста -- work stealing: M крадёт половину горутин у другого P
- Если красть нечего -- берёт из глобальной очереди
Вытеснённость (Preemption)
До Go 1.14 горутина могла быть вытеснена только в определённых точках (вызов функции, операции с каналами). Начиная с Go 1.14, планировщик поддерживает асинхронную вытеснённость через сигналы ОС, что предотвращает блокировку P тяжёлыми вычислительными горутинами.
package main
import (
"fmt"
"runtime"
)
func main() {
// Number of logical processors (P)
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("NumCPU:", runtime.NumCPU())
fmt.Println("NumGoroutine:", runtime.NumGoroutine())
// Set GOMAXPROCS explicitly (rarely needed)
// runtime.GOMAXPROCS(4)
// Default: GOMAXPROCS = runtime.NumCPU() (since Go 1.5)
}
GOMAXPROCS и runtime.NumCPU()
GOMAXPROCS определяет максимальное число потоков ОС, которые могут одновременно выполнять пользовательский Go-код. Начиная с Go 1.5, значение по умолчанию равно runtime.NumCPU().
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
// Query current GOMAXPROCS (pass 0 to read without changing)
current := runtime.GOMAXPROCS(0)
fmt.Printf("GOMAXPROCS=%d, NumCPU=%d\n", current, runtime.NumCPU())
var wg sync.WaitGroup
// Launch goroutines on all available processors
for i := range runtime.NumCPU() {
wg.Add(1)
go func() {
defer wg.Done()
// Each goroutine can run on a separate OS thread
fmt.Printf("Goroutine %d running\n", i)
}()
}
wg.Wait()
}
Заметка: Начиная с Go 1.22, цикл
for i := range Nсоздаёт итерацию от 0 до N-1. Переменнаяiзахватывается корректно -- каждая итерация получает свою копию.
Стек горутины
Стек горутины начинается с 2 KB (с Go 1.4) и динамически растёт до максимума в 1 GB (по умолчанию). Рост стека реализован через copyable stacks -- при нехватке места выделяется новый стек в 2 раза больше, и все данные копируются.
Начальный размер: 2 KB
Рост: удвоение (2 KB → 4 KB → 8 KB → 16 KB → ...)
Максимум: 1 GB (настраивается через runtime/debug.SetMaxStack)
Сжатие: да, при GC (если используется < 1/4 стека)
Для сравнения:
| Характеристика | Горутина | Поток ОС |
|---|---|---|
| Начальный стек | 2 KB | 1-8 MB |
| Максимальный стек | 1 GB | Фиксирован |
| Время создания | ~0.3 мкс | ~1 мс |
| Context switch | ~0.2 мкс (runtime) | ~1-10 мкс (kernel) |
| Количество | Миллионы | Тысячи |
| Управление | Go runtime | OS kernel |
Утечки горутин (Goroutine Leaks)
Утечка горутин -- одна из самых распространённых ошибок в Go. Горутина, которая никогда не завершается, навсегда занимает память.
Паттерны утечек
1. Заблокированная отправка в канал (никто не читает):
package main
func main() {
// LEAK: goroutine blocks forever sending to unbuffered channel
ch := make(chan int)
go func() {
ch <- 42 // blocks forever — no receiver
}()
// main returns, goroutine is leaked
}
2. Заблокированное чтение из канала (никто не пишет):
package main
func main() {
ch := make(chan int)
go func() {
val := <-ch // blocks forever — no sender
_ = val
}()
// main returns, goroutine is leaked
}
3. Бесконечный цикл без выхода:
package main
import "time"
func main() {
// LEAK: no way to stop this goroutine
go func() {
for {
time.Sleep(time.Second)
// do something...
}
}()
}
Предотвращение утечек
Используйте context.Context или done-канал для сигнализации о завершении:
package main
import (
"context"
"fmt"
"time"
)
// worker processes tasks until context is cancelled
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d: shutting down\n", id)
return
default:
fmt.Printf("Worker %d: working...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
for i := range 3 {
go worker(ctx, i)
}
// Wait for context to expire
<-ctx.Done()
// Give workers a moment to print shutdown message
time.Sleep(100 * time.Millisecond)
fmt.Println("All workers stopped")
}
sync.WaitGroup
sync.WaitGroup -- основной механизм ожидания завершения группы горутин:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
urls := []string{
"https://golang.org",
"https://pkg.go.dev",
"https://go.dev",
}
for _, url := range urls {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Printf("Fetching %s\n", url)
// simulate HTTP request
}()
}
// Block until all goroutines call Done()
wg.Wait()
fmt.Println("All fetches complete")
}
Правила WaitGroup:
Add(n)вызывается до запуска горутины (обычно в main горутине)Done()вызывается внутри горутины (лучше черезdefer)Wait()блокирует до тех пор, пока счётчик не станет 0- Никогда не копируйте WaitGroup после первого использования
Главная горутина: завершение программы
Программа Go завершается, когда возвращается функция main(). Все запущенные горутины принудительно завершаются без выполнения defer.
package main
import "fmt"
func main() {
go func() {
// This may NEVER print — main() can return first
fmt.Println("goroutine")
}()
// Program exits here — goroutine is killed
fmt.Println("main done")
}
Выход из main:
main done
Горутина может не успеть выполниться, потому что main завершается первым. Это не ошибка -- это поведение по дизайну.
runtime.Gosched() и runtime.Goexit()
runtime.Gosched()
Gosched() уступает процессор другим горутинам. Текущая горутина помещается обратно в очередь и позже продолжит выполнение.
package main
import (
"fmt"
"runtime"
)
func main() {
go func() {
for i := range 5 {
fmt.Printf("goroutine: %d\n", i)
runtime.Gosched() // yield to main goroutine
}
}()
for i := range 5 {
fmt.Printf("main: %d\n", i)
runtime.Gosched() // yield to other goroutines
}
}
На практике:
runtime.Gosched()редко используется в современном Go, так как планировщик с асинхронной вытеснённостью (Go 1.14+) хорошо распределяет время.
runtime.Goexit()
Goexit() завершает текущую горутину. Все defer-функции выполняются. Если Goexit() вызвана из main горутины, программа завершится после выполнения всех defer, но без вызова os.Exit.
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer fmt.Println("deferred in goroutine") // WILL execute
fmt.Println("goroutine started")
runtime.Goexit() // stop goroutine, run defers
fmt.Println("NEVER printed")
}()
wg.Wait()
fmt.Println("main done")
}
// Output:
// goroutine started
// deferred in goroutine
// main done
Практический пример: параллельная обработка файлов
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
// processFile simulates processing a file
func processFile(name string) (int, error) {
// Simulate varying processing time
duration := time.Duration(rand.Intn(500)) * time.Millisecond
time.Sleep(duration)
return rand.Intn(1000), nil
}
func main() {
files := []string{"a.txt", "b.txt", "c.txt", "d.txt", "e.txt"}
type result struct {
file string
size int
err error
}
var (
wg sync.WaitGroup
mu sync.Mutex
results []result
)
for _, file := range files {
wg.Add(1)
go func() {
defer wg.Done()
size, err := processFile(file)
mu.Lock()
results = append(results, result{file: file, size: size, err: err})
mu.Unlock()
}()
}
wg.Wait()
for _, r := range results {
if r.err != nil {
fmt.Printf(" %s: ERROR %v\n", r.file, r.err)
} else {
fmt.Printf(" %s: %d bytes\n", r.file, r.size)
}
}
}
Пример: мониторинг количества горутин
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
fmt.Printf("Goroutines at start: %d\n", runtime.NumGoroutine())
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Second)
_ = i
}()
}
fmt.Printf("Goroutines after launch: %d\n", runtime.NumGoroutine())
wg.Wait()
// Give GC a moment to clean up
runtime.Gosched()
fmt.Printf("Goroutines after wait: %d\n", runtime.NumGoroutine())
}
// Output (approximate):
// Goroutines at start: 1
// Goroutines after launch: 101
// Goroutines after wait: 1