零、引子

1.创建多线程

在前几次学习中,我们都是创建一个子线程,其实多个子线程是比较常见的。一般在创建多个线程时,把thread对象放入到容器中管理,看起来像个thread对象的数组,对一次创建大量的线程并对大量线程进行管理有好处。下面是一个例子:

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
27
#include <iostream>
#include <thread>
#include <vector>
using namespace std;

void get_id(int i)
{
cout << "第" << i << "线程id为" << this_thread::get_id() << "开始执行" << endl;
cout << "第" << i << "线程id为" << this_thread::get_id() << "结束执行" << endl;
return;
}

int main()
{
vector<thread> a; // 定义一个动态数组容器
for (int i = 0; i < 10; ++i)
{
a.push_back(thread(get_id, i));
// 临时对象作为数组元素
}
for (auto iter = a.begin(); iter != a.end(); iter++)
{
iter->join();
// 迭代器遍历,子线程加入阻塞
}
return 0;
}

输出结果每次都不一样,而且顺序不一致,输出内容有残缺,表明:多个线程执行顺序是乱的,跟操作系统内部对线程的运行调度机制有关

2.共享数据(只读)

将以上程序中的get_id函数改为如下,且定义一个全局容器:

1
2
3
4
5
6
7
8
9
vector<int> i_v = { 1, 2, 3 }; // 这就是所谓的“共享数据”

void get_id(int i)
{
cout << "第" << i << "线程id为" << this_thread::get_id() << "开始执行" << endl;
cout << i_v[0] << i_v[1] << i_v[2] << endl;
cout << "第" << i << "线程id为" << this_thread::get_id() << "结束执行" << endl;
return;
}

虽然执行顺序是乱的,但是程序能正常运行,说明:子线程只读数据是安全稳定的,不需要什么特殊的处理,直接读就可以了。

如果多个子线程对共享数据的操作既有读又有写,那么若不加处理,程序就会崩溃。一个最简单的解决办法是读的时候不能写,写的时候不能读。事实上,这里涉及了一些经典的线程同步问题。

3.线程同步问题

在线程领域中有两个问题你不能不知道:生产者消费者问题读者写者问题。它们不仅常用于操作系统的课程设计,也常常在程序员和软件设计师考试中出现。并且在计算机考研的专业课考试中也是一个非常热门的问题。由于本人不是计算机专业的,本篇文章也不打算深究。感兴趣的可以上网搜搜。

为了解决以上问题,在多线程领域中引入了互斥量和互斥锁的概念。

一、互斥量

互斥锁是一个互斥的同步对象,意味着同一时间有且仅有一个线程可以获取它。互斥锁可适用于一个共享资源每次只能被一个线程访问的情况。

1.互斥锁mutex

C++引入了mutex,又称互斥量,与 mutex 相关的类(包括锁类型)和函数都声明在 头文件中,所以如果你需要使用std::mutex,就必须包含 头文件。

在C++官方参考网站(cppreference)中是这么描述的:

A mutex is a lockable object that is designed to signal when critical sections of code need exclusive access, preventing other threads with the same protection from executing concurrently and access the same memory locations.

mutex objects provide exclusive ownership and do not support recursivity (i.e., a thread shall not lock a mutex it already owns) – see recursive_mutex for an alternative class that does.

It is guaranteed to be a standard-layout class.

mutex类常用的两个成员函数是lock()和unlock(),分别用于加锁和解锁共享数据。多线程访问共享数据的主要思路是:A线程访问数据C,对其加锁,访问结束后解锁;B线程访问数据C,对其也加锁,访问结束后解锁。A线程无法访问被B线程加锁的数据,除非B线程访问结束后解锁;对于B线程也是如此。

下面是一个经过简化后的处理消息队列的程序,其定义了一个类,包含了两个成员函数,入队函数和出队函数(也是消息命令读取函数),这两个函数各处于两个子线程,共享同一个资源队列。如果不使用互斥锁,队列入队和出队的动作同时进行,程序肯定会崩溃。现在我们看看使用了互斥锁后的情况:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <iostream>
#include <list>
#include <thread>
#include <mutex> //需要包含头文件mutex
using namespace std;

class A {
public:
void inMsgRecvQueue(void)
{
for (int i = 0; i < 10000; ++i)
{
myMutex.lock(); //加锁
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i);
myMutex.unlock(); //解锁
}
return;
}

void outMsgRecvQueue(void)
{
int command = 0;
for (int i = 0; i < 10000; ++i)
{
myMutex.lock(); //加锁
if (!msgRecvQueue.empty()) //如果队列不为空
{
command = msgRecvQueue.front(); //代表执行了命令
msgRecvQueue.pop_front();
cout << "outMsgRecvQueue()执行,移除一个元素" << i << endl;
}
else {
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;
}
myMutex.unlock(); //解锁
}
return;
}

private:
list<int> msgRecvQueue;
mutex myMutex;
};

int main()
{
A myObja;
//成员函数加入线程
thread myOutMsgObj(&A::outMsgRecvQueue, &myObja);
thread myInMsgObj(&A::inMsgRecvQueue, &myObja);
myOutMsgObj.join();
myInMsgObj.join();
return 0;
}

该程序的输出结果将会是入队和出队消息轮换进行。需要注意以下几个点:

  • 如果涉及共享数据的程序都不用mutex,程序可能会在某个时刻崩溃。为什么说可能呢?因为会出现这么一种情况:A线程在访问数据的时候,B线程可能刚好没在访问,等到A访问完了,B线程恰好才去访问,这样看起来似乎程序不会崩溃,但是一旦下一次没有这么好运了,程序还是会崩溃。

  • 某线程什么时候能访问共享数据,什么时候不能,与操作系统的调度有关,也就是随机的。

  • lock()和unlock()的执行次数必须相等,否则程序运行会卡住。lock()执行次数多于unlock(),结果其他线程无法访问共享数据,这些线程就会出现一直等待的情况,程序会卡住;unlock()执行次数多于lock(),程序也会卡住。==【但在实际情况中,对于有些许规模的程序,lock()和unlock()的执行次数难以保证相等;这就跟指针类似,你也很难保证某一时刻内存空间被释放了。因此,与智能指针类似,C++引入了“智能锁”,下面会谈到】==

  • lock()和unlock()之间包含的内容要尽可能的少,最好只包含关于共享数据的语句。这是因为如果lock()和unlock()包含的语句过多,反而会影响执行效率。例如以上的程序,如果把锁住的内容扩大到循环体外面,那么入队消息函数要全部处理完后才能轮到出队消息函数执行,效率非常低。

2.lock_guard类模板

一般格式:lock_guard myGuard(myMutex);

lock_guard是一个类模板,它的内部结构很简单,只有构造函数和析构函数,所以也很容易理解它的工作原理,在实例化对象时通过构造函数实现了lock(),在析构函数中实现了unlock()的操作。这样就可以避免忘记unlock()的情况。

我们修改以上的程序,来具体说明如何使用:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <list>
#include <thread>
#include <mutex>
using namespace std;

class A {
public:
void inMsgRecvQueue(void)
{
for (int i = 0; i < 10000; ++i)
{
lock_guard<mutex> myGuard(myMutex); //这就是所谓的智能锁
//myMutex.lock();
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i);
//myMutex.unlock();
}
return;
}

void outMsgRecvQueue(void)
{
int command = 0;
for (int i = 0; i < 10000; ++i)
{
//myMutex.lock();
lock_guard<mutex> myGuard(myMutex);
if (!msgRecvQueue.empty())
{
command = msgRecvQueue.front(); //代表执行了命令
msgRecvQueue.pop_front();
cout << "outMsgRecvQueue()执行,移除一个元素" << i << endl;
}
else {
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;
}
//myMutex.unlock();
}
return;
}

private:
list<int> msgRecvQueue;
mutex myMutex;
};

int main()
{
A myObja;
thread myOutMsgObj(&A::outMsgRecvQueue, &myObja);
thread myInMsgObj(&A::inMsgRecvQueue, &myObja);
myOutMsgObj.join();
myInMsgObj.join();
return 0;
}

该程序的运行效果与上面的程序是一样的。

这个互斥锁对象的生命史在两个大括号之间({}),这意味着,{时lock(),}时unlock()。这样就实现了互斥锁的智能化。因此,为了灵活使用lock_guard,我们可以通过大括号来规定实现的范围:

1
2
3
4
5
6
{
std::lock_guard<std::mutex> mylockguard(mylock);
/*...
中间用来写需要加锁的内容
*/
}

二、死锁

1.死锁的形成

形成死锁至少要有两个互斥量mutex1和mutex2。那么死锁是怎么形成的呢?

  • 线程A执行时,这个线程先锁mutex1,并且锁成功了,然后去锁mutex2的时候,出现了上下文切换。

  • 线程B执行,这个线程先锁mutex2,因为mutex2没有被锁,即mutex2可以被锁成功,然后线程B要去锁mutex1。

  • 此时,死锁产生了,A锁着mutex1,需要锁mutex2,B锁着mutex2,需要锁mutex1,两个线程没办法继续运行下去。最后,程序卡住了!

一个经典的程序死锁情形如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
线程A的函数内:
//实际情况中,两个lock不一定挨着,这里只是演示是这样的一个顺序
myMutex2.lock();
myMutex1.lock();
/*...
中间用来写需要加锁的内容
*/
myMutex1.unlock();
myMutex2.unlock();

线程B的函数内:
myMutex1.lock();
myMutex2.lock();//A和B加锁的顺序不一样,导致死锁产生
/*...
中间用来写需要加锁的内容
*/
myMutex1.unlock(); //unlock的顺序与死锁的形成没有关系
myMutex2.unlock();

或者:

1
2
3
4
5
6
7
8
9
10
11
12
13
线程A的函数内:
lock_guard<mutex> myGuard1(myMutex2);
lock_guard<mutex> myGuard1(myMutex1);
/*...
中间用来写需要加锁的内容
*/

线程B的函数内:
lock_guard<mutex> myGuard2(myMutex1);
lock_guard<mutex> myGuard2(myMutex2);//A和B加锁的顺序不一样,导致死锁产生
/*...
中间用来写需要加锁的内容
*/

死锁的一般解决办法是:lock的顺序在每个线程中都保持一致,A线程顺序是lock1、lock2,那B线程也得lock1、lock2,否则就会出现死锁。

2.死锁的解决办法:std::lock()函数模板

格式:std::lock(mutex1,mutex2……);

工作方式:如果所有互斥量其中一个没锁住,它就等着,等所有互斥量都锁住,才能继续执行。如果有一个没锁住,就会把已经锁住的释放掉(要么互斥量都锁住,要么都没锁住,防止死锁)。

最直接的理解:这个函数的作用就是同时锁住多个锁,这样就不存在谁先锁谁后锁的问题了。

下面是一个正确的例子:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
#include <list>
#include <thread>
#include <mutex>
using namespace std;

class A {
public:
void inMsgRecvQueue(void)
{
for (int i = 0; i < 10000; ++i)
{
lock(myMutex1, myMutex2); //同时锁住两个锁
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i);
//依然需要一个一个unlock(),如果没写程序卡住
myMutex1.unlock();
myMutex2.unlock();
}
return;
}

void outMsgRecvQueue(void)
{
int command = 0;
for (int i = 0; i < 10000; ++i)
{
lock(myMutex1, myMutex2);
if (!msgRecvQueue.empty())
{
command = msgRecvQueue.front(); //代表执行了命令
msgRecvQueue.pop_front();
cout << "outMsgRecvQueue()执行,移除一个元素" << i << endl;
}
else {
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;
}
myMutex1.unlock();
myMutex2.unlock();
}
return;
}

private:
list<int> msgRecvQueue;
mutex myMutex1;
mutex myMutex2;
};

int main()
{
A myObja;
thread myOutMsgObj(&A::outMsgRecvQueue, &myObja);
thread myInMsgObj(&A::inMsgRecvQueue, &myObja);
myOutMsgObj.join();
myInMsgObj.join();
return 0;
}

这里出现了老生常谈的问题:使用了lock函数后,如果还是忘记unlock,该怎么办?lock_grand又上场了。

3.std::lock_guard的std::adopt_lock参数

格式:std::lock_guardstd::mutex my_guard(my_mutex, std::adopt_lock);

std::adopt_lock是结构体对象,起一个标记作用,表示这个互斥量已经lock(),不需要再lock()

下面是一个正确的例子:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <iostream>
#include <list>
#include <thread>
#include <mutex>
using namespace std;

class A {
public:
void inMsgRecvQueue(void)
{
for (int i = 0; i < 10000; ++i)
{
//表示已经lock过
lock_guard<mutex> myGuard1(myMutex1, adopt_lock);
lock_guard<mutex> myGuard2(myMutex2, adopt_lock);
//lock两个互斥量
lock(myMutex1, myMutex2);
cout << "inMsgRecvQueue()执行,插入一个元素" << i << endl;
msgRecvQueue.push_back(i);
//不需要再unlock了
}
return;
}

void outMsgRecvQueue(void)
{
int command = 0;
for (int i = 0; i < 10000; ++i)
{
lock_guard<mutex> myGuard1(myMutex1, adopt_lock);
lock_guard<mutex> myGuard2(myMutex2, adopt_lock);
lock(myMutex1, myMutex2);
if (!msgRecvQueue.empty())
{
command = msgRecvQueue.front(); //代表执行了命令
msgRecvQueue.pop_front();
cout << "outMsgRecvQueue()执行,移除一个元素" << i << endl;
}
else {
cout << "outMsgRecvQueue()执行,消息队列为空" << i << endl;
}
}
return;
}

private:
list<int> msgRecvQueue;
mutex myMutex1;
mutex myMutex2;
};

int main()
{
A myObja;
thread myOutMsgObj(&A::outMsgRecvQueue, &myObja);
thread myInMsgObj(&A::inMsgRecvQueue, &myObja);
myOutMsgObj.join();
myInMsgObj.join();
return 0;
}

程序正常运行,没有崩溃。