c++多线程学习笔记-2

单纯的mutex需要手动进行加锁和解锁,如果加锁解锁不匹配,程序就会出现bug无法运行。此外,因为抛出异常或return等操作可能导致没有解锁就退出的问题。更好的办法是采用”资源分配时初始化”(RAII)方法来加锁、解锁。此时主要会用到lock_guard和unique_lock两个类。

std::lock_guard

  • 当构造函数被调用时,该互斥量会被自动锁定
  • 当析构函数被调用时,该互斥量会自动解锁
  • lock_guard对象不能复制或移动,因此只能在局部作用域中使用

例子如下,两个线程都对一个全局变量进行累加,如果不加锁线程就可能同时对a进行操作,产生矛盾。使用lock_guard,我们不需要手动解锁,在lock_guard被析构时自动解锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include<iostream>
#include<mutex>
#include<thread>

int a = 0;
std::mutex mtx;
void func()
{
for (int i = 0; i < 10000; i++)
{
std::lock_guard<std::mutex> lg(mtx);
a++;
}
}

int main(){
std::thread t1(func);
std::thread t2(func);

t1.join();
t2.join();
std::cout << a << std::endl;
return 0;
}

程序输出:

1
a = 20000

从lock_guard的源码分析(除了构造函数和析构函数以外,没有其他函数):

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
template<class _Mutex>
class lock_guard
{ // class with destructor that unlocks a mutex
public:
using mutex_type = _Mutex;

explicit lock_guard(_Mutex& _Mtx)
: _MyMutex(_Mtx)
{ // 构造函数中对mtx加锁
_MyMutex.lock();
}

lock_guard(_Mutex& _Mtx, adopt_lock_t)
: _MyMutex(_Mtx)
{ // 当前mtx已获得变量所有权时,调用lock_guard需要加一个常数,此时不用再次加锁
}

~lock_guard() noexcept
{ // 在析构函数中对mtx解锁
_MyMutex.unlock();
}

// 禁用拷贝构造和等于(不能复制和移动)
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;

private:
_Mutex& _MyMutex;
};

explicit: 构造类时不支持隐式转换。也就是必须是一个已被定义为_Mutex类的变量才能用于构造lock_guard

std::unique_lock

相比lock_guard,unique_lock可以对互斥量做更丰富的操作,因此使用更加广泛,但同时占用的资源也会更多。

操作一:自动加锁解锁

用上述例子,将lock_guard换成unique_lock可以实现同样功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int a = 0;
std::mutex mtx;
void func()
{
for (int i = 0; i < 10000; i++)
{
std::unique_lock<std::mutex> lg(mtx);
a++;
}
}

int main(){
std::thread t1(func);
std::thread t2(func);

t1.join();
t2.join();
std::cout << a << std::endl;
return 0;
}

操作二:延迟锁定

当调用函数时传入std::defer_lock,构造函数不会自动加锁,而由程序员在后续代码中决定什么时候加锁,加什么样的锁

1
std::unique_lock<std::mutex> lg(mtx,std::defer_lock);

查看源码:

1
2
3
4
5
6
7
8
9
10
class unique_lock{
xxx

unique_lock(_Mutex& _Mtx, defer_lock_t) noexcept
: _Pmtx(_STD addressof(_Mtx)), _Owns(false)
{ // 构造函数什么都不做,没有加锁
}

xxx
}

之后可以进行手动加锁:

1
lg.lock();

查看源码:是调用mutex的lock函数

1
2
3
4
5
6
  void lock()
{ // lock the mutex
_Validate();
_Pmtx->lock();
_Owns = true;
}

操作三:尝试加锁并返回是否加锁成功-try_lock

try_lock尝试获得mutex所有权,如果可以就加锁返回true,否则返回false,程序继续往下运行。

如下面的例子:

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

int a = 0, b = 0;
std::mutex mtx;

void func()
{

std::unique_lock<std::mutex> lg(mtx, std::defer_lock);
if(lg.try_lock()){
a++;
std::this_thread::sleep_for(std::chrono::seconds(3));
}
else{
b++;
}
}

int main(){
std::thread t1(func);
std::thread t2(func);

t1.join();
t2.join();
std::cout << a << " " << b << std::endl;
return 0;
}

t1获得mtx所有权后,会对a加1,等待2s,此时t2尝试获取mtx所有权,但无法获取,返回false,执行b加1.

也可以写成这样:在构造函数中加入try_to_lock的参数

1
2
3
4
5
6
7
8
9
10
11
void func()
{
std::unique_lock<std::mutex> lg(mtx, std::try_to_lock);
if(lock.owns_lock()){
a++;
std::this_thread::sleep_for(std::chrono::seconds(3));
}
else{
b++;
}
}

操作四:在一段时间内尝试加锁-try_lock_for

try_lock_for尝试在一定时间内获得mutex所有权,如果可以就加锁返回true,否则返回false,程序继续往下运行。

如下面的例子:(此时mutex要选用timed_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
27
28
29
30
31
32
#include <iostream>
#include <vector>
#include <string>
#include <thread>
#include <mutex>
using namespace std;

int a = 0, b = 0;
std::timed_mutex mtx;

void func()
{

std::unique_lock<timed_mutex> lg(mtx, std::defer_lock);
if(lg.try_lock_for(std::chrono::seconds(5))){
std::this_thread::sleep_for(std::chrono::seconds(2));
a++;
}
else{
b++;
}
}

int main(){
std::thread t1(func);
std::thread t2(func);

t1.join();
t2.join();
std::cout << a << " " << b << std::endl;
return 0;
}

t1获得mtx所有权后,会对a加1,等待2s,此时t2尝试在5s内获取mtx所有权。在等待过程中t1休眠完毕,释放mtx所有权,t2就可以加锁,再对a加1。最终a=2,b=0

特性:所有权可以移动但是不能复制

查看源码:

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
    unique_lock(unique_lock&& _Other) noexcept
: _Pmtx(_Other._Pmtx), _Owns(_Other._Owns)
{ // 可以移动另一个mutex的所有权
_Other._Pmtx = nullptr;
_Other._Owns = false;
}

unique_lock& operator=(unique_lock&& _Other)
{ // 等号可以用于移动另一个mutex的所有权
if (this != _STD addressof(_Other))
{
if (_Owns)
_Pmtx->unlock();
_Pmtx = _Other._Pmtx;
_Owns = _Other._Owns;
_Other._Pmtx = nullptr;
_Other._Owns = false;
}
return (*this);
}

unique_lock(const unique_lock&) = delete;
unique_lock& operator=(const unique_lock&) = delete;
```

### call_once()

使用场景:某些类是单例模式,在整个程序中只能创建一个实例,此时多线程调用可能有问题。

如下面的例子,该类使用了一个静态成员函数 getInstance() 来获取单例实例,用了一个静态局部变量 instance 来存储单例实例。由于静态局部变量只会被初始化一次,因此该实现可以确保单例实例只会被创建一次。

```C++

using namespace std;

class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
void setData(int data) {
m_data = data;
}
int getData() const {
return m_data;
}

private:
Singleton() {} //构造函数为private
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete; // 禁用拷贝构造和等号
int m_data = 0;
};

但如果多个线程同时调用 getInstance() 函数,可能会导致多个对象被创建,从而违反了单例模式的要求。并且可能会出现多个线程同时调用 setData() 函数来修改m_data,可能会导致数据不一致或不正确的结果。

此时可以用call_once函数,call_once函数将输入一个std::once_flag类型的变量,用于标记该函数是否已经被调用过。将该类改写为:

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
class Singleton {
public:
static Singleton& getInstance() {
std::call_once(m_onceFlag, &Singleton::init);
return *m_instance;
}
void setData(int data) {
m_data = data;
}
int getData() const {
return m_data;
}
private:
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static void init() {
m_instance.reset(new Singleton);
}
static std::unique_ptr<Singleton> m_instance;
static std::once_flag m_onceFlag;
int m_data = 0;
};

std::unique_ptr<Singleton> Singleton::m_instance;
std::once_flag Singleton::m_onceFlag;

此时,我们创建了一个静态成员变量 m_onceFlag 来标记初始化是否已经完成。在 getInstance() 函数中,我们使用 std::call_once 来调用 init() 函数,仅当第一次调用时m_onceFlag有效,可以调用init()。后续再有线程运行到此处无法调用init(),直接返回该实例。

参考资料


c++多线程学习笔记-2
https://sisyphus-99.github.io/2023/08/25/多线程学习笔记-2/
Author
sisyphus
Posted on
August 25, 2023
Licensed under