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 ведёт для процесса.
Почему такая трёхуровневая архитектура? Потому что эти уровни могут переиспользоваться независимо:
- Два процесса открыли один файл независимо — у каждого свой 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)
EMFILE — классическая причина «too many open files» в production. Web-сервер, который не закрывает connection-ы, упирается в этот лимит через несколько часов работы. Решение: исправить leak + поднять ulimit -n.
read(): забираем байты из kernel
Сигнатура read:
ssize_t read(int fd, void *buf, size_t count);
Что физически происходит:
- Процесс зовёт
read(3, buffer, 4096)— хочу 4096 байт из FD=3 в свой buffer. - Происходит переход в kernel mode (об этом подробно в модуле 12).
- Kernel смотрит в свою таблицу: FD=3 у этого процесса указывает на такой-то inode, текущий offset = 0.
- Kernel читает данные с диска (или возвращает из page cache, если уже есть) в свой kernel buffer.
- Kernel копирует из своего buffer в
bufferпроцесса. - Kernel обновляет offset в open file description (теперь = 4096).
- Возвращает количество прочитанных байт.
Важно: 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);
Что происходит:
- Процесс зовёт
write(3, buffer, 100). - Kernel копирует 100 байт из
bufferпроцесса в свой буфер (page cache). - Kernel обновляет offset в open file description.
- Возвращает успех — но это НЕ значит, что данные на диске. Они в page cache, который kernel запишет на диск когда-нибудь потом (или никогда, если процесс упадёт и сервер потеряет питание).
Это критически важный момент, к которому мы вернёмся в уроке про fsync. Пока запомните: write — быстро, но не durable.
# Посмотреть, как 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 делает несколько вещей:
- Уменьшает счётчик ссылок на open file description.
- Если счётчик упал до 0 — освобождает open file description.
- Освобождает запись в FD table процесса (этот FD теперь свободен для следующего
open). - Если файл был открыт на запись, может (но не обязательно) 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 остаются общими:
Это используется в 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. Любая программа, которую вы запускаете, делает десятки таких вызовов до того, как покажет первый байт вывода.