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

Как происходит перераспределение горутин?

3.0 Senior🔥 81 комментариев
#Конкурентность и горутины

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

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

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

Механизм перераспределения горутин в Go

Перераспределение горутин (goroutine rebalancing или work stealing) — это ключевой механизм планировщика Go (scheduler), который обеспечивает эффективное распределение вычислительной нагрузки между потоками операционной системы (OS threads). Этот процесс происходит автоматически и прозрачно для разработчика.

Основные компоненты системы планирования

Планировщик Go построен на модели M:N, где M горутин выполняются на N потоках ОС. Основные сущности:

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

Каждый P имеет собственную локальную очередь (local runqueue) горутин. Также существует глобальная очередь (global runqueue), куда попадают новые горутины.

Процесс перераспределения

Перераспределение активируется в нескольких ситуациях:

1. Когда P исчерпывает свою локальную очередь

Если у процессора P нет готовых к выполнению горутин, он пытается "украсть" (steal) работу у других процессоров:

// Упрощенная логика работы планировщика (концептуально)
func schedule() {
    gp := getGoroutineFromLocalQueue()
    
    if gp == nil {
        // Локальная очередь пуста - пытаемся украсть работу
        gp = stealWorkFromOtherP()
    }
    
    if gp == nil {
        // Проверяем глобальную очередь
        gp = getGoroutineFromGlobalQueue()
    }
    
    execute(gp)
}

Планировщик выбирает случайный P и забирает половину горутин из его локальной очереди.

2. При создании новой горутин

Новая горутина может быть помещена:

  • В локальную очередь текущего P
  • В глобальную очередь (если локальная переполнена или по другим эвристикам)
  • В очередь ожидающего P (при работе stealing)
func newGoroutine(f func()) {
    g := createGoroutine(f)
    
    // Попытка поместить в локальную очередь текущего P
    if !putInLocalQueue(g) {
        // Если не удалось - помещаем в глобальную очередь
        putInGlobalQueue(g)
    }
}

3. При системных вызовах (syscalls)

Когда горутина выполняет блокирующий системный вызов:

  • Текущий поток M блокируется
  • P отсоединяется от M и может быть использован другим M
  • После завершения syscall горутина пытается присоединиться к свободному P
  • Если свободных P нет - горутина помещается в глобальную очередь

4. При вызове runtime.Gosched()

Явная уступка процессорного времени:

func cooperativeFunction() {
    for i := 0; i < 10; i++ {
        if i%3 == 0 {
            // Добровольно уступаем процессор
            runtime.Gosched()
        }
        processItem(i)
    }
}

Эвристики и оптимизации планировщика

Планировщик Go использует несколько умных стратегий:

  1. Привязка горутин к потокам — горутина старается выполняться на том же потоке ОС, что и ранее, для кэширования процессора
  2. Распараллеливание for-циклов — компилятор может автоматически распараллеливать некоторые циклы
  3. Сетевой полинг — горутины, ожидающие сетевых операций, не блокируют потоки ОС
  4. Балансировка каждые 10 мс — независимо от других событий, планировщик периодически проверяет баланс нагрузки

Пример поведения при перегрузке

package main

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

func worker(id int, results chan<- int) {
    // Имитация работы
    sum := 0
    for i := to; i < 10000; i++ {
        sum += i
    }
    results <- sum
}

func main() {
    runtime.GOMAXPROCS(2) // Ограничиваем двумя процессорами
    
    results := make(chan int,与小)
    
    // Запускаем больше горутин, чем процессоров
    for i := 0; i < 100; i++ {
        go worker(i, results)
    }
    
    // Планировщик будет постоянно перераспределять 
    // эти 100 горутин между 2 процессорами
    
    for i := 0; i < 100; i++ {
        <-results
    }
}

В этом примере планировщик постоянно балансирует 100 горутин между двумя логическими процессорами, используя механизмы stealing и перераспределения.

Критические аспекты для разработчика

  1. Не полагайтесь на порядок выполнения — перераспределение недетерминировано
  2. Избегайте "горячих" очередей — если одна горутина порождает много дочерних, они могут перегрузить локальную очередь одного P
  3. Используйте пулы рабочих для CPU.

Практические рекомендации

  • Для CPU-intensive задач используйте runtime.GOMAXPROCS() соответственно количеству ядер
  • Для IO-intensive задач можно увеличить лимит потоков через runtime.SetMaxThreads()
  • Избегайте чрезмерного создания горутин в tight loops без точек прерывания
  • Используйте паттерн worker pools для контроля параллелизма

Механизм перераспределения — одна из ключевых причин эффективности Go в конкурентных сценариях, позволяя достигать высокой утилизации CPU без ручного управления потоками.