Persistence · 16 мин
Контент v0.12.0Файлы, каталоги, дескрипторы
Что такое файл для ОС
Для пользователя файл — это иконка report.txt. Для операционной системы это
две разные вещи:
- Данные — массив байт от 0 до N. Что внутри, текст или видео, ОС не знает и знать не хочет.
- Метаданные — сведения о файле: размер, владелец, права доступа, время изменения и, главное, где на диске лежат сами байты.
Метаданные файла хранятся в структуре под названием inode (index node, индексный узел). Аналогия — камера хранения: данные — это чемодан, inode — карточка ячейки с номером, владельцем и датой, по которой чемодан находят.
inode (метаданные) блоки данных на диске
┌──────────────────────┐ ┌──────┬──────┬──────┐
│ тип: обычный файл │ │ байты│ байты│ байты│
│ размер: 12 000 байт │ └──────┴──────┴──────┘
│ владелец: uid 1000 │ ▲ ▲ ▲
│ права: rw-r--r-- │ │ │ │
│ времена: atime/mtime │ │ │ │
│ число ссылок: 1 │ │ │ │
│ где лежат данные ────┼──────────────┴──────┴──────┘
└──────────────────────┘Как именно inode указывает на блоки, разберём в следующей главе. Здесь важна сама идея: метаданные отдельно, данные отдельно.
Inode без имени
Самый неожиданный факт этой главы: имени файла в inode нет. Размер, права,
времена, указатели на данные — есть, а строки report.txt нет. Inode
идентифицируется номером (inode number), уникальным внутри одной файловой
системы. Увидеть номер можно так:
ls -i report.txt1234567 report.txtПочему так? Имя — это свойство места файла в дереве каталогов, а не самих данных. У одного и того же файла может быть несколько имён в разных каталогах (ниже это увидим), а inode у него один.
Каталог — тоже файл
Имена хранятся в каталогах. Каталог (directory) — это файл особого типа, содержимое которого — таблица «имя → номер inode». Каждая строка такой таблицы называется записью каталога (directory entry, dentry).
каталог /home/user (его содержимое)
имя номер inode
────────────────────────
. 88 ← сам каталог
.. 12 ← родительский каталог
report.txt 1234567 ────────► inode 1234567: права, размер, блоки
photo.jpg 1234999Записи . и .. есть в каждом каталоге, через них работает cd ...
Открытие /home/user/report.txt — это цепочка шагов по каталогам (path
resolution, разрешение пути):
"/" inode корня известен заранее → читаем его таблицу → "home" = 12
"/home" читаем inode 12 → таблицу → "user" = 88
"/home/user" читаем inode 88 → таблицу → "report.txt" = 1234567
готово: проверяем права в inode 1234567 и открываемНа каждом шаге ядро ОС проверяет право на поиск в каталоге (бит x). Поэтому
файл с правами rw-rw-rw- может быть недоступен, если на одном из каталогов по
пути нет x для вас. Длинные пути — это больше шагов, поэтому ядро кэширует
результаты разрешения имён (dentry cache).
Жёсткие ссылки: одни данные, несколько имён
Раз имя лежит в каталоге, ничто не мешает завести две записи, указывающие на
один и тот же inode. Это жёсткая ссылка (hard link), создаётся командой ln:
ln report.txt copy.txt # не копия, а второе имя того же inode report.txt → 1234567 ─┐
copy.txt → 1234567 ─┴─► inode 1234567, число ссылок = 2Второго экземпляра данных нет. Изменили через report.txt — увидели то же через
copy.txt.
В inode есть поле число ссылок (link count, st_nlink) — сколько записей
каталогов указывают на этот inode. На нём построено удаление. Системный вызов
удаления называется unlink, а не delete, и это точное название: он удаляет
имя.
unlink("copy.txt") убрали запись из каталога, ссылок 2 → 1
unlink("report.txt") убрали запись из каталога, ссылок 1 → 0
имён больше нет → inode и блоки можно освободитьНо освободить можно не всегда сразу. Если файл в этот момент открыт каким-то
процессом, ядро держит inode живым, пока файл не закроют. Это отдельный учёт:
число ссылок в inode считает только имена в каталогах, а открытые файлы ядро
отслеживает в памяти. Освобождение происходит, когда оба счётчика дошли до нуля.
Частая ошибка — считать, что открытые дескрипторы входят в link count; stat у
открытого и удалённого файла покажет 0 ссылок.
Проверим на Go:
package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"syscall"
)
// nlink возвращает число жёстких ссылок на inode файла (только Unix).
func nlink(path string) uint64 {
fi, err := os.Stat(path)
if err != nil {
return 0
}
return uint64(fi.Sys().(*syscall.Stat_t).Nlink)
}
func must(err error) {
if err != nil {
log.Fatal(err)
}
}
func main() {
dir, err := os.MkdirTemp("", "links")
must(err)
defer os.RemoveAll(dir)
report := filepath.Join(dir, "report.txt")
copyName := filepath.Join(dir, "copy.txt")
must(os.WriteFile(report, []byte("квартальный отчёт\n"), 0o644))
must(os.Link(report, copyName)) // второе имя того же inode
fmt.Println("ссылок после ln:", nlink(report))
f, err := os.Open(report) // держим файл открытым
must(err)
defer f.Close()
must(os.Remove(report)) // unlink первого имени
must(os.Remove(copyName)) // unlink второго имени
_, err = os.Stat(report)
fmt.Println("имя есть?", err == nil)
data, err := io.ReadAll(f) // а данные всё ещё читаются через дескриптор
must(err)
fmt.Printf("через открытый файл: %q\n", data)
}ссылок после ln: 2
имя есть? false
через открытый файл: "квартальный отчёт\n"Оба имени удалены, а содержимое читается. Отсюда классическая история с
дежурства: «диск забит, удалили 50-гигабайтный лог, место не освободилось».
Лог удалили, а процесс, который в него пишет, держит его открытым. Найти такие
файлы помогает lsof +L1 (открытые файлы с нулём ссылок), а место освободится
после перезапуска процесса или после усечения файла через
/proc/<pid>/fd/<номер>. На том же поведении построены временные файлы:
создать, открыть, сразу удалить имя — файл невидим другим и исчезнет сам, когда
процесс его закроет или умрёт.
Символические ссылки
У жёстких ссылок два ограничения: они работают только внутри одной файловой
системы (номера inode уникальны только в ней), и на каталоги их делать нельзя
(иначе в дереве появятся циклы). Поэтому есть второй вид — символическая
ссылка (symlink), ln -s.
Symlink — это отдельный маленький файл со своим inode, содержимое которого — строка с путём к цели. При открытии ядро читает эту строку и разрешает путь заново.
ln -s /home/user/report.txt link
жёсткая ссылка: имя → тот же inode
symlink: имя → свой inode (тип symlink), внутри строка
"/home/user/report.txt" → разрешается при каждом открытии| Жёсткая ссылка | Symlink | |
|---|---|---|
| Что это | ещё одна запись каталога на тот же inode | отдельный файл со строкой пути |
| Между файловыми системами | нельзя | можно |
| На каталог | нельзя | можно |
| Цель удалили | данные живы, пока есть другие имена | ссылка «висит», открыть нельзя |
| Влияет на число ссылок цели | да | нет |
Symlink повсюду в деплое: current → releases/2024-05-01 позволяет переключить
версию одной атомарной операцией (создать новый symlink и rename поверх
старого).
Файловый дескриптор
Чтобы читать и писать, файл сначала открывают. Вызов open возвращает
файловый дескриптор (file descriptor, fd) — небольшое целое число. Это не
файл и не inode, а номер строки в таблице открытых файлов процесса. Похоже на
номерок в гардеробе: сам по себе ничего не значит, но по нему гардеробщик (ядро)
находит ваше пальто. У каждого процесса своя таблица, поэтому fd 3 в двух
процессах — это, скорее всего, разные файлы.
Первые три номера по соглашению заняты с запуска процесса:
fd 0 — stdin (стандартный ввод)
fd 1 — stdout (стандартный вывод)
fd 2 — stderr (вывод ошибок)Новый open получает наименьший свободный номер, обычно 3.
Между дескриптором и inode есть промежуточный уровень — открытый файл (open file description). В нём живут режим открытия и смещение (offset) — текущая позиция чтения и записи.
таблица fd процесса открытые файлы (в ядре) inode
┌────┬─────┐ ┌──────────────────┐ ┌────────────┐
│ 3 │ ───┼───────────► │ offset = 1024 │ ─────► │ inode 1234 │
└────┴─────┘ │ режим: чтение │ └────────────┘
└──────────────────┘Каждый read и write сдвигает offset, поэтому последовательное чтение «само»
идёт вперёд. Сдвинуть позицию вручную можно через lseek (в Go — f.Seek).
Есть и вызовы с явной позицией, pread/pwrite (f.ReadAt, f.WriteAt): они
offset не трогают, и ими безопасно читать один файл из многих горутин сразу.
В Go дескриптор спрятан внутри *os.File. Номер можно получить через f.Fd(),
а все дескрипторы процесса на Linux видны как ссылки в /proc/<pid>/fd/.
dup и наследование при fork
Вызов dup2(old, new) делает так, что дескриптор new указывает на тот же
открытый файл, что и old, — с общим offset. Так шелл реализует перенаправление
команда > file: открывает файл и через dup2 ставит его на место fd 1, после
чего запускает программу. Программа пишет в stdout и не знает, что это файл.
При fork (см. API процессов) дочерний процесс получает
копию таблицы дескрипторов. Копируются номера, а открытые файлы остаются общими:
у родителя и потомка один offset на двоих.
РОДИТЕЛЬ fd 3 ──┐
├──► открытый файл: offset (общий) ──► inode
ПОТОМОК fd 3 ──┘Если оба пишут в унаследованный дескриптор, записи идут друг за другом и не
затирают одна другую: каждый write двигает общий offset. Строки могут
перемешаться по порядку, но перезаписи не будет. А вот два независимых open
одного файла дают два открытых файла с независимыми offset, и записи будут
затирать друг друга.
| Как получили дескрипторы | Открытых файлов | Offset |
|---|---|---|
fork, dup |
один общий | общий |
два отдельных open |
два | независимые |
Как тогда нескольким процессам писать в один лог? Открывать с флагом
O_APPEND: перед каждой записью ядро атомарно переносит позицию в текущий конец
файла. На локальных файловых системах Linux одна операция write при этом
попадает в файл одним куском. В Go:
f, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)Дескрипторы — ограниченный ресурс
Число открытых дескрипторов на процесс ограничено (ulimit -n; во многих
системах мягкий лимит по умолчанию 1024). Сокеты — тоже дескрипторы, поэтому
сервер на 10 000 соединений упрётся в лимит раньше, чем в память. Ошибка
выглядит так: accept tcp [::]:8080: accept4: too many open files. Go-программы
с версии 1.19 при старте сами поднимают мягкий лимит до жёсткого, но жёсткий
задаёт система (для сервисов systemd — LimitNOFILE, в контейнере — настройки
рантайма).
Чаще лимит кончается из-за утечек. Типичные в Go:
- не закрыли
resp.Bodyпосле HTTP-запроса — соединение не вернётся в пул и держит сокет; os.Openбезdefer f.Close()в цикле;exec.Cmd, у которого не дочитали stdout.
Проверка на Linux: ls /proc/<pid>/fd | wc -l раз в минуту. Если число только
растёт — утечка.
Успешный write ещё не на диске
write возвращает успех, когда данные скопированы в page cache в памяти (см.
память в проде). На диск они попадут позже. Если в этот
момент пропадёт питание, данные потеряются, хотя программа получила «успех».
Чтобы дождаться записи на носитель, нужен fsync (в Go — f.Sync()). Это
настолько важная для бэкенда тема, что ей посвящена отдельная глава:
fsync и durability.
Где хранится имя файла?
Что реально делает rm (системный вызов unlink) для обычного файла с refcount = 1?
Чем символическая ссылка (symlink) отличается от жёсткой (hard link)?
Где хранится смещение (offset) чтения/записи открытого файла?
После fork родитель и ребёнок пишут в унаследованный fd. Что с offset?
Какой номер файлового дескриптора по соглашению соответствует стандартному выводу (stdout)?
Частые ошибки
- Считать, что
rmстирает данные. Он удаляет имя; место освобождается, когда нет ни имён, ни открытых дескрипторов. - Думать, что открытые дескрипторы входят в число ссылок inode.
- Писать в общий лог из нескольких процессов без
O_APPEND. - Не закрывать
resp.Bodyи файлы в цикле и получатьtoo many open files. - Считать успешный
writeзаписью на диск.
Проверьте себя
- Где хранится имя файла и почему у одного inode может быть несколько имён?
- Удалили лог на 50 ГБ, а
dfпоказывает, что место не освободилось. Почему и как найти виновника? - Чем отличается symlink от жёсткой ссылки при удалении цели?
- Процесс открыл файл, сделал
fork, потомок прочитал 100 байт, затем родитель прочитал 100 байт. Какие байты получил родитель? А если бы потомок сам открыл файл заново послеfork?
Показать ответы
Ответы. (1) В записях каталогов; каждая запись — это пара «имя → номер inode», и таких записей на один inode может быть несколько. (2) Файл держит открытым процесс, inode живёт до закрытия; найти через lsof +L1, освободить перезапуском процесса или усечением через /proc/<pid>/fd. (3) Жёсткая ссылка продолжает работать, symlink становится висячим. (4) Байты 100–199: offset общий. Если потомок открыл файл заново, у него свой offset, и родитель получит байты 0–99.
Что спрашивают на собесе
- Где хранится имя файла? В каталоге, в таблице «имя → номер inode». В inode есть всё, кроме имени.
- Hard link против symlink? Жёсткая ссылка — ещё одно имя того же inode (только в пределах ФС, не на каталоги, увеличивает число ссылок). Symlink — отдельный файл со строкой пути (работает между ФС и на каталоги, ломается при удалении цели).
- Что делает
rm? Вызываетunlink: удаляет имя и уменьшает число ссылок. Место освобождается, когда не осталось ни имён, ни открытых дескрипторов. - Удалили большой лог, место не освободилось. Почему? Процесс держит файл
открытым.
lsof +L1, перезапуск процесса. - Где живёт offset? В открытом файле — между дескриптором и inode. Поэтому
после
forkиdupон общий, а у двухopen— разный. - Как писать в один лог из нескольких процессов?
O_APPEND. - Что значит «too many open files»? Кончился лимит дескрипторов: либо он
слишком мал, либо утечка (незакрытые файлы,
resp.Body). - Гарантирует ли успешный
writeзапись на диск? Нет, нуженfsync.