Конкурентность · 12 мин
Контент v0.12.0Событийная модель и epoll
Поток на соединение и где он ломается
Самый простой сетевой сервер устроен так: принял соединение — создал для него поток, поток читает запрос, обрабатывает, отвечает. Код линейный и понятный. Так работали первые веб-серверы и до сих пор работают многие серверы баз данных (PostgreSQL вообще заводит отдельный процесс на подключение).
Теперь представьте чат или биржевой фид: 10 000 клиентов держат соединения открытыми одновременно. Это 10 000 потоков ОС. Что именно станет дорого?
Память. Каждому потоку нужен стек. В Linux по умолчанию под стек потока
резервируется 8 МБ, итого 80 ГБ. Но это виртуальная резервация, а не занятая
RAM: как мы видели в главе про память в проде, в RSS
попадают только реально тронутые страницы. Поток, который сидит в read(),
трогает несколько килобайт стека. Плюс у каждого потока есть структуры в ядре ОС.
В сумме это десятки–сотни мегабайт, неприятно, но не катастрофа.
Планировщик и переключения. Это главная беда. Когда данные приходят на тысячи сокетов, ядро ОС будит тысячи потоков, и каждое пробуждение — это переключение контекста. Адресное пространство при переключении между потоками одного процесса не меняется, и TLB сбрасывать не нужно (см. потоки), но кэши процессора после каждого переключения холодные, а сам переход через ядро ОС стоит микросекунды. При десятках тысяч переключений в секунду заметная доля CPU уходит не на работу, а на жонглирование потоками. Цифры — в главе о цене системного вызова.
Лимиты. Число потоков ограничено (ulimit -u, kernel.threads-max,
pids.max у cgroup контейнера). На 10 000 упрётесь раньше, чем кажется.
Эту задачу в 1999 году назвали C10k (10 000 одновременных соединений на одной машине), и её решение изменило архитектуру серверов.
Наблюдение: соединения почти всё время ждут
Из 10 000 открытых соединений в каждый конкретный момент данные пришли на единицы или десятки. Остальные просто ждут, пока клиент что-то пришлёт. Держать под каждое ожидающее соединение целый поток — расточительство.
Отсюда событийная модель (event-driven): один поток (или по одному на ядро процессора) крутит цикл событий (event loop). Он спрашивает у ОС, на каких соединениях сейчас есть данные, и обрабатывает только их.
while (true) {
готовые = подождать_события(все соединения) ← спрашиваем ОС
for соединение in готовые {
обработать(соединение) ← не блокируясь
}
}Так устроены nginx, Redis, Node.js, HAProxy. Чтобы это заработало, нужны две вещи от ОС: чтение, которое не засыпает, и способ ждать сразу много сокетов.
Неблокирующий ввод-вывод
Обычный read() на сокете блокирующий: если данных нет, поток засыпает,
пока они не появятся. В цикле событий это недопустимо: поток уснёт на одном
клиенте и забросит остальные.
Если открыть сокет с флагом O_NONBLOCK, контракт меняется: когда данных нет,
read() не засыпает, а сразу возвращает ошибку EAGAIN (в Linux она же
EWOULDBLOCK) — «сейчас нечего, попробуй позже». Поток идёт обслуживать других.
Остаётся вопрос, когда это «позже». Перебирать 10 000 сокетов в цикле, пока где-нибудь не появятся данные, — это активное ожидание, которое сожжёт CPU. Нужен системный вызов «усыпи меня, пока хоть на одном из этих сокетов что-то не появится». Это называется мультиплексированием ввода-вывода (I/O multiplexing).
select и poll: передаём весь список каждый раз
Первые механизмы — select() (1983, BSD) и poll(). Программа передаёт ядру ОС
список всех интересующих дескрипторов, ядро возвращает, какие из них готовы.
каждая итерация цикла:
1. программа → ядро: вот 10 000 дескрипторов (копирование списка)
2. ядро проверяет все 10 000 по очереди O(n)
ни один не готов → ставит поток в очередь ожидания у всех 10 000
3. пришли данные на fd 42 → ядро будит поток,
снимает его со всех 10 000 очередей
4. ядро → программа: готов fd 42
5. программа перебирает список, ищет готовые O(n)Проблема в том, что стоимость каждой итерации пропорциональна общему числу
соединений, а не числу готовых. Готовы 3 из 10 000, а платим за 10 000, и так на
каждом круге цикла. У select вдобавок есть лимит FD_SETSIZE — обычно
1024 дескриптора. poll лимит снимает, но остаётся O(n).
epoll: регистрируем один раз, получаем только готовые
epoll (появился в Linux в 2002 году) разделяет две вещи, которые select делал за один
вызов: «за чем следить» и «подожди событий».
epoll_create1()создаёт объект epoll — набор интересов, который живёт в ядре.epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ...)один раз добавляет дескриптор в набор (DELубирает,MODменяет).epoll_wait(epfd, ...)засыпает и возвращает только готовые дескрипторы.
ОДИН РАЗ на соединение:
epoll_ctl(ADD, fd) ядро запоминает интерес (красно-чёрное дерево)
и вешает на сокет функцию обратного вызова
В ЦИКЛЕ:
epoll_wait() спим
... на fd 42 пришёл пакет: сетевой стек ядра вызывает callback,
тот кладёт fd 42 в список готовых и будит ждущего ...
epoll_wait() вернул [fd 42] ← только готовыеЯдру не нужно обходить все дескрипторы при каждом вызове: сокет сам добавляет
себя в список готовых, когда в нём появляются данные. epoll_wait просто
забирает этот список. Стоимость итерации пропорциональна числу готовых событий.
| Механизм | Где | Регистрация | Цена ожидания | Лимит fd |
|---|---|---|---|---|
select |
везде | весь список при каждом вызове | O(всех) | обычно 1024 |
poll |
везде | весь список при каждом вызове | O(всех) | нет |
epoll |
Linux | один раз, epoll_ctl |
O(готовых) | нет |
kqueue |
BSD, macOS | один раз, kevent |
O(готовых) | нет |
В Windows похожую роль играет IOCP, но модель там другая: программа запускает операцию, а ядро сообщает о её завершении. Это ближе к io_uring, о котором речь в главе о цене системного вызова.
Важное ограничение, о котором часто забывают: epoll работает с сокетами,
пайпами, терминалами, но не с обычными файлами на диске. Для файла epoll_ctl
вернёт EPERM: с точки зрения ядра файл «всегда готов», даже если чтение
пойдёт на медленный диск.
Edge-triggered и level-triggered
У epoll два режима уведомлений, и разницу любят спрашивать.
- Level-triggered (LT, по уровню) — режим по умолчанию. Пока в сокете есть
непрочитанные данные, каждый
epoll_waitбудет сообщать, что он готов. - Edge-triggered (ET, по фронту) — флаг
EPOLLET. Уведомление приходит один раз, в момент, когда состояние изменилось (пришла новая порция). Если вы прочитали не всё, повторного уведомления не будет, пока не придут ещё данные.
Пришло 100 байт, программа за раз прочитала 60:
LEVEL-TRIGGERED EDGE-TRIGGERED
epoll_wait → fd готов epoll_wait → fd готов
read 60, осталось 40 read 60, осталось 40
epoll_wait → fd готов (40) epoll_wait → тишина
read 40 40 байт лежат, пока клиент
не пришлёт ещё что-нибудьПравило для ET: после уведомления читать в цикле, пока read не вернёт
EAGAIN. Тогда буфер сокета точно пуст, и следующее уведомление придёт на
следующую порцию. ET экономит вызовы epoll_wait и пробуждения, LT проще и
прощает ошибки.
Go: линейный код поверх epoll
Писать сервер на голом цикле событий тяжело: обработка одного запроса разрывается на куски «прочитали часть — вернулись в цикл — дочитали», состояние приходится хранить вручную, логика превращается в цепочки колбэков. Модель «поток на соединение» читается проще, но плохо масштабируется. Go даёт и то, и другое.
package main
import (
"bufio"
"log"
"net"
)
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:9000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
go func(c net.Conn) { // горутина на соединение
defer c.Close()
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n') // выглядит как блокирующий read
if err != nil {
return
}
c.Write([]byte(line)) // эхо
}
}(conn)
}
}Это эхо-сервер с горутиной на соединение. Код выглядит ровно как «поток на соединение», но потоков ОС под ним несколько штук. Вот что происходит внутри.
- Регистрация. Когда создаётся сокет (здесь — при
Accept), рантайм переводит его в неблокирующий режим и один раз регистрирует в epoll (на macOS — в kqueue) сразу на чтение и запись, в режиме edge-triggered. - Чтение.
ReadStringдоходит до системного вызоваread. Есть данные — они возвращаются сразу. Нет — ядро отвечаетEAGAIN. - Парковка. Получив
EAGAIN, рантайм не усыпляет поток ОС, а паркует горутину: запоминает в структуре сокета «эта горутина ждёт чтения» и снимает её с потока. Поток берёт следующую готовую горутину. - Пробуждение. Планировщик Go, когда ему нечего делать, и фоновый поток
sysmon периодически вызывают
epoll_wait. Пришли данные — горутина возвращается в очередь готовых и при следующем запуске повторяетread, на этот раз успешно.
Эта часть рантайма называется netpoller. Обратите внимание на шаг 1: Go
использует edge-triggered и именно поэтому после пробуждения повторяет read,
пока не получит данные или снова EAGAIN — ровно правило ET из раздела выше.
Частая ошибка на собеседовании — сказать, что Go вызывает epoll_ctl при каждом
Read. Нет, регистрация одна на весь срок жизни сокета.
В итоге 10 000 соединений — это 10 000 горутин со стеками от 2 КБ, один набор epoll и потоков ОС примерно по числу ядер. Переключение между горутинами происходит в пространстве пользователя, без ядра. Подробнее про планировщик — в Go-курсе: горутины, планировщик.
Как это проверить самому на Linux: запустите сервер, подключите несколько
клиентов (nc 127.0.0.1 9000) и посмотрите strace -f -e trace=epoll_ctl,epoll_pwait,read -p <pid>.
epoll_ctl появится по одному разу на соединение, а дальше будут чередоваться
read, возвращающие EAGAIN, и ожидания в epoll_pwait.
А файлы?
Раз epoll не умеет обычные файлы, netpoller им тоже не помогает. os.File.Read
на диске — настоящий блокирующий системный вызов, и поток ОС на нём засыпает.
Рантайм это предусматривает: перед системным вызовом горутина помечается «в
syscall», и если вызов затянулся, освободившийся слот P передаётся другому
потоку ОС (при необходимости рантайм создаёт новый). Поэтому сотня горутин,
одновременно читающих медленный диск, может привести к сотне потоков ОС. Для
сети такого не происходит.
Три подхода рядом
| Поток на соединение | Цикл событий вручную | Go (горутины + netpoller) | |
|---|---|---|---|
| Код | линейный | колбэки, ручное состояние | линейный |
| Память на соединение | стек потока (резерв в МБ, RSS — КБ) + структуры ядра | десятки–сотни байт состояния | стек горутины от 2 КБ |
| Переключения | через ядро | почти нет | в пространстве пользователя |
| epoll | не нужен | руками | спрятан в рантайме |
Почему модель «поток на соединение» плохо масштабируется до 10 000 соединений?
Главное преимущество epoll над select/poll:
Зачем event loop нужен неблокирующий I/O (O_NONBLOCK)?
Что делает рантайм Go, когда conn.Read вызван в горутине, а данных в сокете нет?
В режиме edge-triggered пришло 100 байт, приложение прочитало 60 и не дочитало до EAGAIN. Что произойдёт с оставшимися 40 байтами?
Сколько раз для одного сокета нужно вызвать epoll_ctl(EPOLL_CTL_ADD), чтобы epoll следил за ним в цикле ожидания?
Частые ошибки
- Считать, что 10 000 потоков съедят 80 ГБ RAM: это виртуальная резервация, реальная цена — переключения и структуры ядра ОС.
- Использовать edge-triggered epoll и читать один раз, а не до
EAGAIN. - Думать, что Go вызывает
epoll_ctlпри каждомRead. Регистрация одна на весь срок жизни сокета. - Ждать, что epoll или netpoller ускорят чтение файлов с диска. Для обычных файлов они не работают.
- Блокировать единственный поток цикла событий долгой операцией (в Node.js, Redis-подобных серверах): встают все клиенты.
Проверьте себя
- Почему
selectдорог при 10 000 соединений, даже если готовы только три? - Что вернёт
readна неблокирующем сокете без данных? - Что происходит с потоком ОС, когда горутина вызывает
conn.Read, а данных нет? - Сервер на голом epoll в режиме ET после уведомления делает один
readна 4 КБ и возвращается вepoll_wait. Клиент прислал запрос на 10 КБ и ждёт ответа. Что увидит клиент и почему тесты с короткими запросами этого не поймали?
Показать ответы
Ответы. (1) На каждом вызове список из 10 000 дескрипторов копируется в ядро ОС и проверяется целиком, а потом программа ещё раз перебирает его. (2) Ошибку EAGAIN. (3) Ничего плохого: паркуется только горутина, поток ОС берёт другую готовую горутину. (4) Клиент зависнет: сервер прочитал 4 КБ, остальные 6 КБ лежат в буфере сокета, но нового фронта не будет, пока клиент не пришлёт что-то ещё, а он ждёт ответа. Короткие запросы целиком помещались в один read.
Что спрашивают на собесе
- Что такое C10k и почему поток на соединение плохо масштабируется? Тысячи переключений контекста и пробуждений через ядро, холодные кэши, лимиты на число потоков; память на стеки — скорее виртуальная, чем реальная.
- Чем epoll лучше select/poll? select/poll передают и проверяют весь список на каждом вызове (O(n)), у select лимит 1024. epoll регистрирует интерес один раз и возвращает только готовые дескрипторы.
- Зачем циклу событий неблокирующий I/O? Блокирующий
readусыпит единственный поток на одном клиенте. СO_NONBLOCKreadвозвращаетEAGAIN. - ET или LT? ET уведомляет один раз на изменение, читать нужно до
EAGAIN; LT напоминает, пока есть данные. Go использует ET. - Как работает netpoller? Сокет регистрируется в epoll/kqueue один раз; при
EAGAINпаркуется горутина, а не поток; планировщик забирает готовые события черезepoll_waitи будит горутины. - Работает ли epoll с файлами? Нет. Файловый I/O в Go — блокирующие вызовы, на время которых рантайм отдаёт P другому потоку.