Shaoqun Liu's blog
搜索文档…
C++11锁

0x00 前言

下面是我从C++11之多线程(二、互斥对象和锁)上找的一段代码
1
std::set<int> int_set;
2
auto f = [&int_set]() {
3
try {
4
std::random_device rd;
5
std::mt19937 gen(rd());
6
std::uniform_int_distribution<> dis(1, 1000);
7
for(std::size_t i = 0; i != 100000; ++i) {
8
int_set.insert(dis(gen));
9
}
10
} catch(...) {}
11
};
12
std::thread td1(f), td2(f);
13
td1.join();
14
td2.join();
Copied!
由于std::set::insert不是多线程安全的,多个线程同时对同一个对象调用insert其行为是未定义的(通常导致的结果是程序崩溃)。因此需要一种机制在此处对多个线程进行同步,保证任一时刻至多有一个线程在调用insert函数。

0x01 锁的使用

下面的这段代码,是我在上一篇博客中所写的多线程中进行异常处理的一种方式,就是把所有的异常全部放在一个vector里面,我们需要确保在同一时刻只有一个线程对vector进行插入操作,所以我们必须为其加上一个锁,锁这个东西,依据我个人的理解,是一种互斥关系,有一个线程创建了这个互斥关系,那么当第二个线程再去创建同样的互斥关系的时候就会受到阻塞,就需要等待当前持有锁的线程来解锁,然后继续访问临界资源。
1
#include <iostream>
2
#include <thread>
3
#include <vector>
4
#include <mutex>
5
6
std::vector<std::exception> exptr;
7
std::mutex mut;
8
9
void func()
10
{
11
// 加锁
12
std::lock_guard<std::mutex> lock(mut);
13
exptr.push_back(std::exception{ "ERROR!" });
14
}
15
16
int main()
17
{
18
std::thread thd1(func);
19
thd1.detach();
20
std::thread thd2(func);
21
thd2.detach();
22
for(auto & e : exptr)
23
{
24
std::cout << e.what() << std::endl;
25
}
26
system("pause");
27
return 0;
28
}
Copied!
通过上面的代码来简单介绍一下锁的使用,在C++11中的mutex头文件中定义了四种锁:
    mutex:提供了核心的 lock()unlock() 方法,用来加解锁,以及当 mutex 不可用时就会返回的非阻塞方法 try_lock()
    recursive_mutex:依据名字可以看出这是递归锁,就是允许同一个线程对锁进行多重持有,多用于线程函数需要进行递归操作的情况
    timed_mutex:时间锁,可以使用函数try_lock_for()try_lock_until()来在特定的时长内持有mutex或持有锁到某个特定的时间点
    recursive_timed_mutexrecursive_mutextimed_mutex 的结合

0x02 std::mutex的使用

通过两个程序来演示一下用锁和不用锁的区别:
不用锁的情况:
1
#include <iostream>
2
#include <thread>
3
#include <mutex>
4
5
void fun()
6
{
7
std::cout << "Enter thread: " << std::this_thread::get_id() << std::endl;
8
std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 1000));
9
std::cout << "Exit thread: " << std::this_thread::get_id() << std::endl;
10
}
11
12
int main()
13
{
14
srand(time(nullptr));
15
std::thread t1(fun);
16
std::thread t2(fun);
17
std::thread t3(fun);
18
t1.join();
19
t2.join();
20
t3.join();
21
system("pause");
22
return 0;
23
}
Copied!
程序输出:
1
Enter thread: 9012
2
Enter thread: 19044
3
Enter thread: 20336
4
Exit thread: 9012
5
Exit thread: 19044
6
Exit thread: 20336
Copied!
使用锁的情况:
1
#include <iostream>
2
#include <thread>
3
#include <mutex>
4
5
std::mutex mtx;
6
7
void fun()
8
{
9
mtx.lock();
10
std::cout << "Enter thread: " << std::this_thread::get_id() << std::endl;
11
std::this_thread::sleep_for(std::chrono::milliseconds(rand() % 1000));
12
std::cout << "Exit thread: " << std::this_thread::get_id() << std::endl;
13
mtx.unlock();
14
}
15
16
int main()
17
{
18
srand(time(nullptr));
19
std::thread t1(fun);
20
std::thread t2(fun);
21
std::thread t3(fun);
22
t1.join();
23
t2.join();
24
t3.join();
25
system("pause");
26
return 0;
27
}
Copied!
程序输出:
1
Enter thread: 18484
2
Exit thread: 18484
3
Enter thread: 12256
4
Exit thread: 12256
5
Enter thread: 18572
6
Exit thread: 18572
Copied!
通过使用锁的示例我们可以看到,使用锁之后其他线程必需在等待锁释放之后才能调用线程函数,否则线程函数就一直处于阻塞状态。当占有锁的线程释放锁的时候,其他线程才有可能进入临界区。

如果将锁的定义放在线程函数fun()里面会怎么样呢?

如果将锁的定义放在线程函数fun()里面会怎么样呢?通过试验我们得知,如果将锁的定义放在线程函数里面的话,程序的输出结果会和不使用锁的情况是一样的。这是为什么呢,因为三个线程创建了三把锁,三把不一样的锁,然后各自加锁各自解锁,互不干涉。反观在全局变量中使用锁的情况,三个线程使用了同一把锁,所以才能正确地锁住线程。

0x03 递归锁recursive_mutex

在我想这个递归锁的使用示例的时候,为了比较递归锁和普通mutex的区别,我设计了一个求斐波那契数列的算法,所编制的代码如下:
1
#include <iostream>
2
#include <thread>
3
#include <mutex>
4
#include <set>
5
6
std::mutex mtx;
7
std::set<int> g_vecFib;
8
9
int Fibonacci(int n)
10
{
11
mtx.lock();
12
int ret{ n <= 1 ? n : Fibonacci(n - 1) + Fibonacci(n - 2) };
13
g_vecFib.insert(ret);
14
mtx.unlock();
15
return ret;
16
}
17
18
int main()
19
{
20
srand(time(nullptr));
21
std::thread thd(Fibonacci, 10);
22
thd.join();
23
for (auto i : g_vecFib)
24
std::cout << i << std::endl;
25
system("pause");
26
return 0;
27
}
Copied!
这个程序没有使用递归锁,所以在运行的时候会抛出异常,因为递归后的程序无法拿到锁,只需要将锁的定义从std::mutex mtx;改为std::recursive_mutex mtx;后程序即可不抛出异常,但是在写完这个程序之后,引发了我的深思,就是这个程序为什么需要锁,我一直找不到一个合适的理由,可能是仅仅将其作为一个介绍递归锁的一个实例罢了,同时我考虑了一种情况就是分配两个线程出来,第一个线程来求区间[0-10]以内的数列,第二个线程用来求区间[11-20]内的数列,但是这样做是毫无意义的,首先第二个线程必需依赖第一个线程所产生数列的结果,也就是说第二个线程必需等待第一个线程结束后,才能从set集合中取出用于求数列的充分条件,这样的设计显然是毫无意义的,至少我是这么认为的。
看到了网上的一篇有关递归锁介绍的文章,感觉他给出的示例代码的确很不错:
1
template <typename T>
2
class container
3
{
4
std::mutex _lock;
5
std::vector<T> _elements;
6
public:
7
void add(T element)
8
{
9
_lock.lock();
10
_elements.push_back(element);
11
_lock.unlock();
12
}
13
14
void addrange(int num, ...)
15
{
16
va_list arguments;
17
18
va_start(arguments, num);
19
20
for (int i = 0; i < num; i++)
21
{
22
_lock.lock();
23
add(va_arg(arguments, T));
24
_lock.unlock();
25
}
26
27
va_end(arguments);
28
}
29
30
void dump()
31
{
32
_lock.lock();
33
for(auto e : _elements)
34
std::cout << e << std::endl;
35
_lock.unlock();
36
}
37
};
38
39
void func(container<int>& cont)
40
{
41
cont.addrange(3, rand(), rand(), rand());
42
}
43
44
int main()
45
{
46
srand((unsigned int)time(0));
47
container<int> cont;
48
std::thread t1(func, std::ref(cont));
49
std::thread t2(func, std::ref(cont));
50
std::thread t3(func, std::ref(cont));
51
t1.join();
52
t2.join();
53
t3.join();
54
cont.dump();
55
return 0;
56
}
Copied!
当你运行这个程序时,会进入死锁。原因:在 mutex 被释放前,容器尝试多次持有它,这显然不可能。这就是为什么引入 std::recursive_mutex ,它允许一个线程对 mutex 多重持有。允许的最大持有次数并不确定,但当达到上限时,线程锁会抛出 std::system_error错误。因此,要解决上面例子的错误,除了修改 addrange 令其不再调用 lockunlock 之外,可以用 std::recursive_mutex 代替 mutex
另注意:
    递归锁效率低于普通锁
    需要用到递归锁定的多线程互斥处理往往本身就是可以简化的,允许递归互斥很容易放纵复杂逻辑的产生,从而导致一些多线程同步引起的晦涩问题;
    递归锁虽然允许同一线程多次获得同一互斥量,但是可重复获得的最大次数并未具体说明,一旦超过一定次数,再对lock进行调用就会抛出std::system错误

0x04 时间锁std::timed_mutexrecursive_timed_mutex

时间锁是用来指定锁住一定的时间段或直到一个时间点解锁。提供了两个函数try_lock_fortry_lock_until,用来设置时间,下面是一段示例代码,摘抄自深入应用C++11之多线程
1
std::timed_mutex;
2
void work()
3
{
4
std::chrono::milliseconds timeout(1000);
5
while(true)
6
{
7
if(mutex.try_lock_for(timeout))
8
{
9
//do some work
10
mutex.unlock();
11
}
12
}
13
}
Copied!
try_lock_for是设置一个超时时间,try_lock_until是设置一个超时的时间点

0x05 RAII风格的加锁(互斥对象管理类模板)

0x00 什么是RAII

RAII(Resource Acquisition Is Initialization),也称直译为“资源获取就是初始化”,是C++语言的一种管理资源、避免泄漏的机制。 C++标准保证任何情况下,已构造的对象最终会销毁,即它的析构函数最终会被调用。RAII 机制就是利用了C++的上述特性,在需要获取使用资源RES的时候,构造一个临时对象(T),在其构造T时获取资源,在T生命期控制对RES的访问使之始终保持有效,最后在T析构的时候释放资源。以达到安全管理资源对象,避免资源泄漏的目的。

0x01 std::lock_guard

显式的加锁和解锁会导致一些问题,比如忘记解锁或者请求加锁的顺序不正确,进而产生死锁。std::lock_guard就是基于RAII原则开发的一套模板,在它的构造函数里面会调用锁的lock函数从而实现加锁,当出了他的定义域之后C++就会自动调用他的析构函数,在它的析构函数中会自动调用unlock函数进行解锁,下面来看一个用上面求斐波那契数列的例子改造过来的使用std::lock_guard的例子
1
std::recursive_mutex mtx;
2
std::set<int> g_vecFib;
3
4
int Fibonacci(int n)
5
{
6
std::lock_guard<std::recursive_mutex> lock(mtx);
7
int ret{ n <= 1 ? n : Fibonacci(n - 1) + Fibonacci(n - 2) };
8
g_vecFib.insert(ret);
9
return ret;
10
}
Copied!

0x02 std::unique_lock

std::unique_lock里面实现了try_lock_fortry_lock_until两个函数,用来设置时间锁。

0x03 互斥对象管理类模板的加锁策略

上面提到的std::lock_guardstd::unique_lock对于在构造的过程中是否加锁是可选的设置,C++提供了三种加锁的策略:
策略
描述
默认
请求锁,阻塞当前线程知道成功获得锁
std::defer_lock
不请求锁
std::try_to_lock
尝试请求锁,但不阻塞线程,锁不可用时也会立即返回
std::adopt_lock
假定当前线程已经得到了锁,所以不再请求锁
各类模板的策略支持性
策略
std::lock_guard
std::unique_lock
默认
支持
支持
std::defer_lock
不支持
支持
std::try_to_lock
不支持
支持
std::adopt_lock
支持
支持
可以通过指定构造函数的第二个参数来设置加锁策略,例如:
1
std::unique_lock<std::mutex> lock(mt, std::defer_lock);
Copied!

0x06 对所有的互斥量均不能使用const关键字

一个互斥量(不管使用的哪一种实现)必须要获取和释放,这就意味着要调用非const的lock()和unlock()方法。所以从逻辑上来讲,lock_guard的参数不能使const(因为如果该方法为const,互斥量也必需是const)。同样在类里面也不能在const函数中使用lock_guard

0x07 std::lockstd::try_lock

这个函数一般用于对多个互斥对象进行加锁的情况,现在考虑下面一段代码:
1
#include <iostream>
2
#include <thread>
3
#include <mutex>
4
5
std::mutex mtx1;
6
std::mutex mtx2;
7
8
int main()
9
{
10
std::thread thd1([]()
11
{
12
std::lock_guard<std::mutex> lock1(mtx1);
13
std::this_thread::sleep_for(std::chrono::milliseconds(500));
14
std::lock_guard<std::mutex> lock2(mtx2);
15
std::cout << "Fun1 ended" << std::endl;
16
});
17
std::thread thd2([]()
18
{
19
std::lock_guard<std::mutex> lock2(mtx2);
20
std::this_thread::sleep_for(std::chrono::milliseconds(500));
21
std::lock_guard<std::mutex> lock1(mtx1);
22
std::cout << "Fun2 ended" << std::endl;
23
});
24
thd1.detach();
25
thd2.detach();
26
27
system("pause");
28
return 0;
29
}
Copied!
使用Visual Studio 2017来编译执行上述代码,发生以下异常:
依据程序的输出f:\dd\vctools\crt\crtw32\stdcpp\thr\mutex.c(51): mutex destroyed while busy,我们可以得知发生了死锁,为什么会发生这种现象呢?
在两个线程函数中会以相反的顺序去获得mtx1和mtx2两把锁,现在来考虑这种情况,线程1运行第一行代码拿到了锁1,恰好这个时候,线程2也运行到第一行代码拿到了锁2,现在,来看看当前2个线程的状态
    线程1持有锁1,等待锁2
    线程2持有锁2,等待锁1
发生了死锁,在上面的例子中因为我要明显的看到发生死锁然后抛出异常的例子,所以我在代码里面加上了std::this_thread::sleep_for(std::chrono::milliseconds(500));让线程拿到锁之后休息一下,这样就大大提升了发生死锁的概率,如果去掉这行代码,发生死锁的现象将会是一个带有一定概率的事件,有时候会发生有时候不会发生。
为了避免这种死锁,可以采取以下两种措施
    对于任意两把锁,在加锁的时候保持前后顺序的一致(不推荐),如果是这样的话,两个线程函数将被修改为如下形式:
    1
    std::thread thd1([]()
    2
    {
    3
    std::lock_guard<std::mutex> lock1(mtx1);
    4
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    5
    std::lock_guard<std::mutex> lock2(mtx2);
    6
    std::cout << "Fun1 ended" << std::endl;
    7
    });
    8
    std::thread thd2([]()
    9
    {
    10
    std::lock_guard<std::mutex> lock1(mtx1); // 保持相同的加锁顺序
    11
    std::this_thread::sleep_for(std::chrono::milliseconds(500));
    12
    std::lock_guard<std::mutex> lock2(mtx2);
    13
    std::cout << "Fun2 ended" << std::endl;
    14
    });
    Copied!
    下面我们来看一下程序输出:
    没有异常现象的发生
    使用std::lock来进行加锁,std::lock会使用一种避免死锁的算法来对N个需要加锁的对象加锁,std::lock可以接受N个参数
    1
    #include <iostream>
    2
    #include <thread>
    3
    #include <mutex>
    4
    5
    std::mutex mtx1;
    6
    std::mutex mtx2;
    7
    8
    int main()
    9
    {
    10
    std::thread thd1([]()
    11
    {
    12
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    13
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    14
    std::lock(lock1, lock2);
    15
    std::cout << "Fun1 ended" << std::endl;
    16
    });
    17
    std::thread thd2([]()
    18
    {
    19
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    20
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    21
    std::lock(lock2, lock1);
    22
    std::cout << "Fun2 ended" << std::endl;
    23
    });
    24
    thd1.detach();
    25
    thd2.detach();
    26
    27
    system("pause");
    28
    return 0;
    29
    }
    Copied!

0x08 参考文献

最近更新 1yr ago