Go under the hood
Go: Under the Hood

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 большое». Пусть число конкурентных соединений равно nn, а число готовых в данный момент — kk (обычно k≪nk \ll n):

  • select / poll: O(n)O(n) на вызов. Вызывающая сторона должна каждый раз передавать ядру весь набор fd; ядро линейно сканирует его, отмечая готовые, а после возврата вызывающая сторона снова сканирует результат в поисках нужных. Сам набор копируется туда и обратно между пространством пользователя и ядра, и при большом числе соединений это полное сканирование и копирование становится узким местом.
  • epoll (Linux, Либенци, development-ядро 2.5.44 / 2002, stable 2.6.0 / 2003): набор интересующих fd регистрируется однократно в ядре через epoll_ctl и хранится там постоянно, тогда как epoll_wait возвращает только готовые fd, поэтому стоимость одного вызова составляет O(k)O(k).

Распространённое неточное утверждение — «epoll работает за O(1)O(1)». Более корректная формулировка: стоимость регистрации амортизируется через epoll_ctl, а стоимость epoll_wait пропорциональна числу готовых событий kk, а не общему числу fd nn. Именно это улучшает «O(n)O(n) на вызов» у select/poll до «O(k)O(k) на вызов», что в сценарии долгоживущих соединений при k≪nk \ll n даёт разницу на порядок величины.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// internal/poll/fd_unix.go: скелет дизайна FD.Read (сокращено)
func (fd *FD) Read(p []byte) (int, error) {
    // ...захватить блокировку чтения, подготовить pollDesc...
    for {
        n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p)
        if err != nil {
            n = 0
            // неблокирующее чтение не получило данных, fd управляется поллером: ждать готовности, затем повторить
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }
        }
        return n, fd.eofError(n, err)
    }
}

waitRead через слой runtime_pollWait входит в netpollblock рантайма. Здесь есть один важный момент: перед парковкой функция сначала атомарно захватывает семафор pd.rg из состояния pdNil в pdWait, и лишь после повторной проверки состояния ошибки под блокировкой выполняет gopark, записывая причину ожидания как waitReasonIOWait. Последовательность «захватить — перепроверить — запарковать» необходима, чтобы не потерять уведомление о готовности, которое может прийти одновременно с попыткой парковки:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// runtime/netpoll.go: скелет дизайна netpollblock (сокращено)
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }
    for {
        if gpp.CompareAndSwap(pdReady, pdNil) {
            return true // уведомление пришло первым, парковка не нужна
        }
        if gpp.CompareAndSwap(pdNil, pdWait) {
            break       // захват успешен, готовы к парковке
        }
        // иначе состояние было изменено конкурентно, повторить
    }
    // перепроверить ошибку перед фактической уступкой, перевести G в _Gwaiting, освободить M
    if waitio || netpollcheckerr(pd, mode) == pollNoError {
        gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceBlockNet, 5)
    }
    // ...после пробуждения очистить семафор и вернуть признак реальной готовности...
}

Когда fd становится готовым, платформенная реализация netpoll преобразует событие ядра в режим чтения или записи и вызывает netpollready, извлекая соответствующего ожидателя и добавляя его в gList, который возвращается планировщику для повторной постановки в очередь (9.3, 9.4). Таким образом, тысячи горутин, «заблокированных» в ожидании сети, фактически удерживают очень мало потоков, а стоимость ожидания ложится на таблицу событий ядра. Программист пишет синхронный код и получает событийно-ориентированный ввод-вывод.

9.9.4 pollDesc: конечный автомат готовности для каждого fd

Состояние ожидания обоих концов — читающего и пишущего — хранится в pollDesc: по одному экземпляру на каждый отслеживаемый fd, выделяемому из специального pollcache (свободный список в стиле fixalloc), а не из кучи GC. Поля, значимые для понимания дизайна, — два симметричных набора для состояния чтения и записи:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// runtime/netpoll.go: значимые для дизайна поля pollDesc (сокращено)
type pollDesc struct {
    fd  uintptr        // связанный файловый дескриптор, неизменен за время жизни

    // rg и wg — два бинарных семафора, паркующих горутины-читатели и горутины-писатели соответственно.
    // значения: pdReady (уведомление о готовности ожидает получения) / pdWait (подготовка к парковке) / указатель G (запаркована) / pdNil
    rg  atomic.Uintptr
    wg  atomic.Uintptr

    lock mutex          // защищает следующие поля
    rt   timer          // таймер дедлайна чтения
    rd   int64          // дедлайн чтения (будущее значение nanotime, -1 после истечения)
    wt   timer          // таймер дедлайна записи
    wd   int64          // дедлайн записи
}

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 Платформенные реализации, уведомление по фронту и стык с планировщиком

Поллер использует собственный механизм каждой операционной системы, сведённый к единому интерфейсу, реализуемому платформенными файлами:

1
2
3
4
5
// runtime/netpoll.go: интерфейс, который обязана реализовать каждая платформа (сводный комментарий)
//   netpollinit()                              // инициализировать поллер
//   netpollopen(fd uintptr, pd *pollDesc) int32 // зарегистрировать fd, установить уведомление по фронту
//   netpoll(delta int64) (gList, int32)        // получить готовые события, собрать G через netpollready
//   netpollclose(fd uintptr) int32             // отменить регистрацию fd

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»:

1
2
3
4
5
6
// runtime/netpoll_epoll.go: регистрация fd (сокращено)
ev.Events = linux.EPOLLIN | linux.EPOLLOUT | linux.EPOLLRDHUP | linux.EPOLLET
//                                                              ^^^^^^^^^ уведомление по фронту

// runtime/netpoll_kqueue.go: установить фильтры для чтения и записи с EV_CLEAR (сокращено)
ev[0].flags = _EV_ADD | _EV_CLEAR // EV_CLEAR — семантика уведомления по фронту в kqueue

Это противоречит распространённому предположению о том, что «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 выбрал эргономику «одно соединение — одна горутина», сознательно уступая часть сырой производительности ради того, чтобы сетевой код читался как последовательная программа, — в соответствии с ориентацией, которой придерживается вся эта глава.

Дополнительная литература

  1. Dan Kegel. The C10K problem. 1999-2014. http://www.kegel.com/c10k.html
  2. 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
  3. Davide Libenzi. Improving (network) I/O performance … (epoll). 2002. http://www.xmailserver.org/linux-patches/nio-improve.html
  4. 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
  5. Jens Axboe. Efficient IO with io_uring (Linux 5.1), 2019. https://kernel.dk/io_uring.pdf
  6. libuv. Design overview / Thread pool. https://docs.libuv.org/en/v1.x/design.html
  7. Erlang/OTP. I/O Polling options in OTP 21, 2018. https://blog.erlang.org/IO-Polling/
  8. golang/go#31908. internal/poll: transparently support new linux io_uring interface. https://github.com/golang/go/issues/31908
  9. 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