架构师大神那些年踩过的C++的坑


架构师大神那些年踩过的C++的坑


摘要


C++11标准从发布到现在已经快10年了。笔者在工作中陆陆续续学习并应用了移动语义(move semantics),智能指针(unique_ptr<>, shared_ptr<>),lamda等C++11的新特性。总体感觉还是真香。最近因为项目开发,要搭建多线程的自动化测试,于是尝试使用了条件变量(conditional variable)来协调不同线程的进度。在这个过程中踩了一些坑,想记录下来跟大家一起学习。




问题与背景


这个自动化的测试里面有一组工作线程负责处理数据。开始时,它们处于等待状态。当另一组线程把数据准备好了以后,它们就开始处理数据。处理完毕后,各自完成剩下的任务,进程结束。为了方便演示,笔者把这个过程简化为如下代码:

<code>```cpp
#include <iostream>
#include <mutex>
#include <thread>
using namespace std;

mutex aMutex;

void PrintString(const char* s)
{
unique_lock<mutex> lock(aMutex);
cout << s << endl;
}

void ProcessData()
{
PrintString("Waiting for data...");
PrintString("Got data. Processing data...");
}

void PrepareData()
{
PrintString("Preparing data...");
PrintString("Data is ready!");
}

int main()
{
cout << "Start!" << endl;

thread dataProcessor(ProcessData);
thread dataProducer(PrepareData);

dataProcessor.join();
dataProducer.join();

cout << "Finished!" << endl;
}
```/<mutex>/<thread>/<mutex>/<iostream>/<code>


其中,线程dataProcessor负责处理数据而dataProducer负责产生数据。因为没有机制去协调两个线程,程序的输出是随机的。在笔者编译运行时,能看到输出如下:


Start!

Waiting for data...

Got data. Processing data...

Preparing data...

Data is ready!

Finished!


从打印的结果可以看到,dataProcessor在dataProducer把数据准备好之前就开始处理数据了。这是不合理的,所以我们要引入机制——条件变量来协调两者的执行顺序,从而解决这个问题。




第一次尝试


先来看下cplusplus.com对于条件变量的说明:


Condition variable

A condition variable is an object able to block the calling thread until notified to resume.

It uses a unique_lock (over a mutex) to lock the thread when one of its wait functions is called. The thread remains blocked until woken up by another thread that calls a notification function on the same condition_variable object.


总结起来两点就是:

1. 条件变量可以挂起当前线程,直到收到其他线程的通知。

2. 它需要同锁配合使用。


听上去非常适合解决现在的问题。笔者查询了一下条件变量的基本使用,很快就把它应用到程序中:

<code>```cpp
...
condition_variable condVar; // (1)
...

void ProcessData()
{
PrintString("Waiting for data...");
{
unique_lock<mutex> lock(aMutex); // (2)
condVar.wait(lock);
}
PrintString("Got data. Processing data...");
}

void PrepareData()
{
PrintString("Preparing data...");
PrintString("Data is ready!");
condVar.notify_one(); // (3)
}
```/<mutex>/<code>


程序主体没有大的变化,主要的改动是:

1. 新定义了一个全局的条件变量conVar。

2. 在ProcessData中调用条件变量的wait方法,进入等待状态。

3. 在PrepareData中,在数据生成完毕后,调用条件变量的notify_one方法,来让ProcessData继续。


编译执行,输出如下:


Waiting for data...

Preparing data...

Data is ready!

Got data. Processing data...

Finished!




第一个坑


看上去很完美。但是多测试几次,马上就发现有的时候程序没法结束。Debug了一下,原来是dataProcessor卡在了条件变量的wait方法那里。这是怎么回事呢?
笔者稍微分析了下,感觉多半是因为dataProducer的调用notify_one发生在dataProcessor调用wait之前。这就导致dataProducer的notify_one没有起到效果,然后dataProcessor陷入了深深的睡眠之中……
一个显而易见的解决思路是让dataProducer执行的慢一点:

<code>```cpp
void PrepareData()
{
PrintString("Preparing data...");
this_thread::sleep_for(5s);
PrintString("Data is ready!");
condVar.notify_one();
}
```/<code>


加入5秒的睡眠确实可以达到目的,但是效率上面让人不是很舒服。




第二次尝试


在网上查了一下,发现碰到这个问题人不少,而解决方案也不是什么其他的黑科技,就是使用wait的另一个带条件判断的重载:

<code>```cpp
template <class>
void wait (unique_lock<mutex>& lck, Predicate pred);
```/<mutex>/<class>/<code>


现在可以总结一下wait-notify的工作流程了。


当程序进入wait调用时,

1. 传入的第一个参数-互斥锁会被锁上。

2. 然后检查传入的第二参数-条件谓词。如果条件为

* true:直接返回,当前线程继续工作。

* false:解开互斥锁,当前线程进入挂起状态。


当在挂起状态的条件变量收到唤醒通知时,

1. 进程被唤醒并尝试获取互斥锁。

2. 检查条件谓词。如果条件为

* true:直接返回,当前线程继续工作。

* false:解开互斥锁,当前线程进入挂起状态。


是不是头晕了?知道它为什么叫条件变量了吗?这东西用起来真的是有点复杂。但是没办法,现实就是这么的残酷:)


用了带条件判断的wait,代码看起来大概是这个样子:

<code>```cpp
...
bool isDataReady = false;
...


void ProcessData()
{
PrintString("Waiting for data...");
{
unique_lock<mutex> lock(aMutex);
condVar.wait(lock, [] { return isDataReady; });
}
PrintString("Got data. Processing data...");
}

void PrepareData()
{
PrintString("Preparing data...");
isDataReady = true;
PrintString("Data is ready!");
condVar.notify_one();
}
```/<mutex>/<code>




第二个坑


以为这下应该完美了,但是经过测试发现,程序还是有极小的概率出现dataProcessor卡在wait那里。到底什么地方还藏着坑呢?
在仔细检查两个线程的整个流程后,终于发现了问题所在——在PrepareData里面isDataReady的修改没有被正确的同步。这句话是什么意思呢?要解释清楚的话,先来看下条件变量的带谓词条件判断的wait里面具体做些什么事。根据前面的分析,它的实现大概是长这个样子:

<code>```cpp
template <class>
void wait(unique_lock<mutex>& lck, Predicate pred)
{
while (!pred())
{
wait(lck);
}
}
```/<mutex>/<class>/<code>


所以

<code>```cpp
void ProcessData()
{
PrintString("Waiting for data...");
{
unique_lock<mutex> lock(aMutex);
condVar.wait(lock, [] { return isDataReady; });
}
PrintString("Got data. Processing data...");
}
```/<mutex>/<code>


等价于

<code>```cpp
void ProcessData()
{
PrintString("Waiting for data...");
{
unique_lock<mutex> lock(aMutex);
while (![] { return isDataReady; }())
{
//
condVar.wait(lock);
}
}
PrintString("Got data. Processing data...");
}
```/<mutex>/<code>


在这里有个关键的时间窗口(已经标在上面了)。如果在这个窗口里面,dataProducer把isDataReady的值修改成true并且完成notify_one的调用,那么dataProcessor仍然会错过通知,陷入深深的睡眠……




第三次尝试


既然这个坑的情况已经清楚了,那就来把它填上吧。解决方案就是把对isDataReady的修改,放在互斥锁的范围里面,即:

<code>```cpp
void PrepareData()
{
PrintString("Preparing data...");
{
unique_lock<mutex> lock(aMutex);
isDataReady = true;
}
PrintString("Data is ready!");
condVar.notify_one();
}
```/<mutex>/<code>


注意,你可以把condVar.notify_one()也放在锁的范围内,但这不是必须的。


有了这个保护后,dataProducer对isDataReady的修改不会和dataProcessor调用wait同时发生。如果dataProducer对isDataReady的修改先发生,则dataProducer不会进入等待,因为谓词条件检查结果是false;反之,dataProducer不会错过来自dataProducer的通知。这样一来,所有的坑终于都被填上了。




总结


通过这一番学习,可以看到条件变量使用起来虽然比较灵活,但是要完全把它用对也颇有难度。那么有没有更简单的方法呢?在选择条件变量作为同步机制的前提下,目前我没有发现更简单的方法。但是你也许可以试一下future和promise(C++的另一种线程同步机制),个人认为在这个使用场景下会少踩很多坑。


我们会每周推送商业智能、数据分析资讯、技术干货和程序员日常生活,欢迎关注我们的头条&知乎公众号“

微策略中国”或微信公众号“微策略 商业智能"。


分享到:


相關文章: