0%

正确封装条件变量不容易

最近看《Linux高性能服务端编程》一书,书上对条件变量的封装比较有意思:
cond
作者通过这样奇怪的封装,将mutex隐藏到在cond类里边。但是,这样封装的条件变量是不能正常使用的。

mutex到底用来保护什么?

通常在程序里,我们使用条件变量来表示等待”某一条件”的发生。虽然名叫”条件变量”,但是它本身并不保存条件状态,本质上条件变量仅仅是一种通讯机制:当有一个线程在等待(pthread_cond_wait)某一条件变量的时候,会将当前的线程挂起,直到另外的线程发送信号(pthread_cond_signal)通知其解除阻塞状态。

由于要用额外的共享变量保存条件状态(这个变量可以是任何类型比如bool),由于这个变量会同时被不同的线程访问,因此需要一个额外的mutex保护它。

《Linux系统编程手册》也有这个问题的介绍:

A condition variable is always used in conjunction with a mutex. The mutex provides mutual exclusion for accessing the shared variable, while the condition variable is used to signal changes in the variable’s state.

条件变量总是结合mutex使用,条件变量就共享变量的状态改变发出通知,mutex就是用来保护这个共享变量的。

为什么pthread_cond_wait()需要mutex参数?

首先,我们使用条件变量的接口实现一个简单的生产者-消费者模型,avail就是保存条件状态的共享变量,它对生产者线程、消费者线程均可见。不考虑错误处理,先看生产者实现:

1
2
3
4
5
pthread_mutex_lock(&mutex);
avail++;
pthread_mutex_unlock(&mutex);

pthread_cond_signal(&cond); /* Wake sleeping consumer */

因为avail对两个线程都可见,因此对其修改均应该在mutex的保护之下,再来看消费者线程实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
for (;;)
{
pthread_mutex_lock(&mutex);
while (avail == 0)
pthread_cond_wait(&cond, &mutex);

while (avail > 0)
{
/* Do something */
avail--;
}
pthread_mutex_unlock(&mutex);
}

当”avail==0”时,消费者线程会阻塞在pthread_cond_wait()函数上。如果pthread_cond_wait()仅需要一个pthread_cond_t参数的话,此时mutex已经被锁,要是不先将mutex变量解锁,那么其他线程(如生产者线程)永远无法访问avail变量,也就无法继续生产,消费者线程会一直阻塞下去。因此pthread_cond_wait()需要传递给它一个pthread_mutex_t类型的变量。

pthread_cond_wait()函数首先大致会分为3个部分:

1.解锁互斥量mutex
2.阻塞调用线程,直到当前的条件变量收到通知
3.重新锁定互斥量mutex

其中1和2是原子操作,也就是说在pthread_cond_wait()调用线程陷入阻塞之前其他的线程无法获取当前的mutex,也就不能就该条件变量发出通知。

虚假唤醒

前面判断条件状态的时候avail > 0放在了while循环中,而不是if中,这是因为pthread_cond_wait()阻塞在条件变量上的时候,即使其他的线程没有就该条件变量发出通知(pthread_cond_signal()/pthread_cond_broadcast()),条件变量也有可能会自己醒来(pthread_cond_wait()函数返回),因此需要重新检查一下共享变量上的条件成不成立,确保条件变量是真的收到了通知,否则继续阻塞等待。关于虚假唤醒的相关介绍,可以戳这里查看维基百科下面的几个引用:https://en.wikipedia.org/wiki/Spurious_wakeup

开篇条件变量的封装的问题

结合前面的3节,我们总结了pthread_cond_wait()使用时,必须遵守的几个法则:

1.必须结合mutex使用,mutex用于保护共享变量,而不是保护pthread_cond_wait()的某些内部操作
2.mutex上锁后,才能调用pthread_cond_wait()
3.条件状态判断要放在while()循环里,然后再pthread_cond_wait()

如果这么使用,就不会再用错条件变量了。现在回到开篇的源码上,它是这么封装wait操作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class cond
{
public:
bool wait()
{
int ret = 0;
pthread_mutex_lock(&m_mutex);
ret = pthread_cond_wait(&m_cond, &m_mutex);
pthread_mutex_unlock(&m_mutex);
return ret == 0;
}

bool signal()
{
return pthread_cond_signal(&m_cond) == 0;
}

private:
pthread_mutex_t m_mutex;
pthread_cond_t m_cond;
};

这段代码大致上有2个问题,首先,wait()函数直接将mutex隐藏到其实现里边,这里的mutex完全没发挥作用,没有保护任何的东西,仅仅是为了适配pthread_cond_wait()接口。如果仅仅只有这个问题那也罢了,最多损失一下性能多几下mutex加锁解锁的消耗。

更糟糕的是,这会导致死锁,我们先来使用一下这里封装的条件变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
cond c;
bool flag = false;
std::mutex mutex;

void foo()
{
std::lock_guard<std::mutex> guard(mutex);
while(!flag)
c.wait();
}

void bar()
{
std::lock_guard<std::mutex> guard(mutex);
flag = true;
c.signal();
}

int main()
{
std::thread t1(foo);
std::thread t2(bar);

t1.join();
t2.join();
}

编译运行,程序死锁了,gdb attach上去看到了这样的堆栈:

deadlock

foo()此时阻塞在pthread_cond_wait()上,pthread_cond_wait()已经将条件变量内部封装的m_mutex解锁了,但是std::lock_guard维护的std::mutex还未解锁。再看bar()线程,此时它正阻塞在std::lock_guard上对std::mutex加锁(堆栈里看到在linux平台是std::mutex就是用POSIX mutex实现的)。于是这样就产生了死锁:

1.foo()阻塞等待bar()线程通知改变其所等待的条件变量
2.bar()在等foo()解锁std::mutex,以改变条件状态flag

总结

那么如何封装条件变量呢,可以参考C++11标准库的接口设计,将mutex放出来,在wait的时候当参数传入:https://en.cppreference.com/w/cpp/thread/condition_variable/wait,这里不多赘述了。