Files

120 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Задача 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>