Лекция 7. Контекстные менеджеры и управление ресурсами
1. Зачем нужно управление ресурсами
Программа почти всегда работает не в вакууме: она открывает файлы, устанавливает сетевые соединения, берёт блокировки, выделяет память в драйверах, захватывает дескрипторы операционной системы. Все эти сущности объединяет одно свойство — они ограничены и должны быть освобождены. Файловый дескриптор нужно закрыть, соединение с базой данных — вернуть в пул, блокировку — отпустить, временный файл — удалить.
Если ресурс захвачен, но не освобождён, возникает утечка ресурсов. Утечки коварны тем, что долго не проявляются: пока ресурсов хватает, программа работает. Но рано или поздно операционная система откажет в открытии нового файла («Too many open files»), пул соединений опустеет, а взятая и не отпущенная блокировка приведёт к взаимоблокировке (deadlock).
Рассмотрим наивный код работы с файлом:
f = open("data.txt", "r")data = f.read()process(data) # а если здесь возникнет исключение?f.close()Если process(data) выбросит исключение, строка f.close() никогда не выполнится. Дескриптор останется открытым до тех пор, пока сборщик мусора не доберётся до объекта (а момент этот недетерминирован). В долгоживущем процессе — веб-сервере, демоне — такие утечки накапливаются.
Первое «правильное» решение — try/finally:
f = open("data.txt", "r")try: data = f.read() process(data)finally: f.close() # выполнится всегда: и при успехе, и при исключенииБлок finally выполняется при любом исходе, поэтому ресурс гарантированно освобождается. Это работает, но многословно: на каждый ресурс приходится отдельный try/finally, а при нескольких ресурсах конструкции вкладываются друг в друга и код становится нечитаемым. Именно эту проблему решают контекстные менеджеры и оператор with.
2. Оператор with и протокол контекстного менеджера
Оператор with — это синтаксический сахар над try/finally, инкапсулирующий логику захвата и освобождения ресурса в самом объекте. Тот же пример с файлом:
with open("data.txt", "r") as f: data = f.read() process(data)# файл закрыт здесь — автоматически, при любом исходеОбъект, который можно использовать в with, называется контекстным менеджером. Чтобы стать контекстным менеджером, объект должен реализовать два специальных метода — это и есть протокол контекстного менеджера:
__enter__(self)— вызывается при входе в блокwith. Его возвращаемое значение присваивается переменной послеas.__exit__(self, exc_type, exc_val, exc_tb)— вызывается при выходе из блока, всегда: и при нормальном завершении, и при исключении.
Развёрнём with в эквивалентный код, чтобы понять, что именно делает интерпретатор:
manager = open("data.txt", "r")f = manager.__enter__()try: data = f.read() process(data)finally: manager.__exit__(*sys.exc_info()) # упрощённоКлючевая гарантия: __exit__ будет вызван независимо от того, как выполнился тело блока. Это и обеспечивает надёжное освобождение ресурса.
2.1. Класс-менеджер своими руками
Напишем контекстный менеджер для подключения к базе данных как полноценный класс:
class DatabaseConnection: def __init__(self, host: str): self.host = host self.connection = None
def __enter__(self): print(f"Подключение к {self.host}") self.connection = f"<соединение с {self.host}>" return self.connection # это значение попадёт в as
def __exit__(self, exc_type, exc_val, exc_tb): print("Закрытие подключения") self.connection = None return False # исключения не подавляются
with DatabaseConnection("db.example.com") as conn: print(f"Работаем с {conn}")# Подключение к db.example.com# Работаем с <соединение с db.example.com># Закрытие подключенияОбратите внимание: __enter__ возвращает не сам менеджер, а ресурс (self.connection). Часто возвращают self — тогда после as получаем сам объект-менеджер с его методами. Что именно возвращать — решает автор класса.
3. Обработка исключений в __exit__
Метод __exit__ принимает три аргумента, описывающих исключение, если оно произошло в теле блока:
exc_type— класс исключения (например,ValueError) илиNone;exc_val— сам объект исключения илиNone;exc_tb— объект traceback илиNone.
Если блок завершился нормально, все три аргумента равны None. Если возникло исключение — они содержат информацию о нём.
Возвращаемое значение __exit__ управляет распространением исключения:
- Если
__exit__возвращает истинное значение (True) — исключение подавляется, выполнение продолжается после блокаwith. - Если возвращает ложное значение (
False,Noneили ничего) — исключение пробрасывается дальше, как обычно.
class SuppressErrors: def __enter__(self): return self
def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: print(f"Перехвачено исключение: {exc_type.__name__}: {exc_val}") return True # подавляем — наружу не пробросится return False
with SuppressErrors(): print("До ошибки") raise ValueError("что-то пошло не так") print("После ошибки") # не выполнится
print("Программа продолжает работу")# До ошибки# Перехвачено исключение: ValueError: что-то пошло не так# Программа продолжает работуВажное предупреждение. Возврат True из __exit__ «проглатывает» исключение полностью. Это мощный, но опасный механизм: бездумное подавление маскирует ошибки. Возвращайте True только тогда, когда действительно намерены обработать конкретный тип исключения. По умолчанию (чаще всего) __exit__ должен возвращать False — освобождать ресурс, но не вмешиваться в поток исключений.
Типичная задача __exit__ — корректно освободить ресурс даже при исключении, при этом пробросив само исключение наружу:
class FileWriter: def __init__(self, path: str): self.path = path self.file = None
def __enter__(self): self.file = open(self.path, "w") return self.file
def __exit__(self, exc_type, exc_val, exc_tb): if self.file: self.file.close() # закрываем при любом исходе if exc_type is not None: print(f"Запись прервана из-за {exc_type.__name__}") return False # исключение пробрасываем4. Модуль contextlib
Писать класс с двумя методами ради простого менеджера — избыточно. Стандартный модуль contextlib даёт удобные инструменты.
4.1. Декоратор @contextmanager
@contextmanager превращает функцию-генератор в контекстный менеджер. Код до yield играет роль __enter__, код после yield — роль __exit__. Значение, переданное в yield, попадает в переменную после as.
from contextlib import contextmanager
@contextmanagerdef database_connection(host: str): print(f"Подключение к {host}") conn = f"<соединение с {host}>" try: yield conn # отдаём ресурс в тело with finally: print("Закрытие подключения") # выполнится всегда
with database_connection("db.example.com") as conn: print(f"Работаем с {conn}")Это эквивалентно классу из раздела 2, но короче. Обратите внимание на try/finally вокруг yield: он критически важен. Если в теле with возникнет исключение, оно «вылетит» из точки yield, и без finally код освобождения не выполнится.
Чтобы обработать или подавить исключение в генераторе, оборачивают yield в try/except:
@contextmanagerdef transaction(db): print("BEGIN") try: yield db except Exception as e: print(f"ROLLBACK из-за {e}") raise # пробрасываем (или не пробрасываем — подавляем) else: print("COMMIT") # только если исключения не былоЗдесь видна красивая семантика: else срабатывает при успехе (аналог отсутствия исключения), except — при ошибке. Если убрать raise, исключение будет подавлено — как возврат True из __exit__.
Измерение времени выполнения блока — классический пример:
import timefrom contextlib import contextmanager
@contextmanagerdef timed(label: str): start = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - start print(f"{label}: {elapsed:.4f} с")
with timed("Тяжёлые вычисления"): total = sum(i * i for i in range(1_000_000))4.2. closing — закрытие объектов без протокола
Не все объекты, у которых есть метод close(), поддерживают протокол with. contextlib.closing оборачивает любой такой объект и гарантированно вызовет close() на выходе:
from contextlib import closingfrom urllib.request import urlopen
with closing(urlopen("https://example.com")) as page: html = page.read()# page.close() вызван автоматически4.3. suppress — подавление выбранных исключений
contextlib.suppress позволяет лаконично проигнорировать конкретные типы исключений вместо громоздкого try/except/pass:
from contextlib import suppressimport os
# Вместо:# try:# os.remove("temp.txt")# except FileNotFoundError:# pass
with suppress(FileNotFoundError): os.remove("temp.txt")Если внутри блока возникнет FileNotFoundError, он будет тихо подавлен. Любое другое исключение пройдёт наружу. Подавляйте только те исключения, которые действительно ожидаемы и безопасны.
4.4. ExitStack — динамическое управление множеством ресурсов
ExitStack нужен, когда число ресурсов заранее неизвестно: например, открыть список файлов, имена которых получены во время выполнения. Он поддерживает стек контекстных менеджеров и закрывает их все при выходе (в обратном порядке).
from contextlib import ExitStack
filenames = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack: files = [stack.enter_context(open(name)) for name in filenames] # все файлы открыты; работаем со списком files for f in files: print(f.readline())# при выходе ВСЕ файлы корректно закрыты, даже если один из open упалExitStack гарантирует, что уже открытые ресурсы будут освобождены, даже если открытие очередного завершилось ошибкой. Метод stack.callback(func, *args) позволяет зарегистрировать произвольную функцию очистки, а stack.pop_all() — «отвязать» накопленные менеджеры (полезно, когда нужно передать ответственность за освобождение наружу).
5. Вложенные и множественные менеджеры
Часто нужно несколько ресурсов одновременно. Можно вкладывать with:
with open("input.txt") as src: with open("output.txt", "w") as dst: dst.write(src.read())Но Python позволяет перечислить несколько менеджеров в одном with через запятую — это эквивалентно вложенности, но читается лучше:
with open("input.txt") as src, open("output.txt", "w") as dst: dst.write(src.read())Менеджеры входят слева направо (src.__enter__(), затем dst.__enter__()), а выходят в обратном порядке (dst.__exit__(), затем src.__exit__()) — как стек. Если открытие dst упадёт, src всё равно будет корректно закрыт.
Начиная с Python 3.10 длинный список менеджеров можно заключить в круглые скобки и перенести по строкам:
with ( open("input.txt") as src, open("output.txt", "w") as dst, timed("копирование"),): dst.write(src.read())5.1. Реентерабельность
Реентерабельный контекстный менеджер можно безопасно использовать в with повторно или вложенно с тем же объектом. Не все менеджеры таковы. Например, генератор, созданный @contextmanager, одноразовый: после выхода генератор исчерпан, и повторный with на том же объекте вызовет ошибку.
from contextlib import contextmanager
@contextmanagerdef section(): print("вход") yield print("выход")
ctx = section()with ctx: pass# with ctx: # RuntimeError: generator didn't yield — объект уже исчерпанЕсли менеджер нужен многократно, либо создавайте новый экземпляр на каждый with, либо используйте специально реентерабельные реализации (например, threading.RLock — реентерабельная блокировка, которую один поток может захватить несколько раз вложенно):
import threading
lock = threading.RLock()
with lock: with lock: # тот же поток — повторный захват разрешён print("Защищённая секция")Обычный threading.Lock так бы заблокировался сам на себе (deadlock), а RLock ведёт учёт глубины захвата и реентерабелен.
6. Практические примеры применения
Контекстные менеджеры — это не только файлы. Несколько типичных сценариев:
import threading
lock = threading.Lock()with lock: # захват и гарантированное освобождение блокировки shared_counter += 1# Временная смена рабочего каталогаimport osfrom contextlib import contextmanager
@contextmanagerdef change_dir(path: str): old = os.getcwd() os.chdir(path) try: yield finally: os.chdir(old) # вернёмся, что бы ни случилось# Транзакция базы данных (псевдокод)with connection.cursor() as cursor: # курсор закроется автоматически cursor.execute("INSERT INTO users VALUES (?)", (name,)) connection.commit()Общий принцип: если в коде встречается пара «захватить — освободить», «открыть — закрыть», «начать — завершить», это кандидат на оформление контекстным менеджером. Такой код безопаснее (ресурс не утечёт) и чище (логика освобождения собрана в одном месте, а не размазана по try/finally).
Краткие итоги
- Ресурсы (файлы, соединения, блокировки) ограничены и должны освобождаться; забытое освобождение ведёт к утечкам и взаимоблокировкам.
try/finallyгарантирует освобождение, но многословен;with— это его удобная инкапсуляция.- Протокол контекстного менеджера — методы
__enter__(вход, возвращает ресурс дляas) и__exit__(выход, вызывается всегда). __exit__получает информацию об исключении (exc_type,exc_val,exc_tb); возвратTrueподавляет исключение,False/None— пробрасывает. По умолчанию возвращайтеFalse.contextlibдаёт инструменты:@contextmanager(менеджер из генератора черезyield+try/finally),closing(для объектов сclose()),suppress(тихое подавление исключений),ExitStack(динамическое управление множеством ресурсов).- Несколько менеджеров перечисляются в одном
withчерез запятую; вход — слева направо, выход — в обратном порядке. - Генераторные менеджеры одноразовы; для повторного/вложенного использования нужны реентерабельные реализации (
RLock) либо новый экземпляр.
Вопросы для самопроверки
- Чем
withлучше связкиtry/finally? Что общего у этих конструкций? - Какие два метода составляют протокол контекстного менеджера и за что отвечает каждый?
- Что попадает в переменную после
asв выраженииwith manager as x? - Какие три аргумента получает
__exit__и какими они будут при нормальном завершении блока? - Как заставить
__exit__подавить исключение? Почему этим механизмом нужно пользоваться осторожно? - Зачем
yieldвнутри@contextmanager-функции почти всегда оборачивают вtry/finally? - В каких случаях вы выберете
ExitStackвместо перечисления менеджеров через запятую? - Чем
closingотличается отsuppress? Приведите пример для каждого. - Что значит «реентерабельный» контекстный менеджер? Почему генератор из
@contextmanagerне реентерабелен? - В каком порядке выходят менеджеры, перечисленные в одном
withчерез запятую?