Persistence · 16 мин

Контент v0.12.0

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

Что такое файл для ОС

Для пользователя файл — это иконка report.txt. Для операционной системы это две разные вещи:

  1. Данные — массив байт от 0 до N. Что внутри, текст или видео, ОС не знает и знать не хочет.
  2. Метаданные — сведения о файле: размер, владелец, права доступа, время изменения и, главное, где на диске лежат сами байты.

Метаданные файла хранятся в структуре под названием 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.txt
1234567 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 записью на диск.

Проверьте себя

  1. Где хранится имя файла и почему у одного inode может быть несколько имён?
  2. Удалили лог на 50 ГБ, а df показывает, что место не освободилось. Почему и как найти виновника?
  3. Чем отличается symlink от жёсткой ссылки при удалении цели?
  4. Процесс открыл файл, сделал 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.

Продолжить