Files
eduplan-cpp-eltex/lessons/D2_linux.md
T

300 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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