学习 C++ 多线程时,我有如下疑问:
- mutex 的 lock 和 unlock 做了什么?
- mutex、lock_guard、unique_lock,它们之间的关系是什么?
- condition_variable 中的 wait 做了什么?
带着这些疑问,我查阅了一些资料,整理出本文。
文章目录
一、mutex
看一个经典的代码:
#include <iostream>
#include <thread>
using namespace std;
int n = 0;
void test() {
for (int i = 1; i <= 100000; i++) {
n++;
}
}
int main() {
thread t1(test);
thread t2(test);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
上面的代码创建了 2 个线程,每个线程使 n 自增 100000 次,但是输出的 n 往往不会达到 200000,从小林coding的这篇文章解释了背后的原理,即 n++ (或者 n = n+1)这种操作,包含从内存取值放入寄存器、对寄存器中的值加1、将寄存器中的值放回内存三个步,比如当 n 的值是0 时,如果恰好 t1、t2 都取出 0 放入寄存器,然后 0+1 变成 1,再写回内存,此时 n 变成了 1 而不是期望的 2。
于是引入互斥量 mutex,n++ 计算获取锁,计算完成后释放锁:
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int n = 0;
mutex mtx; // 互斥量
void test() {
for (int i = 1; i <= 100000; i++) {
mtx.lock();
n++;
mtx.unlock();
}
}
int main() {
thread t1(test);
thread t2(test);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
lock() 操作包含以下步骤:
- 检查锁的状态:如果锁是空闲的(未被其他线程持有),线程将锁状态设置为已锁定,并且允许线程进入临界区。
- 等待:如果锁已经被其他线程持有,当前线程将进入等待状态,通常会被阻塞直到锁变为可用。
unlock() 操作包含以下步骤:
- 释放锁:线程将锁的状态设置为空闲(未被持有)。
- 唤醒等待线程:如果有其他线程在等待这个锁,操作系统将从等待队列中选择一个线程,并唤醒它以便重新尝试获取锁。
二、lock_guard
lock_guard 封装了 mutex 的 lock 和 unlock,其好处是提供一种 RAll 机制,创建对象时,尝试获取锁,离开作用域时释放锁,不需要再手动 unlock,解决了忘记 unlock 或者因为其他原因(例如unlock前提前返回)造成的没有执行unlock而造成的死锁,于是前面的代码可以写成这样:
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int n = 0;
mutex mtx;
void test() {
for (int i = 1; i <= 100000; i++) {
lock_guard<mutex> lock(mtx);
n++;
}
}
int main() {
thread t1(test);
thread t2(test);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
lock_guard<mutex> lock(mtx)
包含以下步骤:
- 对象构造:当
lock_guard<std::mutex> lock(mtx)
被创建时,构造函数会立即尝试锁定传递给它的互斥锁mtx
。 - 锁定操作:
lock_guard
在其构造函数中调用std::mutex
的lock
方法。如果互斥锁mtx
当前没有被其他线程持有,它会被锁定,当前线程获得对该互斥锁的所有权。如果mtx
已经被其他线程持有,当前线程将被阻塞,直到该互斥锁可用。 - 作用域管理:
lock_guard
对象的生命周期管理着互斥锁的持有时间。当lock_guard
对象超出作用域(即不再需要时),它的析构函数会被调用。 - 解锁操作: 在
lock_guard
的析构函数中,会自动调用传递给它的互斥锁mtx
的unlock
方法,释放该互斥锁。这确保了即使在函数内发生异常,互斥锁也会被正确释放。
三、unique_lock
unique_lock 可以完全替代 lock_guard,例如上面的代码可以直接改写为下面这样:
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;
int n = 0;
mutex mtx;
void test() {
for (int i = 1; i <= 100000; i++) {
unique_lock<mutex> lock(mtx);
n++;
}
}
int main() {
thread t1(test);
thread t2(test);
t1.join();
t2.join();
cout << n << endl;
return 0;
}
那么 unqiue_lock 和 lock_guard 的区别是什么呢?
- lock_guard 是一种简单、轻量的锁管理器,提供基本的锁定和解锁功能。
- unqiue_lock 可以看作是 lock_guard 的增强版,除了 lock_guard 已有的功能外,还提供了更多的功能,推荐阅读这篇文章
- 在使用的选择上,lock_guard 足够简单,如果不需要其他功能,就用 lock_guard,如果 lock_guard 不能满足你的需求,再用 unqiue_lock!
四、condition_variable
当两个线程之间,需要进行同步,则可以使用条件变量 condition_variable。
小林coding:所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。
例如,我想让两个线程交替打印,一个打印奇数,一个打印偶数,也就是一个线程在打印的时候,另一个线程要等待,代码如下:
例1:两个线程交替打印,一个打印奇数,一个打印偶数
#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;
int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;
void printA() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return flag;});
cout << "n = " << n++ << ", " << 'A' << endl;
flag = false;
cond.notify_one();
}
}
void printB() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return !flag;});
cout << "n = " << n++ << ", " << 'B' << endl;
flag = true;
cond.notify_one();
}
}
int main() {
thread t1(printA);
thread t2(printB);
t1.join();
t2.join();
return 0;
}
输出:
n = 1, A
n = 2, B
n = 3, A
n = 4, B
n = 5, A
n = 6, B
n = 7, A
n = 8, B
n = 9, A
n = 10, B
n = 11, A
n = 12, B
n = 13, A
n = 14, B
n = 15, A
n = 16, B
n = 17, A
n = 18, B
n = 19, A
n = 20, B
重点是这两行代码:
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return flag;});
在解释上面的完整代码之前,先了解一下这两行代码背后的原理。
unique_lock<mutex> lock(mtx);
:尝试获取互斥锁,如果失败(互斥锁已经被其他线程持有)则阻塞当前线程,直到该互斥锁可用,这部分原理已在前面 lock_guard 的小节讲过,二者是一样的。
而 wait() 有两种重载,第一种是仅接收一个参数 unique_lock,其作用是:
- 阻塞当前线程,并释放互斥锁
- 当被 notify_one 或者 notify_all 唤醒时,wait 将重新尝试获取互斥锁,成功获取锁后,线程继续执行
但是直接使用会造成问题,比如下面代码:
#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;
int n = 1;
mutex mtx;
condition_variable cond;
void printA() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock);
cout << "n = " << n++ << ", " << 'A' << endl;
cond.notify_one();
}
}
void printB() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock);
cout << "n = " << n++ << ", " << 'B' << endl;
cond.notify_one();
}
}
int main() {
thread t1(printA);
thread t2(printB);
t1.join();
t2.join();
return 0;
}
这个代码执行后,将一直阻塞下去,不会有任何输出。为什么呢,我们一步一步看:
- t1 线程,执行 printA,
unique_lock<mutex> lock(mtx);
获取锁,然后cond.wait(lock);
阻塞并释放锁 - t2 线程,执行 printB,
unique_lock<mutex> lock(mtx);
获取锁,然后cond.wait(lock);
阻塞并释放锁
结果是两个线程都被阻塞了,而且始终无法到达 cond.notify_one();
,无法被唤醒,一直阻塞下去。
所以 cond.wait(lock);
常常要配合 while 和 一个 flag 标志来使用,例如:
#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;
int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;
void printA() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
while (flag) {
cond.wait(lock);
}
cout << "n = " << n++ << ", " << 'A' << endl;
flag = true;
cond.notify_one();
}
}
void printB() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
while (!flag) {
cond.wait(lock);
}
cout << "n = " << n++ << ", " << 'B' << endl;
flag = false;
cond.notify_one();
}
}
int main() {
thread t1(printA);
thread t2(printB);
t1.join();
t2.join();
return 0;
}
输出:
n = 1, B
n = 2, A
n = 3, B
n = 4, A
n = 5, B
n = 6, A
n = 7, B
n = 8, A
n = 9, B
n = 10, A
n = 11, B
n = 12, A
n = 13, B
n = 14, A
n = 15, B
n = 16, A
n = 17, B
n = 18, A
n = 19, B
n = 20, A
我们一步一步分析:
- flag 初始值是 true
- t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
- t2 执行 printB,unique_lock 获取锁,不进入 while (!flag),打印 “n = 1, B”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (!flag),执行 cond.wait(lock),阻塞线程,并释放锁
- t1 得到唤醒,因为 t2 已经释放锁,因此成功获取锁,同时 flag 已经为 false,跳出循环,打印 “n = 2, A”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
- 重复3、4
第二种 wait 接收两个参数,第一个参数是 unique_lock;第二个参数是一个可调用的对象(lambda 表达式、函数指针、仿函数),被称为谓词(predicate),其背后运行的原理如下:
- 如果阻塞中,则当接到唤醒时,wait 将尝试重新获取互斥锁
- 如果持有锁,则检查谓词
- 当谓词返回 true 时,wait 立即返回,线程继续运行;
- 当谓词返回 false 时,阻塞当前线程,并释放互斥锁
可以看出第二种 wait 就是对前面的 while 进行了封装,所以二者基本是等价的:
cond.wait(lock, [&]{return flag;});
// 上面的代码等价于下面这段代码
while (!flag) { // 注意这里的 "!"
cond.wait(lock);
}
但是,需要注意一个细节,谓词返回 true 时,表示继续持有互斥锁,线程继续运行,所以上面的等价代码,while 里面的 flag 前有个 “!”。
现在,让我们回到开始,分析一下例1的代码:
#include <iostream>
#include <thread>
#include <condition_variable>
using namespace std;
int n = 1;
mutex mtx;
condition_variable cond;
bool flag = true;
void printA() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return flag;});
cout << "n = " << n++ << ", " << 'A' << endl;
flag = false;
cond.notify_one();
}
}
void printB() {
for (int i = 0; i < 10; i++) {
unique_lock<mutex> lock(mtx);
cond.wait(lock, [&]{return !flag;});
cout << "n = " << n++ << ", " << 'B' << endl;
flag = true;
cond.notify_one();
}
}
int main() {
thread t1(printA);
thread t2(printB);
t1.join();
t2.join();
return 0;
}
我们一步一步分析:
- flag 初始值是 true
- t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 1, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
- t2 执行 printB,unique_lock 获取锁,wait 持有锁,检查谓词是 true (!false),线程继续执行,打印 “n = 2, B”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false (!true),阻塞当前线程,释放互斥锁
- t1 被唤醒,wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 3, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
- 重复3、4
参考
- C++11 多线程(std::thread)详解_c++11线程使用-CSDN博客
- condition_variable(条件变量)C++11-CSDN博客
- ⭐虚假唤醒-CSDN
- ⭐c++11中的lock_guard和unique_lock使用浅析-CSDN博客
- C++11多线程 unique_lock详解_uniquelock-CSDN博客
- c++中std::condition_variable最全用法归纳_std condition-CSDN博客