Планировщик (Scheduler)
Введение
Представьте, что вы управляете рестораном с несколькими поварами (процессорные ядра), у вас есть куча заказов (goroutines), и нужен кто-то, кто будет распределять эти заказы между поварами так, чтобы все работали эффективно и никто не простаивал. Этот "кто-то" — и есть планировщик (scheduler).
В Go планировщик — это часть runtime, которая управляет выполнением тысяч и миллионов goroutine на реальных потоках операционной системы. Это одна из ключевых причин, почему Go такой быстрый и эффективный для параллельных задач.
Почему планировщик Go особенный?
Традиционный подход (1:1 threading)
В большинстве языков (Java, C++, Python) каждый поток — это поток операционной системы (OS thread):
User Thread 1:1 OS Thread
┌─────────┐ ┌─────────┐
│ Thread 1│────→│OS Thread│
└─────────┘ └─────────┘
┌─────────┐ ┌─────────┐
│ Thread 2│────→│OS Thread│
└─────────┘ └─────────┘
Проблемы:
- OS потоки тяжелые (несколько МБ памяти на поток)
- Переключение контекста медленное (микросекунды)
- Создание потока дорого
- Нельзя создать миллионы потоков
Подход Go (M:N threading)
Go использует модель M:N — много goroutines на небольшом количестве OS потоков:
Goroutines (M) OS Threads (N)
┌──────┐┌──────┐
│ G1 ││ G2 │ ┌──────────┐
└──────┘└──────┘────→│OS Thread1│
┌──────┐┌──────┐ └──────────┘
│ G3 ││ G4 │ ┌──────────┐
└──────┘└──────┘────→│OS Thread2│
┌──────┐┌──────┐ └──────────┘
│ G5 ││ G6 │
└──────┘└──────┘
Преимущества:
- Goroutine легкие (начинают с 2 КБ стека)
- Можно создать миллионы goroutines
- Быстрое переключение (наносекунды)
- Эффективное использование CPU
Модель GMP
Планировщик Go основан на модели GMP:
G (Goroutine)
G — это сама goroutine, задача, которую нужно выполнить.
go func() {
// Это код G (goroutine)
fmt.Println("Hello from goroutine!")
}()
Каждая goroutine содержит:
- Стек (начинается с 2 КБ, растет по мере необходимости до 1 ГБ)
- Указатель инструкций (program counter)
- Информацию о состоянии
M (Machine/OS Thread)
M — это рабочий поток операционной системы, который выполняет goroutines.
- По умолчанию Go создает столько M, сколько у вас CPU ядер
- M получает G из очереди и выполняет их
- Когда M блокируется (I/O, syscall), Go может создать новый M
P (Processor)
P — это логический процессор, контекст планирования.
- Количество P =
GOMAXPROCS(по умолчанию = количество CPU ядер) - P содержит локальную очередь goroutines
- M должен быть привязан к P, чтобы выполнять G
Как это работает вместе
┌─────────────────────────────────────────┐
│ Global Run Queue │ ← Глобальная очередь G
│ [G] [G] [G] [G] [G] │
└─────────────────────────────────────────┘
↓ (если локальная пуста)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ P1 │ │ P2 │ │ P3 │ ← Processors
│ [G][G][G]│ │ [G][G][G]│ │ [G][G][G]│ ← Локальные очереди
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
↓ ↓ ↓
┌────────┐ ┌────────┐ ┌────────┐
│ M1 │ │ M2 │ │ M3 │ ← OS Threads
└────────┘ └────────┘ └────────┘
Процесс выполнения:
- Создается новая goroutine (G)
- G помещается в локальную очередь P
- M привязывается к P и берет G из локальной очереди
- M выполняет код G
- Когда G завершается или блокируется, M берет следующую G
Алгоритмы планирования
Work Stealing (Кража работы)
Когда локальная очередь P пуста, он может "украсть" работу у другого P:
P1: [G1][G2][G3][G4][G5] ← Много работы
P2: [] ← Пусто
P2 крадет половину у P1:
P1: [G1][G2][G3]
P2: [G4][G5]
Это обеспечивает равномерную нагрузку на все процессоры.
Hand-off (Передача)
Когда M блокируется (например, на системном вызове), P передается другому M:
До:
P1 ─ M1 (выполняет G1)
↓
syscall (блокировка)
После:
P1 ─ M2 (продолжает работу с другими G)
M1 (заблокирован, ждет завершения syscall с G1)
Это гарантирует, что блокировка одной goroutine не остановит выполнение других.
Preemption (Вытеснение)
Go использует кооперативное вытеснение с элементами принудительного вытеснения (с Go 1.14):
Кооперативное вытеснение:
- Goroutine передает управление в "безопасных точках" (function calls, channel operations)
- Проблема: бесконечный цикл без вызовов функций блокирует P
// До Go 1.14 это блокировало бы P навсегда
for {
// Бесконечный цикл без вызовов функций
}
Асинхронное вытеснение (Go 1.14+):
- Планировщик может прервать goroutine принудительно
- Использует сигналы для остановки выполнения
- Решает проблему бесконечных циклов
// С Go 1.14+ это не блокирует систему
// Через ~10ms планировщик прервет goroutine
for {
// Даже без вызовов функций
}
GOMAXPROCS
GOMAXPROCS определяет количество P (логических процессоров):
import "runtime"
func main() {
// Узнать текущее значение
fmt.Println(runtime.GOMAXPROCS(0))
// Установить в 4
runtime.GOMAXPROCS(4)
// Обычно значение по умолчанию (количество CPU) оптимально
}
Или через переменную окружения:
GOMAXPROCS=4 ./myapp
Когда менять GOMAXPROCS:
- ✅ Увеличить: если у вас CPU-intensive задачи и много ядер
- ❌ Уменьшить: редко нужно, но может помочь в контейнерах с ограничениями
- ⚠️ Обычно: оставьте значение по умолчанию
Практические примеры
Параллельное выполнение задач
func processItems(items []int) []int {
results := make([]int, len(items))
var wg sync.WaitGroup
// Создаем по goroutine на каждый элемент
for i, item := range items {
wg.Add(1)
go func(index, value int) {
defer wg.Done()
// Планировщик автоматически распределит
// эти goroutines по доступным P
results[index] = heavyComputation(value)
}(i, item)
}
wg.Wait()
return results
}
CPU-bound vs I/O-bound
CPU-bound задачи:
func cpuIntensive() {
// Тяжелые вычисления
for i := 0; i < 1000000; i++ {
_ = math.Sqrt(float64(i))
}
}
func main() {
// Для CPU-bound задач оптимально создавать
// goroutines примерно равные количеству ядер
numCPU := runtime.NumCPU()
for i := 0; i < numCPU; i++ {
go cpuIntensive()
}
}
I/O-bound задачи:
func ioIntensive(url string) {
// I/O операция (сеть, диск)
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
// Обработка...
}
func main() {
// Для I/O-bound задач можно создать много goroutines
// Планировщик эффективно переключится на другие
// goroutines во время ожидания I/O
urls := getURLs() // Допустим, 10000 URL
for _, url := range urls {
go ioIntensive(url)
}
}
Состояния Goroutine
Goroutine может находиться в одном из нескольких состояний:
┌─────────┐
│ Runnable│ ← Готова к выполнению (в очереди)
└────┬────┘
│
↓
┌─────────┐
│ Running │ ← Выполняется на M
└────┬────┘
│
├→ ┌─────────┐
│ │ Waiting │ ← Ждет (channel, lock, syscall)
│ └────┬────┘
│ │
│ ↓
│ (событие произошло)
│ │
←───────┘
│
↓
┌─────────┐
│ Dead │ ← Завершена
└─────────┘
Переходы между состояниями
func example() {
// Создана → Runnable
go func() {
// Runnable → Running (когда M начинает выполнять)
time.Sleep(1 * time.Second)
// Running → Waiting (блокируется на sleep)
// ... через 1 секунду ...
// Waiting → Runnable
ch := make(chan int)
<-ch
// Running → Waiting (ждет данных из канала)
// (когда данные поступят)
// Waiting → Runnable → Running
// Running → Dead (функция завершилась)
}()
}
Мониторинг планировщика
runtime/trace
Визуализация работы планировщика:
import (
"os"
"runtime/trace"
)
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
// Ваш код
for i := 0; i < 10; i++ {
go func(n int) {
sum := 0
for j := 0; j < 1000000; j++ {
sum += j
}
}(i)
}
time.Sleep(1 * time.Second)
}
Просмотр:
go tool trace trace.out
GODEBUG=schedtrace
Периодический вывод статистики:
GODEBUG=schedtrace=1000 ./myapp
Вывод:
SCHED 0ms: gomaxprocs=8 idleprocs=6 threads=4 spinningthreads=0 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
Расшифровка:
gomaxprocs=8— количество Pidleprocs=6— простаивающих Pthreads=4— количество M (OS threads)runqueue=0— глобальная очередь[0 0 0 0 0 0 0 0]— локальные очереди каждого P
runtime.NumGoroutine()
Количество активных goroutines:
import "runtime"
func main() {
for i := 0; i < 100; i++ {
go func() {
time.Sleep(10 * time.Second)
}()
}
fmt.Println("Active goroutines:", runtime.NumGoroutine())
// Вывод: Active goroutines: 101 (100 + main)
}
Типичные проблемы и решения
Проблема 1: Goroutine Leak (утечка goroutines)
Симптом: количество goroutines постоянно растет
// ПЛОХО: goroutine застрянет навсегда
func leak() {
ch := make(chan int)
go func() {
val := <-ch // Никто никогда не отправит в канал
fmt.Println(val)
}()
// Функция завершилась, но goroutine осталась
}
Решение: всегда обеспечивайте выход из goroutine
// ХОРОШО: используем context для отмены
func noLeak(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
// Выходим при отмене context
return
}
}()
}
Проблема 2: Слишком много goroutines
Симптом: программа медленная, высокое потребление памяти
// ПЛОХО: создаем миллион goroutines сразу
for i := 0; i < 1000000; i++ {
go processItem(i)
}
Решение: используйте worker pool
// ХОРОШО: ограниченный пул воркеров
func processWithPool(items []Item) {
numWorkers := runtime.NumCPU()
jobs := make(chan Item, len(items))
// Запускаем workers
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(jobs, &wg)
}
// Отправляем задачи
for _, item := range items {
jobs <- item
}
close(jobs)
wg.Wait()
}
Проблема 3: CPU-bound задача блокирует другие goroutines
Симптом: одна тяжелая задача замедляет всю программу
// ПЛОХО: бесконечный цикл без yield
go func() {
for {
// Тяжелая работа без передачи управления
heavyComputation()
}
}()
Решение: периодически давайте планировщику возможность переключиться
// ХОРОШО: явно отдаем управление
go func() {
for {
heavyComputation()
runtime.Gosched() // Отдаем управление планировщику
}
}()
Оптимизация производительности
1. Подбор размера worker pool
// Для CPU-bound задач
numWorkers := runtime.NumCPU()
// Для I/O-bound задач (можно больше)
numWorkers := runtime.NumCPU() * 2
// Для задач с высокой задержкой (сеть)
numWorkers := 100 // или больше
2. Batch processing
Обрабатывайте элементы пакетами вместо создания goroutine на каждый:
func processBatches(items []Item, batchSize int) {
numWorkers := runtime.NumCPU()
batches := make(chan []Item, numWorkers)
// Workers обрабатывают пакеты
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for batch := range batches {
for _, item := range batch {
process(item)
}
}
}()
}
// Разбиваем на пакеты
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batches <- items[i:end]
}
close(batches)
wg.Wait()
}
3. Избегайте блокировок в горячих путях
// ПЛОХО: mutex в горячем цикле
var mu sync.Mutex
var counter int
for i := 0; i < 1000000; i++ {
go func() {
mu.Lock()
counter++
mu.Unlock()
}()
}
// ХОРОШО: используйте atomic операции
var counter int64
for i := 0; i < 1000000; i++ {
go func() {
atomic.AddInt64(&counter, 1)
}()
}
Заключение
Планировщик Go — это сложная, но элегантная система, которая делает конкурентность в Go такой простой и эффективной:
- Модель GMP — Goroutines, Machines, Processors
- Work Stealing — автоматическая балансировка нагрузки
- Preemption — предотвращение блокировки системы
- GOMAXPROCS — контроль параллелизма
Ключевые принципы:
- Создавайте goroutines свободно — они дешевые
- Но контролируйте их количество для CPU-bound задач
- Используйте worker pools для ограничения конкурентности
- Мониторьте и профилируйте вашу программу
- Доверяйте планировщику — он очень умный
В 99% случаев планировщик работает оптимально без вашего вмешательства. Понимание его работы нужно для отладки проблем производительности и написания высокоэффективного кода.