Перейти к содержимому

Лекция 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
@contextmanager
def 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:

@contextmanager
def 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 time
from contextlib import contextmanager
@contextmanager
def 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 closing
from 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 suppress
import 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
@contextmanager
def 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 os
from contextlib import contextmanager
@contextmanager
def 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) либо новый экземпляр.

Вопросы для самопроверки

  1. Чем with лучше связки try/finally? Что общего у этих конструкций?
  2. Какие два метода составляют протокол контекстного менеджера и за что отвечает каждый?
  3. Что попадает в переменную после as в выражении with manager as x?
  4. Какие три аргумента получает __exit__ и какими они будут при нормальном завершении блока?
  5. Как заставить __exit__ подавить исключение? Почему этим механизмом нужно пользоваться осторожно?
  6. Зачем yield внутри @contextmanager-функции почти всегда оборачивают в try/finally?
  7. В каких случаях вы выберете ExitStack вместо перечисления менеджеров через запятую?
  8. Чем closing отличается от suppress? Приведите пример для каждого.
  9. Что значит «реентерабельный» контекстный менеджер? Почему генератор из @contextmanager не реентерабелен?
  10. В каком порядке выходят менеджеры, перечисленные в одном with через запятую?