Python Context Managers: 3 Trường Hợp Bạn Thực Sự Cần
Python context managers: 3 trường hợp bạn thực sự cần. Làm chủ câu lệnh with, contextlib và tùy chỉnh context managers để quản lý tài nguyên tốt hơn.
{</* resource-info */>}
Hầu hết các bài giới thiệu về context managers trong Python chỉ đưa ra một ví dụ duy nhất — with open("file.txt") as f: — và dừng lại ở đó. Điều này đủ để bạn sử dụng chúng, nhưng chưa đủ để bạn biết khi nào nên viết một cái.
Sau vài năm viết các dịch vụ bằng Python, tôi nhận thấy mình thường xuyên sử dụng context managers trong ba tình huống cụ thể. Mỗi tình huống giải quyết một vấn đề mà try/finally về mặt kỹ thuật có thể xử lý nhưng thường dễ mắc lỗi trong thực tế.
Trường hợp 1: Kết hợp Acquire (Lấy) và Release (Giải phóng) #
Đây là trường hợp kinh điển nhất. Bạn có một thứ gì đó bắt buộc phải được giải phóng — một cái khóa (lock), một kết nối cơ sở dữ liệu, một file tạm, một socket mạng — và bạn muốn đảm bảo việc giải phóng diễn ra ngay cả khi mã nguồn ở giữa xảy ra lỗi.
from contextlib import contextmanager
import threading
_lock = threading.Lock()
@contextmanager
def critical_section():
_lock.acquire()
try:
yield
finally:
_lock.release()
with critical_section():
do_dangerous_thing()
Tại sao không dùng trực tiếp try/finally? Bạn hoàn toàn có thể — và thực tế context manager cũng sẽ triển khai như vậy. Lợi ích ở đây là try/finally nằm trong một hàm hỗ trợ (helper), không phải ở nơi gọi. Mọi nơi gọi đều được hưởng lợi miễn phí, và không ai có thể quên viết khối finally.
Khi tôi thấy 5 bản sao của try: thing.acquire(); ...; finally: thing.release() trong một dự án, tôi biết đó là lúc một context manager cần được tách ra.
Trường hợp 2: Thay đổi trạng thái toàn cục (global-ish) tạm thời #
Trường hợp này ít được nhắc đến hơn, nhưng lại là nơi context managers thực sự tỏa sáng. Bạn muốn thay đổi một thiết lập nào đó trong suốt thời gian chạy của một khối mã, và bạn muốn nó quay lại trạng thái ban đầu dù khối mã đó kết thúc như thế nào.
import os
from contextlib import contextmanager
@contextmanager
def env(**overrides):
"""Thiết lập tạm thời biến môi trường, khôi phục giá trị cũ khi thoát."""
saved = {k: os.environ.get(k) for k in overrides}
os.environ.update({k: str(v) for k, v in overrides.items()})
try:
yield
finally:
for k, prev in saved.items():
if prev is None:
os.environ.pop(k, None)
else:
os.environ[k] = prev
with env(DEBUG="1", REGION="us-east-1"):
run_test_suite()
# Môi trường sẽ quay lại trạng thái ban đầu tại đây.
Mô hình tương tự cũng áp dụng cho sys.path, các cấp độ logging, context của decimal, các thuộc tính được mock — bất cứ thứ gì tuân theo quy trình “lưu, thay đổi, khôi phục”. Các bài kiểm tra (tests) đặc biệt được hưởng lợi từ điều này; nếu không, các fixture có thể bị rò rỉ nếu có lỗi xảy ra giữa chừng.
Điểm tinh tế ở đây là khôi phục None một cách chính xác. Một lỗi phổ biến là thực hiện os.environ[k] = saved[k] mà không kiểm tra — điều này sẽ ghi chuỗi ký tự "None" vào biến nếu nó không tồn tại trước đó. Luôn khôi phục trạng thái “vắng mặt” bằng pop, không phải bằng một chuỗi ký tự.
Trường hợp 3: Bỏ qua các ngoại lệ bạn thực sự muốn phớt lờ #
Đôi khi bạn thực sự muốn “nuốt” một lớp ngoại lệ cụ thể và tiếp tục. Python cung cấp contextlib.suppress cho việc này:
from contextlib import suppress
with suppress(FileNotFoundError):
os.unlink("maybe-stale.lock")
Cách này rõ ràng hơn nhiều so với việc dùng try/except: pass, bởi vì phạm vi giới hạn buộc bạn phải cụ thể. Bạn không thể vô tình bỏ qua mọi thứ — bạn phải đặt tên lớp ngoại lệ. Và bạn không thể vô tình bỏ qua mã nguồn bên dưới phần dọn dẹp; phạm vi của khối with chính xác là những gì bạn đã viết.
Tôi thấy điều này hữu ích cho việc dọn dẹp trong các hàm hủy (destructors) và các trình xử lý atexit, nơi bạn thực sự không thể để việc dọn dẹp gây ra thêm lỗi.
Khi nào không nên viết một cái #
Context managers không hề miễn phí. Mỗi câu lệnh with giới thiệu một lượng nhỏ cơ chế vận hành, và việc xếp chồng chúng quá nhiều sẽ ảnh hưởng đến khả năng đọc mã nguồn. Tôi tránh dùng chúng khi:
- Phần “acquire” thực sự không cần một phần “release” tương ứng — chỉ cần gọi hàm là đủ.
- Việc dọn dẹp chỉ là nỗ lực tốt nhất (best-effort) và phạm vi đủ nhỏ để
try/finallyđọc dễ hiểu hơn. - Thứ đang được quản lý đã được quản lý bởi một thứ khác (ví dụ: đừng bọc một
Sessiontừ một framework vốn đã tự quản lý vòng đời của nó).
Bài kiểm tra tôi thường dùng: “Nếu tôi bỏ qua phần dọn dẹp, liệu người tiếp theo có vô tình làm rò rỉ tài nguyên không?” Nếu có, hãy viết context manager. Nếu không, một hàm thông thường là đủ.
Lưu ý về Async #
Trong mã nguồn async, hãy sử dụng @asynccontextmanager và async with. Hình thái là tương đương; điều duy nhất cần nhớ là bạn có thể dùng await bên trong thân hàm, điều này làm cho mô hình này thậm chí còn hữu ích hơn cho những việc như “lấy một kết nối từ pool, chạy truy vấn, rồi trả lại”.
from contextlib import asynccontextmanager
@asynccontextmanager
async def borrowed(pool):
conn = await pool.acquire()
try:
yield conn
finally:
await pool.release(conn)
Chỉ vậy thôi. Ba mẫu này bao phủ khoảng 90% các context managers tôi từng viết. 10% còn lại là những trường hợp đặc biệt và bạn sẽ nhận ra chúng khi gặp phải.
Bài viết liên quan #
- Đánh giá Scrapling: Một cách tiếp cận nhanh hơn, ẩn danh hơn để Scraping với Python — Scraping Python nâng cao
- Đọc EXPLAIN ANALYZE trong Postgres mà không bị lạc lối — Tối ưu hóa hiệu suất cơ sở dữ liệu
- Claude Code miễn phí: Sử dụng Claude Code CLI miễn phí với bất kỳ nhà cung cấp AI nào — Lập trình hỗ trợ bởi AI