# D2, часть 2. Файловый ввод-вывод и мультиплексирование (урок) Один и тот же файловый дескриптор можно читать тремя разными способами: обычными `read`/`write` (данные копируются между буфером ядра и буфером пользователя на каждый вызов), через `mmap` (данные читаются напрямую из страничного кэша по факту обращения), или не читать самому, а ждать готовности сразу многих дескрипторов через `select`/`poll`/`epoll`. Разбор идёт в этом порядке: базовые вызовы → почему `write` не гарантирует запись всего буфера → `mmap` как альтернатива → мультиплексирование и его цена → неблокирующие сокеты и retry-цикл, без которого ET-режим `epoll` не работает. ## 1. open/read/write/lseek/close и флаги `int open(const char *path, int flags, mode_t mode)` возвращает файловый дескриптор — целое число, индекс в таблице открытых файлов процесса. Флаги комбинируются побитовым ИЛИ: `O_RDONLY`, `O_WRONLY`, `O_RDWR` (режим доступа, взаимоисключающие), `O_APPEND` (каждая запись атомарно смещается в конец файла перед записью), `O_CREAT` (создать файл, если не существует, требует третий аргумент `mode`), `O_TRUNC` (обрезать существующий файл до нуля при открытии), `O_NONBLOCK` (не блокироваться на операциях, для которых обычно ждут — актуально для FIFO, сокетов, терминалов). `ssize_t read(int fd, void *buf, size_t count)` и `ssize_t write(int fd, const void *buf, size_t count)` возвращают число реально прочитанных/записанных байт, `0` от `read` означает EOF, `-1` — ошибку с кодом в `errno`. `off_t lseek(int fd, off_t offset, int whence)` двигает позицию чтения/записи файла без самого I/O (`SEEK_SET`/`SEEK_CUR`/`SEEK_END`) — именно эта позиция определяет, откуда начнёт читать следующий `read`. `close(fd)` освобождает дескриптор; незакрытые дескрипторы копятся до лимита `ulimit -n` и дают `EMFILE`. **Факты для карточек** - base | Какие флаги комбинируются битовым ИЛИ при open()? — O_RDONLY/O_WRONLY/O_RDWR, O_APPEND, O_CREAT, O_TRUNC, O_NONBLOCK - base | Что возвращает read() при достижении конца файла? — 0 - core | Что делает lseek() и меняет ли он содержимое файла? — двигает позицию чтения/записи файла, содержимое не трогает - core | Чем O_APPEND отличается от ручного lseek(fd, 0, SEEK_END) перед каждой записью? — O_APPEND атомарно смещает позицию в конец непосредственно перед самой записью на уровне ядра, ручной lseek+write у двух процессов может гонку: оба сделают lseek, потом оба write, и один перезапишет данные другого Почему дальше: `read`/`write` возвращают число реально обработанных байт, а не гарантированное — отсюда прямое следствие про буферизацию и частичную запись. ## 2. Буферизация: почему write не обязан записать всё `write(fd, buf, count)` может вернуть значение меньше `count` — это не ошибка. Причины: буфер ядра для канала/сокета заполнен и вмещает меньше, чем просят записать; вызов был прерван сигналом до завершения (`EINTR`); для сокетов и pipe частичная запись — штатное поведение при большом объёме данных. Правильный код пишет весь буфер циклом: ```c++ size_t written = 0; while (written < count) { ssize_t n = write(fd, buf + written, count - written); if (n < 0) { if (errno == EINTR) continue; // прервано сигналом — повторить тот же вызов break; // настоящая ошибка } written += (size_t)n; } ``` То же верно для `read`: он может вернуть меньше байт, чем запрошено, даже если EOF ещё не достигнут (например, из pipe пришла только часть данных) — цикл чтения нужен так же, как и цикл записи. **`fsync`/`fdatasync`.** `write` пишет в буфер страничного кэша ядра, а не сразу на физический носитель — данные могут какое-то время лежать только в памяти. `fsync(fd)` блокирует вызов до тех пор, пока и данные, и все метаданные файла (время модификации, размер и прочее) не будут сброшены на диск. `fdatasync(fd)` делает то же самое для данных, но пропускает метаданные, не влияющие на последующее чтение файла (например, время последнего доступа) — дешевле по числу операций записи на диск, если метаданные важны только там, где влияют на сами данные (например, размер файла). **Ловушки** - Считать, что `write(fd, buf, count)` всегда пишет ровно `count` байт → без цикла часть данных теряется молча, особенно на pipe и сокетах под нагрузкой → видно как обрезанные данные на приёмной стороне без единой ошибки в логе. - Не проверять `errno == EINTR` отдельно от настоящих ошибок в цикле записи/чтения → сигнал, пришедший во время `write`, интерпретируется как фатальная ошибка и обрывает передачу. - Полагаться на `write` без `fsync` там, где данные должны пережить аварийное отключение питания → данные остаются только в буфере страничного кэша и теряются при падении до сброса на диск. **Факты для карточек** - base | Может ли write() записать меньше байт, чем попросили, без ошибки? — да, это не ошибка, нужен цикл дозаписи - core | Чем fdatasync отличается от fsync? — fsync сбрасывает на диск данные и все метаданные файла, fdatasync — только данные и те метаданные, что нужны для последующего чтения (например, размер), пропуская остальные (время доступа) - core | Какой errno означает «вызов прерван сигналом, нужно просто повторить»? — EINTR Почему дальше: `write`/`read` всегда копируют данные между буфером ядра и буфером пользователя — `mmap` даёт способ работать с файлом вообще без этого копирования на каждый вызов. ## 3. mmap: отображение файла в память `mmap` резервирует диапазон виртуальных адресов процесса и связывает его со страницами файла в странично́м кэше ядра — дальше работа с содержимым файла становится обычным разыменованием указателя, а не парой вызовов `read`/`write`. Механизм ленивый: при самом вызове `mmap` физически с диска ничего не читается, только резервируется адресный диапазон. При первом обращении к странице внутри этого диапазона происходит **page fault**: если страница уже есть в странично́м кэше — это **minor fault** (дешёвая операция, просто добавить отображение), если нет — **major fault** (ядро реально читает страницу с диска). Дальнейшие обращения к уже загруженной странице идут без page fault вообще — прямое чтение по адресу. **`MAP_SHARED` vs `MAP_PRIVATE`.** `MAP_SHARED` — изменения видны всем процессам, отобразившим тот же файл, и в конечном счёте попадают обратно в файл на диске. `MAP_PRIVATE` — copy-on-write: страницы изначально общие (как при `fork`), но при первой записи процесс получает собственную копию изменённой страницы, а исходный файл на диске не меняется — тот же механизм COW, что и у `fork()`. `msync(addr, len, flags)` принудительно сбрасывает изменения `MAP_SHARED`-области на диск, не дожидаясь, пока это сделает ядро само — без него при аварийном завершении процесса последние записанные страницы можно потерять. `munmap(addr, len)` снимает отображение. **Когда mmap быстрее read.** Повторный или случайный доступ к большому файлу: не нужно копировать данные между буфером ядра и буфером пользователя на каждый вызов (та самая копия, которую всегда делает `read`), и не нужен системный вызов на каждое обращение — только на первое касание страницы. Также удобен для разделения памяти между процессами через один и тот же файл, отображённый `MAP_SHARED`. **Когда mmap хуже.** Однократное последовательное чтение небольшого файла: накладные расходы на настройку отображения и page fault на каждую новую страницу не амортизируются, обычный `read` одним вызовом может оказаться дешевле. При доступе к огромным областям растёт давление на TLB (аппаратный кэш трансляции виртуальных адресов в физические, ограничен по числу записей) — трансляция адреса вне TLB требует обхода таблицы страниц, что медленнее прямого попадания. На системах с ограниченным адресным пространством (32-бит, порядка нескольких гигабайт) размер файла, который вообще можно отобразить целиком, ограничен размером адресного пространства — на 64-битных системах это практически не проблема. **Факты для карточек** - base | В чём разница MAP_SHARED и MAP_PRIVATE? — MAP_SHARED: изменения видны другим процессам и попадают в файл; MAP_PRIVATE: copy-on-write, изменения приватны и не попадают в файл на диске - base | Что делает msync? — принудительно сбрасывает изменения MAP_SHARED-области на диск, не дожидаясь ядра - core | Чем minor page fault отличается от major? — minor: страница уже в страничном кэше, только добавляется отображение (дёшево); major: страница реально читается с диска (дорого) - core | Когда mmap проигрывает read по скорости? — при однократном последовательном чтении небольшого файла — накладные расходы на отображение и page fault не амортизируются - deep | Что ограничивает mmap на 32-битных системах? — размер доступного виртуального адресного пространства (порядка нескольких гигабайт) — файл целиком отобразить может не получиться Почему дальше: и read/write, и mmap работают с одним дескриптором за раз — сервер, обслуживающий тысячи соединений одним потоком, должен уметь ждать готовности сразу многих дескрипторов, отсюда select/poll/epoll. ## 4. select/poll/epoll: сигнатуры и стоимость `int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)` — `nfds` это максимальный номер дескриптора в наборах плюс один, `fd_set` — битовая маска дескрипторов. Жёсткий лимит — `FD_SETSIZE`, **1024** дескриптора: номер дескриптора больше этого значения `select` обработать не может. При каждом вызове ядро проходит весь набор целиком, чтобы определить, какие дескрипторы готовы, — сложность `O(n)` от общего числа отслеживаемых дескрипторов на каждый вызов, независимо от того, сколько из них реально готовы. `fd_set` к тому же модифицируется вызовом на месте — перед следующим вызовом набор нужно пересобирать заново. `int poll(struct pollfd *fds, nfds_t nfds, int timeout)` убирает лимит `FD_SETSIZE` — набор это обычный массив структур `pollfd` произвольной длины, — но сложность та же: ядро всё равно проходит по всем `nfds` элементам массива на каждый вызов, `O(n)`. `epoll` разделяет операции регистрации и ожидания на разные вызовы: - `int epoll_create1(int flags)` создаёт инстанс epoll (возвращает дескриптор самого epoll); - `int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)` с `op` равным `EPOLL_CTL_ADD`/`EPOLL_CTL_MOD`/`EPOLL_CTL_DEL` добавляет, изменяет или убирает дескриптор из **списка интереса**, который ядро хранит между вызовами в красно-чёрном дереве; - `int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)` просто возвращает содержимое отдельного **списка готовых** — ядро само добавляет туда дескриптор, когда его состояние меняется, без участия вызывающего кода. Отсюда сложность `epoll_wait` — `O(1)` на каждое готовое событие: работа пропорциональна числу реально готовых дескрипторов, а не общему числу зарегистрированных, потому что фильтрация уже произошла в ядре асинхронно, а не в момент вызова. **Факты для карточек** - base | Жёсткий лимит числа дескрипторов у select и его значение? — FD_SETSIZE, 1024 - base | Сложность select и poll на один вызов? — O(n) от общего числа отслеживаемых дескрипторов, независимо от того, сколько готовы - core | Три функции epoll и их роль? — epoll_create1 (создать инстанс), epoll_ctl (ADD/MOD/DEL в списке интереса), epoll_wait (забрать готовые из списка готовых) - core | На чём построен список интереса epoll внутри ядра? — на красно-чёрном дереве - core | Сложность epoll_wait на одно готовое событие? — O(1) — ядро уже отфильтровало готовые дескрипторы заранее, работа не зависит от общего числа зарегистрированных Почему дальше: epoll сообщает, что дескриптор готов, но не гарантирует, что напомнит об этом снова, если не вычитать данные полностью — отсюда разница между level-triggered и edge-triggered режимами. ## 5. Edge-triggered vs level-triggered **Level-triggered (LT)** — поведение по умолчанию у `epoll`, единственный режим у `select` и `poll`: событие сообщается, **пока условие остаётся истинным**. Если в буфере сокета есть непрочитанные данные, каждый следующий `epoll_wait` снова покажет этот дескриптор готовым, даже если в прошлый раз данные вычитали не полностью. **Edge-triggered (ET)**, флаг `EPOLLET` при `epoll_ctl` — событие сообщается **один раз**, в момент перехода состояния из неготового в готовое. Если после этого не вычитать все доступные данные (не дойти до `EAGAIN`), а прерваться раньше, оставшиеся данные никак не будут сигнализированы повторно — дескриптор может «зависнуть» с непрочитанными данными до следующего изменения состояния (например, до прихода новых данных). Из этого прямо следует требование: **ET обязателен на неблокирующих дескрипторах** и требует цикла чтения/записи до `EAGAIN`. Причина — на блокирующем дескрипторе цикл «читать, пока не кончатся данные» на последнем вызове заблокировался бы навсегда в ожидании новых данных вместо того, чтобы сразу вернуть `EAGAIN` и позволить перейти к другому дескриптору. **Факты для карточек** - base | В чём разница level-triggered и edge-triggered? — LT: событие повторяется, пока условие истинно; ET: событие сообщается один раз, в момент перехода в готовое состояние - core | Почему ET требует неблокирующих дескрипторов? — на блокирующем дескрипторе цикл чтения до исчерпания данных на последнем вызове заблокируется навсегда вместо возврата EAGAIN - core | Что произойдёт, если в ET-режиме не дочитать данные до EAGAIN? — оставшиеся данные не будут сигнализированы повторно, пока состояние дескриптора не изменится снова (например, не придут новые данные) - deep | Какой флаг epoll_ctl включает edge-triggered режим? — EPOLLET Почему дальше: раз ET требует вычитывать всё до конца, нужен точный протокол — что означает EAGAIN, чем он отличается от настоящей ошибки, и как устроен retry. ## 6. EAGAIN/EWOULDBLOCK/EINTR и retry-цикл Неблокирующий сокет (`O_NONBLOCK`) не ждёт готовности: `read`/`recv`/`write`/`send` немедленно возвращают управление, даже если данных нет или буфер записи полон. В этом случае вызов возвращает `-1`, а `errno` выставляется в `EAGAIN` (на Linux синоним `EWOULDBLOCK`, то же числовое значение) — это не ошибка в смысле сбоя, а сигнал «сейчас нечего делать, попробуй позже». `EINTR` — другой случай: вызов был прерван доставкой сигнала до завершения, и его нужно просто повторить теми же аргументами, а не считать ошибкой. ```c++ for (;;) { ssize_t n = read(fd, buf, sizeof buf); if (n > 0) { /* обработать n байт */ continue; } if (n == 0) { /* EOF, закрыть соединение */ break; } if (errno == EAGAIN || errno == EWOULDBLOCK) break; // данных больше нет сейчас — выходим из цикла if (errno == EINTR) continue; // прервано сигналом — повторить read /* иначе настоящая ошибка */ break; } ``` Ошибочная трактовка `EAGAIN` как сбоя (например, закрытие соединения при его получении) рвёт рабочие соединения просто потому, что в момент проверки данные ещё не пришли — типичный симптом под нагрузкой: случайные обрывы соединений, которых не должно быть. **Ловушки** - Закрыть соединение при получении EAGAIN вместо того, чтобы просто выйти из цикла чтения → рабочие соединения обрываются под нагрузкой без реальной причины. - Использовать ET без цикла до EAGAIN → часть данных остаётся невычитанной и не сигнализируется повторно → дескриптор «зависает» до следующего изменения состояния. - Не обработать EINTR отдельно от прочих ошибок → сигнал, пришедший во время вызова, обрывает обработку соединения вместо простого повтора. **Факты для карточек** - base | Что означает EAGAIN/EWOULDBLOCK на неблокирующем дескрипторе? — данных для чтения нет (или буфер записи полон) прямо сейчас — не ошибка, повторить позже - base | Что означает EINTR и как на него реагировать? — вызов прерван сигналом — повторить тот же вызов немедленно - core | Совпадают ли числовые значения EAGAIN и EWOULDBLOCK на Linux? — да, это синонимы с одним и тем же значением Почему дальше: retry-цикл нужен на уровне отдельного сокета, но сами сокеты сервер создаёт и закрывает постоянно — здесь важно, что происходит с портом сразу после закрытия соединения. ## 7. SO_REUSEADDR и TIME_WAIT При закрытии TCP-соединения сторона, отправившая последний ACK (первая начавшая закрытие), переходит в состояние **TIME_WAIT** и ждёт там время, равное удвоенному MSL (Maximum Segment Lifetime), прежде чем окончательно освободить сокет и порт. Ожидание нужно, чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения: если бы порт освобождался сразу и тут же переиспользовался новым соединением, устаревший сегмент из старого соединения мог бы быть по ошибке принят как часть новой сессии. Практическое следствие — сервер, часто пересоздающий слушающий сокет на одном и том же порту (например, при рестарте), может получить ошибку `bind`: «Address already in use», пока предыдущие сокеты не выйдут из TIME_WAIT. Опция `SO_REUSEADDR`, выставляемая на сокете перед `bind`, разрешает повторно привязаться к адресу и порту, у которого есть сокеты в TIME_WAIT — это стандартная практика для серверов, которые должны уметь быстро перезапускаться. **Факты для карточек** - base | Сколько времени сокет проводит в TIME_WAIT? — 2×MSL (удвоенное время жизни сегмента в сети) - core | Зачем нужно состояние TIME_WAIT? — чтобы поймать задержавшиеся в сети дубликаты сегментов старого соединения и не спутать их с новым, если порт переиспользуют слишком быстро - core | Что даёт SO_REUSEADDR? — разрешает bind на адрес/порт, у которого уже есть сокеты в состоянии TIME_WAIT — иначе bind вернёт «Address already in use» ## Проверь себя
1. Почему write() может записать меньше байт, чем передали, и как с этим работать правильно? Буфер ядра для сокета/pipe может быть заполнен, вызов может быть прерван сигналом, или для сокетов частичная запись — штатное поведение при большом объёме данных. Правильный код дописывает оставшиеся байты в цикле, обрабатывая EINTR отдельно от настоящих ошибок.
2. Почему select ограничен 1024 дескрипторами, а epoll — нет? select передаёт в ядро набор дескрипторов как битовую маску fd_set фиксированного размера FD_SETSIZE (1024); номер дескриптора больше этого значения физически не помещается в маску. epoll хранит список интереса внутри ядра между вызовами (в красно-чёрном дереве), а не передаёт весь набор при каждом вызове, поэтому ограничения по числу дескрипторов на уровне самого API нет.
3. Почему epoll_wait даёт O(1) на событие, а select и poll — O(n) на вызов? select и poll на каждом вызове заново проходят весь переданный набор дескрипторов, чтобы определить готовые — работа пропорциональна общему числу отслеживаемых дескрипторов. epoll поддерживает отдельный список готовых, который ядро заполняет асинхронно по мере изменения состояния дескрипторов; epoll_wait просто отдаёт содержимое этого списка — работа пропорциональна числу реально готовых событий.
4. Почему edge-triggered режим epoll требует неблокирующих дескрипторов? ET сообщает о готовности один раз за переход состояния, поэтому нужно вычитывать данные в цикле до EAGAIN, чтобы не пропустить оставшиеся данные. На блокирующем дескрипторе последний вызов такого цикла заблокировался бы навсегда в ожидании новых данных вместо немедленного возврата EAGAIN.
5. Зачем нужен SO_REUSEADDR и с чем он связан? Сервер после закрытия соединения оставляет сокет в TIME_WAIT на 2×MSL, чтобы поймать задержавшиеся дубликаты сегментов. Без SO_REUSEADDR повторный bind на тот же адрес/порт, пока есть сокеты в TIME_WAIT, вернёт ошибку «Address already in use» — опция явно разрешает это игнорировать.
## Задачи дня - `tasks/07_epoll` — TCP-эхо-сервер на epoll: неблокирующие сокеты, `EAGAIN`/`EINTR`, частичные чтения и записи, `SO_REUSEADDR`, до 64 одновременных соединений, аккуратное завершение по SIGTERM/SIGINT. ## Материалы - man 2 open — https://man7.org/linux/man-pages/man2/open.2.html - man 2 mmap — https://man7.org/linux/man-pages/man2/mmap.2.html - man 7 epoll — https://man7.org/linux/man-pages/man7/epoll.7.html - man 2 select — https://man7.org/linux/man-pages/man2/select.2.html - man 7 tcp (TIME_WAIT, SO_REUSEADDR) — https://man7.org/linux/man-pages/man7/tcp.7.html - man 7 signal (SIGTERM/SIGINT) — https://man7.org/linux/man-pages/man7/signal.7.html