32 KiB
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 частичная запись — штатное
поведение при большом объёме данных. Правильный код пишет весь буфер циклом:
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 — другой случай: вызов был прерван доставкой сигнала до завершения,
и его нужно просто повторить теми же аргументами, а не считать ошибкой.
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