bigywater.tech
爬虫工程 · 18

asyncio 信号量限制并发

给 asyncio 爬虫加一个 Semaphore 似乎是标准动作,但写法很容易悄悄失效。这篇用本机服务器统计“同时在处理的请求峰值”,看几种写法到底限住了没有,排队时间算不算超时,以及一次建出几十万个任务要付多少内存。

50
Semaphore(5) 只包住 session.get() 时,服务器实际看到的并发峰值
90 / 110
TCPConnector(limit=10) 加 1 秒总超时,200 个请求里成功 / 超时的个数
310.8 对 23.5 MiB
20 万个任务:gather 加信号量与 worker 加队列的峰值内存

信号量等于并发峰值

实测 · 本机执行输出
  Semaphore(  5):服务器同时在处理的峰值   5,总耗时 2148 ms(理论下限 2000 ms)
  Semaphore( 20):服务器同时在处理的峰值  20,总耗时 557 ms(理论下限 500 ms)
  Semaphore(100):服务器同时在处理的峰值 100,总耗时 138 ms(理论下限 100 ms)

Semaphore(N) 包住整个请求流程时,服务器看到的并发峰值恰好是 N,总耗时紧贴 请求数 / N × 单次耗时。

范围写小了

实测 · 本机执行输出
  Semaphore(5),50 个请求,信号量包住整个 async with(含 read):服务器同时在处理的峰值 5,总耗时 3069 ms
  Semaphore(5),50 个请求,信号量只包住 await session.get():服务器同时在处理的峰值 50,总耗时 328 ms

await session.get() 在收到响应头时就返回,这时释放信号量,响应体还没读。如果服务器的响应体很慢,实际的在途连接数会远超信号量。正确的写法是让 async with sem 包住 async with session.get(...) 的整个块,包括 await r.read()。

limit 与总超时

实测 · 本机执行输出
  TCPConnector(limit=10),total 超时 1.0 秒:200 个请求(每个 0.1 秒,10 并发理论上要 2 秒)→ 成功 90,超时 110,总耗时 1011 ms
  TCPConnector(limit=0) + Semaphore(10),total 超时 1.0 秒:200 个请求(每个 0.1 秒,10 并发理论上要 2 秒)→ 成功 200,超时 0,总耗时 2154 ms

TCPConnector(limit=10) 让多出来的请求在连接器里排队,而这个排队时间算在 ClientTimeout(total=…) 里:1 秒的总超时下,200 个请求只有 90 个来得及完成。用 limit=0 加自己的 Semaphore,排队发生在超时计时之前,200 个全部成功。

任务总数很大时

实测 · 本机执行输出
  20000 个任务(每个只 sleep 1 ms,信号量/worker 都是 100):gather:峰值内存 51.2 MiB(起点 23.5),耗时 0.31 s;queue:峰值内存 23.6 MiB(起点 23.5),耗时 0.25 s
  200000 个任务(每个只 sleep 1 ms,信号量/worker 都是 100):gather:峰值内存 310.8 MiB(起点 23.5),耗时 3.41 s;queue:峰值内存 23.5 MiB(起点 23.3),耗时 2.39 s

gather 先把所有协程创建成任务,哪怕它们大部分只是在等信号量:20 万个任务峰值内存 310.8 MiB,耗时 3.41 s。改成固定数量的 worker 从有界队列取任务,内存不随任务总数增长(23.5 MiB),也更快(2.39 s)。这里每个任务只是 sleep(0.001),真实任务持有的数据会让差距更大。

唤醒顺序

实测 · 本机执行输出
  Semaphore(2),12 个任务按 0..11 顺序创建,实际获得信号量的顺序:[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
  与创建顺序一致:True

实测等待信号量的任务按到达顺序获得它。要按优先级处理,需要优先级队列而不是信号量。

分步演示

信号量限制并发
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。服务器每个请求停 0.1 秒(慢响应体的版本先发响应头、0.3 秒后发体),记录同时在处理的请求峰值;客户端是 aiohttp 3.14.3;第四节在独立子进程里用 resource.getrusage 测峰值内存;每项各测一次(没有重复取中位数),所以毫秒级数字只供参考,结论看相对关系
    • 没有验证:Semaphore 在取消、异常时的释放(async with 会释放,这里没单独测);asyncio.TaskGroup 与 Semaphore 的配合;限速(每秒多少个请求)与限并发的区别(后面有令牌桶一篇);多进程下每个进程各一个信号量时总并发;aiohttp 之外的客户端(httpx、curl_cffi)里相同的陷阱
    京ICP备2026067502号-1京公网安备11010502063487号