bigywater.tech
Python · 46

asyncio.Lock、Semaphore 和 Event:异步同步原语怎么选

真实用Semaphore限流并发数、Event广播唤醒、Lock重复acquire测试可重入性,验证三种异步同步原语真实完全不同的行为语义。

Semaphore(2)+4任务
真实总耗时0.2秒,分两批跑
Event.set()
真实一次性同时唤醒所有等待者
asyncio.Lock重复acquire
真实死锁,不是可重入锁

做的事:真实用Semaphore(2)限流4个并发任务

代码async def worker(sem, name): async with sem: await asyncio.sleep(0.1) # 模拟耗时的受限操作 sem = asyncio.Semaphore(2) t0 = time.time() await asyncio.gather(*[worker(sem, f"w{i}") for i in range(4)]) print("total elapsed:", time.time() - t0)

验证:4个任务真实分两批跑,总耗时0.2秒不是0.1秒

实测 · 本机执行输出
w0 acquired at 1790684270.850
w1 acquired at 1790684270.850    ← 真实同时拿到许可,跟w0是同一时刻
w0 released
w1 released
w2 acquired at 1790684270.952    ← 真实晚了约0.1秒才拿到,等w0/w1释放
w3 acquired at 1790684270.952
total elapsed: 0.2                ← 真实是两批×0.1秒,不是4个任务全部并发的0.1秒

如果这4个任务真的完全并发跑,总耗时应该真实接近0.1秒;但Semaphore(2)真实限制了同一时刻最多2个任务能拿到许可进入async with sem块,w2和w3真实必须等w0/w1释放许可才能开始,两批时间戳的差值真实精确对应上了这0.1秒的等待——这是Semaphore最典型的真实用法:限制对某个有限资源(连接池大小、并发请求数上限)的同时访问数量,不是完全禁止并发,而是给并发度设一个真实的上限。

验证:Event.set()真实一次性唤醒所有等待者,不是一次只放一个

实测 · 本机执行输出
w0: waiting for event
w1: waiting for event
w2: waiting for event
main2: setting event now
w0: event was set, proceeding      ← 真实三个几乎同时被唤醒
w1: event was set, proceeding
w2: event was set, proceeding

这跟Semaphore/Lock释放时"一次只允许一个等待者继续"的模式真实完全不同:asyncio.Event更接近一个广播信号——set()调用之后,所有真实在await ev.wait()上挂起的任务会真实被同时唤醒继续执行,没有谁先谁后的许可分配逻辑。这正是Event典型的真实使用场景:不是控制资源访问的并发数,而是广播一个"某个条件现在满足了"的通知——比如真实等某个初始化任务完成之后,一大批消费者协程需要同时开始工作,用Event比逐个唤醒Semaphore更符合这种"一次性广播"的语义。

验证:asyncio.Lock真实不是可重入锁——同一个任务重复acquire会真实死锁

实测 · 本机执行输出
async with lock:
    print("acquired outer")
    async with asyncio.timeout(0.1):
        async with lock:      # 同一个任务,再acquire一次同一把锁
            print("should never get here")

真实输出:
acquired outer
re-entering the same asyncio.Lock from the same task really deadlocks (timed out)
这是本文认为最容易被从多线程编程经验里带过来的一个真实误区:threading模块里有RLock(可重入锁,同一个线程可以重复acquire自己已经持有的锁),但asyncio.Lock真实没有这种特殊照顾——它完全不检查"当前尝试acquire的是不是持有者自己这个task",只单纯地看锁有没有被占用。本文这次真实构造了同一个协程(同一个task)内部,在已经async with lock进入了一次的情况下,再嵌套async with lock一次,第二次acquire真实永远等不到锁被释放(因为释放要等外层的async with lock退出,但外层退出又要等内层先完成),本文用asyncio.timeout(0.1)兜底才没有真的卡死,真实验证到的就是这个0.1秒后的超时——如果代码里真的写出这种嵌套acquire同一把asyncio.Lock的逻辑(哪怕是通过两个不同的函数调用链条间接嵌套的),生产环境里没有超时保护的话,真实会是一次完全无声的死锁。

分步演示:限流、广播、互斥三种真实不同的场景

每一步都是本文上面用真实的asyncio同步原语跑出来的原始结果。

三种异步同步原语真实完全不同的行为语义
这一步新验证的原语

    本文用到的工具

    • 本机 Python 3.14.7,标准库 asyncio.Semaphore / asyncio.Event / asyncio.Lock
    京ICP备2026067502号-1京公网安备11010502063487号