---
title: "Файлы, каталоги, дескрипторы"
description: "Метаданные файла хранятся в структуре под названием inode (index node, индексный узел). Аналогия — камера хранения: данные — это чемодан, inode — карточка ячейки с номером…"
canonical: https://graphlms.ru/os/book/files-directories
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

> Источник: https://graphlms.ru/os/book/files-directories — курс «Операционные системы», GraphLMS.

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

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

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

Метаданные файла хранятся в структуре под названием **inode** (index node,
индексный узел). Аналогия — камера хранения: данные — это чемодан, inode — карточка
ячейки с номером, владельцем и датой, по которой чемодан находят.

```text
   inode (метаданные)                 блоки данных на диске
   ┌──────────────────────┐          ┌──────┬──────┬──────┐
   │ тип: обычный файл    │          │ байты│ байты│ байты│
   │ размер: 12 000 байт  │          └──────┴──────┴──────┘
   │ владелец: uid 1000   │              ▲      ▲      ▲
   │ права: rw-r--r--     │              │      │      │
   │ времена: atime/mtime │              │      │      │
   │ число ссылок: 1      │              │      │      │
   │ где лежат данные ────┼──────────────┴──────┴──────┘
   └──────────────────────┘
```

Как именно inode указывает на блоки, разберём в
[следующей главе](/os/book/fs-implementation). Здесь важна сама идея: метаданные
отдельно, данные отдельно.

## Inode без имени

Самый неожиданный факт этой главы: **имени файла в inode нет**. Размер, права,
времена, указатели на данные — есть, а строки `report.txt` нет. Inode
идентифицируется **номером** (inode number), уникальным внутри одной файловой
системы. Увидеть номер можно так:

```bash
ls -i report.txt
```

```text
1234567 report.txt
```

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

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

Имена хранятся в **каталогах**. Каталог (directory) — это файл особого типа,
содержимое которого — таблица «имя → номер inode». Каждая строка такой таблицы
называется **записью каталога** (directory entry, dentry).

```text
   каталог /home/user (его содержимое)

   имя          номер inode
   ────────────────────────
   .            88          ← сам каталог
   ..           12          ← родительский каталог
   report.txt   1234567 ────────► inode 1234567: права, размер, блоки
   photo.jpg    1234999
```

Записи `.` и `..` есть в каждом каталоге, через них работает `cd ..`.

Открытие `/home/user/report.txt` — это цепочка шагов по каталогам (path
resolution, разрешение пути):

```text
   "/"           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`:

```bash
ln report.txt copy.txt     # не копия, а второе имя того же inode
```

```text
   report.txt  → 1234567 ─┐
   copy.txt    → 1234567 ─┴─► inode 1234567, число ссылок = 2
```

Второго экземпляра данных нет. Изменили через `report.txt` — увидели то же через
`copy.txt`.

В inode есть поле **число ссылок** (link count, `st_nlink`) — сколько записей
каталогов указывают на этот inode. На нём построено удаление. Системный вызов
удаления называется `unlink`, а не `delete`, и это точное название: он удаляет
**имя**.

```text
   unlink("copy.txt")    убрали запись из каталога, ссылок 2 → 1
   unlink("report.txt")  убрали запись из каталога, ссылок 1 → 0
                         имён больше нет → inode и блоки можно освободить
```

Но освободить можно не всегда сразу. Если файл в этот момент открыт каким-то
процессом, ядро держит inode живым, пока файл не закроют. Это отдельный учёт:
число ссылок в inode считает только имена в каталогах, а открытые файлы ядро
отслеживает в памяти. Освобождение происходит, когда оба счётчика дошли до нуля.
Частая ошибка — считать, что открытые дескрипторы входят в link count; `stat` у
открытого и удалённого файла покажет 0 ссылок.

Проверим на Go:

```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)
}
```

```text
ссылок после ln: 2
имя есть? false
через открытый файл: "квартальный отчёт\n"
```

Оба имени удалены, а содержимое читается. Отсюда классическая история с
дежурства: «диск забит, удалили 50-гигабайтный лог, место не освободилось».
Лог удалили, а процесс, который в него пишет, держит его открытым. Найти такие
файлы помогает `lsof +L1` (открытые файлы с нулём ссылок), а место освободится
после перезапуска процесса или после усечения файла через
`/proc/<pid>/fd/<номер>`. На том же поведении построены временные файлы:
создать, открыть, сразу удалить имя — файл невидим другим и исчезнет сам, когда
процесс его закроет или умрёт.

## Символические ссылки

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

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

```text
   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 в двух
процессах — это, скорее всего, разные файлы.

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

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

Новый `open` получает наименьший свободный номер, обычно 3.

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

```text
   таблица 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 процессов](/os/book/process-api)) дочерний процесс получает
копию таблицы дескрипторов. Копируются номера, а открытые файлы остаются общими:
у родителя и потомка один offset на двоих.

```text
   РОДИТЕЛЬ fd 3 ──┐
                   ├──► открытый файл: offset (общий) ──► inode
   ПОТОМОК  fd 3 ──┘
```

Если оба пишут в унаследованный дескриптор, записи идут друг за другом и не
затирают одна другую: каждый `write` двигает общий offset. Строки могут
перемешаться по порядку, но перезаписи не будет. А вот два независимых `open`
одного файла дают два открытых файла с независимыми offset, и записи будут
затирать друг друга.

| Как получили дескрипторы | Открытых файлов | Offset |
|---|---|---|
| `fork`, `dup` | один общий | общий |
| два отдельных `open` | два | независимые |

Как тогда нескольким процессам писать в один лог? Открывать с флагом
`O_APPEND`: перед каждой записью ядро атомарно переносит позицию в текущий конец
файла. На локальных файловых системах Linux одна операция `write` при этом
попадает в файл одним куском. В Go:

```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 в памяти (см.
[память в проде](/os/book/memory-cloud)). На диск они попадут позже. Если в этот
момент пропадёт питание, данные потеряются, хотя программа получила «успех».
Чтобы дождаться записи на носитель, нужен `fsync` (в Go — `f.Sync()`). Это
настолько важная для бэкенда тема, что ей посвящена отдельная глава:
[fsync и durability](/os/book/fsync-durability).

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/files-directories

## Частые ошибки

- Считать, что `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`.

## Вопросы для самопроверки

**Квиз «Файлы, каталоги, дескрипторы»**

1. Где хранится имя файла?
   - В записи каталога (таблица «имя → номер inode») (верно)
   - В самом inode рядом с правами и размером
   - В первом блоке данных файла
   - В таблице открытых файлов процесса
   - Пояснение: Inode хранит метаданные и указатели на блоки, но НЕ имя. Имя — это запись в каталоге, который сопоставляет строку-имя номеру inode. Поэтому у одного inode может быть несколько имён.
2. Что реально делает rm (системный вызов unlink) для обычного файла с refcount = 1?
   - Сразу затирает байты данных на диске нулями
   - Создаёт копию файла в корзине
   - Помечает inode как скрытый, но данные остаются навсегда
   - Удаляет запись из каталога и уменьшает refcount; при refcount=0 освобождает inode и блоки (верно)
   - Пояснение: unlink убирает имя из каталога и уменьшает счётчик ссылок. Данные и inode освобождаются, только когда refcount достиг нуля (и файл никем не открыт). Само слово unlink, а не delete, подсказывает суть.
3. Чем символическая ссылка (symlink) отличается от жёсткой (hard link)?
   - Symlink может указывать на файл в другой файловой системе и на каталог (верно)
   - Symlink — отдельный файл, хранящий строку-путь к цели (верно)
   - Hard link ломается («битая ссылка»), если удалить оригинал
   - Hard link — ещё одно имя для того же inode и увеличивает его refcount (верно)
   - Пояснение: Symlink хранит путь и работает между ФС/на каталоги, но ломается при удалении цели. Hard link делит inode и поднимает refcount, поэтому данные живут, пока есть хоть одно имя. Утверждение про «битую ссылку» у hard link неверно: ломается как раз symlink.
4. Где хранится смещение (offset) чтения/записи открытого файла?
   - В таблице файловых дескрипторов процесса (рядом с числом fd)
   - В записи открытого файла (open file description), на которую указывает fd (верно)
   - В блоках данных самого файла
   - В inode файла
   - Пояснение: Между fd и inode есть промежуточный уровень — запись открытого файла. Именно там лежит offset. Поэтому при fork/dup, где запись общая, offset тоже общий, а при двух независимых open — разный.
5. После fork родитель и ребёнок пишут в унаследованный fd. Что с offset?
   - Offset сбрасывается в 0 у ребёнка
   - У каждого свой независимый offset, записи затирают друг друга
   - Offset общий: они делят одну запись открытого файла, вывод дописывается по очереди (верно)
   - fd у ребёнка закрывается автоматически
   - Пояснение: fork копирует таблицу fd, но указывает на ту же запись открытого файла. Значит offset общий: запись одного двигает позицию для обоих, и данные не затираются. Это отличается от двух независимых open.
6. Какой номер файлового дескриптора по соглашению соответствует стандартному выводу (stdout)?
   - Ответ: 1
   - Пояснение: 0 — stdin, 1 — stdout, 2 — stderr. Поэтому редирект вывода в шелле подменяет именно fd 1 (через dup2), а первый открытый тобой файл обычно получает fd 3.
