Python asyncio.Queue 实战:用生产者-消费者给爬虫/任务流限速又解耦

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.CancelledErrorraise 放行,别被 except Exception 吞掉,否则程序跑完不退出。
  • 消费者可以往队列里回塞新任务,配一个 seen 集合去重,就是一个自扩张的广度优先爬虫。

一句话记忆:当你想对一堆异步任务说"同时最多跑 N 个"时,就该把 gather 换成 Queue + 固定数量的消费者。

相关推荐
我命由我123451 小时前
人脸识别 - 判断人脸是否在画面中央
java·人工智能·python·java-ee·人脸识别·android jetpack·android runtime
Black_Rock_br1 小时前
本地AI工具更新,#DSH Desktop桌面端# 正式发布迭代
人工智能·python·开源·运维开发·开源软件
gfdr51 小时前
把吃灰的电视盒子,改成一台不花月租的监控
python·嵌入式硬件·电视盒子
OPEN-F1 小时前
Python进阶教程:Git版本控制与团队协作
git·python·elasticsearch
ellenwan20262 小时前
2026年量化入门:先连起概念、规则和简单实现
人工智能·python
问君能有几多愁~2 小时前
Qt Json 序列化操作
开发语言·qt·json
xieliyu.2 小时前
MySQL 进阶笔记:用户 / 会话 / 全局 / 局部变量 + CASE 分支语法
开发语言·数据库·笔记·mysql
格林威2 小时前
C#高位深相机图像保存:12位16位图像保存方案,OpenCvSharp和Halcon实现
开发语言·人工智能·数码相机·计算机视觉·c#·视觉检测·工业相机
小小龙学IT2 小时前
Qt Graphics View Framework(图形视图框架)深度解析:从 Scene/View/Item 到工业级 2D 场景
开发语言·数据库·qt