asyncio.Semaphore不能直接用于ClientSession,因其不感知信号量;须用async with semaphore:包裹请求逻辑以确保acquire/release成对执行,且需全局共享实例并协同TCPConnector.limit设置。

asyncio.Semaphore 为什么不能直接用在 aiohttp.ClientSession 上?
因为 aiohttp.ClientSession 本身不感知信号量,你不能把它当“锁”塞进 session.get() 调用里。常见错误是试图在每次请求前手动 await semaphore.acquire() 却忘了 release(),导致信号量永远卡住、协程阻塞不动。
正确做法是用 async with semaphore: 语句块包裹整个请求逻辑,确保 acquire/release 成对执行。尤其注意:释放必须发生在异常路径里,否则后续所有协程都会等死。
- 不要写
semaphore.acquire()+try/finally semaphore.release()—— 容易漏掉异常分支 - 必须用
async with semaphore:,这是唯一能保证资源安全释放的语法 - 信号量对象要定义在所有并发任务共享的作用域(比如主函数里),不能在每个协程里重新创建
如何让信号量真正限制并发请求数,而不是并发任务数?
很多人误以为给 asyncio.create_task() 加信号量就完事了,但实际并发数由 aiohttp.TCPConnector(limit) 和信号量共同决定,且后者只管“进队列”,前者才管“发出去”。如果 connector 的 limit 比信号量大,照样会超发;反之则浪费信号量额度。
两者必须协同设置:
立即学习“Python免费学习笔记(深入)”;
-
semaphore = asyncio.Semaphore(5)控制最多 5 个协程同时进入请求逻辑 -
TCPConnector(limit=5)控制底层 TCP 连接池最多开 5 个连接 - 两个值建议保持一致,否则会出现“协程等信号量 → 拿到后又等连接空闲”的双重排队
- 如果目标网站有严格 QPS 限制,还应叠加
asyncio.sleep()做粗粒度节流,仅靠信号量无法控制时间间隔
使用 asyncio.Semaphore 时容易被忽略的异常传播问题
当某个请求抛出异常(如 aiohttp.ClientError 或网络超时),async with semaphore: 仍会正常释放信号量 —— 这没问题。但如果你在信号量保护块里启动了多个子任务(比如解析+存数据库),而其中一个失败没被 catch,整个协程退出时信号量仍会被释放,看似正常,实则掩盖了错误扩散风险。
典型踩坑场景:
- 在
async with semaphore:块内调用asyncio.create_task(parse_and_save(...)) - parse_and_save 抛异常,但主协程没 await 它,也不做
task.exception()检查 - 信号量照常释放,后续请求继续,错误静默丢失
- 解决方案:要么 await 子任务,要么用
asyncio.gather(..., return_exceptions=True)统一捕获
Python 3.11 下 Semaphore 与结构化并发(TaskGroup)怎么共存?
Python 3.11 引入了 asyncio.TaskGroup,但它和 asyncio.Semaphore 不是替代关系,而是分工不同:TaskGroup 管生命周期(全部子任务完成才退出),Semaphore 管资源配额(同一时刻最多几个干活)。混用时顺序很重要。
推荐模式是:先用信号量控并发,再在受控协程内用 TaskGroup 处理子任务:
async def fetch_one(url):
async with semaphore: # ← 必须在外层
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
data = await resp.text()
async with asyncio.TaskGroup() as tg: # ← 可在内层用
tg.create_task(save_to_db(data))
tg.create_task(log_metrics(url))
反过来把 TaskGroup 包在外面、信号量包在里面,会导致部分协程还没拿到信号量就被 TaskGroup 等待超时中断,信号量 never released。


















