3. Файловая система Linux

VFS, Inodes, дескрипторы и Page Cache

«В Unix всё есть поток байтов, и практически всем можно управлять через файловый дескриптор».
— Брайан Керниган

В классическом представлении прикладного бэкенд-разработчика файловая система — это всего лишь папка на диске, где лежат конфигурационные YAML-файлы, логи приложения и статические ассеты. Но в операционной системе Linux философия радикально глубже: «Всё есть файл» (Everything is a file).

Сетевой TCP-сокет — это файл. Анонимный межпроцессный канал (pipe) — это файл. Виртуальное устройство /dev/null — это файл. Директория на диске — это тоже файл. Системные метрики ядра в /proc и параметры железа в /sys — это файлы.

Для ядра Linux все эти сущности взаимодействуют через единый интерфейс Виртуальной файловой системы (VFS). Понимание того, как VFS, дисковые блоки и страничный кэш операционной системы работают на физическом уровне (Mechanical Sympathy), напрямую определяет, как вы будете проектировать дисковый ввод-вывод в Go, организовывать логирование и тюнить параметры высоконагруженных баз данных.


VFS и Inodes: Как ядро видит файлы

Когда вы открываете файл в Go (os.Open), вы не получаете прямой указатель на сектор на диске. Вы взаимодействуете с абстракцией — VFS (Virtual File System). VFS позволяет Linux поддерживать десятки разных файловых систем (ext4, XFS, Btrfs, NFS) под одним унифицированным API.

VFS предоставляет унифицированный набор системных вызовов (openat, read, write, close, stat), транслируя их в конкретные драйверы файловых систем.

Ключевая концепция VFS — Inode (Index Node). Когда вы создаете файл, ядро выделяет для него Inode — структуру данных на диске, которая хранит всё о файле, кроме его имени:

flowchart TD
    A["Директория - Файл со списком имен"] -->|"Маппинг: Имя -> Inode номер"| B["Inode"]
    B -->|"Хранит метаданные"| C["Размер, Права доступа, Владелец, Таймстемпы"]
    B -->|"Хранит указатели"| D["Блоки данных на диске"]
    D --> E["Фрагментированные или непрерывные блоки - 4KB каждый"]

    style A fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#fff
    style B fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#fff
    style C fill:#065f46,stroke:#10b981,color:#fff
    style D fill:#78350f,stroke:#f59e0b,color:#fff
    style E fill:#0f172a,stroke:#475569,color:#fff
  

В современных файловых системах (ext4, XFS) блоки данных часто организуются в экстенты (extents) — непрерывные цепочки блоков, что минимизирует фрагментацию диска при последовательной записи.

Если процесс удерживает открытый файловый дескриптор, вызов rm удалит имя из каталога, но блоки данных на диске не освободятся, пока процесс не закроет дескриптор или не завершится. Это классическая причина, почему df -h показывает 100% заполненный диск, а du -sh сообщает о полупустой файловой системе.


Файловые дескрипторы (File Descriptors)

Когда Go вызывает системный вызов openat (современная версия open), ядро создает запись в таблице файловых дескрипторов процесса и возвращает целое число — FD (File Descriptor).

Для процесса в Linux выделяются первые три дескриптора по умолчанию:

  • 0 — Standard Input (stdin)
  • 1 — Standard Output (stdout)
  • 2 — Standard Error (stderr)

Каждый открытый файл, сокет или канал получает следующий доступный FD. В Go структуре os.File соответствует поле file.pfd.Sysfd, которое и хранит этот номер дескриптора.

Ловушка лимитов (ulimit)

Процесс не может открывать файлы бесконечно. Ядро Linux ограничивает количество открытых FD на процесс.


Mechanical Sympathy: Page Cache и цена дискового IO

Обращение к диску (даже NVMe SSD) — это миллионы тактов CPU. Обращение к RAM — это сотни тактов. ОС Linux пытается минимизировать прямое чтение с диска, используя Page Cache (страничный кэш).

Когда вы читаете файл, ядро ищет его блоки в Page Cache (в оперативной памяти). Если их там нет (cache miss), оно читает с диска и сохраняет копию в Page Cache. При последующих чтениях данные берутся из памяти.

Когда вы пишете в файл через write(), ядро не пишет сразу на диск. Оно копирует данные из пространства пользователя в Page Cache (в пространство ядра), помечает страницу как "грязную" (dirty) и немедленно возвращает управление в программу. Физическая запись на диск произойдет позже асинхронно фоновым потоком ядра (pdflush/flush). Это называется Write-back cache.

Go
// Не делайте так в базах данных!
data := []byte("critical financial transaction")
file.Write(data) 
// Write вернул success, но данные могут быть только в Page Cache!
// Если сервер моргнет светом, вы потеряете данные.

Чтобы гарантировать, что данные физически на диске (durability), нужно вызвать fsync(fd). Это заставляет ядро сбросить все dirty pages для этого FD на диск. Это невероятно дорогая операция, которая блокирует вызывающий поток.

O_DIRECT: Обход кэша

СУБД (PostgreSQL, RocksDB) часто открывают файлы с флагом O_DIRECT. Этот флаг говорит ядру: "Не используй Page Cache, читай и пиши напрямую в пользовательский буфер в обход кэша ядра". СУБД предпочитают управлять кэшированием сами (shared buffers в Postgres), чтобы избежать двойного потребления RAM.


Go и работа с IO

В Go есть важная архитектурная особенность, о которой часто забывают: сетевой IO и файловый IO обрабатываются рантаймом по-разному.

  1. Сетевой IO (Net Poller): Go использует системный вызов epoll в Linux. Когда горутина читает из сокета, а данных нет, рантайм переводит горутину в состояние ожидания (паркует), не потребляя поток ОС (M). Один системный поток может обслуживать тысячи спящих сетевых горутин.
  2. Файловый IO: К сожалению, epoll не работает с обычными файлами в Linux (они всегда готовы для чтения/записи). Поэтому, когда горутина делает системный вызов read или write для файла, рантайм Go блокирует поток ОС (M) до завершения операции.

Буферизация: почему bufio спасает производительность

Учитывая стоимость системных вызовов (переключение Ring 3 -> Ring 0 и копирование данных), вызов file.Write для записи 10 байт — это преступление против Mechanical Sympathy. Вы потратите 90% времени на оверхед ядра, а не на само IO.

Пакет bufio в Go реализует буферизацию в User Space.

Go
package main

import (
    "bufio"
    "os"
)

func main() {
    // Открываем файл с флагами O_WRONLY, O_CREATE, O_APPEND
    f, err := os.OpenFile("app.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644)
    if err != nil {
        panic(err)
    }
    defer f.Close()

    // Оборачиваем в bufio.Writer. По умолчанию буфер 4KB.
    writer := bufio.NewWriter(f)

    // Эти записи просто кладут данные в слайс в памяти (User Space).
    // Никаких системных вызовов нет!
    _, err = writer.WriteString("log entry 1\n")
    if err != nil {
        panic(err)
    }
    _, err = writer.WriteString("log entry 2\n")
    if err != nil {
        panic(err)
    }

    // Только сейчас данные из буфера (4KB) копируются в ядро 
    // через один системный вызов write().
    if err := writer.Flush(); err != nil {
        panic(err)
    }
}

Используя bufio, вы группируете мелкие записи в один большой буфер, и сбрасываете его одним системным вызовом. Это снижает нагрузку на CPU и улучшает утилизацию Page Cache.


Итог

  1. Inodes — фундамент файловой системы Linux. Имя файла — это лишь ссылка в директории, а метаданные и указатели на данные хранятся в Inode.
  2. File Descriptors (FD) — это интерфейс взаимодействия с любым IO в Linux. Следите за лимитами ulimit -n в продакшене.
  3. Page Cache — ваш лучший друг. Ядро кэширует чтение и откладывает запись (write-back). Но если вам нужна гарантия записи (durability) — вы обязаны вызывать fsync.
  4. Go Runtime: Сетевой IO асинхронен (через epoll), а дисковый IO блокирует потоки ОС. Для тяжелой работы с диском используйте пулы горутин.
  5. Буферизация (bufio) — обязательна для интенсивного файлового IO. Она минимизирует количество системных вызовов.

Файлы в Linux имеют не только содержимое, но и владельца, а также набор прав, определяющих, кто может этот файл читать, менять или исполнять. В следующей статье мы погрузимся в модель безопасности Linux и узнаем, почему контейнеры категорически не рекомендуется запускать от пользователя root: 4. Права доступа и безопасность.