Конкурентность · 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)
	}
}

Это эхо-сервер с горутиной на соединение. Код выглядит ровно как «поток на соединение», но потоков ОС под ним несколько штук. Вот что происходит внутри.

  1. Регистрация. Когда создаётся сокет (здесь — при Accept), рантайм переводит его в неблокирующий режим и один раз регистрирует в epoll (на macOS — в kqueue) сразу на чтение и запись, в режиме edge-triggered.
  2. Чтение. ReadString доходит до системного вызова read. Есть данные — они возвращаются сразу. Нет — ядро отвечает EAGAIN.
  3. Парковка. Получив EAGAIN, рантайм не усыпляет поток ОС, а паркует горутину: запоминает в структуре сокета «эта горутина ждёт чтения» и снимает её с потока. Поток берёт следующую готовую горутину.
  4. Пробуждение. Планировщик 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 не нужен руками спрятан в рантайме
Проверь себя· Событийная модель и 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-подобных серверах): встают все клиенты.

Проверьте себя

  1. Почему select дорог при 10 000 соединений, даже если готовы только три?
  2. Что вернёт read на неблокирующем сокете без данных?
  3. Что происходит с потоком ОС, когда горутина вызывает conn.Read, а данных нет?
  4. Сервер на голом 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_NONBLOCK read возвращает EAGAIN.
  • ET или LT? ET уведомляет один раз на изменение, читать нужно до EAGAIN; LT напоминает, пока есть данные. Go использует ET.
  • Как работает netpoller? Сокет регистрируется в epoll/kqueue один раз; при EAGAIN паркуется горутина, а не поток; планировщик забирает готовые события через epoll_wait и будит горутины.
  • Работает ли epoll с файлами? Нет. Файловый I/O в Go — блокирующие вызовы, на время которых рантайм отдаёт P другому потоку.

Продолжить