Погружение в 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.