Перейти к содержанию

Page Cache: как Linux ускоряет файловый I/O

Диск — самое медленное звено в иерархии памяти: даже NVMe-SSD на порядки медленнее RAM по латентности (~100 мкс против ~100 нс), а HDD — ещё на два порядка хуже. Если бы каждый read(2) приводил к реальному обращению к блочному устройству, любая программа, повторно читающая файл, упиралась бы в диск. Поэтому ядро Linux держит в RAM page cache — прозрачный кэш страниц всех файлов, которые были когда-либо прочитаны или записаны.

Page cache работает без участия приложения: read(2) и write(2) не отличаются по API для холодного и горячего файла — разница только в скорости. Свободная RAM в Linux почти всегда занята page cache, и это не «утечка памяти», а ожидаемое поведение. Команда free показывает эти страницы в колонке buff/cache, и они мгновенно освобождаются под нужды приложений.

Где живёт page cache в архитектуре ядра

Page cache расположен между VFS (Virtual File System — единый интерфейс для всех файловых систем) и block layer (драйверы блочных устройств). Любой read/write через VFS сначала смотрит, есть ли нужная страница в кэше, и только при промахе обращается к конкретной ФС, которая в свою очередь идёт в block layer.

┌─────────────────────────────────────────────────────────────┐
│                    user-space (приложение)                  │
│                                                             │
│       read(fd, buf, n)              write(fd, buf, n)       │
└──────────┬───────────────────────────────┬──────────────────┘
           │                               │
           ▼                               ▼
┌─────────────────────────────────────────────────────────────┐
│                       VFS (sys_read / sys_write)            │
└──────────┬───────────────────────────────┬──────────────────┘
           │                               │
           ▼                               ▼
┌─────────────────────────────────────────────────────────────┐
│                      Page Cache                             │
│                                                             │
│   ┌──────────────────────────────────────────────────────┐  │
│   │  inode A.address_space                               │  │
│   │  ┌──────┬──────┬──────┬──────┬──────┬──────┬──────┐  │  │
│   │  │ pg 0 │ pg 1 │  -   │ pg 3 │  -   │ pg 5 │  -   │  │  │
│   │  └──────┴──────┴──────┴──────┴──────┴──────┴──────┘  │  │
│   │           ▲                                          │  │
│   │           └── 4 KiB страницы файла, ключ = offset    │  │
│   └──────────────────────────────────────────────────────┘  │
└──────────┬───────────────────────────────┬──────────────────┘
           │  miss                         │  dirty page
           ▼                               ▼
┌─────────────────────────────────────────────────────────────┐
│       Filesystem (ext4, xfs, btrfs, ...) ── метаданные      │
└──────────┬───────────────────────────────┬──────────────────┘
           │                               │
           ▼                               ▼
┌─────────────────────────────────────────────────────────────┐
│   Block layer (bio, request queue, I/O scheduler)           │
└──────────┬───────────────────────────────┬──────────────────┘
           │                               │
           ▼                               ▼
                       блочное устройство (NVMe, SATA, ...)

Каждая страница в кэше принадлежит ровно одному файлу (точнее, одному inode) и идентифицируется парой (inode, offset_in_file). Ключевая структура — struct address_space, она привязана к inode и хранит дерево страниц этого файла:

// упрощённое представление, ядро 6.x
struct address_space {
    struct inode       *host;            // inode-владелец
    struct xarray       i_pages;         // дерево: offset → struct page*
    atomic_t            nr_thps;         // huge pages в кэше
    pgoff_t             writeback_index; // позиция writeback-обхода
    const struct address_space_operations *a_ops; // readpage, writepage, ...
    unsigned long       flags;
    // ... gfp_mask, private_lock, private_list, host i_mmap, ...
};

Поиск страницы по offset — операция в xarray (с ядра 4.20 — замена radix tree), O(log n) с очень маленькой константой. На горячих файлах попадание в кэш стоит десяток наносекунд.

Чтение через page cache

Первый read(2) страницы — cache miss: ядро аллоцирует физическую страницу, инициирует чтение с диска, копирует данные в user buffer. Все последующие чтения той же страницы — cache hit: данные копируются прямо из RAM.

read() — cache miss (холодный файл)

  user                kernel                   page cache         disk
   │                    │                         │                │
   │  read(fd, buf, n)  │                         │                │
   │ ─────────────────▶ │                         │                │
   │                    │  поиск в i_pages        │                │
   │                    │ ──────────────────────▶ │                │
   │                    │       MISS              │                │
   │                    │ ◀────────────────────── │                │
   │                    │                         │                │
   │                    │  alloc_page + submit_bio                 │
   │                    │ ──────────────────────────────────────▶  │
   │                    │                                          │
   │                    │  ждёт (process sleeps, I/O wait)         │
   │                    │ ◀──────────────────────────────────────  │
   │                    │  страница в кэше, добавляем в xarray     │
   │                    │                         │                │
   │                    │  copy_to_user(buf)      │                │
   │ ◀───────────────── │                         │                │
   │  read вернул n     │                         │                │


read() — cache hit (горячий файл)

  user                kernel                   page cache         disk
   │  read(fd, buf, n)  │                         │                │
   │ ─────────────────▶ │                         │                │
   │                    │  поиск в i_pages        │                │
   │                    │ ──────────────────────▶ │                │
   │                    │       HIT               │                │
   │                    │ ◀────────────────────── │                │
   │                    │  copy_to_user(buf)      │                │
   │ ◀───────────────── │                         │                │
   │  read вернул n (≈ 10× быстрее)               │                │

Системный вызов read всегда копирует данные дважды: с диска в page cache, затем из page cache в user buffer. Для нулевого копирования существуют sendfile(2), splice(2) и mmap(2) — они отдают page cache напрямую, без второго memcpy.

Запись через page cache: writeback

write(2) по умолчанию не идёт на диск синхронно. Ядро копирует данные в page cache, помечает страницу как dirty (грязная — содержит изменения, не сброшенные на диск) и возвращает управление приложению. Реальный сброс на диск выполняется асинхронно специальными потоками writeback.

write() — асинхронный путь

  user                kernel              page cache       writeback     disk
   │                    │                    │                │            │
   │ write(fd, buf, n)  │                    │                │            │
   │ ─────────────────▶ │                    │                │            │
   │                    │  copy_from_user    │                │            │
   │                    │ ─────────────────▶ │                │            │
   │                    │  set_page_dirty    │                │            │
   │                    │ ─────────────────▶ │                │            │
   │ ◀───────────────── │                    │                │            │
   │   write вернул n   │                    │                │            │
   │                    │                    │                │            │
   │  ... время идёт ...│                    │                │            │
   │                    │                    │                │            │
   │                    │  bdi-writeback просыпается:         │            │
   │                    │  таймер 30 сек / dirty > порог      │            │
   │                    │                    │ ──────────────▶│            │
   │                    │                    │  собирает      │            │
   │                    │                    │  dirty pages   │            │
   │                    │                    │                │ submit_bio │
   │                    │                    │                │ ─────────▶ │
   │                    │                    │                │            │
   │                    │                    │  страница clean│            │

Такая отложенная запись приносит две выгоды:

  • Поглощение перезаписей. Если приложение записывает в одно и то же место 1000 раз, на диск уйдёт только последняя версия страницы.
  • Объединение в крупные I/O. Writeback собирает соседние dirty страницы в один большой запрос к блочному устройству, что в разы эффективнее точечных записей.

Платой служит риск потери данных при пропадании питания: всё, что было записано через write за последние десятки секунд и не успело уйти на диск, исчезает. Для критичных данных существуют fsync(2)/fdatasync(2).

Writeback-потоки

Исторически отдельная подсистема пережила несколько имён:

Имя Когда Реализация
bdflush до ядра 2.6 один поток на всю систему
pdflush 2.6.0 – 2.6.31 пул потоков, динамически растёт
flush-MAJ:MIN 2.6.32 – 2.6.39 один поток на каждое устройство
bdi-writeback с 3.10 и до сих пор per-BDI workqueue, имена kworker/*

BDI (Backing Device Info) — структура, описывающая одно блочное устройство со стороны page cache. Каждый BDI имеет свой список dirty inodes и свою очередь writeback-работ. Имя bdi в утилитах вроде ps обычно скрыто внутри generic-имён kworker/u*:*.

Writeback запускается по нескольким триггерам:

  • Периодический. Раз в dirty_writeback_centisecs (по умолчанию 5 секунд) поток просыпается и ищет страницы, которые были помечены dirty дольше dirty_expire_centisecs (30 секунд).
  • По порогу dirty. Когда грязных страниц становится больше dirty_background_ratio от RAM — writeback запускается фоном, не блокируя пишущих.
  • Throttling. Когда dirty превышает dirty_ratiowrite(2) блокируется до тех пор, пока writeback не сбросит часть данных. Цель — не дать одному приложению забить всю RAM грязью.
  • Принудительный. sync(2), fsync(2), fdatasync(2), sync_file_range(2), размонтирование ФС.

Параметры через sysctl

# Доля dirty pages от total RAM, при которой writeback запускается фоном
sysctl vm.dirty_background_ratio          # default: 10
# Доля dirty pages, при которой write() блокируется (throttling)
sysctl vm.dirty_ratio                     # default: 20
# Возраст dirty page в сотых долях секунды, после которого она подлежит сбросу
sysctl vm.dirty_expire_centisecs          # default: 3000 (= 30 сек)
# Период пробуждения writeback-потоков
sysctl vm.dirty_writeback_centisecs       # default: 500 (= 5 сек)

Альтернативные vm.dirty_background_bytes / vm.dirty_bytes задают пороги в байтах вместо процентов и удобнее на машинах с большим RAM, где 10% — это уже десятки гигабайт.

На серверах баз данных и приложениях с высокой пишущей нагрузкой ratios обычно занижают: иначе накопленные за минуту дюжины гигабайт грязи разом уходят на диск, и latency fsync улетает в небо.

Readahead

Когда приложение читает файл последовательно, ядро может опередить запросы и заранее загрузить в кэш соседние страницы. Это readahead (упреждающее чтение): один read(4096) может фактически вытащить с диска 128 KiB и более.

Алгоритм Linux адаптивный — он отслеживает паттерн доступа и подстраивает размер окна.

Адаптивное readahead window

  страницы файла:  0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15 16 17 18 19

  app reads pg 0   [█]                       первый промах → init window = 32 KiB
                   [█ █ █ █ █ █ █ █]                 ← async readahead на (0..7)

  app reads pg 1                             cache hit, тренд = sequential
  ...
  app reads pg 7   граница окна, async trigger
                   [█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █]  ← window удвоилось до 64 KiB
                                  ↑ readahead на (8..15)

  app reads pg 16  опять граница, window растёт
                   до vm.max_readahead (по умолчанию 128 KiB)

При обнаружении непоследовательного доступа окно сбрасывается. На случайных чтениях readahead выключается — каждая страница тянется отдельно, иначе ядро впустую читало бы соседние данные.

Глобальный потолок задаётся per-device:

# Текущий размер readahead в KiB
cat /sys/block/sda/queue/read_ahead_kb     # default: 128
# или через blockdev (в секторах по 512 байт)
sudo blockdev --getra /dev/sda             # значение в 512-байтных секторах
sudo blockdev --setra 4096 /dev/sda        # 2 MiB readahead

Для последовательного сканирования больших файлов (резервные копии, ETL) полезно поднять до нескольких мегабайт. Для random-IO нагрузок (СУБД, OLTP) — наоборот, оставить дефолт или уменьшить.

Подсказки ядру: fadvise и madvise

Приложение может явно сообщить ядру намерения доступа к файлу. Это меняет стратегию readahead и вытеснения.

posix_fadvise(2)

Применяется к открытому файловому дескриптору:

#include <fcntl.h>
int posix_fadvise(int fd, off_t offset, off_t len, int advice);
Совет Эффект
POSIX_FADV_NORMAL сбросить совет, обычная стратегия
POSIX_FADV_SEQUENTIAL удваивает readahead window; ожидаем последовательный обход
POSIX_FADV_RANDOM отключает readahead — каждый блок тянется отдельно
POSIX_FADV_WILLNEED префетч диапазона страниц немедленно
POSIX_FADV_DONTNEED сбросить страницы диапазона из page cache
POSIX_FADV_NOREUSE страница нужна один раз (на Linux — игнорируется)

Типичные применения:

// Большой бэкап-архив, читаем строго последовательно
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);

// База данных, рандомный доступ к страницам
posix_fadvise(fd, 0, 0, POSIX_FADV_RANDOM);

// Прочитали лог-файл целиком, второй раз не понадобится — освободить кэш
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);

POSIX_FADV_DONTNEED для долгоиграющего процесса, который пишет много одноразовых данных (логгеры, бэкап-агенты, антивирус), не даёт ему вымыть из кэша горячие страницы других приложений.

madvise(2)

Аналог для регионов mmap:

#include <sys/mman.h>
int madvise(void *addr, size_t length, int advice);

Те же MADV_SEQUENTIAL, MADV_RANDOM, MADV_WILLNEED, MADV_DONTNEED. Плюс Linux-расширения:

Совет Эффект
MADV_FREE страницы можно освободить лениво (для аллокаторов)
MADV_HUGEPAGE пометить регион как кандидат для transparent huge pages
MADV_NOHUGEPAGE запретить huge pages для региона
MADV_COLD пометить страницы как «холодные» — кандидаты на вытеснение
MADV_PAGEOUT принудительно вытолкнуть страницы из RAM (в swap или на диск)

Direct I/O: обход кэша

Иногда page cache мешает. Например, у СУБД есть собственный buffer pool, который знает рабочую нагрузку гораздо лучше ядра. Хранить те же страницы дважды (в page cache и в buffer pool) — пустая трата RAM. Решение — флаг O_DIRECT при open(2).

int fd = open("/var/lib/db/table.dat", O_RDWR | O_DIRECT);

С O_DIRECT ядро не кэширует страницы файла: каждый read/write идёт напрямую между user-space буфером и блочным устройством. Условия использования жёсткие:

  • буфер выровнен на размер блока ФС (обычно 4096 байт);
  • offset в файле кратен размеру блока;
  • длина чтения/записи кратна размеру блока.

Нарушения возвращают EINVAL. Для выделения выровненного буфера применяется posix_memalign(3).

Direct I/O используют PostgreSQL (через настройку), Oracle, MySQL InnoDB (O_DIRECT режим), ScyllaDB, многие СУБД. Современная альтернатива — io_uring с регистрированными буферами, который даёт сравнимую производительность с более удобным API.

Гарантия persistency: fsync, fdatasync, sync

write(2) возвращается, когда данные легли в page cache, не на диск. Чтобы убедиться, что данные действительно записаны на устройство и переживут пропадание питания, нужны явные вызовы:

Вызов Что сбрасывает
sync(2) все dirty pages всех файлов всех ФС, может занять минуты
syncfs(2) dirty pages конкретной ФС (через fd любого файла на ней)
fsync(fd) данные + метаданные одного файла
fdatasync(fd) данные + только критичные метаданные (без обновления mtime)
sync_file_range диапазон страниц без барьера кэша устройства (не гарантирует persist)
write(fd, data, n);    // данные в page cache, не на диске
fsync(fd);             // блокируемся, пока всё не записано на устройство
                       // (включая FLUSH CACHE команду на SSD/HDD)

fdatasync быстрее fsync для типичного случая (запись данных, перезапись существующего файла), так как пропускает обновление inode, если не менялся размер. Базы данных обычно используют именно его.

fsync гарантирует только то, что блочное устройство получило данные. Дешёвые SSD могут потерять запись при отключении питания, если у них нет супер-конденсатора. Полная гарантия требует устройств enterprise-класса или ИБП.

Сброс кэшей для бенчмарков

При замере производительности «холодного» чтения нужно гарантировать, что данные читаются с диска, а не из кэша:

# Сначала сбросить dirty pages на диск, иначе они тоже исчезнут
sync

# 1 — освободить только page cache
echo 1 | sudo tee /proc/sys/vm/drop_caches

# 2 — освободить dentry cache и inode cache
echo 2 | sudo tee /proc/sys/vm/drop_caches

# 3 — освободить и то, и другое
echo 3 | sudo tee /proc/sys/vm/drop_caches

drop_caches — недеструктивная операция: она освобождает только чистые (clean) страницы, dirty данные остаются. Поэтому sync перед drop_caches нужен, чтобы максимизировать освобождение, но не из соображений безопасности.

В продакшене drop_caches использовать не следует: после него все горячие файлы придётся читать заново. Это исключительно инструмент для воспроизводимых бенчмарков.

Мониторинг page cache

Сколько занято кэшем

free -h
#               total        used        free      shared  buff/cache   available
# Mem:           31Gi        4.2Gi       2.1Gi      512Mi        25Gi        26Gi

buff/cache = page cache + dentry/inode cache + slab объекты ФС. available — оценка того, сколько RAM реально может быть выделено приложению без свопа: free + большая часть кэша, которую можно освободить.

Детали через /proc/meminfo

cat /proc/meminfo | grep -E 'Cached|Buffers|Dirty|Writeback'
# MemTotal:       32890124 kB
# Cached:         24130472 kB   ← страницы файлов + tmpfs + shmem
# Buffers:          245112 kB   ← кэш метаданных блочного устройства
# Dirty:             18432 kB   ← модифицированные, ещё не записанные
# Writeback:          1024 kB   ← в процессе записи на диск
# Active(file):    8124456 kB   ← страницы файлов, недавно использованные
# Inactive(file): 15998144 kB   ← кандидаты на вытеснение

Разница Cached vs Buffers исторически: Buffers — кэш блочного устройства напрямую (метаданные ФС, суперблоки, журнал), Cached — кэш на уровне файлов.

Какие страницы файла в кэше

# vmtouch — утилита для inspect/control page cache конкретного файла
vmtouch /var/log/syslog
#            Files: 1
#      Directories: 0
#   Resident Pages: 2143/2143  8M/8M  100%
#          Elapsed: 0.00067 seconds

# Загрузить файл в кэш принудительно
vmtouch -t /var/log/syslog

# Выкинуть файл из кэша
vmtouch -e /var/log/syslog

Активность writeback и I/O

# vmstat: si/so — swap in/out, bi/bo — block in/out (KiB/sec)
vmstat 1 5

# iostat: per-device IOPS, throughput, await
iostat -xz 1

# Какие именно процессы пишут на диск
sudo iotop -oP

Эффективность кэша через perf

# Счётчик major page faults (страница тянется с диска)
perf stat -e major-faults,minor-faults ./prog

Связанные темы

Источники