asyncio.Semaphore 是什么,怎么用?
asyncio.Semaphore 是什么”时,我通常不会直接背定义,而是先扔一个场景——因为它就是为解决并发过载而生的,尤其在我们刚才聊过的 API 限流、工具调用那些场景里。
🚦 一句话理解¶
asyncio.Semaphore 就是一个协程世界的红绿灯,它允许多个协程“同时运行”,但最多只放行固定数量。
你可以把它当成一个只有 N 个名额的洗手间,出来一个人,才能放进去下一个人。
🧰 基本用法 & 核心方法¶
import asyncio
# 创建信号量,最多允许 3 个协程同时进入
sem = asyncio.Semaphore(3)
async def limited_task(name):
async with sem: # 获取名额,没有空闲就挂起等待
print(f"{name} 进入")
await asyncio.sleep(1) # 模拟 IO 操作
print(f"{name} 离开")
# 出了 with 块自动释放名额
-
semaphore = asyncio.Semaphore(value):value 是最大并发数 -
await semaphore.acquire():尝试减一,如果内部计数器为 0 则挂起当前协程 -
semaphore.release():加一,唤醒一个等待的协程 -
async with semaphore:等价于acquire+try...finally release
重点:release() 可以在不同于 acquire() 的协程里调用,这点和 Lock 不同,使它非常灵活。
🏊 典型场景一:限制并发 API 调用¶
这也是我们之前计算 RPM 时用到的那个信号量。比如调用一个允许同时 10 个请求的 API:
db_sem = asyncio.Semaphore(20)
async def query(sql):
async with db_sem:
async with pool.acquire() as conn:
return await conn.execute(sql)
即使有 1000 个 URL,同时在飞的请求也永远不超过 10 个,不会打爆对方服务。
🔗 典型场景二:限制数据库连接池¶
很多异步数据库驱动有连接数上限。与其让任务报错再重试,不如用信号量在协程层排队:
db_sem = asyncio.Semaphore(20)
async def query(sql):
async with db_sem:
async with pool.acquire() as conn:
return await conn.execute(sql)
这样 100 个查询不会同时去抢 20 个连接,避免 Too many connections。
⚠️ 和 Lock 的区别(常被问)¶
因为 Semaphore 不绑定所有权,你可以用它实现跨协程的流量控制,甚至把 release 当成“补充名额”的操作,比如动态调整并发数。
🧨 踩坑经验:动态调整 Semaphore 要小心¶
有一次我需要根据下游的实时负载动态减小并发数,直接在运行时改了 sem._value。这在单协程调试时没事,高并发下会出现竞态,因为 _value 的读写不是原子的,会有超发。
正确做法是用一个新 Semaphore 替换,或者用 asyncio.BoundedSemaphore 防止过度 release 导致计数器超过初始值(那意味着逻辑 bug)。如果必须动态调整,更安全的是引入一个中间的令牌桶或队列,而不是直接改内部值。
💡 一个我常记在心里的准则¶
在 asyncio 里写并发代码,信号量是你手里的“调速旋钮”,不是“限速标识”。
你不仅要设对初始值,还要在系统运行时观察队列长度和超时比例,把旋钮拧到吞吐与稳定平衡的那一格。面试时如果能说出“我会配合 prometheus 监控等待队列长度来调参”,会比只讲 API 更能证明你真的在生产环境里用过它。