ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python上下文管理器:从with语句原理到资源管理实战

Python上下文管理器:从with语句原理到资源管理实战 写Python这些年随着对文件操作越来越熟练我越发觉得with open(...) as f已经像呼吸一样自然以至于刚入门时根本没想过它背后藏着什么。直到自己踩过一次因为忘记释放数据库连接、导致连接池被占满的坑才真正意识到with不是什么奇技淫巧而是 Python 上下文管理器最日常也最关键的体现。这篇文章想认真聊聊上下文管理器的原理与实践适合三类人刚学完 Python 基础、只会用with open但想搞懂背后逻辑的读者写业务代码时经常和文件、连接、锁打交道想减少重复代码的人以及对源码感兴趣、想在项目里自定义上下文管理器的人。1. 为什么必须有 with 语句1.1 没有上下文管理器时资源管理有多痛先看一个最基础的操作写文件。f open(output.txt, w) f.write(hello) f.close()这段代码在正常流程下没有任何问题但只要你中间插入业务逻辑比如f open(output.txt, w) data fetch_data() f.write(data) f.close()假设fetch_data()抛了异常f.close()根本执行不到文件句柄就一直挂在进程里。在脚本里影响不大可在长时间运行的服务里每漏一次就是一份资源泄漏积累到一定程度就会出现“too many open files”这类让人头大的问题。你可能会说那用try/finally不就行了f open(output.txt, w) try: data fetch_data() f.write(data) finally: f.close()确实解决了异常路径下的资源释放问题但你会发现一个尴尬的现实每个需要清理的资源都要写一套 try/finally 模板。文件要 close数据库连接要 close锁要 release临时目录要清理。同一个模板逻辑在不同地方反复复制粘贴一旦某处忘了写又是一个隐蔽的坑。1.2 with 把“进入-执行-退出”抽象成了通用协议with语句本质上是在解决一个非常通用的问题有一段代码需要在一个“受保护的上下文”里执行不管这段代码是正常结束还是中途抛异常都必须在离开时执行固定的清理动作。这个“受保护的上下文”可以是一个打开的文件、一条数据库事务、一把线程锁、一次临时环境变量修改甚至是一段时间计时。它们的共同点是进入时要做事退出时要收尾中间跑业务代码。with的高明之处在于它把这个“进入-执行-退出”的模式抽成了一套协议也就是 Python 上下文管理器协议。只要一个对象实现了__enter__和__exit__两个方法它就可以配合with使用。于是你不再需要为每个资源手写清理模板只需要让对象自己知道怎么进入、怎么退出然后把业务代码塞进with块里就行。我经常用一个生活类比来解释这件事with就像一家餐厅的就餐流程。__enter__是服务员领你入座__exit__是你离开时服务员确认你买单、收拾桌子。不管你这顿饭是正常吃完还是吃到一半突发意外要走餐厅都必须保证桌子被收拾干净。至于桌子怎么擦、碗筷怎么消毒那是餐厅自己的事你不需要干预。2. 上下文管理器的协议与底层执行流程2.1__enter__和__exit__到底负责什么任何一个对象只要实现了下面两个方法就能被with使用def __enter__(self): # 进入上下文时的准备工作 # 返回值会赋给 as 后面的变量 return something def __exit__(self, exc_type, exc_val, exc_tb): # 离开上下文时的清理动作 # exc_type: 异常类型没有异常时为 None # exc_val: 异常实例没有异常时为 None # exc_tb: traceback 对象没有异常时为 None # 返回值决定是否抑制异常 return False这里有两个细节很多人容易忽略。第一个__enter__的返回值会绑定到as后面的变量。这个返回值不一定是对象本身可以是任意东西。比如with open(a.txt) as f里as f拿到的是文件对象而这正是__enter__返回的。如果是自定义上下文管理器你完全可以让__enter__返回一个数据库游标、一个请求会话或者一个配置好的资源对象。第二个__exit__的返回值是一个布尔语义决定异常是否被吞掉。如果返回True说明这个上下文管理器“处理”了异常异常不会再向上抛如果返回False或None异常会照常继续传播。这是整个协议里最危险也最强大的地方后面避坑篇会专门展开讲。用表格能看得更清楚场景__enter__返回值__exit__返回 False/None__exit__返回 True正常结束绑定到 as 变量继续执行 with 块后代码继续执行 with 块后代码块内抛异常已绑定异常继续抛出异常被抑制继续执行清理动作本身出错—新异常取代旧异常新异常取代旧异常或按逻辑抑制2.2 with 语句执行时到底经历了什么很多初学者以为with是简单的语法糖其实它的执行流程有非常明确的规则用文字可以描述成下面这几步计算with后面那个表达式得到上下文管理器对象。注意with后面跟的是一个表达式表达式的结果才是上下文管理器。调用这个对象的__enter__方法把返回值绑定到as后面的变量如果没有as这步省略。执行with块内的代码。块内代码正常执行完毕调用__exit__(None, None, None)。块内代码抛了异常调用__exit__(exc_type, exc_val, exc_tb)。根据__exit__返回值决定是否抑制异常。这个流程保证了__exit__一定被调用不管是正常退出还是异常退出。听起来简单但仔细想一下第 5 步才是整个机制的灵魂异常在传播出去之前必须先经过__exit__这一关相当于给资源清理留了一个“无论如何都会执行”的最后机会。2.3 亲手实现一个最小可用的上下文管理器纸上谈兵再多不如写一段代码来得直观。我平时给团队做分享时最常用的例子是自定义文件管理class ManagedFile: def __init__(self, name): self.name name def __enter__(self): self.file open(self.name, w) return self.file def __exit__(self, exc_type, exc_val, exc_tb): if self.file: self.file.close() return False用法with ManagedFile(hello.txt) as f: f.write(hello world)在这个例子里你不用自己 close 文件甚至中间写代码抛异常也无所谓__exit__一定会把文件关闭。注意__exit__的返回值是False这表示如果块里有异常我不打算在这里拦截让它正常抛出去给上层处理。更实用的一个例子是自动提交或回滚事务的游标。我之前在业务代码里封装过类似的class TransactionCursor: def __init__(self, connection): self.connection connection def __enter__(self): self.cursor self.connection.cursor() return self.cursor def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is None: self.connection.commit() else: self.connection.rollback() self.cursor.close() return False这段代码的思路很清晰正常结束就提交事务异常结束就回滚。这样业务代码里只需要写with TransactionCursor(conn) as cursor:不用在每一处都 try/finally 处理 commit 和 rollback数据库事务的完整性也能得到保障。3. 更省力的写法生成器与 contextlib3.1 用 contextmanager 把普通函数变成上下文管理器手写一个类来实现__enter__和__exit__固然标准但有些场景下显得有点重。如果进入和退出的逻辑很简单用生成器配合contextlib.contextmanager装饰器能省不少代码。from contextlib import contextmanager contextmanager def managed_file(name): f open(name, w) try: yield f finally: f.close()用法和类版本完全一样with managed_file(hello.txt) as f: f.write(hello world)这段代码背后的原理是contextmanager装饰器把被装饰的函数包装成一个上下文管理器。当with进入时函数开始执行执行到yield这一行时“暂停”yield产出的值就是as绑定到的对象yield之后的代码会在with块退出时继续执行。关键点在于yield后面的清理动作必须放在finally里。因为如果with块内抛了异常这个异常会在生成器的yield处被重新抛出。如果你在yield后面裸写清理代码清理代码可能被跳过或者会掩盖原始异常。用try/finally包住yield才能保证清理代码无论如何都会运行。哪种场景适合用生成器方式我的判断标准是如果只需要一个简单的进入动作和一个简单的退出动作生成器方式明显更直观如果需要在退出时根据异常类型做复杂分支处理比如事务的 commit/rollback则类方式更清晰毕竟exc_type是显式传给__exit__的而生成器方式里你得自己写try/except才能拿到异常信息。3.2 生成器方式最常见的两个坑第一个坑contextmanager装饰的函数里只能有一个yield。它必须是一个单产生成器。多个yield会直接让你的上下文管理器逻辑错乱运行时报错或者行为匪夷所思。第二个坑不能在yield之后的代码里return一个非None的值。在 Python 3.7 及以上版本这会导致RuntimeError: generator didnt stop after throw()。我见过一位同事为了在上下文里返回一个结果在yield后写了个return True结果一运行就崩排查了好久才找到原因。如果需要把结果带出来请用as绑定变量。还有一个实际经验不要在生成器方式里用过深的缩进嵌套。contextmanager的优势是平铺直叙地写“先做什么、再 yield、最后做什么”如果你在里面又套了多层 try/except/if那可能说明这个场景更适合用类方式实现。3.3 contextlib 工具包里的几个宝贝除了contextmanagercontextlib还提供了几个特别好用的现成工具我平时用得很多。closing适用于那些实现了close()但没有实现__enter__/__exit__的对象。比如某些 HTTP 连接、自定义资源对象from contextlib import closing with closing(some_resource()) as res: res.use()这等价于自动调用close()非常简洁。sanitize不我重说suppress用来忽略指定异常替代裸try/except passfrom contextlib import suppress with suppress(FileNotFoundError): os.remove(not_exist.txt)这比写try: os.remove(...) except FileNotFoundError: pass干净得多。注意suppress只能用于你确实想忽略异常的场合不要把它当成万能消音器。redirect_stdout可以临时把print的输出重定向到文件或字符串缓冲区from contextlib import redirect_stdout import io buf io.StringIO() with redirect_stdout(buf): print(这段内容不会打印到屏幕)我写工具脚本时经常用这个库去捕获第三方库的日志输出比改全局 stdout 安全。最重量级的是ExitStack它支持动态管理和组合任意多个上下文管理器。比如需要按顺序打开多个文件但中途可能随时失败用ExitStack可以保证前面已打开的文件在异常时全部关闭from contextlib import ExitStack with ExitStack() as stack: files [stack.enter_context(open(fname)) for fname in file_list] # 如果中途某个文件打开失败之前的文件会自动关闭 # 业务处理完成后所有文件按后进先出顺序关闭ExitStack还可以callback()注册任意清理函数不限于上下文管理器对象。这在处理不确定数量、不确定类型的资源时极其好用。4. 实操从文件到数据库的真实场景4.1 文件操作基础与进阶封装最标准的文件写法是with open(data.txt, r, encodingutf-8) as f: content f.read()这里open()返回的文件对象本身就是一个上下文管理器所以可以直接用with。__exit__会负责关闭文件连try/finally都不用写。实际项目中我常会在文件操作外面再包一层自定义逻辑比如记录日志。以下是一个带打开/关闭日志的封装contextmanager def logged_open(path, moder): logger.info(f打开文件: {path}) f open(path, mode, encodingutf-8) try: yield f finally: f.close() logger.info(f关闭文件: {path})这样在排查问题时能很直观地看到文件有没有被正常释放。日志一打出来哪个文件漏关了、哪个文件打开时间过长一目了然。4.2 数据库连接与事务管理数据库操作是最该用上下文管理器的领域因为连接对象和事务的生命周期都非常敏感。以pymysql为例import pymysql from contextlib import contextmanager contextmanager def get_connection(): conn pymysql.connect(hostlocalhost, useruser, passwordpass, databasedb) try: yield conn finally: conn.close()这样业务代码就是with get_connection() as conn: with conn.cursor() as cursor: cursor.execute(SELECT * FROM users) rows cursor.fetchall()配合前面写的TransactionCursor就能把事务的 commit/rollback 也自动化。这里有一个真实的经验连接关闭后不能再使用。我见过有人把conn从with块里带出来在块外又执行了一次查询结果直接报ProgrammingError: Connection is closed。上下文管理器只在with块内保证资源有效这是它的设计边界不是 bug。4.3 线程锁with 处理获取与释放线程锁的标准用法是import threading lock threading.Lock() with lock: # 临界区代码 shared_counter 1threading.Lock实现了__enter__和__exit__所以with lock等价于lock.acquire()加try/finally: lock.release()。好处很明显即使临界区代码抛异常锁也会被释放不会出现死锁。如果你需要带超时的锁可以自己封装contextmanager def lock_with_timeout(lock, timeout): acquired lock.acquire(timeouttimeout) if not acquired: raise TimeoutError(获取锁超时) try: yield finally: lock.release()这样就实现了“拿不到锁就抛异常而不是无限等待”。在服务治理、限流场景下这种模式比裸acquire()更稳健。4.4 临时工作目录、计时器与环境变量上下文管理器不仅能管理“资源”还能管理“状态”。比如临时切换工作目录import os from contextlib import contextmanager contextmanager def working_directory(path): old_dir os.getcwd() os.chdir(path) try: yield finally: os.chdir(old_dir)用法with working_directory(/tmp): # 当前目录是 /tmp os.listdir(.) # 退出后自动回到之前目录干这种全局状态操作时恢复动作必须放在finally里否则一旦中途异常工作目录就永久变了后面的代码全都受影响。计时器也是一个经典场景import time from contextlib import contextmanager contextmanager def timer(name): start time.perf_counter() try: yield finally: elapsed time.perf_counter() - start print(f{name} 耗时: {elapsed:.4f}s)用在性能分析、接口耗时统计里非常顺手。4.5 ExitStack 的高级用法按需动态清理前面提过ExitStack可以动态管理多个上下文我再展开一个真实案例。假设要处理多组配置文件每一组都要打开而且打开个数不固定config_files [a.conf, b.conf, c.conf] with ExitStack() as stack: streams [stack.enter_context(open(f, encodingutf-8)) for f in config_files] for stream in streams: parse_config(stream)如果b.conf打开失败a.conf会被自动关闭不会泄漏。如果在处理过程中抛异常所有已打开的文件也会全部关闭不需要你手动做“前向清理”。这种后进先出的释放顺序和嵌套with的退出顺序完全一致是ExitStack最核心的价值。另外ExitStack的callback()方法能注册任意清理函数比如发送一条“资源使用结束”的消息、更新一个统计指标with ExitStack() as stack: stack.callback(send_metrics, request_finished) # 业务代码无论业务代码是否异常send_metrics都会在退出时执行。5. 必须收藏的避坑清单5.1 as 绑定的不一定是上下文管理器本身很多人以为with 表达式 as x里的x就是表达式结果这对with open(...) as f碰巧成立。但在自定义类里x其实是__enter__()的返回值不是上下文管理器对象本身。我之前封装连接池时习惯让__enter__返回一个游标或一个会话对象而不是返回self。这样业务代码里拿到的是真正要操作的东西语义更清晰。但也提醒你不要在__enter__里返回一个你自己都在纠结“要不要关闭”的对象。__exit__负责清理返回对象只是给业务代码用的句柄两者职责别混淆。5.2exit返回 True 是一把双刃剑我见过几个项目里有人在__exit__里直接写return True然后把异常打印出来美其名曰“防止程序崩溃”。结果就是所有异常都被静默吞掉连调试日志都没有线上问题找不到根因。这种写法的危害远大于好处。正确的做法是除非你确实有“吞掉这个异常并恢复正常流程”的明确理由否则一律返回 False 或 None。比如suppress工具内部就是专门处理“忽略指定异常”的它有明确的异常类型列表而不是无差别吞掉所有异常。5.3 生成器方式中 yield 后的清理代码要放到 finally前面已经强调过contextmanager函数里yield后的代码在 body 抛异常时不一定能按你的预期执行完整。正确的姿势是contextmanager def managed_resource(): resource acquire() try: yield resource finally: release(resource)只有finally能保证release在正常退出和异常退出时都执行。不要图省事把release直接放在yield后面遇到异常中断时会让你很被动。5.4 多个上下文管理器的执行顺序Python 支持with A() as a, B() as b:这种写法。它的语义等价于嵌套with A() as a: with B() as b: ...即__enter__从左到右执行而__exit__从右到左执行也就是后进先出。记住这个顺序很重要因为资源之间存在依赖关系时释放顺序错了会引发连锁问题。比如先关闭外层连接再关闭内层游标就很可能报错。ExitStack的释放顺序也是后进先出这个行为在官方文档里有明确说明实际开发中我建议你在注释里写清楚顺序避免后人误改。5.5 上下文管理器是同步的别在跨协程场景乱用最后提醒一句with是同步的词法作用域结构__exit__一定在with块代码段结束后被调用。如果你在异步代码里想做资源清理应该使用async with配合__aenter__和__aexit__。把同步上下文管理器用在异步回调里很容易出现“块内代码还没跑完退出动作已经执行”的时序错乱。这个坑相对隐蔽遇到了记得第一时间往异步上下文管理器方向排查。6. 从标准库源码里能看到什么想彻底理解上下文管理器我强烈建议去读一读标准库的contextlib.py源码。里面contextmanager的实现不到一百行但是核心逻辑非常精妙它利用生成器对象的throw()方法把with块内抛出的异常“丢回”到yield那一行让生成器内部的try/finally有机会执行清理代码然后再决定异常是继续传播还是被抑制。读一遍源码比我上面写的所有原理都更能让你建立起完整的认知。threading.Lock的__enter__和__exit__也很值得看你会在里面看到最标准的“重复获取、确保释放”模式。我还记得自己第一次在业务代码里从“手写 try/finally”切换到“自定义上下文管理器”时的感受代码行数少了一截异常路径统一了排查资源泄漏时有了日志抓手。如果你还没尝试过建议从封装一个计时器或者事务游标开始亲手写一遍这个套路之后你再看到任何资源管理代码都会觉得清爽得多。
返回列表