Файловые дескрипторы

Предпосылки: потоки (общее адресное пространство, разделение ресурсов).

Потоки | Виртуальная память

Потоки и процессы — контексты выполнения. Они исполняют инструкции, работают с памятью, но рано или поздно им нужно взаимодействовать с внешним миром: прочитать файл конфигурации, записать лог, принять сетевое соединение, отправить данные в другой процесс. Для всего этого ядро Linux использует одну абстракцию — файловый дескриптор (file descriptor, fd): целое число, через которое процесс обращается к ресурсу.

Всё — файл

В Unix файл — не только данные на диске. Сетевой сокет, канал между процессами (pipe), терминал, блочное устройство /dev/sda, генератор случайных чисел /dev/urandom — всё это «файлы» с точки зрения процесса. К каждому из них применимы одни и те же системные вызовы: read(), write(), close(). Процесс не знает и не обязан знать, что стоит за fd=3 — обычный файл, сокет или pipe. Он вызывает write(3, buf, len), а ядро направляет данные куда нужно.

Это упрощает код: ls пишет результат в fd=1 и не задумывается, куда идут байты — в терминал, в файл или в pipe к grep. Механизм перенаправления работает именно потому, что интерфейс един.

Откуда берётся дескриптор

Каждый системный вызов, открывающий ресурс, возвращает fd:

int fd = open("/var/log/app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
// fd = 3 (если stdin/stdout/stderr уже заняли 0, 1, 2)

open() открывает файл на диске. socket() создаёт сетевой сокет. accept() принимает входящее соединение и возвращает новый fd для него. pipe() создаёт канал и возвращает сразу два fd — на чтение и на запись. Во всех случаях ядро выбирает наименьший свободный номер в таблице дескрипторов процесса. Если fd 0, 1 и 2 заняты, первый open() вернёт 3, следующий — 4.

Три стандартных дескриптора

Каждый процесс рождается с тремя уже открытыми fd:

fdИмяНазначениеТипичный ресурс
0stdinВводКлавиатура терминала
1stdoutВыводЭкран терминала
2stderrОшибкиЭкран терминала

Когда программа вызывает printf("hello"), libc записывает строку в fd=1. Когда perror() выводит сообщение об ошибке — в fd=2. Разделение stdout и stderr позволяет перенаправить вывод в файл (./app > out.log), а ошибки по-прежнему видеть в терминале.

Эти три fd — не магия ядра. Их создаёт и передаёт процесс-родитель (обычно shell — командная оболочка) перед запуском программы. Если закрыть fd=0 и вызвать open(), новый файл получит номер 0 — ядро всегда выбирает наименьший свободный.

Трёхуровневая таблица

За простым числом стоит цепочка из трёх структур ядра. Без этой схемы невозможно понять, почему fork() разделяет одни свойства, а другие — нет.

 Процесс A (pid 100)              Процесс B (pid 101)
 ┌──────────────────┐              ┌──────────────────┐
 │  fd table         │              │  fd table         │
 │ ┌────┬──────────┐ │              │ ┌────┬──────────┐ │
 │ │ 0  │ ------> ─┼─┤              │ │ 0  │ ------> ─┼─┤
 │ │ 1  │ ------> ─┼─┤              │ │ 1  │ ------> ─┼─┤
 │ │ 2  │ ------> ─┼─┤              │ │ 2  │ ------> ─┼─┤
 │ │ 3  │ ------> ─┼─┼──┐           │ │ 3  │ ------> ─┼─┼──┐
 │ └────┴──────────┘ │  │           │ └────┴──────────┘ │  │
 └──────────────────┘  │           └──────────────────┘  │
                        │                                  │
   (a) Per-process      │     (b) System-wide              │
       fd table         v         open file table          │
                   ┌──────────────────────┐                │
                   │  open file           │ <──────────────┘
                   │  description         │
                   │                      │
                   │  offset: 4096        │
                   │  mode: O_WRONLY      │
                   │  flags: O_APPEND     │
                   │  refcount: 2         │
                   └─────────┬────────────┘
                             │
                             v
                   ┌──────────────────────┐
                   │  inode               │  (c) Inode
                   │                      │
                   │  file size: 82301    │
                   │  permissions: 0644   │
                   │  block pointers      │
                   │  device: /dev/sda1   │
                   └──────────────────────┘

(a) Таблица дескрипторов процесса (per-process fd table). Каждый процесс имеет свой массив. Индекс — номер fd, значение — указатель на запись в системной таблице. Когда процесс вызывает read(3, ...), ядро берёт элемент с индексом 3 из его таблицы и переходит по указателю.

(b) Открытое файловое описание (open file description). Системная структура, общая для всего ядра. Хранит текущее смещение (offset), режим открытия (чтение/запись), флаги (O_APPEND, O_NONBLOCK) и счётчик ссылок. Одно описание может быть разделено между несколькими fd — и это ключ к пониманию fork() и dup().

(c) Inode. Представляет сам ресурс: файл на диске, сокет, pipe. Содержит метаданные (размер, права, временные метки) и указатели на данные. Два независимых open() одного файла создадут два разных open file description, но оба укажут на один inode.

fork() и разделённое смещение

Родительский процесс открывает лог-файл, затем вызывает fork():

int fd = open("/var/log/app.log", O_WRONLY | O_APPEND, 0644);
// fd = 3, offset = 0
 
pid_t pid = fork();
if (pid == 0) {
    // дочерний процесс
    write(fd, "child: started\n", 15);
} else {
    // родительский процесс
    write(fd, "parent: forked\n", 15);
}

fork() копирует таблицу дескрипторов процесса, но не копирует open file description. У ребёнка fd=3 указывает на ту же запись в системной таблице, что и у родителя. Offset, режим, флаги — общие. refcount увеличивается с 1 до 2.

Это означает: если дочерний процесс вызывает write() и записывает 15 байт, смещение в open file description сдвигается на 15. Следующий write() родителя начнётся с позиции 15, а не с 0. Записи не затирают друг друга, но чередуются непредсказуемо — какой процесс получит CPU раньше, тот и запишет первым.

С флагом O_APPEND ситуация надёжнее: перед каждым write() ядро атомарно перемещает offset в конец файла. Два процесса с O_APPEND не перезатрут данные друг друга, хотя порядок строк непредсказуем.

Если же оба процесса откроют файл независимо (каждый вызовет open() сам), они получат разные open file description с отдельными offset. Запись одного процесса может затереть данные другого, потому что оба начнут писать с offset=0.

 fork():                              Независимый open():

 parent fd=3 --\                      parent fd=3 --> description A (offset=0)
                 --> description (offset общий)
 child  fd=3 --/                      child  fd=3 --> description B (offset=0)

 Общий offset: записи идут             Раздельные offset: записи
 последовательно (чередуясь)           затирают друг друга

dup и dup2: несколько номеров — один ресурс

dup() создаёт копию fd внутри одного процесса: новый номер указывает на то же open file description.

int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// fd = 3
int fd2 = dup(fd);
// fd2 = 4, оба указывают на одно description

write(3, ...) и write(4, ...) пишут в один файл с общим offset — как fork(), но внутри одного процесса.

dup2() делает то же самое, но позволяет выбрать номер для копии. Если целевой fd занят — он сначала закрывается:

dup2(fd, 1);  // fd=1 теперь указывает туда же, куда fd=3

После этого вызова stdout (fd=1) перенаправлен в файл output.txt. Любая программа, пишущая в stdout, теперь пишет в файл, не подозревая об этом.

Именно так shell реализует перенаправление >. Когда пользователь вводит ls > files.txt, shell выполняет последовательность:

pid_t pid = fork();
if (pid == 0) {
    // дочерний процесс
    int fd = open("files.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, STDOUT_FILENO);  // fd=1 теперь -> files.txt
    close(fd);                 // fd=3 больше не нужен
    execvp("ls", args);        // ls пишет в fd=1, думая, что это терминал
}

ls вызывает write(1, ...) — и байты идут в файл. Программа не знает о перенаправлении. Подмена произошла до exec(), а exec() сохраняет таблицу дескрипторов.

pipe: канал между процессами

pipe() создаёт однонаправленный канал с буфером в ядре (~64 КБ на Linux). Системный вызов заполняет массив из двух fd: pipefd[0] — конец для чтения, pipefd[1] — конец для записи.

int pipefd[2];
pipe(pipefd);
// pipefd[0] = 3 (read end)
// pipefd[1] = 4 (write end)

Данные, записанные в pipefd[1], появляются при чтении из pipefd[0]. Это как труба: байты входят с одной стороны и выходят с другой, в том же порядке. Pipe не поддерживает seek — только последовательный поток.

Когда shell встречает ls | grep .md, он комбинирует pipe, fork и dup2:

int pipefd[2];
pipe(pipefd);
 
// Первый дочерний процесс: ls
pid_t pid1 = fork();
if (pid1 == 0) {
    close(pipefd[0]);           // ls не читает из pipe
    dup2(pipefd[1], STDOUT_FILENO); // stdout -> write end
    close(pipefd[1]);           // оригинал больше не нужен
    execvp("ls", ls_args);
}
 
// Второй дочерний процесс: grep
pid_t pid2 = fork();
if (pid2 == 0) {
    close(pipefd[1]);           // grep не пишет в pipe
    dup2(pipefd[0], STDIN_FILENO);  // stdin -> read end
    close(pipefd[0]);
    execvp("grep", grep_args);
}
 
// Родитель закрывает оба конца — он не участвует в обмене
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

ls пишет в stdout, не зная, что stdout — это write end pipe. grep читает из stdin, не зная, что stdin — это read end того же pipe. Каждый процесс работает со стандартными fd 0 и 1, а связь между ними установлена родителем до exec().

Закрытие неиспользуемых концов pipe критично. Если родитель не закроет pipefd[1], grep никогда не получит EOF (End Of File) при чтении: ядро считает pipe открытым, пока существует хоть один fd на запись. grep повиснет навсегда, ожидая данных, которые никто не отправит.

Буфер pipe ограничен: на Linux это 65536 байт (16 страниц по 4 КБ). Когда буфер заполнен, write() блокируется, пока читатель не заберёт данные. Когда буфер пуст, read() блокируется, пока писатель не добавит. Это встроенная синхронизация без явных мьютексов — паттерн «производитель-потребитель» (producer-consumer) через механизм ядра.

close() и утечка дескрипторов

close(fd) удаляет запись из таблицы дескрипторов процесса и уменьшает refcount в open file description. Когда refcount падает до нуля, ядро освобождает description. Когда на inode больше нет ссылок (ни от open file description, ни от directory entry) — освобождается и сам ресурс.

Каждый процесс имеет лимит на количество открытых дескрипторов. Мягкий лимит (soft limit) по умолчанию — 1024, жёсткий (hard limit) — обычно до 1048576 (~1 млн). Мягкий лимит процесс может поднять сам через setrlimit() до значения жёсткого. Жёсткий может поднять только root (суперпользователь).

$ ulimit -n        # мягкий лимит
1024
$ ulimit -Hn       # жёсткий лимит
1048576

Утечка дескрипторов — распространённая ошибка серверных приложений. Веб-сервер принимает соединения через accept(), каждый вызов возвращает новый fd. Если после обработки запроса забыть вызвать close(), дескрипторы накапливаются. При 100 запросах в секунду лимит в 1024 исчерпается за ~10 секунд (три стандартных fd + один серверный сокет = 1020 свободных). Следующий accept() вернёт ошибку EMFILE (too many open files — слишком много открытых файлов), и сервер перестанет принимать соединения.

Диагностика: заглянуть в /proc/<pid>/fd. /proc — виртуальная файловая система, через которую ядро показывает информацию о процессах (подробнее). Каждый открытый дескриптор отображается как символическая ссылка (файл-указатель, хранящий путь к другому файлу) на ресурс:

$ ls -la /proc/12345/fd
lrwx------ 1 app app 64 Mar 23 10:00 0 -> /dev/pts/0
lrwx------ 1 app app 64 Mar 23 10:00 1 -> /dev/pts/0
lrwx------ 1 app app 64 Mar 23 10:00 2 -> /dev/pts/0
lrwx------ 1 app app 64 Mar 23 10:00 3 -> socket:[98765]
lrwx------ 1 app app 64 Mar 23 10:00 4 -> socket:[98766]
...
lrwx------ 1 app app 64 Mar 23 10:00 1023 -> socket:[99784]

Тысяча сокетов — верный признак утечки. Команда lsof -p 12345 покажет, какие именно соединения открыты (IP-адреса, порты). Если же нужно посчитать быстро:

$ ls /proc/12345/fd | wc -l
1024

Автоматическое закрытие при завершении

Когда процесс завершается (через exit(), получение сигнала или возврат из main()), ядро автоматически закрывает все его дескрипторы. Это значит, что утечка дескрипторов — проблема долгоживущих процессов: серверов, демонов, воркеров. Короткоживущие утилиты вроде ls или cat могут позволить себе не закрывать fd вручную — процесс завершится через миллисекунды, и ядро уберёт за ним.

Но полагаться на это в серверном коде — ошибка. Сервер живёт часы, дни, недели. Каждый незакрытый fd — это занятый слот в таблице и ресурс ядра (буфер сокета, файловый кеш). При 1000 утечек в минуту сервер упадёт через секунды, даже если поднять лимит до миллиона.

Полная картина: сценарий с fork(), pipe и dup2

Соберём все механизмы. Сервер логирования: родительский процесс открывает лог-файл и форкает два воркера. Каждый воркер соединён с родителем через pipe — родитель агрегирует сообщения и пишет в лог.

int logfd = open("/var/log/app.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
// logfd = 3
 
int pipe1[2], pipe2[2];
pipe(pipe1);   // pipe1[0]=4, pipe1[1]=5
pipe(pipe2);   // pipe2[0]=6, pipe2[1]=7
 
pid_t w1 = fork();
if (w1 == 0) {
    // Worker 1: пишет в pipe1
    close(pipe1[0]); close(pipe2[0]); close(pipe2[1]); close(logfd);
    write(pipe1[1], "w1: request handled\n", 20);
    close(pipe1[1]);
    _exit(0);
}
 
pid_t w2 = fork();
if (w2 == 0) {
    // Worker 2: пишет в pipe2
    close(pipe2[0]); close(pipe1[0]); close(pipe1[1]); close(logfd);
    write(pipe2[1], "w2: request handled\n", 20);
    close(pipe2[1]);
    _exit(0);
}
 
// Родитель: читает из обоих pipe, пишет в лог
close(pipe1[1]); close(pipe2[1]);  // не пишет в pipe
 
char buf[256];
ssize_t n;
while ((n = read(pipe1[0], buf, sizeof(buf))) > 0)
    write(logfd, buf, n);
while ((n = read(pipe2[0], buf, sizeof(buf))) > 0)
    write(logfd, buf, n);
 
close(pipe1[0]); close(pipe2[0]); close(logfd);

Структура дескрипторов после fork() первого воркера, до закрытия лишних:

 Parent                 Worker 1                Kernel
 fd table               fd table
 ┌────┬─────┐           ┌────┬─────┐
 │ 0  │ --> stdin        │ 0  │ --> stdin
 │ 1  │ --> stdout       │ 1  │ --> stdout
 │ 2  │ --> stderr       │ 2  │ --> stderr
 │ 3  │ --> log desc ─── │ 3  │ --> log desc ------> inode /var/log/app.log
 │ 4  │ --> pipe1 read   │ 4  │ --> pipe1 read ----> pipe1 buffer (64KB)
 │ 5  │ --> pipe1 write  │ 5  │ --> pipe1 write --/
 │ 6  │ --> pipe2 read   │ 6  │ --> pipe2 read ----> pipe2 buffer (64KB)
 │ 7  │ --> pipe2 write  │ 7  │ --> pipe2 write --/
 └────┴─────┘           └────┴─────┘

Worker 1 закрывает всё ненужное (pipe1[0], pipe2[0], pipe2[1], logfd), оставляя только pipe1[1] — конец для записи в свой канал. Родитель закрывает write-концы обоих pipe (pipe1[1], pipe2[1]) — он только читает. Каждый процесс держит минимум дескрипторов: ровно те, которые ему нужны.

Файловый дескриптор — точка входа процесса во внешний мир. Целое число, за которым стоит цепочка из трёх структур ядра, позволяет единообразно работать с файлами, сокетами, pipe и устройствами. fork() разделяет open file description между родителем и ребёнком — отсюда общий offset и необходимость координации при записи. dup2() подменяет ресурс за номером дескриптора, не меняя код программы, — отсюда перенаправление I/O. pipe() создаёт канал с буфером в ядре, а в связке с fork() и dup2() превращается в основу конвейеров shell.

Но процессы работают не только с дескрипторами — они работают с памятью. Каждый процесс «видит» адреса начиная с 0x400000, и два процесса могут использовать один и тот же адрес без конфликта. Как это возможно — вопрос виртуальной памяти.

Sources


Потоки | Виртуальная память