asyncio.Queue 有界队列实战:背压、task_done、join 与异常收尾

把并发数设成20,内存就一定稳定吗?如果先为几十万个输入创建Task,再让它们等信号量,正在执行的数量受限,等待任务却已经留在内存里。

asyncio.Queue(maxsize=N) 可以控制排队项数,但前提是生产者逐项 await put(),消费者数量固定,上下游也遵守容量约定。本篇用无网络的模拟流水线,验证满队列等待、空队列仍未完成、正常收尾及异常传播。

分清三种数量

qsize() 只统计还在队列里的项目;worker 已取走、尚未处理完的项目不在其中。并发数是当前同时执行的工作量,未完成计数则由 put() 增加、task_done() 减少。

例如队列容量3,worker有2个,每个worker一次只持有1项,单个生产者准备1项后等待入队。在这个受限模型里,相关项目可能分布为"排队3+处理中2+生产者手里1",而不是总共只有3项。若输入每项大小不同,这也不是一个固定字节数上限。

有界队列满时,await put() 挂起当前生产者,让它暂时别继续取新输入。这个反馈就是背压。它让积压有上限,不会凭空让消费者变快,也不是每秒固定请求数的限速器。

一个完整、可退出的最小流水线

本次实际运行:macOS arm64、CPython 3.13.13,2026-09-30。示例要求Python 3.11及以上,因为用了TaskGroup;没有使用3.13才增加的Queue.shutdown。这里只以异步短暂等待模拟I/O,不代表真实爬虫或模型接口性能。

保存为 queue_lab.py,运行 python3 queue_lab.py:

python 复制代码
import asyncio


async def boundaries():
    q = asyncio.Queue(maxsize=2)
    await q.put("A")
    await q.put("B")
    entered = asyncio.Event()

    async def put_third():
        entered.set()
        await q.put("C")

    putter = asyncio.create_task(put_third())
    await entered.wait()
    assert not putter.done() and q.qsize() == 2
    print("full queue: third put is waiting")
    assert await q.get() == "A"
    q.task_done()
    await putter
    assert await q.get() == "B"
    q.task_done()
    assert await q.get() == "C"
    assert q.empty()
    waiter = asyncio.create_task(q.join())
    await asyncio.sleep(0)
    assert not waiter.done()
    print("empty queue: join still waits for task_done")
    q.task_done()
    await waiter
    print("acknowledged: join finished")


async def pipeline(fail_at=None):
    q = asyncio.Queue(maxsize=3)
    stop = object()
    stats = {"active": 0, "peak_active": 0, "peak_queued": 0, "success": 0}

    async def worker():
        while True:
            item = await q.get()
            try:
                if item is stop:
                    return
                stats["active"] += 1
                stats["peak_active"] = max(stats["peak_active"], stats["active"])
                try:
                    await asyncio.sleep(0.001)  # Simulated I/O, no network requests.
                    if item == fail_at:
                        raise RuntimeError("synthetic failure")
                    stats["success"] += 1
                finally:
                    stats["active"] -= 1
            finally:
                q.task_done()  # Accounting; does not mean business success.

    async with asyncio.TaskGroup() as group:
        for _ in range(2):
            group.create_task(worker())
        for item in range(20):  # Lazy source, no list of 20 tasks.
            await q.put(item)
            stats["peak_queued"] = max(stats["peak_queued"], q.qsize())
        for _ in range(2):
            await q.put(stop)
        await q.join()
    return stats


async def main():
    await boundaries()
    stats = await pipeline()
    assert stats["success"] == 20 and stats["active"] == 0
    assert stats["peak_active"] <= 2 and stats["peak_queued"] <= 3
    print("normal:", stats)
    try:
        await pipeline(fail_at=4)
    except* RuntimeError:
        print("failure: TaskGroup raised, no success report")
    else:
        raise AssertionError("failure was swallowed")


if __name__ == "__main__":
    asyncio.run(main())

本机实际输出:

text 复制代码
full queue: third put is waiting
empty queue: join still waits for task_done
acknowledged: join finished
normal: {'active': 0, 'peak_active': 2, 'peak_queued': 3, 'success': 20}
failure: TaskGroup raised, no success report

为什么空队列仍然等不到 join

实验取出了最后一个项目C,但还没有调用它对应的 task_done()。这时 q.empty() 已经为真,q.join() 仍在等待。取出、处理与确认结束是不同动作。

示例每次成功 get() 后进入 try/finally,确保相应计数被结算一次;结束哨兵也是入过队的项目,因此也会结算。task_done() 没有成功或失败参数。这里故意把业务成功数量单独计入 success,而不是根据join返回推断全部成功。

如果实际工作失败,不能仅在finally结算后吞掉异常并报"全部完成"。本示例发生合成错误时,TaskGroup取消其他成员,等待它们退出,再把异常交给上层;打印的是失败分支,没有正常成功统计。取消不会撤销已经完成的写入,恢复任务仍需要独立的业务状态和幂等设计。

让容量约定贯穿整条链路

生产者使用惰性的 range(20),逐项入队,并没有先构造20个Task。真实项目可逐页读数据或逐批读取文件,避免在排队之前就把全量响应或所有URL加载进内存。也不要对每次 q.put(item) 再包一层无界 create_task,否则等待会转移到队列外。

worker数量控制处理并发;队列容量控制等待项数;每秒请求预算、目标站点规则、连接池上限与单项超时需要另外设置。如果目标接口允许的持续吞吐低于生产速度,任何有限队列最终都会填满,系统必须选择等待、拒绝、落盘或降采样等明确策略。

输出端同样会积压。示例只保留固定大小的计数,真实代码如果把全部结果追加到列表,输入队列再小,输出列表仍会持续增长。大响应应考虑流式读取、大小限制和及时写入持久化存储。

两个容易漏掉的故障

一是动态爬虫"所有消费者都回填同一个满队列"。如果每个worker都在等 put(),却没有worker继续 get(),就可能相互等待。不能因为有Queue就保证任何拓扑都不会死锁。需要重新设计任务发现与调度边界,例如让专门调度器管理待抓取集合,并给发现结果的传递设置明确容量和溢出策略。

二是把失败后的 join() 当作兜底清理。TaskGroup失败会中止这一批,队列里可能还有未取走的项目;不应退出异常处理后再无条件等待同一个join。示例让生产、消费者和等待过程处于同一个TaskGroup作用域,失败直接传播,调用方据此决定如何持久化与重启。

这份内存队列本身不耐进程崩溃,不保证恰好一次执行,也不跨线程安全。这里用它验证单事件循环内的背压和生命周期;需要跨进程、持久化与可靠交付时,要采用相应的任务存储和确认协议。

怎样选择容量

先观察单项大小、平均与尾部处理耗时、允许等待时间和上下游速度,再确定worker数与缓冲量。容量加大只是允许更多等待,不是吞吐优化的证据。监控至少应区分排队量、在途量、最老任务等待时间、成功数和失败数。

正常运行结果里的 peak_queued=3 与 peak_active=2 是本次合成实验观察;20项全部成功也是本地结果。它们证明示例遵守自己的数量约束,不能推导线上内存占用、吞吐或收益。

参考:Python asyncio.Queue 文档、TaskGroup 官方文档。

相关推荐
小凡geo1 小时前
本地商家 GEO:用脚本一键生成 FAQPage 结构化数据,让 AI 切片更稳
开发语言·人工智能·python·microsoft·搜索引擎·ai
Zhou1411361 小时前
SpringBoot_03_Web开发
前端·spring boot·python
weixin199701080161 小时前
《1688物流API接口边界:logistics.trace.get 与 freight.template.list 的隐藏约束》(附Python源码)
windows·python·list
mhmh1232 小时前
Python 对象模型与数据模型:魔术方法、描述符与元类深度解析
开发语言·python
估值探索者2 小时前
【Python量化策略实战 #02】多因子合成mom和vol两因子打分合成
开发语言·c++·python·数据挖掘·c#
FYKJ_20103 小时前
SSM校园失物招领系统41452-计算机课程设计、毕业设计
java·vue.js·spring boot·python·mysql·typescript·spark
头发够用的程序员4 小时前
TensorRT 自定义算子插件实战(一):从零手写 customScaledTanh
c++·人工智能·pytorch·python·深度学习·边缘计算·jetson
xiaoqi01954 小时前
期货量化软件回测方式深度横评:期魔方、文华财经、无限易、TB开拓者、金字塔全面对比
python·机器学习
别动我齐刘海4 小时前
简历技术栈全面复习——UDP / TCP / CAN / ZMQ / Protobuf 通信工程
网络·c++·python·tcp/ip·机器学习·udp·github