Files

12 KiB
Raw Permalink Blame History

Задача 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` снова покажет готовность.