sem = asyncio.Semaphore(MAX_CONCURRENT) async with sem: 语法详解

这两行代码是 Python asyncio 中非常经典的并发控制组合。简单来说,Semaphore(信号量)就像一个“限流器”,而 async with 则是确保这个限流器能安全、自动管理的语法糖。
下面为你详细拆解:

1. sem = asyncio.Semaphore(MAX_CONCURRENT)

这行代码创建了一个信号量对象,并设置了最大并发数 MAX_CONCURRENT
  • 内部机制:你可以把它想象成一个只有 MAX_CONCURRENT 个座位的候车室,或者一个计数器
  • 初始状态:创建时,计数器等于 MAX_CONCURRENT
  • 核心动作
    • 当有协程想要执行受保护的代码时,需要调用 await sem.acquire()。如果计数器 > 0,计数器减 1,协程继续执行;如果计数器 == 0,协程就会挂起(等待),直到有其他协程释放信号量。
    • 当协程执行完毕,调用 sem.release(),计数器加 1,并唤醒一个正在等待的协程。

2. async with sem:

这是 Python 的异步上下文管理器语法。它完美地封装了上面的“获取”和“释放”动作。
当你写下 async with sem: 时,Python 在底层会自动帮你做以下事情:
# async with sem: 的底层等价逻辑:
await sem.acquire()      # 1. 进入代码块前:尝试获取信号量(如果满了就在此挂起等待)
try:
    # 2. 执行 async with 下面的代码块
    await do_something() 
finally:
    sem.release()        # 3. 无论代码块是否报错,退出时都会自动释放信号量
为什么强烈建议用 async with 而不是手动 acquire/release
因为 async with 能防止死锁。如果你的业务代码抛出异常,手动写的 release() 可能会被跳过,导致信号量永远无法归还,其他协程会永久卡死。而 async with 的 finally 机制保证了信号量一定会被释放。

3. 完整实战示例

假设你要爬取 100 个网页,但服务器规定最多只能同时发 5 个请求:
import asyncio

MAX_CONCURRENT = 5

async def fetch(url, sem):
    # 只有拿到信号量的协程才能进入这里
    async with sem:
        print(f"正在请求: {url}")
        await asyncio.sleep(1)  # 模拟网络请求耗时
        print(f"请求完成: {url}")

async def main():
    sem = asyncio.Semaphore(MAX_CONCURRENT)
    urls = [f"url_{i}" for i in range(10)]
    
    # 虽然创建了 10 个任务,但同一时刻最多只有 5 个在执行 fetch 内部逻辑
    tasks = [fetch(url, sem) for url in urls]
    await asyncio.gather(*tasks)

asyncio.run(main())

⚠️ 两个极易踩坑的注意事项

  1. 不要跨事件循环创建asyncio.Semaphore() 必须在事件循环运行前或运行中创建,且绑定到当前循环。如果在多线程环境下,每个线程需要自己的信号量。
  2. 不要在信号量外做耗时操作
    # ❌ 错误示范:耗时操作在 async with 外面
    async with sem:
        pass  # 瞬间释放了信号量
    await heavy_computation()  # 这个耗时操作不受并发限制了!
    
    # ✅ 正确示范:耗时操作必须在 async with 内部
    async with sem:
        await heavy_computation()
总结:asyncio.Semaphore 负责限制并发数量async with 负责安全地获取和释放。这两者结合是 Python 异步编程中实现“并发限流”的标准范式。
posted @ 2026-07-12 10:44  chenlight  阅读(6)  评论(0)    收藏  举报