结论:asyncio.Semaphore仅控制并发数,无法实现频率限制;需配合sleep或令牌桶等时间控制机制。它只计数不计时,瞬时请求仍可能超限,真实限频须结合last_request_time动态等待并确保semaphore在请求前获取。

直接说结论:用 asyncio.Semaphore 能有效控制协程并发数,但仅靠它无法实现“频率限制”(如每秒最多 5 次请求);它只管“同时跑几个”,不管“多久发一个”。真要限频,得配合 asyncio.sleep() 或更精确的令牌桶/漏桶逻辑。
为什么 asyncio.Semaphore 不能替代 rate limiting?
asyncio.Semaphore 的作用是资源计数器:每次 acquire() 减一,release() 加一。它确保最多 N 个协程能同时进入临界区(比如发起 HTTP 请求),但不干预时间间隔。
常见误解场景:
- 设了
semaphore = asyncio.Semaphore(3),但所有协程在毫秒级内抢到锁并发出请求 → 瞬时 QPS 爆表 - 某个请求耗时很长(如 10s),其他协程等它释放后立刻冲进去 → 时间分布不均,仍可能触发反爬
如何组合 Semaphore + 时间控制实现真实限频?
最简可行方案:每个请求前先过 semaphore,再强制等待固定间隔(需动态计算)。关键不是“睡 1/N 秒”,而是保证平均速率稳定。
立即学习“Python免费学习笔记(深入)”;
实操建议:
- 维护一个
last_request_time共享变量(用asyncio.Lock保护写入) - 每次请求前:计算距上次至少该等多久(如目标 5 QPS → 最小间隔 0.2s),再
await asyncio.sleep(max(0, interval - elapsed)) -
semaphore放在 sleep 后、aiohttp.ClientSession.get()前,避免空等占用槽位 - 示例片段:
sem = asyncio.Semaphore(3) last_req = 0.0 req_lock = asyncio.Lock() <p>async def fetch(url): global last_req async with req_lock: now = asyncio.get_event_loop().time() interval = 0.2 # 5 QPS if now - last_req < interval: await asyncio.sleep(interval - (now - last_req)) last_req = asyncio.get_event_loop().time() async with sem: async with session.get(url) as resp: return await resp.text()
asyncio.Semaphore 容易踩的坑
实际写的时候,这几个点最容易出错:
- 忘记
await semaphore.acquire()—— 会抛RuntimeError: unawaited coroutine,不是静默失效 - 在
try/finally外提前return,导致release()漏调用 → 半永久卡死 - 把
semaphore实例放在函数内创建(每次调用新建)→ 完全无效,必须是全局或传入的同一实例 - 和
aiohttp.TCPConnector(limit_per_host=5)混用时,两者叠加可能导致实际并发远低于预期(如 sem=3 + limit_per_host=5 → 最多 3;但若 host 不同,connector 限制就不起作用)
真正难的不是加 semaphore,而是判断「限多少、在哪限、和谁协同」——反爬策略千差万别,同一个网站对单 IP 和单 Session 的容忍阈值可能差 10 倍。别迷信数字,先抓包看它怎么封你。


















