「C++ 多线程」std::unique_lock 基本用法

文章大图来源: pixiv_id=114658725

1 unique_lock 简介

1.1 基本概念

std::unique_lock 是 C++ 标准库中的一个类模板,定义在 <mutex> 头文件中。用于管理互斥量(Mutex)的加锁与解锁操作,并且提供了一种灵活的 RAII (资源获取即初始化)机制来更好地处理多线程并发同步问题。

1.2 对比 lock_guard

相比于 std::lock_guardstd::unique_lock 更加灵活。默认情况下, std::unique_lockstd::lock_guard 是一样的,会自动地进行加锁和解锁。

除此以外,std::unique_lock 允许在对象的生命周期内手动对互斥量的加锁和解锁。可以在定义 std::unique_lock 对象稍后的某个时间点显式地调用 lock()(需要配合 std::defer_lock);可以在之后的某个时间点显式地调用 unlock(),用于提前或者暂时解锁。而 std::lock_guard 是无法执行这些操作的。

但是,由于 std::unique_lock 支持了复杂的操作,相对于 std::lock_guard 来说 开销比较大 的。因此,对于比较简单的自动加锁解锁的情形,我们尽量优先使用 std::lock_guard;对于比较复杂的,需要支持暂时解锁、重新加锁等手动操作的情形,我们可以考虑使用 std::unque_lock

2 unique_lock 构造

2.1 构造 unique_lock 对象

假如当前有一个线程执行 foo() 函数,有一个共享变量 count 和一个 std::mutex 互斥量 mtx,在对共享数据进行访问和修改前,构造一个 unique_lock 对象,尝试对互斥量 mtx 加锁。如果当前可以获得锁,那么进行加锁,然后访问和修改数据,最后进行自动解锁;如果当前无法获得锁(被其他线程占用),那么当前线程会被阻塞,直到可以获得锁。

例如下面这种形式:

1
2
3
4
5
6
7
std::mutex mtx;
int count;

void foo(){
std::unique_lock<std::mutex> lck(mtx);
count ++ ;
}

以下是一个简单的示例:

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

std::mutex mtx; // 互斥量
int count;

void foo(){
// 默认情况下和 lock_guard 一样,自动加锁、解锁
std::unique_lock<std::mutex> lck(mtx);
count ++ ;
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
}

int main(){
std::vector<std::thread> v(10);
for(auto& t : v) t = std::thread(foo);
for(auto& t : v) t.join();

return 0;
}

在上面的代码中创建了 10 个子线程(用 std::vector 存储),每个线程执行 foo() 函数,对共享变量 count 进行 +1,并输出线程 id 以及当前线程对数据修改操作后数据 count 的值。

定义了一个互斥量 mtx,并采用 std::unique_lock 实现自动加锁解锁,保证多线程互斥访问和修改共享数据。

可能的运行结果如下:

2.2 unique_lock::lock() 和 unique_lock::unlock()

std::unique_lock 也有自己的成员函数 lock()unlock()。分别用于在某个时间点手动地加锁和解锁。

如果需要 暂时解锁,或者需要 提前解锁,可以手动调用 std::unique_lock 成员函数 unlock() 解锁。有时我们线程函数中的一些操作是不需要锁保护的,而且能够 使得其他线程在此期间有机会获得锁,手动暂时解锁这时就显得很有用了。

之后如果要 再进行加锁,可以直接调用 std::unique_lock 成员函数 lock() 手动加锁。

例如下面这种形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::mutex mtx;
int count;

void foo(){
std::unique_lock<std::mutex> lck(mtx);
count ++ ;
lck.unlock(); // 暂时解锁

do_something_else(); // 执行一些不需要锁保护的操作

lck.lock(); // 重新加锁,调用 std::unique_lock 成员函数 lock()
count ++ ;
// 之后自动解锁
}

以下是一个简单的示例:

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
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
#include <chrono>

std::mutex mtx;
int count;

void foo(){
std::unique_lock<std::mutex> lck(mtx);
count ++ ;
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";

lck.unlock(); // 暂时解锁

// 模拟做一些其他不需要锁保护的操作
std::this_thread::sleep_for(std::chrono::milliseconds(200));

lck.lock(); // 重新加锁
count ++ ;
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
// 作用域结束 return 时自动解锁
}

int main(){
std::vector<std::thread> v(10);
for(auto& t : v) t = std::thread(foo);
for(auto& t : v) t.join();

std::cout << "final value = " << count << "\n";

return 0;
}

在上面的代码的 foo() 中,先定义一个 std::unique_lock 实现自动尝试加锁,如果获得了锁,那么对共享数据 count 进行 +1 修改操作,并输出当前线程 id 和 count 的值。

然后我们需要做一些其他不需要锁保护的操作,此时调用 std::unique_lock::unlock() 进行手动解锁(其他不需要锁保护的操作用 std::this_thread::sleep_for() 线程休息操作进行了模拟)。

执行完其他不需要锁保护的操作时,我们要再一次对共享数据 count 进行 +1 修改操作,因此需要在之前需要调用 std::unique_lock::lock() 再重新加锁,确保线程互斥访问和修改数据。

最后函数作用域结束 return 时自动地解锁。线程函数执行完毕。

可能的运行结果如下:

可以看出,创建的 10 个线程两次数据访问和 +1 修改操作中间暂时解锁休息的一段时间中,有其他线程获得了锁并执行自己的数据数据访问和 +1 修改操作,这表明暂时解锁使得其他线程有机会获得了锁,提高了运行的效率。

最终运行的结果为 20,这个结果是正确的,表明这 10 个线程互斥地对共享数据进行了两次 +1 的操作。

2.3 std::defer_lock

std::defer_lock 是一个标记类型,用于 延迟加锁。我们可以在构造 std::unique_lock 时加上第二个参数 std::defer_lock 来支持延迟加锁。例如我们可以在定义完 std::unique_lock 对象之后,先进行一些其他操作,然后再调用 std::unique_lock 的成员函数 lock() 进行手动加锁。

例如下面这种形式:

1
2
3
4
5
6
7
8
9
10
11
std::mutex mtx;
int count;

void foo(){
std::unique_lock<std::mutex> lck(mtx, std::defer_lock);
do_something_else(); // 做一些其他事情
// 再手动进行加锁
lck.lock(); // 调用 std::unique_lock 成员函数 lock()
count ++ ;
// 之后自动解锁
}

以下是一个简单的示例:

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
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int count;

void foo(int id){
std::unique_lock<std::mutex> lck(mtx, std::defer_lock);
if(id & 1){
lck.lock(); // 手动加锁
count ++ ;
std::cout << "thread " << id << " acquired the lock." << "\n";
}else{
std::cout << "thread " << id << " did not acquire the lock." << "\n";
}
}

int main(){
std::vector<std::thread> v(20);
for(int i = 0; i < 20; i ++ ) v[i] = std::thread(foo, i);
for(auto& t : v) t.join();

std::cout << "final count = " << count << "\n";

return 0;
}

在上面的代码中的 foo() 函数中,定义 std::unique_lock 对象时增加了 std::defer_lock 延迟加锁标记参数,表示稍后再进行加锁。当线程的标记为奇数时,我们手动地进行加锁( std::unique_lock 之后会自动进行解锁),然后对共享数据 count 进行 +1 操作;当线程地标记为偶数时,我们不进行加锁。

可能的运行结果如下:

可以看出,只有传入的线程标记为奇数时,线程才会获得锁并对共享变量 count 进行 +1 操作。一共 20 个线程,有 10 个线程互斥地获得锁,并对数据进行修改操作。因此最终 count10

2.4 std::adopt_lock

假如一个线程已经拥有了互斥量的锁,此时互斥量已经被锁定,我们若要将锁的管理转移给 std::unique_lock 对象,需要在构造 std::unique_lock 时加上第二个参数 std::adopt_lock

例如下面这种形式:

1
2
3
4
5
6
7
8
9
std::mutex mtx;
int count;

void foo(){
mtx.lock(); // 已经获得了锁
// 将锁的管理转移给 unique_lock 对象
std::unique_lock<std::mutex> lck(mtx, std::adopt_lock);
count ++ ;
}

下面是一个简单的示例:

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 <mutex>
#include <vector>

std::mutex mtx;
int count;

void foo(){
mtx.lock();
// 锁的管理转移给 unique_lock,加上 std::adopt_lock
std::unique_lock<std::mutex> lck(mtx, std::adopt_lock);
count ++ ;
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
// 不需要再调用 mtx.unlock() 进行解锁,会自动解锁
}

int main(){
std::vector<std::thread> v(10);
for(auto& t : v) t = std::thread(foo);
for(auto& t : v) t.join();

std::cout << "final count = " << count << "\n";

return 0;
}

在上面的代码的 foo() 中,一开始已经调用 std::mutex::lock() 函数使线程获得了锁,然后定义一个 std::unique_lock 对象并加上 std::adopt_lock 参数,将锁的管理转移到 std::unique_lock 上。之后,就可以自动地进行解锁了,不需要再调用 std::mutex::unlock() 进行解锁。

可能的运行结果如下:

可以看出,多个线程互斥访问和修改共享数据,最终的结果为 10。

2.5 std::try_to_lock

我们也可以尝试构造一个 std::unique_lock 对象并尝试非阻塞地锁定互斥量。如果互斥量当前不可用,它不会阻塞线程,而是直接返回。此时我们可以通过 std::unique_lock 成员函数 owns_lock() 来判断是否获得了锁,如果获得了锁,成员函数 owns_lock() 返回 true,否则返回 false

例如下面这种形式:

1
2
3
4
5
6
7
8
9
10
11
12
std::mutex mtx;
int count;

void foo(){
std::unique_lock<std::mutex> lck(mtx, std::try_to_lock);
if(lck.owns_lock()){ // 成功获得锁
count ++ ;
std::cout << "Successfully get the lock." << "\n";
}else{ // 未能获得锁,不等待,线程直接返回
std::cout << "Unsuccessfully get the lock." << "\n";
}
}

以下是一个简单的代码示例:

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
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int count;

void foo(){
// 定义 unique_lock,加上 std::try_to_lock
std::unique_lock<std::mutex> lck(mtx, std::try_to_lock);
if(lck.owns_lock()){
// 成功获得锁
count ++ ;
std::cout << "Successfully get the lock." << "\n";
}else{
// 未能获得锁,不等待,线程直接返回
std::cout << "Unsuccessfully get the lock." << "\n";
}
}

int main(){
std::vector<std::thread> v(20);
for(auto& t : v) t = std::thread(foo);
for(auto& t : v) t.join();

std::cout << "final count = " << count << "\n";

return 0;
}

在上面的代码的 foo() 中,定义了一个 std::unique_lock 对象,并加上 std::try_to_lock。我们用 std::unique_lock::owns_lock() 函数判断线程是否获得了锁,如果获得了锁,那么对共享数据进行访问和修改;否则,当前线程会直接返回,不会被阻塞并等待其他线程释放锁。

可能的运行结果如下:

可以看出只有少量的线程可以顺利获得锁,而其他没有成功获得锁的线程直接返回。

3 unique_lock 其他成员函数

上面的介绍中有了 std::unique_lock::lock()std::unique_lock::unlock() 以及 std::unique_lock::owns_lock() 成员函数。除这些函数之外,还有一些其他成员函数。

3.1 try_lock

std::unique_locktry_lock 成员函数用于尝试以非阻塞的方式获取互斥量(mutex)的锁,通常需要搭配第二个参数 std::defer_lock 延迟加锁标记使用。有点类似于前面提到的构造函数参数 std::try_to_lock 的作用。

如果 std::unique_lock::try_lock() 返回值为 true,表示成功获得了锁,接下来进行一系列操作;否则,无法成功获得锁,但是线程不会被阻塞并等待其他线程释放锁,而是会直接返回。

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
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int count;

void foo() {
std::unique_lock<std::mutex> lck(mtx, std::defer_lock);
if(lck.try_lock()){
// 成功获得锁
count ++ ;
std::cout << "Successfully get the lock." << "\n";
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
}else{
// 无法获得锁,直接返回
std::cout << "Unsuccessfully get the lock." << "\n";
}
}

int main() {
std::vector<std::thread> v(10);
for(auto& t : v) t = std::thread(foo);
for(auto& t : v) t.join();

return 0;
}

可能的运行结果如下:

3.2 release

std::unique_lock::releasestd::unique_lock 类模板的一个成员函数。它的主要作用是释放 std::unique_lock 对象对互斥量(mutex)的所有权,返回值是 一个指向被管理的互斥量的指针,并将 std::unique_lock 对象与互斥量的关联断开。

之后例如转移给某个 std::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
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;
int count;

void foo() {
std::unique_lock<std::mutex> lck(mtx);
// 假设我们需要将锁的所有权转移给另一个对象
std::mutex *m = lck.release(); // 释放锁所有权
// 此时 lck 不再持有锁,转移到了 std::mutex 互斥量 m 上
count ++ ;
std::cout << "thread id = " << std::this_thread::get_id();
std::cout << ", count = " << count << "\n";
m->unlock(); // 需要手动释放锁
}

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

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

return 0;
}

在上面的代码的 foo() 中,先创建了一个 std::unique_lock 对象并锁定了互斥量mtx。然后通过 std::mutex* m = lck.release(); 释放了 lckmtx 的所有权,同时获取了指向 mtx 的指针。

之后,std::unique_lock 对象不再管理互斥量,必须手动调用 mtx->unlock() 进行解锁互斥量。

可能的运行结果如下:

4 简单应用实例

和之前 std::mutex 文章 「C++ 多线程」std::mutex,lock(), unlock() 中的问题相同:

  1. 一个线程用来接受用户命令(用数字表示命令),并把命令写入一个队列中
  2. 另一个线程从队列中读取命令,解析命令并执行一系列操作

程序中共享数据(数据结构) msg_que,消息队列同一时刻只允许一个线程进行操作(读取或写入)。因此 msg_que 是临界资源,我们需要对访问临界资源的代码加锁。

我们可以用 unique_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 <thread>
#include <mutex>
#include <vector>
#include <queue>

class MyClass{
std::queue<int> msg_que; // 存储用户命令的消息队列
std::mutex mtx; // 互斥量
public:
void in_msg_que(int num){
for(int i = 0; i < num; i ++ ){
std::cout << "in_msg_que() running, push data: " << i << "\n";
// 使用 try_to_lock 参数,尝试加锁
std::unique_lock<std::mutex> lck(mtx, std::try_to_lock);
if(lck.owns_lock()){
// 尝试后成功拿到了锁
msg_que.push(i);
}else{
// 没有成功拿到锁,直接返回
std::cout << "in_msg_que() running, but cannot get lock: " << i << "\n";
}
}
}

void out_msg_que(int num){
int command = 0;
for(int i = 0; i < num; i ++ ){
if(pop_command(command)){
// 消息队列不为空,处理取出的指令
std::cout << "out_msg_que() running, command is: " << command << "\n";
}else{
// 消息队列为空
std::cout << "out_msg_que() running, queue is empty: " << i << "\n";
}
}
}

bool pop_command(int& command){
std::unique_lock<std::mutex> lck(mtx); // 使用 unique_lock 实现自动加锁、解锁
if(msg_que.empty()){
// 如果为空,直接返回 false
return false;
}
command = msg_que.front(); // 从队首取出指令
msg_que.pop();
return true;
}
};

int main(){
MyClass obj;
std::thread out_msg_t(&MyClass::out_msg_que, &obj, 10000);
std::thread in_msg_t(&MyClass::in_msg_que, &obj, 10000);

out_msg_t.join();
in_msg_t.join();

return 0;
}

可能的运行结果如下:


「C++ 多线程」std::unique_lock 基本用法
https://marisamagic.github.io/2024/12/26/20241226_/
作者
MarisaMagic
发布于
2024年12月26日
许可协议