基于并发支持库 - cppreference.com,如下为C++并发库中涉及的一些内容。
本文重点看锁,其他的例如线程的库,默认已掌握。
1、 互斥锁mutex
家里的防盗门,从里面可以直接开下来,从外面只能用钥匙打开。如果钥匙只有一把,且当你用钥匙开了门以后会将钥匙顺便带到房内,则其他人从外面不能直接进入房内。
互斥锁的概念类似于此,某个线程A先一步持锁(拿到了钥匙),线程A执行相关操作修改变量m(进入房间),线程B后来也想要持锁并修改变量m,但是因为线程A已经持锁导致线程B无法持锁(钥匙已经被带进房间,外面的人无法从外面打开房门),只有当线程A释放锁(从房间里面打开房门,并将钥匙放在门口供其他人使用),线程B才能拿到持锁并修改变量m。
std::mutex - cppreference.com,mutex是C++11起提供的用于保护共享数据免受从多个线程同时访问的类。
Demo1,创建了两个线程t1和t2,它们都调用print()函数。在print()函数中,我们使用std::mutex来保证线程安全,即每次只有一个线程能够输出信息。
// Demo1
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void print(int num) {
mtx.lock();
std::cout << "Thread " << num << " is running" << std::endl;
mtx.unlock();
}
int main() {
std::thread t1(print, 1);
std::thread t2(print, 2);
t1.join();
t2.join();
return 0;
}
打印信息如下:
Thread 1 is running
Thread 2 is running
我们稍微修改一个print函数来模拟加锁后函数异常退出的例子。
void print(int num) {
mtx.lock();
std::cout << "Thread " << num << " is running" << std::endl;
// 模拟函数在释放锁前异常结束导致未释放锁
return;
mtx.unlock();
}
运行发现程序一直处于等待状态,发送了死锁。
基于上述问题,C++引入了RAII(Resource Acquisition Is Initialization,资源获取即初始化)风格的编程。在我们这个例子里面,即设计一个类A,在类A构造函数中持锁mutex,在类A的析构函数中释放锁mutex,使用时创建一个类A的对象即可。
基于上面的Demo1,Demo2类MutexGuard实现RAII风格,在print函数中创建MutexGuard对象即可实现持锁,另外为了演示效果,增加线程3,看看会不会出现死锁。
// Demo2
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
class MutexGuard {
public:
explicit MutexGuard(std::mutex& mutex) : mutex_(mutex) {
mutex_.lock();
}
~MutexGuard() {
mutex_.unlock();
}
private:
std::mutex& mutex_;
};
void print(int num) {
MutexGuard guard(mtx);
if (2==num) return;
std::cout << "Thread " << num << " is running" << std::endl;
}
int main() {
std::thread t1(print, 1);
std::thread t2(print, 2);
std::thread t3(print, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
运行结果如下,不会死锁。
Thread 1 is running
Thread 3 is running
Demo2中的类MutexGuard,在C++11中其实已经有了,参考std::lock_guard - cppreference.com。
Demo3,基于lock_guard修改Demo2.
// Demo3
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void print(int num) {
std::lock_guard<std::mutex> lk(mtx);
if (2==num) return;
std::cout << "Thread " << num << " is running" << std::endl;
}
int main() {
std::thread t1(print, 1);
std::thread t2(print, 2);
std::thread t3(print, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
2、unique_lock
看了类lock_guard,发现没有手动释放锁的操作,导致在其对象的生命周期内,一直持锁,粒度较大,不能在需要时随时释放锁。例如下面的例子:
// Demo4
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx;
void func()
{
std::lock_guard<std::mutex> lock1(mtx);
std::lock_guard<std::mutex> lock2(mtx); // 第二个锁定操作会导致死锁
std::cout << "Hello from thread " << std::this_thread::get_id() << std::endl;
}
int main()
{
std::thread t1(func);
t1.join();
return 0;
}
为了方便说明,我们将lock_guard的部分还改成类MutexGuard。即func()函数变成下面的:
void func()
{
MutexGuard lock1(mtx);
MutexGuard lock2(mtx); // 第二个锁定操作会导致死锁
std::cout << "Hello from thread " << std::this_thread::get_id() << std::endl;
}
注:对于mutex,我们经过debug发现,在一个线程中,持锁后再尝试持锁线程会一直等待;释放锁后再释放锁对线程没有影响。
针对这种情况造成的死锁,我们可以修改类MutexGuard如下:
class MutexGuard {
public:
explicit MutexGuard(std::mutex& mutex) : mutex_(mutex) {
mutex_.lock();
}
explicit MutexGuard(std::mutex& mutex, bool trylock) : mutex_(mutex) {
if (trylock)
{
mutex_.try_lock();
} else {
mutex_.lock();
}
}
~MutexGuard() {
mutex_.unlock();
}
void unlock() {
mutex_.unlock();
}
void lock() {
mutex_.lock();
}
bool trylock() {
return mutex_.try_lock();
}
private:
std::mutex& mutex_;
};
然后进行针对性的修复,func()函数修改如下,死锁解开。
void func()
{
MutexGuard lock1(mtx);
lock1.unlock(); // lock1释放锁
MutexGuard lock2(mtx);
std::cout << "Hello from thread " << std::this_thread::get_id() << std::endl;
}
Hello from thread 140737335801600
针对上述MutexGuard,在C++11也引入了std::unique_lock - cppreference.com,但是这种死锁问题,还是得看程序员能否注意到持锁释放锁的时机,例如C++多线程编程中的死锁问题_阅后即奋的博客-CSDN博客提到的死锁问题,就是因为同一个线程下重复的持锁导致的。对于同一个线程下持有同一把锁的问题,主要的注意点就是以最小的粒度去修改共享变量。
unique_lock有很多重载的构造函数,其中有几个重载函数的第二个参数设计到lock type
std::defer_lock_t, std::try_to_lock_t, std::adopt_lock_t - cppreference.com,关于它们的描述如下:
类型 | 效果 |
defer_lock_t | 不获得互斥的所有权 |
try_to_lock_t | 尝试获得互斥的所有权而不阻塞 |
adopt_lock_t | 假设调用方线程已拥有互斥的所有权 |
defer_lock、try_to_lock和adopt_lock分别是它们的对象。
adopt_lock:用之前先lock(),例如:
std::lock(e1.m, e2.m);
std::lock_guard<std::mutex> lk1(e1.m, std::adopt_lock);
std::lock_guard<std::mutex> lk2(e2.m, std::adopt_lock);
defer_lock:不能先lock(),需要上锁时再用lock()加锁,例如:
std::unique_lock<std::mutex> lk1(e1.m, std::defer_lock);
std::unique_lock<std::mutex> lk2(e2.m, std::defer_lock);
std::lock(lk1, lk2);
try_to_lock:尝试加锁,通过
unique_lock锁对象的owns_lock()判断是否加锁成功。
3、scoped_lock
还有一种例子,某一类操作会导致多个共享变量发送变化,当多线程同时对多个共享变量持互斥锁且构成死锁条件的。
以银行转账为例,两个线程执行互相转账的操作,每个转账操作都需要加锁,以确保操作的原子性和正确性。然而,由于线程1需要获取账户1的锁、线程2需要获取账户2的锁,导致了两个线程之间的互斥锁竞争,从而形成死锁。
#include <iostream>
#include <mutex>
#include <thread>
// 初始化账户余额
static int account1 = 1000;
static int account2 = 2000;
// 定义互斥锁
std::mutex mutex1;
std::mutex mutex2;
// 转账操作
void transfer(int amount, int& fromAccount, int& toAccount, std::mutex& fromMutex, std::mutex& toMutex)
{
// 申请取款账户的互斥锁
fromMutex.lock();
std::cout << "Lock fromAccount " << &fromMutex<< std::endl;
// 模拟其他操作耗费时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 申请存款账户的互斥锁
toMutex.lock();
std::cout << "Lock toAccount " << &toAccount << std::endl;
// 执行转账
fromAccount -= amount;
toAccount += amount;
// 释放存款账户互斥锁
toMutex.unlock();
std::cout << "Unlock toAccount" << std::endl;
// 释放取款账户互斥锁
fromMutex.unlock();
std::cout << "Unlock fromAccount" << std::endl;
}
int main()
{
// 创建线程1执行转账1-2的操作
std::thread t1(transfer, 500, std::ref(account1), std::ref(account2), std::ref(mutex1), std::ref(mutex2));
// 创建线程2执行转账2-1的操作
std::thread t2(transfer, 300, std::ref(account2), std::ref(account1), std::ref(mutex2), std::ref(mutex1));
// 等待两个线程执行完成
t1.join();
t2.join();
// 打印账户余额
std::cout << "account1: " << account1 << std::endl;
std::cout << "account2: " << account2 << std::endl;
return 0;
}
怎么解决呢?可以使用 std::lock一次锁住多个锁。修改如下:
void transfer(int amount, int& fromAccount, int& toAccount, std::mutex& fromMutex, std::mutex& toMutex)
{
// 申请取款账户,存款账户的互斥锁
std::lock(fromMutex,toMutex);
std::cout << "Lock fromAccount " << &fromMutex<< std::endl;
std::cout << "Lock toAccount " << &toAccount << std::endl;
// 模拟其他操作耗费时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 执行转账
fromAccount -= amount;
toAccount += amount;
// 释放存款账户互斥锁
toMutex.unlock();
std::cout << "Unlock toAccount" << std::endl;
// 释放取款账户互斥锁
fromMutex.unlock();
std::cout << "Unlock fromAccount" << std::endl;
}
C++17开始通过std::scoped_lock对std::lock提供了RAII风格的封装。同样地,对于上面死锁问题的修改如下:
void transfer(int amount, int& fromAccount, int& toAccount, std::mutex& fromMutex, std::mutex& toMutex)
{
// 申请取款账户,存款账户的互斥锁
std::scoped_lock lock(fromMutex,toMutex);
std::cout << "Lock fromAccount " << &fromMutex<< std::endl;
std::cout << "Lock toAccount " << &toAccount << std::endl;
// 模拟其他操作耗费时间
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// 执行转账
fromAccount -= amount;
toAccount += amount;
std::cout << "Unlock" << std::endl;
}
4、可重入锁recursive_mutex
recursive_mutex是一种可重入的互斥锁,它允许同一个线程多次获得锁,而不会导致死锁。
#include <iostream>
#include <thread>
#include <mutex>
class X {
std::recursive_mutex m;
std::string shared;
public:
void fun1() {
std::lock_guard<std::recursive_mutex> lk(m);
shared = "fun1";
std::cout << "in fun1, shared variable is now " << shared << '\n';
}
void fun2() {
std::lock_guard<std::recursive_mutex> lk(m);
shared = "fun2";
std::cout << "in fun2, shared variable is now " << shared << '\n';
fun1(); // 递归锁在此处变得有用
std::cout << "back in fun2, shared variable is " << shared << '\n';
};
};
int main()
{
X x;
std::thread t1(&X::fun1, &x);
std::thread t2(&X::fun2, &x);
t1.join();
t2.join();
}
读者可以先将例子中的std::recursive_mutex修改为std::mutex跑一轮,先看一下死锁问题。
5、补充:mutex和lock有啥区别呢?
参考回答:c++ - What's the difference between "mutex" and "lock"? - Stack Overflow。
6、共享锁shared_mutex
前面讲过,mutex是用于保护共享数据免受从多个线程同时访问的类。C++17起,引入了std::shared_mutex - cppreference.com,提供二个访问级别:
- 共享 - 多个线程能共享同一互斥的所有权。
- 独占性 - 仅一个线程能占有互斥。
多线程访问共享资源,如果每一次都要等另外一个线程释放互斥锁,这必然会造成性能的浪费。为了提高并发性能,避免资源竞争问题,shared_mutex引入了。
引用文章C++多线程——读写锁shared_lock/shared_mutex_princeteng的博客-CSDN博客中的例子:
#include <iostream>
#include <mutex> //unique_lock
#include <shared_mutex> //shared_mutex shared_lock
#include <thread>
std::mutex mtx;
class ThreadSaferCounter
{
private:
mutable std::shared_mutex mutex_;
unsigned int value_ = 0;
public:
ThreadSaferCounter(/* args */) {};
~ThreadSaferCounter() {};
unsigned int get() const {
//读者, 获取共享锁, 使用shared_lock
std::shared_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock_shared();
return value_; //lck 析构, 执行mutex_.unlock_shared();
}
unsigned int increment() {
//写者, 获取独占锁, 使用unique_lock
std::unique_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock();
value_++; //lck 析构, 执行mutex_.unlock();
return value_;
}
void reset() {
//写者, 获取独占锁, 使用unique_lock
std::unique_lock<std::shared_mutex> lck(mutex_);//执行mutex_.lock();
value_ = 0; //lck 析构, 执行mutex_.unlock();
}
};
ThreadSaferCounter counter;
void reader(int id){
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::unique_lock<std::mutex> ulck(mtx);//cout也需要锁去保护, 否则输出乱序
std::cout << "reader #" << id << " get value " << counter.get() << "\n";
}
}
void writer(int id){
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
std::unique_lock<std::mutex> ulck(mtx);//cout也需要锁去保护, 否则输出乱序
std::cout << "writer #" << id << " write value " << counter.increment() << "\n";
}
}
int main()
{
std::thread rth[10];
std::thread wth[10];
for(int i=0; i<10; i++){
rth[i] = std::thread(reader, i+1);
}
for(int i=0; i<10; i++){
wth[i] = std::thread(writer, i+1);
}
for(int i=0; i<10; i++){
rth[i].join();
}
for(int i=0; i<10; i++){
wth[i].join();
}
return 0;
}
7、自旋锁
自旋锁是一种忙等待锁,当线程尝试获取锁时,如果锁已经被其他线程占用,该线程会一直循环等待,直到锁被释放。自旋锁适用于锁的持有时间很短的情况,因为它不会引起线程的上下文切换。
看到这里的定义,有些读者会有疑惑,自旋锁是忙等待,互斥锁是阻塞线程(等待持锁),有什么区别呢?
这边可以先了解下进程的状态机(图片引用自5.1 进程、线程基础知识 | 小林coding),也同样可以用于线程的状态机。对于互斥锁阻塞后再持锁,其状态由运行状态->阻塞状态->就绪状态->运行状态;而自旋锁的忙等待期间,一直处于运行状态。
阻塞的进程或者线程的资源如果还一直保留在内存中,这明显是一种资源的浪费。资源的保存与恢复可以称之为上下文切换。
对于上下文切换的描述,这里不赘述(引用自5.1 进程、线程基础知识 | 小林coding):
CPU 上下文切换就是先保存前一个任务的 CPU 上下文(CPU 寄存器和程序计数器),然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
进程上下文,切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。(进程切换由操作系统调度)
线程上下文,切换的是线程的私有数据、寄存器等不共享的数据。(进程的多个线程间共享虚拟内存等资源)
了解了上面的背景,可以互斥锁阻塞再恢复有两次线程的上下文切换。 那么这两种锁的应用场景就比较明显了。自旋锁适用于锁的持有时间很短的情况,因为它不会引起线程的上下文切换。如果自旋锁等待时间超过两个线程上下文切换时间,则推荐使用互斥锁。
下面是一个自旋锁的简单例子:
#include <atomic>
class SpinLock {
public:
SpinLock() : flag(ATOMIC_FLAG_INIT) {}
void lock() {
while (flag.test_and_set(std::memory_order_acquire));
}
void unlock() {
flag.clear(std::memory_order_release);
}
private:
std::atomic_flag flag;
};
当然自旋锁也有退避算法,比如自旋一定次数后,让线程睡一会。