← Назад к вопросам

Как устроено планирование горутин?

2.8 Senior🔥 151 комментариев
#Конкурентность и горутины

Комментарии (1)

🐱
deepseek-v3.2PrepBro AI5 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Механизм планирования горутин в Go

Планирование горутин в Go организовано через рабоче-украденный планировщик (work-stealing scheduler), который является частью среды выполнения Go (runtime). Это многопоточный планировщик, который распределяет горутины по потокам ОС (обычно отображаемым на ядра процессора).

Архитектура планировщика

Основные компоненты:

  1. М (Machine) — поток ОС, который выполняет код Go
  2. P (Processor) — логический процессор, который управляет ресурсами для выполнения горутин
  3. G (Goroutine) — легковесный поток, единица выполнения
  4. Глобальная очередь (Global Run Queue) — очередь готовых к выполнению горутин
  5. Локальные очереди (Local Run Queues) — очереди горутин, привязанные к каждому P
  6. Список свободных P — логические процессоры, не выполняющие в данный момент код

Принцип работы

// Упрощенное представление структур планировщика в runtime
type p struct {
    // Локальная очередь горутин (кольцевой буфер)
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr
    
    // Ссылка на текущую выполняемую горутину
    curg *g
    
    // Ссылка на поток ОС (m)
    m *m
}

type schedt struct {
    // Глобальная очередь
    runq     gQueue
    runqsize int32
}

Алгоритм планирования

1. Создание горутины

Когда создается новая горутина (через go func()):

  • Она помещается в локальную очередь текущего P
  • Если локальная очередь переполнена (более 256 горутин), половина перемещается в глобальную очередь
func newproc(fn *funcval) {
    // Создание новой горутины
    newg := gfget(_p_)
    
    // Инициализация и помещение в очередь
    runqput(_p_, newg, true)
}

2. Выполнение и переключение

Каждый P выполняет горутины из своей локальной очереди по принципу FIFO. Когда горутина блокируется (канал, системный вызов, мьютекс), планировщик:

  • Сохраняет контекст текущей горутины
  • Выбирает следующую горутину из локальной очереди
  • Если локальная очередь пуста, пытается "украсть" работу у других P

3. Work-stealing (кража работы)

Если у P пуста локальная очередь, он:

  1. Проверяет глобальную очередь
  2. Проверяет очередь сетевого поллинга (network poller)
  3. Пытается "украсть" половину горутин из очереди случайного другого P
func findrunnable() (gp *g, inheritTime bool) {
    // 1. Проверка локальной очереди
    if gp, inheritTime := runqget(_p_); gp != nil {
        return gp, inheritTime
    }
    
    // 2. Проверка глобальной очереди
    if sched.runqsize != 0 {
        lock(&sched.lock)
        gp := globrunqget(_p_, 0)
        unlock(&sched.lock)
        if gp != nil {
            return gp, false
        }
    }
    
    // 3. Попытка кражи у других P
    for i := 0; i < 4; i++ {
        // Выбор случайного P для кражи
        stealRunNextG := false
        gp := runqsteal(_p_, allp[enum.position()], stealRunNextG)
    }
}

Ключевые особенности

Кооперативная многозадачность с вытеснением

  • Горутины добровольно уступают управление в точках вызова (каналы, системные вызовы)
  • Вытесняющая многозадачность реализована через:
    • Сигналы OS для прерывания длительных вычислений
    • Кооперативные точки уступки (Gosched)
    • Фоновый монитор, отслеживающий "зависшие" горутины

Системные вызовы

  • При блокирующем системном вызове горутина и P отделяются от потока M
  • P может быть переиспользован для выполнения других горутин
  • После завершения syscall горутина пытается вернуться к любому доступному P

Сетевые операции

  • Используется асинхронный ввод-вывод через netpoller (epoll/kqueue/IOCP)
  • Горутины, ожидающие сетевых операций, не блокируют потоки ОС

Преимущества такого подхода

1. Низкие накладные расходы

  • Переключение контекста происходит в userspace, без переключения в ядро ОС
  • Размер стека горутин динамически меняется (от 2KB до 1GB)

2. Высокая масштабируемость

  • Может эффективно использовать тысячи горутин
  • Автоматическое распределение нагрузки между ядрами CPU

3. Минимальная contention (конкуренция)

  • Локальные очереди уменьшают contention на глобальных блокировках
  • Work-stealing балансирует нагрузку без центрального координатора

Настройка и мониторинг

// Управление количеством потоков ОС
runtime.GOMAXPROCS(8) // Устанавливает максимальное количество потоков

// Отладка планировщика
GODEBUG=schedtrace=1000,scheddetail=1 ./program

// Текущая статистика
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)

Практические следствия для разработчика

  1. Избегайте "голодания" горутин — длительные вычисления без точек уступки
  2. Балансировка нагрузки — планировщик эффективно распределяет работу, но не идеально
  3. Локализация данных — горутины, работающие с общими данными, лучше выполнять на одном P
  4. Особенности системных вызовов — блокирующие вызовы могут снизить производительность

Планировщик Go постоянно развивается — в последних версиях были добавлены преemptive scheduling (вытесняющее планирование) и улучшенная поддержка асинхронных системных вызовов, что делает его одним из самых эффективных планировщиков пользовательского уровня среди современных языков программирования.

Как устроено планирование горутин? | PrepBro