Погружение в libuv. Часть 2. Неблокирующий ввод-вывод.

Погружение в libuv. Часть 2. Неблокирующий ввод-вывод.

Anastasia Kotova

Предыдущие части

Погружение в libuv. Часть 1. Зачем он нужен?


Как бы нам ни хотелось пройти по лёгкому пути, чтобы разобраться, как работает Event Loop в libuv и на чём вообще держится Node.js, — придётся чуть-чуть углубиться в устройство операционных систем. Без этого epoll, kqueue и IOCP будут выглядеть как заклинания из курса чёрной магии для сисадминов. А мы хотим попробовать разобраться, что там под капотом.

Как ОС обрабатывает сетевые запросы

Представим, что у нас есть сервер на Node.js, который принимает кучу запросов. Но по факту, "сервер" — это не только Node.js или Java. Это ещё и операционная система, на которой работает процесс нашего приложения. И чтобы понять, как обрабатываются запросы, надо сначала понять, как на уровне ОС это всё выглядит.

Каждый новый запрос от клиента — это сокет.

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

В UNIX-системах почти всё представлено как файл, и сокеты — не исключение. Они тоже получают файловые дескрипторы.

Файловые дескрипторы (FD) — это числа, уникальные идентификаторы, с помощью которых операционная система отслеживает, какие ресурсы (включая сокеты) открыты у конкретного процесса.

Когда сервер получает N клиентских подключений, процесс Node.js имеет у себя N файловых дескрипторов — по одному на каждый активный сокет. Через них он может читать и писать данные.

Проблема масштабируемости и блокировки

В какой-то момент возникает вопрос: а как читать данные из всех этих сокетов одновременно?

Один из вариантов — выделять под каждый сокет отдельный поток. Но у потоков есть накладные расходы: каждому нужен отдельный стек в памяти, плюс операционная система тратит ресурсы на переключение контекста. В результате количество одновременно обрабатываемых запросов будет ограничено максимальным числом потоков, которое система способна выдержать.

Поэтому появился альтернативный подход: использовать один поток, но сделать так, чтобы чтение и запись не блокировали его выполнение. Именно так и работает неблокирующий ввод-вывод (non-blocking I/O), на котором построен Event Loop в Node.js, и который реализован внутри libuv.

Как устроен неблокирующий ввод-вывод

Чтобы сделать дескриптор неблокирующим, ему нужно установить специальный флаг O_NONBLOCK. Тогда при попытке прочитать из сокета, в котором ещё нет данных, операция read() не зависнет, а сразу вернёт ошибку (EAGAIN или EWOULDBLOCK). Это позволяет не терять время в ожидании и переключиться на другие задачи.

Но просто опрашивать дескрипторы в цикле — тоже не выход. Это приведёт к активному ожиданию (busy waiting), когда CPU будет занят бесполезной работой.

Чтобы избежать этого, придумали механизм подписки на события — когда ядро ОС само скажет процессу, что какие-то дескрипторы стали "готовы" к чтению или записи.

От select до epoll: эволюция I/O-механизмов

Изначально в UNIX появился системный вызов select().

Системный вызов (syscall) — обращение программы к ядру операционной системы для выполнения какой-либо операции.

Он принимает список дескрипторов, которые нужно отслеживать. Ядро опрашивает их и возвращает те, которые готовы. Но select имеет ограничения:

  • Ограничение на количество дескрипторов (~1024).
  • Каждый вызов select() требует копирования массивов туда-обратно между user space (пространство нашего процесса) и kernel space (пространство ядра).
  • Сложность O(n), так как ядро перебирает все дескрипторы.

Потом появился poll() — убрал ограничение в 1024 FD, но логика осталась той же: массив дескрипторов + перебор в ядре.

Решением стал epoll (в Linux) и kqueue (в BSD/macOS):

  • Они позволяют один раз создать очередь, используя epoll_create1()/kqueue();
  • Зачем зарегистрировать интересующие дескрипторы через epoll_ctl()/kevent();
  • А потом просто вызывать epoll_wait()/kevent() и получать готовые события.
  • Это не один системный вызов, а полноценный интерфейс, где ядро уже само ведёт очередь отслеживаемых дескрипторов.
  • Работает за амортизированное O(1), потому что не требует полного перебора.

IOCP и модель завершения операций в Windows

На Windows всё работает немного иначе. Там есть select() и WSAPoll() (аналог poll()), но они тоже медленные.

Поэтому была создана система IOCP — Input/Output Completion Ports. Это асинхронный механизм, в котором:

  • Программа заранее даёт ядру буфер и просит выполнить операцию (ReadFileEx(), WSARecv() и т.д.).
  • Ядро само читает или пишет данные, и потом сигнализирует через порт, что операция завершена.

Ключевое отличие IOCP от kqueue и epoll — в используемой модели асинхронного ввода-вывода. В случае с epoll и kqueue это ready-based модель: они сообщают программе, что определённый дескриптор готов к чтению или записи, но само чтение или запись должна выполнить программа, а не ядро.

В случае с IOCP используется completion-based модель. Здесь ядро самостоятельно выполняет операцию — например, читает данные из сокета в буфер — и только после завершения уведомляет приложение, что всё готово и отдает данные из буфера.

Именно из-за столь разных подходов к асинхронному I/O в UNIX-системах и Windows появилась библиотека libuv. Она абстрагирует различия между этими моделями и предоставляет единое API для неблокирующего ввода-вывода, позволяя Node.js работать одинаково стабильно на всех платформах.

Почему нужен пул потоков в libuv

Если epoll и kqueue такие классные, зачем тогда в libuv есть ещё и пул потоков?

А потому что... файловая система!

Дело в том, что в POSIX-системах обычные файлы (не сокеты) не работают с epoll/kqueue:

  • Ядро считает, что файл всегда готов к чтению — он не может "ожидать" события.
  • Даже если выставить флаг O_NONBLOCK для дескриптора, read() из файла всё равно будет блокировать поток.

У Windows всё лучше — IOCP умеет работать с файлами асинхронно.

В Linux же появился io_uring — новый интерфейс, который позволяет делать неблокирующий ввод-вывод даже с файлами, но он пока не стал стандартом.

Поэтому в libuv сделали компромисс: все потенциально блокирующие операции (работа с файлами, DNS и т.д.) выносятся в отдельный пул потоков. Так основной поток с Event Loop остаётся свободным.

Ограничения

Конечно, даже у неблокирующего ввода-вывода есть свои ограничения:

Лимит на количество дескрипторов

У каждого процесса есть ограничение на количество открытых файловых дескрипторов (ulimit).

Его можно увеличить, но всё равно нужно учитывать.

Файлы не всегда поддерживают неблокирующий I/O

Как уже рассмотрели выше — не все типы дескрипторов можно отдать epoll'у.

Загрузка CPU

Даже с подпиской, программе всё равно нужно обрабатывать события, дергать read()/write(), обрабатывать мелкие порции данных. Это не "бесплатно".


Тем не менее, именно такой подход позволяет Node.js справляться с тысячами одновременных соединений без использования многопоточности. И всё это спрятано внутри кросплатформенной библиотеки libuv.


Следующие части

Погружение в libuv. Часть 3. Опять Event Loop.

Погружение в libuv. Часть 4. Другие функции.

Report Page