Python RLock、Semaphore 的适用场景

23次阅读

RLock适用于同一线程需多次获取同一锁的递归或重入场景,允许重复acquire并需对应次数release;Semaphore则用于控制并发数,允许多线程共享资源但限制总量,acquire与release可跨线程。

Python RLock、Semaphore 的适用场景

RLock 适合递归加锁的线程安全场景

当一个线程需要多次获取同一把锁(比如在递归调用、重入方法中),用普通 Lock 会直接阻塞自己,导致死锁。而 RLock(可重入锁)允许同一线程重复调用 acquire(),只要对应次数的 release() 就能真正释放锁。

常见错误现象:threading.Lock递归函数里卡住,CPU 占用低但程序不继续;报错信息里没有异常,只是静默挂起。

使用场景:

  • 类方法中调用自身其他加锁方法(如 add() 内部调用 update(),两者都需保护共享状态)
  • 实现线程安全的缓存装饰器,内部可能多次访问被锁保护的字典
  • 构造复杂对象时,初始化过程嵌套调用多个需同步的子步骤

注意点:

立即学习Python免费学习笔记(深入)”;

  • RLockLock 开销略大,因为要维护持有线程 ID 和计数器
  • 必须由同一个线程完成所有 acquire()release(),跨线程调用 release() 会抛 RuntimeError: release unlocked lock
  • 不解决“锁顺序不一致”导致的死锁,只解决“自己锁自己”的问题

Semaphore 更适合资源池或并发数控制

Semaphore 的核心不是“互斥”,而是“限制同时访问的线程数量”。它内部维护一个计数器,acquire() 减一,release() 加一,计数器为 0 时阻塞。

典型使用场景:

  • 控制对数据库连接池、http 客户端、GPU 显存等有限资源的并发访问
  • 限流:比如最多允许 5 个线程同时执行耗时任务,其余排队
  • 模拟生产者-消费者中“槽位”数量(比 Queue 更轻量,但不带数据传递)

Lock/RLock 的关键差异:

  • acquire() 可由 A 线程调用,release() 可由 B 线程调用(即不要求同线程)
  • 初始化时传入的数值就是最大并发数,设为 1 时行为接近 Lock,但语义不同、开销略高
  • 不保证临界区的排他性——多个线程可以同时在临界区内,只要没超限额

容易踩的坑:

  • 忘记调用 release(),导致计数器永远卡在 0,后续所有线程永久阻塞
  • 在异常路径中未做 try/finally 保护,造成资源泄漏(建议用上下文管理器)
  • 误以为 Semaphore 能替代条件变量,其实它不提供“等待某个状态成立”的能力

别用 RLock 去代替 Semaphore 控制并发数

有人看到 RLock 也能“控制进入”,就试图靠它限制并发线程数,这是错的。因为 RLock 的重入特性意味着:只要一个线程拿到锁,它就能无限次 acquire(),完全绕过限制。

示例问题代码:

lock = threading.RLock() # 错误:下面这段对并发数毫无约束力 lock.acquire() do_work() lock.release()

这和用 Lock 效果一样,且更易引发隐藏 bug。真正要控并发,请明确用 Semaphore(value=3)

另一个混淆点:BoundedSemaphoreSemaphore子类,会在 release() 超出初始值时抛异常,适合调试阶段捕获误释放。

实际选型看“谁该放行”和“放行依据”

决定用哪个,关键是回答两个问题:

  • 放行依据是“是不是同一个线程”?→ 选 RLock
  • 放行依据是“当前已占用资源数是否达到上限”?→ 选 Semaphore
  • 需要严格一对一加锁/解锁,且不允许跨线程释放?→ 只能用 LockRLock

性能上差异不大,但语义错位会导致极难复现的竞态。比如在连接池里误用 RLock,可能让单个线程占满所有连接却不释放,而其他线程干等。

最常被忽略的一点:python 的 GIL 让纯计算密集型任务无法真正并行,所以这些同步原语主要保护的是 I/O、共享数据结构(如 dictlist)、或 C 扩展中释放 GIL 后的临界区——别在无共享的 CPU 绑定循环里白加锁。

text=ZqhQzanResources