# Задача 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
Проверь себя 1. Почему при `EPOLLET` недостаточно один раз прочитать данные из сокета, даже если это покрыло все данные, которые были на момент события?
ОтветПотому что ET сообщает о фронте один раз; если между этим чтением и следующим `epoll_wait` в сокет придут новые данные до того, как буфер опустел, можно потерять уведомление — правильная практика: читать в цикле, пока `read` не вернёт `EAGAIN`, тогда гарантированно вычитан весь буфер на момент вызова.
2. Почему в этой задаче нельзя просто заблокироваться в `accept()` в цикле по одному клиенту?
ОтветТребование — до 64 одновременных соединений одним потоком; блокирующий `accept`/`read` на одном соединении не даёт параллельно обслуживать остальные — отсюда обязательность `epoll` и неблокирующих сокетов.
3. Чем частичная запись (`write` вернул меньше байт, чем передано) отличается от ошибки записи?
ОтветЭто не ошибка: буфер сокета ограничен по размеру, ядро приняло часть данных и вернуло, сколько именно — оставшуюся часть нужно дописать следующим вызовом `write`, когда `EPOLLOUT` снова покажет готовность.