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

新手上路,请多包涵

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

当线程 T1 有这段代码时:

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

和线程 T2 有这个代码:

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

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

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

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

原文由 Yam Marcovic 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 773
2 个回答

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

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

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

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

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

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

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

可能会出现以下顺序:

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

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

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

原文由 Yam Marcovic 发布,翻译遵循 CC BY-SA 3.0 许可协议

有几个令人信服的理由(综合考虑)。

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)

This fails because both the push() and the notifyUnlocked() can intervene between the if qu: and the 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 确实唤醒了通知线程以使其切换到等待互斥锁(而不是简单地 将其移动到该等待队列)。

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()

假设 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,它从 waitFor 返回.

4.硬件可能需要锁

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

5.实时系统可能需要它

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

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

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

原文由 Davis Herring 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题