GraphLMS

ОС
Начать

ОС · Persistence · 14 мин

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

Что такое файл на самом деле

Когда мы говорим «файл», в голове обычно картинка: иконка с именем report.txt. Но для операционной системы файл — это две совершенно разные вещи, склеенные вместе:

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

Бытовая аналогия. Представь камеру хранения на вокзале. Данные — это твой чемодан. Метаданные — это бирка на ячейке: номер ячейки, кто хозяин, когда положил. Ты сам по бирке находишь чемодан. В файловой системе роль такой «бирки со всей служебной информацией» играет inode (index node, «индексный узел»).

        ФАЙЛ = ДАННЫЕ + МЕТАДАННЫЕ
   ┌──────────────────────────────────────┐
   │  Метаданные (inode)   Данные (блоки)  │
   │  ┌─────────────┐      ┌────┬────┬────┐│
   │  │ размер       │      │byte│byte│byte││
   │  │ владелец/uid │ ───► │ s  │ s  │ s  ││
   │  │ права rwx    │      └────┴────┴────┘│
   │  │ времена a/m/c│       массив байт    │
   │  │ refcount     │                      │
   │  │ → блоки данных│                     │
   │  └─────────────┘                       │
   └──────────────────────────────────────┘

Что нарисовано: слева «паспорт» файла (inode) со всеми атрибутами и указателями; справа — собственно содержимое, разбросанное по блокам диска. Подробно, как inode указывает на блоки (прямые и непрямые указатели), разбираем в главе fs-implementation — здесь нам важна сама идея разделения.

Inode: паспорт без имени

Ключевой и самый контринтуитивный факт главы: имя файла в inode не хранится. В inode есть размер, права, времена, указатели на блоки — но строки report.txt там нет. Inode опознаётся не по имени, а по номеру (inode number, i-number) — это его адрес внутри файловой системы.

Проверить можно прямо в терминале:

$ ls -i report.txt
1234567 report.txt

   └── это и есть номер inode

Почему так сделали? Потому что имя — свойство расположения файла в дереве каталогов, а не свойство самих данных. Один и тот же файл может иметь несколько имён одновременно (увидим ниже, в разделе про жёсткие ссылки). А вот inode у данных всегда один.

Каталог — это тоже файл

Откуда же берётся имя? Из каталога. Каталог (directory, «папка») — это не какая-то особая магическая сущность, а тоже файл. Только содержимое у него специальное: это таблица соответствия «имя → номер inode». Каждая строка такой таблицы называется dentry (directory entry, запись каталога).

   КАТАЛОГ /home/user  (его данные = таблица)
   ┌──────────────┬────────────┐
   │  имя         │ номер inode│
   ├──────────────┼────────────┤
   │  .           │     88     │  ← сам каталог
   │  ..          │     12     │  ← родительский каталог
   │  report.txt  │   1234567  │ ─────────┐
   │  photo.jpg   │   1234999  │          │
   └──────────────┴────────────┘          ▼
                                   ┌───────────────┐
                                   │ inode 1234567 │
                                   │ права, размер,│
                                   │ → блоки данных│
                                   └───────────────┘

Что нарисовано: каталог — это список пар. Чтобы открыть report.txt, ОС читает таблицу каталога, находит строку с этим именем, берёт оттуда номер inode (1234567), а уже по номеру достаёт сам inode и через него — данные. Имя живёт в каталоге, данные живут в inode — это два разных места.

Каждый каталог автоматически содержит две особые записи: . (он сам) и .. (родитель). Именно по ним работает навигация cd ...

Как путь превращается в файл

Открытие /home/user/report.txt — это цепочка просмотров (path traversal):

  "/"          читаем корневой inode → его таблицу → ищем "home" → inode 12
  "/home"      читаем inode 12 → таблицу → ищем "user" → inode 88
  "/home/user" читаем inode 88 → таблицу → ищем "report.txt" → inode 1234567
  готово: открыли inode 1234567

Что нарисовано: ОС идёт по пути слева направо, на каждом шаге читая каталог и находя следующий номер inode. Поэтому длинные пути и глубокая вложенность — это буквально больше работы для ФС.

Жёсткие ссылки: одни данные, много имён

Раз имя лежит в каталоге, а данные — в inode, ничто не мешает завести две записи каталога, указывающие на один и тот же номер inode. Это и есть жёсткая ссылка (hard link). Создаётся командой ln:

$ ln report.txt copy.txt     # не копия! второе имя того же inode
 
   КАТАЛОГ
   ┌──────────────┬────────────┐
   │  report.txt  │  1234567   │──┐
   │  copy.txt    │  1234567   │──┤   оба указывают
   └──────────────┴────────────┘  │   в ОДИН inode

                          ┌──────────────────┐
                          │ inode 1234567    │
                          │ refcount = 2      │ ← счётчик имён
                          │ → блоки данных    │
                          └──────────────────┘

Что нарисовано: два разных имени, один inode, одни данные на диске. Это не копия — второго экземпляра байт нет. Изменишь через report.txt — увидишь то же через copy.txt.

В inode есть поле refcount (link count, счётчик ссылок) — сколько имён на него указывает. Это и объясняет, как на самом деле работает удаление файла. Системный вызов называется unlink, а не delete, и это важно:

  unlink("copy.txt")  → удаляем запись из каталога, refcount 2 → 1
  unlink("report.txt")→ удаляем запись из каталога, refcount 1 → 0
                        refcount == 0  ⇒ ВОТ ТЕПЕРЬ блоки данных
                                          и inode освобождаются

То есть rm не стирает данные. Он убирает имя и уменьшает счётчик. Реальные байты освобождаются, только когда исчезло последнее имя (refcount дошёл до 0).

Важная тонкость: счётчик учитывает не только имена, но и открытые дескрипторы (о них ниже). Если процесс держит файл открытым, а ты сделал rm, имя из каталога пропадёт, но данные останутся живы, пока процесс не закроет файл. На этом основан классический трюк: создать временный файл, открыть его и сразу unlink — файл невидим в каталоге, но доступен процессу и гарантированно удалится при выходе.

Символические ссылки: ярлык с путём

У жёстких ссылок два ограничения: они не могут указывать на файл в другой файловой системе (номера inode уникальны только внутри одной ФС) и обычно нельзя жёстко слинковать каталог. Поэтому есть второй вид — символическая ссылка (symlink, мягкая ссылка), создаётся ln -s.

Symlink — это отдельный маленький файл со своим собственным inode, а его содержимое — просто текстовая строка пути на цель.

$ ln -s /home/user/report.txt link
 
   HARD LINK                    SYMLINK
   имя → тот же inode           имя → отдельный inode (тип: symlink)
                                       данные = строка "/home/user/report.txt"
   ┌──────────┐                 ┌──────────┐     ┌──────────────────────┐
   │ inode    │◄─── copy.txt    │ inode    │     │ inode report (цель)  │
   │ данные   │◄─── report.txt  │ "путь"   │────►│ данные                │
   └──────────┘                 └──────────┘ по  └──────────────────────┘
                                              имени, не по номеру!

Что нарисовано: hard link делит сам inode; symlink хранит строку-путь и при обращении ОС заново проходит этот путь, чтобы найти цель.

Отсюда все различия:

Свойство Hard link Symlink
Что хранит то же имя для inode отдельный файл с путём
Указывает на номер inode строку пути
Через разные ФС нельзя можно
На каталог нельзя (обычно) можно
Удалили цель данные живы (refcount) висячая ссылка (broken)
Влияет на refcount цели да, +1 нет

Главная ловушка: если удалить оригинал, symlink станет «битым» (показывает на несуществующий путь), а hard link продолжит работать — ведь данные не делись никуда, пока их refcount > 0.

Файловый дескриптор: ручка к открытому файлу

Чтобы читать и писать, файл сначала открывают вызовом open, который возвращает файловый дескриптор (file descriptor, fd) — небольшое целое число. Это не сам файл и не inode, а индекс в таблице открытых файлов процесса.

Аналогия: fd — это номерок из гардероба. Сам по себе он ничего не значит, но по нему гардеробщик (ядро) находит твоё пальто. У каждого процесса свой гардероб (своя таблица fd), поэтому fd=3 у одного процесса и fd=3 у другого — это разные файлы.

По соглашению первые три дескриптора заняты всегда:

   fd 0 → stdin  (стандартный ввод)
   fd 1 → stdout (стандартный вывод)
   fd 2 → stderr (стандартный вывод ошибок)
   fd 3 → первый файл, который ты откроешь

Самое интересное — трёхуровневая структура. Между fd и inode есть промежуточный слой: запись открытого файла (open file description), и в ней живёт смещение (offset) — текущая позиция чтения/записи внутри файла.

  ТАБЛИЦА fd          ОБЩЕСИСТЕМНАЯ ТАБЛИЦА        ТАБЛИЦА inode
  (своя у процесса)   ОТКРЫТЫХ ФАЙЛОВ              (одна на ФС)
  ┌────┬─────┐        ┌──────────────────┐        ┌────────────┐
  │ fd │ ──► │ ─────► │ offset = 1024    │ ─────► │ inode 1234 │
  │ 3  │     │        │ режим: чтение     │        │ блоки,     │
  └────┴─────┘        │ ссылка на inode   │        │ refcount   │
                      └──────────────────┘        └────────────┘

Что нарисовано: fd показывает на запись открытого файла, а та — на inode. Offset лежит в средней таблице, не в inode и не рядом с fd. Это объяснит поведение при fork.

Базовые операции работают так:

  fd = open("/home/user/report.txt", O_RDONLY)  → fd = 3, offset = 0
  read(fd, buf, 100)   → читает 100 байт, offset: 0 → 100
  read(fd, buf, 100)   → читает следующие 100, offset: 100 → 200
  write(fd, buf, 50)   → пишет 50 байт, двигает offset
  close(fd)            → освобождает запись, fd снова свободен

Каждый read/write двигает offset автоматически — поэтому последовательное чтение «само» идёт дальше по файлу, без указания позиции. Сдвинуть offset вручную можно через lseek.

dup: ещё один fd на ту же запись

Вызов dupdup2) копирует дескриптор: новый fd указывает на ту же самую запись открытого файла, а значит, делит с оригиналом offset.

  fd 3 ─┐
        ├──► [ offset, inode ]   dup(3) → fd 4, та же запись
  fd 4 ─┘

Именно через dup2 шелл реализует перенаправление command > file: открывает файл и подменяет им fd 1 (stdout), чтобы вывод программы ушёл в файл, а сама программа об этом даже не знает.

Что происходит с fd при fork

Когда процесс делает fork (создаёт копию себя — см. process), дочерний процесс наследует таблицу fd. Но копируются не сами открытые файлы, а только указатели на записи: родитель и ребёнок делят одну запись открытого файла, а значит — один offset.

   РОДИТЕЛЬ          ДОЧЕРНИЙ
   ┌────┐            ┌────┐
   │fd 3│──┐      ┌──│fd 3│
   └────┘  │      │  └────┘
           ▼      ▼
        ┌──────────────────┐
        │ offset (ОБЩИЙ!)  │ ── если ребёнок пишет и двигает offset,
        │ → inode          │    родитель пишет уже дальше, не поверх
        └──────────────────┘

Что нарисовано: после fork два процесса смотрят в одну запись с общим offset. Поэтому если оба пишут в один унаследованный fd, их вывод не затирает друг друга, а аккуратно дописывается по очереди — общий offset сдвигается для обоих. Это, по сути, основа того, как пайплайн и редиректы в шелле работают корректно.

Сравним это с открытием файла дважды независимо (два open): тогда у каждого будет своя запись и свой offset, и записи будут затирать друг друга.

Способ Записи открытого файла Offset
fork / dup одна общая общий
два независимых open две разные независимые

А где же гарантия, что данные на диске?

Важный момент, который ловит на собесах: успешный write не означает, что байты уже на диске. Чаще всего они лежат в кэше страниц в памяти (page cache), а на диск попадут позже. Если в этот момент пропадёт питание — данные потеряются. Чтобы заставить ОС додавить данные до носителя, есть fsync. Это настолько важная и тонкая тема, что ей посвящена отдельная глава fsync-durability — здесь просто запомни: write ≠ durable (долговечно записано).

Проверь себя· Файлы, каталоги, дескрипторы

Где хранится имя файла?

Что реально делает rm (системный вызов unlink) для обычного файла с refcount = 1?

Чем символическая ссылка (symlink) отличается от жёсткой (hard link)?

Где хранится смещение (offset) чтения/записи открытого файла?

После fork родитель и ребёнок пишут в унаследованный fd. Что с offset?

Какой номер файлового дескриптора по соглашению соответствует стандартному выводу (stdout)?

Что спрашивают на собесе

  • Где хранится имя файла? В каталоге (таблица «имя → номер inode»), а не в inode. Inode знает про файл всё, кроме имени.
  • В чём разница hard link и symlink? Hard link — ещё одно имя для того же inode (делит данные, влияет на refcount, в пределах одной ФС). Symlink — отдельный файл со строкой-путём (может ломаться, работает между ФС и на каталоги).
  • Что реально делает rm? Вызывает unlink: убирает имя из каталога и уменьшает refcount. Данные освобождаются только когда refcount = 0 и файл никем не открыт.
  • Что такое файловый дескриптор и где живёт offset? fd — индекс в таблице открытых файлов процесса. Offset лежит в записи открытого файла (средний уровень), не в inode.
  • Что с fd после fork? Наследуются; родитель и ребёнок делят запись открытого файла и общий offset (как и при dup). Два независимых open дали бы разные offset.
  • Гарантирует ли успешный write запись на диск? Нет, данные могут быть в page cache; для долговечности нужен fsync (см. главу про durability).
RAID и надёжность храненияКак устроена файловая система (vsfs)