42. Буферизация IO и Page Cache

Почему диск — узкое горлышко современной системы

Для бэкенд-разработчика, привыкшего измерять производительность сервисов в микросекундах и наносекундах, фундаментальное правило архитектуры компьютеров остается незыблемым: любые операции с дисковыми накопителями на 3–4 порядка медленнее, чем прямое обращение к оперативной памяти.

Даже современные серверные накопители NVMe SSD с интерфейсом PCIe 4.0/5.0 не способны нарушить законы физики полупроводников. Именно поэтому операционная система Linux никогда не читает и не записывает данные на физический диск при каждом вызове прикладной программы. Вместо этого ядро выстраивает мощный защитный барьер — Page Cache (кэш страниц), превращающий всю незанятую оперативную память сервера в сверхбыстрый интеллектуальный буфер дискового ввода-вывода.

Понимание того, как устроена буферизация на уровне ОС, критически важно для Go-инженера. Оно определяет выбор между bufio и прямым системным вызовом syscall, влияние операций fsync на 99-й перцентиль задержки (p99 latency), поведение микросервисов под пиковой нагрузкой и причины внезапных просадок производительности процессора.

flowchart TD
    subgraph UserspaceTier["1. Прикладной буфер (User Space)"]
        GoBuf["bufio.Writer / byte slice<br/>Предотвращает частые системные вызовы"]
    end

    subgraph KernelTier["2. Системный Page Cache (Kernel Space RAM)"]
        RadixTree["xarray (структура struct page в RAM)<br/>Мгновенные Page Hit за доли наносекунд"]
    end

    subgraph HardwareTier["3. Физический накопитель (NVMe SSD / HDD)"]
        DiskHW["Контроллер диска + Флеш-память NAND<br/>Медленные миллисекундные задержки"]
    end

    GoBuf -->|"syscall write() (пакетный сброс)"| RadixTree
    RadixTree -->|"Асинхронный writeback (kworker)"| DiskHW

    classDef user fill:#1e1b4b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef kern fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
    classDef hw fill:#022c22,stroke:#10b981,stroke-width:2px,color:#f8fafc;

    class UserspaceTier,GoBuf user;
    class KernelTier,RadixTree kern;
    class HardwareTier,DiskHW hw;
  

Page Cache: Первый рубеж защиты от медленного железа

Когда ваше приложение вызывает функцию os.ReadFile() или метод file.Write(), данные не отправляются на диск немедленно. Они проходят через многоуровневую систему кэширования в оперативной памяти ядра.

flowchart TD
    Req["syscall read(fd, buf, len)"] --> Lookup{"Поиск страницы в xarray"}
    Lookup -->|"Page Hit (Страница в RAM)"| CopyHit["copy_to_user(): мгновенный возврат! (~100 нс)"]
    Lookup -->|"Page Miss (Нет в RAM)"| DiskIO["Блокирующий запрос к контроллеру диска"]
    DiskIO --> DMA["DMA загрузка в новую struct page"]
    DMA --> ReadAhead["Read-Ahead: чтение соседних страниц вперед!"]
    ReadAhead --> CopyMiss["copy_to_user(): возврат данных (~1-5 мс)"]

    classDef hit fill:#022c22,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    classDef miss fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
    classDef step fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;

    class Req,Lookup,DMA,ReadAhead step;
    class CopyHit hit;
    class DiskIO,CopyMiss miss;
  

Механизмы чтения и записи: Hit, Miss и Dirty Pages

Чтение (Read): Упреждающий алгоритм Read-Ahead
ОС не ограничивается пассивным ожиданием. Если ядро видит, что процесс последовательно считывает страницу за страницей, планировщик I/O включает алгоритм Read-Ahead: он превентивно считывает следующие 16–32 страницы с диска в Page Cache еще до того, как ваше приложение вызовет следующий Read(). Для программы чтение превращается в непрерывную череду мгновенных Page Hit!

Запись (Write): Отложенная буферизация Write-Back
Запись работает по принципу Write-Back. Когда вы вызываете системный вызов write(), ядро выделяет страницу в Page Cache, копирует туда байты приложения через copy_from_user и выставляет в структуре страницы флаг dirty (грязная).

Физическая запись на накопитель откладывается. Этим занимаются выделенные фоновые потоки ядра (kworker, ранее известные как flush и pdflush), которые в фоновом режиме упорядочивают грязные страницы и пакетно сбрасывают их на диск.


Два уровня буферизации: Прикладной vs Системный

В архитектуре Go буферизация выстроена на двух независимых уровнях. Понимание границы между ними позволяет избежать грубых архитектурных ошибок.

Уровень Инструмент Физическая локация Что делает и от чего защищает
Прикладной bufio, io.Writer User Space (память процесса Go) Накапливает мелкие порции данных в памяти приложения, сокращая число переключений контекста CPU и системных вызовов write().
Системный Page Cache (ядро ОС) Kernel Space (оперативная память RAM) Кэширует дисковые блоки в структуре struct page, маскируя физические задержки накопителей.
flowchart LR
    subgraph AppSpace["User Space (Приложение Go)"]
        P1["Write(10B)"] --> Bufio["bufio.Writer (Буфер 64 КБ)"]
        P2["Write(10B)"] --> Bufio
        P3["Write(10B)"] --> Bufio
    end

    subgraph KernelSpace["Kernel Space (Linux)"]
        Bufio ==>|"ОДИН syscall write() на 64 КБ"| SyscallGate["Переход Ring 3 -> Ring 0"]
        SyscallGate --> PageCache["Page Cache (RAM)"]
    end

    classDef app fill:#1e1b4b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef kern fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;

    class AppSpace,P1,P2,P3,Bufio app;
    class KernelSpace,SyscallGate,PageCache kern;
  

Если вы вызываете метод file.Write() с крошечными кусочками по 10–20 байт без обертки bufio, вы на каждой итерации платите:

  1. За переключение контекста процессора между User Space и Kernel Space.
  2. За копирование байтов в память ядра функцией copy_from_user (а при чтении — copy_to_user).
  3. За блокировку внутренних структур VFS ядра.

Page Cache ядра не спасает от этих затрат: он защищает только от медленного диска, но не от накладных расходов на системные вызовы!


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

Стандартная библиотека Go и системные пакеты предоставляют богатую палитру инструментов для управления дисковым вводом-выводом.

1. bufio — стандартный подход

Go
package main

import (
    "bufio"
    "os"
)

func writeLogs() error {
    f, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
    if err != nil {
        return err
    }
    defer f.Close()

    // NewWriterSize создает буфер нужного размера в куче Go
    w := bufio.NewWriterSize(f, 64*1024) // 64 КБ
    defer w.Flush() // Принудительный сброс остатка буфера в системный вызов write()

    _, err = w.Write([]byte("data"))
    if err != nil {
        return err
    }
    return nil
}

2. syscall.O_DIRECT — обход Page Cache

Иногда кэш операционной системы не помогает, а откровенно вредит. Классический пример — ядро СУБД (PostgreSQL, ScyllaDB, RocksDB) или видеостриминг терабайтных файлов:

  • Данные уже закэшированы во внутреннем буферном пуле СУБД.
  • Дублирование их в Page Cache ядра приводит к нерациональной трате RAM.
  • Потоковое чтение гигантских файлов вымывает из кэша ядра полезные страницы других сервисов.

Флаг O_DIRECT (в Go он объявлен как syscall.O_DIRECT: константы os.O_DIRECT не существует) заставляет контроллер DMA передавать данные напрямую между пользовательским буфером в оперативной памяти и накопителем, полностью минуя Page Cache:

Go
package main

import (
    "os"
    "syscall"
)

func directWrite() error {
    // Открываем файл с системным флагом O_DIRECT
    f, err := os.OpenFile("data.bin", os.O_RDWR|os.O_CREATE|syscall.O_DIRECT, 0644)
    if err != nil {
        return err
    }
    defer f.Close()

    // ОЧЕНЬ ВАЖНО: адрес памяти и размер слайса для O_DIRECT 
    // обязаны быть строго выровнены по размеру страницы (обычно 4096 байт)
    // (make([]byte, 4096) такого не гарантирует: надёжнее взять память из syscall.Mmap, она всегда выровнена по странице)
    buf := make([]byte, 4096) 
    _, err = f.Write(buf)
    if err != nil {
        return err
    }
    return nil
}

3. mmap — маппинг файла в память

Системный вызов mmap проецирует дисковый файл прямо в виртуальное адресное пространство процесса. Это позволяет работать с файлом как с гигантским слайсом байтов, а ядро само подгружает страницы в Page Cache по мере физического обращения к ним (ленивая загрузка через механизм Page Fault):

Go
package main

import (
    "os"
    "golang.org/x/sys/unix"
)

func mmapRead(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close()

    stat, err := f.Stat()
    if err != nil {
        return nil, err
    }

    // Маппинг только для чтения (PROT_READ) в режиме MAP_SHARED
    data, err := unix.Mmap(int(f.Fd()), 0, int(stat.Size()), unix.PROT_READ, unix.MAP_SHARED)
    if err != nil {
        return nil, err
    }

    // data теперь доступен как обычный []byte.
    // Страницы загружаются ядром в Page Cache прозрачно при чтении data[i]!
    return data, nil
}

Mechanical Sympathy: Влияние на CPU, GC и память

  1. Копирование памяти и кэши процессора: При классическом вызове write() данные преодолевают маршрут: Буфер Go в userspace -> Буфер сокета/VFS -> Page Cache -> DRAM накопителя. Это требует системных операций копирования (copy_from_user и copy_to_user), нагружая кэш процессора L1/L2 и шину памяти.
  2. Давление на сборщик мусора (GC): Необдуманное создание временных буферов bufio размером по 64–256 КБ на каждое сетевое соединение раздувает кучу и создает постоянную работу для GC. Используйте sync.Pool для переиспользования буферов.
  3. Давление на память (Memory Pressure): Если свободной памяти в операционной системе становится мало, ядро начинает агрессивно вытеснять чистые страницы Page Cache и форсировать сброс dirty страниц на диск. Это выражается в неожиданных скачках latency дисковых операций в метриках Go-сервиса.
  4. Сравнение с PHP и Java: В классическом PHP функция file_put_contents() сбрасывает данные мелкими порциями через буфер libc. В Java класс FileOutputStream также требует явной обертки BufferedOutputStream. Go дает разработчику бескомпромиссную прозрачность: вы можете филигранно выбрать между удобством bufio, экстремальной скоростью O_DIRECT или ленивой проекцией mmap.

Итоги

  1. Page Cache — краеугольный камень производительности ОС, превращающий оперативную память в прозрачный кэш страниц дисковых блоков (по 4 КБ), индексируемый через дерево xarray.
  2. Write-Back и Dirty Pages: Записи буферизуются в памяти. Достижение порога vm.dirty_ratio приводит к принудительной блокировке пишущих процессов прямо в вызове write().
  3. bufio решает другую задачу: Он уменьшает число переходов контекста CPU между пространством пользователя и ядром, не заменяя Page Cache, а дополняя его.
  4. fsync() стоит дорого: Он принудительно останавливает поток до физического подтверждения от диска.
  5. O_DIRECT и mmap позволяют обойти стандартное копирование в Page Cache, требуя строгого выравнивания буферов и понимания физики накопителей.

Следующая статья

Мы детально исследовали, как операционная система буферизует ввод-вывод в оперативной памяти. Но что делать, если мы хотим вообще исключить процессор из цепочки перекладывания байтов между файлами и сетевыми сокетами? В следующей статье мы разберем механизмы обхода кэша и аппаратного ускорения: 43. Direct IO и Zero Copy.