MEASUREMENT
bench/durability/fsync.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/sre/page-cache-fsync
- How to run it
python3 bench/durability/fsync.py > bench/durability/runs/fsync.txt python3 bench/durability/practice.py > bench/durability/runs/practice.txt
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для урока «Page cache и fsync»
| Файл | Что делает |
|---|---|
fsync.py |
четыре блока: цена одной записи без сброса, с fdatasync и с fsync (с самопроверкой, различим ли разрыв между двумя последними); что остаётся в файле, если писавший процесс убит SIGKILL; сто записей со сбросом после каждой против одного сброса в конце; отдельная цена сброса каталога |
practice.py |
ответы к задачам урока: содержимое файла после убийства автора, то же глазами постороннего процесса, и кратность «сброс против записи» |
Запуск из корня репозитория:
python3 bench/durability/fsync.py > bench/durability/runs/fsync.txt
python3 bench/durability/practice.py > bench/durability/runs/practice.txt
Что в этих числах воспроизводимо, а что нет
Воспроизводимо везде: данные, записанные без fsync, переживают
SIGKILL автора и видны другому процессу; сброс дороже записи на два порядка;
пачка дешевле поштучности в разы.
Не воспроизводимо и будет другим: все абсолютные времена. Они сняты на виртуальном диске контейнера; на NVMe запись со сбросом дешевле, на сетевом томе дороже. Кратности тоже поедут — содержателен их порядок, а не цифра.
Отдельно про fsync против fdatasync. На этой машине разрыв между ними
меньше разброса между кругами, и скрипт печатает difference resolvable on this machine: no. Это не поломка замера, а его результат: заявлять кратность,
которой не видно, нельзя. На машине, где метаданные дороже, строка станет
yes.
Требования к среде
Права на запись во временный каталог — и больше ничего. Скрипт работает в
/tmp/de_durability и /tmp/de_durability_practice и не трогает ничего за их
пределами.
Числа сняты на CPython 3.11.15, Linux 6.18.44 (x86-64).
Script
200 lines"""Что стоит между `write` и диском: page cache, `fsync` и цена долговечности.
ЗАЧЕМ ЭТОТ ФАЙЛ. «Записали в файл» и «данные не потеряются» — разные
утверждения, и разница между ними измеряется на одной машине. Здесь показано
всё, из чего она состоит: цена записи без сброса и со сбросом, разница между
`fsync` и `fdatasync`, что переживает смерть процесса, и почему запись пачкой
дешевле записи по одной.
ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ, А ЧТО НАБЛЮДАЕТСЯ. Времена — измеряются и от машины
зависят сильно: на этом диске числа одни, на вашем другие. Наблюдение о том,
что данные переживают убийство процесса, от машины не зависит вовсе: это
свойство того, кому принадлежит кеш.
ПОЧЕМУ ПОДПИСИ ПО-АНГЛИЙСКИ. Урок существует в двух языках и цитирует запись
прогона дословно обеими версиями.
ЗАПУСК: python3 bench/durability/fsync.py
Вывод: runs/fsync.txt
"""
import os
import statistics
import subprocess
import sys
import tempfile
import time
RECORD = b"x" * 4096
ROUNDS = 200
WORK = os.path.join(tempfile.gettempdir(), "de_durability")
def show(title: str) -> None:
print()
print(title)
print("-" * len(title))
def row(label: str, value: object) -> None:
print(f" {label:<44} {value}")
def timed_writes(path: str, mode: str, rounds: int = ROUNDS) -> float:
"""Среднее время одной записи в миллисекундах при заданной строгости."""
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
samples = []
try:
for _ in range(rounds):
started = time.perf_counter()
os.write(fd, RECORD)
if mode == "fsync":
os.fsync(fd)
elif mode == "fdatasync":
os.fdatasync(fd)
samples.append((time.perf_counter() - started) * 1000)
finally:
os.close(fd)
return statistics.median(samples)
# ------------------------------------------------------------------ 1
def block1() -> None:
show("1. THE PRICE OF DURABILITY, PER WRITE")
os.makedirs(WORK, exist_ok=True)
path = os.path.join(WORK, "records.bin")
# Три режима меряются ЧЕРЕДУЯСЬ и по пять кругов: иначе первый режим
# получает холодный файл, а последний — прогретый, и разница между
# fsync и fdatasync тонет в этом перекосе.
rounds = {"none": [], "fdatasync": [], "fsync": []}
for _ in range(5):
for mode in rounds:
rounds[mode].append(timed_writes(path, mode))
plain = statistics.median(rounds["none"])
fdata = statistics.median(rounds["fdatasync"])
full = statistics.median(rounds["fsync"])
row("write only (4 KiB), median", f"{plain * 1000:.1f} us")
row("write + fdatasync, median", f"{fdata * 1000:.1f} us")
row("write + fsync, median", f"{full * 1000:.1f} us")
row("fsync over plain write", f"{full / plain:.0f}x")
# САМОПРОВЕРКА. Разницу между fsync и fdatasync объявлять можно только
# если она больше разброса между кругами. На этой машине она обычно
# меньше — и тогда честный ответ «не различимы здесь», а не число.
spread = max(
max(rounds["fsync"]) - min(rounds["fsync"]),
max(rounds["fdatasync"]) - min(rounds["fdatasync"]),
)
gap = abs(full - fdata)
row("gap between fsync and fdatasync", f"{gap * 1000:.1f} us")
row("run-to-run spread of the same mode", f"{spread * 1000:.1f} us")
row("difference resolvable on this machine", "yes" if gap > spread else "no")
print()
print(" A plain write does not reach the disk. It reaches the page cache,")
print(" which is the kernel's memory, and returns.")
# ------------------------------------------------------------------ 2
def block2() -> None:
show("2. WHOSE MEMORY THE PAGE CACHE IS")
# Потомок пишет БЕЗ сброса и умирает от SIGKILL — самой грубой смертью,
# какая бывает. Если бы кеш принадлежал процессу, данные исчезли бы
# вместе с ним.
path = os.path.join(WORK, "killed.bin")
program = (
"import os, sys, time\n"
"fd = os.open(sys.argv[1], os.O_CREAT | os.O_WRONLY | os.O_TRUNC)\n"
"os.write(fd, b'survived the kill')\n"
"time.sleep(30)\n"
)
proc = subprocess.Popen([sys.executable, "-c", program, path])
time.sleep(0.7)
proc.kill()
proc.wait()
with open(path, "rb") as fh:
content = fh.read()
row("child wrote without fsync, then SIGKILL", f"exit {proc.returncode}")
row("file content after the kill", content.decode())
print()
print(" A process crash loses nothing that reached the page cache: the")
print(" cache belongs to the kernel. What loses it is the machine going")
print(" down before the kernel writes the pages out.")
# ------------------------------------------------------------------ 3
def block3() -> None:
show("3. ONE FSYNC FOR MANY RECORDS, OR ONE EACH")
path = os.path.join(WORK, "batch.bin")
batch = 100
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
started = time.perf_counter()
for _ in range(batch):
os.write(fd, RECORD)
os.fsync(fd)
each = time.perf_counter() - started
os.close(fd)
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC)
started = time.perf_counter()
for _ in range(batch):
os.write(fd, RECORD)
os.fsync(fd)
once = time.perf_counter() - started
os.close(fd)
row(f"{batch} records, fsync after each", f"{each * 1000:.1f} ms")
row(f"{batch} records, one fsync at the end", f"{once * 1000:.1f} ms")
row("ratio", f"{each / once:.0f}x")
print()
print(" Same bytes, same disk, same durability at the end of the batch.")
print(" What differs is how many times the promise was demanded.")
# ------------------------------------------------------------------ 4
def block4() -> None:
show("4. WHAT FSYNC IS FOR: THE FILE, NOT THE DIRECTORY")
# Создание файла — изменение КАТАЛОГА. fsync файла о нём не говорит
# ничего; чтобы имя пережило падение машины, синхронизируют каталог.
path = os.path.join(WORK, "fresh.bin")
if os.path.exists(path):
os.unlink(path)
fd = os.open(path, os.O_CREAT | os.O_WRONLY)
os.write(fd, b"data")
os.fsync(fd)
os.close(fd)
dir_fd = os.open(WORK, os.O_RDONLY)
started = time.perf_counter()
os.fsync(dir_fd)
dir_cost = (time.perf_counter() - started) * 1000
os.close(dir_fd)
row("file synced", "yes")
row("directory sync cost", f"{dir_cost:.2f} ms")
print()
print(" Syncing the file makes its CONTENT durable. The fact that the")
print(" file exists under that name lives in the directory, and that is")
print(" a separate object with a separate sync.")
def main() -> None:
print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
print(f"working directory: {WORK}")
block1()
block2()
block3()
block4()
if __name__ == "__main__":
main()