Атомарная запись файлов в Python: защита от повреждения
Обрыв процесса во время open(w) оставляет обрезанный файл, а полный диск — пустой. Разбираем, как записывать так, чтобы читатель всегда видел либо старую, либо целую новую версию.

Сервис пишет конфиг через open(path, 'w'), посреди записи процесс получает SIGKILL от OOM-киллера — и на диске остаётся файл на 800 байт вместо 4 килобайт. При следующем старте парсер падает на неполном JSON, сервис не поднимается. Классика: сама запись «сработала», но между усечением старого содержимого и записью нового есть окно, в котором файл битый. Разбираем, как это окно закрыть на Linux и почему на Windows всё иначе. Версии в тексте — CPython 3.11–3.12.
Почему обычная запись небезопасна
Вызов open(path, 'w') делает две вещи по порядку: обрезает существующий файл до нуля, затем пишет новые данные. Между этими шагами файл уже пуст. Если процесс умрёт здесь — данные потеряны безвозвратно, старой версии больше нет.
Второй источник проблем — буферизация. write() кладёт данные в буфер Python, оттуда они попадают в страничный кеш ядра, и только fsync сбрасывает их на физический носитель. После with open(...) файл закрыт и данные в кеше ядра, но при внезапном отключении питания страничный кеш теряется. Приложение думало, что записало, а на диске — пусто или частично.
Правильная запись — это не «записать в файл», а «сделать так, чтобы читатель видел либо целиком старую версию, либо целиком новую, и никогда — половину».
Приём: запись во временный файл + rename
Основа безопасной записи на POSIX — атомарность rename в пределах одной файловой системы1. Переименование существующего пути атомарно: любой процесс, открывающий файл, видит либо старый inode целиком, либо новый целиком. Промежуточного состояния нет.
Схема из четырёх шагов:
- создать временный файл в той же директории, что и целевой;
- записать в него все данные;
- вызвать
fsyncна дескрипторе временного файла — чтобы данные гарантированно попали на диск до переименования; - вызвать
os.replace(tmp, target)— атомарная замена.
Временный файл именно в той же директории — потому что rename между разными файловыми системами не атомарен: там происходит копирование и удаление, а это уже не защищает ни от чего. tempfile.gettempdir() часто указывает на другой раздел (/tmp может быть tmpfs), поэтому NamedTemporaryFile с дефолтным путём здесь не годится.
import os
import tempfile
def atomic_write_bytes(target_path: str, data: bytes) -> None:
# Директория цели: временный файл создаём рядом,
# иначе os.replace может оказаться межразделным и потеряет атомарность
directory = os.path.dirname(os.path.abspath(target_path))
# delete=False: файл нужен после закрытия, удалим его сами при ошибке
fd, tmp_path = tempfile.mkstemp(dir=directory, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as tmp_file:
tmp_file.write(data)
tmp_file.flush() # из буфера Python в кеш ядра
os.fsync(tmp_file.fileno()) # из кеша ядра на диск
os.replace(tmp_path, target_path) # атомарная замена
except BaseException:
# При любой ошибке чистим временный файл, целевой не тронут
os.unlink(tmp_path)
raise
Ловля BaseException, а не Exception — намеренно: чтобы временный файл убрался и при KeyboardInterrupt, и при SystemExit. После os.replace временного файла уже нет, повторный unlink не случится, потому что исключение туда не долетит.
Проверка, что сработало
Убить процесс в отладчике на строке между flush и os.replace и убедиться, что целевой файл остался в старой версии, а на диске лежит .tmp-огрызок. После штатного завершения целевой файл содержит новые данные целиком, временного нет.
Что часто забывают: fsync на директории
Сама запись данных защищена fsync на файле. Но rename меняет запись в директории, а метаданные директории тоже буферизуются. При отключении питания сразу после os.replace возможна ситуация: данные во временном файле на диске есть, а обновлённая запись директории — нет, и после перезагрузки виден старый путь или вовсе битая ссылка.
Для строгой гарантии директорию тоже синхронизируют:
def fsync_directory(directory: str) -> None:
# Открываем директорию только на чтение и синхронизируем её метаданные
dir_fd = os.open(directory, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
Вызывается после os.replace. На большинстве прикладных задач (запись конфигов, кеша, состояния) этим шагом пренебрегают — он важен для баз данных и систем, переживающих внезапное отключение питания. Но знать про него нужно: без него «атомарность» неполна на уровне отказа железа.
Windows: почему код выше не переносим напрямую
Исторически os.rename на Windows падал, если целевой файл существует. Поэтому в коде — os.replace, а не os.rename: os.replace перезаписывает цель и на POSIX, и на Windows. Но и он не атомарен так же жёстко, как на POSIX: если целевой файл открыт другим процессом на чтение, замена может завершиться ошибкой PermissionError. На Linux открытый на чтение файл переименованию не мешает — читатель продолжает видеть старый inode.
Ещё различие: os.fsync на дескрипторе директории на Windows не работает так, как на POSIX. Приведённая функция fsync_directory — POSIX-специфична.
Сравнение подходов
| Подход | Защита от битого файла | Защита при отключении питания | Переносимость |
|---|---|---|---|
open(path, 'w') | нет | нет | полная |
tmp + os.replace | да | частично (без fsync) | полная |
tmp + fsync файла + os.replace | да | данные — да, запись директории — нет | полная |
| + fsync директории | да | да | POSIX |
Когда что выбирать
- Логи, временные выгрузки, файлы, потеря которых не критична — обычный
open('w'). Атомарность тут только замедляет. - Конфиги, состояние сервиса, файлы, которые читает другой процесс — tmp +
os.replaceсfsyncфайла. Это разумный дефолт для прикладного кода. - Данные, которые обязаны пережить отключение питания (журналы транзакций, метаданные хранилища) — добавляйте fsync директории и тестируйте на целевой ФС; часто здесь стоит взять готовую БД, а не писать своё.
Что ломается на практике
- Временный файл в /tmp. Самая частая ошибка.
os.replaceмежду разделами — не атомарная операция, а копирование. Создавайте временный файл рядом с целевым. - Права и владелец.
mkstempсоздаёт файл с правами0600и от имени текущего процесса. Послеreplaceу нового файла будут эти права, а не права старого. Если исходный файл имел0644или другого владельца — восстанавливайте их черезos.chmod/os.chownдо переименования. - Забытый flush перед fsync.
os.fsyncработает с дескриптором ядра и не знает про буфер Python. Безflush()часть данных останется в буфере интерпретатора и на диск не попадёт. - Symlink как цель.
os.replaceзаменит саму символическую ссылку, а не файл, на который она указывает. Если вы ждали, что перезапишется целевой файл, — получите сюрприз.
Что дальше
Оберните код в утилиту с вариантом для текста (encoding, перевод строк) и добавьте сохранение прав исходного файла. Если пишете из нескольких процессов одновременно — os.replace защищает от битого содержимого, но не от гонки «кто последний записал»; тут нужна блокировка (fcntl.flock на POSIX) — это отдельная тема. Прежде чем полагаться на fsync директории в проде, проверьте поведение на вашей файловой системе: гарантии ext4, XFS и сетевых ФС различаются.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: os.replace, os.fsync — Python docs, модуль os, tempfile.mkstemp — Python docs, модуль tempfile
Частые вопросы
А если у меня Python 3.8, а не 3.11 — код будет работать?
os.replace действительно атомарен, или это упрощение?
Нужен ли fsync, если я не боюсь отключения питания?
Почему у нового файла сбросились права доступа?
Не сработало на Windows — os.replace бросает PermissionError. Что делать?
Можно ли писать так в файл на сетевой ФС (NFS)?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.