---
title: "Событийная модель и epoll"
description: "Самый простой сетевой сервер устроен так: принял соединение — создал для него поток, поток читает запрос, обрабатывает, отвечает. Код линейный и понятный. Так работали первые…"
canonical: https://graphlms.ru/os/book/event-based-concurrency
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Событийная модель и epoll

> Источник: https://graphlms.ru/os/book/event-based-concurrency — курс «Операционные системы», GraphLMS.

## Поток на соединение и где он ломается

Самый простой сетевой сервер устроен так: принял соединение — создал для него
поток, поток читает запрос, обрабатывает, отвечает. Код линейный и понятный.
Так работали первые веб-серверы и до сих пор работают многие серверы баз данных
(PostgreSQL вообще заводит отдельный процесс на подключение).

Теперь представьте чат или биржевой фид: 10 000 клиентов держат соединения
открытыми одновременно. Это 10 000 потоков ОС. Что именно станет дорого?

**Память.** Каждому потоку нужен стек. В Linux по умолчанию под стек потока
резервируется 8 МБ, итого 80 ГБ. Но это виртуальная резервация, а не занятая
RAM: как мы видели в главе про [память в проде](/os/book/memory-cloud), в RSS
попадают только реально тронутые страницы. Поток, который сидит в `read()`,
трогает несколько килобайт стека. Плюс у каждого потока есть структуры в ядре ОС.
В сумме это десятки–сотни мегабайт, неприятно, но не катастрофа.

**Планировщик и переключения.** Это главная беда. Когда данные приходят на
тысячи сокетов, ядро ОС будит тысячи потоков, и каждое пробуждение — это
переключение контекста. Адресное пространство при переключении между потоками
одного процесса не меняется, и TLB сбрасывать не нужно (см.
[потоки](/os/book/threads-intro)), но кэши процессора после каждого
переключения холодные, а сам переход через ядро ОС стоит микросекунды. При
десятках тысяч переключений в секунду заметная доля CPU уходит не на работу, а
на жонглирование потоками. Цифры — в главе
[о цене системного вызова](/os/book/syscall-cost).

**Лимиты.** Число потоков ограничено (`ulimit -u`, `kernel.threads-max`,
`pids.max` у cgroup контейнера). На 10 000 упрётесь раньше, чем кажется.

Эту задачу в 1999 году назвали **C10k** (10 000 одновременных соединений на
одной машине), и её решение изменило архитектуру серверов.

## Наблюдение: соединения почти всё время ждут

Из 10 000 открытых соединений в каждый конкретный момент данные пришли на
единицы или десятки. Остальные просто ждут, пока клиент что-то пришлёт. Держать
под каждое ожидающее соединение целый поток — расточительство.

Отсюда **событийная модель** (event-driven): один поток (или по одному на ядро процессора)
крутит **цикл событий** (event loop). Он спрашивает у ОС, на каких соединениях
сейчас есть данные, и обрабатывает только их.

```text
   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()`. Программа передаёт ядру ОС
список всех интересующих дескрипторов, ядро возвращает, какие из них готовы.

```text
   каждая итерация цикла:
   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, ...)` засыпает и возвращает только готовые дескрипторы.

```text
   ОДИН РАЗ на соединение:
     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, о котором речь
в главе [о цене системного вызова](/os/book/syscall-cost).

Важное ограничение, о котором часто забывают: epoll работает с сокетами,
пайпами, терминалами, но не с обычными файлами на диске. Для файла `epoll_ctl`
вернёт `EPERM`: с точки зрения ядра файл «всегда готов», даже если чтение
пойдёт на медленный диск.

## Edge-triggered и level-triggered

У epoll два режима уведомлений, и разницу любят спрашивать.

- **Level-triggered (LT, по уровню)** — режим по умолчанию. Пока в сокете есть
  непрочитанные данные, каждый `epoll_wait` будет сообщать, что он готов.
- **Edge-triggered (ET, по фронту)** — флаг `EPOLLET`. Уведомление приходит один
  раз, в момент, когда состояние изменилось (пришла новая порция). Если вы
  прочитали не всё, повторного уведомления не будет, пока не придут ещё данные.

```text
   Пришло 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 даёт и то, и
другое.

```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-курсе: [горутины](/go/book/goroutines),
[планировщик](/go/book/scheduler-deep).

Как это проверить самому на 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 | не нужен | руками | спрятан в рантайме |

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/event-based-concurrency

## Частые ошибки

- Считать, что 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 другому потоку.

## Вопросы для самопроверки

**Квиз «Событийная модель и epoll»**

1. Почему модель «поток на соединение» плохо масштабируется до 10 000 соединений?
   - TCP не поддерживает столько соединений
   - Планировщик ОС дорого переключает контекст между тысячами потоков (верно)
   - Каждый поток держит свой стек (мегабайты памяти) (верно)
   - Потоки не умеют читать из сети
   - Пояснение: Две беды классической проблемы C10k: каждому потоку нужен свой стек (при 8 МБ на 10 000 потоков это 80 ГБ виртуального адресного пространства, и реальный расход тоже заметен), а планировщик ядра тратит всё больше времени на переключения. Читать из сети потоки умеют, и лимит TCP-соединений тут ни при чём.
2. Главное преимущество epoll над select/poll:
   - epoll шифрует трафик
   - Интерес регистрируется один раз, и ядро отдаёт только готовые дескрипторы — O(числа готовых) (верно)
   - epoll работает на всех ОС
   - epoll не требует неблокирующих сокетов
   - Пояснение: select/poll копируют и обходят весь список на каждый вызов (O(n)). epoll регистрирует интерес единожды (epoll_ctl) и через epoll_wait отдаёт только готовые события — стоимость зависит от числа готовых, а не от общего числа соединений.
3. Зачем event loop нужен неблокирующий I/O (O_NONBLOCK)?
   - Чтобы увеличить размер стека
   - Чтобы read шифровал данные
   - Чтобы отключить epoll
   - Чтобы один поток не заснул на одном соединении и не забросил все остальные (верно)
   - Пояснение: Блокирующий read усыпляет поток при отсутствии данных — для единственного потока event loop это смерть. Неблокирующий режим возвращает EAGAIN, и поток продолжает обслуживать другие соединения.
4. Что делает рантайм Go, когда conn.Read вызван в горутине, а данных в сокете нет?
   - Создаёт новый поток ОС под это соединение
   - Возвращает ошибку и закрывает соединение
   - Регистрирует дескриптор в netpoller (epoll/kqueue) и паркует горутину, освобождая поток ОС (верно)
   - Делает блокирующий syscall и усыпляет поток ОС
   - Пояснение: Netpoller регистрирует fd в epoll/kqueue и паркует именно горутину (она снимается с потока ОС). Поток свободен для других горутин; когда epoll_wait сообщит о готовности, горутина вернётся в очередь планировщика.
5. В режиме edge-triggered пришло 100 байт, приложение прочитало 60 и не дочитало до EAGAIN. Что произойдёт с оставшимися 40 байтами?
   - epoll_wait снова уведомит о них на следующей итерации
   - Они зависнут до прихода новой порции данных (нового изменения состояния) (верно)
   - Соединение разорвётся
   - Ядро автоматически дочитает их за приложение
   - Пояснение: Edge-triggered уведомляет только в момент изменения состояния. Не дочитав до EAGAIN, остаток вы не получите, пока не придёт НОВАЯ порция данных. Поэтому с ET читают в цикле до EAGAIN. В level-triggered напомнили бы снова.
6. Сколько раз для одного сокета нужно вызвать epoll_ctl(EPOLL_CTL_ADD), чтобы epoll следил за ним в цикле ожидания?
   - Ответ: 1
   - Пояснение: Один раз при регистрации. Дальше в цикле вызывается только epoll_wait, и ядро само отдаёт готовые дескрипторы. В этом и выигрыш перед select/poll, которым на каждой итерации заново передают весь список.
