Python 异步编程最容易忽视的 8 类资源泄漏:从连接、Task 到线程池的完整治理指南

Python 异步编程最容易忽视的 8 类资源泄漏:从连接、Task 到线程池的完整治理指南

在同步程序里,资源泄漏通常比较直观:文件忘了关闭、数据库连接没有归还、Socket 一直占着不释放。

到了 Python 异步编程里,事情却变得微妙得多。

一段程序可能:

  • 没有明显异常;
  • 请求也能正常返回;
  • CPU 占用看起来不高;
  • 单元测试全部通过;

但运行几个小时后,却突然出现:

Too many open files

或者:

Unclosed client session
Unclosed connector
Task was destroyed but it is pending!
ResourceWarning: unclosed transport

甚至什么错误都没有,只是进程内存不断上涨、数据库连接池逐渐耗尽、后台 Task 越积越多,最后整个服务开始超时。

这就是异步程序最危险的一类问题——资源的生命周期已经结束,但资源本身没有真正结束。

本文将围绕 Python 异步编程中最常见的资源泄漏展开,重点讨论 HTTP 连接、Socket、协程 Task、异步生成器、数据库连接池、子进程、线程池以及无界队列等场景,并给出可以直接应用于项目的解决方案。


一、先理解一个核心问题:异步资源为什么特别容易泄漏?

同步程序的生命周期通常很直观:

申请资源
   ↓
使用资源
   ↓
释放资源

异步程序却更像这样:

申请资源
   ↓
await
   ↓
任务切换
   ↓
恢复执行
   ↓
再次 await
   ↓
超时 / 异常 / CancelledError / 服务关闭
   ↓
???

问题恰恰发生在最后这个“???”。

因为异步代码随时可能因为下面这些原因提前离开:

await operation()

可能发生:

正常完成
异常
超时
Task 被取消
父任务失败
程序收到 SIGTERM
事件循环开始关闭

所以,对于异步程序来说,真正可靠的资源管理原则并不是:

“代码最后调用一下 close() 就好了。”

而是:

无论协程从哪一条执行路径退出,都必须保证资源释放逻辑能够执行。

这也是 async withtry/finallyTaskGroup 等机制如此重要的原因。

截至当前 Python 3.14 文档,官方依然明确建议取消场景中的协程使用 try/finally 完成可靠清理;如果显式捕获 CancelledError,通常应在清理完成后继续抛出,而不是吞掉它。(Python documentation)


二、泄漏一:HTTP ClientSession 和连接池没有关闭

这是异步爬虫、API 网关和微服务中最常见的问题之一。

aiohttp 为例。

很多初学者会这么写:

import aiohttp

async def fetch(url):
    session = aiohttp.ClientSession()

    response = await session.get(url)

    return await response.text()

代码能够运行。

但这里至少存在两个生命周期问题:

ClientSession 没有关闭
HTTP Response 生命周期没有明确结束

不断调用:

for url in urls:
    await fetch(url)

意味着程序可能不断创建 Session、Connector、Socket。

运行时间一长,就可能看到:

Unclosed client session
Unclosed connector

甚至文件描述符耗尽。

正确方法之一是使用异步上下文管理器:

import aiohttp
import asyncio

async def fetch(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    async with aiohttp.ClientSession() as session:
        html = await fetch(
            session,
            "https://example.com"
        )

        print(len(html))

asyncio.run(main())

更重要的是:

通常不要为每一次 HTTP 请求创建一个 ClientSession。

aiohttp 官方文档也明确建议不要“一次请求创建一个 Session”。Session 内部维护连接池,并通过 Keep-Alive 实现连接复用。(AIOHTTP)

一个典型的服务结构应该是:

应用启动
    ↓
创建 ClientSession
    ↓
请求 1 ─┐
请求 2 ─┼→ 共用连接池
请求 3 ─┘
    ↓
应用关闭
    ↓
关闭 ClientSession

而不是:

请求 → 创建 Session → 请求
请求 → 创建 Session → 请求
请求 → 创建 Session → 请求

这不仅容易泄漏,性能也更差。


三、泄漏二:TCP StreamWriter / Socket 没有真正关闭

使用原生 asyncio 编写 TCP 客户端时,一个很典型的代码是:

reader, writer = await asyncio.open_connection(
    "127.0.0.1",
    8888
)

其中 writer 持有底层网络 Transport 和 Socket。

如果写成:

async def request():
    reader, writer = await asyncio.open_connection(
        "127.0.0.1",
        8888
    )

    writer.write(b"hello")

    data = await reader.read(100)

    return data

函数虽然返回了,但连接并没有按照确定的生命周期关闭。

推荐:

async def request():
    reader, writer = await asyncio.open_connection(
        "127.0.0.1",
        8888
    )

    try:
        writer.write(b"hello")
        await writer.drain()

        return await reader.read(100)

    finally:
        writer.close()
        await writer.wait_closed()

这里有两个动作:

writer.close()
await writer.wait_closed()

close() 发起关闭,而 wait_closed() 等待底层连接真正关闭。

Python 官方 Stream API 的示例同样采用:

writer.close()
await writer.wait_closed()

并指出 wait_closed() 可以等待底层连接关闭并确保退出前完成必要的数据刷新。(Python documentation)

尤其对于:

HTTPS
大量短连接
代理连接
长连接服务

“发起关闭”和“已经完全关闭”不是完全相同的概念。


四、泄漏三:后台 Task 创建之后无人管理

这是异步项目中非常隐蔽的一类泄漏。

考虑:

async def heartbeat():
    while True:
        print("heartbeat")
        await asyncio.sleep(1)

async def main():
    asyncio.create_task(heartbeat())

    await do_business()

heartbeat() 是一个无限任务。

问题在于:

谁负责结束它?

如果答案是:

“不知道,反正程序结束的时候应该会自己结束吧。”

那么项目里迟早会出现生命周期问题。

异步 Task 也应该拥有明确的 Owner:

谁创建 Task
    ↓
谁等待 Task
    ↓
谁取消 Task
    ↓
谁处理异常

一个常见的安全写法:

import asyncio
from contextlib import suppress

async def heartbeat():
    try:
        while True:
            print("heartbeat")
            await asyncio.sleep(1)

    finally:
        print("heartbeat cleanup")

async def main():
    task = asyncio.create_task(heartbeat())

    try:
        await asyncio.sleep(5)

    finally:
        task.cancel()

        with suppress(asyncio.CancelledError):
            await task

asyncio.run(main())

注意关键点:

task.cancel()
await task

很多人只有:

task.cancel()

然后立即退出。

cancel() 本质上是提出取消请求

Task 要在后续运行过程中收到 CancelledError,执行自己的 finally 清理代码,生命周期才真正完成。


五、比 create_task 更安全:使用 TaskGroup

Python 3.11 引入的 asyncio.TaskGroup 是处理一组相关任务时非常重要的工具。

传统方式:

task1 = asyncio.create_task(job1())
task2 = asyncio.create_task(job2())
task3 = asyncio.create_task(job3())

await task1
await task2
await task3

如果中途:

task1

发生异常,开发者就需要认真考虑:

task2 怎么办?
task3 怎么办?
异常怎么收集?
任务什么时候结束?

TaskGroup 可以把任务生命周期绑定到一个作用域:

import asyncio

async def worker(name):
    await asyncio.sleep(1)
    print(name)

async def main():
    async with asyncio.TaskGroup() as tg:
        tg.create_task(worker("A"))
        tg.create_task(worker("B"))
        tg.create_task(worker("C"))

asyncio.run(main())

退出:

async with asyncio.TaskGroup()

之前,其中的任务都会得到管理。

如果某个任务以非取消异常失败,TaskGroup 会取消其他相关任务,并等待它们完成退出流程。(Python documentation)

这类设计叫:

Structured Concurrency——结构化并发。

可以把它理解为:

Task 不再是“满世界乱飞的后台协程”,而是拥有明确父子生命周期的结构。

这是现代 Python 异步代码非常值得采用的设计思想。


六、泄漏四:吞掉 CancelledError,导致任务永远无法正确终止

下面这段代码看起来非常负责:

async def worker():
    while True:
        try:
            await do_something()

        except BaseException as exc:
            print("error:", exc)

实际上它非常危险。

因为:

asyncio.CancelledError

继承自 BaseException

也就是说,Task 收到取消信号以后:

task.cancel()

可能进入:

except BaseException:

然后被程序吞掉。

于是 Task:

收到取消
↓
CancelledError
↓
被捕获
↓
继续 while True
↓
永远活着

更推荐:

async def worker():
    try:
        while True:
            await do_something()

    except asyncio.CancelledError:
        print("worker is stopping")

        await cleanup()

        raise

那个:

raise

非常重要。

Python 官方文档明确指出,在绝大多数情况下,捕获 CancelledError 后都应该重新抛出,否则可能破坏 TaskGroup、timeout 等依赖取消机制实现的结构化并发组件。(Python documentation)


七、泄漏五:异步生成器提前 break,却没有确定性关闭

考虑一个数据库流式读取:

async def stream_users():
    conn = await get_connection()

    try:
        async for row in conn.stream(
            "SELECT * FROM users"
        ):
            yield row

    finally:
        await conn.close()

调用:

async for user in stream_users():
    if user.id == target_id:
        break

逻辑看起来没问题。

但对于拥有昂贵外部资源的异步生成器,更可靠的原则是:

不要依赖垃圾回收替你决定什么时候完成清理。

可以使用:

from contextlib import aclosing

async with aclosing(stream_users()) as users:
    async for user in users:

        if user.id == target_id:
            break

退出上下文时:

aclose()

会被确定性调用。

如果你自己手动管理事件循环,还需要注意异步生成器整体关闭问题。

Python 事件循环提供:

await loop.shutdown_asyncgens()

用于关闭当前仍然打开的异步生成器;不过如果使用:

asyncio.run(main())

则通常无需自己调用,因为 asyncio.run() 会负责相关收尾工作。(Python documentation)

因此现代异步程序通常优先:

asyncio.run(main())

而不是自己随意维护:

new_event_loop()
run_forever()
loop.close()

八、泄漏六:数据库连接没有归还连接池

异步数据库应用中最危险的情况之一是:

conn = await pool.acquire()

然后中间出现:

await query()

query 抛异常。

于是:

await pool.release(conn)

根本没有机会执行。

连接就可能一直处于:

checked out

状态。

几十个请求后,连接池彻底耗尽。

然后你会看到一种非常具有迷惑性的现象:

数据库正常
SQL 也没问题
CPU 也不高

但是所有请求都卡在:

等待数据库连接

正确模式应该是:

conn = await pool.acquire()

try:
    await execute_query(conn)

finally:
    await pool.release(conn)

如果库提供异步上下文管理器,则优先使用:

async with pool.acquire() as conn:
    await execute_query(conn)

以 SQLAlchemy AsyncEngine 为例:

async with engine.connect() as conn:
    result = await conn.execute(statement)

而在整个应用生命周期结束时,则可以:

await engine.dispose()

释放连接池持有的连接资源。SQLAlchemy 官方异步文档同样提供 AsyncEngine.dispose() 用于处理连接池资源。(SQLAlchemy 文档)

因此生产系统应该明确区分:

请求级生命周期
    ↓
Connection

应用级生命周期
    ↓
Engine / Pool

不要:

每个请求创建一个 Engine

也不要:

创建 Connection 后等待 GC 替你归还

九、泄漏七:异步子进程启动了,却没人负责回收

Python 可以非常方便地异步启动程序:

proc = await asyncio.create_subprocess_exec(
    "python",
    "worker.py"
)

问题依旧是那个经典问题:

谁负责结束它?

例如:

async def run_worker():
    proc = await asyncio.create_subprocess_exec(
        "python",
        "worker.py"
    )

    await asyncio.sleep(3)

    return

协程结束了。

子进程未必结束。

更可靠的设计:

async def run_worker():
    proc = await asyncio.create_subprocess_exec(
        "python",
        "worker.py",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )

    try:
        stdout, stderr = await asyncio.wait_for(
            proc.communicate(),
            timeout=10
        )

        return stdout

    except TimeoutError:
        proc.kill()

        stdout, stderr = await proc.communicate()

        raise

为什么 kill 以后还要:

await proc.communicate()

因为终止进程和等待系统完成进程回收不是完全相同的一件事。

另外,如果:

stdout=PIPE
stderr=PIPE

却只等待:

await proc.wait()

当子进程输出量很大时,还可能因为系统 Pipe 缓冲区被写满形成死锁。Python 官方文档因此建议此类场景使用 communicate()。(Python documentation)


十、泄漏八:线程池、Executor 生命周期失控

异步并不意味着所有代码都是非阻塞的。

我们经常把阻塞函数丢到线程执行:

result = await asyncio.to_thread(
    blocking_function
)

或者:

loop.run_in_executor(...)

如果使用 Python 默认 Executor,并使用:

asyncio.run(main())

通常事件循环会在退出过程中处理默认 Executor 的关闭。

但如果你创建自己的:

from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=10)

却永远不:

executor.shutdown()

那么线程和相关资源就可能长期存在。

推荐:

from concurrent.futures import ThreadPoolExecutor

async def main():

    loop = asyncio.get_running_loop()

    with ThreadPoolExecutor(max_workers=10) as executor:

        result = await loop.run_in_executor(
            executor,
            blocking_function
        )

        print(result)

如果手动管理事件循环,Python 还提供:

await loop.shutdown_default_executor()

用于等待默认线程池中的线程退出。不过官方同样指出,使用 asyncio.run() 时不应该自己调用它,因为 asyncio.run() 已经负责默认 Executor 的关闭。(Python documentation)


十一、还有一种非常容易漏掉的“逻辑资源泄漏”:无界队列

下面这段程序没有忘记关闭任何 Socket:

queue = asyncio.Queue()

async def producer():
    while True:
        data = await receive_data()
        await queue.put(data)

async def consumer():
    while True:
        data = await queue.get()
        await process(data)

但如果:

producer = 每秒 10000 条
consumer = 每秒 3000 条

那么:

积压速度 = 每秒 7000 条

运行越久:

queue.qsize()

越大。

最终结果仍然是:

内存泄漏般增长
↓
GC 压力增加
↓
延迟升高
↓
OOM

虽然严格来说这可能属于“无界资源增长”而不是传统意义上的对象泄漏,但在生产环境中的后果完全一样。

应该使用有界队列:

queue = asyncio.Queue(maxsize=1000)

于是:

await queue.put(data)

在队列满时会阻塞生产者,形成:

Backpressure——背压。

这是异步系统设计中非常重要的概念。

真正稳定的并发系统不是:

“能创建多少 Task 就创建多少。”

而是:

系统能够限制自己接受工作的速度。


十二、一个真实的异步爬虫泄漏案例

假设有 5000 个 URL:

urls = [...]

最初的代码:

async def fetch(url):

    session = aiohttp.ClientSession()

    response = await session.get(url)

    return await response.text()

然后:

tasks = [
    asyncio.create_task(fetch(url))
    for url in urls
]

await asyncio.gather(*tasks)

这里同时存在几个问题:

5000 个 Task 一次性创建
5000 个 Session
大量 TCP 连接
缺少并发限制
Session 没关闭
缺少请求超时

这不是“异步性能优化”。

这其实是在:

用极快的速度消耗系统资源。

更合理的版本:

import asyncio
import aiohttp


async def fetch(session, url, semaphore):

    async with semaphore:

        async with asyncio.timeout(10):

            async with session.get(url) as response:

                response.raise_for_status()

                return await response.text()


async def crawl(urls):

    semaphore = asyncio.Semaphore(100)

    timeout = aiohttp.ClientTimeout(total=15)

    connector = aiohttp.TCPConnector(
        limit=100
    )

    async with aiohttp.ClientSession(
        timeout=timeout,
        connector=connector
    ) as session:

        async with asyncio.TaskGroup() as tg:

            tasks = [
                tg.create_task(
                    fetch(
                        session,
                        url,
                        semaphore
                    )
                )
                for url in urls
            ]

    return [
        task.result()
        for task in tasks
    ]

现在生命周期非常清楚:

crawl()
   │
   ├── ClientSession
   │      │
   │      └── Connection Pool
   │
   ├── Semaphore
   │
   └── TaskGroup
          │
          ├── fetch()
          ├── fetch()
          └── fetch()

退出:

crawl()

的时候:

TaskGroup 完成
↓
请求结束
↓
Session 关闭
↓
连接池关闭
↓
协程退出

这就是一种健康的异步资源所有权模型。

不过,如果 URL 数量达到几十万甚至上百万,依然不建议一次创建全部 Task。

这时更适合:

Producer
   ↓
Bounded Queue
   ↓
固定数量 Worker
   ↓
HTTP Connection Pool

例如只启动 100 个 Worker,而不是创建 100 万个 Task。


十三、怎样快速发现异步资源泄漏?

资源泄漏最大的问题不是修复难,而是:

你经常不知道它正在发生。

因此开发和 CI 环境应该主动暴露问题。

1. 开启 asyncio Debug Mode

可以:

PYTHONASYNCIODEBUG=1 python app.py

或者:

asyncio.run(
    main(),
    debug=True
)

Asyncio 的调试功能可以帮助发现一些:

未等待的协程
异常未被消费的 Task
慢回调
线程调用问题

Python 专门提供了 Developing with asyncio 文档说明这些调试机制。(Python documentation)


2. 不要忽略 ResourceWarning

一个很容易被忽视的问题是:

ResourceWarning 在普通 Python 配置下默认可能不会显示。(Python documentation)

开发环境可以:

python -W default app.py

更严格的 CI 环境甚至可以:

python -W error::ResourceWarning app.py

这样本来只是:

Warning

的问题会直接导致测试失败。

这个策略非常值得采用。

因为与其:

让生产服务器运行三天以后才出现 Too many open files,

不如:

在 CI 阶段因为一个未关闭的连接直接红灯。


十四、监控当前还活着多少 asyncio Task

调试长期运行程序时,可以周期性观察:

asyncio.all_tasks()

例如:

async def monitor_tasks():

    while True:

        tasks = asyncio.all_tasks()

        pending = [
            task
            for task in tasks
            if not task.done()
        ]

        print(
            "pending tasks:",
            len(pending)
        )

        await asyncio.sleep(10)

如果业务请求量已经恢复正常,但你发现:

pending tasks

100
300
800
2000
7000
...

只升不降,那么就应该调查。

另外,生产环境最好同时监控:

进程内存 RSS
Open File Descriptors
TCP Connections
数据库连接池 checked-out 数量
asyncio Task 数
Queue Size
线程数量
子进程数量
请求 P95 / P99 延迟

资源泄漏很少是突然发生的。

通常它会在指标里留下非常明显的增长曲线。


十五、一个简单但非常有效的资源管理原则

我在异步项目中非常推荐建立这样一条团队规范:

谁创建资源,谁就必须明确它由谁关闭。

创建:

ClientSession

就应该知道谁:

await session.close()

创建:

Task

就应该知道谁:

await task

创建:

Connection

就应该知道谁:

release / close

创建:

Process

就应该知道谁:

wait / communicate

创建:

Executor

就应该知道谁:

shutdown

如果一个资源的生命周期设计回答不了:

“什么时候释放?”

那这个资源实际上已经处于泄漏风险中了。


十六、生产环境推荐的“资源生命周期五步法”

我通常把异步资源治理总结成五个动作:

Acquire
   ↓
Bound
   ↓
Use
   ↓
Cancel / Exception
   ↓
Cleanup

对应代码设计就是:

resource = await acquire()

try:

    async with asyncio.timeout(10):

        await use(resource)

finally:

    await cleanup(resource)

如果库支持上下文管理器:

async with resource_manager() as resource:

    await use(resource)

通常更加安全。

并且需要补上另外三个关键词:

Timeout
Backpressure
Structured Concurrency

一个成熟的异步服务,几乎一定同时具备:

资源生命周期管理
+
并发数量限制
+
超时控制
+
取消传播
+
背压
+
优雅关闭

这几件事情的重要程度,往往远远超过“如何再提高 10% 的并发性能”。


十七、异步资源泄漏检查清单

上线一个新的 Python 异步服务之前,可以逐项检查:

检查项目推荐做法
HTTP Session应用级复用并显式关闭
HTTP Responseasync with
TCP Streamclose() + await wait_closed()
数据库 Connection上下文管理器或 finally
数据库 Pool / Engine应用关闭时 dispose
Background Task保存引用并管理结束
Task Cancellationcleanup 后重新抛出 CancelledError
相关并发任务优先考虑 TaskGroup
Async Generator必要时 aclosing()
子进程communicate() / wait()
自定义 Executorshutdown()
Queue设置 maxsize
HTTP 并发Semaphore / Connector limit
外部请求设置 Timeout
Event Loop优先使用 asyncio.run()
ResourceWarningCI 中开启
任务数量监控 asyncio.all_tasks()
连接数量加入生产监控
服务关闭实现 Graceful Shutdown

十八、最后:真正困难的不是 async,而是生命周期

刚接触 Python 异步编程时,我们很容易把注意力集中到:

async
await
create_task
gather

这些语法上。

但写过真正长期运行的异步服务以后,你会发现:

异步编程最难的部分并不是并发,而是生命周期。

什么时候创建?

什么时候开始?

谁拥有它?

谁等待它?

异常发生以后怎么办?

Timeout 以后怎么办?

服务退出时怎么办?

取消过程中还能不能安全释放?

如果这些问题都能得到清晰回答,你的异步代码通常不会太差。

反过来,如果一个项目到处都是:

asyncio.create_task(...)

却没有人知道这些 Task 最后在哪里结束;

到处都是:

ClientSession()

却不知道谁负责关闭;

到处都是:

queue = asyncio.Queue()

却没有考虑生产速度大于消费速度时怎么办——

那么再漂亮的 async/await 语法,也无法阻止系统最终耗尽资源。

优秀的 Python 异步程序,不只是“跑得快”。

它应该做到:

每一个创建出来的资源,都拥有一个能够被证明正确的结束方式。

这才是真正可靠的 Python 异步编程。


参考资料

本文相关机制可进一步阅读 Python 官方 asyncio 文档,包括协程与 Task、取消机制和 TaskGroupPython asyncio Coroutines and Tasks

网络 Stream 的关闭、StreamWriter.close()wait_closed() 可参考官方 Stream API。Python asyncio Streams

事件循环、异步生成器以及默认 Executor 的关闭流程可参考 Event Loop 文档。Python asyncio Event Loop

异步程序的 Debug Mode、未等待协程和相关诊断方法可参考开发指南。Developing with asyncio

HTTP Session 与连接池生命周期可参考 aiohttp 官方文档。aiohttp Client Quickstart

数据库异步连接和 AsyncEngine 生命周期可以继续阅读 SQLAlchemy AsyncIO 文档。SQLAlchemy AsyncIO Documentation


互动话题

你在实际项目中遇到过 Unclosed client sessionTask was destroyed but it is pending 或数据库连接池耗尽吗?

你认为 Python 异步项目里最难处理的是连接生命周期、任务取消,还是服务的 Graceful Shutdown?

很多真正有价值的异步编程经验,都不是来自“Hello World”,而是来自那些运行了几天以后才暴露出来的问题。欢迎把你的案例和解决思路分享出来——一个看似不起眼的资源泄漏经历,很可能正好能帮另一位开发者避开一次生产事故。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值