Системное программирование на C Системные вызовы и ввод-вывод: файлы, дескрипторы, буферизация
0%

Системные вызовы и ввод-вывод: файлы, дескрипторы, буферизация

Системные вызовы и ввод-вывод: файлы, дескрипторы, буферизация

Всё, что программа делает сама, — это арифметика над своей памятью. Сложить два числа, пройти по массиву, вызвать функцию — процессор выполняет это, ни у кого не спрашивая. Но программа, которая только считает и никому не показывает результат, бесполезна. В тот момент, когда нужно прочитать файл, отправить байт в сокет, узнать время или создать процесс, программа упирается в стену: она работает в непривилегированном режиме, где инструкции доступа к устройствам просто не выполняются. Единственная дверь в стене — системный вызов.

Это и есть настоящая граница языка 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().

Цена перехода в ядро

Переход стоит денег: сохранить регистры, переключить стек, а со смягчениями 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, на которой построены классические трюки с перенаправлением). За этим маленьким числом стоят три уровня косвенности, и непонимание их различия — источник целого класса багов.

Три уровня: таблица дескрипторов процесса, таблица открытых файлов, таблица inode

  1. Таблица дескрипторов процесса. Своя у каждого процесса. Хранит указатель на описание открытого файла плюс единственный флаг — FD_CLOEXEC.
  2. Описание открытого файла (open file description, OFD). Общесистемная сущность, создаётся каждым успешным open(). Хранит текущую позицию (offset), режим доступа, флаги статуса (O_APPEND, O_NONBLOCK) и ссылку на inode. Имеет счётчик ссылок.
  3. 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 и намагниченной областью на диске стоят как минимум три независимых кэша, и каждый теряется от своего вида сбоя.

Слои буферизации: stdio, page cache, кэш накопителя

Уровень 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:

// 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). Это не ошибка, а ответ «пока нечего». Дальше нужен способ узнать, когда появится.

Три поколения механизмов ожидания: 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() фатальна: данные не «где-то ждут», они выброшены, а повторный вызов вернёт успех и соврёт. Долговечная запись — только через связку временный файл → fsyncrenamefsync каталога.
  • EAGAIN — штатный ответ, а не ошибка; краевой режим epoll обязывает читать до EAGAIN, иначе соединение зависает. А сами опасности здесь идут от окружения, а не от компилятора: TOCTOU на путях, SIGPIPE, off_t на 32 битах, небезопасные функции в обработчике сигнала. Проверяйте открытый объект, а не имя.

Источники

Что дальше

Мы научились разговаривать с ядром об одном ресурсе — файле. Но самый интересный системный вызов не читает и не пишет: он создаёт второй экземпляр вашей программы. Дальше — fork() и копирование при записи, exec() и замена образа процесса, каналы как способ соединить программы в конвейер и сигналы как асинхронные прерывания, приходящие между любыми двумя инструкциями.

Процессы и сигналы: fork, exec, каналы, обработка сигналов

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов