Learning Platform
Глоссарий Troubleshooting
Урок 11.01 · 22 мин
Начальный
File I/OSyscallsFile DescriptorsLinuxKernel

open/read/write/close — четыре syscall, на которых стоит весь file I/O

Когда вы пишете open("data.txt") в Python или fs.readFile("data.txt") в Node, под капотом дёргаются буквально те же четыре системных вызова, что и тридцать лет назад: open, read, write, close. Это фундамент file I/O в Unix-системах — настолько глубокий, что в манифесте Linux прямо написано «everything is a file»: и обычный файл, и сокет, и устройство /dev/null, и pipe между процессами — всё это работает через эти же четыре syscall.

В этом уроке разберём, что физически происходит на каждом шаге: что такое file descriptor, какие таблицы kernel ведёт для каждого процесса, и почему lsof так часто спасает в production.


Зачем понимать low-level file I/O

«Подождите, я же просто использую Python — зачем мне знать про syscalls?» Затем, что:

  • Файловые дескрипторы кончаются. По дефолту процесс может держать открытыми ~1024 файла. Утечки FD — частый bug в долгоживущих сервисах (web-сервер, который не закрывает соединения).
  • fork() копирует таблицу FD. Когда вы видите в lsof десяток процессов с одним и тем же открытым файлом — вот почему.
  • Strace показывает именно эти вызовы. Любая диагностика производительности I/O начинается со strace, который показывает openat, read, write. Без понимания базы это просто шум.
  • Производительность. Один read(fd, buf, 4096) — это переход в kernel mode. Делать его в цикле по одному байту — кошмар. Поэтому есть buffering (следующий урок).

Что такое file descriptor

File descriptor (FD) — это маленькое целое число, которым процесс ссылается на открытый файл (или сокет, или pipe). У каждого процесса свой набор FD, нумерация начинается с 0. Три FD зарезервированы:

  • 0 — stdin (стандартный ввод)
  • 1 — stdout (стандартный вывод)
  • 2 — stderr (стандартная ошибка)

Когда вы открываете первый файл, kernel выдаёт вам FD = 3. Следующий — 4. И так далее. Это просто индекс в таблице, которую kernel ведёт для процесса.

Три уровня таблиц, через которые kernel находит файл
Process FD tableУ каждого процесса своя таблица. Индекс -- это число FD (3, 4, 5...). Каждая запись указывает на open file description в kernel-wide таблице
Open file tableГлобальная таблица в kernel. Каждая запись -- offset в файле, флаги (O_RDONLY/O_WRONLY/O_APPEND), указатель на inode. Один файл может иметь несколько open file descriptions
Inode tableРеальный объект файловой системы: размер, владелец, права, указатели на блоки данных на диске. Inode один на физический файл, даже если он открыт 100 раз

Почему такая трёхуровневая архитектура? Потому что эти уровни могут переиспользоваться независимо:

  • Два процесса открыли один файл независимо — у каждого свой open file description (свой offset чтения), но inode общий.
  • fork() копирует FD table, но дочерний процесс делит open file description с родителем — если родитель сдвинет offset, ребёнок это увидит.
  • dup(fd) создаёт новый FD, указывающий на ту же open file description. Это используется в shell для редиректов (2>&1).
# Посмотреть FD текущего процесса (например, shell):
ls -la /proc/$$/fd/
# Что-то вроде:
# lrwx------ 1 user user 64 May 18 12:00 0 -> /dev/pts/0
# lrwx------ 1 user user 64 May 18 12:00 1 -> /dev/pts/0
# lrwx------ 1 user user 64 May 18 12:00 2 -> /dev/pts/0
# lrwx------ 1 user user 64 May 18 12:00 255 -> /dev/pts/0

Каждый /proc/PID/fd/N — это симлинк на то, что открыто на этом FD. В Linux это бесценный инструмент диагностики.


open(): получаем file descriptor

Системный вызов open (а точнее в современном Linux — openat) превращает путь к файлу в FD. Сигнатура:

int open(const char *pathname, int flags, mode_t mode);

flags — bitmask, основные:

  • O_RDONLY — только чтение
  • O_WRONLY — только запись
  • O_RDWR — чтение + запись
  • O_CREAT — создать, если нет (тогда mode указывает права 0644 и т.п.)
  • O_TRUNC — обрезать до нуля, если есть
  • O_APPEND — запись всегда в конец
  • O_DIRECT — bypass page cache (подробно в следующем уроке)
  • O_NONBLOCK — неблокирующий I/O

В Python это спрятано за open("file", "r"), но под капотом тот же openat:

# Запустить Python и посмотреть, как он открывает файл:
strace -e openat python3 -c "open('/etc/passwd').read()" 2>&1 | tail -5
# Примерный вывод:
# openat(AT_FDCWD, "/etc/passwd", O_RDONLY|O_CLOEXEC) = 3
# +++ exited with 0 +++

openat(AT_FDCWD, ...) — современная форма, где первый аргумент это «относительно какого FD считать путь». AT_FDCWD означает «относительно текущей рабочей директории». O_CLOEXEC — флаг «закрыть этот FD при exec()», чтобы дочерний процесс случайно не унаследовал.

Возвращаемое значение — FD (положительное число) или -1 при ошибке. На ошибке нужно смотреть errno:

  • ENOENT — файла не существует
  • EACCES — нет прав
  • EMFILE — кончились FD у процесса (типичный лимит 1024)
  • ENFILE — кончились FD на уровне всей системы
# Узнать лимит FD текущего shell:
ulimit -n
# Обычно 1024 или 4096

# Посмотреть system-wide лимит:
cat /proc/sys/fs/file-max
# Что-то вроде 9223372036854775807 (фактически без лимита на современных системах)

# Сколько FD сейчас открыто во всей системе:
cat /proc/sys/fs/file-nr
# 1856 0 9223372036854775807 (открыто, свободно, max)
WARNING

EMFILE — классическая причина «too many open files» в production. Web-сервер, который не закрывает connection-ы, упирается в этот лимит через несколько часов работы. Решение: исправить leak + поднять ulimit -n.


read(): забираем байты из kernel

Сигнатура read:

ssize_t read(int fd, void *buf, size_t count);

Что физически происходит:

  1. Процесс зовёт read(3, buffer, 4096) — хочу 4096 байт из FD=3 в свой buffer.
  2. Происходит переход в kernel mode (об этом подробно в модуле 12).
  3. Kernel смотрит в свою таблицу: FD=3 у этого процесса указывает на такой-то inode, текущий offset = 0.
  4. Kernel читает данные с диска (или возвращает из page cache, если уже есть) в свой kernel buffer.
  5. Kernel копирует из своего buffer в buffer процесса.
  6. Kernel обновляет offset в open file description (теперь = 4096).
  7. Возвращает количество прочитанных байт.
Что происходит на read() -- путь данных с диска до приложения
App bufferПамять приложения (heap, stack). Сюда kernel в итоге скопирует данные
copy
Kernel bufferПамять kernel. read() скопирует данные сюда сначала, потом в user memory. Это и есть 'два копирования' на каждом read
DMA
Page cacheКеш недавно прочитанных файлов. Если файл там есть -- диск не трогаем вообще. Подробно в следующем уроке
Disk blockФизический сектор на диске или SSD. Чтение оттуда -- миллисекунды (HDD) или микросекунды (SSD)

Важно: read может вернуть меньше, чем вы запросили. Это не баг, это feature:

  • Чтение из pipe или сокета: возвращает то, что доступно прямо сейчас.
  • Чтение в конце файла: возвращает 0 (EOF).
  • Прерывание сигналом: возвращает -1, errno = EINTR (нужно повторить).

Поэтому в C/Rust код часто выглядит как цикл:

ssize_t total = 0;
while (total < want) {
    ssize_t n = read(fd, buf + total, want - total);
    if (n == 0) break;        // EOF
    if (n < 0) {
        if (errno == EINTR) continue;
        return -1;
    }
    total += n;
}

В Python это спрятано: file.read(N) сам делает цикл до получения N байт или EOF.


write(): отдаём байты в kernel

Сигнатура симметричная:

ssize_t write(int fd, const void *buf, size_t count);

Что происходит:

  1. Процесс зовёт write(3, buffer, 100).
  2. Kernel копирует 100 байт из buffer процесса в свой буфер (page cache).
  3. Kernel обновляет offset в open file description.
  4. Возвращает успех — но это НЕ значит, что данные на диске. Они в page cache, который kernel запишет на диск когда-нибудь потом (или никогда, если процесс упадёт и сервер потеряет питание).

Это критически важный момент, к которому мы вернёмся в уроке про fsync. Пока запомните: write — быстро, но не durable.

stdin, stdout, stderr: три file descriptor, которые всегда открыты
# Посмотреть, как cat пишет в файл:
strace -e openat,write,close cat /etc/hostname 2>&1 > /tmp/host.txt
# Примерный вывод:
# openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
# write(1, "myhost\n", 7) = 7
# close(3) = 0

write(1, ...) — запись в stdout (FD=1). Поскольку мы редиректнули в файл, kernel запишет в этот файл.


close(): отдаём FD обратно kernel

int close(int fd);

close делает несколько вещей:

  1. Уменьшает счётчик ссылок на open file description.
  2. Если счётчик упал до 0 — освобождает open file description.
  3. Освобождает запись в FD table процесса (этот FD теперь свободен для следующего open).
  4. Если файл был открыт на запись, может (но не обязательно) flush’нуть данные.

Что НЕ делает close:

  • Не гарантирует, что данные на диске. Для этого нужен fsync ДО close.
  • Не сигналит об ошибках записи. Если write вернул успех, а потом запись в диск упала — close об этом не скажет (на большинстве файловых систем).

В Python это решено через with:

# Это idiomatic Python -- close вызовется автоматически
with open("data.txt") as f:
    data = f.read()
# f.close() уже вызван здесь
# Что произойдёт, если не закрывать файл (FD leak):
python3 -c "
files = []
for i in range(10000):
    files.append(open('/etc/passwd'))
" 2>&1 | tail -3
# OSError: [Errno 24] Too many open files: '/etc/passwd'

fork() и file descriptors

Когда процесс делает fork(), ребёнок получает копию FD table родителя. Но open file descriptions остаются общими:

После fork(): FD table копируется, open file description -- разделяется
Parent FD 3У родителя FD=3 указывает на open file description X
points
Open file desc X (offset=100)Один open file description. Offset=100 -- общий для родителя и ребёнка после fork
points
Child FD 3У ребёнка после fork() тоже FD=3, указывает на ТОТ ЖЕ open file description
parent: read(3, ..., 10)После этого offset в OFD X станет 110
child: read(3, ...)Ребёнок читает с offset=110 -- продолжает с того места, где остановился родитель

Это используется в shell: когда вы пишете cmd1 | cmd2, shell делает fork и оба процесса делят pipe FD. Запись родителя в pipe — читается ребёнком.

O_CLOEXEC (или fcntl(fd, F_SETFD, FD_CLOEXEC)) меняет это поведение: при exec() дочерний процесс автоматически закроет этот FD. По умолчанию Python и большинство современных языков ставят O_CLOEXEC на все FD, чтобы случайно не утечь дескриптор в запущенную через subprocess программу.


Реальный пример: что показывает lsof

lsof (list open files) — золотая команда для диагностики:

# Все файлы, открытые процессом PID 1234:
lsof -p 1234

# Кто держит открытым файл /var/log/app.log:
lsof /var/log/app.log

# Все сетевые соединения на порту 8080:
lsof -i :8080

# Что открыто всеми процессами Python:
lsof -c python

Пример вывода:

COMMAND  PID  USER   FD   TYPE  DEVICE   NODE NAME
nginx   123 root    cwd    DIR  259,1     2 /
nginx   123 root    rtd    DIR  259,1     2 /
nginx   123 root    txt    REG  259,1  4571 /usr/sbin/nginx
nginx   123 root      0u   CHR  136,0   3 /dev/pts/0
nginx   123 root      1u   CHR  136,0   3 /dev/pts/0
nginx   123 root      2u   CHR  136,0   3 /dev/pts/0
nginx   123 root      3u  IPv4  12345  TCP *:http (LISTEN)
nginx   123 root      4u   REG  259,1   100 /var/log/nginx/access.log

FD 0, 1, 2 — стандартные. FD 3 — listening сокет на HTTP-порту. FD 4 — лог-файл.

Если в выводе много открытых файлов, многие из которых указывают на удалённый inode ((deleted) в NAME) — это признак того, что процесс держит файл, который кто-то уже удалил. Место на диске не освободится, пока процесс не закроет FD или не умрёт.


Попробуй сам

# 1. Посмотреть все открытые FD текущего shell:
ls -la /proc/$$/fd/

# 2. Открыть FD в shell вручную и посмотреть:
exec 5< /etc/passwd
ls -la /proc/$$/fd/5
# Закрыть:
exec 5<&-

# 3. Посмотреть лимит FD:
ulimit -n
# Попробовать поднять (только для текущего shell):
ulimit -n 2048

# 4. Поймать leak: запустить Python в фоне и считать FD:
python3 -c "
import time
files = []
while True:
    files.append(open('/etc/passwd'))
    time.sleep(0.5)
" &
PID=$!
# Подождать пару секунд и посмотреть:
sleep 3
ls /proc/$PID/fd/ | wc -l
# Убить:
kill $PID

# 5. strace на ваш любимый скрипт:
strace -e openat,read,write,close python3 your_script.py 2>&1 | head -30

Особо рекомендую запустить strace -e openat ls — увидите, сколько библиотек динамически подгружается, сколько раз дергается openat. Любая программа, которую вы запускаете, делает десятки таких вызовов до того, как покажет первый байт вывода.


Проверка знанийKnowledge check
Команда возвращает 'Too many open files'. Что искать? Опиши конкретный план диагностики и какие команды Linux помогут найти источник проблемы.
ОтветAnswer
Это EMFILE -- процесс упёрся в лимит file descriptors. План диагностики: 1) Найти процесс, который ест FD. `lsof | awk '{print $1}' | sort | uniq -c | sort -rn | head` покажет, у каких команд больше всего открытых FD. Или `ls /proc/*/fd 2>/dev/null | wc -l` для общего числа. 2) Для конкретного процесса посмотреть, ЧТО он открыл. `ls -la /proc/PID/fd/ | sort -k 11` -- сортировка по имени файла покажет, не открыто ли тысячи раз одно и то же (типичный leak). 3) Сравнить с лимитами. `cat /proc/PID/limits | grep open` -- soft и hard limit на open files для этого процесса. `ulimit -n` -- лимит текущего shell. 4) Системный лимит. `cat /proc/sys/fs/file-nr` -- сколько FD открыто во всей системе и максимум. Если упёрлись в системный -- проблема серьёзнее. 5) Если это ваш сервис -- ищите место, где открываете файлы/сокеты, но не закрываете. В Python это значит не `with open(...)` а просто `f = open(...)`. Часто это утечка соединений с БД или HTTP-клиента, который не реюзает connections. 6) Быстрый workaround -- поднять ulimit: `ulimit -n 65536` для процесса, или в systemd `LimitNOFILE=65536`. Но это не решает leak -- через сутки опять будет EMFILE.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. Какие три FD зарезервированы в Unix для каждого процесса и что они означают?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4