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

Ответ

На одном ядре процессора Go выполняет несколько горутин конкурентно, но не параллельно. Это достигается с помощью встроенного планировщика (scheduler), который реализует кооперативную многозадачность.

Ключевые концепции

  • Конкурентность vs Параллелизм:

    • Конкурентность — это способность управлять несколькими задачами одновременно, переключаясь между ними. Задачи могут выполняться на одном ядре.
    • Параллелизм — это фактическое одновременное выполнение нескольких задач на разных ядрах процессора.

  • Модель M:P:G: Планировщик Go использует модель M (системный поток) : P (логический процессор) : G (горутина). Он распределяет множество горутин (G) для выполнения на небольшом количестве системных потоков (M), управляемых через логические процессоры (P). На одном ядре (один P) горутины выполняются по очереди.


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

Планировщик переключает контекст с одной горутины на другую в так называемых точках вытеснения (preemption points). Горутина добровольно уступает управление, когда:

  1. Выполняет блокирующую операцию: чтение/запись в канал, сетевое взаимодействие, ожидание мьютекса.
  2. Вызывает runtime.Gosched(): явный вызов для передачи управления другой горутине.
  3. Выполняет длительный цикл: с Go 1.14 планировщик может вытеснять горутины, которые долго работают без блокирующих вызовов.
  4. Выполняет системный вызов.

Пример

package main

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

func main() {
    // Ограничиваем выполнение одним ядром для демонстрации
    runtime.GOMAXPROCS(1)

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        defer wg.Done()
        for i := 0; i < 3; i++ {
            fmt.Println("Горутина 1:", i)
            // Без этого вызова вторая горутина могла бы не получить управление
            runtime.Gosched()
        }
    }()

    go func() {
        defer wg.Done()
        for i := 0; i < 3; i++ {
            fmt.Println("Горутина 2:", i)
            runtime.Gosched()
        }
    }()

    wg.Wait()
}

Вывод будет перемешан, так как runtime.Gosched() заставляет горутину уступить процессор, позволяя планировщику запустить другую. Например:

Горутина 1: 0
Горутина 2: 0
Горутина 1: 1
Горутина 2: 1
Горутина 1: 2
Горутина 2: 2