何时需要条件变量,互斥锁不够吗?

84

我相信互斥锁并不足够,这就是条件变量存在的原因;但我还没有能够用具体场景说服自己必须要用条件变量。

条件变量、互斥锁和锁之间的区别问题的被接受答案指出,条件变量是带有“信号”机制的锁。它在线程需要等待资源可用时使用。一个线程可以"等待"一个条件变量,然后资源生成者可以"signal"这个变量,此时等待条件变量的线程会得到通知并且可以继续执行。

让我感到困惑的是,一个线程也可以等待互斥锁,在它被唤醒后,变量就变为可用了,那我为什么需要一个条件变量呢?

P.S.: 而且,无论如何都需要一个互斥锁来保护条件变量,这让我更难以理解条件变量的作用。


4
你应该阅读有关“忙等待”的内容。基本上,通过使用条件等待,你可以释放一个线程以避免进一步的不必要计算。 - none
http://stackoverflow.com/a/11560110/183120清楚地解释了互斥锁和二进制信号量之间的区别,后者类似于条件变量。 - legends2k
一个线程也可以在互斥锁上等待,这是错误的。线程只能在互斥锁上阻塞,不能“等待”,因为它不会在释放时得到通知。条件变量恰好就是为此而需要的。很抱歉,我发现这一点从下面给出的答案中并不明显。 - Slava
8个回答

46
尽管你可以按照你所描述的方式使用它们,但互斥锁并不是为了作为通知/同步机制而设计的。 它们的目的是提供对共享资源的互斥访问。 使用互斥锁来发出条件信号很麻烦,我想会像这样(其中Thread2向Thread1发出信号):

Thread1:

while(1) {
    lock(mutex); // Blocks waiting for notification from Thread2
    ... // do work after notification is received
    unlock(mutex); // Tells Thread2 we are done
}

线程2:

while(1) {
    ... // do the work that precedes notification
    unlock(mutex); // unblocks Thread1
    lock(mutex); // lock the mutex so Thread1 will block again
}

这段代码存在几个问题:

  1. Thread2无法继续执行“在通知之前进行的工作”,直到Thread1完成了“通知之后的工作”。按照这种设计,Thread2甚至是不必要的。也就是说,为什么不将“在通知之前”的工作和“通知之后”的工作移到同一个线程中,因为在任何给定时间只有一个线程可以运行!
  2. 如果Thread2无法抢占Thread1,则当Thread1重复while(1)循环并重新锁定互斥体时,Thread1将立即重新锁定互斥体,并开始执行“通知之后的工作”,即使没有通知。这意味着您必须以某种方式保证Thread2在Thread1之前锁定互斥体。如何做到这一点?也许通过睡眠或其他特定于操作系统的手段强制进行调度事件,但这甚至也不能保证能够解决问题,这取决于时间、操作系统和调度算法。

这两个问题都不是小问题,事实上,它们都是主要的设计缺陷和潜在的错误。这两个问题的根源在于互斥体需要在同一个线程中锁定和解锁。那么如何避免以上问题呢?使用条件变量!

顺便说一句,如果您的同步需求确实很简单,您可以使用普通的信号量,这样可以避免使用条件变量所带来的额外复杂性。


12
感谢您详细的回复。我试着用互斥锁代替条件变量,并发现如果 T1 在 T2 之前获得锁,等待 T2 的信号的整个意义就消失了,T1 最终会得到一个错误的资源。因此,条件变量用于发出“嘿,我做完了”的信号,而互斥锁用于“你先还是我先?”- 条件变量 = T1 依赖于 T2,互斥锁 = T1 与 T2 竞争。 - legends2k
2
没错,我的第二个问题描述中提到的“坏资源”就是你所说的“即使没有通知T1仍然运行”的情况。你把它称为“坏资源”更加恰当。 - slowjelj

13

互斥锁是用于访问共享资源的独占锁,而条件变量是等待某个条件成立的锁。它们是两种不同类型的内核资源。一些人可能认为可以通过互斥锁自己实现条件变量,一个常见的模式是“标志 + 互斥锁”:

lock(mutex)

while (!flag) {
    sleep(100);
}

unlock(mutex)

do_something_on_flag_set();

但这是行不通的,因为您在等待期间从未释放互斥锁,没有其他人可以以线程安全的方式设置标志。这就是我们需要内核支持条件变量的原因,因此当您在等待条件变量时,关联的互斥锁不会被您的线程保持,直到它被信号通知。


C++有原子变量。 - NoSenseEtAl

8

我也在思考这个问题,我认为到处都缺少的最重要信息是互斥锁只能由一个线程拥有(或更改)。因此,如果你只有一个生产者和多个消费者,则生产者必须在互斥锁上等待以进行生产。而使用条件变量,则可以随时进行生产。


5
您需要条件变量,与互斥锁一起使用(每个条件变量属于一个互斥锁),以便从一个线程向另一个线程发出信号来改变状态(条件)。其思想是一个线程可以等待某些条件成立。这些条件是特定于程序的(例如,“队列为空”,“矩阵很大”,“某些资源已经耗尽”,“某些计算步骤已完成”等)。一个互斥锁可能有几个相关的条件变量。您需要条件变量,因为这些条件可能并不总是像“互斥锁被锁定”那样简单(因此您需要广播条件的更改给其他线程)。
阅读一些好的posix线程教程,例如这个教程那个那个。更好的方法是阅读一本好的pthread书籍。请参见这个问题
还要阅读高级Unix编程高级Linux编程 附注:并行性和线程是难以理解的概念。花时间阅读,实践,再次阅读。

2
我认为问题实际上是“为什么不能只等待互斥锁解锁,而不使用条件变量”。 - CharlesB
我之前错过了这个答案,但现在阅读它时感觉很有道理。感谢您的教程和答案! - legends2k

3
条件变量和互斥锁可以被二元信号量和互斥锁替代。使用条件变量+互斥锁的消费者线程操作顺序为:
  1. 锁定互斥锁
  2. 等待条件变量
  3. 处理
  4. 解锁互斥锁
生产者线程的操作顺序是:
  1. 锁定互斥锁
  2. 发出条件变量信号
  3. 解锁互斥锁
当使用sema+mutex对应的消费者线程操作顺序为:
  1. 等待二元信号量
  2. 锁定互斥锁
  3. 检查预期条件
  4. 若条件为真,则进行处理
  5. 解锁互斥锁
  6. 如果步骤3中的条件检查为false,则返回步骤1。
生产者线程的操作顺序为:
  1. 锁定互斥锁
  2. 发送二元信号量
  3. 解锁互斥锁
可以看到,当使用条件变量时,第三步的无条件处理被条件处理的步骤3和步骤4所取代。原因是,在使用sema+mutex时,在竞态条件下,另一个消费者线程可能会在步骤1和2之间潜入并处理/清空条件。当使用条件变量时,不会发生这种情况。使用条件变量时,保证在第2步之后条件为真。
二元信号量可以用普通计数信号量替换。这可能会导致步骤6到步骤1的循环执行几次。

使用条件变量时,在步骤2之后条件保证为真,这是错误的,至少由于虚假唤醒 - max

0
Slowjelj说得没错,但为了解决这个问题,看一下下面的Python代码。我们有一个缓冲区、一个生产者和一个消费者。想一想,如果你能只用互斥锁重写它呢?
import threading, time, random
cv = threading.Condition()
buffer = []
MAX = 3

def put(value):    
    cv.acquire()
    while len(buffer) == MAX:            
        cv.wait()        
    buffer.append(value)
    print("added value ", value, "length =", len(buffer))
    cv.notify()
    cv.release()

def get():    
    cv.acquire()
    while len(buffer) == 0:            
        cv.wait()        
    value = buffer.pop()
    print("removed value ", value, "length =", len(buffer))
    cv.notify()
    cv.release()

def producer():
    while True:
        put(0) # it doesn't mater what is the value in our example
        time.sleep(random.random()/10)

def consumer():
    while True:
        get()
        time.sleep(random.random()/10)

if __name__ == '__main__':
    cs = threading.Thread(target=consumer)    
    pd = threading.Thread(target=producer)
    cs.start()            
    pd.start()
    cs.join()
    pd.join()

-1

依我之见,或许你可以使用 两个互斥锁 来实现 互斥锁 + 条件变量

以下是具体步骤:

  • pthread_cond_wait(&cond_var, &mutex) 替换为
pthread_mutex_unlock(&mutex);
pthread_mutex_trylock(&mutex_new);
pthread_mutex_lock(&mutex_new);
pthread_mutex_lock(&mutex);

用以下代码替换pthread_cond_signal(&cond_var)
pthread_mutex_unlock(&mutex_new);

但是还有一个问题,也许制造商在其中做了一些“信号”方面的事情。
pthread_mutex_unlock(&mutex);
producer signal!
pthread_mutex_trylock(&mutex_new);

然后消费者永远不会醒来,但是“条件变量”不会让这种情况发生。

-1

我认为这是实现定义的。

互斥锁是否足够取决于你是否将互斥锁视为临界区的机制或其他更多的东西。

正如在http://en.cppreference.com/w/cpp/thread/mutex/unlock中提到的:

互斥锁必须由当前执行线程锁定,否则行为是未定义的。

这意味着在C++中,一个线程只能解锁自己锁定/拥有的互斥锁。
但在其他编程语言中,你可能可以在进程之间共享互斥锁。

因此,区分这两个概念可能只是性能考虑,对于简单应用程序来说,复杂的所有权识别或进程间共享并不值得。


例如,您可以通过添加一个额外的互斥锁(可能是不正确的修复)来解决@slowjelj的情况:
Thread1:
lock(mutex0);
while(1) {
    lock(mutex0); // Blocks waiting for notification from Thread2
    ... // do work after notification is received
    unlock(mutex1); // Tells Thread2 we are done
}

线程2:

while(1) {
    lock(mutex1); // lock the mutex so Thread1 will block again
    ... // do the work that precedes notification
    unlock(mutex0); // unblocks Thread1
}

但是你的程序会抱怨你触发了编译器留下的断言(例如,在Visual Studio 2015中的“解锁未拥有的互斥体”)。


你的程序不应该依赖于未定义的行为。如果操作系统更新、编译器更新、编译参数更改或代码的某个部分移动到不同的文件中,行为可能会发生变化。 - VLL
你提到了其他编程语言,但是你在Visual Studio中编译你的代码示例,所以我推断它是C++。然而这段代码在C++中永远不会工作,因为标准禁止这样做。 - VLL

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接