Как планировщик Go распределяет горутины между ядрами процессора?

Ответ

Планировщик Go (Go Scheduler) использует модель планирования M:P:G, чтобы эффективно распределять горутины по доступным ядрам процессора.

Ключевые компоненты планировщика:

  • G (Goroutine): Легковесный поток исполнения, создаваемый с помощью ключевого слова go.
  • M (Machine): Системный поток (OS thread), управляемый операционной системой. Именно он выполняет код.
  • P (Processor): Контекст для выполнения. Он связывает горутины (G) с системными потоками (M). Количество P по умолчанию равно количеству ядер CPU и регулируется переменной GOMAXPROCS.

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


  1. Модель M:N: Множество горутин (N) выполняются на множестве системных потоков (M). Планировщик стремится к тому, чтобы на каждом ядре (P) постоянно выполнялась работа.



  2. Очереди горутин: У каждого P есть своя локальная очередь горутин. Это позволяет избежать блокировок при доступе к общей очереди.



  3. Work-Stealing (Воровство работы): Если локальная очередь горутин у одного P опустела, он может "украсть" половину горутин из очереди другого P. Это обеспечивает равномерную загрузку всех ядер.



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


Пример с правильной синхронизацией:

Вместо time.Sleep для ожидания завершения горутин следует использовать sync.WaitGroup.

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    // Устанавливаем количество потоков ОС, равное количеству ядер
    runtime.GOMAXPROCS(runtime.NumCPU())

    var wg sync.WaitGroup

    // Запускаем 10 горутин
    for i := 0; i < 10; i++ {
        wg.Add(1) // Увеличиваем счетчик WaitGroup
        go func(num int) {
            defer wg.Done() // Уменьшаем счетчик по завершении горутины
            fmt.Printf("Горутина %d выполняетсяn", num)
        }(i)
    }

    wg.Wait() // Ожидаем, пока все горутины не завершатся
    fmt.Println("Все горутины завершили работу.")
}

Таким образом, планировщик Go абстрагирует сложность параллельного выполнения, позволяя разработчику писать конкурентный код без ручного управления потоками.