4. epoll, kqueue и netpoller
Epoll, Kqueue и Netpoller: асинхронное сердце Go
В предыдущих статьях мы подробно разобрали природу системных вызовов ядра (1. Системные вызовы и их стоимость), классифицировали узкие места дискового и сетевого ввода-вывода (2. IO bottlenecks) и препарировали физическую структуру сетевой задержки (3. Network latency). Теперь настало время изучить механизм, который является подлинным технологическим фундаментом успеха Go в мире высоконагруженных серверных систем.
Этот механизм — сетевой поллер (netpoller). В его основе лежат передовые системные API мультиплексирования ввода-вывода современных операционных систем: epoll в Linux и kqueue в macOS и BSD-системах.
Без netpoller модель конкурентности Go была бы невозможна в её нынешнем виде. Если бы каждая горутина, ожидающая сетевых байтов от клиента или базы данных, вызывала блокирующий системный вызов ядра, операционной системе для обслуживания 50 000 одновременных WebSocket-соединений потребовалось бы создать 50 000 тяжелых потоков ядра (OS threads). Планировщик Linux CFS захлебнулся бы в переключениях контекста, а память исчерпалась бы стеками потоков (по 2–8 МБ на поток).
Вместо этого сетевой поллер позволяет горстке системных потоков M (обычно равной числу физических ядер процессора GOMAXPROCS) асинхронно обслуживать сотни тысяч горутин. Для разработчика код выглядит абсолютно синхронным и линейным, но под капотом рантайм Go дирижирует сложнейшим асинхронным реактором событий.
Эта статья — глубокое погружение в устройство асинхронного ввода-вывода: от системных вызовов ядра (epoll_create, epoll_ctl, epoll_wait) и их аналогов в BSD (kqueue, kevent) до структур данных pollDesc в рантайме Go и их взаимодействия с планировщиком горутин (1. Scheduler Go. G-M-P модель).
Зачем нужен epoll/kqueue: проблема select/poll
Чтобы оценить элегантность epoll, обратимся к истории Unix. Исторически первыми механизмами мультиплексирования ввода-вывода были системные вызовы select (появился в 4.2BSD) и poll (System V).
Оба механизма страдали от фундаментального архитектурного порока: отсутствия памяти состояний в ядре (stateless model). При каждом вызове select или poll пользовательский процесс обязан:
- Скопировать из пространства пользователя в ядро полный битовый массив или массив структур со всеми десятками тысяч отслеживаемых файловых дескрипторов.
- Ядро линейным циклом последовательно обходит весь список дескрипторов от первого до последнего ($O(n)$), опрашивая внутренние драйверы на предмет готовности данных.
- Процесс усыпляется, а при пробуждении ядро снова перезаписывает массив и копирует его обратно в userspace.
- Программа вынуждена заново обойти весь массив в userspace ($O(n)$), чтобы найти, какой именно из 10 000 сокетов готов к чтению.
Представьте себе ресторан на 10 000 столиков и одного официанта. При модели select официант обязан непрерывно обходить все 10 000 столиков по кругу, подходя к каждому гостю и спрашивая: «Вы готовы сделать заказ?». Даже если заказ готов сделать всего один посетитель за вечер, официант потратит 99.9% сил и времени на бессмысленный обход пустых столиков. Сложность $O(n)$ делала обслуживание более 1024 соединений (лимит FD_SETSIZE) архитектурно невозможным (знаменитая проблема C10K).
Epoll (внедрен в ядре Linux 2.6) и kqueue (появился во FreeBSD 4.1) решили эту проблему радикально, применив событийно-ориентированную модель с сохранением состояния в ядре (stateful event-driven model):
- В нашем ресторане на каждый столик установили электронную кнопку с лампочкой.
- Приложение один раз регистрирует интересующие дескрипторы и интересующие события (чтение, запись) через
epoll_ctl(или вызовkeventс макросомEV_SETдля регистрации фильтра в kqueue). - Ядро сохраняет эти дескрипторы во внутренней структуре данных (сбалансированное красно-чёрное дерево в epoll).
- Когда на сокет приходят данные, драйвер сетевой карты помещает дескриптор в отдельный список готовых событий (ready list).
- Последующие системные вызовы ожидания (
epoll_wait,kevent) возвращают только те дескрипторы, которые реально готовы к обработке. Нет необходимости каждый раз передавать ядру полный список дескрипторов.
Сложность выборки готовых событий упала с $O(n)$ до строгого $O(1)$ относительно общего числа соединений.
Сравнительная таблица эволюции мультиплексирования:
| Характеристика | select/poll | epoll | kqueue |
|---|---|---|---|
| Передача списка дескрипторов | При каждом системном вызове | Один раз при регистрации (epoll_ctl) |
Один раз при регистрации (kevent) |
| Сложность ожидания | $O(n)$ от числа всех соединений | $O(1)$ по числу готовых событий | $O(1)$ по числу готовых событий |
| Лимит дескрипторов на процесс | Ограничена FD_SETSIZE (1024 для select) |
До миллионов (ограничено памятью ядра) | До миллионов (ограничено памятью ядра) |
| Режимы срабатывания (Triggers) | Только Level-triggered | Level- и Edge-triggered (EPOLLET) |
Level- и Edge-triggered (EV_CLEAR) |
| Поддерживаемые платформы | Любые POSIX-системы | Linux | macOS, FreeBSD, OpenBSD, NetBSD |
Epoll в Linux: ядерная кухня
Системные вызовы epoll
Интерфейс подсистемы epoll в ядре Linux опирается на три ключевых системных вызова:
-
epoll_create1(flags)
Создает экземпляр подсистемы epoll и возвращает специальный файловый дескрипторepfd. В пространстве ядра аллоцируется структураstruct eventpoll, содержащая:- Сбалансированное красно-чёрное дерево (
rbr_nodes), в котором хранятся все зарегистрированные для отслеживания сокеты. - Двусвязный кольцевой список готовых событий (
rdllist— ready list). - Очередь ожидания потоков (
wait_queue_head_t).
ФлагEPOLL_CLOEXECгарантирует закрытие дескриптора при вызовеexecve.
- Сбалансированное красно-чёрное дерево (
-
epoll_ctl(epfd, op, fd, event)
Служит для управления деревом дескрипторов. Параметрopпринимает команды:EPOLL_CTL_ADD— зарегистрировать файловый дескрипторfdв экземпляреepfd.EPOLL_CTL_MOD— изменить битовую маску отслеживаемых событий дляfd.EPOLL_CTL_DEL— удалить сокет из отслеживания (автоматически выполняется ядром при закрытии дескриптора черезclose).
В структуреstruct epoll_eventзадается битовая маска интересующих событий:EPOLLIN(доступны данные для чтения),EPOLLOUT(буфер отправки свободен для записи),EPOLLRDHUP(удаленный узел закрыл соединение),EPOLLERR(ошибка на сокете) иEPOLLHUP(обрыв связи). Также можно выставить флагEPOLLET— переход в edge-triggered режим.
-
epoll_wait(epfd, events, maxevents, timeout)
Переводит вызывающий поток в режим ожидания событий. Если список готовых дескрипторовrdllistпуст, поток засыпает на времяtimeout(или бессрочно приtimeout = -1). Как только сетевая карта доставляет данные, ядро переносит готовые дескрипторы в пользовательский массивeventsи возвращает их точное количество.
Edge-triggered vs Level-triggered
Это ключевое разделение режимов уведомлений:
- Level-triggered (LT, триггер по уровню — режим по умолчанию):
Системный вызовepoll_waitбудет возвращать дескриптор как «готовый» на каждом вызове до тех пор, пока в буфере сокета ядра остается хотя бы один непрочитанный байт. Этот режим интуитивен, но порождает лишние системные вызовы и пробуждения, если приложение читает данные небольшими порциями. - Edge-triggered (ET, триггер по фронту / изменению состояния, флаг
EPOLLET):
Ядро генерирует событие готовности ровно один раз — в момент физического перехода состояния сокета из «нет данных» в «появились данные». Если приложение прочитало лишь часть данных и снова уснуло вepoll_wait, ядро не разбудит его повторно для старых данных! Приложение обязано вычитывать сокет в цикле до тех пор, пока системный вызовreadне вернет код ошибкиEAGAIN(илиEWOULDBLOCK).
Язык Go использует именно edge-triggered epoll (EPOLLET). Это сознательное архитектурное решение команды рантайма: режим ET минимизирует количество вызовов epoll_wait и полностью исключает шум паразитных повторных уведомлений.
Kqueue в macOS и BSD
В операционных системах семейства BSD и macOS аналогом epoll является подсистема kqueue. Ее интерфейс концептуально универсальнее:
kqueue()— создает очередь событий в ядре и возвращает дескриптор очередиkq.kevent(kq, changelist, nchanges, eventlist, nevents, timeout)— многофункциональный системный вызов, объединяющий функционал регистрации и ожидания. Если передан массивchangelist, ядро регистрирует изменения (аналогepoll_ctl). Если передан буферeventlist, вызов ожидает готовности событий и заполняет его (аналогepoll_wait).
Kqueue аппаратно спроектирован вокруг концепции «фильтров событий» (EVFILT_READ, EVFILT_WRITE, фильтры файловой системы vnode, процессов, сигналов и таймеров ядра). Внутри ядро использует эффективную хэш-таблицу зарегистрированных дескрипторов, обеспечивая ту же скорость $O(1)$.
В исходном коде рантайма Go различия между операционными системами аккуратно изолированы: пакет src/runtime/netpoll.go описывает общий интерфейс, а платформенная специфика скрыта в файлах netpoll_epoll.go (Linux), netpoll_kqueue.go (macOS/BSD), netpoll_windows.go (IOCP) и netpoll_aix.go.
Архитектура netpoller в Go
Netpoller — это специализированная подсистема рантайма Go, бесшовно соединяющая горутины, спящие в ожидании сетевого ввода-вывода, с системными очередями epoll/kqueue ядра ОС.
Основные структуры данных
pollDesc(дескриптор опроса):
Центральная структура, ассоциированная с каждым открытым сокетом. Она хранит:- Ссылку на файловый дескриптор ядра
fd uintptr. - Указатель на горутину, ожидающую чтения (
rg pdReady / *g). - Указатель на горутину, ожидающую записи (
wg pdReady / *g). - Метки сетевых дедлайнов и таймеров отмены.
- Ссылку на файловый дескриптор ядра
pollCache:
Высокопроизводительный lock-free кэш структурpollDesc, организованный в виде связанных страниц (chunks) памяти, что исключает обращение к глобальному аллокатору памяти Go при открытии новых сокетов.epfd(илиkq):
Глобальный файловый дескриптор инстанса epoll/kqueue, создаваемый один раз на весь процесс Go в функцииruntime.netpollinit.
Инициализация
При старте рантайма вызывается runtime.netpollinit() (или netpollinit()). В Linux она делает epoll_create1(EPOLL_CLOEXEC), в macOS — kqueue(). Созданный дескриптор сохраняется в глобальной переменной epfd. Для пробуждения спящего netpoller создается неблокирующий pipe или счетчик eventfd, который также добавляется в epoll.
Регистрация и ожидание: жизненный цикл горутины
Проследим по шагам, как выполняется операция conn.Read(b) в Go:
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
Start["Горутина: conn.Read"]:::system --> ReadSys["syscall.Read (неблокирующий)"]
ReadSys --> DataReady{"Данные готовы?"}
DataReady -- "Да" --> CopyData["Копирование в b"]
DataReady -- "Нет, EAGAIN" --> GetPD["Получить pollDesc по fd"]
GetPD --> WaitReg["Сохранить горутину в pollDesc.rg"]
WaitReg --> EpollCtl["epoll_ctl: ADD fd с EPOLLIN|EPOLLET"]
EpollCtl --> Gopark["gopark: парковка горутины"]
Gopark --> Sched["Планировщик: другая горутина"]
CopyData --> Return["Горутина продолжается"]
Return --> Start2["Следующие вызовы"]
- Неблокирующий вызов (Fast Path):
Любой сокет в Go, созданный черезnet.Dialилиnet.Listen, сразу переводится рантаймом в неблокирующий режим с флагомO_NONBLOCKчерез вызовsyscall.SetNonblock(fd, true). Методconn.Read(b)сразу делает системный вызовread(fd). Если данные уже лежат в буфере сокета, вызов возвращает байты немедленно. Никаких переключений горутин не происходит. - Обработка
EAGAIN:
Если в сокете пусто, системный вызов возвращает ошибкуEAGAIN. Рантайм черезruntime.pollDescполучает указатель наpollDesc, связанный с этим fd. -
Регистрация в epoll:
При первом обращении к сокету вызываетсяnetpollopen(fd, pd):C epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev)где
ev.events = EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET. -
Парковка горутины (
gopark):
В полеpollDesc.rgатомарно записывается указатель на текущую горутину G. Затем вызывается внутренняя функцияgopark(netpollblockcommit, ...). Горутина переходит из состояния_Grunningв состояние_Gwaitingс причиной ожиданияwaitReasonIOWait. - Освобождение потока M:
Поток ОС (M) не уходит в сон! Он отпускает заснувшую горутину и немедленно берет следующую готовую задачу из очереди локального процессора P.
sequenceDiagram
participant G as Горутина
participant RT as Рантайм Go
participant EP as Epoll/Kqueue
participant OS as Ядро ОС
G->>RT: conn.Read
RT->>OS: read(fd) (неблокирующий)
OS-->>RT: EAGAIN
RT->>EP: epoll_ctl(ADD, fd, EPOLLIN)
RT->>G: gopark (горутина засыпает)
Note over G: _Gwaiting
OS-->>EP: Данные пришли, fd готов
RT->>EP: netpoll() (epoll_wait)
EP-->>RT: fd готов
RT->>G: goready (горутина в очередь)
G->>RT: conn.Read (продолжение)
RT->>OS: read(fd)
OS-->>RT: данные
RT-->>G: результат
Где вызывается netpoll()
Как и кто опрашивает epoll_wait, если все потоки M заняты выполнением других горутин? В рантайме Go функция опроса netpoll() вызывается в двух стратегических точках планировщика:
- Планировщик при поиске работы (
findRunnable()):
Когда процессор P исчерпал свою локальную очередьrunq, проверил глобальную очередь и безуспешно попытался украсть работу у соседей (3. Work stealing), он вызываетnetpoll(false)(неблокирующий опрос epoll с нулевым таймаутом). Если в сети есть готовые сокеты,netpoll(false)возвращает список проснувшихся горутин. Одна из них сразу берется на исполнение, а остальные помещаются в очередьrunq. - Фоновый поток системного монитора (
sysmon):
Если все процессоры P заняты непрерывными вычислительными CPU-задачами и не заходят вfindRunnable(), сетевые события рискуют зависнуть. Для предотвращения этой проблемы поток-надзирательsysmon, работающий без процессора P, периодически (каждые 10–20 мс) проверяет сетевой поллер. Он вызываетnetpoll(true)с бесконечным таймаутом, чтобы гарантированно пробуждать горутины, ожидающие сеть, находит проснувшиеся горутины, переводит их функциейgoreadyв состояние_Grunnableи инжектирует их в глобальную очередь планировщика.
Закрытие и ошибки
Когда сетевое соединение закрывается удаленной стороной или происходит аппаратный сброс (EPOLLERR, EPOLLHUP, EPOLLRDHUP), дескриптор epoll точно так же генерирует событие готовности. Netpoller пробуждает горутину, ожидающую чтения. При повторном read вызов возвращает 0 (сигнал io.EOF) или системную ошибку ECONNRESET.
При закрытии сокета в Go (conn.Close()) вызывается функция netpollclose(pd), которая инвалидирует pollDesc и очищает связи, предотвращая утечки дескрипторов.
Диагностика netpoller
Для мониторинга эффективности работы сетевого поллера Go-инженеру доступны следующие инструменты:
1. Execution tracer
Трассировщик рантайма Go (3. execution tracer) — эталонный визуальный инструмент для анализа netpoller:
- На временной диаграмме наглядно отображаются горутины в статусе
Waitingс пометкойNetwork I/O. - Видны моменты вызова функции
netpollпланировщиком и потокомsysmon. - Позволяет мгновенно выявить ситуации, когда сетевой поллер вовремя не опрашивается из-за монополизации CPU тяжелыми циклами.
2. Block profile
Профиль блокировок (5. block profile) фиксирует время, проведенное горутинами в ожидании внешних событий:
- Если в топе отчета доминирует функция
runtime.netpollblock, это означает, что сервис уперся в задержки внешних сетевых зависимостей.
3. GODEBUG=schedtrace
Запуск приложения с параметром трассировки:
GODEBUG=schedtrace=1000,scheddetail=1 ./my_server
Выводит в консоль ежесекундный срез состояния:
- Параметр
waiting— суммарное число горутин, ожидающих в netpoller и на примитивах синхронизации. - Позволяет отследить пробуждения потоков при сетевых всплесках.
4. Статистика ОС
ss -ti— показываетrtt,rto, размер очередей сокетов (Recv-QиSend-Q) и потери.netstat -s— агрегированная статистика стека TCP/IP ядра (ошибки, сброшенные пакеты).perf topилиperf record -e syscalls:sys_enter_epoll_pwait— замеряет частоту и длительность вызововepoll_waitна уровне ядра Linux.
Преимущества и ограничения модели netpoller
Преимущества
- Колоссальная масштабируемость по памяти:
Вместо 2–8 МБ на поток ОС горутина в ожидании сети требует всего 2 КБ памяти стека. Сервер на Go с 8 ГБ RAM может спокойно удерживать миллион открытых неактивных WebSocket-соединений. - Нулевые затраты на смену контекста ОС:
Парковка и пробуждение горутины происходят в userspace за 10–15 наносекунд без переключения колец защиты CPU (Ring 3 → Ring 0). - Синхронный стиль кода:
Инженеру не нужно писать спагетти из callback-функций, промисов или конечных автоматов (как в Node.js или старом C++). Код пишется последовательно, от строчки к строчке.
Ограничения
- Неприменимость к файловому вводу-выводу:
Дисковые операции (os.File.Read) блокируют поток M, потому что стандартная библиотека не использует epoll для файлов (в ядре Linux обычные файлы на диске не поддерживают полноценныйepoll— дескриптор файла всегда возвращаетEPOLLINи считается «готовым», хотя реальное чтение блокирует поток на миллисекунды). Поэтому файловый ввод-вывод в Go вызывает hand-off потоков M (1. Системные вызовы и их стоимость). Полноценный асинхронный дисковый ввод-вывод требует интерфейсаio_uring. - Опасность задержки пробуждения при загрузке CPU:
Если в приложении запущено множество длинных вычислительных горутин без точек прерывания, опросnetpollможет задерживаться до 10 мс (период работыsysmon), увеличивая хвостовую задержку p99.
Mechanical Sympathy: что внутри epoll и как это влияет на производительность
- Память структур ядра:
Каждый сокет, добавленный в epoll, создает в пространстве ядра структуруstruct epitemразмером около 160 байт. На миллион соединений ядро Linux выделит около 160 МБ невыгружаемой оперативной памяти (SLAB/SLUB аллокатор) под структурыeventpoll. - Локальность кэша L1/L2 при обработке softirq:
Когда сетевой пакет поступает на сетевой адаптер, прерывание обрабатывается на конкретном ядре процессора, определенном маской RSS (Receive Side Scaling). На этом же ядре запускается softirq и выполняетсяep_poll_callback(). Если поток рантайма Go M, вызвавшийepoll_wait, работает на другом ядре того же или соседнего процессорного сокета (NUMA), структуры событий будут передаваться по шине межъядерного взаимодействия с задержкой в десятки тактов.
Заключение
Подсистема netpoller — это шедевр системного проектирования в рантайме Go. Она объединяет бескомпромиссную мощь событийных механизмов ядра (epoll и kqueue) с непревзойденной элегантностью и простотой модели горутин.
Понимание того, как устроены структуры pollDesc, как переплетаются фазы парковки gopark и пробуждения findRunnable/sysmon, дает Senior-инженеру четкое понимание того, почему сетевые микросервисы на Go демонстрируют феноменальную плотность и производительность.
Далее мы перейдем к технологиям, которые позволяют еще сильнее снизить накладные расходы при передаче сетевых и дисковых данных, полностью исключая промежуточное копирование байт в памяти: 5. Zero copy подходы.