40. Как runtime обрабатывает системные вызовы
В прошлой статье (39. defer, panic и recover под капотом) мы изучили, как рантайм управляет потоком выполнения внутри самой программы. Но наша программа не существует в вакууме. Любое полезное действие — прочитать файл с SSD, отправить HTTP-запрос, записать строчку в лог или даже получить текущее время — требует прямого обращения к ядру операционной системы.
Такое взаимодействие называется Системным вызовом (Syscall).
В классическом многопоточном программировании (например, в C++ или Java на уровне java.lang.Thread) системные вызовы тривиальны: один поток операционной системы выполняет одну задачу. Поток делает сисколл, процессор переключается из пространства пользователя (Ring 3) в пространство ядра (Ring 0), ядро ОС усыпляет этот поток, пока контроллер NVMe или сетевой чип не отдадут данные, а затем планировщик ОС будит поток.
Но для архитектуры Go с её M:N планировщиком (миллионы легковесных горутин G поверх фиксированного пула потоков ОС M и логических процессоров P) блокировка потока ОС — это архитектурная катастрофа. Если поток M намертво уснет в ядре, логический процессор P окажется заблокированным, а все десятки тысяч горутин в его локальной очереди остановятся и перестанут обрабатывать пользовательские запросы!
Как рантайм Go умудряется выполнять миллионы системных вызовов в секунду, обеспечивая минимальные задержки и не замораживая приложение?
Рантайм делит все системные вызовы на две принципиальные категории: асинхронные (Fast Path через Netpoller) и синхронные (Slow Path через Handoff и sysmon).
1. Fast Path: Асинхронные вызовы и Netpoller
Если вы разрабатываете веб-сервисы, gRPC-микросервисы или распределенные базы данных, 99% ваших системных вызовов — это сетевой ввод-вывод (Network I/O).
Сеть принципиально асинхронна и непредсказуема. Мы можем ждать ответа от удаленного клиента 100 микросекунд, а можем ждать 60 секунд при медленном соединении (Keep-Alive). Блокировать реальный поток операционной системы на 60 секунд — непозволительная роскошь для высоконагруженного сервера.
Поэтому для работы с сетевыми соединениями Go использует Неблокирующий системный ввод-вывод (Non-blocking I/O) в тесной связке со встроенным в ядро механизмом мультиплексирования событий ввода-вывода:
epollна Linux;kqueueна macOS и FreeBSD;IOCP(I/O Completion Ports) на Windows.
В рантайме Go эта мощная подсистема называется Netpoller (сетевой поллер).
sequenceDiagram
autonumber
actor G as Горутина G1
participant M as Поток ОС (M)
participant NP as Netpoller (epoll)
participant K as Ядро Linux (Socket)
G->>M: conn.Read(buf)
M->>K: read(fd) (неблокирующий O_NONBLOCK)
K-->>M: Ошибка EAGAIN / EWOULDBLOCK (данных нет!)
Note over M: Поток M НЕ блокируется в ОС!
M->>NP: epoll_ctl(EPOLL_CTL_ADD, fd)
M->>G: gopark() -> перевод G1 в _Gwaiting
Note over M: Поток M свободен! Берет G2 из очереди P.
Note over K,NP: Сетевая карта приняла TCP-пакет
K-->>NP: epoll_wait сообщает: fd готов к чтению!
NP->>G: goready(G1) -> перевод G1 в _Grunnable
G->>M: Горутина G1 продолжает выполнение и читает буфер
Анатомия неблокирующего чтения:
- При создании сетевого сокета рантайм автоматически переводит файловый дескриптор в неблокирующий режим с помощью флага
O_NONBLOCK. - Когда горутина делает вызов
conn.Read(), рантайм выполняет инструкциюSYSCALL(системный вызовread). - Если данных в буфере сокета ядра еще нет, ядро Linux не усыпляет поток, а мгновенно возвращает управление с кодом ошибки
EAGAIN(илиEWOULDBLOCK— «данных пока нет, попробуйте позже»). - Рантайм перехватывает этот статус. Вместо того чтобы блокировать поток
M, он регистрирует файловый дескриптор сокета вepollчерезnetpollopen. - Горутина вызывает внутреннюю функцию рантайма
goparkи переходит в состояние ожидания_Gwaiting, отвязываясь от потокаM. - Поток
Mостается полностью свободным! Он моментально заглядывает в локальную очередь логического процессораP, забирает следующую готовую к выполнению горутину (G2) и продолжает работу без единого переключения контекста ОС.
Кто будит горутину?
Фоновый опрос Netpoller происходит регулярно: его проверяет поток sysmon, а также сам планировщик рантайма при поиске работы (findrunnable), вызывая функцию netpoll(delay).
Как только в сокет приходят байты, ядро Linux через механизм прерываний NIC и кольцевой буфер epoll сообщает о готовности дескриптора. Netpoller извлекает дескриптор, находит припаркованную горутину, вызывает функцию goready и возвращает её в статус _Grunnable. Ближайший свободный процессор P берет её на исполнение.
2. Slow Path: Синхронные вызовы и Handoff
Что делать с файловыми операциями на диске (Disk I/O) или вызовами C-библиотек через CGO?
Исторически стандартные интерфейсы POSIX/Linux для работы с регулярными дисковыми файлами (в отличие от сокетов) не поддерживают неблокирующий режим через классический epoll (события готовности диска всегда возвращаются мгновенно, но сам вызов read/write блокирует поток в ядре до завершения перемещения головок HDD или чтения ячеек SSD). Лишь относительно недавно появившийся интерфейс ядра Linux io_uring решает эту проблему асинхронно, но стандартный файловый ввод-вывод Go (os.ReadFile, os.Open, os.Write) по сей день остается синхронным и блокирующим на уровне ОС.
Когда поток ОС засыпает на дисковом сисколле, в игру вступает фундаментальный механизм планировщика — Syscall Handoff (Передача процессора).
Когда горутина собирается выполнить блокирующий сисколл, компилятор обрамляет его вызовами функций рантайма entersyscall и exitsyscall:
// Псевдокод системного вызова в стандартной библиотеке
entersyscall()
syscall(SYS_READ, fd, buf, len) // Ассемблерная инструкция SYSCALL (блокирует поток M)
exitsyscall()
- Функция
entersyscallсохраняет контекст регистров горутины и переводит логический процессорPв состояние_Psyscall. - Поток
M1выполняет реальный системный вызов ядра Linux и намертво засыпает в пространстве ядра (состояниеD— uninterruptible sleep вps/top). - Все остальные горутины, стоявшие в очереди этого
P, оказываются под угрозой голодания (Starvation).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph S1 ["1. Блокирующий Syscall (entersyscall)"]
M1["Поток ОС (M1)"] --- P["Процессор (P)<br>Состояние: _Psyscall"] --- G1["Горутина (G1)"]
G1 -->|"Делает Syscall"| KERNEL["Ядро Linux"]
M1 -.->|"Поток уснул в кольце 0"| KERNEL
end
subgraph S2 ["2. Вмешательство sysmon (Handoff)"]
SYSMON["Фоновый поток sysmon"]:::process -->|"Блокировка > 20 мкс<br>или горутины ждут в очереди"| ACTION["Отрыв процессора (Handoff):<br>handoffp(P)"]
M2["Свободный поток ОС (M2)<br>(из пула sched.midle или clone)"]
ACTION -->|"Передает P новому потоку"| M2
G2["Очередь горутин в P"]:::data
M2 --- P --- G2
end
Фоновый страж: sysmon
Чтобы спасти горутины, оставшиеся без процессора, рантайм использует специальный фоновый поток — sysmon (System Monitor).
sysmon — это уникальный компонент рантайма Go. Ему вообще не требуется логический процессор P для исполнения: он работает непосредственно как чистый поток операционной системы M. Он периодически просыпается (с динамическим интервалом от 20 микросекунд до 10 миллисекунд) и выполняет инспекцию состояния всей системы.
Если sysmon видит, что процессор P находится в состоянии _Psyscall дольше допустимого интервала (или если в локальной очереди P есть другие ожидающие горутины, а поток M1 заблокировался), он выполняет операцию Handoff:
- Он насильно отрывает
Pот уснувшего потокаM1. - Передает
Pсвободному потокуM2из глобального пула свободных потоков (sched.midle). - Если свободных потоков в пуле нет, рантайм запрашивает у ядра ОС создание нового физического потока через системный вызов
clone(Linux) илиpthread_create. - Поток
M2мгновенно начинает исполнять оставшиеся горутины из очередиP. Задержки приложения спасены!
3. Возвращение из Ядра (exitsyscall)
Что происходит, когда контроллер диска закончил передачу данных, и ядро ОС, наконец, разбудило поток M1?
Поток M1 возвращается из системного вызова в пространство пользователя и немедленно вызывает функцию exitsyscall.
Но поток M1 теперь «гол»: у него больше нет привязанного процессора P (его отнял sysmon и отдал потоку M2). Без контекста P поток M1 не имеет права выполнять Go-код, выделять память через mcache или трогать стек горутин!
Алгоритм exitsyscall работает по следующей строгой схеме:
- Fast Path:
M1проверяет, освободился ли его прежнийP. Если старыйPсвободен,M1мгновенно забирает его обратно и продолжает выполнять горутинуG1. - Idle P: Если старый
Pзанят,M1пытается захватить любой свободный логический процессор в системе из спискаsched.pidle. - Slow Path: Если все процессоры
Pв системе заняты выполнением других горутин, потокM1вынужден смириться. Он переводит горутинуG1в состояние_Grunnableи помещает её в Глобальную очередь выполнения (Global Run Queue), чтобы её подобрал первый освободившийся процессор. - Сам поток
M1паркуется и отправляется спать в глобальный пул неактивных потоков (sched.midle), ожидая следующего Handoff.
4. Исключение из правил: vDSO (Virtual Dynamic Shared Object)
vDSO (Virtual Dynamic Shared Object) — это блестящая аппаратная оптимизация современных UNIX-подобных операционных систем (Linux).
Ядро ОС при старте процесса проецирует (маппит) небольшую страницу физической памяти ядра напрямую в адресное пространство пользовательского процесса (в режиме Read-Only). На этой странице ядро регулярно обновляет глобальные переменные, не требующие привилегированного доступа: текущее системное время, счетчик монотонного времени и параметры таймеров.
Пространство ядра (Ring 0)
┌────────────────────────┐
│ Обновление времени │
└───────────┬────────────┘
│ Запись без прерываний
▼
┌──────────────────────────────────────────────┐
│ Страница vDSO (Read-Only для User Space) │
└──────────────────┬───────────────────────────┘
│ Прямое чтение RDTSC / L1d
▼
Пространство пользователя (Ring 3)
┌────────────────────────┐
│ Go: time.Now() │ <-- 0 переключений контекста CPU!
└────────────────────────┘
Когда рантайм Go выполняет time.Now() (через встроенную реализацию в runtime.nanotime), он не выполняет инструкцию SYSCALL. Он просто читает значение прямо из спроецированной страницы памяти vDSO и задействует аппаратный счетчик тактов процессора RDTSC (Read Time-Stamp Counter).
Переключения контекста процессора в кольцо 0 не происходит вовсе — это обычная операция чтения из кэша L1d, абсолютно бесплатная для планировщика Go!
Итог
- Netpoller (epoll / kqueue / IOCP): Применяется для всех сетевых сокетов. Неблокирующий ввод-вывод (
O_NONBLOCK). При отсутствии данных горутина паркуется (gopark), а поток ОСMнемедленно берет другую работу. Самый эффективный путь. - Syscall Handoff: Применяется для синхронных файловых операций (диск) и CGO. Поток
Mзасыпает в ядре ОС. - sysmon: Автономный фоновый поток, спасающий систему от зависания. Если сисколл длится дольше допустимого, он забирает процессор
Pу спящего потока и отдает его другомуM. - exitsyscall: При выходе из ядра поток пытается вернуть
P, а при его отсутствии сдает горутину в глобальную очередь и засыпает. - vDSO: Оптимизация ядра Linux, позволяющая получать время через
time.Now()за наносекунды без реального входа в ядро.
Мы увидели, как рантайм Go изолирует блокирующие системные вызовы. Однако в системном программировании нередко приходится вызывать не системные вызовы ядра, а сторонние библиотеки, написанные на языке C (CGO).
Почему вызов простейшей C-функции стоит в десятки раз дороже обычного вызова функции Go? Как рантайм решает конфликт между фиксированным стеком C и динамическим сегментированным стеком Go?
В следующей статье мы разберем анатомию межъязыкового моста:
41. cgo. Как Go взаимодействует с C