25. Файловые дескрипторы. Все в Linux является файлом

Философия «Всё есть файл» и зачем это Go-разработчику

В Unix-подобных системах (включая Linux) действует фундаментальная абстракция: любое устройство, ресурс или канал связи представляется как файл. Сетевой сокет, пайп, терминал, диск, /dev/null или /dev/zero — всё это для ядра и пользовательских программ является файлами.

"Одним из самых гениальных упрощений Кена Томпсона и Денниса Ритчи в Bell Labs было решение отказаться от специализированных API для каждого вида оборудования. Лента, перфокарта, терминал или файл на диске — для программиста всё это просто непрерывный поток байтов с четырьмя базовыми операциями: открыть, прочитать, записать, закрыть."

Для Go-разработчика это не просто историческая данность, а ключ к пониманию работы io.Reader / io.Writer, сетевого стека и асинхронного IO. Понимание файловой модели позволяет писать предсказуемый код, избегать утечек ресурсов и эффективно работать с системными лимитами. Интерфейсы io.Reader и io.Writer в стандартной библиотеке Go являются прямым концептуальным наследником этой полувековой философии Unix.


Что такое файловый дескриптор (FD) «под капотом»

Файловый дескриптор (File Descriptor, FD) — это не указатель на файл и не структура данных. Это маленькое целое число (обычно int32), которое служит индексом в таблице открытых файлов текущего процесса.

Представьте номерок в гардеробе театра: сам по себе пластиковый жетон с цифрой 42 не греет вас в мороз. Но когда вы отдаете номерок гардеробщику (ядру ОС), он заглядывает в ячейку #42 гардеробной стойки (таблицу дескрипторов процесса), находит вешалку (структуру файла в ядре) и выдает ваше пальто (данные с диска или из сети).

По умолчанию каждому процессу при рождении достаются три стандартных номерка:

  • 0 — stdin (стандартный ввод);
  • 1 — stdout (стандартный вывод);
  • 2 — stderr (стандартный поток ошибок).

Когда вы вызываете системный вызов open() или socket(), ядро:

  1. Выделяет структуру struct file (описание файла) в ядре.
  2. Ищет свободный индекс в таблице struct files_struct текущего процесса (обычно начиная с наименьшего свободного номера).
  3. Возвращает этот индекс в пользовательское пространство.
flowchart TD
    A["Процесс Go"] -->|"open() или socket()"| B["Ядро Linux"]
    B -->|"Поиск свободного индекса в"| C["files_struct"]
    C -->|"Выделение"| D["struct file"]
    D -->|"Привязка к драйверу"| E["Диск, Сеть, Pipe, TTY"]
    B -->|"Возврат индекса int32"| A

    style A fill:#1e293b,stroke:#818cf8,color:#fff
    style B fill:#1e3a5f,stroke:#38bdf8,color:#fff
    style C fill:#064e3b,stroke:#34d399,color:#fff
    style D fill:#2e1065,stroke:#c084fc,color:#fff
    style E fill:#334155,stroke:#64748b,color:#cbd5e1
  

Ключевой момент: Таблица FD — это простой массив указателей. Поиск свободного дескриптора работает за O(1) или O(log N) в зависимости от реализации, что делает открытие файлов крайне быстрым. Однако при большом количестве открытых дескрипторов ядро может использовать bitmaps или RB-деревья для оптимизации поиска.


VFS: Магия унификации ресурсов

Чтобы абстракция «всё есть файл» работала, ядро использует VFS (Virtual File System) — слой виртуальной файловой системы. VFS предоставляет единый интерфейс read, write, close, lseek для любых ресурсов.

В ядре Linux каждый открытый ресурс описывается тремя уровнями:

  1. FD (индекс в таблице процесса) — локальный для процесса.
  2. File Description (struct file) — глобальный объект ядра, содержит текущую позицию чтения/записи (f_pos), флаги (O_APPEND, O_NONBLOCK), права доступа.
  3. Inode (struct inode) — метаданные на диске (для обычных файлов) или структура драйвера (для сокетов/пайпов).
graph LR
    subgraph Process1["Процесс 1 (PID 100)"]
        FD3_1["FD 3"]
    end

    subgraph Process2["Процесс 2 (PID 200, потомок после fork)"]
        FD3_2["FD 3"]
    end

    subgraph KernelSpace["Kernel Space: Open File Table"]
        OFD1["struct file (f_pos = 1024, flags = O_RDONLY)"]
    end

    subgraph VFSInode["VFS Inodes"]
        Inode1["struct inode (ext4 /data/app.log)"]
    end

    FD3_1 --> OFD1
    FD3_2 --> OFD1
    OFD1 --> Inode1

    style Process1 fill:#1e293b,stroke:#818cf8,color:#fff
    style Process2 fill:#1e293b,stroke:#818cf8,color:#fff
    style KernelSpace fill:#064e3b,stroke:#34d399,color:#fff
    style VFSInode fill:#2e1065,stroke:#c084fc,color:#fff
  

Управление дескрипторами в Go: Идиомы и ловушки

Go предоставляет высокоуровневый обертку os.File, но под капотом это просто обертка над FD. Понимание того, как Go управляет дескрипторами, критично для production-кода.

1. os.NewFile и ручное управление

Если вы работаете с низкоуровневыми API (например, получаете FD из syscall или net.Conn), вы должны явно создать Go-обертку:

Go
import (
    "os"
    "syscall"
)

func handleRawFD() error {
    // Допустим, мы получили FD из внешнего API
    rawFD := uintptr(42)

    // Создаем обертку. Второй аргумент — имя для дебага/логов.
    // Важно: os.NewFile НЕ делает dup! Он просто забирает владение.
    f := os.NewFile(rawFD, "custom-resource")
    if f == nil {
        return syscall.EBADF
    }
    defer f.Close() // Явное закрытие обязательно!

    // Работа с файлом...
    return nil
}

2. O_CLOEXEC и предотвращение утечек

При вызове exec.Command или fork() дочерний процесс наследует таблицу FD родителя. Если вы открыли сокет или файл без флага O_CLOEXEC, он автоматически откроется в дочернем процессе, что может привести к:

  • Утечке ресурсов (child держит дескриптор открытым после exec).
  • Конфликтам (несколько процессов пишут в один сокет/лог).
  • Нарушению безопасности (child получает доступ к приватным ресурсам).
Go
import (
    "os"
    "syscall"
)

func openWithCloexec(path string) (*os.File, error) {
    fd, err := syscall.Open(path, syscall.O_RDWR|syscall.O_CLOEXEC, 0644)
    if err != nil {
        return nil, err
    }
    return os.NewFile(uintptr(fd), path), nil
}

3. os.File vs net.Conn vs raw syscall

  • os.File — оптимизирован для read/write с буферизацией, поддерживает io.Reader/Writer, io.Seeker.
  • net.Conn — обертка над сокетами. В Go 1.11+ net.Conn реализует io.ReaderAt и io.WriterTo только если underlying FD поддерживает sendfile или splice.
  • syscall — нулевая абстракция. Используется в netpoller и для epoll/kqueue.

Механическая симпатия: Стоимость и оптимизация

1. Поиск FD и VFS overhead

Каждый read/write проходит через:
User Space -> Syscall -> files_struct lookup -> struct file validation -> VFS dispatch -> Driver
Этот путь добавляет несколько десятков тактов CPU. В высоконагруженном Go-сервере (10k+ RPS) вызовы os.File могут стать узким местом по сравнению с raw syscall или epoll-циклами.

2. Лимиты и масштабируемость

Каждый открытый FD потребляет память в ядре (~1-2 КБ на struct file + struct inode). При 100k одновременных соединений (например, WebSocket или gRPC) вы подходите к системному лимиту.

3. Zero Copy и FD

Понимание FD позволяет использовать splice и sendfile для передачи данных между диском и сетью без копирования в user space. В Go это реализуется через syscall.Sendfile или golang.org/x/sys/unix, что критично для статических файлов или проксирования.


Итоги

  1. FD — это индекс, а не указатель. Таблица дескрипторов работает за O(1).
  2. VFS унифицирует диск, сеть, пайпы и терминалы под единый интерфейс read/write/close.
  3. Go управляет FD через os.File, но требует явного Close(). GC не спасает от утечек FD в production.
  4. O_CLOEXEC обязателен при работе с низкоуровневыми syscall и fork/exec.
  5. Лимиты и overhead FD напрямую влияют на масштабируемость высоконагруженных Go-серверов.

Мы разобрали фундаментальный механизм доступа к ресурсам. Но как эти ресурсы передаются между процессами без создания новых соединений? В следующей статье мы погрузимся в 26. Pipe, Named Pipe и перенаправление потоков, где разберем, как Linux реализует канальную передачу данных и как Go использует это для асинхронного пайплайна.