Как устроен планировщик (scheduler) в Go? Расскажите про модель M, P, G.

Ответ

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

  • G (Goroutine): Легковесный поток, управляемый рантаймом Go. Имеет свой собственный стек, который может расти и сжиматься. Это то, что вы создаете с помощью ключевого слова go.

  • M (Machine): Системный поток (OS Thread), управляемый операционной системой. Это реальный исполнитель кода.

  • P (Processor): Контекст для выполнения. Это ключевая абстракция, которая связывает горутины с потоками. У каждого P есть локальная очередь исполняемых горутин. Количество P по умолчанию равно количеству ядер CPU и контролируется переменной runtime.GOMAXPROCS.

Как это работает:

  1. Для выполнения P должен быть привязан к M.
  2. P берет G из своей локальной очереди и выполняет ее на M.
  3. Work Stealing (Воровство работы): Если у одного P заканчиваются горутины в очереди, он может "украсть" половину горутин из очереди другого P, чтобы сбалансировать нагрузку и не простаивать.
  4. Блокирующие системные вызовы: Если G на M выполняет блокирующий системный вызов (например, чтение файла), P отсоединяется от этого M (вместе с заблокированной G), находит другой свободный M (или создает новый) и продолжает выполнять другие горутины из своей очереди. Это предотвращает блокировку целого потока ОС.
func main() {
    // Устанавливаем количество "процессоров" (P), которые будут
    // одновременно выполнять горутины.
    runtime.GOMAXPROCS(2)

    var wg sync.WaitGroup
    wg.Add(10)

    for i := 0; i < 10; i++ {
        go func(i int) {
            defer wg.Done()
            fmt.Printf("Горутина %d выполняетсяn", i)
            // какая-то работа
            time.Sleep(10 * time.Millisecond)
        }(i)
    }

    wg.Wait()
}

Эта модель позволяет Go достигать высокой степени параллелизма с минимальными накладными расходами на переключение контекста по сравнению с традиционными потоками ОС.

Ответ 18+ 🔞

Давай разберёмся, как эта штука работает, а то звучит как какая-то мартышлюшка с аббревиатурами. G-P-M, блядь. Не ГПУ, а вот эта хуйня.

Представь себе такую картину: у тебя есть горутины (G) — это как легковесные тараканы, которых ты плодишь пачками командой go. Их дохуя, они шустрые, у каждого свой маленький стек, который может раздуваться, если надо. Тысячи их, блядь.

Но выполнять-то их надо на чём-то реальном, да? Вот тут появляются потоки ОС (M) — это уже серьёзные дядьки, системные, тяжёлые. Каждый такой поток — это Machine, железный исполнитель.

А теперь, внимание, самый важный пиздец — процессор (P). Это не физическое ядро, а такая абстрактная хуйня, контекст. Его главная задача — быть сводником между тараканами-горутинами и потоками-тяжеловесами. У каждого P есть своя локальная очередь тараканов, готовых к работе. Количество этих P по умолчанию равно твоим ядрам процессора. Хочешь больше — дергай runtime.GOMAXPROCS, но обычно это как вставить палку в колёса, если не знаешь, зачем.

Как вся эта банда работает:

  1. Чтобы что-то делать, P должен прилепиться к какому-нибудь свободному M. Связка готова.
  2. P хватает первого таракана G из своей очереди и суёт его на поток M — тот начинает его выполнять.
  3. Воровство работы (Work Stealing) — вот где начинается цирк! Если один P простаивает, потому что его очередь пуста, он не будет сидеть и чесать яйца. Он подкрадётся к другому, более занятому P, и украдёт половину его тараканов из очереди! Честное воровство, сбалансированное, блядь. Красота.
  4. А если горутина тупит? Ну, например, читает файл или ждёт сетевой ответ — это блокирующий вызов. Если бы поток M встал колом и ждал, это был бы пиздец и неэффективность. Поэтому система делает гениальную вещь: P отсоединяется от этого M вместе с заблокированной горутиной, находит другой свободный M (или создаёт новый) и продолжает гонять других тараканов из своей очереди! Заблокированная горутина потом, когда проснётся, встанет в очередь какого-нибудь P и будет ждать своего часа. Ни один поток ОС не простаивает просто так! Ёперный театр!
func main() {
    // Говорим: "Давай, используй два процессора (P) для всей этой движухи".
    runtime.GOMAXPROCS(2)

    var wg sync.WaitGroup
    wg.Add(10)

    for i := 0; i < 10; i++ {
        go func(i int) {
            defer wg.Done()
            fmt.Printf("Горутина %d выполняетсяn", i)
            // какая-то работа
            time.Sleep(10 * time.Millisecond)
        }(i)
    }

    wg.Wait()
}

Вот за счёт всей этой хитрожопой архитектуры Go и может гонять сотни тысяч горутин, не падая в обморок от переключения контекста, как это делают классические потоки. Всё дело в том, что переключает он не потоки ОС (это дорого), а этих легковесных тараканов G внутри потоков M. И делает это умно, с воровством и отцеплением. Гениально и немного по-бандитски, если вдуматься.