单例模式 | 死锁

单例模式

什么是单例模式?

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

核心特点

  • 唯一性 :内存中只能有一个对象。
  • 全局访问 :任何地方都可以通过 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) 是最有效的手段。

相关推荐
路西法012 小时前
# CentOS系统yum方式安装MySQL
linux·mysql·centos
胡萝卜3.02 小时前
穿透表象:解构Linux文件权限与粘滞位的底层逻辑
运维·服务器·机器学习·文件管理·linux安全·linux权限·umask
CAU界编程小白2 小时前
Linux编程系列之进程概念(上)
linux
yangn02 小时前
ysu-527科研服务器使用指南
linux·运维·服务器
ydswin3 小时前
Sidecar不就是在Pod里多跑一个容器吗!
linux·kubernetes
QT 小鲜肉3 小时前
【Linux命令大全】001.文件管理之mdu命令(实操篇)
linux·运维·服务器·chrome·笔记·microsoft
xiaomin-Michael3 小时前
linux 用户信息 PAM用户认证 auditctl审计
服务器·网络·安全
wangxingps3 小时前
phpmyadmin版本对应的各php版本
服务器·开发语言·php
旖旎夜光3 小时前
Linux(6)(上)
linux·学习