300 lines
32 KiB
Markdown
300 lines
32 KiB
Markdown
# 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»
|
||
|
||
## Проверь себя
|
||
|
||
<details>
|
||
<summary>1. Почему write() может записать меньше байт, чем передали, и как с этим работать правильно?</summary>
|
||
Буфер ядра для сокета/pipe может быть заполнен, вызов может быть прерван сигналом, или для
|
||
сокетов частичная запись — штатное поведение при большом объёме данных. Правильный код
|
||
дописывает оставшиеся байты в цикле, обрабатывая EINTR отдельно от настоящих ошибок.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>2. Почему select ограничен 1024 дескрипторами, а epoll — нет?</summary>
|
||
select передаёт в ядро набор дескрипторов как битовую маску fd_set фиксированного размера
|
||
FD_SETSIZE (1024); номер дескриптора больше этого значения физически не помещается в маску.
|
||
epoll хранит список интереса внутри ядра между вызовами (в красно-чёрном дереве), а не
|
||
передаёт весь набор при каждом вызове, поэтому ограничения по числу дескрипторов на уровне
|
||
самого API нет.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>3. Почему epoll_wait даёт O(1) на событие, а select и poll — O(n) на вызов?</summary>
|
||
select и poll на каждом вызове заново проходят весь переданный набор дескрипторов, чтобы
|
||
определить готовые — работа пропорциональна общему числу отслеживаемых дескрипторов. epoll
|
||
поддерживает отдельный список готовых, который ядро заполняет асинхронно по мере изменения
|
||
состояния дескрипторов; epoll_wait просто отдаёт содержимое этого списка — работа
|
||
пропорциональна числу реально готовых событий.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>4. Почему edge-triggered режим epoll требует неблокирующих дескрипторов?</summary>
|
||
ET сообщает о готовности один раз за переход состояния, поэтому нужно вычитывать данные в
|
||
цикле до EAGAIN, чтобы не пропустить оставшиеся данные. На блокирующем дескрипторе последний
|
||
вызов такого цикла заблокировался бы навсегда в ожидании новых данных вместо немедленного
|
||
возврата EAGAIN.
|
||
</details>
|
||
|
||
<details>
|
||
<summary>5. Зачем нужен SO_REUSEADDR и с чем он связан?</summary>
|
||
Сервер после закрытия соединения оставляет сокет в TIME_WAIT на 2×MSL, чтобы поймать
|
||
задержавшиеся дубликаты сегментов. Без SO_REUSEADDR повторный bind на тот же адрес/порт,
|
||
пока есть сокеты в TIME_WAIT, вернёт ошибку «Address already in use» — опция явно разрешает
|
||
это игнорировать.
|
||
</details>
|
||
|
||
## Задачи дня
|
||
|
||
- `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
|