Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv

This commit is contained in:
Kodlo-chan
2026-09-24 13:38:58 +07:00
parent e0ad8e0fee
commit 4259fbce75
17 changed files with 1688 additions and 115 deletions
+106 -4
View File
@@ -4,14 +4,116 @@
принятых байт обратно в то же соединение, обслуживает несколько клиентов одновременно,
завершается по 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, обработка ошибок).
Проверка: `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>