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

Как распределятся 4 горутины при их запуске на четырех ядрах?

1.7 Middle🔥 211 комментариев
#Конкурентность и горутины#Производительность и оптимизация

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

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

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

Распределение горутин на ядрах процессора в Go

Распределение 4 горутин на 4-ядерном процессоре в Go — недетерминированный процесс, который управляется планировщиком Go (scheduler), реализующим модель M:N (много-ко-много). Внешне это выглядит как параллельное выполнение, но внутренние механизмы сложнее.

Архитектура планировщика Go

Планировщик Go оперирует тремя ключевыми сущностями:

  • G (Goroutine) — легковесная горутина, пользовательский поток.
  • M (Machine) — поток операционной системы (OS thread), который выполняет код.
  • P (Processor) — виртуальный процессор, контекст планировщика, связывающий M и G.

По умолчанию количество P равно количеству логических ядер (в нашем случае — 4). Каждый P имеет локальную очередь (Local Run Queue — LRQ) готовых к выполнению горутин.

Возможные сценарии распределения

package main

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

func main() {
    // Устанавливаем использование всех доступных ядер
    runtime.GOMAXPROCS(4) // Обычно это значение по умолчанию
    
    var wg sync.WaitGroup
    wg.Add(4)
    
    for i := 0; i < 4; i++ {
        go func(id int) {
            defer wg.Done()
            fmt.Printf("Горутина %d запущена\n", id)
            // Имитация работы
            time.Sleep(100 * time.Millisecond)
        }(i)
    }
    
    wg.Wait()
}

Сценарий 1: Идеальное распределение (наиболее вероятное)

  1. Каждое из 4 ядер получает по одному виртуальному процессору (P).
  2. Каждый P привязывается к своему потоку ОС (M).
  3. Планировщик распределяет по одной горутине (G) в очередь каждого P.
  4. Результат: Каждая горутина выполняется на отдельном физическом ядре, обеспечивая истинный параллелизм.
Ядро 1 (P1, M1) -> G1
Ядро 2 (P2, M2) -> G2  
Ядро 3 (P3, M3) -> G3
Ядро 4 (P4, M4) -> G4

Сценарий 2: Распределение с миграцией

  1. Горутины могут быть сначала помещены в очередь одного P (особенно если они запускаются из одного системного потока).
  2. Планировщик использует ворк-стеaling (work stealing) — если у P пустая очередь, он "крадет" горутину из очереди другого P.
  3. Также может происходить hand off — если горутина блокируется (например, на I/O), M может отдать свой P другой горутине.
  4. Результат: Горутины могут мигрировать между ядрами во время выполнения для оптимальной загрузки.

Сценарий 3: Влияние системных вызовов

func worker(id int) {
    // Блокирующий системный вызов
    data := make([]byte, 1024)
    file, _ := os.ReadFile("data.txt") // Системный вызов
    
    // После возврата из системного вызова
    // горутина может продолжить на другом ядре
    process(data)
}

При блокирующем системном вызове:

  1. Текущий M блокируется вместе с G.
  2. P освобождается и может быть использован другим M для выполнения других горутин.
  3. Когда системный вызов завершается, G пытается найти свободный P для продолжения работы, возможно, на другом ядре.

Ключевые факторы, влияющие на распределение

  • Нагрузка на ядра — планировщик ОС также влияет на распределение потоков M.
  • Поведение горутин — горутины, выполняющие интенсивные вычисления (CPU-bound), ведут себя иначе, чем те, что часто блокируются (I/O-bound).
  • Количество доступных P — можно изменить через runtime.GOMAXPROCS().
  • Время запуска — горутины, запущенные почти одновременно, имеют больше шансов распределиться по разным ядрам.
  • Приоритеты планировщика — планировщик старается минимизировать миграцию горутин между ядрами (аффинность).

Практические наблюдения

  1. Не гарантируется равномерное распределение — планировщик оптимизирует производительность, а не равномерность.
  2. Горутины могут мигрировать между ядрами во время выполнения.
  3. При малом количестве горутин (4 горутины на 4 ядра) распределение обычно близко к идеальному.
  4. Не нужно явно управлять распределением — планировщик Go эффективно справляется с этой задачей.

Вывод

В типичном сценарии 4 горутины на 4-ядерном процессоре с высокой вероятностью будут распределены по одному на каждое ядро, что обеспечит параллельное выполнение. Однако это не гарантировано спецификацией языка — планировщик может при необходимости перераспределять их для оптимизации производительности, особенно при наличии блокирующих операций или асинхронных вызовов. Это преимущество, а не недостаток — разработчик сосредотачивается на конкурентной логике, а планировщик эффективно использует доступные ресурсы процессора.