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

Куда откладывается горутина при Syscall?

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

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

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

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

Отличный, глубокий вопрос, который касается внутренней механики планировщика Go (scheduler). Давайте разберем его детально.

Краткий ответ

При выполнении блокирующего системного вызова (blocking syscall) горутина не просто "откладывается" в очередь, а переводится в особое состояние, и её поток (M) отделяется от неё. Это ключевой механизм для неблокирующей работы многих горутин на ограниченном числе потоков ОС.

Детальное объяснение

Чтобы понять процесс, нужно вспомнить модель планировщика Go, построенную на трёх сущностях: G (горутина), M (поток ОС, machine), P (логический процессор, контекст).

1. Что происходит при обычном (блокирующем) Syscall?

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

  1. Захват ресурсов: Горутина (G) и её поток (M) захватывают свой P (P связан с M).
  2. Переход в состояние Gsyscall: Горутина переводится в состояние Gsyscall (системный вызов) и продолжает выполняться в том же потоке ОС (M).
  3. Отделение P от M: Планировщик Go понимает, что вызов блокирующий и M "заснёт", управляемый ядром ОС. Поэтому P отсоединяется (hand off) от этого M. P становится свободным и может быть использован для выполнения других горутин.
  4. Освобождение P: Освободившийся P помещается в так называемый список "свободных" P или сразу используется другим ожидающим потоком (M) из пула планировщика.
  5. Блокировка M: Исходный поток ОС (M), в котором выполняется syscall, блокируется на уровне ядра ОС. Он не потребляет ресурсы CPU, но ждёт завершения системного вызова. Теперь это просто поток ОС, не связанный с планировщиком Go.
  6. Завершение Syscall: Когда системный вызов завершается, поток ОС (M) просыпается.
  7. Попытка вернуться: Проснувшаяся горутина (G) пытается вернуться в работу. Ей нужен P, чтобы продолжить исполнение пользовательского кода. Она ищет свободный P.
    *   **Если свободный `P` найден:** Горутина захватывает его (возможно, на другом `M` из пула), переходит в состояние `Grunnable`, а затем `Grunning`, и продолжает выполнение.
    *   **Если свободного `P` нет:** Горутина помещается в **глобальную очередь исполнения** в состоянии `Grunnable`, а её поток (`M`) отправляется спать или остается в пуле свободных потоков.

2. А что с неблокирующими (асинхронными) Syscall'ами (netpoller)?

Go использует асинхронный ввод-вывод (через epoll в Linux, kqueue в BSD, IOCP в Windows) для сетевых операций. Это кардинально меняет картину:

  1. Горутина вызывает сетевую операцию (например, conn.Read).
  2. Если данных нет, рантайм регистрирует дескриптор файла в netpoller (системе уведомлений о событиях ввода-вывода).
  3. Горутина не делает блокирующий syscall. Вместо этого она переводится в состояние Gwaiting и снимается с исполнения.
  4. Поток (M) освобождается вместе со своим P и немедленно начинает выполнять другую горутину из очереди этого P. Никакого блокирования потока ОС не происходит.
  5. Когда в netpoller приходит событие (данные доступны для чтения), ожидающая горутина помечается как Grunnable и ставится в локальную или глобальную очередь на выполнение.

Именно благодаря netpoller'у горутины могут эффективно обрабатывать тысячи сетевых соединений "одновременно" без создания такого же количества потоков ОС.

3. Итог: Куда же "откладывается" горутина?

Горутина не хранится в какой-то специальной "очереди syscall". Её состояние меняется, а судьба зависит от типа вызова:

  • При блокирующем syscall: Горутина (G) связана с заблокированным потоком ОС (M) до завершения вызова. Её планированием занимается ядро ОС, а не планировщик Go. После завершения она борется за свободный P.
  • При неблокирующем syscall (через netpoller): Горутина (G) переводится в состояние Gwaiting и связывается с дескриптором в netpoller. Она находится в "списке ожидающих" netpoller'а до наступления события ввода-вывода, после чего возвращается в обычную очередь исполнения планировщика.
// Пример, иллюстрирующий разницу
package main

import (
    "fmt"
    "net/http"
    "time"
    "os"
)

func main() {
    // Пример 1: Неблокирующий сетевой вызов (использует netpoller)
    go func() {
        resp, _ := http.Get("https://example.com") // Горутина будет "ожидать" в netpoller
        _ = resp
        fmt.Println("Сетевой запрос завершен")
    }()

    // Пример 2: Блокирующий syscall (напр., файловый ввод-вывод в текущей реализации)
    go func() {
        data, _ := os.ReadFile("/tmp/bigfile.txt") // Поток ОС (M) заблокируется ядром
        _ = data
        fmt.Println("Файл прочитан")
    }()

    time.Sleep(2 * time.Second)
}

Ключевые выводы

  • Основная цель — не дать заблокированному потоку ОС (M) удерживать ценный ресурс — логический процессор (P). P должен всегда быть доступен для исполнения готовых горутин.
  • Разделение ответственности: При блокирующем syscall управление горутиной временно переходит к ядру ОС. При неблокирующем — к внутреннему механизму Go (netpoller).
  • Эффективность: Модель с отделением P и использованием netpoller позволяет создавать высоконагруженные конкурентные приложения с малым числом реальных потоков ОС, минимизируя накладные расходы на переключение контекста.
Куда откладывается горутина при Syscall? | PrepBro