C++多线程原理详解

学习 C++ 多线程时,我有如下疑问:

  1. mutex 的 lock 和 unlock 做了什么?
  2. mutex、lock_guard、unique_lock,它们之间的关系是什么?
  3. 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() 操作包含以下步骤:

  1. 检查锁的状态:如果锁是空闲的(未被其他线程持有),线程将锁状态设置为已锁定,并且允许线程进入临界区。
  2. 等待:如果锁已经被其他线程持有,当前线程将进入等待状态,通常会被阻塞直到锁变为可用。

unlock() 操作包含以下步骤:

  1. 释放锁:线程将锁的状态设置为空闲(未被持有)。
  2. 唤醒等待线程:如果有其他线程在等待这个锁,操作系统将从等待队列中选择一个线程,并唤醒它以便重新尝试获取锁。

二、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) 包含以下步骤:

  1. 对象构造:当 lock_guard<std::mutex> lock(mtx) 被创建时,构造函数会立即尝试锁定传递给它的互斥锁 mtx
  2. 锁定操作lock_guard 在其构造函数中调用 std::mutexlock 方法。如果互斥锁 mtx 当前没有被其他线程持有,它会被锁定,当前线程获得对该互斥锁的所有权。如果 mtx 已经被其他线程持有,当前线程将被阻塞,直到该互斥锁可用。
  3. 作用域管理lock_guard 对象的生命周期管理着互斥锁的持有时间。当 lock_guard 对象超出作用域(即不再需要时),它的析构函数会被调用。
  4. 解锁操作: 在 lock_guard 的析构函数中,会自动调用传递给它的互斥锁 mtxunlock 方法,释放该互斥锁。这确保了即使在函数内发生异常,互斥锁也会被正确释放。

三、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 的区别是什么呢?

  1. lock_guard 是一种简单、轻量的锁管理器,提供基本的锁定和解锁功能。
  2. unqiue_lock 可以看作是 lock_guard 的增强版,除了 lock_guard 已有的功能外,还提供了更多的功能,推荐阅读这篇文章
  3. 在使用的选择上,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 类型的变量作为参数,其作用是:

  1. 阻塞当前线程,并释放互斥锁
  2. 当被 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;
}

这个代码执行后,将一直阻塞下去,不会有任何输出。为什么呢,我们一步一步看:

  1. t1 线程,执行 printA,unique_lock<mutex> lock(mtx); 获取锁,然后 cond.wait(lock); 阻塞并释放锁
  2. 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

我们一步一步分析:

  1. flag 初始值是 true
  2. t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
  3. t2 执行 printB,unique_lock 获取锁,不进入 while (!flag),打印 “n = 1, B”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (!flag),执行 cond.wait(lock),阻塞线程,并释放锁
  4. t1 得到唤醒,因为 t2 已经释放锁,因此成功获取锁,同时 flag 已经为 false,跳出循环,打印 “n = 2, A”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,进入 while (flag),执行 cond.wait(lock),阻塞线程,并释放锁
  5. 重复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;
}

我们一步一步分析:

  1. flag 初始值是 true
  2. t1 执行 printA,unique_lock 获取锁(同时t2获取锁失败,阻塞),wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 1, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
  3. t2 执行 printB,unique_lock 获取锁,wait 持有锁,检查谓词是 true (!false),线程继续执行,打印 “n = 2, B”,将 flag 置为 true,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false (!true),阻塞当前线程,释放互斥锁
  4. t1 被唤醒,wait 持有锁,检查谓词是 true,线程继续执行,打印 “n = 3, A”,将 flag 置为 false,唤醒阻塞中的一个线程,同时进行第二轮循环,unique_lock 获取锁,wait 检查谓词,谓词是 false,阻塞当前线程,释放互斥锁
  5. 重复3、4

参考

  • 18
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值