单例模式 | 死锁

单例模式

什么是单例模式?

单例模式是一种创建型设计模式,它保证 一个类只有一个实例,并提供一个全局访问点。就像 一个国家只有一个总统 。

核心特点

  • 唯一性 :内存中只能有一个对象。
  • 全局访问 :任何地方都可以通过 GetInstance () 访问它。
  • 私有构造 :构造函数必须私有 (private),防止外部 new 。
  • 禁拷贝 :拷贝构造和赋值运算符必须禁用 (delete)。

现在我们要对下面这个很大的类做一个单例模式

复制代码
class BigData {
public:
    BigData() {
        std::cout << "  [BigData] 构造函数被调用 (加载100GB数据...)" << std::endl;
    }
    void process() {
        std::cout << "  [BigData] 正在处理数据..." << std::endl;
    }
};

饿汉模式

  • 原理 : "饿了就要吃" 。程序一启动( main 函数执行前),就立马把对象创建好。

    class SingletonEager {
    private:
    static BigData data; // 静态成员对象
    SingletonEager() {} // 私有构造函数,禁止外部创建
    SingletonEager(const SingletonEager&) = delete;
    SingletonEager& operator=(const SingletonEager&) = delete;

    public:
    static BigData* GetInstance() {
    return &data;
    }
    };

    // 在类外初始化静态成员
    BigData SingletonEager::data;

  • 优点 :

    • 天然线程安全 :C++ 保证静态变量在 main 之前初始化,此时还没多线程。
    • 执行效率高 :获取实例时不需要加锁判断。
  • 缺点 :

    • 启动慢 :如果对象很大(加载 100G 数据),程序启动会卡很久。
    • 浪费内存 :如果程序运行了一整天都没用到它,这 100G 内存就白占了。

懒汉模式 - 线程不安全

  • 原理 : "懒得动,要用才去洗碗" 。第一次调用 GetInstance 时才创建。

  • 问题 :多线程环境下,A 线程判断 inst == NULL 准备创建,还没创建完,B 线程也判断 inst == NULL ,于是两人都创建了一份。

    class SingletonLazyUnsafe {
    private:
    static BigData* inst;
    SingletonLazyUnsafe() {}
    SingletonLazyUnsafe(const SingletonLazyUnsafe&) = delete;
    SingletonLazyUnsafe& operator=(const SingletonLazyUnsafe&) = delete;
    public:
    static BigData* GetInstance() {
    if (inst == NULL) {
    inst = new BigData();
    }
    return inst;
    }
    };
    BigData* SingletonLazyUnsafe::inst = NULL;

懒汉模式 - 线程安全版

这是经典的 生产级实现。

复制代码
class SingletonLazySafe {
private:
    // volatile: 防止编译器对代码进行过度优化(例如指令重排),
    // 确保多线程下 inst 的可见性和有序性。
    volatile static BigData* inst; 
    static std::mutex _mtx;
    SingletonLazySafe() {}
    SingletonLazySafe(const SingletonLazySafe&) = delete;
    SingletonLazySafe& operator=(const SingletonLazySafe&) = delete;

public:
    static BigData* GetInstance() {
        // 第一重检查:如果已经创建了,直接返回,避免每次都加锁(性能关键!)
        if (inst == NULL) { 
            _mtx.lock(); // 加锁
            
            // 第二重检查:防止在加锁等待期间,别人已经创建了
            if (inst == NULL) {
                inst = new BigData();
            }
            
            _mtx.unlock(); // 解锁
        }
        return (BigData*)inst;
    }
};
volatile BigData* SingletonLazySafe::inst = NULL;
std::mutex SingletonLazySafe::_mtx;
  • 注意点 :
    1. 双重 if :外层 if 挡住 99% 的请求(避免锁竞争),内层 if 保证安全性。
    2. volatile : volatile static T* inst; 防止编译器优化指令重排(在某些老旧编译器或特定硬件上, new 操作可能被乱序,导致返回未完全构造的对象)。

Meyers' Singleton

如果你用的是 C++11 及以上,这是 最推荐 的写法。

复制代码
class SingletonMeyers {
private:
    SingletonMeyers() {}
public:
    static BigData& GetInstance() {
        // C++11 规定:局部静态变量的初始化是线程安全的
        static BigData instance; 
        return instance;
    }
};
  • 原理 :C++11 标准明确规定: 局部静态变量的初始化是线程安全的。编译器会自动加锁保护初始化过程。
  • 优点 :代码极少,既是懒汉(第一次调用才初始化),又是线程安全的,还没指针管理的麻烦。

总结建议

模式 启动速度 运行时性能 线程安全 推荐指数
饿汉 慢 快 (无锁) 是 ⭐⭐ (仅限小对象)
懒汉 (不安全) 快 快 否 ❌ (禁止使用)
懒汉 (DCL) 快 中 (首次加锁) 是 ⭐⭐⭐ (旧标准 / 复杂控制)
Meyers (局部静态) 快 快 是 ⭐⭐⭐⭐⭐ (C++11 首选)

关于volatile

你可能以为 inst = new T(); 是一个原子操作(要么做完,要么没做),但实际上,编译器会把它拆成三步独立的指令:

复制代码
// 伪代码:new T() 的实际执行步骤
1. 分配内存:给 T 类型的对象申请一块内存空间(比如 malloc );
2. 初始化对象:调用 T 的构造函数,给这块内存赋值(比如初始化成员变量);
3. 指针赋值:把 inst 指针指向刚分配的内存地址。

正常情况下,CPU 按「1→2→3」执行,inst 只有在对象完全构造好后才会非 NULL,这没问题。

但是为了提升执行效率,编译器(或 CPU)会对没有数据依赖的指令做「指令重排」(这就是 "过度优化" 的核心)。

对于上面的三步,编译器会认为:"步骤 2(初始化对象)和步骤 3(指针赋值)没有直接依赖",于是可能把顺序改成:

复制代码
1. 分配内存 → 3. 指针赋值 → 2. 初始化对象

这个重排对单线程完全无害,但对「多线程的 DCL 场景」是致命的!

假设现在有线程 A 和线程 B,执行流程如下:

  1. 线程 A 执行 inst = new T(),被重排为「1→3→2」:
    • 步骤 1:分配了内存;
    • 步骤 3:inst 指针已经指向这块内存(此时 inst != NULL);
    • 步骤 2:还没执行(对象还没初始化,是 "半成品")。
  2. 线程 B 此时走到 DCL 的「第一重检查」:if (inst == NULL),发现 inst != NULL,直接返回这个指针;
  3. 线程 B 拿到 inst 后,试图调用对象的方法 / 访问成员变量 ------ 但对象还没完成构造,结果就是程序崩溃、数据错乱、逻辑异常(比如访问未初始化的成员变量)。

volatile 关键字的核心作用,就是给编译器 / CPU 下 "禁令",针对被修饰的变量(比如 volatile static T* inst):

  1. 禁止指令重排 :编译器 / CPU 不能对涉及 volatile 变量的指令做重排 ------ 也就是说,inst = new T() 的三步必须严格按「1→2→3」执行,inst 只有在对象完全构造后才会非 NULL;
  2. 禁止缓存优化 :保证每次读写 inst 都是直接操作内存,而不是缓存到 CP

死锁

死锁是指多个执行流(进程 / 线程)在执行过程中,因争夺资源而造成的一种互相等待的现象。 如果没有外力干预,它们都将无法推进下去,程序就像 "卡死" 了一样。

形象比喻:两个人过独木桥,A 在桥头占着位置等 B 让路,B 在对面占着位置等 A 让路,结果谁也过不去。

死锁发生的四个必要条件

这四个条件缺一不可,只要破坏其中任意一个,死锁就不会发生。

  • 互斥条件资源是独占的,同一时刻只能被一个线程使用(如互斥锁)。这是锁的特性,通常无法破坏。

  • 请求与保持条件吃着碗里的,看着锅里的。线程已经持有了锁 A,在不释放 A 的情况下,去申请锁 B。

  • 不剥夺条件线程持有的资源,在未用完之前,不能被其他线程强行抢走。只能由它自己主动释放。

  • 循环等待条件A 等 B,B 等 C,...,Z 等 A。形成了一个闭环。

场景一:死锁现场

复制代码
void deadlock_routine_A() {
    std::lock_guard<std::mutex> lock1(mtx1);
    std::cout << "[线程A] 获取了 mtx1,正在处理..." << std::endl;
    
    // 模拟处理耗时,确保线程B有机会获取 mtx2,形成死锁条件
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    
    std::cout << "[线程A] 尝试获取 mtx2..." << std::endl;
    std::lock_guard<std::mutex> lock2(mtx2); // 在这里阻塞,等待 mtx2
    
    std::cout << "[线程A] 成功获取 mtx2,执行完毕。" << std::endl;
}

void deadlock_routine_B() {
    std::lock_guard<std::mutex> lock2(mtx2);
    std::cout << "[线程B] 获取了 mtx2,正在处理..." << std::endl;
    
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    
    std::cout << "[线程B] 尝试获取 mtx1..." << std::endl;
    std::lock_guard<std::mutex> lock1(mtx1); // 在这里阻塞,等待 mtx1
    
    std::cout << "[线程B] 成功获取 mtx1,执行完毕。" << std::endl;
}

结果:

复制代码
[线程A] 获取了 mtx1...
[线程B] 获取了 mtx2...
[线程B] 尝试获取 mtx1... (等待)
[线程A] 尝试获取 mtx2... (等待)
(程序永久卡死)

这就构成了典型的环路等待:A -> mtx2 -> B -> mtx1 -> A。

破解法一

统一加锁顺序

复制代码
void safe_routine_ordered1() {
    std::lock_guard<std::mutex> lock1(mtx1);
    std::cout << "[SafeThread1] 获取了 mtx1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    
    std::lock_guard<std::mutex> lock2(mtx2);
    std::cout << "[SafeThread1] 获取了 mtx2" << std::endl;
}
void safe_routine_ordered2() {
    std::lock_guard<std::mutex> lock1(mtx1);
    std::cout << "[SafeThread2] 获取了 mtx1" << std::endl;
    std::this_thread::sleep_for(std::chrono::milliseconds(10));
    
    std::lock_guard<std::mutex> lock2(mtx2);
    std::cout << "[SafeThread2] 获取了 mtx2" << std::endl;
}

破解法二

使用 std::lock (C++标准库算法)

复制代码
void safe_routine_std_lock() {
    // defer_lock 表示初始化时不立即加锁
    std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
    std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
    
    std::cout << "[StdLockThread] 尝试同时获取 mtx1 和 mtx2..." << std::endl;
    
    // 原子性地锁定两个锁(避免死锁的核心)
    std::lock(lock1, lock2);
    
    std::cout << "[StdLockThread] 成功获取双锁!" << std::endl;
    // 退出作用域时自动解锁
}

原理:std::lock 内部使用了一种死锁避免算法(通常是 Try-and-Backoff 机制):

  1. 尝试锁住 lock1。
  2. 尝试锁住 lock2。
  3. 如果锁住 lock2 失败(被别人占了),它会主动释放 lock1(破坏请求与保持条件)。
  4. 等待一小会儿,然后重试,直到同时拿到两把锁。

破解法三

**使用超时锁 (破坏不剥夺)**使用 try_lock_for。"我尝试等 1 秒,如果拿不到锁 B,我就把自己手里的锁 A 释放掉,过会再来。"

在实际开发中,策略 1 (固定顺序) 和 策略 2 (std::lock) 是最有效的手段。

相关推荐
liqingdi43723 分钟前
[SWPUCTF 2023 秋季新生赛]Easy SMC
服务器·前端·javascript
Linux系统运维工程师_郭主任41 分钟前
运维人还在被拒offer?现在能考的,只有 RHCE 10.0。
linux
自养Agent1 小时前
MCP Server Boundary Audit Report
linux·服务器·人工智能·windows·大模型·llm
牧瀬クリスだ2 小时前
单例模式终极指南:从入门到防破坏
单例模式·设计模式
派小心.2 小时前
网站统计采样数据的误差核对
大数据·服务器·前端·数据库·人工智能·小程序·数据分析
Coisinier2 小时前
docker命令管理镜像及容器
linux·运维·nginx·docker·容器·操作系统
A心有千千结2 小时前
通过 SSH 反向代理让服务器借用本机网络访问 GitHub 的最佳实践
linux·服务器·网络·git·ssh·github
fengkai45452 小时前
十三、Zabbix 监控
运维·服务器·zabbix·监控·网络设备
数字新视界3 小时前
机房动环监控助力智能管理
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
书生执笔画浮沉3 小时前
Linux 内核模块为什么不能自己解决兼容性问题?
linux·c·kernel·update·灵感