Buffering — три уровня кешей между вашим write и физическим диском
Между моментом, когда вы пишете print("hello") в Python, и моментом, когда буква h физически намагнитится на диске или попадёт в NAND-ячейку SSD, проходит три отдельных буфера в разных слоях системы. Понять, где данные сейчас застряли — ключевой навык для всего: от отладки «почему лог не пишется» до настройки production database.
В этом уроке разберём все три уровня: stdio-буфер в библиотеке (живёт в user-space), page cache в kernel (живёт в RAM), и что делает O_DIRECT — флаг для тех, кому buffering мешает (Postgres, ClickHouse, Kafka).
Зачем вообще нужен buffering
Если бы каждый print("a") приводил к одному syscall + одной операции с диском, ваша программа была бы примерно в 1000 раз медленнее, чем сейчас. Buffering решает эту проблему: накапливаем данные в памяти, потом разом пишем большой блок.
Конкретные цифры (примерные):
- Один syscall: ~100-500 нс (только переход в kernel mode и обратно).
- Один write в kernel page cache: ~1-10 мкс.
- Один write на NVMe SSD: ~50-200 мкс.
- Один write на HDD (random): ~5-15 мс.
Если приложение делает миллион write(fd, "a", 1) — это миллион syscall, ~миллисекунда CPU только на переходы. С буфером в 4KB это превращается в ~244 syscall, что в 4000 раз меньше.
Уровень 1: stdio-буфер (user-space)
Когда вы используете fopen/fread/fwrite в C (или open/print/file.write в Python), вы говорите со стандартной библиотекой, которая держит свой буфер. Это user-space память, kernel про неё не знает.
Поведение stdio-буфера зависит от того, куда направлен поток:
- Block buffered — буфер копится до полного заполнения (обычно 4KB или 8KB). Используется для файлов на диске.
- Line buffered — буфер сбрасывается на каждом
\n. Используется для терминала (stdout, когда он подключен к TTY). - Unbuffered — каждый write идёт сразу в syscall. Используется для stderr.
Это объясняет известный gotcha:
# Запустить и НЕ нажимать Enter:
python3 -c "
import time, sys
print('starting') # При TTY -- сразу выведется (line buffered)
time.sleep(2)
print('done')
"
# А теперь то же, но перенаправить в файл:
python3 -c "
import time, sys
print('starting')
time.sleep(2)
print('done')
" > /tmp/out.txt
# В файле обе строки появятся через 2 секунды, не сразу.
# Потому что когда stdout = файл, stdio переключается на block buffered.
Это часто кусает в Docker/Kubernetes: вы видите docker logs container — а там тишина, хотя приложение «работает». Просто буфер не заполнился и не дошёл до сброса. Решения:
# 1. Запустить Python с -u (unbuffered):
python3 -u app.py
# 2. Установить переменную окружения:
PYTHONUNBUFFERED=1 python3 app.py
# 3. В коде: sys.stdout.reconfigure(line_buffering=True) или print(..., flush=True)
В C аналогично:
setvbuf(stdout, NULL, _IOLBF, 0); // line buffered
setvbuf(stdout, NULL, _IONBF, 0); // unbuffered
fflush(stdout); // принудительный flush
Стандартный совет: для production-сервисов всегда стартуйте Python с -u или ставьте PYTHONUNBUFFERED=1. Иначе при крэше последние логи (которые часто содержат причину падения) останутся в stdio-буфере и пропадут.
Уровень 2: Kernel page cache
Когда stdio решает «пора» — зовёт write(fd, buf, N). Kernel принимает данные и кладёт их в page cache — свой собственный кеш в свободной RAM.
Page cache — это центральный механизм производительности I/O в Linux. Несколько важных свойств:
- Хранит и read, и write. Когда вы читаете файл, kernel кладёт его страницы в page cache. Следующий read из этого файла — из RAM, без диска.
- Занимает всю свободную память. Linux не «жадничает»: если RAM пустует, она забивается кешем. Это нормально.
- Сбрасывается асинхронно. Грязные (dirty) страницы записываются на диск kernel-тредом (
kworker,flush) каждые 5-30 секунд или при memory pressure. - Освобождается под давлением. Если приложению нужна память, kernel выкидывает чистые (не-dirty) страницы из кеша.
# Посмотреть текущее состояние page cache:
free -h
# total used free shared buff/cache available
# Mem: 15Gi 3Gi 2Gi 100Mi 10Gi 12Gi
# 'buff/cache' = ~10Gi -- это и есть page cache. Эта память 'занята', но реально
# доступна приложениям, kernel её отдаст по запросу.
# Сколько dirty pages ждут записи:
cat /proc/meminfo | grep -E 'Dirty|Writeback'
# Dirty: 12 kB
# Writeback: 0 kB
Page cache работает по умолчанию и обычно мегаполезен. Но есть два известных косяка:
Косяк 1: данные «потерялись» при крэше. write вернул успех — но это значит «положил в page cache». Если процесс упал или сервер потерял питание ДО writeback’а — данные пропали. Решение — fsync (следующий урок).
Косяк 2: cache pollution. Большой sequential-чтение (например, cat huge_file > /dev/null) забивает page cache и вытесняет «горячие» данные базы. Решения: posix_fadvise(POSIX_FADV_DONTNEED), O_DIRECT (см. ниже), или nocache утилита.
# Принудительно сбросить page cache (требует root):
# 1 -- сбросить page cache
# 2 -- сбросить dentries и inodes
# 3 -- всё
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches
# Это диагностический тулз, не используйте в production -- кеш мгновенно заполнится заново.
Уровень 3: Disk buffer / disk cache
Когда kernel наконец зовёт write_io к драйверу диска — данные могут пожить ещё в встроенном кеше диска (SSD/HDD имеют свой DRAM, обычно 256MB-2GB). Только когда контроллер диска решит «пора» — байты ложатся на NAND или на пластину.
Это «третий уровень», на который мы как пользователи влияем меньше всего. Кеш диска защищён конденсатором (на enterprise SSD) или нет (на consumer). Если без конденсатора — при power loss данные в кеше могут пропасть, даже если kernel был уверен, что fsync завершился. Это причина существования таких флагов как FUA (Force Unit Access) для гарантии записи мимо disk cache.
Демонстрация: одно print — один syscall? Считаем strace
# Сколько write() будет, если просто вывести 10 строк?
strace -c -e trace=write python3 -c "
for i in range(10):
print(f'line {i}')
" 2>&1 | tail -5
# % time seconds usecs/call calls errors syscall
# ------ ----------- ----------- --------- --------- ----------------
# 100.00 0.000050 5 10 write
Вывод подключён к TTY, поэтому line-buffered — каждая строка с \n идёт отдельным write. 10 print = 10 write.
А теперь редиректим в файл:
strace -c -e trace=write python3 -c "
for i in range(10):
print(f'line {i}')
" > /tmp/out.txt 2>&1
# write вызовов будет 1-2 -- весь вывод собрался в буфер и ушёл одним syscall.
Это и есть buffering в действии. Вы пишете 10 раз — kernel получает 1 раз.
O_DIRECT: bypass page cache
Иногда buffering мешает. Конкретные сценарии:
- База данных. Postgres, Oracle, MySQL ведут СВОЙ buffer pool. Они точно знают, какие страницы горячие, какие нет. Linux page cache в этом случае избыточен — удвоение памяти и непредсказуемая политика вытеснения.
- Большие sequential дампы. Резервное копирование 1TB файла загадит page cache на всю RAM и выкинет оттуда полезные данные.
- Real-time системы. Нужны предсказуемые latency. Page cache даёт «иногда быстро, иногда медленно» — база может предпочесть «всегда не очень быстро, но стабильно».
O_DIRECT — флаг для open(), который говорит «писать и читать минуя page cache, прямо в DMA-буфер контроллера диска».
Цена за O_DIRECT:
- Жёсткие требования к выравниванию. Буфер в памяти должен быть выровнен по размеру блока (обычно 4KB). Offset в файле — тоже. Размер чтения — кратен блоку. Если что-то не так — syscall возвращает EINVAL.
- Производительность сама по себе не лучше. Page cache часто помогает — если вы читаете тот же файл дважды, второй раз будет из RAM. С O_DIRECT всегда из диска.
- Возможные ошибки. Не все файловые системы корректно поддерживают O_DIRECT (NFS, btrfs в некоторых режимах).
Когда O_DIRECT действительно выигрывает:
- У вас собственный intelligent buffer pool (Postgres
shared_buffers). - Workload содержит много холодного I/O, не вытесняющего горячий контент.
- Нужны предсказуемые latency.
# Посмотреть, открывает ли Postgres файлы с O_DIRECT:
strace -p $(pgrep -f postgres | head -1) -e openat 2>&1 | head -20
# По умолчанию Postgres использует обычный I/O, но
# wal_sync_method=open_direct заставит писать WAL с O_DIRECT
Когда buffering мешает: реальные примеры
Пример 1: docker logs молчит, потом сразу всё. Приложение в контейнере пишет в stdout, stdout block-buffered (это не TTY!). Логи копятся в стдио-буфере. Через 4KB или после краша вы увидите всё сразу. Решение: PYTHONUNBUFFERED=1 или эквивалент.
Пример 2: tail -f log.txt ничего не показывает. Программа пишет в файл, но fflush не делает. Логи в stdio-буфере, на диске пусто. Решение: line buffering или явный flush.
Пример 3: rsync сожрал всю память. rsync копирует 100GB файлов, page cache заполняется. Если у вас на сервере крутится Postgres — его горячие страницы могут быть вытеснены. Решение: rsync --drop-cache или nocache rsync ....
Пример 4: «у нас тормозит fsync». База честно вызывает fsync, и у вас page cache забит — системе нужно прокачать всё к диску. На медленном HDD это секунды. Решение: тюнить vm.dirty_ratio, выводить логи на отдельный диск, использовать NVMe.
Попробуй сам
# 1. Посчитать write syscalls с буферизацией и без:
strace -c -e trace=write python3 -c "
for i in range(1000):
print('x')
" > /tmp/buffered.txt 2>&1
strace -c -e trace=write python3 -u -c "
for i in range(1000):
print('x')
" > /tmp/unbuffered.txt 2>&1
# Сравните количество write вызовов
# 2. Посмотреть page cache:
free -h
# Запустить heavy read:
cat /var/log/syslog > /dev/null
# Снова free -- buff/cache должен вырасти
free -h
# 3. Время повторного чтения большого файла:
dd if=/dev/zero of=/tmp/big bs=1M count=500
# Первое чтение -- с диска:
time dd if=/tmp/big of=/dev/null bs=1M
# Второе чтение -- из page cache:
time dd if=/tmp/big of=/dev/null bs=1M
# Второе обычно в 10-100 раз быстрее
# 4. То же, но с O_DIRECT (минуя кеш):
time dd if=/tmp/big of=/dev/null bs=1M iflag=direct
# Каждый раз одинаково медленно (нет кеша)
# 5. Посмотреть, сколько dirty pages прямо сейчас:
watch -n 1 'grep -E "Dirty|Writeback" /proc/meminfo'
# Запустите параллельно запись большого файла -- увидите рост Dirty