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

Как работает Sheduler?

3.0 Senior🔥 142 комментариев
#Конкурентность и горутины#Операционные системы и Linux

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

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

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

Как работает Go Scheduler

Go scheduler — это часть рантайма Go, реализующая модель параллелизма M:N, где множество горутин (G) планируется на множестве нитей ОС (M), которые выполняются на логических процессорах (P). Это ключевой компонент, обеспечивающий эффективное и масштабируемое выполнение конкурентных программ на многопроцессорных системах.

Основные сущности

Работу scheduler'а невозможно понять без знакомства с его фундаментальными компонентами:

  • G (Goroutine) — легковесная нить, абстракция пользовательского конкурентного кода. Горутина содержит стек, указатель на выполняемую функцию, состояние (_Gidle, _Grunnable, _Grunning, _Gwaiting, _Gdead) и другие метаданные для планирования.
  • M (Machine) — абстракция нити ядра ОС (например, POSIX-нити). Именно M выполняет код горутины на реальном процессоре. Количество M может быть значительно больше количества ядер CPU.
  • P (Processor) — логический процессор, ресурс, необходимый M для выполнения кода G. P управляет локальной очередью исполняемых горутин (runq). Количество P по умолчанию равно количеству логических ядер CPU (GOMAXPROCS) и определяет реальный параллелизм программы.
// Упрощенное представление структур в runtime (для понимания концепции)
type g struct {
    stack       stack
    sched       gobuf
    goid        int64
    status      uint32 // _Gidle, _Grunnable и т.д.
    m           *m    // текущий m, если running
    // ... другие поля
}

type p struct {
    id          int32
    status      uint32 // _Pidle, _Prunning
    m           muintptr // привязанный m
    runqhead    uint32
    runqtail    uint32
    runq        [256]guintptr // локальная очередь из 256 горутин
    runnext     guintptr // специальная "следующая" горутина
    // ... другие поля
}

type m struct {
    g0          *g     // специальная горутина для планирования
    curg        *g     // текущая исполняемая горутина
    p           puintptr // привязанный p
    nextp       puintptr
    // ... другие поля
}

Принцип работы планировщика

  1. Инициализация: При запуске программы runtime создает количество P, равное GOMAXPROCS. Каждый P имеет свою локальную очередь (runq) из 256 горутин. Также создается одна "спящая" очередь (global runq) в sched структуре.

  2. Создание горутины: При вызове go func() создается новая горутина G. Она помещается в локальную очередь (runq) того P, с которым ассоциирован M, выполняющий текущий код. Это обеспечивает локальность данных и снижает конкуренцию за блокировки.

  3. Планирование выполнения (schedule):

    *   **Поток `M`** ищет работу: он должен быть привязан к свободному `P`, чтобы начать выполнение горутин.
    *   Алгоритм выбора следующей горутины для выполнения следует приоритетам:
        *   Проверить `runnext` у текущего `P` (специальная высокоприоритетная горутина, например, только что разблокированная).
        *   Взять горутину из локальной очереди `runq` текущего `P` (работает по принципу **кольцевого буфера**).
        *   Попытаться **украсть** горутину из локальной очереди другого `P` (work-stealing). Это критически важный механизм для балансировки нагрузки и предотвращения простаивания ядер.
        *   Взять горутину из глобальной очереди (`sched.runq`).
        *   Проверить **сетевой поллинг** (`netpoller`) — горутины, ожидающие сетевых событий.
        *   Если работы нет, `M` может отсоединиться от `P` и заснуть.

  1. Прерывания (преэмпция):
    Scheduler должен предотвращать монополизацию CPU одной горутиной. Это реализуется через:
    *   **Кооперативную многозадачность** на точек входа функций (вызовов функций, циклов `for` в некоторых случаях). Это устаревший, но все еще присутствующий механизм.
    *   **Вытесняющую многозадачность (preemption)** на основе **сигналов ОС**. Специальная системная нить (`sysmon`) помечает долго выполняющиеся горутины (`G`), и отправляет асинхронный сигнал (например, `SIGURG` на Linux) соответствующему `M`. Обработчик сигнала проверяет флаг и дает возможность scheduler'у переключиться на другую горутину. Это гарантирует, что даже плотный цикл `for` без вызовов функций может быть прерван.

Ключевые механизмы

  • Work-Stealing: Когда P опустошает свою локальную очередь, он сначала пытается взять работу из глобальной очереди, а затем случайным образом выбирает другого P и "ворует" половину горутин из его локальной очереди. Это делает планировщик децентрализованным и масштабируемым.
  • Sysmon (System Monitor): Фоновая нить, не привязанная к P. Она выполняет критически важные фоновые задачи:
    *   Сетевой поллинг (просыпает горутины, ожидающие I/O).
    *   Принудительная сборка мусора.
    *   Вытеснение долго выполняющихся горутин (preemption).
    *   Возвращение `P` в пул, если его `M` заблокировался в системном вызове на долгое время, и создание/привязка нового `M` к этому `P` для поддержания параллелизма.
  • Блокировки: Когда горутина блокируется (канал, мьютекс, системный вызов), текущий M может отсоединиться от горутины и P, чтобы позволить P выполнять другие горутины на другом M. Когда блокировка снимается, горутина пытается найти свободный P для продолжения работы.

Пример иллюстрации работы

package main

import (
    "fmt"
    "runtime"
    "time"
)

func worker(id int) {
    for i := 0; i <黏合; i++ {
        fmt.Printf("Worker %d: %d\n", id, i)
        runtime.Gosched() // Явная точка кооперативного перепланирования
    }
}

func main() {
    fmt.Printf("GOMAXPROCS: %d\n", runtime.GOMAXPROCS(0)) // Число P

    for i := 0; i < 5; i++ {
        go worker(i) // Создаются 5 G, попадают в локальные очереди P
    }

    time.Sleep(1 * time.Second)
}

В этом примере 5 горутин конкурируют за время на P процессорах. Вызов runtime.Gosched() добровольно уступает текущий P, помещая горутину обратно в очередь и запуская следующую.

Выводы

Go scheduler — это гибридная, вытесняющая, work-stealing система планирования, оптимизированная для низких задержек и высокой пропускной способности в многопоточных окружениях. Его главные преимущества:

  1. Масштабируемость благодаря децентрализованным очередям и work-stealing.
  2. Эффективность благодаря локальным очередям, минимизирующим блокировки.
  3. Справедливость благодаря вытеснению и работе sysmon.
  4. Интеграция с сетевым I/O через netpoller, что делает блокирующие операции неблокирующими для потоков ОС.

Понимание этих принципов позволяет писать более эффективные конкурентные программы, правильно настраивать GOMAXPROCS и избегать паттернов, которые могут деградировать производительность scheduler'а (например, создание миллионов "спящих" горутин, длительные вычисления без точек вызова функций).