浅谈条件变量与互斥量的关系

这是五行代码思考一天的故事。

条件变量与互斥量的关系

1
2
3
4
5
(1)	pthread_mutex_lock(&mutex);
(2) while (未满足条件) // 使用while而非if是为了避免虚假唤醒
(3) pthread_cond_wait(&cond, &mutex);
(4) /*other code*/
(5) pthread_mutex_unlock(&mutex);

传递给pthread_cont_wait的互斥量对条件进行保护。调用者把锁住的互斥量传递给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量进行解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住。

——《UNIX环境高级编程》

让我们来思考下下面两个问题:

  1. Q:为什么条件变量要搭配互斥量一起使用?

    A:条件变量只是让线程睡眠(pthread_cond_wait),避免一直等待从而消耗CPU,然后由另一个线程发出信号(pthread_cond_signal或pthread_cond_boardcast),让其唤醒,条件变量本身不含所有任何条件和变量。条件的判断不在pthread_cond_wait这个函数中,而是在外面,例如上述代码中的第(2)行。这个条件通常是多个线程或进程的共享变量。为了防止多个线程或者进程产生竞争条件(rare condition),就必须使用互斥量来访问和修改共享变量。

  2. Q:为什么pthread_cond_wait函数中必须传递互斥量?

    A:首先要明白pthread_cond_wait这个函数函数干了哪些事情:

    1. 解锁互斥量,将调用函数的线程加入到等待队列中

    2. 在一个线程发出信号后(也就是条件成立时),唤醒等待队列中的某个线程。

    3. 与其他线程争抢mutex,然后重新锁定互斥量。

上述2、3步为原子操作,在调用这个函数时已经把互斥量锁定,要是想其他线程修改共享变量进而发送信号,就必须解锁。要是不传递互斥量,就必须手动解锁,但这就面临一个问题:

1
2
3
4
5
6
7
8
9
(1)	pthread_mutex_lock(&mutex);
(2) while (未满足条件)
(4) {
(5) pthread_mutex_unlock(&mutex);
(6) pthread_cond_wait(&cond);
(7) pthread_mutex_lock(&mutex);
(8) }
(9) /*other code*/
(10) pthread_mutex_unlock(&mutex);

在执行完第(5)行代码后,CPU执行到这里后,本线程暂时挂起,CPU转而去执行其他线程中代码,由于互斥量已经解锁,其他线程可以获取互斥量,并对互相变量进行修改,然后发送信号,但此时该条件变量的等待队列中还没有线程,就导致这个信号丢失了。pthread_cond_wait的函数就是将解锁互斥量将调用函数的线程加入到等待队列中这两个操作变为一个原子操作,不可再分割,从而消除条件满足和线程进入睡眠等待条件发生的时间间隙,不错过任何的条件变化。


简而言之:pthread_cond_wait()pthread_mutex_lock()配合的作用是:

mutex 的作用是避免判断条件时候发生竞态。而又因为条件存在未成立的可能,如果由于条件不成立而阻塞并持有mutex会大大占用CPU的资源,并且在大多数情况一个进程中只有一把mutex,这个时候pthread_cond_wait会释放mutex,并且会把当前执行线程加入到等待队列中进行休眠。其他线程,在这个时候可以争抢这个唯一的mutex

如果在生产者消费者模型中,若又是消费者抢到mutex继续上述过程,pthread_cond_wait释放mutex进入等待队列。反之,如果生产者抢到mutex,会往任务队列中添加任务,使得在pthread_cond_wait的等待队列上的线程条件成立。

在生产者完成任务后发送pthread_cond_signal再释放锁,这个时候可能出现三种情况:

  1. 又是生产者抢到锁,继续做生产者应该做的事。
  2. 消费者抢到锁,但是这个消费者可能不是进入pthread_cond_wait等待队列中的线程,而是其他消费者,这样就会出现虚假唤醒的情况,明明需要等待的条件成立了,但是因为没有抢到锁而不能执行下去。非等待队列上的这个消费者会消耗掉任务队列上的任务。
  3. 消费者抢到锁,这个消费者是pthread_cond_wait等待队列中的线程,这样就可以正常执行下去。

下面我们用三张图来说明上面一大串的文字

由于条件没有成立,提前来的消费者都乖乖的进入到pthread_cond_wait的等待队列中

生产者线程抢到mutex,首先向任务队列中添加任务,再给pthread_cond_wait阻塞队列发送signal

pthread_cond_wait阻塞队列中的线程3被唤醒但是没有抢到mutex,所以线程3被虚假唤醒了。线程4抢到了mutex,它来作为消费者处理task。

1
2
3
4
pthread_mutex_lock(&mutex)
修改互斥量保护的条件
pthread_cond_signal(&cond)
pthread_mutex_unlock(&mutex)

在某些线程的实现中,由于pthread_cond_signal会造成等待线程从内核中唤醒,然后又因为pthread_cond_wait要解锁互斥锁,但是这里还没有释放互斥锁,就导致等待线程又回到内核空间,线程刚要运行又要阻塞,一来一回会有性能问题。但是在Linux系统中,就不会有这个问题,因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列, pthread_cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。

1
2
3
4
pthread_mutex_lock(&mutex)
修改互斥量保护的条件
pthread_mutex_unlock(&mutex)
pthread_cond_signal(&cond)

优点:不会出现之前说的那个潜在的性能损耗,因为在 pthread_cond_signal之前就已经释放锁了。

缺点:有两种情况。

  1. 非等待线程有可能获取互斥锁,然后修改共享变量,本来共享变量已经满足条件,但现在不满足条件了,再释放互斥锁。这时调用pthread_cond_signal,等待线程苏醒时发现条件还是不满足,线程继续等待。这就是为什么唤醒的线程必须重新检查条件,不能仅仅因为pthread_cond_wait返回就认为条件满足。
  2. 非等待线程有可能获取互斥锁,,然后一直持有,等切CPU换到等待线程时要解锁时发现锁还被锁着,只能继续等待。