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

时间:2017-09-06 13:09:54

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

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

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

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

6 个答案:

答案 0 :(得分:4)

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

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

  

当调用此方法时,实现可能(通常会)要求当前线程保持与此Condition关联的锁。

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

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

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

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

  3. 可能会发生以下顺序:

    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(); 发生在notify()之前,那么 LC HC 之前就无法release()醒了。这是优先级倒置发生的地方。这是第二个论点。

    支持在锁之外通知的论点是高性能线程,其中一个线程不需要重新进入休眠状态,只是为了在下一次获取切片时再次唤醒 - 这已经解释了它是如何解决的可能会出现在我的问题中。

    Python的acquire()模块

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

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

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

答案 1 :(得分:2)

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

1。通知者需要锁定

假装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()是线程安全的要求,但不再需要锁定它来调用Condition 以及。< / p>

这样做仍然需要解释偏好,特别是考虑到(as you observed) CPython确实唤醒了通知的线程,让它切换到等待互斥锁(而不仅仅是{{ 3}})。

2。条件变量本身需要一个锁

notify()具有在并发等待/通知的情况下必须受到保护的内部数据。 (看了moving it to that wait queue,我发现两个不同步的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() 可能会错误地定位同一个等待线程,这可能会导致吞吐量降低甚至死锁。)它可以使用专用锁来保护数据,当然;因为我们已经需要一个用户可见的锁,所以使用它可以避免额外的同步成本。

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

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

box.val

假设FalsewaitFor(box,True,cv)而线程#1正在setSignal中等待。线程#2调用cv;当它释放waitFor(box,False,cv)时,#1仍然被阻止。线程#3然后调用box.val,发现Truenotify(),然后等待。然后#2调用def setTrue(box,cv): with cv: if not box.val: box.val=True cv.notify() ,唤醒#3,这仍然不满意并再次阻止。现在#1和#3都在等待,尽管其中一个必须满足条件。

waitFor

现在情况不会出现:#3在更新之前到达并且从不等待,或者它在更新期间或之后到达并且还没有等待,保证通知转到#1,从{{1}返回}。

4。硬件可能需要锁定

使用等待变形而没有GIL(在Python的某些备用或未来实现中),{{1}之后锁定释放所强加的内存排序( cf。 the CPython implementation)从notify()返回的锁定获取可能是通知线程的更新对等待线程可见的唯一保证。

5。实时系统可能需要它

在POSIX文字Java's rules之后我们you quoted

  

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

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

答案 2 :(得分:0)

  

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

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

答案 3 :(得分:0)

几个月前,我遇到了完全相同的问题。但是,自从ipython打开后,查看threading.Condition.wait??结果(方法的source)并不需要很长时间自己回答。

简而言之,wait方法创建另一个名为waiter的锁,获取它,将其附加到列表然后,出乎意料,释放锁定。之后,它再次获得了服务员,即它开始等待有人释放服务员。然后它再次获取锁定并返回。

notify方法从服务员列表中弹出服务员(服务员是一个锁,正如我们记得的那样)并释放它,允许相应的wait方法继续。

这就是诀窍是wait方法在等待notify方法释放服务员时没有对条件本身进行锁定。

UPD1 :我似乎误解了这个问题。在T2发布之前,T1可能会尝试重新获取锁定,这是否正确?

但是有可能在python的GIL环境中吗?或者您认为在释放条件之前可以插入IO调用,这将允许T1唤醒并永远等待?

答案 4 :(得分:0)

在Python 3文档https://docs.python.org/3/library/threading.html#condition-objects中对此进行了解释。

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

答案 5 :(得分:-2)

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

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

你是对的,如果在调用notify()时直接唤醒T1,可能会有一些低效率。但是,条件变量通常是通过OS原语实现的,并且操作系统通常足够聪明,可以意识到T2仍然具有锁定功能,因此它不会立即唤醒T1,而是将其排队等待唤醒。

此外,在python中,这并不重要,因为由于GIL只有一个线程,所以线程无法同时运行。

此外,最好使用以下表格,而不是直接调用获取/发布:

with cv:
    cv.wait()

with cv:
    cv.notify()

这确保即使发生异常也会释放基础锁。