Системные вызовы и ввод-вывод: файлы, дескрипторы, буферизация
Всё, что программа делает сама, — это арифметика над своей памятью. Сложить два числа, пройти по массиву, вызвать функцию — процессор выполняет это, ни у кого не спрашивая. Но программа, которая только считает и никому не показывает результат, бесполезна. В тот момент, когда нужно прочитать файл, отправить байт в сокет, узнать время или создать процесс, программа упирается в стену: она работает в непривилегированном режиме, где инструкции доступа к устройствам просто не выполняются. Единственная дверь в стене — системный вызов.
Это и есть настоящая граница языка C. Стандарт знает про fopen и printf, но не знает ни про файловые дескрипторы, ни про диски, ни про сеть — всё это даёт операционная система через свой ABI. Понимание того, что происходит на границе, объясняет вещи, которые в высокоуровневых языках выглядят как магия или как баг: почему последние строки лога пропали при падении, почему write() вернул меньше, чем вы просили, почему после fork() вывод продублировался, почему база данных вызывает fsync() и падает в панику, если он вернул ошибку. Мы разбирали, как программа выглядит изнутри: раскладку памяти в https://courses.digitable.life/post/systems-programming/02-memory-and-pointers/, кучу в https://courses.digitable.life/post/systems-programming/04-dynamic-memory/, сборку бинаря в https://courses.digitable.life/post/systems-programming/05-build-and-linking/. Теперь смотрим наружу. Про устройство ядра и файловых систем есть отдельный трек — https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/ и https://courses.digitable.life/post/operating-systems/05-filesystems/; здесь взгляд прикладного программиста на C: какие вызовы есть, как их правильно вызывать и где именно теряются данные. Все примеры — Linux x86-64, сборка: gcc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -O2 -g -o app app.c. Макрос _POSIX_C_SOURCE обязателен: со строгим -std=c17 (а не gnu17) glibc прячет всё, чего нет в стандарте C, включая open и fdatasync. Первый практический урок темы — POSIX не часть C, его нужно запросить явно.
Что такое системный вызов на самом деле
Системный вызов — не вызов функции. Обычный call переходит по адресу в том же адресном пространстве и в том же режиме процессора. Системный вызов меняет режим: процессор переключается из кольца 3 (пользователь) в кольцо 0 (ядро), переключает стек на стек ядра для этого потока и прыгает по адресу, который ядро заранее записало в служебный регистр (MSR_LSTAR). Прикладной код не выбирает, куда прыгнуть, — он может только сказать «вызов номер N с такими аргументами».
Соглашение x86-64 Linux: номер вызова в rax; аргументы 1–6 в rdi, rsi, rdx, r10, r8, r9; инструкция syscall; результат в rax; разрушаются rcx (туда кладётся rip) и r11 (туда — rflags). Обратите внимание: четвёртый аргумент в r10, а не в rcx, как в обычном System V ABI (https://courses.digitable.life/post/systems-programming/10-assembly-basics/) — именно потому, что rcx занят самой инструкцией.
// raw_write.c — системный вызов напрямую, без единой функции libc
#include <stddef.h>
static long sys_write(int fd, const void *buf, size_t n) {
long ret;
__asm__ volatile ("syscall"
: "=a"(ret) // результат из rax
: "a"(1L), "D"(fd), "S"(buf), "d"(n) // rax=1 (__NR_write), rdi, rsi, rdx
: "rcx", "r11", "memory"); // ядро портит rcx/r11, память меняется
return ret;
}
int main(void) {
const char msg[] = "привет из кольца 0 и обратно\n";
sys_write(1, msg, sizeof msg - 1); // 1 = STDOUT_FILENO
return 0;
}
Ключевая деталь: ядро не устанавливает errno. Оно возвращает в rax либо результат (≥ 0), либо отрицательный код ошибки: -EBADF = −9, -ENOSPC = −28. Переменной errno не существует на уровне ABI, это изобретение библиотеки C. Посмотрим, что реально делает обёртка glibc — objdump -d /lib/x86_64-linux-gnu/libc.so.6 | grep -A 12 '<write>:':
0000000000114870 <write>:
endbr64
mov %fs:0x18,%eax # есть ли отложенная отмена этого потока?
test %eax,%eax
jne 114890 <write+0x20> # да — медленный путь с точкой отмены
mov $0x1,%eax # __NR_write
syscall
cmp $0xfffffffffffff000,%rax # результат в диапазоне [-4095, -1]?
ja 1148e0 <write+0x70> # да — ошибка: __syscall_error установит errno
ret # нет — вернуть rax как есть
Беззнаковое сравнение с −4096 — стандартная идиома: значения от −4095 до −1 трактуются как ошибки, остальное как успех. Именно поэтому read() может вернуть 0 (конец файла) и это не ошибка, а −1 — ошибка, и только тогда errno осмысленна.
errnoв многопоточной программе — не глобальная переменная, а макрос(*__errno_location()), дающий переменную в TLS каждого потока. Читать её нужно сразу после неуспешного вызова: любая библиотечная функция между ними имеет право её испортить. Печатать — черезperror()или потокобезопасныйstrerror_r().
syscall НЕ происходит App->>Libc: fflush(f) Libc->>K: rax=1, rdi=fd, rsi=buf, rdx=len; syscall K->>K: проверка fd в таблице процесса, VFS K->>PC: copy_from_user в грязные страницы K-->>Libc: rax = число записанных байт → App Note over App,Dev: данных на диске ещё НЕТ App->>K: fsync(fileno(f)) K->>PC: writeback грязных страниц PC->>Dev: bio-запросы + FLUSH CACHE K-->>App: 0 — теперь долговечно
Цена перехода в ядро
Переход стоит денег: сохранить регистры, переключить стек, а со смягчениями Meltdown/Spectre — ещё и таблицы страниц (KPTI) и буферы предсказателя переходов. Порядок величин на современной x86-64: «пустой» вызов вроде getpid() — от ~60 нс на машине без смягчений до 300–700 нс с KPTI и retpoline; обычный вызов функции — 1–2 нс; промах в кэш L3 — ~80 нс. То есть один системный вызов эквивалентен нескольким сотням арифметических операций. Измерьте на своём железе:
// syscall_cost.c: честный syscall против вызова, обслуживаемого vDSO
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#define BENCH(label, expr) do { \
struct timespec a, b; clock_gettime(CLOCK_MONOTONIC, &a); \
for (int i = 0; i < N; i++) { expr; } \
clock_gettime(CLOCK_MONOTONIC, &b); \
double ns = (double)(b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec); \
printf("%-16s %.1f нс/вызов\n", label, ns / N); } while (0)
int main(void) {
enum { N = 2000000 }; struct timespec ts;
BENCH("getpid syscall:", syscall(SYS_getpid)); // переход в ядро
BENCH("clock_gettime:", clock_gettime(CLOCK_MONOTONIC, &ts)); // уйдёт в vDSO
return 0;
}
Второй цикл окажется в 10–30 раз быстрее, и это не ошибка измерения: clock_gettime обслуживается через vDSO — маленькую разделяемую библиотеку, которую ядро отображает в адресное пространство каждого процесса. Она читает время из страницы, обновляемой ядром, и возвращает ответ без перехода в кольцо 0. Убедиться легко: grep vdso /proc/self/maps, ldd ./app (там будет linux-vdso.so.1 без пути на диске), а strace -c ./app этих вызовов вообще не покажет.
Практический вывод, определяющий всю остальную статью: системные вызовы нужно укрупнять. Не «прочитать байт тысячу раз», а «прочитать килобайт один раз». Именно поэтому существует буферизация в stdio — и именно поэтому её поведение нужно понимать, а не терпеть. Разбор стоимости I/O со стороны производительности — в https://courses.digitable.life/post/performance/06-io-and-syscalls/.
Файловый дескриптор: маленькое целое с большими последствиями
Файловый дескриптор (fd) — это int. Не указатель, не объект, не хэндл с методами. Просто индекс в массиве, который ядро держит для вашего процесса. Значения 0, 1, 2 по соглашению — stdin, stdout, stderr, и open() всегда возвращает наименьший свободный номер (гарантия POSIX, на которой построены классические трюки с перенаправлением). За этим маленьким числом стоят три уровня косвенности, и непонимание их различия — источник целого класса багов.
- Таблица дескрипторов процесса. Своя у каждого процесса. Хранит указатель на описание открытого файла плюс единственный флаг —
FD_CLOEXEC. - Описание открытого файла (open file description, OFD). Общесистемная сущность, создаётся каждым успешным
open(). Хранит текущую позицию (offset), режим доступа, флаги статуса (O_APPEND,O_NONBLOCK) и ссылку на inode. Имеет счётчик ссылок. - inode. Один на файл. Размер, права, времена, карта блоков, страницы page cache.
Отсюда все нетривиальные следствия:
dup()/dup2()иfork()создают новый дескриптор на тот же OFD → общий offset. Два процесса дописывают в один лог, не затирая друг друга, иlseek()в одном виден другому.- Два независимых
open()одного файла → разные OFD → независимые offset. Один процесс читает файл с начала, пока другой пишет в конец. close()уменьшает счётчик ссылок OFD; файл закрывается, когда счётчик обнулился. Отсюда «удалил файл, а место не освободилось» — где-то остался открытый дескриптор (покажетlsof +L1).FD_CLOEXECживёт на первом уровне,O_APPEND/O_NONBLOCK— на втором. Поэтомуfcntl(fd, F_SETFL, O_NONBLOCK)затрагивает всех, кто разделяет OFD, аFD_CLOEXEC— только этот дескриптор.
Отсюда же практическое правило: всегда открывайте с O_CLOEXEC, иначе дескриптор утечёт в дочерний процесс через exec() — это и утечка ресурса, и дыра в безопасности (потомок с меньшими правами получит доступ к вашему файлу). Ещё лучше открывать относительно уже открытого каталога — openat(dirfd, "config", O_RDONLY | O_CLOEXEC | O_NOFOLLOW): так путь не может быть подменён между проверкой и открытием. И помните, что дескрипторы — ограниченный ресурс: лимит процесса RLIMIT_NOFILE (ulimit -n, в systemd LimitNOFILE=), при исчерпании open() даёт EMFILE, системный лимит — ENFILE. Утечка дескрипторов — второй по популярности вид утечек после утечки памяти, и в отличие от памяти её не видит ASan: нужен valgrind --track-fds=yes или наблюдение за /proc/$PID/fd.
Пять вызовов, на которых всё держится
open, read, write, lseek, close — этого хватает, чтобы написать cat, cp, лог-ротатор и половину coreutils. Тонкость в том, что почти никто не вызывает их правильно с первого раза.
Короткие чтения и записи
write() имеет полное право записать меньше, чем вы просили — не в исключительной ситуации, а в штатной: заполнился буфер канала или сокета, пришёл сигнал посреди операции, упёрлись в RLIMIT_FSIZE, кончилось место. Linux вдобавок ограничивает одну передачу 0x7ffff000 байтами (чуть меньше 2 ГиБ). Симметрично read() может вернуть меньше запрошенного, и для сокетов и каналов это норма.
Код write(fd, buf, n); без проверки возврата — ошибка. Код if (write(fd, buf, n) != n) — тоже ошибка: короткая запись здесь трактуется как фатальная. Правильно так:
#include <errno.h> /* errno, EINTR, EIO */
#include <stddef.h> /* size_t */
#include <unistd.h> /* read, write, ssize_t */
/* Записать РОВНО n байт. 0 — успех, -1 — ошибка (errno установлена). */
int write_all(int fd, const void *buf, size_t n) {
const unsigned char *p = buf;
while (n > 0) {
ssize_t w = write(fd, p, n);
if (w < 0) {
if (errno == EINTR) continue; // сигнал прервал — просто повторяем
return -1; // настоящая ошибка
}
if (w == 0) { errno = EIO; return -1; } // патология: защита от вечного цикла
p += (size_t)w; n -= (size_t)w;
}
return 0;
}
/* Прочитать до n байт. Возвращает прочитанное; меньше n значит EOF; -1 — ошибка. */
ssize_t read_full(int fd, void *buf, size_t n) {
unsigned char *p = buf; size_t got = 0;
while (got < n) {
ssize_t r = read(fd, p + got, n - got);
if (r < 0) { if (errno == EINTR) continue; return -1; }
if (r == 0) break; // EOF — это не ошибка
got += (size_t)r;
}
return (ssize_t)got;
}
EINTR и почему close() нельзя повторять
EINTR означает «вызов прерван доставкой сигнала до того, как что-то было сделано». Если обработчик установлен через sigaction с SA_RESTART, ядро само перезапустит большинство вызовов — но не все: вызовы с таймаутом (poll, select, epoll_wait, sem_timedwait) не перезапускаются никогда, иначе таймаут потерял бы смысл. Поэтому цикл с проверкой errno == EINTR — не паранойя; подробности про сигналы — в https://courses.digitable.life/post/systems-programming/08-processes-and-signals/.
Отдельный случай — close(). В Linux дескриптор освобождается всегда, даже когда close() вернул ошибку. Повторный close(fd) после EINTR — классическая гонка: в многопоточной программе другой поток мог уже получить этот номер от open(), и вы закроете чужой файл. По природе это тот же баг, что use-after-free (https://courses.digitable.life/post/systems-programming/04-dynamic-memory/), только вместо адреса — целое число. Правило: close() вызывается ровно один раз; ошибку логируем, но не повторяем. При этом игнорировать её нельзя: на сетевых ФС и при отложенном выделении блоков именно здесь всплывают ENOSPC и EIO от неудавшегося writeback.
Атомарность и позиционные операции
// O_APPEND: поиск конца файла и запись — одна атомарная операция ядра.
// Без него lseek(fd, 0, SEEK_END) + write() — гонка между процессами.
int log = open("app.log", O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
// pread/pwrite: абсолютное смещение, offset в OFD не трогается — единственный
// безопасный способ работать с одним fd из нескольких потоков. Специфика Linux:
// pwrite() ИГНОРИРУЕТ O_APPEND (см. раздел BUGS в man 2 pwrite).
ssize_t r = pread(fd, buf, len, 4096);
Записи в канал размером не более PIPE_BUF (в Linux — 4096) атомарны: сообщения нескольких писателей не перемешаются. Больше — могут. Это фундаментальное ограничение при проектировании простых межпроцессных протоколов.
Буферизация: три кэша на пути одного байта
Между вашим printf и намагниченной областью на диске стоят как минимум три независимых кэша, и каждый теряется от своего вида сбоя.
Уровень 1: буфер stdio в пространстве пользователя
FILE * — структура библиотеки C, содержащая дескриптор и буфер. У неё три режима:
| Режим | Когда по умолчанию | Когда сбрасывается |
|---|---|---|
_IOFBF — полная |
вывод не в терминал (файл, канал) | буфер заполнен, fflush, fclose, exit |
_IOLBF — построчная |
вывод в терминал (isatty(fd)) |
символ \n, заполнение, fflush |
_IONBF — без буфера |
stderr всегда |
немедленно, каждый fwrite = write |
Размер буфера glibc берёт из st_blksize, полученного fstat() (обычно 4096). Отсюда знаменитая ловушка: ./app печатает строку за строкой (терминал → _IOLBF), а ./app | grep ERROR выдаёт вывод пачками по 4 КиБ (канал → _IOFBF), и при kill -9 последних 4 КиБ в файле не окажется вообще. Никакой мистики: printf не делал write(), байты лежали в куче процесса, процесс убили — байты исчезли вместе с ним.
Лечится изнутри — setvbuf(stdout, NULL, _IOLBF, 0) до первого вывода (построчно даже в канал) или _IONBF (медленно, но не теряется ничего), — либо снаружи, без правки кода: stdbuf -oL ./app | grep ERROR.
Вторая классическая ловушка — fork() при непустом буфере: буфер копируется вместе с адресным пространством, и его содержимое выводится дважды.
printf("начало\n"); // ушло в буфер: stdout перенаправлен в файл → _IOFBF
if (fork() == 0) { printf("ребёнок\n"); _exit(0); } // буфер уже скопирован
wait(NULL); // в файле: "начало\nребёнок\nначало\n" — строка продублирована
// Лечение: fflush(NULL) НЕПОСРЕДСТВЕННО перед fork().
Уровень 2: page cache ядра и уровень 3: кэш накопителя
write() вернул число байт — значит, ядро скопировало их в свои страницы и пометило страницы грязными. На устройстве данных нет. Их запишет фоновый writeback: по таймеру (dirty_expire_centisecs, обычно 30 с), по объёму (dirty_ratio) или по явной просьбе. Это не недостаток, а главная причина, по которой файловый ввод-вывод в Linux вообще быстрый: повторное чтение не идёт на диск, мелкие записи объединяются, дописывание в конец не требует чтения блока. Внутренности — в https://courses.digitable.life/post/operating-systems/06-io-and-drivers/.
Уровень 3 — кэш внутри накопителя. У SSD и HDD есть собственная DRAM: контроллер подтверждает запись, положив данные в неё. Опустошается командой FLUSH CACHE или обходится флагом FUA на конкретной записи. Дешёвые накопители умеют подтверждать FLUSH, ничего не сделав, — на этом ломались целые поколения «надёжных» конфигураций.
Сколько стоит размер буфера
Простейший эксперимент — читать файл в 1 ГиБ буфером разного размера и считать вызовы (strace -c -e trace=read подтвердит цифры). Порядок величин при прогретом page cache — у вас будут свои числа, но соотношения устойчивы:
| Размер буфера | Вызовов read() |
Время | Комментарий |
|---|---|---|---|
| 1 байт | 1 073 741 824 | ~350 с | всё время — переходы в ядро |
| 16 байт | 67 108 864 | ~22 с | всё ещё катастрофа |
| 512 байт | 2 097 152 | ~0,9 с | уже терпимо |
| 4 КиБ | 262 144 | ~0,30 с | размер страницы — колено кривой |
| 64 КиБ | 16 384 | ~0,24 с | практический оптимум |
| 1 МиБ | 1 024 | ~0,24 с | выигрыша нет, портится кэш L2 |
mmap |
0 | ~0,22 с | вместо вызовов — отказы страниц |
Кривая имеет резкое колено около размера страницы и выходит на плато к 64 КиБ. Гнаться за мегабайтными буферами бессмысленно: упрётесь в пропускную способность памяти и вытеснение кэша процессора (https://courses.digitable.life/post/computer-science/06-memory-hierarchy/). Разумная эвристика — max(64 КиБ, st_blksize).
Долговечность: write() не значит «записано»
Здесь ломаются базы данных, файловые синхронизаторы и брокеры сообщений. Разделите три понятия:
fflush(FILE*)— переносит байты из буфера библиотеки в ядро, делаетwrite(). Про диск не гарантирует ничего.fsync(int fd)— приказывает ядру довести данные и метаданные файла до устройства и дождаться подтверждения, включая FLUSH кэша накопителя.fdatasync(int fd)— то же без метаданных, не нужных для чтения данных: пропускает mtime, но размер файла обновляет. Заметно быстрее при частых синхронизациях в СУБД.
Для FILE* нужны оба, именно в этом порядке: fflush(f) (библиотека → ядро), затем fsync(fileno(f)) (ядро → носитель).
Атомарная замена файла
Задача: у пользователя должна быть либо старая версия конфига, либо новая, и никогда — половина новой. Единственный корректный рецепт на POSIX:
РЯДОМ с целевым"] --> B["write_all данных"] B --> C["fsync временного файла,
затем close"] C -->|"ошибка"| X["Удалить временный,
сообщить о неудаче"] C --> E["rename tmp в target:
атомарно в пределах одной ФС"] E --> F["Открыть КАТАЛОГ и сделать ему fsync:
переименование тоже должно пережить сбой"] F --> I["Готово: старый файл или новый,
третьего не дано"] style X fill:#c0563f,fill-opacity:0.15 style I fill:#3f8f5c,fill-opacity:0.15
// atomic_save.c — сохранение с гарантией «всё или ничего» (write_all — см. выше)
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <libgen.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
int atomic_save(const char *path, const void *data, size_t len) {
char tmp[4096], dir[4096];
snprintf(tmp, sizeof tmp, "%s.tmp.XXXXXX", path);
int fd = mkstemp(tmp); // создаёт с правами 0600
if (fd < 0) return -1;
if (fchmod(fd, 0644) < 0 || write_all(fd, data, len) < 0 || fsync(fd) < 0) {
int e = errno; close(fd); unlink(tmp); errno = e; return -1; // fsync: данные на носителе
}
if (close(fd) < 0 || rename(tmp, path) < 0) { // rename — атомарная подмена имени
int e = errno; unlink(tmp); errno = e; return -1;
}
// Сам факт переименования тоже живёт в page cache каталога: без fsync каталога
// после сбоя питания можно обнаружить старое имя при уже записанном содержимом.
snprintf(dir, sizeof dir, "%s", path);
int dfd = open(dirname(dir), O_RDONLY | O_DIRECTORY | O_CLOEXEC);
if (dfd < 0) return -1;
int rc = fsync(dfd);
close(dfd);
return rc;
}
rename() атомарен только в пределах одной файловой системы — поэтому временный файл создаётся рядом с целевым, а не в /tmp.
Ошибка fsync() — это не «попробуй ещё раз»
Ещё в 2018 году разработчики PostgreSQL обнаружили (fsyncgate, разбор в LWN): при ошибке writeback Linux выбрасывает грязные страницы, помечает их чистыми и сообщает об ошибке через fsync() только один раз. Повторный fsync() вернёт 0 — а данных при этом нет нигде. До ядра 4.13 ошибку получал лишь тот дескриптор, который случайно оказался первым; после — счётчик errseq_t доставляет её каждому открытому дескриптору, но ровно однократно.
Правило, к которому пришли все серьёзные системы хранения: ошибка fsync() фатальна. Не ретраить, не продолжать, а считать состояние на диске неизвестным и восстанавливаться из журнала; PostgreSQL по умолчанию делает PANIC (data_sync_retry = off). Академический разбор — Rebello et al., «Can Applications Recover from fsync Failures?», USENIX ATC 2020, популярный обзор смежных граблей — Dan Luu, «Files are hard».
Два вызова, которые часто путают с fsync(): sync_file_range() — не даёт долговечности (man-страница предупреждает прямым текстом), это лишь подсказка планировщику writeback; posix_fadvise(..., POSIX_FADV_DONTNEED) — просьба выбросить страницы из кэша, полезна при однократном проходе по гигабайтам, чтобы не вытеснить чужие горячие данные.
Не блокировать: O_NONBLOCK, poll, epoll
По умолчанию read() из сокета или канала блокирует поток до появления данных. Для одного соединения это нормально; для десяти тысяч — нет: поток стоит около мегабайта стека, и переключения между ними тоже стоят времени. Флаг O_NONBLOCK меняет контракт: если данных нет, read() немедленно возвращает −1 с errno == EAGAIN (он же EWOULDBLOCK). Это не ошибка, а ответ «пока нечего». Дальше нужен способ узнать, когда появится.
событий нет" as Idle state "epoll_wait вернул EPOLLIN" as Ready state "read вернул данные" as Reading state "read = -1, EAGAIN:
буфер ядра пуст" as Drained state "read = 0: EOF или
EPOLLERR / EPOLLHUP" as Eof [*] --> Idle: epoll_ctl ADD с EPOLLIN и EPOLLET Idle --> Ready: пришли данные Ready --> Reading: read Reading --> Reading: ещё данные в буфере Reading --> Drained: буфер опустошён Drained --> Idle: ждём следующее событие Reading --> Eof: r == 0 или ошибка сокета Eof --> [*]: close, fd удалён из epoll note right of Drained Краевой режим ET: пока не дошли до EAGAIN, нового события НЕ будет. Вышли из цикла раньше — соединение зависло до таймаута. end note
Три поколения механизмов ожидания: select() — O(n) на итерацию, жёсткий лимит FD_SETSIZE = 1024, набор пересобирается каждый раз; poll() — O(n), лимита нет, но массив копируется в ядро при каждом вызове; epoll — O(1) на готовый дескриптор, набор живёт в ядре между вызовами, зато Linux-специфичен (в BSD аналог — kqueue). При этом epoll работает в двух режимах. Уровневый (LT, по умолчанию) — «сообщать, пока есть данные»; прощает недочитывание. Краевой (ET, EPOLLET) — «сообщить один раз при изменении»; требует дочитывания до EAGAIN, зато не будит поток лишний раз. Классическая ошибка ET — прочитать один буфер и вернуться в epoll_wait: остаток лежит в ядре, нового события не будет, соединение висит. Событийная модель целиком — тема сетевого трека (https://courses.digitable.life/post/networking/03-tcp/); здесь важно, что O_NONBLOCK — флаг на уровне OFD, а не свойство функции чтения, и что EAGAIN обязан обрабатывать любой цикл ввода-вывода.
Меньше копий: writev, sendfile, mmap, io_uring
Обычная пара read() + write() копирует данные дважды: устройство → page cache → буфер процесса → page cache → устройство. Ядро предлагает несколько способов срезать углы.
// writev: заголовок и тело — одна запись, без склеивания в промежуточный буфер.
// Экономит копирование и системный вызов, сохраняя атомарность записи.
struct iovec iov[2] = { { header, hlen }, { body, blen } };
writev(sock, iov, 2);
off_t off = 0;
sendfile(sock, filefd, &off, filesize); // файл → сокет мимо user space (так отдаёт nginx)
copy_file_range(src, NULL, dst, NULL, size, 0); // файл → файл силами ядра, на CoW-ФС мгновенно
mmap() заслуживает предупреждения. Он превращает файл в массив: чтение элемента — это отказ страницы, который ядро обслуживает подкачкой; системных вызовов нет вообще, что прекрасно для случайного доступа к большому индексу. Но усечение файла другим процессом даёт SIGBUS вместо EIO, ошибка чтения с диска тоже приходит сигналом, а не кодом возврата, и «сохранить» — это msync(), а не fsync(). Разработчики СУБД написали на эту тему отдельную статью — «Are You Sure You Want to Use MMAP in Your Database Management System?» (CIDR 2022).
io_uring (ядро 5.1+) — современный ответ на дороговизну переходов: два кольцевых буфера в памяти, разделяемой с ядром. Приложение кладёт описания операций в кольцо отправки, ядро складывает результаты в кольцо завершения; при IORING_SETUP_SQPOLL в установившемся режиме системных вызовов нет вовсе. Каноническое введение — «Efficient IO with io_uring» Йенса Аксбо.
Где здесь неопределённое поведение и как ломается «на моей машине»
Системный вызов — граница, за которой аргументы проверяет ядро, а не компилятор. Ядро проверит права и адреса (вернёт EFAULT вместо падения), но не проверит смысл. Типичные способы выстрелить себе в ногу:
Буфер меньше, чем третий аргумент read(). Ядро честно запишет столько байт, сколько попросили, — поверх соседних переменных. Это переполнение буфера (https://courses.digitable.life/post/systems-programming/03-arrays-strings-structs/), только источник данных внешний, то есть управляемый атакующим. ASan ловит немедленно; привычка писать sizeof buf вместо константы ловит ещё дешевле.
Игнорирование возвращаемого значения и путаница со знаковостью. glibc помечает write атрибутом __wur не из вредности: молчаливая потеря данных — самый дорогой класс багов. Рядом живёт вторая ловушка: read() возвращает ssize_t, а не int, и сравнение if (read(fd, b, n) < n) с беззнаковым n ломается полностью — −1 конвертируется в огромное беззнаковое число, и условие оказывается ложным. Компилируйте с -Wsign-compare (входит в -Wextra).
Файлы больше 2 ГиБ на 32-битных системах. Без -D_FILE_OFFSET_BITS=64 тип off_t 32-битный и lseek() за 2 ГиБ даёт EOVERFLOW. Ровно тот случай, когда «на моей машине работает»: на x86-64 всё в порядке, на armhf-роутере — нет. Смежная тема — 2038 год и 32-битный time_t; в https://courses.digitable.life/post/embedded/02-c-for-embedded/ это живая проблема, а не историческая.
TOCTOU — гонка между проверкой и использованием, классика уязвимостей в setuid-программах:
// ПЛОХО (CWE-367): между access() и open() путь подменяют символической ссылкой.
if (access(path, W_OK) == 0) { // проверяет правами РЕАЛЬНОГО uid
int fd = open(path, O_WRONLY); // открывает правами ЭФФЕКТИВНОГО uid
}
// ХОРОШО: открыть и разбираться с ошибкой, затем проверить УЖЕ ОТКРЫТЫЙ объект.
int fd = open(path, O_WRONLY | O_NOFOLLOW | O_CLOEXEC);
struct stat st;
if (fd >= 0 && fstat(fd, &st) == 0 && !S_ISREG(st.st_mode)) { close(fd); /* не файл */ }
Общий принцип: проверяйте объект, а не имя. Имя может измениться между двумя вашими вызовами, открытый дескриптор — нет. Отсюда всё семейство openat, fstatat, unlinkat, renameat2.
Небезопасные функции в обработчике сигнала. printf() не async-signal-safe: если сигнал пришёл, пока основной поток был внутри printf, повторный вход разрушит структуру FILE. write() безопасен, потому что это тонкая обёртка над syscall. Список — man 7 signal-safety.
SIGPIPE. Запись в канал или сокет, закрытый с той стороны, по умолчанию убивает процесс: именно поэтому ./app | head -3 завершает app. В долгоживущем сервере обязательны signal(SIGPIPE, SIG_IGN) (тогда write вернёт EPIPE) или send(..., MSG_NOSIGNAL).
Отличие этих ошибок от «обычного» неопределённого поведения (https://courses.digitable.life/post/systems-programming/11-undefined-behavior/) в том, что компилятор здесь не строит агрессивных предположений — он не знает семантику системных вызовов. Зато поведение зависит от ядра, файловой системы, опций монтирования, sandbox и прав. Спектр «работает на моей машине» тут даже шире: код может годами работать на ext4 и разваливаться на NFS, где O_APPEND не атомарен, а блокировки работают иначе.
Инструменты: смотреть, а не гадать
strace -f -e trace=openat,read,write,fsync ./app # что и с какими аргументами
strace -c ./app # сводка: сколько вызовов и где время; -y добавит пути к номерам fd
perf trace ./app # то же через perf, заметно дешевле; ltrace — вызовы libc
ls -l /proc/$PID/fd # открытые дескрипторы; /proc/$PID/io — счётчики прочитанных байт
lsof -p $PID # что именно открыто; lsof +L1 — удалённые, но всё ещё держимые файлы
valgrind --track-fds=yes ./app # утечки дескрипторов
Самый ценный приём — сравнить strace -c -e trace=write до и после изменения. Один и тот же вывод миллиона строк даст либо ~250 вызовов write (буферизованный printf), либо миллион (наивный write на строку). Это же мгновенно показывает, работает ли ваша буферизация вообще.
Практика: cat, который не стыдно показать
// mycat.c — gcc -std=c17 -Wall -Wextra -O2 -o mycat mycat.c (write_all — см. выше)
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
static int copy_fd(int in, int out) {
struct stat st;
size_t bs = 65536; // разумный минимум
if (fstat(in, &st) == 0 && (size_t)st.st_blksize > bs)
bs = (size_t)st.st_blksize; // уважаем подсказку ФС
char *buf = malloc(bs);
if (!buf) return -1;
int rc = 0;
for (;;) { // тот самый укрупнённый ввод-вывод
ssize_t r = read(in, buf, bs);
if (r == 0) break; // EOF
if (r < 0) { if (errno == EINTR) continue; rc = -1; break; }
if (write_all(out, buf, (size_t)r) < 0) { rc = -1; break; }
}
free(buf);
return rc;
}
int main(int argc, char **argv) {
if (argc == 1) return copy_fd(0, 1) < 0 ? (perror("stdin"), 1) : 0;
int rc = 0;
for (int i = 1; i < argc; i++) {
int fd = open(argv[i], O_RDONLY | O_CLOEXEC);
if (fd < 0) { perror(argv[i]); rc = 1; continue; }
if (copy_fd(fd, STDOUT_FILENO) < 0) { perror(argv[i]); rc = 1; }
close(fd); // ровно один раз, ошибку не ретраим
}
return rc; // ненулевой код, если хотя бы один файл не прочитался
}
Тридцать строк, а решений в них уже пять: размер буфера из st_blksize, обработка EINTR, корректная работа с короткими записями, O_CLOEXEC, продолжение после ошибки на одном файле. Настоящий cat из coreutils добавляет быстрый путь через copy_file_range и обработку разрежённых файлов — но принципиально это тот же цикл.
Итог
- Системный вызов — смена режима процессора, а не вызов функции. Номер в
rax, аргументы вrdi/rsi/rdx/r10/r8/r9, ответ вrax; значения от −4095 до −1 — коды ошибок,errnoпридумана библиотекой C. Переход стоит сотни наносекунд — отсюда весь дизайн ввода-вывода: укрупнять операции, буферизовать, пользоваться vDSO, векторными вызовами иio_uring. - Дескриптор — индекс, за которым три уровня: таблица процесса (здесь
FD_CLOEXEC), описание открытого файла (здесь offset,O_APPEND,O_NONBLOCK), inode.dup()иfork()разделяют offset, дваopen()— нет. read()иwrite()имеют право на короткую операцию. Цикл с обработкойEINTR— минимальный корректный код, а не паранойя.close()вызывается ровно один раз.- Буферизаций три: stdio, page cache, кэш накопителя.
fflush()≠fsync(), и каждый уровень теряется от своего сбоя: падение процесса, паника ядра, пропадание питания. Ошибкаfsync()фатальна: данные не «где-то ждут», они выброшены, а повторный вызов вернёт успех и соврёт. Долговечная запись — только через связку временный файл →fsync→rename→fsyncкаталога. EAGAIN— штатный ответ, а не ошибка; краевой режимepollобязывает читать доEAGAIN, иначе соединение зависает. А сами опасности здесь идут от окружения, а не от компилятора: TOCTOU на путях,SIGPIPE,off_tна 32 битах, небезопасные функции в обработчике сигнала. Проверяйте открытый объект, а не имя.
Источники
- M. Kerrisk. The Linux Programming Interface — https://man7.org/tlpi/ (главы 4, 5, 13, 63 — эта статья, но на 200 страницах); W. R. Stevens, S. Rago. Advanced Programming in the UNIX Environment, 3rd ed.
- Man-страницы: https://man7.org/linux/man-pages/man2/write.2.html,
man 2 open,man 2 fsync,man 7 epoll,man 7 vdso,man 7 signal-safety; таблица системных вызовов с регистрами — https://filippo.io/linux-syscall-table/ - PostgreSQL Wiki. Fsync Errors — https://wiki.postgresql.org/wiki/Fsync_Errors; разбор в LWN — https://lwn.net/Articles/752063/
- A. Rebello et al. Can Applications Recover from fsync Failures?, USENIX ATC 2020 — https://www.usenix.org/conference/atc20/presentation/rebello; D. Luu. Files are hard — https://danluu.com/file-consistency/; J. Axboe. Efficient IO with io_uring — https://kernel.dk/io_uring.pdf
- A. Crotty et al. Are You Sure You Want to Use MMAP in Your DBMS?, CIDR 2022 — https://db.cs.cmu.edu/mmap-cidr2022/
Что дальше
Мы научились разговаривать с ядром об одном ресурсе — файле. Но самый интересный системный вызов не читает и не пишет: он создаёт второй экземпляр вашей программы. Дальше — fork() и копирование при записи, exec() и замена образа процесса, каналы как способ соединить программы в конвейер и сигналы как асинхронные прерывания, приходящие между любыми двумя инструкциями.