C++锁简介

基于并发支持库 - 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;
};

当然自旋锁也有退避算法,比如自旋一定次数后,让线程睡一会。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阅后即奋

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值