C++11 ——— 线程库与单例模式的原理、实现及线程安全设计

目录

[C++ 线程库的基本使用](#C++ 线程库的基本使用)

[main.cpp(thread 对象的基本使用)](#main.cpp(thread 对象的基本使用))

[一、C++11 线程库的基本使用(结合代码拆解)](#一、C++11 线程库的基本使用(结合代码拆解))

[二、thread构造函数的核心:万能引用 + 参数包(泛型模板)](#二、thread构造函数的核心:万能引用 + 参数包(泛型模板))

[三、thread封装pthread_create的细节(Linux 下)](#三、thread封装pthread_create的细节(Linux 下))

四、为什么thread的泛型设计能简化使用?

五、总结(关键点回顾)
线程与互斥锁的基本使用

main.cpp(互斥锁)

[一、核心问题:thread构造函数的参数传递机制(值拷贝 vs 引用)](#一、核心问题:thread构造函数的参数传递机制(值拷贝 vs 引用))

[二、更简洁的替代方案:Lambda 表达式的引用捕获](#二、更简洁的替代方案:Lambda 表达式的引用捕获)

三、补充:mutex禁用拷贝的原因

总结(关键点回顾)
封装互斥锁避免死锁(RAII)

[main.cpp(封装锁 ------ 类似智能指针)](#main.cpp(封装锁 —— 类似智能指针))

一、先明确核心痛点:手动管理锁的致命问题

[二、LockGuard 的核心实现:RAII 封装锁(类比智能指针)](#二、LockGuard 的核心实现:RAII 封装锁(类比智能指针))

[三、LockGuard 如何解决 "死锁 / 异常导致的锁未释放" 问题](#三、LockGuard 如何解决 “死锁 / 异常导致的锁未释放” 问题)

[四、代码执行流程(验证 LockGuard 的作用)](#四、代码执行流程(验证 LockGuard 的作用))

总结(关键点回顾)
多线程done(两个线程,奇偶数交替输出)

main.cpp(交替打印奇偶数)

[一、condition_variable(条件变量):线程间的 "等待 / 唤醒" 同步工具](#一、condition_variable(条件变量):线程间的 “等待 / 唤醒” 同步工具)

[二、unique_lock vs lock_guard:锁的灵活性对比](#二、unique_lock vs lock_guard:锁的灵活性对比)

[三、cv.wait () 和 cv.notify_one () 的核心行为](#三、cv.wait () 和 cv.notify_one () 的核心行为)

四、交替打印的核心逻辑(两种场景验证)

五、总结(关键点回顾)
单例模式之饿汉模式

[main.cpp(单例模式 - 饿汉模式)](#main.cpp(单例模式 - 饿汉模式))

一、单例模式的核心定义

二、饿汉模式的核心实现逻辑(结合代码拆解)

三、饿汉模式的优缺点(用户指定要点)

四、总结(关键点回顾)
[单例模式之懒汉模式 - 缺少线程板块代码](#单例模式之懒汉模式 - 缺少线程板块代码)

[main.cpp(单例模式 - 懒汉模式)](#main.cpp(单例模式 - 懒汉模式))

[一、GetInstance 函数的线程安全:双重检查锁(Double-Checked Locking)的设计逻辑](#一、GetInstance 函数的线程安全:双重检查锁(Double-Checked Locking)的设计逻辑)

[二、DelInstance 函数设为 static 的原因](#二、DelInstance 函数设为 static 的原因)

[三、Gc 私有内部类的作用(单例的 "守护类")](#三、Gc 私有内部类的作用(单例的 “守护类”))

[四、static Gc _gc 能自动调用析构的原因](#四、static Gc _gc 能自动调用析构的原因)

五、提前释放的两种调用方式区别

[六、为什么 A::DelInstance () 能调用私有析构函数](#六、为什么 A::DelInstance () 能调用私有析构函数)

总结(关键点回顾)


C++ 线程库的基本使用

main.cpp(thread 对象的基本使用)

复制代码
#include <iostream>   // 用于cout控制台输出
#include <thread>     // C++11线程库核心头文件(thread类、this_thread命名空间)
#include <string>     // string字符串类头文件
using namespace std;  // 简化std::前缀,新手更易阅读

// 线程执行函数:循环输出线程ID、自定义字符串和循环变量
// begin:循环起始值,end:循环结束值,s:线程标识字符串
void Func(int begin, int end, const string& s)
{
    // 循环遍历[begin, end)区间的整数
    for (int i = begin; i < end; i++)
    {
        // this_thread::get_id():获取当前执行线程的唯一ID(类型为thread::id)
        // 输出格式:线程ID + 自定义字符串 + 循环变量i
        cout << this_thread::get_id() << s << " : " << i << endl;
    }
}

int main()
{
    // 1. 创建线程对象t1,绑定执行函数Func,并传递参数:10(begin)、100(end)、"线程1"(s)
    // thread类构造时,会立即启动新线程执行Func函数
    thread t1(Func, 10, 100, "线程1");
    // 创建线程对象t2,执行Func函数,参数:50(begin)、100(end)、"线程2"(s)
    thread t2(Func, 50, 100, "线程2");

    // 2. thread类禁用拷贝构造:以下代码编译报错
    // 原因:thread对象管理的是系统内核线程资源,拷贝会导致资源归属混乱,因此C++11显式禁用拷贝
    // thread t3(t1);  error

    // 3. thread类支持移动构造/移动赋值:通过std::move转移线程资源所有权
    // std::move(t1):将t1的线程资源所有权转移给t3,转移后t1变为"空线程"(joinable()返回false)
    // thread t3(move(t1));

    // 4. 使用lambda表达式创建线程(C++11+特性,更灵活的线程执行逻辑)
    int n2 = 100;          // 循环次数变量
    string s = "线程3";     // 线程标识字符串
    // 创建线程t4,执行lambda表达式(捕获n2和s的值,无参数)
    thread t4([n2, s]()    // [n2, s]:值捕获,lambda内使用n2和s的副本(避免线程间数据竞争)
        {
            // lambda内的循环逻辑:输出线程ID、标识字符串和循环变量
            for (int i = 0; i < n2; i++)
            {
                cout << this_thread::get_id() << s << " : " << i << endl;
            }
        }
    );

    // 5. 等待线程执行完毕(join()):主线程阻塞,直到对应子线程执行完成
    // 必须调用join()或detach(),否则线程对象析构时会调用std::terminate()终止程序
    t1.join();  // 等待t1线程执行完毕
    t2.join();  // 等待t2线程执行完毕
    t4.join();  // 等待t4线程执行完毕

    return 0;
}

一、C++11 线程库的基本使用(结合代码拆解)

C++11 引入的<thread>库是对操作系统底层线程 API(如 Linux 的 pthread、Windows 的 CreateThread)的跨平台封装,核心目标是让开发者无需关注不同系统的线程 API 差异,用统一的语法创建和管理线程。代码中体现了线程库的核心使用场景:

1. 线程创建:thread对象构造即启动线程

复制代码
// 普通函数作为线程执行体,传递3个参数
thread t1(Func, 10, 100, "线程1");
// Lambda表达式作为线程执行体(更灵活)
thread t4([n2, s](){ ... });
  • thread是管理线程资源的 "智能对象":构造时会立即调用底层 API(如 pthread_create)创建系统内核线程,并让新线程执行指定的可调用对象(普通函数、Lambda、仿函数等);
  • 可调用对象的参数直接跟在函数名后(如10, 100, "线程1"),无需手动封装,由thread库自动处理。

2. 线程的拷贝与移动:禁用拷贝,支持移动

复制代码
// 编译报错:thread禁用拷贝构造(避免线程资源归属混乱)
// thread t3(t1);
// 合法:移动构造,将t1的线程资源所有权转移给t3,t1变为"空线程"
thread t3(move(t1));
  • 线程是 "唯一资源"(一个内核线程对应一个thread对象),拷贝会导致 "多个对象管理同一个内核线程",因此 C++11 显式禁用thread的拷贝构造 / 赋值;
  • 移动语义(std::move)可转移线程资源所有权,保证资源唯一归属。

3. 等待线程完成:join()的核心作用

复制代码
t1.join(); // 主线程阻塞,直到t1线程执行完毕
t2.join();
t4.join();
  • join():主线程暂停执行,等待子线程执行完成后再继续;
  • 必须调用join()detach()(分离线程):若thread对象析构时仍未调用,会触发std::terminate()终止程序(避免线程资源泄漏)。

4. 线程 ID 获取:this_thread::get_id()

复制代码
cout << this_thread::get_id() << s << " : " << i << endl;
  • this_thread<thread>库的命名空间,get_id()返回当前执行线程的唯一标识(thread::id类型),用于区分不同线程。

二、thread构造函数的核心:万能引用 + 参数包(泛型模板)

thread能支持 "任意可调用对象 + 任意参数",核心依赖 C++11 的万能引用(Universal Reference)参数包(Variadic Templates) 特性,先明确两个核心概念:

1. 万能引用(Universal Reference)

  • 定义 :仅当模板参数为T且发生类型推导 时,T&&才是万能引用(否则是右值引用)。它的核心能力是:既能接收左值,也能接收右值,并通过std::forward实现完美转发(保持参数的左 / 右值属性)。

  • thread 构造中的应用thread的构造函数是模板函数,简化原型如下:

    复制代码
    template <class F, class... Args>
    explicit thread(F&& f, Args&&... args);
    • F&& f:万能引用,接收任意可调用对象(左值如普通函数名、右值如临时 Lambda);
    • Args&&... args:参数包的万能引用,接收任意个数、任意类型的参数(如10, 100, "线程1")。

2. 参数包(Variadic Templates)

  • 定义class... Args(模板参数包)和Args&&... args(函数参数包)是 C++11 的 "可变参数模板",能承接任意个数、任意类型的参数(0 个或多个),解决了 "线程函数参数个数不确定" 的问题。
  • 核心价值:无需为不同参数个数的线程函数写不同的封装逻辑,一套模板适配所有场景。

3. 万能引用 + 参数包的协作逻辑

代码中thread t1(Func, 10, 100, "线程1")的构造过程:

  1. 模板推导:F推导为void(*)(int, int, const string&)(Func 的类型),Args推导为int, int, const string&
  2. 完美转发:F&& f转发 Func,Args&&... args转发10, 100, "线程1",保证参数的左 / 右值属性不丢失;
  3. 绑定执行:thread库将可调用对象和参数包绑定,生成一个 "适配函数",供底层线程 API 调用。

三、thread封装pthread_create的细节(Linux 下)

Linux 系统中,thread的底层实现依赖 POSIX 线程库(pthread),核心是封装了pthread_create函数。先看pthread_create的原型和痛点:

复制代码
// pthread_create原型(Linux)
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);

1. pthread_create的核心痛点

  • 线程函数必须是void* (*)(void*)类型:只能接收一个void*参数,返回void*,限制极大;
  • 多参数需手动封装:若线程函数需要多个参数,需将参数封装成结构体,再强转为void*传递;
  • 类型转换繁琐:参数和返回值都需强制类型转换,易出错且代码冗余。

2. thread的封装逻辑(屏蔽底层痛点)

thread通过 "泛型 + 万能引用 + 参数包" 封装了pthread_create的痛点,核心步骤:

  1. 参数封装 :将用户传入的可调用对象(如 Func)和参数包(10,100,"线程 1")绑定成一个 "无参数的可调用对象"(通过std::bind或 lambda);
  2. 类型适配 :将绑定后的可调用对象转换成void* (*)(void*)类型的函数(符合pthread_create的要求);
  3. 参数传递 :将封装后的可调用对象指针强转为void*,作为pthread_create的第四个参数;
  4. 执行与清理:新线程启动后,调用适配函数,解包参数并执行原可调用对象,执行完成后清理资源。

3. 封装后的价值:简化使用

对比pthread_create的繁琐写法(以调用 Func 为例):

复制代码
// pthread_create调用Func的繁琐写法(仅示例)
struct Args { int begin; int end; string s; };
void* Func_wrap(void* arg) {
    Args* p = (Args*)arg;
    Func(p->begin, p->end, p->s);
    delete p;
    return nullptr;
}

int main() {
    pthread_t tid;
    Args* arg = new Args{10, 100, "线程1"};
    pthread_create(&tid, nullptr, Func_wrap, (void*)arg);
    pthread_join(tid, nullptr);
    return 0;
}

而 C++ thread仅需一行:thread t1(Func, 10, 100, "线程1");------ 万能引用 + 参数包隐藏了所有封装、类型转换的细节,让线程创建和调用普通函数一样简单。

四、为什么thread的泛型设计能简化使用?

  1. 支持任意可调用对象 :普通函数、Lambda(含捕获变量)、仿函数、成员函数都能作为线程执行体,而pthread_create仅支持无捕获的 Lambda(或普通函数);
  2. 支持任意参数个数 / 类型 :无需手动封装结构体,直接传递参数即可,thread库自动处理参数的转发和封装;
  3. 无强制类型转换 :模板推导自动匹配类型,避免void*强转带来的错误;
  4. 跨平台兼容 :Windows 下封装CreateThread,Linux 下封装pthread_create,用户无需关注系统差异。

五、总结(关键点回顾)

  1. C++11 <thread>库是底层线程 API 的跨平台封装,核心用法:构造thread对象创建线程,调用join()等待线程完成;
  2. thread构造函数的核心是万能引用 + 参数包:万能引用实现可调用对象 / 参数的完美转发,参数包承接任意个数 / 类型的参数;
  3. 封装pthread_create的价值:屏蔽void* (*)(void*)函数类型限制、单参数限制、类型转换繁琐等痛点;
  4. 简化使用的体现:无需手动封装参数、转换类型,直接传递任意可调用对象和参数,代码简洁且不易出错。

线程与互斥锁的基本使用

main.cpp(互斥锁)

复制代码
 #include <iostream>   // 用于cout控制台输出
#include <thread>     // C++11线程库头文件(thread类)
#include <mutex>      // C++11互斥锁头文件(mutex类)
#include <functional> // 用于std::ref(包装引用)
using namespace std;  // 简化std::前缀

// 线程函数:循环n次对共享变量rx进行自增操作,rm为保护共享变量的互斥锁
// n:循环次数,rx:共享变量的引用(需保护),rm:互斥锁的引用(用于加锁)
void AddX(int n, size_t& rx, mutex& rm)
{
    // 循环n次执行自增操作
    for (int i = 0; i < n; i++)
    {
        // 1. 加锁:获取互斥锁rm,若锁已被其他线程持有,则当前线程阻塞等待
        // 加锁后进入临界区,确保同一时间只有一个线程操作共享变量rx
        rm.lock();
        // 临界区:操作共享变量rx(自增),必须加锁保护,否则会出现竞态条件(数据错乱)
        rx++;
        // 2. 解锁:释放互斥锁rm,让其他等待的线程可以获取锁
        // 解锁必须与加锁一一对应,否则会导致死锁
        rm.unlock();
    }
}

int main()
{
    // 共享变量x:多个线程会同时修改,需用互斥锁保护
    size_t x = 0;
    // 互斥锁m:用于保护共享变量x的原子操作
    mutex m;

    // 创建线程t1,执行AddX函数,传递参数:100(n)、ref(x)(x的引用)、ref(m)(m的引用)
    // 关键:thread构造函数默认对参数做值拷贝,若要传递引用,必须用std::ref()包装
    // 若直接传x/m(而非ref(x)/ref(m)),编译会报错:无法将拷贝的临时对象绑定到非const引用
    thread t1(AddX, 100, ref(x), ref(m));

    // 互斥锁mutex禁用拷贝构造:以下代码编译报错
    // 原因:mutex管理的是系统内核锁资源,拷贝会导致资源归属混乱,因此C++11显式禁用拷贝
    // mutex m2(m);

    // 使用lambda表达式创建线程t2,避开ref传递的问题
    int n2 = 200; // lambda的循环次数
    // 创建线程t2,lambda表达式通过引用捕获x和m,值捕获n2
    // 优势:lambda的引用捕获(&x、&m)可直接访问主线程的变量,无需std::ref包装
    thread t2([&x, &m, n2]()
        {
            // 循环n2次执行自增操作
            for (int i = 0; i < n2; i++)
            {
                // 加锁保护共享变量x
                m.lock();
                x++;
                // 解锁释放锁资源
                m.unlock();
            }
        }
    );

    // 等待线程t1执行完毕,主线程阻塞至t1结束
    t1.join();
    // 等待线程t2执行完毕,主线程阻塞至t2结束
    t2.join();

    // 输出最终的x值(正确结果应为100+200=300,加锁保证结果正确)
    cout << x << endl;

    return 0;
}

一、核心问题:thread构造函数的参数传递机制(值拷贝 vs 引用)

thread类的构造函数有一个关键设计规则:默认对所有传入的参数进行 "值拷贝" ,而非直接传递引用。这个规则的初衷是避免 "悬垂引用"(比如主线程变量提前销毁,子线程引用失效),但在 "多线程修改主线程共享变量" 的场景下,会引发致命问题 ------ 这也是必须用std::ref的核心原因。

1. 不使用ref的错误逻辑(以传递x为例)

如果直接写:

复制代码
// 错误写法:未用ref包装x和m
thread t1(AddX, 100, x, m);

实际执行过程:① thread构造函数接收到主线程的x后,会拷贝一份临时的size_t变量 (记为temp_x),存储在thread对象内部;② thread构造函数将temp_x的地址传递给AddX的参数size_t& rx;③ 此时rx引用的是 **thread内部的temp_x**,而非主线程的x------ 引发两个问题:

  • 编译错误 :C++ 规定 "非 const 的左值引用(size_t&)不能绑定到临时对象(temp_x)",直接报编译错;
  • 逻辑错误 :即使强行绕过编译(比如用const引用),AddX中修改的是temp_x,主线程的x完全不受影响,最终x的结果还是 0,失去多线程修改共享变量的意义。

同理,mutex m的传递:mutex禁用了拷贝构造函数 (代码注释中也提到),直接传m会触发 "无法拷贝 mutex" 的编译错误 ------ 必须传递m的引用,而传递引用就需要解决thread构造函数 "值拷贝" 的问题。

2. std::ref的核心作用:包装引用,传递 "真实引用"

std::ref是 C++ 的引用包装器 ,它的核心价值是:将普通变量包装成 "可拷贝的引用类型",让thread构造函数传递的不是变量的拷贝,而是变量的真实引用。

当写:

复制代码
thread t1(AddX, 100, ref(x), ref(m));

执行过程:① std::ref(x)生成一个reference_wrapper<size_t>类型的对象(可拷贝),这个对象内部保存的是主线程x的地址 ;② thread构造函数拷贝这个reference_wrapper对象(而非拷贝x),然后将其解包,传递给AddXsize_t& rx;③ 此时rx引用的是主线程的xrm引用的是主线程的m ------ 既解决了编译错误,又能让AddX真正修改主线程的共享变量x

二、更简洁的替代方案:Lambda 表达式的引用捕获

Lambda 表达式可以直接通过 "引用捕获" 获取主线程变量的引用,完全避开std::ref的使用,核心逻辑:

复制代码
thread t2([&x, &m, n2]() {
    for (int i = 0; i < n2; i++) {
        m.lock();
        x++;
        m.unlock();
    }
});

关键解析:

① Lambda 的捕获列表[&x, &m, n2]

  • &x引用捕获 主线程的x,Lambda 内部的x直接指向主线程的x
  • &m引用捕获 主线程的m,Lambda 内部的m直接指向主线程的m
  • n2值捕获 ,拷贝一份n2到 Lambda 内部(无需修改,值拷贝更安全);② thread构造函数只需要拷贝 Lambda 对象(Lambda 对象本身很小,且可拷贝),而 Lambda 内部的&x&m已经绑定了主线程的变量 ------ 相当于 "绕开了thread构造函数的参数值拷贝规则",直接通过 Lambda 的捕获机制拿到了共享变量的引用;③ 优势:代码更简洁,无需记忆std::ref的使用场景,是多线程传递共享变量的推荐写法。

三、补充:mutex禁用拷贝的原因

代码中mutex m2(m);编译报错,核心原因是:mutex类管理的是操作系统内核级的锁资源 (比如 Linux 的pthread_mutex_t),每个mutex对象对应一个唯一的内核锁。如果允许拷贝,会导致多个mutex对象对应同一个内核锁,解锁 / 加锁操作会混乱(比如一个线程解锁另一个线程加的锁),引发死锁或竞态条件。因此 C++11 显式禁用了mutex的拷贝构造和赋值重载,必须通过引用传递。

总结(关键点回顾)

  1. thread构造函数默认值拷贝 参数:不使用ref时,AddX的引用参数绑定的是thread内部的临时拷贝,而非主线程变量,导致编译错误 / 修改无效;
  2. std::ref的作用:包装变量为 "可拷贝的引用类型",让thread传递真实引用,绑定到主线程的xm
  3. Lambda 的优势:通过引用捕获(&x&m)直接绑定主线程变量,无需std::ref,代码更简洁;
  4. mutex必须传引用:因为mutex禁用拷贝,只能通过ref或 Lambda 引用捕获传递。

封装互斥锁避免死锁(RAII)

main.cpp(封装锁 ------ 类似智能指针)

复制代码
#include <iostream>   // 控制台输出头文件
#include <thread>     // C++11线程库头文件
#include <mutex>      // 互斥锁头文件
using namespace std;  // 简化std::前缀

// 通用模板类LockGuard:基于RAII机制封装任意锁类型(如mutex、spinlock等),自动管理锁的加解锁
// 模板参数Lock:锁类型(需支持lock()和unlock()成员函数,如std::mutex)
template<class Lock>
class LockGuard
{
public:
    // 构造函数:资源获取即初始化(RAII核心)
    // 参数lock:传入锁的引用(避免拷贝,mutex禁用拷贝),构造时自动加锁
    LockGuard(Lock& lock)
        : _lock(lock)  // 保存锁的引用,关联到外部的锁对象
    {
        _lock.lock();  // 构造对象时自动调用lock()加锁,进入临界区前必加锁
    }

    // 析构函数:资源自动释放(RAII核心)
    // 栈对象生命周期结束时(如出作用域),自动调用unlock()解锁,无需手动调用
    ~LockGuard()
    {
        _lock.unlock(); // 析构时自动解锁,确保锁一定会被释放
    }

private:
    // 私有成员:锁的引用(必须用引用,避免拷贝锁对象,且保证操作的是外部的同一个锁)
    Lock& _lock;

    // 禁用拷贝构造和赋值:避免LockGuard对象拷贝导致锁被重复管理(可选,增强安全性)
    LockGuard(const LockGuard&) = delete;
    LockGuard& operator=(const LockGuard&) = delete;
};

int main()
{
    int x = 0;          // 多线程共享变量,需锁保护
    int n = 100;        // 每个线程的循环次数
    mutex m;            // 保护共享变量x的互斥锁

    // 创建线程t1:循环n次对x自增,使用LockGuard管理锁
    thread t1([&x, &m, n]()
        {
            for (int i = 0; i < n; i++)
            {
                // 创建LockGuard栈对象lg:构造时自动调用m.lock()加锁
                // lg是栈对象,作用域为当前for循环体,出循环体时自动析构
                LockGuard<mutex> lg(m);

                // 临界区:操作共享变量x,无需手动加解锁
                // 即使此处抛出异常(如x++改为复杂逻辑触发异常),lg也会析构解锁
                x++;

                // 出for循环体时,lg的生命周期结束,析构函数自动调用m.unlock()解锁
            }
        }
    );

    // 创建线程t2:逻辑同t1,共享同一个锁m,保证临界区互斥
    thread t2([&x, &m, n]()
        {
            for (int i = 0; i < n; i++)
            {
                // 自动加锁,无需手动调用m.lock()
                LockGuard<mutex> lg(m);

                x++;

                // 自动解锁,无需手动调用m.unlock()
            }
        }
    );

    // 等待两个线程执行完毕
    t1.join();
    t2.join();

    // 输出最终x值(加锁保护下必为200,无竞态条件)
    cout << x << endl;

    return 0;
}

一、先明确核心痛点:手动管理锁的致命问题

在没有封装锁之前,手动调用lock()/unlock()管理互斥锁存在两个核心风险,也是LockGuard要解决的核心问题:

  1. 漏解锁 / 解锁不及时 :比如代码中途return、抛出异常,导致unlock()无法执行 ------ 锁永远被持有,其他线程阻塞等待,最终引发死锁
  2. 人为操作失误 :加锁和解锁必须一一对应,手动写lock()/unlock()时,容易多解锁、少解锁,或解锁位置写错,破坏锁的互斥性。

LockGuard的设计思路和智能指针(如 shared_ptr)完全一致:基于 RAII(资源获取即初始化)思想,将 "锁的获取(加锁)" 绑定到对象构造,"锁的释放(解锁)" 绑定到对象析构,让编译器自动管理锁的生命周期,彻底规避手动操作的风险。

二、LockGuard 的核心实现:RAII 封装锁(类比智能指针)

智能指针的核心是 "构造获取内存,析构释放内存";LockGuard的核心是 "构造获取锁(加锁),析构释放锁(解锁)",二者都是 RAII 思想的典型应用。以下结合代码拆解:

1. 模板设计:适配任意锁类型(通用化)

复制代码
template<class Lock>
class LockGuard
  • 模板参数Lock:支持任意符合 "有lock()unlock()成员函数" 的锁类型(如std::mutex、自旋锁spinlock等),和shared_ptr的模板参数T(支持任意内存类型)思路一致,实现通用化封装。

2. 构造函数:自动加锁(RAII - 获取资源)

复制代码
LockGuard(Lock& lock)
    : _lock(lock)  // 保存锁的引用(关键:操作外部的同一个锁)
{
    _lock.lock();  // 构造时自动加锁,进入临界区前必加锁
}
  • 为什么用引用 保存锁:std::mutex禁用拷贝构造(和智能指针禁用拷贝同理),必须通过引用关联到外部的锁对象,保证操作的是 "同一个锁";
  • 自动加锁:创建LockGuard对象的瞬间,就完成加锁,无需手动调用lock(),避免 "忘加锁" 的问题。

3. 析构函数:自动解锁(RAII - 释放资源)

复制代码
~LockGuard()
{
    _lock.unlock(); // 析构时自动解锁,锁一定会被释放
}
  • 核心保障:LockGuard栈对象 (如代码中LockGuard<mutex> lg(m);),栈对象的生命周期由作用域决定 ------ 只要出作用域(比如 for 循环结束、函数返回、抛出异常),析构函数一定会执行,解锁操作一定会触发。
    • 对比智能指针:shared_ptr是栈对象,析构时自动释放内存;LockGuard是栈对象,析构时自动释放锁,逻辑完全一致。

4. 禁用拷贝:避免锁被重复管理(增强安全性)

复制代码
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
  • shared_ptr/thread/mutex禁用拷贝同理:如果允许拷贝,会导致多个LockGuard对象管理同一个锁,可能出现 "重复解锁"(一个LockGuard析构解锁后,另一个析构时再次解锁),引发未定义行为;禁用拷贝后,一个锁只能被一个LockGuard管理,保证加解锁的唯一性。

三、LockGuard 如何解决 "死锁 / 异常导致的锁未释放" 问题

1. 解决 "异常导致的锁未释放"(核心优势)

假设手动加解锁时,临界区抛出异常:

复制代码
// 手动加解锁的错误示例(异常导致死锁)
void AddX(int n, int& x, mutex& m) {
    for (int i = 0; i < n; i++) {
        m.lock();
        x++;
        throw runtime_error("异常"); // 抛出异常,unlock()无法执行
        m.unlock(); // 永远执行不到,锁被永久持有→死锁
    }
}

而用LockGuard时:

复制代码
void AddX(int n, int& x, mutex& m) {
    for (int i = 0; i < n; i++) {
        LockGuard<mutex> lg(m); // 构造加锁
        x++;
        throw runtime_error("异常"); // 抛出异常
        // 无需unlock(),异常触发栈展开,lg析构→自动解锁
    }
}
  • 栈展开(Stack Unwinding):C++ 异常抛出时,会销毁当前作用域的所有栈对象 ------lg作为栈对象,析构函数会被调用,unlock()执行,锁被释放,不会死锁。

2. 解决 "漏解锁 / 解锁位置错误" 问题

手动加解锁时,容易因代码逻辑(如return)导致漏解锁:

复制代码
// 手动加解锁:return导致漏解锁
void AddX(int n, int& x, mutex& m) {
    for (int i = 0; i < n; i++) {
        m.lock();
        if (x > 50) return; // 提前return,unlock()执行不到→死锁
        x++;
        m.unlock();
    }
}

LockGuard时,即使returnlg的析构函数仍会执行,自动解锁,彻底避免漏解锁。

3. 简化代码:专注业务逻辑

手动加解锁需要写m.lock()/m.unlock(),且要保证一一对应;用LockGuard后,只需创建栈对象,临界区无需关注锁的管理,代码更简洁,也减少了人为失误的可能。

四、代码执行流程(验证 LockGuard 的作用)

复制代码
thread t1([&x, &m, n]() {
    for (int i = 0; i < n; i++) {
        LockGuard<mutex> lg(m); // 构造→m.lock()
        x++;                    // 临界区(互斥访问x)
    } // 出for循环→lg析构→m.unlock()
});
  1. 每次循环创建lg:构造时调用m.lock()加锁,确保同一时间只有一个线程进入临界区;
  2. 循环结束:lg出作用域,析构时调用m.unlock()解锁,其他线程可获取锁;
  3. 即使x++抛出异常:lg析构仍会解锁,不会死锁;
  4. 最终x的结果必为 200(100+100),无竞态条件,也无死锁风险。

总结(关键点回顾)

  1. 核心思想LockGuard和智能指针同源(RAII)------ 构造获取资源(锁 / 内存),析构释放资源(解锁 / 内存),让编译器自动管理生命周期;
  2. 解决的核心问题
    • 异常安全:即使临界区抛异常,栈对象析构仍会解锁,避免死锁;
    • 避免漏解锁:无需手动写unlock(),彻底规避人为操作失误;
  3. 设计关键
    • 用引用保存锁(避免拷贝,操作外部同一个锁);
    • 禁用拷贝(避免重复管理锁);
    • 模板化设计(适配任意锁类型,通用化)。

多线程done(两个线程,奇偶数交替输出)

main.cpp(交替打印奇偶数)

复制代码
#include <iostream>       // 控制台输出头文件
#include <thread>         // C++11线程库头文件
#include <mutex>          // 互斥锁头文件
#include <condition_variable> // 条件变量头文件
using namespace std;       // 简化std::前缀

// 定义宏NUM:每个线程需要打印的次数(总共打印20个数:1-20)
#define NUM 10

int main()
{
    mutex m;                      // 互斥锁:保护共享变量(x、flag)的原子操作
    int x = 1;                    // 共享变量:要打印的数字,初始为1(第一个奇数)
    bool flag = false;            // 同步标志位:控制线程交替执行
                                  // false:线程1(奇数)执行;true:线程2(偶数)执行
    condition_variable cv;        // 条件变量:实现线程间的等待/唤醒,配合互斥锁使用

    // 创建线程t1:负责打印奇数(1、3、5...19)
    thread t1([&]()               // 捕获所有外部变量的引用(x、m、flag、cv)
        {
            // 循环NUM次,打印NUM个奇数
            for (int i = 0; i < NUM; i++)
            {
                // 创建unique_lock对象:相比lock_guard,支持wait()时自动释放锁,是条件变量的必需搭配
                // 构造时自动加锁,保护临界区(flag判断、x操作)
                unique_lock<mutex> lock(m);  
                
                // 若flag为true(此时该线程1不该执行,该线程2执行),则等待
                while (flag)
                {
                    // cv.wait(lock):核心操作,分两步:
                    // 1. 自动释放锁m,让线程2能获取锁执行;
                    // 2. 阻塞当前线程t1,直到被notify_one()唤醒,唤醒后重新获取锁m并继续执行
                    cv.wait(lock);
                }

                // 临界区:打印当前线程ID和奇数,x自增(准备下一个数)
                cout << this_thread::get_id() << ":" << x++ << endl;

                // 切换标志位:设为true,让线程2执行(打印偶数)
                flag = !flag;

                // 唤醒等待在cv上的一个线程(此处唤醒线程2)
                // 通知线程2:可以执行打印偶数的操作了
                cv.notify_one();  
            }
        }
    );

    // 创建线程t2:负责打印偶数(2、4、6...20)
    thread t2([&]()               // 捕获所有外部变量的引用
        {
            // 循环NUM次,打印NUM个偶数
            for (int i = 0; i < NUM; i++)
            {
                // 创建unique_lock对象,自动加锁
                unique_lock<mutex> lock(m);

                // 若flag为false(此时该线程2不该执行,该线程1执行),则等待
                while (!flag)
                {
                    // wait前自动释放锁m,让线程1能执行;被唤醒后重新获取锁
                    cv.wait(lock);  
                }

                // 临界区:打印当前线程ID和偶数,x自增
                cout << this_thread::get_id() << ":" << x++ << endl;

                // 切换标志位:设为false,让线程1执行(打印下一个奇数)
                flag = !flag;

                // 唤醒等待在cv上的一个线程(此处唤醒线程1)
                cv.notify_one();
            }
        }
    );

    // 等待线程t1执行完毕
    t1.join();
    // 等待线程t2执行完毕
    t2.join();

    return 0;
}

一、condition_variable(条件变量):线程间的 "等待 / 唤醒" 同步工具

1. 核心定义

condition_variable是 C++11 封装的线程同步原语,核心作用是:让一个 / 多个线程等待某个 "条件满足",直到其他线程通知(唤醒)它 ------ 解决了 "线程轮询检查条件(浪费 CPU)" 的问题,实现高效的线程间同步。

2. 底层实现(Linux 下)

condition_variable底层封装了 POSIX 线程库的pthread_cond_t(条件变量)和pthread_cond_wait/pthread_cond_signal等函数:

  • cv.wait(lock) → 封装pthread_cond_wait(&cond, &mutex)
  • cv.notify_one() → 封装pthread_cond_signal(&cond)
  • cv.notify_all() → 封装pthread_cond_broadcast(&cond);C++ 封装的价值:跨平台(Windows 下封装CONDITION_VARIABLE),无需关注系统底层 API 差异,且结合 C++ 的锁机制(如unique_lock)更安全。

3. 核心依赖:必须配合互斥锁使用

条件变量本身不保证 "条件判断的原子性",必须和互斥锁(mutex)搭配:

  • 条件(如flag)是共享变量,需锁保护;
  • wait()/notify_one()的执行依赖锁的释放 / 重新获取,保证同步逻辑的正确性。

二、unique_lock vs lock_guard:锁的灵活性对比

两者都是基于 RAII 的锁封装,但核心差异是灵活性,以下是关键对比:

特性 lock_guard unique_lock
核心定位 简单 RAII 锁,自动加解锁 灵活 RAII 锁,支持手动加解锁
手动解锁 不支持(仅析构解锁) 支持unlock()/lock()手动控制
配合条件变量 wait 不支持(无法手动释放锁) 支持(wait () 时自动释放锁,唤醒后重新加锁)
性能 / 开销 轻量(无额外状态) 稍重(维护锁的状态,如是否持有锁)
适用场景 简单临界区(无需手动控锁) 复杂同步(如条件变量、手动控锁)

为什么这里必须用 unique_lock?

cv.wait(lock)的核心操作需要 "先释放锁→阻塞→唤醒后重新获取锁",而lock_guard仅支持 "构造加锁、析构解锁",无法手动释放锁 ------ 如果用lock_guardwait()时无法释放锁,会导致:

  • 线程阻塞时仍持有锁,其他线程永远拿不到锁,最终死锁;unique_lock的灵活性刚好满足wait()的需求:wait()调用时会自动释放锁,唤醒后又自动重新加锁,保证同步逻辑的正确性。

三、cv.wait () 和 cv.notify_one () 的核心行为

1. cv.wait (unique_lock& lock)(核心操作,分三步)

复制代码
while (flag) {
    cv.wait(lock); 
}
  • 第一步 :自动释放lock关联的互斥锁(m),让其他线程能获取锁执行;
  • 第二步 :阻塞当前线程(如 t1),直到被notify_one()/notify_all()唤醒;
  • 第三步 :被唤醒后,重新获取互斥锁m (若锁被其他线程持有则阻塞等待),然后重新检查while条件(而非if)------ 这是为了处理 "虚假唤醒"(系统层面的误唤醒,需重新验证条件)。

⚠️ 关键:必须用while判断条件,而非if!如果用if,虚假唤醒会导致线程跳过条件检查直接执行临界区,破坏交替逻辑。

2. cv.notify_one ()(唤醒操作)

复制代码
cv.notify_one();
  • 唤醒一个 等待在当前条件变量(cv)上的线程(如 t1 唤醒 t2,t2 唤醒 t1);
  • 注意:notify_one()仅 "通知",不会释放锁 ------ 被唤醒的线程需要等当前线程释放锁后,才能重新获取锁继续执行。

四、交替打印的核心逻辑(两种场景验证)

先明确初始状态:x=1(第一个奇数)、flag=false(奇数线程 t1 执行)、m未加锁、cv无等待线程。

场景 1:假设 t1 先运行(打印奇数的线程先执行)

  • 核心:t1 执行完奇数后,切换 flag 并唤醒 t2,自己进入 wait;t2 执行完偶数后,切换 flag 并唤醒 t1,自己进入 wait,形成交替。

场景 2:假设 t2 先运行(打印偶数的线程先执行)

  • 核心:t2 先运行时,因 flag=false 直接进入 wait 并释放锁,t1 顺利获取锁执行奇数打印;t1 执行完后唤醒 t2,t2 此时 flag=true,执行偶数打印,后续逻辑和场景 1 一致,仍能保证交替。

关键结论:无论 t1/t2 谁先运行,最终都能交替

  • t1 先运行:主动切换 flag 并唤醒 t2,自己等待;
  • t2 先运行:因条件不满足直接等待,释放锁让 t1 执行,t1 执行后唤醒 t2;
  • 条件变量 + flag 的组合,保证了 "谁先运行都不影响交替逻辑"------ 本质是通过 "等待 - 唤醒" 让线程按条件执行,而非依赖执行顺序。

五、总结(关键点回顾)

  1. condition_variable:封装pthread_cond_t,实现线程间 "等待 - 唤醒" 同步,必须配合互斥锁使用;
  2. unique_lock vs lock_guard
    • lock_guard:简单 RAII,自动加解锁,无灵活性;
    • unique_lock:灵活 RAII,支持手动控锁,是cv.wait()的必需搭配;
  3. cv.wait():释放锁→阻塞→唤醒后重新加锁 + 条件检查(必须用 while);
  4. cv.notify_one():唤醒一个等待线程,仅通知不释放锁;
  5. 交替逻辑:无论 t1/t2 先运行,flag+wait+notify的组合都会让线程按 "奇数→偶数→奇数" 的顺序执行,保证交替打印。

单例模式之饿汉模式

main.cpp(单例模式 - 饿汉模式)

复制代码
// 补充编译所需的头文件(必须)
#include <iostream>  // cout 控制台输出
#include <string>    // string 字符串类型
#include <map>       // map 键值对容器
using namespace std; // 简化std::前缀,新手更易阅读

// 饿汉模式单例类A:程序启动时就创建唯一实例,线程安全(静态成员初始化天然线程安全)
class A
{
public:
    // 核心:获取单例实例的静态方法(全局唯一入口)
    // 返回值:A& 实例的引用(避免拷贝,保证全局唯一)
    static A& GetInstance()
    {
        // 返回类内静态成员_inst(程序启动时已初始化,全局唯一)
        return _inst;
    }

    // 业务方法:向单例的map中添加键值对
    // k:键(const& 避免拷贝,提高效率),v:值(const& 同理)
    void Add(const string& k, const string& v)
    {
        _dict[k] = v; // 向map容器中插入/更新键值对
    }

    // 业务方法:打印map中的所有键值对
    void Print()
    {
        // 遍历map容器(范围for循环)
        for (auto& kv : _dict)
        {
            // kv.first:键,kv.second:值
            cout << kv.first << ":" << kv.second << endl;
        }
    }

private:
    // 1. 私有化默认构造函数:禁止外部通过 A a; 创建实例
    // 饿汉模式的核心:外部无法手动构造对象,只能通过GetInstance获取唯一实例
    A()
    {}

    // 2. 私有化并删除拷贝构造函数:禁止外部拷贝实例(A a = A::GetInstance(); 编译报错)
    // = delete:C++11特性,显式禁用该函数
    A(const A& aa) = delete;

    // 3. 私有化并删除赋值重载函数:禁止外部赋值实例(A a; a = A::GetInstance(); 编译报错)
    A& operator=(const A& aa) = delete;

    // 成员变量:存储键值对的map(单例的业务数据,全局唯一)
    map<string, string> _dict;

    // 核心:静态成员变量(类内声明),存储唯一的单例实例
    // 静态成员属于类,而非对象,程序启动时(main函数执行前)就初始化,全局唯一
    static A _inst;
};

// 静态成员变量(类外定义):饿汉模式的关键
// 程序启动时,全局区初始化该静态对象,此时还未进入main函数,且初始化过程天然线程安全
A A::_inst;

// 主函数:测试饿汉模式单例的使用
int main()
{
    // 通过GetInstance()获取唯一实例,调用Add方法添加键值对
    // 多次调用GetInstance(),返回的都是同一个_inst实例
    A::GetInstance().Add("sort", "排序");
    A::GetInstance().Add("left", "左边");
    A::GetInstance().Add("right", "右边");

    // 调用Print方法,打印单例中存储的所有键值对
    A::GetInstance().Print();

    return 0;
}

一、单例模式的核心定义

单例模式是创建型设计模式 的一种,核心目标是:保证一个类在整个程序生命周期中只能创建一个对象(实例),并提供一个全局唯一的访问入口,让所有程序模块共享这个唯一实例。

这种模式适用于 "全局唯一资源管理" 场景(如配置文件管理器、日志管理器、连接池)------ 避免多次创建对象导致资源冲突(如重复打开配置文件)、内存浪费,同时保证所有模块访问的是同一套数据。

二、饿汉模式的核心实现逻辑(结合代码拆解)

饿汉模式的核心特点是:程序启动时(main 函数执行前)就创建好唯一实例,而非 "用到时才创建"(懒汉模式)。代码中通过 "私有化构造 + 静态成员 + 静态访问函数" 实现,以下逐点解释:

1. 为什么要私有化构造、拷贝构造、赋值重载?(单例的核心保障)

单例的核心是 "只能创建一个对象",必须禁止外部通过常规方式创建 / 拷贝对象,因此需要私有化并禁用这些函数:

复制代码
// 1. 私有化默认构造函数:禁止外部通过 A a; 创建实例
A() {}

// 2. 私有化并删除拷贝构造:禁止外部拷贝实例(A a = A::GetInstance(); 编译报错)
A(const A& aa) = delete;

// 3. 私有化并删除赋值重载:禁止外部赋值实例(A a; a = A::GetInstance(); 编译报错)
A& operator=(const A& aa) = delete;
  • 默认构造私有化 :如果构造函数是 public,外部可以直接A a;创建任意多个对象,违背 "单例" 核心;私有化后,只有类内部能调用构造函数创建对象,外部无法手动构造。
  • 拷贝构造 / 赋值重载删除 :即使外部拿到了单例实例的引用(如A& ref = A::GetInstance();),也无法通过A a = ref;a = ref;拷贝 / 赋值出新对象,彻底杜绝 "多实例" 可能。

2. static A _inst;:类内静态成员的本质与声明 / 定义规则

复制代码
// 类内声明:静态成员变量(属于类,而非对象)
static A _inst;

// 类外定义:程序启动时初始化该静态对象
A A::_inst;
  • 本质_inst是一个全局静态变量 ,只是 "放在类的作用域内"(通过A::_inst访问)。全局静态变量的特性是:程序启动时(main 函数执行前)在全局区初始化,生命周期贯穿整个程序,且只有一份(全局唯一)。
  • 为什么要 "类内声明 + 类外定义"
    • C++ 语法规定:类内的静态成员变量只是 "声明"(告诉编译器有这个变量),必须在类外 "定义"(分配内存并初始化),否则会报 "未定义的引用" 错误;
    • 类外定义A A::_inst;时,会调用 A 的私有化构造函数(只有类内部能调用),创建唯一的实例 ------ 这也是为什么要把构造函数私有化:只有类内的静态成员初始化时能调用,外部无法调用。

3. 为什么static A& GetInstance()必须是静态成员函数?

复制代码
static A& GetInstance()
{
    return _inst; // 返回唯一实例的引用
}
  • 核心原因:静态成员函数属于 "类本身",无需创建对象即可调用(通过类名::函数名;而非静态成员函数属于 "对象",必须先创建对象才能调用 ------ 但单例模式禁止外部创建对象,因此必须用静态函数作为 "全局访问入口"。
  • 补充:返回值用A&(引用)而非A(值),是为了避免返回时拷贝出新对象(进一步保证单例);用引用也能直接修改实例的成员(如调用Add方法)。

4. 为什么必须通过A::GetInstance().Add(...)访问成员方法?

复制代码
A::GetInstance().Add("sort", "排序");

拆解这个调用的逻辑:

  1. A::GetInstance():通过类名调用静态函数,获取唯一实例的引用(A&);
  2. .Add(...):通过实例引用调用非静态成员方法(Add/Print是操作实例数据的业务方法,必须通过实例调用)。

如果直接写A::Add(...)会报错 ------ 因为Add非静态成员方法 ,必须绑定到具体实例才能调用;而单例模式下唯一的实例只能通过GetInstance()获取,因此必须通过这种 "静态函数获取实例 + 实例调用方法" 的方式访问。

三、饿汉模式的优缺点(用户指定要点)

1. 优点:实现简单,天然线程安全

  • 实现简单 :无需考虑 "懒汉模式" 的线程安全问题(如多线程同时调用GetInstance导致创建多个实例),仅需私有化构造 + 静态成员 + 静态访问函数,代码量少,新手易理解;
  • 天然线程安全 :静态成员_inst在程序启动时(main 前)由编译器初始化,这个过程是单线程的,不会出现多线程竞争创建实例的问题。

2. 缺点:启动慢 + 无法控制实例初始化顺序

  • 缺点 1:进程启动变慢 饿汉模式的实例在main函数执行前就创建完成,如果程序中有多个饿汉单例类(如配置管理器、日志管理器、连接池),这些实例会在程序启动时逐个初始化 ------ 初始化逻辑复杂(如加载配置文件、建立数据库连接)时,会导致程序启动时间变长,甚至出现 "一直卡在启动阶段,进不到 main 函数" 的情况。
  • 缺点 2:无法控制实例初始化顺序C++ 没有规定 "多个全局静态变量(饿汉单例的_inst)" 的初始化顺序 ------ 如果单例 A 的初始化依赖单例 B,但编译器先初始化 A、后初始化 B,会导致 A 初始化时 B 还未创建,出现 "空指针 / 未初始化" 的错误,而饿汉模式无法手动控制这个顺序。

四、总结(关键点回顾)

  1. 单例模式核心:保证类只有一个实例,提供全局访问入口;
  2. 饿汉模式核心:main 前创建唯一实例,通过 "私有化构造 + 静态成员 + 静态访问函数" 实现;
  3. 关键语法:
    • 构造 / 拷贝 / 赋值私有化:禁止外部创建 / 拷贝实例;
    • 静态成员_inst:类内声明 + 类外定义,本质是全局静态变量,唯一实例;
    • 静态GetInstance:无需对象即可调用,作为全局访问入口;
  4. 饿汉模式特点:实现简单、线程安全,但启动慢、无法控制初始化顺序。

单例模式之懒汉模式 - 缺少线程板块代码

main.cpp(单例模式 - 懒汉模式)

复制代码
#include <iostream>       // 控制台输出头文件
#include <mutex>          // 互斥锁头文件
#include <map>            // map容器头文件
#include <string>         // string字符串头文件
#include <memory>         // unique_lock所需头文件
using namespace std;       // 简化std::前缀

// 线程安全的懒汉式单例类A:全局唯一实例,存储键值对字典并支持自动/手动释放
class A
{
public:
    // 静态成员函数:获取类A的唯一实例(核心:双检查锁保证线程安全+懒加载)
    static A* GetInstance()
    {
        // 第一层检查:双检查锁(DCL)的外层判断
        // 作用:避免实例创建后,每次调用GetInstance都加解锁(减少无意义的锁开销)
        if (_inst == nullptr)  
        {
            // 加锁保护:实例创建阶段(new A())的线程安全,防止多个线程同时new
            // unique_lock支持自动加解锁,比lock_guard灵活(此处仅需基础加解锁,也可用lock_guard)
            unique_lock<mutex> lock(_mtx);  
            
            // 第二层检查:双检查锁的内层判断
            // 作用:即使多个线程突破外层检查,也只有一个线程能创建实例(避免重复new)
            if (_inst == nullptr)
            {
                _inst = new A();  // 懒加载:第一次调用时才创建实例(懒汉式核心)
            }
        }

        return _inst;  // 返回唯一实例的指针
    }

    // 成员函数:向单例的字典中添加键值对
    void Add(const string& k, const string& v)
    {
        _dict[k] = v;  // _dict是单例的成员,全局唯一,所有调用都操作同一个字典
    }

    // 成员函数:打印字典中所有键值对
    void Print()
    {
        // 遍历map容器,输出每个键值对
        for (auto& kv : _dict)
        {
            cout << kv.first << ":" << kv.second << endl;
        }
    }

    // 静态成员函数:手动释放单例实例(支持提前释放资源)
    static void DelInstance()
    {
        // 判空:避免重复释放导致野指针/崩溃
        if (_inst != nullptr)
        {
            delete _inst;    // 释放实例内存,触发A的析构函数(执行资源清理)
            _inst = nullptr; // 置空指针,避免悬空指针
        }
    }

private:
    // 私有化构造函数:禁止外部通过A()创建对象(单例的核心约束)
    // 确保只能通过GetInstance()获取唯一实例
    A()
    {
    }

    // 私有化析构函数:禁止外部通过delete直接释放实例(必须通过DelInstance()释放)
    // 析构时可执行资源清理(如文件写入、数据库连接关闭等)
    ~A()
    {
        cout << "数据写入文件等操作" << endl; // 模拟析构时的资源清理逻辑
    }

    // 私有化拷贝构造函数并删除:禁止外部拷贝单例实例(=delete表示禁用该函数)
    A(const A& aa) = delete;

    // 私有化赋值运算符并删除:禁止外部赋值单例实例
    A& operator=(const A& aa) = delete;

    // 成员变量:存储键值对的字典(全局唯一,所有单例调用共享)
    map<string, string> _dict;

    // 静态成员变量:指向类A唯一实例的指针(懒汉式初始化为nullptr)
    static A* _inst;

    // 嵌套GC类(垃圾回收类):充当单例的"最后一道闸口",确保实例最终被释放
    // 原理:static成员_gc在main函数结束后自动析构,触发~Gc()调用DelInstance()
    class Gc
    {
    public:
        // GC类的析构函数:调用DelInstance()释放单例
        ~Gc()
        {
            DelInstance();
        }
    };

    // 静态GC对象:全局生命周期,main结束后析构
    static Gc _gc;
    // 静态互斥锁:保护GetInstance()中new A()的线程安全
    static mutex _mtx;
};

// 静态成员初始化:单例实例指针初始化为nullptr(懒汉式,初始不创建实例)
A* A::_inst = nullptr;
// 静态GC对象初始化:触发GC类的构造(无实际逻辑,仅占内存,等待析构)
A::Gc A::_gc;
// 静态互斥锁初始化:默认构造,用于GetInstance()的加锁保护
mutex A::_mtx;

int main()
{
    // 通过单例的GetInstance()获取实例,调用Add添加键值对
    A::GetInstance()->Add("sort", "排序");
    A::GetInstance()->Add("left", "左边");
    A::GetInstance()->Add("right", "右边");

    // 打印单例字典中的所有键值对
    A::GetInstance()->Print();

    // 手动释放单例实例:触发A的析构函数(打印"数据写入文件等操作")
    A::DelInstance();
    cout << "提前手动释放" << endl;

    // 说明:若未手动调用DelInstance(),main结束后GC类的_gc析构,会自动调用DelInstance();
    // 若已手动释放,DelInstance()中_inst已置空,GC析构时再次调用也不会重复释放(判空保护)

    return 0;
}

一、GetInstance 函数的线程安全:双重检查锁(Double-Checked Locking)的设计逻辑

懒汉模式的核心是 "用到时才创建实例 "(区别于饿汉模式的 "启动即创建"),但这种 "延迟创建" 会引入线程安全问题 ------ 多个线程同时调用GetInstance()时,可能重复创建实例。因此需要通过 "加锁 + 双重检查" 解决,且兼顾性能:

1. 仅加锁不做外层检查的问题(性能浪费)

如果只写:

复制代码
static A* GetInstance()
{
    unique_lock<mutex> lock(_mtx); // 每次调用都加锁
    if (_inst == nullptr)
    {
        _inst = new A();
    }
    return _inst;
}
  • 问题:_inst创建完成后(非空),后续所有线程调用GetInstance()仍会执行 "加锁→解锁" 操作 ------ 加锁是有系统开销的(内核态 / 用户态切换),这些操作完全无意义,会降低程序性能。

2. 仅做单层检查不加锁的问题(线程安全问题)

如果只写:

复制代码
static A* GetInstance()
{
    if (_inst == nullptr) // 单层检查,不加锁
    {
        _inst = new A(); // 多线程同时进入,重复new
    }
    return _inst;
}
  • 问题:多个线程可能同时通过if (_inst == nullptr)的检查,进而执行new A(),导致创建多个A实例,破坏单例的核心规则。

3. 双重检查锁的核心逻辑(兼顾线程安全 + 性能)

复制代码
static A* GetInstance()
{
    // 外层检查(无锁):_inst非空时直接返回,避免无意义的加解锁
    if (_inst == nullptr)  
    {
        // 加锁:保护new操作的原子性,避免多线程重复创建
        unique_lock<mutex> lock(_mtx);  
        // 内层检查:防止"多个线程同时过外层检查,等待锁后重复new"
        if (_inst == nullptr)
        {
            _inst = new A();
        }
    }
    return _inst;
}
  • 外层检查 :无锁,快速判断 ------_inst已创建时直接返回,跳过加锁逻辑,避免性能损耗;
  • 加锁 :仅当_inst为空时,才加锁保护new操作,保证同一时间只有一个线程执行new
  • 内层检查 :加锁后再次判断 ------ 假设线程 1 和线程 2 同时过外层检查,线程 1 先获取锁并执行new,线程 2 等待锁;线程 1 释放锁后,线程 2 获取锁,此时内层检查_inst已非空,不会重复new

二、DelInstance 函数设为 static 的原因

复制代码
static void DelInstance()
{
    if (_inst != nullptr)
    {
        delete _inst;
        _inst = nullptr;
    }
}
  1. 操作静态成员的必需条件DelInstance要修改类的静态成员_inst,C++ 语法规定:非静态成员函数必须绑定到具体对象才能调用,而静态成员函数属于 "类本身",可直接访问静态成员(无需对象);
  2. 符合单例的访问逻辑 :单例的核心是 "全局唯一访问入口,无需创建对象",DelInstance作为释放单例的函数,需要通过A::DelInstance()调用(而非对象.DelInstance()),因此必须设为 static;
  3. 外部无法创建对象的适配 :A 的构造函数私有化,外部无法创建 A 的对象,若DelInstance是非静态函数,外部根本无法调用(无对象可绑定)。

三、Gc 私有内部类的作用(单例的 "守护类")

A 的析构函数是私有化 的(~A() {}),且_instnew出来的堆对象 ------ 如果用户忘记手动调用DelInstance()_inst指向的内存会泄漏,且析构函数无法执行(析构私有化,外部delete会报错)。

Gc 类的核心作用:作为单例的 "最后一道保障" ,即使用户忘记手动释放_inst,程序退出时也会自动调用DelInstance()释放资源,避免内存泄漏:

复制代码
// 私有内部类:仅A能访问,外部不可见
class Gc
{
public:
    ~Gc()
    {
        DelInstance(); // Gc析构时,自动调用DelInstance释放_inst
    }
};
static Gc _gc; // A的静态成员,全局生命周期

四、static Gc _gc 能自动调用析构的原因

static Gc _gc;是 A 类的静态成员变量,其生命周期遵循 C++"静态 / 全局对象" 的规则:

  1. 静态成员的生命周期:从程序启动(main 执行前)初始化,到程序完全退出(main 执行后)销毁,而非局部变量的 "作用域生命周期";
  2. 析构触发时机:main 函数执行完毕后,程序进入 "退出阶段",系统会自动销毁所有全局 / 静态对象 ------ 此时_gc(A 的静态成员)会被销毁,进而调用 Gc 的析构函数;
  3. 容错性:即使用户提前手动调用了DelInstance()_inst = nullptr),DelInstance()内部会判断_inst != nullptr才执行delete,因此 Gc 析构时重复调用也不会出错(只是空操作)。

五、提前释放的两种调用方式区别

1. A::GetInstance()->DelInstance()

  • 执行逻辑:先调用GetInstance()获取_inst指针(会触发双重检查锁逻辑),再通过指针调用DelInstance()
  • 本质问题:DelInstance()是静态函数,通过对象 / 指针调用静态函数,本质还是调用类的静态函数 ------ 相当于 "多此一举":先获取实例指针,再用指针调静态函数,额外执行了GetInstance()的外层检查逻辑,无意义且稍低效。

2. A::DelInstance()

  • 执行逻辑:直接调用 A 类的静态函数DelInstance(),跳过GetInstance()的检查步骤;
  • 优势:更高效、更符合静态函数的调用规范(静态函数应通过 "类名::函数名" 调用),且DelInstance()内部已包含_inst != nullptr的检查,效果和前者完全一致。

核心区别

调用方式 执行步骤 效率 合理性
A::GetInstance()->DelInstance() GetInstance 检查 → 调 DelInstance 冗余(静态函数无需实例)
A::DelInstance() 直接调 DelInstance 符合静态函数调用规范

六、为什么 A::DelInstance () 能调用私有析构函数

复制代码
// DelInstance内部执行delete _inst;
if (_inst != nullptr)
{
    delete _inst; // 调用A的析构函数 + 释放内存
    _inst = nullptr;
}

C++ 的访问权限规则是 "类的成员函数(包括静态成员函数)可访问类的所有私有成员":

  1. DelInstance()是 A 的静态成员函数,属于 A 类的内部函数,因此可以访问 A 的私有析构函数;
  2. delete _inst的执行过程:先调用_inst指向对象的析构函数(~A()),再释放堆内存 ------ 虽然~A()是私有的,但DelInstance()作为内部函数,调用无权限问题;
  3. 若外部直接写delete A::GetInstance(),会编译报错(外部无法访问私有析构),但通过DelInstance()间接调用则合法。

总结(关键点回顾)

  1. 双重检查锁:外层无锁检查避免性能浪费,内层加锁检查保证线程安全,解决懒汉模式的线程安全 + 性能问题;
  2. DelInstance 设为 static:可直接访问静态成员_inst,且符合单例 "无需对象即可调用" 的逻辑;
  3. Gc 内部类:作为守护类,利用静态成员的生命周期,在程序退出时自动释放单例,避免内存泄漏;
  4. 静态 Gc _gc 自动析构:静态成员生命周期贯穿程序全程,main 结束后销毁,触发 Gc 析构→调用 DelInstance;
  5. 释放方式区别:A::DelInstance () 更高效,GetInstance ()->DelInstance () 冗余;
  6. 私有析构可调用:DelInstance 是类内部函数,有权访问私有析构,通过 delete _inst 触发析构。
相关推荐
m0_662577972 小时前
C++中的享元模式实战
开发语言·c++·算法
tankeven2 小时前
最短路径问题00:dijkstra算法
c++·算法
REDcker2 小时前
glibc、libstdc++ 与 libc++ 区别与联系
开发语言·c++
2401_844221322 小时前
内存对齐与缓存友好设计
开发语言·c++·算法
qiuyunoqy2 小时前
Linux进程 --- 5(进程地址空间初识)
linux·c++·算法
计算机安禾2 小时前
【C语言程序设计】第28篇:指针的概念与指针变量
c语言·开发语言·数据结构·c++·算法·visual studio code·visual studio
sycmancia3 小时前
C++——智能指针类模板
开发语言·c++
格林威3 小时前
工业相机图像高速存储(C++版):直接IO存储方法,附Basler相机实战代码!
开发语言·c++·人工智能·数码相机·计算机视觉·视觉检测·工业相机
m0_716667073 小时前
嵌入式C++驱动开发
开发语言·c++·算法