Файловый ввод-вывод
Предпосылки: отображение памяти (mmap, page cache), файловые системы (page cache, fsync, dirty pages).
← Отображение памяти | Сокеты →
read() и write() читают и пишут последовательно, начиная с текущей позиции в файле. Для простых случаев — прочитать конфиг, записать результат — этого достаточно. Но как только несколько процессов пишут в один файл, или нужна гарантия, что данные переживут сбой питания, или параллельные потоки читают разные части одного файла — базовых вызовов не хватает. Флаги open() и специализированные системные вызовы решают эти задачи.
O_APPEND: атомарная запись в конец
Два процесса пишут в один лог-файл /var/log/app.log. Без специальных мер каждый write() начинается с текущего смещения (offset) в open file description. Если процессы открыли файл независимо, у каждого своё описание с offset=0. Первый записывает 50 байт — его offset становится 50. Второй тоже записывает 50 байт — но начинает с offset=0 своего описания и затирает первую запись.
Наивное решение — перед каждой записью вызвать lseek(fd, 0, SEEK_END), чтобы переместить offset в конец файла. Но между lseek() и write() — окно: другой процесс может дописать данные, и offset снова указывает не туда. Классическая ошибка TOCTOU (time-of-check-to-time-of-use).
Флаг O_APPEND решает проблему на уровне ядра: перед каждым write() ядро атомарно перемещает offset в конец файла и выполняет запись. Два шага — seek и write — происходят как единая операция, без окна для гонки.
int fd = open("/var/log/app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
// Каждый write() атомарно переходит в конец файла
write(fd, "2026-03-23 worker[1]: request handled\n", 38);Два процесса с O_APPEND никогда не перезатрут данные друг друга. Порядок строк в логе зависит от того, кто первым получит CPU, но ни один байт не потеряется. POSIX (Portable Operating System Interface) гарантирует атомарность O_APPEND для обычных файлов. Для NFS (Network File System) эта гарантия не действует — NFS не поддерживает атомарный append на уровне протокола.
Логи пишутся атомарно. Но приложению нужно убедиться, что оно --- единственный работающий экземпляр.
O_CREAT и O_EXCL: атомарное создание
O_CREAT создаёт файл, если он не существует, и открывает существующий, если он уже есть. Третий аргумент open() задаёт права нового файла (модифицированные umask процесса):
int fd = open("/tmp/data.txt", O_WRONLY | O_CREAT, 0644);
// создаст файл, если нет; откроет, если естьЭтого недостаточно для lock-файлов. PID-файл демона (/var/run/nginx.pid) должен создаваться только если демон ещё не запущен. Если файл уже существует — значит, другой экземпляр работает, и запуск нужно отклонить.
Проверять существование файла через access() или stat(), а потом создавать через open() — та же ошибка TOCTOU: между проверкой и созданием другой процесс может создать файл раньше. O_EXCL в паре с O_CREAT превращает создание в атомарную операцию: open() возвращает дескриптор, только если файл не существовал, иначе — ошибку EEXIST.
int fd = open("/var/run/myapp.pid", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd == -1 && errno == EEXIST) {
fprintf(stderr, "daemon already running\n");
exit(1);
}
// fd > 0 — мы единственный экземпляр
dprintf(fd, "%d\n", getpid());Тот же приём используется для создания временных файлов: mkstemp() внутри вызывает open() с O_CREAT|O_EXCL, гарантируя, что имя уникально и файл создан атомарно.
Экземпляр единственный, PID-файл создан. Теперь приложение ведёт журнал транзакций --- каждая запись должна пережить сбой питания.
O_SYNC и O_DSYNC: гарантия записи на диск
Обычный write() копирует данные из пользовательского буфера в page cache ядра и возвращает управление. Данные в этот момент — в оперативной памяти. Ядро запишет грязные страницы (dirty pages) на диск позже, в фоне. Если питание пропадёт до этого момента, данные потеряются.
fsync(fd) заставляет ядро сбросить все грязные страницы файла и его метаданные на диск и дождаться подтверждения от контроллера. Это гарантия durability, но вызов fsync() после каждого write() — дополнительный системный вызов и переключение контекста.
Флаг O_SYNC встраивает эту гарантию в каждый write(): вызов не возвращается, пока данные и метаданные (размер файла, mtime) не записаны на диск. O_DSYNC — более мягкий вариант: ждёт записи данных, но не метаданных. Если write() не изменяет размер файла (перезапись существующих блоков), O_DSYNC не ждёт обновления метаданных и работает быстрее.
// WAL-файл базы данных: каждая запись должна быть durable
int wal_fd = open("/data/pg_wal/000000010000000000000001",
O_WRONLY | O_DSYNC, 0600);
write(wal_fd, wal_record, record_len);
// write() вернулся — данные на дискеЦена гарантии зависит от устройства. На NVMe (Non-Volatile Memory Express) SSD задержка одной синхронной записи — порядка 50-100 мкс: контроллер подтверждает запись после размещения данных во внутреннем конденсаторном буфере. На HDD — 5-15 мс: головка должна физически переместиться и дождаться оборота пластины. Без O_SYNC/O_DSYNC write() завершается за ~1-10 мкс — это просто копирование в page cache.
PostgreSQL на Linux по умолчанию использует fdatasync() после каждого WAL-write (wal_sync_method = fdatasync). Альтернатива — open_datasync (O_DSYNC при open), которая может быть эффективнее на некоторых платформах. Выбор между ними зависит от ОС и файловой системы: на Linux с ext4 разница минимальна.
Разница между fsync() и fdatasync() — аналог разницы между O_SYNC и O_DSYNC. fsync() сбрасывает данные и метаданные; fdatasync() — только данные (и метаданные лишь когда они необходимы для доступа к данным, например, при изменении размера файла). На практике для WAL, который растёт append-only, fdatasync() иногда пропускает обновление mtime — выигрыш невелик, но измерим при десятках тысяч вызовов в секунду.
Журнал гарантированно ложится на диск. Но данные БД проходят через page cache дважды --- в буферном кеше приложения и в кеше ядра.
O_DIRECT: запись мимо page cache
Page cache — буфер ядра между приложением и диском. При обычном write() данные попадают в page cache, откуда ядро вытесняет их на диск в фоне. При read() ядро сначала ищет страницу в кеше; если есть — отдаёт без обращения к диску (cache hit). Page cache ускоряет повторное чтение и позволяет ядру группировать мелкие записи в крупные.
Но база данных управляет кешированием сама. PostgreSQL поддерживает собственный буферный кеш (shared buffers), оптимизированный под паттерны доступа к страницам B-tree и heap. Если данные проходят ещё и через page cache ядра, они дублируются: одна страница занимает 8 КБ в shared buffers и 4-8 КБ в page cache. На сервере с 32 ГБ RAM, 8 ГБ shared buffers и активной нагрузкой page cache может съедать ещё 8-10 ГБ под те же данные.
O_DIRECT исключает page cache: данные передаются между пользовательским буфером и диском через DMA (Direct Memory Access), минуя копирование в ядре.
int fd = open("/data/tablespace/16384/16385",
O_RDWR | O_DIRECT, 0600);Взамен O_DIRECT предъявляет требования к выравниванию (alignment). Буфер в пользовательском пространстве, смещение в файле и размер операции должны быть кратны размеру сектора устройства — обычно 512 байт, но большинство реализаций требуют выравнивания по 4 КБ (размер страницы ФС). Обычный malloc() не гарантирует такого выравнивания; нужен posix_memalign() или aligned_alloc():
void *buf;
// Выровнять буфер по 4096 байт
posix_memalign(&buf, 4096, 4096);
// Теперь можно читать/писать с O_DIRECT
pread(fd, buf, 4096, 0); // прочитать первую страницу
memcpy(buf, new_data, 4096);
pwrite(fd, buf, 4096, 0); // записать обратно
free(buf);Если буфер не выровнен, read()/write() вернёт EINVAL. Это частая ошибка при первом использовании O_DIRECT.
Отказ от page cache означает потерю двух оптимизаций ядра: read-ahead (упреждающее чтение последовательных страниц) и кеширование повторных чтений. Если приложение читает файл последовательно мелкими блоками без собственного кеша, O_DIRECT замедлит его — каждый read() будет обращаться к диску. MySQL InnoDB и PostgreSQL компенсируют это собственными механизмами: InnoDB buffer pool с LRU-вытеснением (LRU — Least Recently Used, вытеснение наименее недавно использованных), PostgreSQL shared buffers с clock-sweep.
Комбинация O_DIRECT|O_DSYNC — один из вариантов для WAL: данные минуют page cache и гарантированно ложатся на диск после каждого write(). Двойная страховка: O_DIRECT исключает потерю данных из-за невытесненного page cache, O_DSYNC исключает потерю из-за буфера контроллера диска.
Кеш приложения управляет данными напрямую. Параллельные рабочие потоки читают разные страницы файла данных --- и здесь возникает вопрос позиционирования.
lseek: управление смещением
Каждое open file description хранит текущее смещение (offset) — позицию, с которой начнётся следующий read() или write(). При открытии файла offset равен 0 (если не указан O_APPEND). Каждый read()/write() сдвигает offset на количество прочитанных/записанных байт.
lseek() перемещает offset вручную:
off_t pos;
pos = lseek(fd, 0, SEEK_SET); // в начало файла
pos = lseek(fd, 100, SEEK_CUR); // на 100 байт вперёд от текущей позиции
pos = lseek(fd, 0, SEEK_END); // в конец файла (pos = размер файла)
pos = lseek(fd, -10, SEEK_END); // за 10 байт до концаlseek() не выполняет ввод-вывод — он изменяет число в структуре ядра. Возвращаемое значение — новая позиция в байтах от начала файла. Если файл не поддерживает seek (pipe, сокет, терминал), lseek() возвращает -1 с ESPIPE.
Установка offset за пределы текущего размера файла допустима. Последующий write() создаст «дыру» (hole) — диапазон байт, который логически заполнен нулями, но не занимает блоки на диске. Файл с дырами называется разреженным (sparse file). Виртуальные машины часто создают образы дисков как sparse-файлы: образ на 100 ГБ занимает на хосте только объём реально записанных данных. Linux предоставляет расширенные whence-константы SEEK_DATA и SEEK_HOLE для навигации по разреженным файлам: lseek(fd, 0, SEEK_DATA) перемещает offset к первому байту, за которым стоит реальный блок, пропуская дыры.
Offset — свойство open file description, а не дескриптора. После fork() родитель и ребёнок разделяют одно описание, поэтому lseek() в дочернем процессе сдвигает offset и для родителя. После dup() — то же самое: fd=3 и fd=4 указывают на одно описание. Это важно понимать при многопоточной работе: два потока, читающие через один fd, конкурируют за общий offset. Вызов lseek() из одного потока ломает позицию для другого.
lseek() + read() --- два системных вызова с окном для гонки между потоками.
pread и pwrite: позиционный ввод-вывод
Проблема с lseek() + read() — два системных вызова вместо одного, и между ними может вклиниться другой поток (или процесс после fork). Если два потока работают с одним fd:
Поток A: lseek(fd, 1000, SEEK_SET) offset = 1000
Поток B: lseek(fd, 5000, SEEK_SET) offset = 5000
Поток A: read(fd, buf, 100) читает с offset 5000, а не 1000
pread() и pwrite() объединяют позиционирование и операцию в один атомарный вызов. Они принимают смещение как аргумент и не изменяют offset в open file description:
// Прочитать 4096 байт с позиции 8192 — offset в описании не меняется
ssize_t n = pread(fd, buf, 4096, 8192);
// Записать 4096 байт в позицию 0 — offset по-прежнему не тронут
ssize_t w = pwrite(fd, buf, 4096, 0);Два потока могут одновременно вызывать pread() на одном fd с разными смещениями без каких-либо гонок — каждый вызов самодостаточен. База данных использует это постоянно: рабочий поток читает страницу B-tree с позиции page_no * 8192, не мешая другим потокам, которые параллельно читают другие страницы того же файла.
pread()/pwrite() также экономят системные вызовы: один вместо двух (lseek + read). При 100 000 операций в секунду — это 100 000 сэкономленных переключений в ядро. На современных процессорах один системный вызов через syscall стоит порядка 100-200 нс (включая смену привилегий и сброс спекулятивного буфера после Spectre/Meltdown), так что экономия составляет 10-20 мс в секунду — заметная величина для высоконагруженной базы данных.
Позиционное чтение решено. Приложение формирует ответ клиенту --- заголовок в одном буфере, данные в другом.
readv и writev: рассеянный ввод-вывод
HTTP-сервер формирует ответ из двух частей: заголовки (200-500 байт) и тело (десятки килобайт). Заголовки лежат в одном буфере, тело — в другом. Можно вызвать write() дважды — но это два системных вызова и, возможно, два TCP-сегмента вместо одного.
writev() (write vector, рассеянная запись) принимает массив структур iovec, каждая из которых описывает буфер: адрес и длину. Ядро собирает данные из всех буферов и записывает их в дескриптор за одну операцию:
#include <sys/uio.h>
char *headers = "HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\n";
char *body = "Hello, World!";
struct iovec iov[2];
iov[0].iov_base = headers;
iov[0].iov_len = strlen(headers); // 39 байт
iov[1].iov_base = body;
iov[1].iov_len = strlen(body); // 13 байт
ssize_t written = writev(sockfd, iov, 2);
// written = 52, отправлено одним вызовомreadv() (рассеянное чтение, scatter read) делает обратное: считывает данные из дескриптора и раскладывает по нескольким буферам последовательно. Первый буфер заполняется целиком, затем второй, и так далее:
struct header hdr; // 16 байт
char payload[4080]; // 4080 байт — вместе ровно 4096
struct iovec iov[2];
iov[0].iov_base = &hdr;
iov[0].iov_len = sizeof(hdr); // 16
iov[1].iov_base = payload;
iov[1].iov_len = sizeof(payload); // 4080
ssize_t n = readv(fd, iov, 2);
// ядро прочитало до 4096 байт, первые 16 — в hdr, остальные — в payloadScatter-gather I/O (рассеянный/собирательный ввод-вывод) особенно полезен, когда формат данных содержит заголовок фиксированной длины и тело переменной длины. Вместо чтения в один большой буфер с последующим memcpy() для разделения заголовка и тела — readv() раскладывает данные по местам сразу.
WAL-запись базы данных — ещё один типичный случай. Каждая запись состоит из заголовка (длина, LSN (Log Sequence Number — номер позиции в журнале), тип операции) и тела (изменённые данные). Заголовок формируется в стеке, тело указывает на область в shared buffers. writev() позволяет записать и то, и другое одним вызовом без промежуточного копирования в единый буфер:
struct wal_header hdr = { .len = data_len, .lsn = current_lsn, .type = XLOG_INSERT };
struct iovec iov[2] = {
{ .iov_base = &hdr, .iov_len = sizeof(hdr) },
{ .iov_base = data, .iov_len = data_len }
};
writev(wal_fd, iov, 2);Ограничение: максимальное количество элементов iovec в одном вызове определяется константой IOV_MAX — на Linux это 1024. Для комбинации позиционного доступа и scatter-gather существуют preadv()/pwritev(): они принимают и массив iovec, и смещение в файле, не затрагивая offset.
Отдельные операции атомарны, но приложению нужна защита для последовательности read-modify-write.
Блокировка файлов
Когда два процесса работают с одним файлом, флаги open() не всегда достаточны. O_APPEND гарантирует атомарность отдельного write(), но не защищает последовательность из нескольких операций: прочитать запись, изменить, записать обратно. Между чтением и записью другой процесс может изменить ту же запись.
Файловые блокировки (file locks) решают эту проблему. Классический пример: почтовый сервер хранит ящики в формате mbox — один файл на пользователя. Доставка нового письма — это append в конец файла, а чтение с удалением — перезапись файла без удалённых сообщений. Без блокировки доставка во время чтения может привести к повреждению ящика. Linux предоставляет два механизма.
flock() блокирует файл целиком. Блокировка может быть разделяемой (shared, LOCK_SH) — несколько процессов могут держать одновременно, для чтения — или эксклюзивной (exclusive, LOCK_EX) — только один процесс, для записи:
#include <sys/file.h>
int fd = open("/var/lib/app/data.db", O_RDWR);
flock(fd, LOCK_EX); // ждать, пока другие отпустят
// ... чтение, модификация, запись ...
flock(fd, LOCK_UN); // освободить блокировкуfcntl() с командой F_SETLK (неблокирующая) или F_SETLKW (блокирующая, wait) обеспечивает блокировку на уровне диапазона байт (byte-range locking). Процесс может заблокировать байты 1000-2000, позволяя другому процессу параллельно работать с байтами 3000-4000:
struct flock fl = {
.l_type = F_WRLCK, // эксклюзивная блокировка
.l_whence = SEEK_SET,
.l_start = 1000, // начало диапазона
.l_len = 1000 // длина (0 = до конца файла)
};
fcntl(fd, F_SETLKW, &fl); // ждать, пока диапазон освободится
// ... работа с байтами 1000-1999 ...
fl.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &fl); // освободитьОба механизма — рекомендательные (advisory): ядро не запрещает процессу читать или писать в заблокированный файл, если процесс не проверяет блокировку. Рекомендательная блокировка — это конвенция между корректно написанными программами, а не принудительная защита. Linux поддерживает и обязательные блокировки (mandatory locking) через монтирование с флагом mand и установку setgid-бита без execute, но этот механизм был объявлен устаревшим в ядре 5.14 (с предупреждением при монтировании с mand) и полностью удалён в 5.15. Начиная с 5.15 опция mand при монтировании игнорируется.
flock() привязан к open file description: после fork() родитель и ребёнок разделяют блокировку (и могут освободить независимо). fcntl()-блокировки привязаны к процессу и inode: если процесс закрывает любой fd, указывающий на тот же inode, все его fcntl()-блокировки на этом inode снимаются. Это контринтуитивное поведение — частый источник ошибок в коде, который открывает файл в нескольких местах.
От файлов к сокетам: O_NONBLOCK
Все предыдущие флаги и вызовы решали задачи файлового ввода-вывода --- атомарность записи, durability, позиционирование, scatter-gather, блокировки. O_NONBLOCK решает другую задачу: что делать, когда данных ещё нет?
Для обычных файлов на диске read() и write() всегда завершаются за конечное время — данные либо в page cache (микросекунды), либо на диске (миллисекунды). Но pipe и сокеты — другая история. read() из пустого pipe блокирует процесс, пока другая сторона не запишет данные. Если другая сторона занята или упала, процесс зависнет навсегда.
O_NONBLOCK меняет поведение: вместо блокировки read() немедленно возвращает -1 с ошибкой EAGAIN (или EWOULDBLOCK, то же значение). Приложение узнаёт, что данных нет, и может заняться другой работой.
int flags = fcntl(pipefd, F_GETFL);
fcntl(pipefd, F_SETFL, flags | O_NONBLOCK);
ssize_t n = read(pipefd, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
// данных нет — не блокируемся, делаем что-то другое
}Для сокетов O_NONBLOCK можно установить при создании через socket() с флагом SOCK_NONBLOCK, а для существующего fd — через fcntl(), как показано выше. На pipe флаг устанавливается через pipe2(pipefd, O_NONBLOCK) — атомарное создание неблокирующего канала без гонки между pipe() и fcntl().
O_NONBLOCK сам по себе бесполезен --- приложению пришлось бы опрашивать дескриптор в цикле, тратя CPU впустую. Сила O_NONBLOCK раскрывается в связке с мультиплексорами: epoll (Linux), kqueue (BSD). Приложение регистрирует тысячи неблокирующих дескрипторов в epoll и вызывает epoll_wait() --- ядро разбудит процесс, только когда на одном из них появятся данные. Так работают Nginx, Redis, Node.js --- один поток обслуживает тысячи соединений. Как устроены сетевые соединения --- в следующей заметке о сокетах, как мультиплексировать тысячи дескрипторов --- в мультиплексировании ввода-вывода.
Sources
- Michael Kerrisk, 2010, The Linux Programming Interface — Chapters 4-5, 13: File I/O: https://man7.org/tlpi/
man 2 open: https://man7.org/linux/man-pages/man2/open.2.htmlman 2 readv: https://man7.org/linux/man-pages/man2/readv.2.htmlman 2 pread: https://man7.org/linux/man-pages/man2/pread.2.html
← Отображение памяти | Сокеты →