为什么 Python threading.Condition() notify() 需要锁?

Why does Python threading.Condition() notify() require a lock?

我的问题具体指的是为什么它是这样设计的,因为不必要的性能影响。

当线程 T1 有此代码时:

cv.acquire()
cv.wait()
cv.release()

并且线程 T2 具有此代码:

cv.acquire()
cv.notify()  # requires that lock be held
cv.release()

发生的事情是 T1 等待并释放锁,然后 T2 获取它,通知 cv 唤醒 T1。现在,在 T2 的释放和 T1 从 wait() 返回后重新获取之间存在竞争条件。如果 T1 尝试首先重新获取,它将被不必要地重新挂起,直到 T2 的 release() 完成。

注意:我有意不使用 with 语句,以更好地说明显式调用的竞争。

这似乎是一个设计缺陷。是否有任何已知的理由,或者我错过了什么?

没有竞争条件,这就是条件变量的工作方式。

调用 wait() 时,会释放底层锁,直到出现通知。保证 wait 的调用者会在函数 returns 之前(例如,等待完成后)重新获取锁。

你是对的,如果在调用notify()时直接唤醒T1,可能会导致效率低下。然而,条件变量通常是通过 OS 原语实现的,并且 OS 通常会足够聪明地意识到 T2 仍然持有锁,因此它不会立即唤醒 T1 而是将其排队等待被唤醒。

此外,在 python 中,这并不重要,因为 GIL 只有一个线程,所以线程无论如何都不能并发 运行。


此外,最好使用以下形式而不是直接调用 acquire/release:

with cv:
    cv.wait()

并且:

with cv:
    cv.notify()

这确保即使发生异常也能释放底层锁。

What happens is that T1 waits and releases the lock, then T2 acquires it, notifies cv which wakes up T1.

不完全是。 cv.notify() 调用不会 唤醒 T1 线程:它只会将它移动到不同的队列。在 notify() 之前,T1 正在等待条件为真。 notify()之后,T1等待获取锁。 T2 不会释放锁,T1 不会 "wake up" 直到 T2 显式调用 cv.release().

几个月前,我遇到了完全相同的问题。但是因为我打开了 ipython,查看 threading.Condition.wait?? 结果(方法的 source )没多久我自己就回答了。

简而言之,wait 方法创建了另一个名为 waiter 的锁,获取它,将其附加到列表中,然后令人惊讶的是,释放了对自身的锁。之后它又一次获得了waiter,也就是开始等待,直到有人释放了waiter。然后它再次获得对自身的锁定并且 returns.

notify 方法从等待者列表中弹出一个等待者(我们记得,等待者是一把锁)并释放它以允许相应的 wait 方法继续。

诀窍在于 wait 方法在等待 notify 方法释放服务员时并未持有条件本身的锁。

UPD1: 我好像理解错了问题。您是否担心 T1 可能会在 T2 释放之前尝试重新获取对自身的锁定?

但是在 python 的 GIL 的上下文中可能吗?或者你认为可以在释放条件之前插入一个IO调用,这样可以让T1唤醒并永远等待?

这不是一个确定的答案,但它应该涵盖了我设法收集到的关于这个问题的相关细节。

首先是Python的threading implementation is based on Java's。 Java 的 Condition.signal() 文档如下:

An implementation may (and typically does) require that the current thread hold the lock associated with this Condition when this method is called.

现在,问题是为什么 强制执行 这种行为,特别是在 Python 中。但首先我想介绍一下每种方法的优缺点。

至于为什么有些人认为持有锁通常是更好的主意,我发现了两个主要论点:

  1. 从服务员 acquire() 锁定的那一刻起——也就是说,在 wait() 释放它之前——保证会收到信号通知。如果相应的 release() 发生在信号之前,这将允许序列(其中 P=ProducerC=ConsumerP: release(); C: acquire(); P: notify(); C: wait() 在这种情况下,对应于同一流的 acquire()wait() 将错过信号。在某些情况下这无关紧要(甚至可以被认为更准确),但在某些情况下这是不可取的。这是一个论点。

  2. 当你notify()在锁外时,这可能会导致调度优先级倒置;也就是说,低优先级线程可能最终会比高优先级线程获得优先级。考虑一个有一个生产者和两个消费者的工作队列(LC=低优先级消费者HC=高优先级消费者),其中LC 当前正在执行一个工作项,HCwait().

  3. 中被阻止

可能会出现以下顺序:

P                    LC                    HC
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                     execute(item)                   (in wait())
lock()                                  
wq.push(item)
release()
                     acquire()
                     item = wq.pop()
                     release();
notify()
                                                     (wake-up)
                                                     while (wq.empty())
                                                       wait();

而如果 notify() 发生在 release() 之前,LC 将无法在 之前 acquire() HC被唤醒。这就是发生优先级反转的地方。这是第二个参数。

支持在锁外通知的论点是为了高性能线程,线程不需要回到睡眠状态只是为了在它获得的下一个时间片再次唤醒——这已经解释过了在我的问题中它是如何发生的。

Python 的 threading 模块

在Python中,我说了,通知的时候一定要持有锁。具有讽刺意味的是,内部实现不允许底层 OS 避免优先级反转,因为它对等待者强制执行 FIFO 顺序。当然,服务员的顺序是确定性的这一事实可能会派上用场,但问题仍然是为什么要强制执行这样的事情,因为有人认为区分锁和条件变量会更精确,因为在一些需要优化并发和最小阻塞的流程,acquire() 本身不应该注册一个前面的等待状态,而应该只有 wait() 调用自己。

可以说,Python 程序员无论如何都不会在意这种程度的性能——尽管这仍然没有回答为什么在实施标准库时,不应该允许多个标准行为的问题可能。

还有一点要说的是,threading 模块的开发人员可能出于某种原因特别想要一个 FIFO 顺序,并发现这是实现它的最佳方式,并且想要以牺牲其他(可能更普遍的)方法为代价将其确定为 Condition。为此,他们应该从怀疑中获益,直到他们自己解释。

有几个令人信服的理由(当放在一起时)。

1。通知者需要拿锁

假装 Condition.notifyUnlocked() 存在。

标准的producer/consumer安排需要在两边都加锁:

def unlocked(qu,cv):  # qu is a thread-safe queue
  qu.push(make_stuff())
  cv.notifyUnlocked()
def consume(qu,cv):
  with cv:
    while True:       # vs. other consumers or spurious wakeups
      if qu: break
      cv.wait()
    x=qu.pop()
  use_stuff(x)

这会失败,因为 push()notifyUnlocked() 都可以介于 if qu:wait() 之间。

写入

def lockedNotify(qu,cv):
  qu.push(make_stuff())
  with cv: cv.notify()
def lockedPush(qu,cv):
  x=make_stuff()      # don't hold the lock here
  with cv: qu.push(x)
  cv.notifyUnlocked()

有效(这是一个有趣的演示练习)。第二种形式的优点是消除了 qu 是线程安全的要求,但它不需要更多的锁来围绕对 notify() 的调用以及 .

还有待解释 偏好 这样做,尤其是考虑到 CPython does wake up the notified thread to have it switch to waiting on the mutex (rather than simply moving it to that wait queue)。

2。条件变量本身需要锁

Condition 具有在并发 waits/notifications 的情况下必须保护的内部数据。 (扫视 the CPython implementation,我发现两个未同步的 notify() 可能会错误地以同一个等待线程为目标,这可能会导致吞吐量降低甚至死锁。)它可以使用专用锁保护该数据,当然;因为我们已经需要一个用户可见的锁,所以使用它可以避免额外的同步成本。

3。多个唤醒条件可能需要锁

(改编自下面链接的博客 post 上的评论。)

def setSignal(box,cv):
  signal=False
  with cv:
    if not box.val:
      box.val=True
      signal=True
  if signal: cv.notifyUnlocked()
def waitFor(box,v,cv):
  v=bool(v)   # to use ==
  while True:
    with cv:
      if box.val==v: break
      cv.wait()

假设 box.valFalse 并且线程 #1 正在等待 waitFor(box,True,cv)。线程 #2 调用 setSignal;当它释放 cv 时,#1 仍然被阻塞。线程 #3 然后调用 waitFor(box,False,cv),发现 box.valTrue,并等待。然后#2 调用 notify(),唤醒仍然不满足并再次阻塞的#3。现在 #1 和 #3 都在等待,尽管其中一个必须满足其条件。

def setTrue(box,cv):
  with cv:
    if not box.val:
      box.val=True
      cv.notify()

现在不会出现这种情况:#3 在更新之前到达并且从不等待,或者它在更新期间或更新之后到达并且尚未等待,保证通知转到#1,return 来自 waitFor.

4。硬件可能需要锁

使用 wait 变形且没有 GIL(在 Python 的某些替代或未来实现中),内存排序 (cf. Java's rules) notify() 之后的锁释放和 wait() 在 return 上的锁获取可能是通知线程更新对等待线程可见的唯一保证。

5。实时系统可能需要它

紧跟在 POSIX 文本 we find 之后:

however, if predictable scheduling behavior is required, then that mutex shall be locked by the thread calling pthread_cond_broadcast() or pthread_cond_signal().

One blog post 包含对该建议的基本原理和历史的进一步讨论(以及此处的其他一些问题)。

在 Python 3 文档中有解释:https://docs.python.org/3/library/threading.html#condition-objects

Note: the notify() and notify_all() methods don’t release the lock; this means that the thread or threads awakened will not return from their wait() call immediately, but only when the thread that called notify() or notify_all() finally relinquishes ownership of the lock.