Python asyncio.Queue 实战:用生产者-消费者给爬虫/任务流限速又解耦
写异步爬虫或批量任务时,新手最容易掉进的坑是这样的:一次性把 5000 个 URL 全丢进 asyncio.gather。
python
async def crawl_all(urls):
# 5000 个协程瞬间全部起飞
await asyncio.gather(*[fetch(u) for u in urls])
结果就是同一瞬间对目标站点发起 5000 个连接:对方 429 限流甚至封 IP,自己这边文件描述符耗尽、内存飙升。你想控制"同时最多跑 20 个",gather 却不给你这个旋钮。
asyncio.Queue 就是解决这类问题的标准工具。它让你把"谁产生任务"和"谁处理任务"彻底解耦,并且天然带背压(back-pressure),并发数由你说了算。这篇我们从一个会爆的版本一步步改到生产可用。
先理解模型:队列在中间,两头解耦
生产者-消费者的核心是三样东西:一个队列、若干往里塞任务的生产者、若干从里取任务干活的消费者。它们互相不认识,只认队列。
python
import asyncio
async def producer(queue: asyncio.Queue, urls):
for url in urls:
await queue.put(url) # 队列满了会在这里挂起等待------这就是背压
async def consumer(queue: asyncio.Queue, name: str):
while True:
url = await queue.get() # 队列空了就在这挂起,不占 CPU
try:
await fetch(url)
print(f'[{name}] done {url}')
finally:
queue.task_done() # 关键:告诉队列这个任务处理完了
await queue.get() 在队列为空时会挂起当前消费者,直到有新任务进来,不会忙轮询空转。而 await queue.put() 在队列满时会挂起生产者------这一条就是限速的关键。
用 maxsize 给内存和速度同时上闸
asyncio.Queue(maxsize=N) 限定队列里最多堆 N 个未处理任务。一旦堆满,生产者的 put 就会等待,直到消费者取走一个腾出空位。
python
async def fetch(url):
await asyncio.sleep(0.5) # 模拟网络请求
async def main():
urls = [f'https://example.com/page/{i}' for i in range(50)]
# maxsize=10:内存里最多缓 10 个待处理 URL,而不是一次性 50 个全展开
queue = asyncio.Queue(maxsize=10)
# 启动 5 个消费者:并发度就锁死在 5,再多 URL 也不会同时发超过 5 个请求
consumers = [
asyncio.create_task(consumer(queue, f'w{i}'))
for i in range(5)
]
# 生产者把 URL 逐个塞进队列,队列满就自动等
await producer(queue, urls)
# 等队列里所有任务都被 task_done() 确认处理完
await queue.join()
# 任务全干完了,消费者还在 while True 里挂着 get(),取消掉它们
for c in consumers:
c.cancel()
asyncio.run(main())
这里有两个数字在联合控速:
- 消费者数量(5) 决定了同时在处理的任务上限,也就是并发度。想更快就多起几个消费者,想温柔点就少起------不用改任何业务代码。
- maxsize(10) 决定了内存里最多缓存多少待处理任务。哪怕有一亿个 URL,队列也永远不会展开成一亿个对象堆在内存里。
这就是比 gather 优越的地方:并发度和内存占用变成了两个独立可调的旋钮。
queue.join() 和 task_done() 是怎么配对的
新手最容易漏掉 task_done(),导致 queue.join() 永远卡住。这两个是一对:
- 每次
put让队列的未完成计数 +1; - 每次
task_done()让计数 -1; queue.join()会一直等到计数归零才返回。
如果你在消费者里忘了调 task_done(),计数永远降不到 0,join() 就会永久挂起。所以务必把它放在 finally 里------哪怕 fetch 抛异常,也要确认这个任务"处理过了",否则一个异常就能让整个程序卡死。
消费者里的异常:别让一个失败拖垮全场
上面 finally 保证了 task_done,但异常本身被吞掉了。生产环境要把失败收集起来,而不是让某个消费者因为一个异常直接死掉退出 while 循环:
python
async def consumer(queue, name, failures: list):
while True:
url = await queue.get()
try:
await fetch(url)
except asyncio.CancelledError:
# 收到取消信号(比如 main 里 c.cancel()),要往外抛,别咽下去
raise
except Exception as e:
failures.append((url, repr(e))) # 单个任务失败只记录,消费者继续活着
finally:
queue.task_done()
注意 asyncio.CancelledError 要单独放行。它不是业务异常,是我们在 main 末尾 c.cancel() 主动发的停止信号;如果被 except Exception 一起吞了,消费者就永远关不掉了。这是异步代码里极隐蔽的一类 bill------程序跑完却不退出,往往就是取消信号被误吞。
让"发现新任务"也能入队:动态扩张的队列
爬虫的真实场景是:抓一个页面,解析出新链接,再塞回队列继续抓。队列模型天然支持这种自我扩张,因为消费者本身也可以是生产者:
python
import asyncio
async def worker(queue, seen: set, sem: asyncio.Semaphore):
while True:
url = await queue.get()
try:
async with sem: # 再叠一层信号量,精确控制在途请求数
new_links = await fetch_and_parse(url)
for link in new_links:
if link not in seen: # 去重,否则会无限循环抓下去
seen.add(link)
await queue.put(link) # 消费者摇身一变又当了生产者
finally:
queue.task_done()
seen 集合做去重是必须的,否则页面互相引用会让队列永远清不空、join() 永不返回。这个模式在真实爬虫里非常常见:队列既是待办清单,又是天然的广度优先遍历器。
小结
asyncio.Queue 把并发任务从"一把梭 gather"变成了可控的流水线:
- 生产者/消费者只认队列,互不耦合;要加处理逻辑,只改消费者。
- 并发度 = 消费者数量 ,内存上限 = maxsize ,两个旋钮独立可调,这是它碾压
gather的核心。 task_done()必须放finally,和queue.join()配对,漏一个就永久挂起。- 消费者里
asyncio.CancelledError要raise放行,别被except Exception吞掉,否则程序跑完不退出。 - 消费者可以往队列里回塞新任务,配一个
seen集合去重,就是一个自扩张的广度优先爬虫。
一句话记忆:当你想对一堆异步任务说"同时最多跑 N 个"时,就该把 gather 换成 Queue + 固定数量的消费者。