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