把并发数设成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项全部成功也是本地结果。它们证明示例遵守自己的数量约束,不能推导线上内存占用、吞吐或收益。