Как Go runtime обрабатывает блокирующие системные вызовы (например, I/O операции), не блокируя при этом весь поток ОС?

Ответ

Go достигает высокой производительности в I/O операциях благодаря умной работе планировщика (scheduler) и интеграции с сетевым поллером (network poller). Это позволяет горутинам выглядеть блокирующими в коде, но быть неблокирующими для операционной системы.

Вот как это работает по шагам:


  1. Инициация вызова: Горутина (G) собирается выполнить блокирующую операцию, например, net.Read().



  2. Перехват вызова: Вместо прямого системного вызова, который заблокировал бы поток ОС (M), стандартная библиотека Go обращается к runtime.



  3. Делегирование поллеру: Runtime регистрирует файловый дескриптор (например, сокет) в сетевом поллере. Сетевой поллер — это внутренняя сущность Go, которая использует эффективные механизмы опроса событий ОС (такие как epoll в Linux, kqueue в macOS/BSD, IOCP в Windows).



  4. Парковка горутины: После регистрации дескриптора, планировщик паркует текущую горутину (G). Она переходит в состояние ожидания, но не блокирует системный поток (M), на котором выполнялась. Контекст (P) и поток (M) освобождаются.



  5. Выполнение другой работы: Поскольку поток (M) свободен, планировщик немедленно передает ему другую готовую к выполнению горутину из очереди. Таким образом, поток ОС не простаивает.



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



  7. Возвращение в очередь: Планировщик получает уведомление и перемещает запаркованную горутину (G) из состояния ожидания обратно в очередь готовых к выполнению.



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


Результат

Этот механизм позволяет Go эффективно управлять сотнями тысяч горутин, выполняющих I/O операции, используя лишь небольшое количество системных потоков. Для программиста код остается простым и синхронным, а вся асинхронная магия происходит "под капотом" в runtime.

Важное замечание: Этот механизм идеально работает для сетевых I/O. Для блокирующих файловых I/O (до Go 1.18) и вызовов через cgo, которые runtime не может перехватить, планировщик может прибегнуть к другой стратегии — временно создать новый системный поток, чтобы не блокировать текущий.