Как работает автоматическое закрытие файла в контекстном менеджере Python на уровне языка и ОС?
Что проверяют
Понимаете ли вы, что with это синтаксис поверх протокола __enter__/__exit__, и знаете ли, что происходит с файловым дескриптором на уровне ядра. Следом часто спрашивают, гарантирует ли закрытие файла, что данные уже на диске.
Как отвечать
Разложите ответ на три уровня.
Язык. with open(path) as f: сначала вычисляет open(path), и файл открывается уже здесь. Затем вызывается __enter__, у файлового объекта он просто возвращает self. При выходе из блока, обычном, по return или по исключению, вызывается __exit__(exc_type, exc, tb). Это эквивалент try/finally. Если __exit__ вернёт истинное значение, исключение будет подавлено; файловый объект возвращает None, поэтому ошибка летит дальше.
Объект. open() в текстовом режиме собирает стек: TextIOWrapper поверх BufferedWriter поверх FileIO. Метод close() сначала вызывает flush() и сбрасывает буфер Python в ядро, затем FileIO закрывает дескриптор.
ОС. Дескриптор это номер в таблице открытых файлов процесса. Системный вызов close(2) освобождает номер и уменьшает счётчик ссылок на открытый файл в ядре. Число дескрипторов ограничено (ulimit -n, в Linux мягкий лимит часто 1024), поэтому утечка в долгоживущем сервисе заканчивается ошибкой Too many open files.
Хорошо добавить:
- без
with: CPython закроет файл, когда счётчик ссылок объекта упадёт до нуля, и выдастResourceWarning, который по умолчанию скрыт. В PyPy и при циклических ссылках момент закрытия не определён; contextlib:@contextmanagerдля своих менеджеров иExitStackдля переменного числа ресурсов.
Где спотыкаются
Говорят, что файл открывает __enter__. Открывает open(), и если он упал, __exit__ не вызывается вообще: закрывать нечего.
Путают закрытие с записью на диск. close() отдаёт данные в страничный кеш ядра, а на носитель их гарантированно отправляет только os.fsync(f.fileno()). Для конфигов и журналов это разница между целым файлом и пустым после сбоя питания.
В своём менеджере на @contextmanager забывают обернуть yield в try/finally. Тогда при исключении внутри блока код очистки после yield не выполнится, и ресурс останется открытым.