「C++ 多线程」std::mutex,lock(), unlock()

文章大图来源: pixiv_id=116700258

1 问题背景

在多线程编程环境中,多个线程可以同时执行不同的任务。当线程需要访问共享资源(例如全局变量、共享的数据结构等),会产生一些问题。例如,有两个线程 t1t2,都需要访问和修改一个共享的计数器 count

如果没有适当的同步机制时,可能会存在 数据竞争。若线程 t1t2 同时修改共享计数器 count(假设当前计数器 count 的值为 7),各自对其进行 +1 操作,此时两个线程可能会 同时 将修改的结果(+1 后结果为 8)写入 count。最终导致 count8,然而预期应该是两个线程都对计数器进行 +1,因此预期最终结果为 9。这导致了数据的不一致。

2 互斥锁

为了解决多线程并发访问共享资源中数据竞争的问题,我们有了互斥锁(Mutex)。互斥锁基于一种互斥的原则,即 同一时刻 只允许 一个线程访问被保护的资源

互斥锁的工作原理就类似于一个门锁,只有持有钥匙(获取锁)的线程才能访问房间(共享资源)内的内容。

当一个线程获取互斥锁,其他试图访问共享资源的线程会被 阻塞,直到当前这个获取锁的线程 释放锁

在之前线程 t1t2 的例子中,如果使用了互斥锁,可以保证 t1t2 对共享数据 count 的修改访问操作是互斥的。当 t1count 进行修改操作,先获得了锁,那么t2 线程会被阻塞,直到 t1 释放锁。反之亦然。这样就确保避免了数据竞争的问题。

3 std::mutex

3.1 基本概念

std::mutex(互斥量 / 互斥锁)是 C++ 标准库中用于实现线程间互斥访问的同步原语。用于多线程中保护共享资源,防止多个线程同时访问和修改导致的数据竞争和数据不一致的问题。

std::mutex 不允许拷贝构造,也不允许 std::move,最初产生的 mutex 对象处于 unlocked 状态。

3.2 lock() 函数

std::mutex::lock() 的功能是 尝试获取互斥量 mutex 的锁

如果互斥量当前没有被其他线程锁定,那么调用 std::mutex::lock() 的线程将成功获取锁并继续执行后续代码。

如果互斥量已经被其他线程锁定,那么调用 std::mutex::lock() 的线程会被阻塞,直到能够获取锁为止。

3.3 unlock() 函数

std::mutex::unlock() 的功能是 释放互斥量的锁,使得其他等待获取锁的线程有机会获取锁并访问被保护的共享资源。

必须确保 lock 和 unlock 成对出现,并且在正确的逻辑位置调用 unlock。如果一个线程在没有获取锁的情况下调用 unlock,或者多次调用 unlock 而没有相应次数的 lock,会导致未定义行为。

3.4 try_lock() 函数

std::mutex::try_lock() 的功能是 尝试锁住互斥量

如果获取成功,线程会执行需要互斥访问的代码段,然后通过 unlock() 释放锁。如果获取失败,线程会输出一条消息表示无法获取锁。

4 std::mutex 代码与测试

4.1 示例

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

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

void foo(int num){
for(int i = 0; i < num; i ++ ){
mtx.lock();
count ++ ;
mtx.unlock();
}
}

int main(){
std::thread t1(foo, 100000);
std::thread t2(foo, 100000);
t1.join();
t2.join();

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

return 0;
}

在上面的程序中,创建了两个线程分别为 t1t2,线程函数均为 foo(),传入的参数均为 100000,两个线程都将执行对公共数据执行 count 自加 100000 次的任务。

我们同时还定义了一个 std::mutex 互斥量 mtx,也就是互斥锁。为了让两个线程互斥访问和修改数据,我们需要在每次进行 count 自加修改操作之前进行加锁 mtx.lock(),在执行完一次 count 自加修改操作后,进行解锁 mtx.unlock()

最终我们的 count 变量答案为 200000

运行结果如下:

4.2 加锁与解锁位置分析

如果不进行加锁解锁的互斥操作,由于数据竞争可能导致的问题,那么有可能最终答案是一个介于 0 ~ 200000 的中间值:

如果在循环之外加锁解锁,那么本质上就变成第一个线程 t1 先执行完增加 100000 次,然后第二个线程 t2 执行增加 100000 次,而不是并发(并行)执行的。因此本质上就不是多线程了。

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

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

// 错误的加锁、解锁位置。本质上不再是多线程并发执行
void foo(int num){
mtx.lock();
for(int i = 0; i < num; i ++ ){
count ++ ;
}
mtx.unlock();
}

int main(){
std::thread t1(foo, 100000);
std::thread t2(foo, 100000);
t1.join();
t2.join();

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

return 0;
}

在上面的代码中,我们在线程函数的循环之外加锁解锁,那么本质上不是多线程,虽然最终结果也是 200000,但是脱离了我们要并行执行的本意。

5 简单应用实例

假设我们有如下问题背景:

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

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

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 <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";
mtx.lock(); // 加锁
msg_que.push(i); // 放入消息队列
mtx.unlock(); // 解锁
}
}

void out_msg_que(int num){
for(int i = 0; i < num; i ++ ){
int command; // 取出的指令
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){
mtx.lock();
if(msg_que.empty()){ // 如果为空,直接解锁返回false
mtx.unlock();
return false;
}
command = msg_que.front(); // 从队首取出指令
msg_que.pop();
mtx.unlock();
return true;
}
};

int main(){
MyClass obj_a;
std::thread out_msg_t(&MyClass::out_msg_que, &obj_a, 7); // 通过类成员函数创建线程
std::thread in_msg_t(&MyClass::in_msg_que, &obj_a, 5);

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

return 0;
}

在上面的代码中,定义了一个名为 MyClass 的类,用于管理一个存储整数的 std::queue<int> 消息队列 msg_que,,并且通过互斥量(std::mutex)来保证对消息队列操作的线程安全性。在 main 函数中创建了两个线程,分别用于向消息队列中插入数据(in_msg_que 函数)和从消息队列中取出数据(out_msg_que 函数)。

在每次从消息队列中取出数据时,调用 pop_command 函数尝试去除队列中的一个数据。如果队列不为空,那么成功取出并输出消息指令的内容;如果队列为空,那么无法取出数据。

pop_command 函数中,先进行加锁,然后判断队列是否为空,如果为空则直接解锁互斥量并返回 false,表示无法取出数据;如果队列不为空,就将队首的数据赋值给传入的引用参数 command,接着将队首元素从队列中移除,最后再进行解锁,返回 true,表示成功取出数据。

运行结果如下:


「C++ 多线程」std::mutex,lock(), unlock()
https://marisamagic.github.io/2024/12/25/20241225/
作者
MarisaMagic
发布于
2024年12月25日
许可协议