C++多线程同步工具箱:call_once精准触发、lock_guard/unique_lock智能管理,打造无死锁程序!

文章目录

本篇摘要

本文介绍C++多线程同步相关知识。涵盖call_once保证函数单次执行,lock_guard简单管理锁,unique_lock功能丰富的锁管理,lock与try_lock解决多锁死锁及尝试锁定,阐述原理并举例。

一.自动管理锁之lock_guard介绍

1. 定义与作用

lock_guard 是 C++11 提供的一个类,它支持以 RAII(Resource Acquisition Is Initialization,资源获取即初始化)的方式来管理互斥锁资源。借助这种方式,可以更有效地避免因异常等情况引发的死锁问题。

2. 功能特点

  • 功能纯粹性:功能简单纯粹,仅支持以 RAII 的方式管理锁对象。
  • 特殊构造方式 :在构造的时候,可以通过传入 adopt_lock_t 类型的 adopt_lock 对象,来管理已经被 lock 过的锁对象。
  • 不支持拷贝构造lock_guard 类不支持拷贝构造操作 。 这意味着不能通过拷贝的方式来创建 lock_guard 对象的副本。

官网: https://legacy.cplusplus.com/reference/mutex/lock_guard/?kw=lock_guard

定义如下:

  • 简单来说就是一种作用域自动析构的锁;当构造lock_guard对象的时候自动上锁;出了对应作用域自动解锁。

下面看下它大致实现原理:

cpp 复制代码
template<class Mutex>
class LockGuard
{ public:

LockGuard(Mutex& mtx)
:_mtx(mtx)
{

_mtx.lock();

} ~
LockGuard()
{
_mtx.unlock();

}
private:

Mutex& _mtx;
};

下面演示下应用:

  • 这里整个lamda就是它的作用域。
  • 这里对应lock_guard的作用域就是for循环了;当线程1走完for进行休眠之前的时候自动解开锁了。

测试源码:

cpp 复制代码
#include <thread>
#include <mutex>

int main()
{
    int x = 0;
    std::mutex mtx;  

    
    auto Print = [&x, &mtx](size_t n) {
        std::lock_guard<std::mutex> lock(mtx);  
        // 构造时自动加锁,析构时自动解锁,哪怕中间有异常
        {
            for (size_t i = 0; i < n; i++)
            {
                ++x;
            }
        }

        this_thread::sleep_for(chrono::seconds(2s));
      
        };

  
    std::thread t1(Print, 1000000);
    std::thread t2(Print, 2000000);


    t1.join();
    t2.join();

    std::cout << x << std::endl;

    return 0;
}

这里可以看出lock_gurad支持的用法也是比较简单。

二.自动管理锁之unique_lock介绍

1. unique_lock 基本性质与作用

  • unique_lock 是 C++11 提供的、支持 RAII(资源获取即初始化)方式来管理互斥锁资源的类。和 lock_guard 相比,它功能更丰富、更复杂,当然了它也是支持对应的{ }表示作用域的;相关官方参考文档可访问:https://legacy.cplusplus.com/reference/mutex/unique_lock/

2. 构造时通过不同 tag 控制锁行为

unique_lock 在构造时可以传入不同的 tag,以此来决定构造阶段如何处理锁对象,常见 tag含义如下:

  • (no tag):构造时通过调用成员函数 lock() 来加锁。
  • try_to_lock:构造时通过调用成员函数 try_lock() 尝试加锁(若锁已被其他线程占用,则不会阻塞,直接返回尝试结果)。
  • defer_lock:构造时不进行加锁操作(前提是假设当前线程并没有持有该锁,否则发生未定行为。)。
  • adopt_lock:构造时"接管"当前已经持有的锁(前提是假设当前线程已经持有了该锁,否则发生未定行为。)。

3. 配合 timed_mutex 管理超时锁

  • unique_lock 还可以在构造时传入时间段时间点 ,用于配合 timed_mutex 系统来实现带超时机制的锁操作。构造时会分别调用 try_lock_for(...)try_lock_until(...) 这类接口,从而支持"尝试在指定时长内加锁"或"尝试在指定时间点前加锁"等场景(这里拿到锁,对应unique_lock就有对应锁否则等待对应时间再次尝试,时间结束后可以调用owns_lock() 查看是否有锁这里不会一直阻塞住,而是到时间填充对应的owns_lock()的值 )(也可以传递recursive_mutex,来让它自动管理)。

4. 拷贝、移动与接口支持

  • 拷贝与移动unique_lock 不支持拷贝构造拷贝赋值 ;但支持移动构造移动赋值(这使得它能在需要转移锁所有权等场景下灵活使用)。
  • 接口提供unique_lock 还提供了像 lock()try_lock()unlock() 等一系列和锁操作相关的系统接口,方便开发者更细粒度地控制互斥锁的加锁、解锁与尝试加锁等行为。

下面演示下关于它的移动构造和移动赋值:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
using namespace std;

int main() {

    //移动构造:
    mutex mtx;
    unique_lock<mutex> lock1(mtx);
     
    unique_lock<mutex> lock2(move(lock1));

    //移动赋值:

    unique_lock<mutex>lock3 = move(lock2);
    

}
  • 这里用了move表明转移占用资源所有权并告诉编译器走的是移动;不然编译器也不知道是拷贝构造 拷贝赋值还是移动构造 移动赋值。

下面演示下那四个tag传给unique_lock效果:

  • 这里主线程加上锁;然后让那四个子线程去跑设置对应标签的函数;这里必然有no_tag标签的一定阻塞;然后adopt_lock的前提必须保证自己加上锁;因此也会阻塞住;而try_to_lock的就不阻塞,发现自己无锁执行对应逻辑,defer_lock开始等待锁到来。(因此效果看到的不是try_to_lock打印对应逻辑就是defer_lock打印对应逻辑)。

对应代码:

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>
std::mutex g_mtx; // 全局互斥锁

void print_with_tag(const char* tag) {

    if (strcmp(tag, "no_tag") == 0) {

        // 1. 无 tag:构造 unique_lock 时自动调用 lock() 加锁

        std::unique_lock<std::mutex> ulk(g_mtx);

        std::cout << "no_tag: 已加锁,线程 ID = " << std::this_thread::get_id() << std::endl;

        // ulk 析构时会自动 unlock()

    }

    else if (strcmp(tag, "try_to_lock") == 0) {

        // 2. try_to_lock:构造时尝试加锁,若锁被占用则立即返回 false

        std::unique_lock<std::mutex> ulk(g_mtx, std::try_to_lock);

        if (ulk.owns_lock()) {

            std::cout << "try_to_lock: 成功抢到锁,线程 ID = " << std::this_thread::get_id() << std::endl;

        }
        else {

            std::cout << "try_to_lock: 未抢到锁,线程 ID = " << std::this_thread::get_id() << std::endl;

        }

        // 这里可以根据 owns_lock() 决定是否要再次尝试或做其他事情

    }

    else if (strcmp(tag, "defer_lock") == 0) {

        // 3. defer_lock:构造时不加锁,需要后续手动调用 lock()

        std::unique_lock<std::mutex> ulk(g_mtx, std::defer_lock);

        std::cout << "defer_lock: 暂未加锁,线程 ID = " << std::this_thread::get_id() << std::endl;

        // ... 做一些其他准备后,再手动加锁

        ulk.lock();

        std::cout << "defer_lock: 手动加锁成功,线程 ID = " << std::this_thread::get_id() << std::endl;

        // ulk 析构时会自动 unlock()

    }

    else if (strcmp(tag, "adopt_lock") == 0) {

        // 4. adopt_lock:构造时"接管"已有的锁(前提:当前线程必须已经持有该锁!)

        // 这里先手动加锁,再交给 unique_lock 管理

        g_mtx.lock();

        std::unique_lock<std::mutex> ulk(g_mtx, std::adopt_lock);

        std::cout << "adopt_lock: 接管已有锁,线程 ID = " << std::this_thread::get_id() << std::endl;

        // ulk 析构时会自动 unlock()

    }

}

int main() {



    g_mtx.lock(); // 主线程先占住锁

    std::thread t1(print_with_tag, "no_tag");
    std::thread t2(print_with_tag, "try_to_lock");
    std::thread t3(print_with_tag, "defer_lock");
    std::thread t4(print_with_tag, "adopt_lock");

    t1.join();
    t2.join();
    t3.join();
    t4.join();

    g_mtx.unlock();  // 主线程解锁
    return 0;
}

下面演示下unique_lock来管理timed_mutex与recursive_mutex两种类型的锁:

1·timed_mutex

  • 这里发现主线程锁住后等子线程休眠完成,把对应的timed锁交个unique_lock进行管理的时候,告诉它去拿锁;拿不到就休眠3秒再看看。
  • 休眠三秒后还拿不到,不阻塞直接不在接管这个锁,直接往下走;可以通过owns_lock发现并未管理到锁。
  • 设置时间点也是一样的。

其实这里就是在构建的时候,unique_lock自动调用时间锁的try_lock_for与try_lock_until这俩接口(根据传的参数)。

测试代码:

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>
#include<chrono>
using namespace std;

timed_mutex mtx;
int main() {
     
    thread t1([]() {
         
        this_thread::sleep_for(3s);
       /* unique_lock<timed_mutex> tm(mtx, chrono::seconds(3));

        if (!tm.owns_lock()) cout << "超时拿不到锁" << endl;*/
         unique_lock<timed_mutex> tm(mtx, chrono::system_clock::now()+chrono::seconds(3));

       if (!tm.owns_lock()) cout << "超时拿不到锁" << endl;
        
        });

    mtx.lock();

    t1.join();
    
   

}

2·recursive_mutex:

对于recursive_mutex也是一样,unique_lock内部通过自动调用recursive_mutex的接口(也就是说当出现递归等情况的时候unique_lock会调用recursive_mutex的那套判断逻辑);因此这里就不演示了。

小结下unique_lock:

除了上面的unique_lock支持的很多特性及接口;还有比如owns_lock来查看是否对象持有锁,release来释放掉锁资源,重载了bool类型判断是否有锁等;由此可以看出功能特性等比lock_guard要强得多;因此可以把unique_lock理解成可以管理不同类型锁的管理者,内部封装了所有种类锁具有的接口(进行套壳为用户提供调用),还有很多自身自带接口等,为使用者提供更大便利。

三.lock与try_lock介绍

这里的lock与try_lock和mutex的成员函数不同。

关于 lock

  1. 本质:是一个函数模板。
  2. 核心能力 :支持一次性对多个锁对象进行锁定操作。
  3. 锁定逻辑 :如果在尝试锁定其中某个锁时失败(没锁住),lock 会先把自己已经成功锁定的那些对象解锁 ,然后进入阻塞状态,直到把所有目标锁都成功锁定为止(保证锁都是被lock自己锁住的)。

关于 try_lock

  1. 本质:同样是一个函数模板。
  2. 核心能力 :尝试一次性对多个锁对象做锁定,但不会像 lock 那样阻塞等待。
  3. 锁定结果判定
  • 若所有锁对象都成功锁定,则返回 -1
  • 若某一个(或多个)锁对象锁定失败,try_lock 会先把已经成功锁定 的那些锁对象解锁,然后返回第一个锁定失败的对象的下标 (下标从 0开始计数,且以"第一个传入的参数对象"为第 0 个)。

简单来说,lock 更偏向"要么全部锁成功、要么解锁重试(阻塞等待)",而 try_lock 则是"尝试全部锁、失败就解锁已锁部分并告知哪个失败了",二者都是为了方便多锁场景下的同步控制而设计的函数模板。

下面演示下:

首先演示下死锁的产生:

  • 这里发现两个线程互相持有对方下次将要持有的锁就会造成所谓的死锁(死锁四大条件: 互斥,占用并等待,互相不能从对方手中争抢,循环等待。)。
  • 而我们最初的目的就是让他每个线程同时锁住对应的多把锁,自然就不会造成死锁了。
  • 因此lock模版函数就是为了解决上面这样死锁问题产生的。

引入lock:

  • 这里只要按照顺序调用lock进行锁就不会出现死锁;如果更换下mtx1 mtx2的顺序,根据lock必须保证一口气由自己锁住的特性可能导致死锁发生。

测试代码:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx1;
std::mutex mtx2;

// 线程1的函数:先锁 mtx1,再尝试锁 mtx2
void thread1_func() {
    lock(mtx1, mtx2);
  

    // 模拟操作...

    mtx2.unlock();
    mtx1.unlock();
    std::cout << "线程1:已释放所有锁" << std::endl;
}

// 线程2的函数:先锁 mtx2,再尝试锁 mtx1
void thread2_func() {
    
    lock(mtx1, mtx2);

    // 模拟操作...

    mtx1.unlock();
    mtx2.unlock();
    std::cout << "线程2:已释放所有锁" << std::endl;
}

int main() {
    // 创建两个线程,分别执行 thread1_func 和 thread2_func
    std::thread t1(thread1_func);
    std::thread t2(thread2_func);

   
    t1.join();
    t2.join();

    std::cout << "主线程:所有线程执行完毕" << std::endl;
    return 0;
}

引入try_lock:

  • 这里让主线程锁住mtx1,然后子线程去try_lock;发现到了mtx1就失败,因此解开所有的锁,返回第一个失败的下标(也就是0)。

测试代码:

cpp 复制代码
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx1, mtx2;

void try_lock_example() {
    std::cout << "尝试使用 std::try_lock 锁定 mtx1 和 mtx2..." << std::endl;

    // 尝试一次性锁定 mtx1 和 mtx2
    int result = std::try_lock(mtx1, mtx2);

    if (result == -1) {
        std::cout << "成功锁定 mtx1 和 mtx2!" << std::endl;

        
        std::this_thread::sleep_for(std::chrono::seconds(1));

    
        mtx1.unlock();
        mtx2.unlock();
        std::cout << "已解锁 mtx1 和 mtx2" << std::endl;
    }
    else if (result==0){
        
       
        std::cout << "锁定失败,第一个失败的互斥量是第 " << result << " 个参数" << std::endl;

        
    }
    else {

    }
}

int main() {
    mtx1.lock();

    std::thread t(try_lock_example);
    t.join();

    mtx1.unlock();  
    return 0;
}

当然了对应的lock和try_lock也是可以传递不同类型锁,或者unique_lock等,如下:

  • 所以说他是个模版函数,根据传入的类型实例化。

对于它俩内部实现机制,可以理解成把对应不同类型的mutex调用的lock以及try_lock,unlock等函数接口按照一定逻辑配合起来使用来实现的。

四.call_once介绍

在 C++ 中,std::call_once是 头文件提供的一个工具,用来保证​​多个线程中只有一个线程会执行某个函数​​,其余线程会跳过。它的典型用法是配合 std::once_flag一起使用。

下面演示下:

  • 这里当第一个线程进入call_once;就会把对应g_onceFlag给标记上,然后去执行对应函数,然后当之后的线程来的时候发现被标记了,就不会执行了。


  • 这里因为多线程没加锁故看起来乱;可以看到每个线程都是不确定谁第一个去执行函数完成标记的。

五.本篇小结

本文详细讲解了C++中多种多线程同步工具。从call_once的单次执行保障,到lock_guard、unique_lock的锁管理,再到lock和try_lock处理多锁情况,通过示例展示其用法与效果,助力多线程编程。

相关推荐
电子_咸鱼4 小时前
【QT——信号和槽(1)】
linux·c语言·开发语言·数据库·c++·git·qt
想唱rap4 小时前
Linux下进程的控制
linux·运维·服务器·c++·算法
Queenie_Charlie4 小时前
小明统计数组
数据结构·c++·set
郝学胜-神的一滴4 小时前
Separate Buffer、InterleavedBuffer 策略与 OpenGL VAO 深度解析
开发语言·c++·程序人生·算法·游戏程序·图形渲染
承渊政道6 小时前
C++学习之旅【C++类和对象(下)】
c++·学习·visual studio
枫叶丹46 小时前
【Qt开发】Qt窗口(九) -> QFontDialog 字体对话框
c语言·开发语言·数据库·c++·qt
旖旎夜光13 小时前
多态(11)(下)
c++·学习
yangpipi-13 小时前
《C++并发编程实战》 第4章 并发操作的同步
开发语言·c++
Chance_to_win13 小时前
C++基础知识
c++