Python技术迷

Python开发者必看:这个特性让你的代码不再“泄漏”!

昨天晚上十一点多,我在公司楼下拿着奶茶吹风,手机一震,我们组那个小李给我发截图,说他那台服务的内存一路飙,最后被 K8s 直接干掉了,人还在那边说“我 Python 代码又没搞什么大对象,怎么就泄漏了???”

我当时困得一批,但一看他代码,笑出声:经典的资源没关干净,文件、HTTP 连接、数据库游标,全靠“良心”去 close。

说实话,这种问题我年轻那会儿也老犯。直到后面我几乎把所有“可能泄漏”的东西都换成了一个特性:with + 上下文管理器,那后面类似的故障就几乎没再遇到过了,真的是省命的。之前写数据库那篇对比的时候就顺手提过一点,今天就专门把 Python 这块拎出来聊聊。

那些年忘记关的资源:文件版“内存慢慢涨”

你们肯定写过这种代码对吧:

defread_config(path):
    f = open(path, 'r', encoding='utf-8')
    data = f.read()
# 这儿一堆逻辑
if"xxx"in data:
raise ValueError("配置不合法")
    f.close()
return data

看着还行对不对?问题是,一旦在 raise ValueError 那里抛了异常,后面的 f.close() 是不会执行的。多来几次,这个进程里打开的文件句柄数就一直涨,最后你会遇到各种诡异错误:

  • 打开文件突然报 Too many open files
  • 某些日志写不进去
  • 容器内存慢慢涨,但又看不到明显大对象

然后你就开始怀疑人生。

用 with 写一遍就好看多了:

defread_config(path):
with open(path, 'r', encoding='utf-8') as f:
        data = f.read()
if"xxx"in data:
raise ValueError("配置不合法")
return data

你啥都没关对吧?但是只要离开 with 那个缩进块,不管是正常 return 还是抛异常,文件一定会被关掉。资源清理这件事,从“靠自觉”变成“语言保证”,这个体验完全不一样。

with 背后到底干了啥?

很多人就会问了:with 这个东西,具体是怎么保证“不会泄漏”的?

简单说一下哈,不上特别教科书的那种讲法。

只要一个对象实现了两个方法:

classSomething:
def__enter__(self):
# 进入 with 块之前干的事
return self  # 或者返回别的对象给 as 右边用

def__exit__(self, exc_type, exc_val, exc_tb):
# 离开 with 块一定会调用,不管有没有异常
# 这儿做清理:close、release、rollback...
        ...

Python 解释器看到:

with Something() as obj:
# 这里写你的业务逻辑
pass

内部大概就等价于这样(伪代码):

mgr = Something()
obj = mgr.__enter__()
try:
# with 里的代码
pass
except BaseException as e:
# 有异常也会走这
    mgr.__exit__(type(e), e, e.__traceback__)
raise
else:
# 没异常也会走这
    mgr.__exit__(None, None, None)

所以,不管你在 with 里面是正常 return、还是抛异常、还是半路 break、continue,__exit__ 都会被调用。你只要把“释放资源”的动作塞到 __exit__ 里,就不会再因为“忘记 close”而泄漏。

你看,核心就一句话:不要再自己记得去 close,让对象自己在 __exit__ 里做“最后一件事”。

用 with 管 HTTP 连接:别让 socket 挂在那儿发呆

再说个常见的坑:HTTP 请求。

小李之前是这么写的:

import requests

deffetch_user(user_id):
    resp = requests.get(f"https://api.xxx.com/users/{user_id}")
if resp.status_code != 200:
raise RuntimeError("remote error")
return resp.json()

能跑没错,但问题是 requests.get 默认会帮你做一些释放,不过如果你用的是 Session,就经常会有人忘记关:

import requests

session = requests.Session()

deffetch_user(user_id):
    resp = session.get(f"https://api.xxx.com/users/{user_id}")
return resp.json()

Session 不关,连接池里那些 socket 就挂着不释放,时间一久,各种网络资源问题就出了。

更好的写法其实就是让你自己的会话变成一个上下文管理器用:

import requests

classHttpClient:
def__init__(self):
        self._session = requests.Session()

def__enter__(self):
return self._session

def__exit__(self, exc_type, exc_val, exc_tb):
# 不管成功失败,记得把 Session 关掉
        self._session.close()

deffetch_user(user_id):
with HttpClient() as sess:
        resp = sess.get(f"https://api.xxx.com/users/{user_id}")
        resp.raise_for_status()
return resp.json()

这样一来,HttpClient 的使用方是没机会“忘记 close”的,只要缩进结束,Session 必关。

数据库连接也一样:用上下文管事务,爽

再说一个特别适合用 with 的东西:数据库事务。

以前我们喜欢这么写:

deftransfer(conn, from_id, to_id, amount):
    cursor = conn.cursor()
try:
        cursor.execute("UPDATE account SET balance = balance - %s WHERE id = %s", (amount, from_id))
        cursor.execute("UPDATE account SET balance = balance + %s WHERE id = %s", (amount, to_id))
        conn.commit()
except Exception:
        conn.rollback()
raise
finally:
        cursor.close()

东西一多,这个 try / except / finally 就越来越长,复制粘贴到处都是。

换上上下文管理器就清爽很多:

import pymysql

classTransaction:
def__init__(self, conn):
        self.conn = conn
        self.cursor = None

def__enter__(self):
        self.cursor = self.conn.cursor()
return self.cursor

def__exit__(self, exc_type, exc_val, exc_tb):
try:
if exc_type isNone:
                self.conn.commit()
else:
                self.conn.rollback()
finally:
if self.cursor isnotNone:
                self.cursor.close()

deftransfer(conn, from_id, to_id, amount):
with Transaction(conn) as cursor:
        cursor.execute(
"UPDATE account SET balance = balance - %s WHERE id = %s",
            (amount, from_id)
        )
        cursor.execute(
"UPDATE account SET balance = balance + %s WHERE id = %s",
            (amount, to_id)
        )

你看,现在 transfer 函数里就只剩业务逻辑了,连接提交/回滚、游标关闭都被“藏”在了上下文管理器里,异常也不会导致事务悬挂不提交或者不回滚。

懒得写类?那就用 contextlib.contextmanager

有时候你就想包一小段逻辑,写一个完整的类嫌麻烦,Python 还贴心给了个装饰器:contextlib.contextmanager。

这个装饰器可以让你用生成器写上下文管理器,看一个很常用的“计时器”例子:

import time
from contextlib import contextmanager

@contextmanager
deftime_block(name: str):
    start = time.time()
try:
yield
finally:
        end = time.time()
        print(f"[{name}] 耗时: {end - start:.3f}s")

defrun_job():
with time_block("导出报表"):
# 里面随便干事,异常也没问题
        export_big_report()

这里有两个关键点:

  • yield 之前的代码,相当于 __enter__
  • yield 之后的代码,相当于 __exit__,无论里面抛不抛异常,这段都会执行

你不仅可以用它做“计时”,还能做很多“用完就收尾”的事情,比如:

  • 临时切换某个配置
  • 临时修改 logger 的级别
  • 临时进入一个目录再切回来

随口写一个“临时切换目录”的:

import os
from contextlib import contextmanager

@contextmanager
defcd(path: str):
    old = os.getcwd()
    os.chdir(path)
try:
yield
finally:
        os.chdir(old)

defbuild():
with cd("/tmp/project-build"):
        os.system("make all")

不管 build 的过程成功失败,最后都会回到原来的工作目录,不会出现“莫名其妙切到 /tmp 就不回来了”的问题。

多个资源一起管理:别搞 try 里套 try 那种怪物

现实里经常是多个资源一起用,比如你要:

  • 打开一个配置文件
  • 打开一份日志文件
  • 建一个数据库事务

要是不用 with,你大概会变成这样:

defprocess(path, log_path, conn):
    f = open(path)
try:
        log = open(log_path, 'a')
try:
            cursor = conn.cursor()
try:
# 一顿操作
                ...
finally:
                cursor.close()
finally:
            log.close()
finally:
        f.close()

我光打这段字手都累,更别说以后谁改动的时候,少一个 finally 就出事。

用 with 可以这么写:

defprocess(path, log_path, conn):
with open(path) as f, \
         open(log_path, 'a') as log, \
         conn.cursor() as cursor:
# 这里写正常业务逻辑
        ...

同时管理三个资源,缩进还保持一级,with 会帮你按顺序调用每个对象的 __enter__ / __exit__。一旦出错,也会按照“反向顺序”一个个清理。

再进阶一点:ExitStack 应对“数量不确定”的资源

有时候,你要打开的东西数量是“运行时决定”的,比如你要把一堆文件合并:

from contextlib import ExitStack

defmerge_files(paths, out_path):
with ExitStack() as stack:
        files = [stack.enter_context(open(p, 'r')) for p in paths]
with open(out_path, 'w') as out:
for f in files:
for line in f:
                    out.write(line)

ExitStack 的好处就是:你可以在循环里不断 enter_context 新资源,最后只用一个 with 来兜底释放。中间一旦抛异常,已经打开的那些文件也都会被安全关掉,不会出现“前 5 个关了,后 3 个还挂着”的场面。

说到底:把“清理动作”封装起来,别散落在代码里

总结这堆例子,其实都是一个套路:

  1. 找出你代码里所有需要“成对出现”的东西比如:open/close、connect/disconnect、lock/release、begin/commit、acquire/release。

  2. 把“清理那一半”统一写进一个上下文管理器类也行,@contextmanager 也行,看你喜欢。

  3. 在业务代码里,只暴露一个 with XXX:让使用的人根本“没有机会忘记释放”。

以前出资源泄漏,排查都是这样:先怀疑 Python GC、再怀疑第三方库,最后一圈兜兜转转发现——是自己忘了 close。 把这一类东西系统地改成 with 之后,日志干净了,内存图也平滑了,人也睡得更香了。

行了,我这会儿咖啡也凉了,差不多先聊到这。

你可以先翻一翻你现在在维护的项目,搜一下 open(、Session(、cursor(、connect( 这些关键词,看有没有一大片 close() 散落在各个函数里的,那些地方,基本都能用上下文管理器收一收。

有啥具体场景搞不定,你扔一段代码过来,我帮你一起改成不“泄漏”的版本,顺便再吐槽两句。