跳转至

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 更能证明你真的在生产环境里用过它。