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

Какое количество задач забирает освободившийся процессор у другого процессора?

2.0 Middle🔥 141 комментариев
#Другое#Основы Go

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

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

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

Количество задач при steal в Go scheduler

В планировщике Go (начиная с версии 1.14) используется механизм work stealing (кража работы), который является частью гибридной модели планирования, сочетающей кооперативную многозадачность и вытеснение. Когда поток (M) заканчивает выполнение своей локальной очереди задач (горутин, G) или обнаруживает её пустой, он пытается "украсть" задачи у других потоков.

Конкретное количество забираемых задач

Ответ: по умолчанию освободившийся процессор (M) забирает ПОЛОВИну задач из локальной очереди другого процессора.

Это поведение зафиксировано в исходном коде планировщика в файле runtime/proc.go. Рассмотрим ключевые детали:

Алгоритм и код

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

// runtime/proc.go (упрощённо для понимания логики)
func stealWork(now int64) (gp *g, inheritTime bool) {
    // Выбор случайной очереди (P) для кражи
    for i := 0; i < 4; i++ { // Попытки украсть у разных P
        // Логика определения количества
        // Берётся половина задач из локальной очереди выбранного P
    }
}

Точное место, где определяется "половина", можно найти в функции runqsteal:

// runtime/proc.go
func runqsteal(pp, p2 *p, stealRunNextG bool) (*g, bool) {
    // Берём половину задач из очереди p2
    n := p2.runqlen / 2
    // Если stealRunNextG true, также пытаемся забрать runnext
}

Важные нюансы:

  1. Половинное деление: n := p2.runqlen / 2 — это целочисленное деление. Если в очереди 3 задачи, будет взято 1 (3/2 = 1).
  2. Минимальное количество: Если после деления получается 0, но в очереди есть хотя бы одна задача, то забирается одна задача.
  3. Приоритет runnext: Сначала проверяется runnext — специальная позиция для только что созданной или уже выполнявшейся горутины (имеет повышенный приоритет). Если runnext не пуст, часто забирается именно эта задача.
  4. Кольцевой буфер: Локальные очереди реализованы как кольцевые буферы размером 256. Кража происходит из "хвоста" чужой очереди.

Почему именно половина?

Это компромисс, учитывающий несколько факторов:

  • Баланс нагрузки: Цель — быстро сбалансировать нагрузку между ядрами, но не вызывать излишних накладных расходов на передачу задач.
  • Локальность данных: Полное опустошение очереди другого потока ухудшило бы локальность кэша процессора.
  • Агрессивность vs справедливость: Более агрессивная кража (больше задач) может привести к "перекачиванию" задач между потоками. Половинный подход считается эмпирически оптимальным.

Пример на практике

Представим ситуацию:

  • P1 (процессор 1): освободился, локальная очередь пуста.
  • P2 (процессор 2): в локальной очереди 10 задач (runqlen = 10).

P1 при попытке кражи:

  1. Выбирает P2 как жертву (случайно или по определённому алгоритму).
  2. Вычисляет количество: n = 10 / 2 = 5.
  3. Пытается атомарно забрать 5 задач из "хвоста" очереди P2.
  4. Если успешно — перемещает их в свою локальную очередь и начинает выполнение.

Эволюция и настройки

  • В более ранних версиях Go логика могла отличаться (например, кража одной задачи).
  • Нет прямой настройки этого параметра через runtime или переменные окружения. Он жёстко закодирован в планировщике.
  • Для тонкой настройки производительности в экстремальных нагрузках можно менять приоритеты или использовать runtime.LockOSThread(), но это не влияет на коэффициент кражи.

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