120 lines
12 KiB
Markdown
120 lines
12 KiB
Markdown
# Задача 07 — TCP-эхо-сервер на epoll (Linux, C++)
|
||
|
||
Написать сервер: принимает TCP-соединения на `127.0.0.1:<порт из argv[1]>`, эхо всех
|
||
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
|
||
завершается по SIGTERM/SIGINT аккуратно (exit 0).
|
||
|
||
## Требования
|
||
|
||
- мультиплексирование через `epoll` (не блокирующий `accept` в цикле, не поток на клиента);
|
||
- сокеты неблокирующие, обработка `EAGAIN`/`EINTR`, частичные чтения и записи;
|
||
- `SO_REUSEADDR`, bind на 127.0.0.1, прослушивание на нужном порту;
|
||
- до 64 одновременных соединений, закрытие по EOF со стороны клиента;
|
||
- никаких утечек дескрипторов (тест проверяет закрытие).
|
||
|
||
**Факты для карточек**
|
||
- base | На каком адресе и с каким флагом сокета должен слушать сервер? — 127.0.0.1, `SO_REUSEADDR`
|
||
- base | Сколько одновременных соединений сервер должен держать минимум? — 64
|
||
- core | По какому событию сервер закрывает соединение с клиентом? — по EOF со стороны клиента
|
||
|
||
Почему дальше: раз сервер должен обслуживать до 64 соединений одним потоком без блокировки
|
||
на каждом, нужен механизм, который скажет, какие именно дескрипторы готовы — `epoll`.
|
||
|
||
## Механизм: epoll, уровень vs фронт
|
||
|
||
`epoll` — мультиплексор ввода-вывода ядра Linux: `epoll_ctl` регистрирует дескрипторы в
|
||
«списке интереса», который ядро хранит между вызовами; когда состояние дескриптора меняется
|
||
(пришли данные, освободился буфер записи), ядро само кладёт его в «список готовых»;
|
||
`epoll_wait` просто возвращает этот список. Отсюда сложность на каждый вызов пропорциональна
|
||
числу реально готовых дескрипторов, а не общему их числу (в отличие от `select`/`poll`,
|
||
которые каждый раз заново проходят весь набор).
|
||
|
||
У `epoll` два режима уведомления:
|
||
- **LT (level-triggered, по умолчанию)** — `epoll_wait` возвращает дескриптор снова и снова,
|
||
пока в его буфере остаются непрочитанные данные (условие «уровня» истинно). Прочитал не
|
||
всё за один раз — на следующем `epoll_wait` дескриптор опять придёт в списке готовых.
|
||
- **ET (edge-triggered, флаг `EPOLLET`)** — `epoll_wait` сообщает о готовности только один раз,
|
||
в момент перехода состояния «не готов → готов» (фронт). Если не вычитать буфер целиком за
|
||
этот раз, повторного уведомления не будет, пока не придут новые данные (новый фронт).
|
||
|
||
Из этого прямо следует требование к сокетам: **ET обязывает читать/писать в цикле до
|
||
`EAGAIN`**, потому что единственный сигнал готовности уже был использован и другого не будет,
|
||
пока состояние снова не изменится. А раз цикл идёт до `EAGAIN`, дескриптор обязан быть
|
||
**неблокирующим** (`O_NONBLOCK`) — на блокирующем сокете последний вызов `read`/`write` в этом
|
||
цикле, когда данных больше нет, завис бы навсегда вместо того, чтобы вернуть `-1`/`EAGAIN`.
|
||
При LT неблокирующий режим тоже нужен по той же причине разделения потока между многими
|
||
клиентами, но не читать «всё до EAGAIN» за раз не так опасно — событие придёт снова.
|
||
|
||
`EAGAIN` (синоним `EWOULDBLOCK`) — не ошибка сбоя, а сигнал «сейчас нечего делать, попробуй
|
||
позже»; `EINTR` — вызов прерван доставкой сигнала (например, при обработке SIGTERM/SIGINT) и
|
||
его нужно просто повторить, а не трактовать как реальную ошибку. Частичное чтение/запись
|
||
(`read`/`write` вернул меньше байт, чем просили) — нормальное поведение сокета, а не признак
|
||
проблемы: буфер ядра ограничен, и остаток нужно дочитывать/дописывать следующим вызовом.
|
||
|
||
**Ловушки**
|
||
- Трактовать `EAGAIN` как ошибку и закрывать соединение → сервер рвёт рабочие соединения
|
||
просто потому, что в момент проверки данные ещё не пришли → видно как случайные обрывы
|
||
под нагрузкой, ловится логированием `errno` перед реакцией на ошибку.
|
||
- Забыть вычитывать буфер в цикле до `EAGAIN` при `EPOLLET` → повторного уведомления не будет,
|
||
часть данных «зависает» в буфере ядра → клиент не получает остаток эха, тест на эхо большого
|
||
объёма данных повисает или обрезает ответ.
|
||
- Не закрывать `fd` при ошибке/отключении клиента → утечка дескрипторов → процесс упирается
|
||
в лимит `ulimit -n` и получает `EMFILE` на новые `accept`, видно через `lsof -p PID` или
|
||
`/proc/PID/fd`.
|
||
- Игнорировать частичную запись (`write` вернул меньше, чем передали) → часть эха теряется
|
||
без ошибки → видно как обрезанный ответ клиенту при большом объёме данных за одну посылку.
|
||
|
||
**Факты для карточек**
|
||
- base | Что означает `EAGAIN` на неблокирующем сокете? — не ошибка, сигнал «данных пока нет, попробуй позже»
|
||
- core | В чём разница между LT и ET режимами epoll? — LT сообщает о готовности, пока данные остаются в буфере; ET — один раз, при переходе «не готов → готов»
|
||
- core | Почему ET требует неблокирующих дескрипторов? — цикл чтения до EAGAIN на блокирующем сокете завис бы на последнем вызове, когда данных больше нет
|
||
- deep | Какая асимптотика у `epoll_wait` относительно select/poll? — O(1) на готовое событие против O(n) на весь набор у select/poll
|
||
|
||
Почему дальше: раз сервер должен «аккуратно завершаться по SIGTERM/SIGINT», нужно понимать,
|
||
как доставка сигнала прерывает системные вызовы и как это связать с циклом `epoll_wait`.
|
||
|
||
## Завершение по сигналу
|
||
|
||
Аккуратное завершение (`exit 0`) означает: сервер должен закрыть все сокеты и выйти из цикла
|
||
`epoll_wait`, а не просто дать процессу упасть от сигнала по умолчанию. Практический механизм —
|
||
установить обработчик на SIGTERM/SIGINT, который выставляет флаг (`volatile sig_atomic_t`), и
|
||
проверять этот флаг в цикле после каждого возврата из `epoll_wait` (который сам может вернуться
|
||
с `-1`/`EINTR` из-за прихода сигнала — это нужно отличать от реальной ошибки и не падать на ней).
|
||
|
||
**Факты для карточек**
|
||
- base | Каким кодом возврата должен завершаться сервер по SIGTERM/SIGINT? — 0
|
||
- core | Что возвращает `epoll_wait`, если его прервал сигнал? — -1 с `errno == EINTR`, не ошибка выполнения
|
||
|
||
## Проверка
|
||
|
||
Запускается как самостоятельный бинарник: `./solution <порт>`.
|
||
Проверка: `python3 grade.py 07` — тест сам поднимает сервер, открывает 3 соединения, льёт
|
||
данные, проверяет эхо и корректное завершение по SIGTERM. Критерий: все `ok` + ревью кода
|
||
(использование epoll, обработка ошибок).
|
||
|
||
**Факты для карточек**
|
||
- base | Сколько соединений открывает тест `grade.py 07`? — 3
|
||
- base | Каким сигналом тест проверяет корректное завершение сервера? — SIGTERM
|
||
|
||
<details>
|
||
<summary>Проверь себя</summary>
|
||
|
||
1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это
|
||
покрыло все данные, которые были на момент события?
|
||
<details><summary>Ответ</summary>Потому что ET сообщает о фронте один раз; если между этим
|
||
чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел,
|
||
можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт
|
||
`EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.</details>
|
||
|
||
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
|
||
<details><summary>Ответ</summary>Требование — до 64 одновременных соединений одним потоком;
|
||
блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные —
|
||
отсюда обязательность `epoll` и неблокирующих сокетов.</details>
|
||
|
||
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
|
||
<details><summary>Ответ</summary>Это не ошибка: буфер сокета ограничен по размеру, ядро
|
||
приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим
|
||
вызовом `write`, когда `EPOLLOUT` снова покажет готовность.</details>
|
||
|
||
</details>
|