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

Yam*_*vic 26 python multithreading condition-variable race-condition python-3.x

由于不必要的性能影响,我的问题特别提到为什么它是这样设计的.

当线程T1有这个代码时:

cv.acquire()
cv.wait()
cv.release()
Run Code Online (Sandbox Code Playgroud)

和线程T2有这个代码:

cv.acquire()
cv.notify()  # requires that lock be held
cv.release()
Run Code Online (Sandbox Code Playgroud)

会发生什么是T1等待并释放锁定,然后T2获取它,通知cv哪个唤醒T1.现在,在T2的释放和T1的重新获得之后存在一个竞争条件wait().如果T1尝试首先重新获取,则在T2 release()完成之前将不必要地重新暂停.

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

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

Dav*_*ing 6

有几个令人信服的原因(综合起来)。

1.通知者需要加锁

假装Condition.notifyUnlocked()存在。

标准的生产者/消费者安排需要双方都加锁:

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)
Run Code Online (Sandbox Code Playgroud)

这会失败,因为 和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()
Run Code Online (Sandbox Code Playgroud)

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

仍然需要解释这样做的偏好,特别是考虑到(正如您所观察到的) CPython 确实会唤醒通知线程以使其切换到等待互斥体(而不是简单地将其移动到等待队列)。

2.条件变量本身需要锁

具有Condition在并发等待/通知的情况下必须受到保护的内部数据。(浏览一下CPython 实现,我发现两个不同步的notify()可能会错误地针对同一个等待线程,这可能会导致吞吐量降低甚至死锁。)当然,它可以使用专用锁来保护该数据;由于我们已经需要一个用户可见的锁,因此使用该锁可以避免额外的同步成本。

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

(改编自下面链接的博客文章的评论。)

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()
Run Code Online (Sandbox Code Playgroud)

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

def setTrue(box,cv):
  with cv:
    if not box.val:
      box.val=True
      cv.notify()
Run Code Online (Sandbox Code Playgroud)

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

4.硬件可能需要锁

使用等待变形并且没有 GIL(在 Python 的某些替代或未来实现中),之后的锁释放和返回时的锁获取所强加的内存排序(参见 Java 的规则)可能是通知线程的唯一保证更新对等待线程可见。notify()wait()

5. 实时系统可能需要它

在您引用的POSIX 文本之后,我们立即发现

但是,如果需要可预测的调度行为,则该互斥锁应由调用 pthread_cond_broadcast() 或 pthread_cond_signal() 的线程锁定。

一篇博客文章进一步讨论了该建议的基本原理和历史(以及此处的一些其他问题)。


Yam*_*vic 5

这不是一个明确的答案,但是应该涵盖我已经设法收集的有关此问题的相关详细信息。

首先,Python的线程实现基于Java的。Java的Condition.signal()文档内容如下:

一个实现可能(并且通常确实)要求在调用此方法时当前线程持有与此Condition相关联的锁。

现在,问题是为什么特别在Python中强制执行此行为。但是首先,我想介绍每种方法的利弊。

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

  1. 从服务员一刻开始acquire()(即在将其释放之前),wait()可以确保收到信号通知。如果相应的release()发生在信令之前,则这将允许序列(其中P = ProducerC = Consumer),P: release(); C: acquire(); P: notify(); C: wait()在这种情况下,wait()acquire()相同流的对应将丢失信号。在某些情况下,这无关紧要(甚至可以认为更准确),但是在某些情况下,这是不希望的。这是一个论点。

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

可能会发生以下顺序:

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

鉴于notify()以前发生的情况release()LCacquire()HC被唤醒之前是无法做到的。这是发生优先级反转的地方。这是第二个论点。

支持在锁之外进行通知的论点是针对高性能线程,其中线程不必为了进入下一个时间片而再次唤醒而重新进入睡眠状态,这已经在其中解释了如何发生。我的问题。

Python的threading模块

正如我所说,在Python中,您必须在通知时按住锁。具有讽刺意味的是,内部实现不允许底层OS避免优先级倒置,因为它在等待者上强制执行FIFO顺序。当然,服务员的顺序是确定性的这一事实可能派上用场,但是问题仍然是为什么当有人认为区分锁和条件变量会更精确时,为什么要强制执行这种事情呢?一些需要优化并发性和最小阻塞的流,acquire()本身不应注册先前的等待状态,而应仅注册wait()自身。

可以说,Python程序员无论如何都不会关心这种程度的性能-尽管仍然不能回答为什么在实现标准库时不应允许几种标准行为的问题。

还有一点要说的是,由于threading某些原因,模块的开发人员可能特别想要FIFO指令,并发现这在某种程度上是实现它的最佳方法,并且希望以此为Condition代价来建立它。其他(可能更普遍)的方法。为此,他们值得怀疑的好处,直到他们自己解决。