9.9 Опросчик сети
Исходные факты верифицированы по
src/runtime/netpoll.goи его платформенным реализациям (netpoll_epoll.go,netpoll_kqueue.goи другим), а также поsrc/internal/poll/fd_unix.go.
Сетевой код Go выглядит блокирующим: conn.Read просто «зависает» в ожидании данных.
Но если бы он действительно блокировал поток операционной системы, на котором выполняется,
то десять тысяч горутин, ожидающих данных из сети, удерживали бы десять тысяч потоков, и
модель M:N, тщательно выстроенная в 9.1, мгновенно рухнула бы. Возможность
сохранить блокирующий стиль и при этом обеспечить масштабируемость — заслуга опросчика
сети (netpoller). За ним стоит долгая история «как обслуживать огромное число соединений
с помощью горстки потоков». Этот раздел сначала излагает эту историю и определяющие оси
проектирования, а затем рассматривает, как Go скрывает зрелый механизм событий внутри
рантайма: программист пишет синхронный код, а под капотом работает событийно-ориентированный
ввод-вывод.
9.9.1 C10k и эволюция уведомлений о готовности
Около 2000 года Дэн Кегель сформулировал знаменитую проблему C10k: способен ли один сервер одновременно обрабатывать десять тысяч соединений? Его аргумент состоял в том, что аппаратное обеспечение того времени вполне справлялось с этой нагрузкой, а узким местом была стратегия ввода-вывода программного обеспечения. Наивная модель «одно соединение — один поток» не выдерживала: каждый поток резервирует стек размером в мегабайты, и десять тысяч потоков — это уже несколько гигабайт; сверх того — накладные расходы ядра на переключение между тысячами потоков быстро обваливали планировщик. Выход — мультиплексирование по готовности: несколько потоков обслуживают большое число файловых дескрипторов (fd), обращаясь к конкретному fd только тогда, когда он становится готовым.
Сам механизм уведомлений о готовности также прошёл путь эволюции. Удобнее всего выразить стоимость операций в нотации «O большое». Пусть число конкурентных соединений равно , а число готовых в данный момент — (обычно ):
select/poll: на вызов. Вызывающая сторона должна каждый раз передавать ядру весь набор fd; ядро линейно сканирует его, отмечая готовые, а после возврата вызывающая сторона снова сканирует результат в поисках нужных. Сам набор копируется туда и обратно между пространством пользователя и ядра, и при большом числе соединений это полное сканирование и копирование становится узким местом.epoll(Linux, Либенци, development-ядро 2.5.44 / 2002, stable 2.6.0 / 2003): набор интересующих fd регистрируется однократно в ядре черезepoll_ctlи хранится там постоянно, тогда какepoll_waitвозвращает только готовые fd, поэтому стоимость одного вызова составляет .
Распространённое неточное утверждение — «epoll работает за ». Более корректная формулировка: стоимость регистрации амортизируется через
epoll_ctl, а стоимостьepoll_waitпропорциональна числу готовых событий , а не общему числу fd . Именно это улучшает « на вызов» уselect/pollдо « на вызов», что в сценарии долгоживущих соединений при даёт разницу на порядок величины.
kqueue во FreeBSD (Лемон, USENIX FREENIX 2001) — ещё одна ветвь того же поколения,
причём более универсальная: он управляет не только сокетами, но и следит за файлами,
процессами, сигналами и таймерами, объединяя разнородные источники событий под единым
интерфейсом kevent. Windows избрала иной путь — IOCP. Здесь также важно одно
принципиальное различие:
- Уведомление по уровню (level-triggered): пока fd «ещё имеет данные для чтения», он сообщает об этом непрерывно; приложение вправе читать столько, сколько нужно.
- Уведомление по фронту (edge-triggered): сообщение приходит однократно — только
в момент перехода из состояния «не готов» в «готов»; приложение обязано дочитывать
данные до
EAGAIN, иначе следующего уведомления не придёт и оставшиеся данные будут потеряны.
Уведомление по фронту сокращает лишние пробуждения, перекладывая на приложение ответственность за «дочтение до конца». Это различие нам понадобится далее — одно из неочевидных решений Go сделано именно в этой точке.
9.9.2 Готовность или завершение: Reactor и Proactor
Всё изложенное выше описывает одну фундаментальную ось проектирования:
- Модель готовности (
select/poll, epoll, kqueue): ядро сообщает «этот fd готов», а вы выполняете неблокирующий ввод-вывод. - Модель завершения (Windows IOCP, Linux io_uring): вы говорите «выполни эту операцию ввода-вывода и уведоми меня по завершении»; ядро берёт на себя всю операцию, вы предоставляете буфер заранее и получаете событие завершения после.
Это в точности соответствует двум паттернам проектирования — Reactor (Шмидт, PLoPD 1995) и Proactor: первый диспетчеризует события готовности поверх синхронного демультиплексора (например, epoll), оставляя ввод-вывод приложению; второй диспетчеризует результат только после завершения операции. io_uring (Аксбое, ядро 5.1 / 2019) — современный представитель модели завершения: пара кольцевых очередей в разделяемой памяти (очередь отправки SQ и очередь завершения CQ) позволяет пакетно отправлять операции и пакетно получать результаты, сводя число системных вызовов к минимуму.
| Измерение | Модель готовности (Reactor) | Модель завершения (Proactor) |
|---|---|---|
| Что сообщает ядро | «Вы можете выполнить ввод-вывод» | «Ввод-вывод уже выполнен» |
| Буфер | предоставляется после получения готовности, может быть временным | предоставляется при отправке, должен быть закреплён |
| Представители | epoll, kqueue | IOCP, io_uring |
| Кто выполняет копирование | приложение (один неблокирующий read/write) | ядро |
Go в настоящее время не использует io_uring: его Linux-поллер по-прежнему работает через epoll (см. 9.9.5), по причинам, рассмотренным в разделе 9.9.7.
9.9.3 Подход Go: преобразование «блокировки» в «парковку»
Суть приёма такова: пользователю предъявляется блокирующая семантика, тогда как под капотом используется неблокирующий ввод-вывод в сочетании с уведомлениями о событиях. Когда горутина читает из сокета, а данные ещё не поступили, рантайм не позволяет потоку ждать впустую. Вместо этого он переводит fd в неблокирующий режим, регистрирует его в механизме событий, а затем паркует эту горутину, освобождая M для выполнения других G. Когда механизм событий сообщает о готовности fd, горутина пробуждается и продолжает чтение с того места, где остановилась.
sequenceDiagram
participant G as горутина
participant FD as internal/poll.FD
participant RT as поллер рантайма
participant M as M (поток ОС)
G->>FD: conn.Read(p)
FD->>FD: неблокирующий read(2), возвращает EAGAIN
FD->>RT: runtime_pollWait (режим чтения)
RT->>RT: netpollblock: устанавливает pd.rg в pdWait
RT->>G: gopark (waitReasonIOWait) паркует как _Gwaiting
RT->>M: освобождает M для выполнения других G
Note over RT: позже epoll/kqueue сообщает о готовности fd
RT->>RT: netpoll, затем netpollready собирает готовые G
RT->>G: пометить как Runnable, поставить в очередь
G->>FD: возобновление после read(2), на этот раз успешно
FD->>G: вернуть n, nil
На уровне рантайма internal/poll.FD служит мостом между net/os и поллером рантайма.
Ядро FD.Read — это цикл: сначала выполнить неблокирующий системный вызов, при EAGAIN
перейти в ожидание готовности, а после пробуждения продолжить через continue:
|
|
waitRead через слой runtime_pollWait входит в netpollblock рантайма. Здесь есть
один важный момент: перед парковкой функция сначала атомарно захватывает семафор pd.rg
из состояния pdNil в pdWait, и лишь после повторной проверки состояния ошибки под
блокировкой выполняет gopark, записывая причину ожидания как waitReasonIOWait.
Последовательность «захватить — перепроверить — запарковать» необходима, чтобы не потерять
уведомление о готовности, которое может прийти одновременно с попыткой парковки:
|
|
Когда fd становится готовым, платформенная реализация netpoll преобразует событие ядра в
режим чтения или записи и вызывает netpollready, извлекая соответствующего ожидателя и
добавляя его в gList, который возвращается планировщику для повторной постановки в очередь
(9.3, 9.4). Таким образом, тысячи горутин, «заблокированных»
в ожидании сети, фактически удерживают очень мало потоков, а стоимость ожидания ложится на
таблицу событий ядра. Программист пишет синхронный код и получает событийно-ориентированный
ввод-вывод.
9.9.4 pollDesc: конечный автомат готовности для каждого fd
Состояние ожидания обоих концов — читающего и пишущего — хранится в pollDesc: по одному
экземпляру на каждый отслеживаемый fd, выделяемому из специального pollcache (свободный
список в стиле fixalloc), а не из кучи GC. Поля, значимые для понимания дизайна, —
два симметричных набора для состояния чтения и записи:
|
|
rg/wg — это центральный элемент всего механизма. Они одновременно являются записью о
«кто ожидает» (хранят указатель G), флагом «уже готово» (pdReady) и меткой резервирования
перед парковкой (pdWait). Четыре состояния переходят друг в друга между четырьмя участниками —
читателем, уведомлением ввода-вывода, таймаутом и закрытием — посредством атомарных операций:
stateDiagram-v2
[*] --> pdNil
pdNil --> pdWait: читатель готовится к парковке
pdWait --> Gptr: gopark, сохранить указатель G
Gptr --> pdReady: уведомление netpollready пришло, пробудить G
pdWait --> pdReady: уведомление и парковка конкурируют, сначала сброс, затем пробуждение
pdNil --> pdReady: уведомление пришло раньше читателя
pdReady --> pdNil: читатель получил уведомление
Пара таймеров rt/wt обслуживает дедлайны. SetReadDeadline устанавливает rd в
будущий момент времени и взводит rt; когда время наступает, коллбэк таймера через
netpollunblock пробуждает ожидателя с ошибкой «timeout». Это и есть стык между
SetDeadline (9.10) и поллером: таймаут — не отдельный механизм, а
повторное использование таймеров чтения и записи, встроенных в тот же pollDesc.
9.9.5 Платформенные реализации, уведомление по фронту и стык с планировщиком
Поллер использует собственный механизм каждой операционной системы, сведённый к единому интерфейсу, реализуемому платформенными файлами:
|
|
Linux — netpoll_epoll.go (epoll), BSD и macOS — netpoll_kqueue.go (kqueue), Windows —
netpoll_windows.go (IOCP); помимо них есть реализации для solaris, aix, wasip1 и других.
Факт, заслуживающий прямого указания: Go использует уведомление по фронту как для epoll,
так и для kqueue. При регистрации netpoll_epoll.go включает EPOLLET в маску событий,
netpoll_kqueue.go — EV_CLEAR, а комментарий к платформонезависимому интерфейсу явно
гласит «установить уведомление по фронту для fd»:
|
|
Это противоречит распространённому предположению о том, что «Go использует epoll с
уведомлением по уровню». Уведомление по фронту выбрано с целью сократить лишние пробуждения:
единичная готовность сообщается только один раз, избегая повторных пробуждений G, пока fd
остаётся читаемым. Плата за это — необходимость дочитывать данные до EAGAIN; эта
требующая аккуратности обязанность естественным образом выполняется циклом из
9.9.3, который «паркует только при
EAGAIN и продолжает через continue после пробуждения» — всё прозрачно для пользователя.
Готовая горутина возвращается в очередь выполнения тремя путями:
- Активный опрос циклом планирования: когда
findRunnable(9.4) не находит ни локальной, ни глобальной работы, он вызываетnetpoll— иногда это быстрая неблокирующая проверка мимоходом, а иногда, когда работы совсем нет, — блокирующийnetpoll(delay)до прихода события или срабатывания таймера, заменяя им вращающийся вхолостую M. - Резервная проверка системного монитора: когда
sysmon(9.8) обнаруживает, что сеть не опрашивалась примерно 10 мс (в исходном коде буквальноlastpoll+10*1000*1000 < now), он выполняет неблокирующийnetpoll(0)для компенсации, инъектируя готовые G и закрывая окно, в котором все P заняты и никто не опрашивает сеть. - Срабатывание таймера: операции чтения и записи с дедлайном используют
rt/wtизpollDescдля пробуждения ожидателя в нужный момент (9.10).
9.9.6 Как это устроено у других
Рассмотрение Go в контексте эволюции асинхронного ввода-вывода позволяет чётче увидеть его особенности.
- Node.js / libuv: однопоточный событийный цикл по модели Reactor, где сетевой ввод-вывод мультиплексируется через epoll/kqueue/IOCP; файлы, не имеющие переносимого примитива готовности, обрабатываются libuv путём передачи блокирующих файловых операций в пул потоков (по умолчанию 4 потока). Структура «сокеты — через поллер, файлы — через потоки» поразительно близка к тому, что делает Go.
- Java NIO / Netty:
Selectorреализует Reactor, выбирая провайдер epoll/kqueue/IOCP в зависимости от платформы; Netty надстраивает ещё один Reactor с явными обработчиками (NioEventLoop), а его нативный Linux-транспортEpollEventLoopиспользует уведомление по фронту — в соответствии с выбором Go. - Rust tokio: I/O-драйвер построен на
mio(кросс-платформенной абстракции над epoll/kqueue/IOCP), транслируя события ОС в пробуждения задачFuture;async/awaitсвязывает стек вызовов воедино. - Erlang/BEAM: так же интегрирует опрос ввода-вывода в рантайм, однако здесь требуется уточнение: начиная с OTP 21 (2018) BEAM по умолчанию использует выделенные потоки опроса ввода-вывода вместо доставки событий потоками-планировщиками, поэтому «интегрированный в планировщик опрос, как у Go» — историческая, а не современная форма по умолчанию.
Общая черта всех перечисленных решений — открытый Reactor на стороне пользователя:
коллбэки (Node), Future (Rust), обработчики (Netty). Отличие Go — в том, что Reactor скрыт
под рантаймом, а пользователю предъявляется только синхронный блокирующий код: ни коллбэков,
ни async/await. Это обменивает часть сырой эффективности событийного цикла на
эргономическое удобство модели «одно соединение — одна горутина».
9.9.7 Почему файлы не проходят через поллер, и граница исследований
Не весь ввод-вывод может быть обработан через поллер. Обычный дисковый файл не может
отслеживаться через epoll на большинстве платформ: для обычного файла epoll_ctl завершается
ошибкой, и к тому же он почти «всегда готов», поэтому уведомление о готовности для него
лишено смысла (именно здесь срабатывает проверка fd.pd.pollable() в коде FD.Read из
9.9.3). Поэтому «блокирующие» операции
чтения и записи над файлами в Go по-прежнему используют блокирующие системные вызовы, со
страховкой в виде отдельных потоков: когда такой вызов удерживает M на длительное время,
sysmon отсоединяет P и передаёт его другому M (9.5). Это объясняет
распространённое наблюдение: большое число конкурентных сетевых соединений почти не увеличивает
число потоков, тогда как большое число конкурентных блокирующих файловых операций может
заставить счётчик потоков расти.
На границе исследований сохраняется напряжение. epoll несёт в себе давние проблемы, такие как
«thundering herd» (несколько ожидателей конкурируют за один fd), требующие компенсаций вроде
EPOLLEXCLUSIVE и SO_REUSEPORT; тонкости семантики его интерфейса и различие между
уведомлением по уровню и по фронту также долгое время вызывают критику. Модель завершения
io_uring привлекательна с точки зрения пропускной способности и задержек, однако она требует
заблаговременной передачи и закрепления буферов, а также управления владением выполняемыми
операциями — что плохо сочетается с синхронной моделью Go «буферы произвольно на стеке или
куче, одна операция на горутину в каждый момент времени». Это и есть фундаментальная причина,
по которой прямая интеграция io_uring в поллер Go представляет значительную сложность.
В сообществе давно существует предложение (golang/go#31908, «прозрачная поддержка io_uring»,
пока открыто / на стадии изучения), есть и сторонние библиотеки, однако стандартная библиотека
не имеет планов по замене epoll по сей день.
Выигрыш в производительности никогда не достаётся бесплатно. В итоге Go выбрал эргономику «одно соединение — одна горутина», сознательно уступая часть сырой производительности ради того, чтобы сетевой код читался как последовательная программа, — в соответствии с ориентацией, которой придерживается вся эта глава.
Дополнительная литература
- Dan Kegel. The C10K problem. 1999-2014. http://www.kegel.com/c10k.html
- Jonathan Lemon. “Kqueue: A Generic and Scalable Event Notification Facility.” USENIX ATC (FREENIX track) 2001, pp. 141-153. https://people.freebsd.org/~jlemon/papers/kqueue.pdf
- Davide Libenzi. Improving (network) I/O performance … (epoll). 2002. http://www.xmailserver.org/linux-patches/nio-improve.html
- Douglas C. Schmidt. “Reactor: An Object Behavioral Pattern for Concurrent Event Demultiplexing and Event Handler Dispatching.” PLoPD vol. 1, 1995. https://www.dre.vanderbilt.edu/~schmidt/PDF/Reactor.pdf
- Jens Axboe. Efficient IO with io_uring (Linux 5.1), 2019. https://kernel.dk/io_uring.pdf
- libuv. Design overview / Thread pool. https://docs.libuv.org/en/v1.x/design.html
- Erlang/OTP. I/O Polling options in OTP 21, 2018. https://blog.erlang.org/IO-Polling/
- golang/go#31908. internal/poll: transparently support new linux io_uring interface. https://github.com/golang/go/issues/31908
- The Go Authors. runtime/netpoll.go, netpoll_epoll.go, netpoll_kqueue.go, internal/poll/fd_unix.go. https://github.com/golang/go/tree/master/src/runtime