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

Ответ

Планировщик Go реализует модель M:N, где M горутин выполняются на N потоках операционной системы. Количество потоков ОС, которые могут одновременно выполнять код Go, по умолчанию равно количеству доступных ядер процессора и регулируется переменной runtime.GOMAXPROCS.

Рассмотрим пример с 4 горутинами и 4 ядрами:


  1. Настройка: При запуске Go-приложения GOMAXPROCS будет установлено в 4. Это означает, что рантайм создаст 4 системных потока (P в модели GMP), каждый из которых может быть привязан к своему ядру процессора.



  2. Идеальный сценарий (CPU-bound задачи): Если все 4 горутины выполняют исключительно вычислительные операции (не блокируются), планировщик, скорее всего, распределит каждую горутину на отдельный поток/ядро, и они будут выполняться параллельно.



  3. Реальный сценарий (с блокировками): Если одна из горутин блокируется (например, ждет ответа по сети, чтения файла или данных из канала), планировщик немедленно "усыпляет" ее, отвязывает от потока и ставит в очередь на выполнение другую, готовую к работе горутину. Это позволяет максимально утилизировать процессорное время.


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

func main() {
    // Устанавливаем GOMAXPROCS равным количеству ядер (в современных версиях Go это делается автоматически)
    runtime.GOMAXPROCS(runtime.NumCPU())
    fmt.Printf("Running with %d logical CPUsn", runtime.NumCPU())

    var wg sync.WaitGroup
    wg.Add(4)

    for i := 0; i < 4; i++ {
        go func(id int) {
            defer wg.Done()
            // Какая-то работа
            fmt.Printf("Goroutine %d is workingn", id)
        }(i)
    }

    wg.Wait()
}

Вывод:

  • Распределение не является фиксированным. Планировщик динамически переключает горутины между потоками для достижения максимальной пропускной способности.
  • Для задач, требующих привязки к конкретному потоку ОС (например, для интеграции с некоторыми C-библиотеками), можно использовать runtime.LockOSThread().