【问题标题】:Why does Python threading.Condition() notify() require a lock?为什么 Python threading.Condition() notify() 需要锁?
【发布时间】:2018-02-15 00:02:36
【问题描述】:

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

当线程 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 语句,以便通过显式调用更好地说明比赛。

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

【问题讨论】:

  • pthread_cond_signal(3) 可能会提供信息
  • @o11c 确实,谢谢,问题是:The pthread_cond_broadcast() or pthread_cond_signal() functions may be called by a thread whether or not it currently owns the mutex that threads calling pthread_cond_wait() or pthread_cond_timedwait() have associated with the condition variable during their waits
  • 那么 python 又被过度限制了。诚然,您通常希望即使在 C 中也能持有锁。
  • 通过快速查看其他语言的同步原语,这个要求在Java和C#中也存在,但在pthreads、Windows条件变量或c++11条件变量中不存在。您通常希望为notify 与其他线程相关的任何内容持有锁,但为notify 本身持有锁的要求似乎没有必要。它可能是从需要锁来保护条件变量本身的历史设计中复制而来的,或者它可能被认为促进更安全的锁使用。
  • 如果您不必考虑其他线程在您通知的内容和实际的通知调用之间进行调解,那么推理正确性肯定会更容易。特别是对于像 Python 这样的语言,仅出于这个原因,我就会支持这个设计决策。我怀疑实际的历史原因是他们复制了 Java 所做的事情。文档确实提到 threading 模块是基于 Java 的设计。

标签: python multithreading python-3.x race-condition condition-variable


【解决方案1】:

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

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

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

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

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

  1. 从服务员acquire()s 锁定的那一刻起——也就是说,在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() 之前,LCHC 被唤醒之前就无法acquire()。这是发生优先级反转的地方。这是第二个论点。

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

Python 的threading 模块

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

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

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

【讨论】:

    【解决方案2】:

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

    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)
    

    这失败了,因为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()
    

    works(这是一个有趣的练习来演示)。第二种形式的优点是取消了qu 是线程安全的要求,但在调用notify() 时不需要更多的锁

    仍然需要解释这样做的偏好,特别是考虑到(as you observed) CPython 确实唤醒了通知线程以使其切换到等待互斥体(而不是简单地moving it to that wait queue) .

    2。条件变量本身需要加锁

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

    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 的某些替代或未来实现中),notify() 和锁之后的锁释放强加的内存排序 (cf. Java's rules) -acquire on return from wait() 可能是通知线程的更新对等待线程可见的唯一保证。

    5。实时系统可能需要它

    紧跟在 POSIX 文本 you quoted 我们 find 之后:

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

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

    【讨论】:

    • 感谢您的详细解答。我不明白你的第一个论点中的第二个例子。除此之外,我认为第一个很容易通过在锁定时推动来解决。至于第二个参数,它假定 Python 的特定设计决策,我们知道它可以以其他方式实现。至于第三个论点,当然,但这是一个边缘情况,并没有解释为什么要强制执行该流程。至于第四个论点,A)我说的是在发布操作后通知; B) 无论如何都可以插入屏障。至于第 5 点,Python 的实现无论如何都阻止了 RT 考虑(优先级反转)。
    • #1: lockedPush 正是您的建议。 #2 和 #5:要求为其他实现留出了空间。 #3:我确实说过你必须同时考虑所有这些。 #4: A) CV 的状态必须是可见的; B)那将是更多的同步。
    【解决方案3】:

    T1 等待并释放锁,然后 T2 获取它,通知 cv 唤醒 T1。

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

    【讨论】:

    • 这是不正确的。首先,我的陈述是准确的,我刚刚通过修改线程模块并测试时间和内部唤醒来验证它。其次,我认为你对它如何工作的概念是错误的。它的工作方式是每个等待者创建自己的互斥体并将其排入条件的等待者队列,每个 notify() 将一个出列并释放其互斥体,有效地唤醒另一个线程上的 wait()。但是随后又重新获得了条件本身的锁定。同样,这就是问题所在。
    • 顺便说一下,我的猜测是,他们需要在通知时获取条件锁,因为这样设计很方便,这样两个通知者都可以安全地访问共享队列和服务员。但同样,这种便利是有代价的。这是我试图探索的基本原理。
    • @YamMarcovic,对不起,我没有仔细阅读您的问题。我以为你问的是程序员的模型(即如何理解和使用条件变量。)我没有意识到你问的是实现
    【解决方案4】:

    几个月前,我也想到了同样的问题。但由于我打开了ipython,查看threading.Condition.wait?? 的结果(方法的source)并没有花很长时间自己回答。

    简而言之,wait 方法创建了另一个名为 waiter 的锁,获取它,将其附加到一个列表中,然后,令人惊讶的是,它自己释放了锁。之后它再次获取服务员,即开始等待,直到有人释放服务员。然后它再次获取自己的锁并返回。

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

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

    UPD1:我似乎误解了这个问题。您担心 T1 可能会在 T2 释放之前尝试重新获取自己的锁,这是否正确?

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

    【讨论】:

    • 是的,你误解了这个问题。 :) 你更新的理解是正确的。是的,我不明白为什么 GIL 会在这里干扰任何事情,因为这是一个调度问题,并且也可能发生在单核系统上。
    【解决方案5】:

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

    注意:notify() 和 notify_all() 方法不会释放锁;这意味着被唤醒的线程不会立即从它们的 wait() 调用中返回,而只会在调用 notify() 或 notify_all() 的线程最终放弃锁的所有权时返回。

    【讨论】:

      【解决方案6】:

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

      当调用 wait() 时,底层锁将被释放,直到发生通知。保证wait的调用者会在函数返回之前重新获取锁(例如,在等待完成之后)。

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

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


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

      with cv:
          cv.wait()
      

      还有:

      with cv:
          cv.notify()
      

      这样可以确保即使发生异常也会释放底层锁。

      【讨论】:

      • 我不明白:我演示了我所说的竞争条件。这不是条件变量的工作方式,而是 Python 的强制执行。而且,在 Python 中没有一个线程,只有一个解释线程。比赛甚至在那里也适用,因为这是一场日程安排比赛。而且我看不出操作系统如何知道这一点,因为它是用户空间行为,可以以任何一种方式工作。
      猜你喜欢
      • 2013-06-12
      • 2021-12-23
      • 1970-01-01
      • 2016-01-14
      • 2011-03-16
      • 1970-01-01
      • 2020-10-19
      • 2018-02-15
      • 1970-01-01
      相关资源
      最近更新 更多