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_ratio—write(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)¶
Применяется к открытому файловому дескриптору:
| Совет | Эффект |
|---|---|
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:
Те же 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).
С 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¶
Сколько занято кэшем¶
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
Связанные темы¶
- Виртуальная память — Page Fault, как страницы попадают в физический RAM
- mmap и маппинг файлов —
mmapотдаёт page cache в адресное пространство процесса - Реализация malloc и free — анонимные страницы вне page cache
- Основы файловых систем — место VFS и block layer
- io_uring — современный async I/O с registered buffers
Источники¶
- Kernel Documentation: filesystems/vfs.rst
- Kernel Documentation: admin-guide/sysctl/vm.rst
- Linux Kernel Source: mm/filemap.c
- Robert Love, «Linux Kernel Development», 3rd ed. — главы 16, 17
man 2 fsync,man 2 fdatasync,man 2 sync_file_rangeman 2 posix_fadvise,man 2 madvise- LWN: A reworked page-cache subsystem