C++单例模式

C++单例模式

设计模式的第一篇,笔者尝试结合实际工作中的经验描述设计模式的作用。

目的

创建一个全局唯一、共享的对象

  1. 全局唯一:一个类当且仅当有一个对象(禁止拷贝,禁止外部创建)。
  2. 共享:项目中多处需要使用。

使用场景

就笔者而言,使用最多的就是跟工厂模式打配合,各种工厂类就是单例,除此之外还有用于注册和线程池。

工厂模式

工厂模式的典型使用场景:

最常用的使用场景是这样:

  • 存在统一的处理流程(如调用顺序或生命周期);
  • 各处理步骤固定,但需要根据不同上下文,在某些步骤中插入差异化行为。
    典型实现方式如下:
  1. 定义一个抽象基类,封装公共流程;
  2. 实现多个派生类,分别处理具体差异;
  3. 定义一个工厂类,用于根据上下文创建不同派生类实例;
  4. 工厂类作为单例存在;
  5. 所有可创建的派生类在程序初始化阶段就已注册到工厂中;
  6. 工厂根据上下文信息决定实例化哪个派生类,返回基类指针;
  7. 调用者通过基类指针使用统一接口执行操作。

为什么工厂类需要是单例:

在该模式中,工厂类不仅负责创建对象,还承担类型注册与选择逻辑的统一管理。例如可以将"类型匹配逻辑"与"具体创建方法"绑定到工厂内部,并通过静态初始化方式完成注册,可以确保:

  • 所有注册只发生一次,避免重复逻辑;
  • 全局一致性,避免因多份工厂状态不一致导致行为偏差;
  • 注册过程与使用解耦,便于扩展和维护。
    因此,工厂采用单例模式具有关键意义:确保注册机制只初始化一次,且全局可用,为类型创建提供统一、可扩展的机制。

笔者用的最多的就是这种方法,因为C++的多态能很方便的派生不同的行为,所以对于一些大部分相同,小部分有差异的事情,都习惯用工厂模式,通过不同的上下文生成一个基类去执行差异化的任务。

注册

在程序运行前或运行中,把某些信息(如类、函数、对象、配置)添加到某个中心化的管理结构中(如全局表、工厂、调度器),以便后续通过标识符动态使用它们。

例如笔者用过的方法注册,将某种函数调用方法跟类型进行绑定,实现类似RTTR的结构。

线程池

  1. 单例模式便于统一管理线程资源。
  2. 由于有统一入口,更方便维护和监控。
  3. 不同线程共享同一进程资源,而单例模式能确保进程唯一。

创建方式

不考虑DLL的情况下,单例模式建议在主程序中创建。

cpp 复制代码
class Singleton
{
public:
    static Singleton& getInstance() {
		    static Singleton instance;
		    return instance;
		}
};

考虑DLL的情况

如果单例定义在DLL中,则建议把定义和声明拆分

cpp 复制代码
// Singleton.h
class Singleton {
public:
    static Singleton& getInstance();
private:
    Singleton() {}
};

// Singleton.cpp
Singleton& Singleton::getInstance() {
    static Singleton instance;  // 真正只会生成一份
    return instance;
}

如果不小心在多个模块 include 带 static 的头文件实现,会导致不同模块拥有不同"单例"。

因为

  • 在同一翻译模块中,不同的翻译单元这样直接定义在函数体内的内联函数,会在链接阶段因为ODR的存在而被合并。
  • 不同的翻译模块中,链接阶段并不会在翻译模块之间进行合并,从而导致不同的翻译模块(也就是不同的)有不同的static实例。
  • 从而出现在同一进程中有多个静态对象。

防止通过头文件包含在不同的DLL生成多个实例

Windows场景下,通过__declspec(dllexport) / __declspec(dllimport)

  • __declspec(dllexport):在DLL中定义,让外部可见
  • __declspec(dllimport)用于 DLL 外部(EXE 或 另一个 DLL) ,告诉编译器:这个符号是从 DLL 里导入的,不要重新定义它
cpp 复制代码
// Singleton.h
#pragma once

#ifdef BUILD_DLL
  #define DLL_API __declspec(dllexport)
#else
  #define DLL_API __declspec(dllimport)
#endif

class DLL_API Singleton {
public:
    static Singleton& getInstance();
    void sayHello();
private:
    Singleton();
};
------- 
// Singleton.cpp (编译进 DLL)
#include "Singleton.h"
#include <iostream>

Singleton::Singleton() {
    std::cout << "Singleton constructed\n";
}

Singleton& Singleton::getInstance() {
    static Singleton instance;  // 只在 DLL 中定义,防止重复生成
    return instance;
}

void Singleton::sayHello() {
    std::cout << "Hello from Singleton at: " << this << std::endl;
}

单例之间的析构顺序

单例模式的初始化方式可以通过懒汉模式,使用的时候再进行初始化,这个时候初始化的顺序总是可以确定的。

但是单例模式还有一个不容易察觉的点,析构顺序,C++标准并没有规定静态变量的析构顺序,对于有顺序依赖的析构,有两个办法:

  1. 主动调用注册/析构:定义资源释放函数,在程序关闭前主动调用。一般用这种方式也会在程序启动时主动调用注册方法确保注册顺序。

例如笔者开发过的项目有自己实现的RTTI,通过静态注册的方法确保派生顺序(也就是建立parent和child关系),因为这种方法在注册时需要确保parent先于child注册,析构的时候需要child咸鱼parent析构,因此对于顺序要求是非常严格的。

  1. std::atexit:使用std::unique_ptr,并通过std::atexit进行析构,在程序启动时候主动调用getInstance确保注册顺序。
    • std::atexit在程序终止时调用,多个std::atexit注册的函数调用顺序是注册时的逆序【先注册后执行】。
cpp 复制代码
class Singleton {
public:
    static Singleton& getInstance() {
        static std::unique_ptr<Singleton> instance = [] {
            auto ptr = std::make_unique<Singleton>();
            std::atexit([] { instance.reset(); }); // 注册全局析构回调
            return ptr;
        }();
        return *instance;
    }
};
// 确保初始化顺序,在main入口处调用initializeSingletons
void initializeSingletons() {
    B::getInstance(); // 先初始化 B
    A::getInstance(); // 再初始化 A
}

int atexit(void (*func)());func 是一个无参数、无返回值的函数指针,表示程序终止时要执行的函数

不过需要注意最大注册数量,标准最小要求是支持注册 32 个函数,具体取决于编译器实现。

实战踩坑案例:Windows DLL 中的线程池与静态变量析构顺序问题

背景:Windows中DLL生命周期:

  • DLL_PROCESS_ATTACH:进程加载 DLL 时调用(只调用一次)。
  • DLL_THREAD_ATTACH:进程中的新线程创建时调用(每个线程都会调用)。
  • DLL_THREAD_DETACH:线程退出时调用(每个线程都会调用)。
  • DLL_PROCESS_DETACH:进程卸载 DLL 时调用(只调用一次)。

DLL_PROCESS_DETACH阶段,DLL会析构static变量(析构顺序不确定)

BUG出现!!!

线程池中的线程在 DLL_PROCESS_DETACH 之后仍然存活并运行,访问了已被析构的 static 单例资源,导致访问非法内存(use-after-free)或崩溃。
原因拆解:

  • 静态变量的析构发生在 DLL_PROCESS_DETACH 阶段;
  • 但线程退出触发的 DLL_THREAD_DETACH 可能在其之后;
  • 如果某线程仍在运行,且访问了已被析构的单例对象,就会触发不可预期行为。

解决方案:

1. DLL卸载前,主动调用线程池shutdown接口

在主程序卸载 DLL 之前,主动调用导出的 shutdown() 函数完成线程池停止派发线程,等待线程结束和资源清理。

这里包括主动卸载和程序关闭时的被动卸载。

2. 使用thread_local 管理static变量,这种static变量只有在线程退出时候才会析构

cpp 复制代码
static Singleton& getInstance() {
thread_local Singleton instance;  // 每个线程独有
return instance;
}

总结:如何安全退出线程池并卸载 DLL

因此,为了确保线程池中所有线程都能安全退出,常见的做法是:

  1. 在 DLL 卸载前由外部主动调用一个显式的 shutdown() 方法,通知线程池停止接收新任务并等待所有线程退出;
  2. 避免在 DllMain(DLL_PROCESS_DETACH) 中进行复杂的同步等待操作,而是让主程序在卸载 DLL 前确保线程池已经完全关闭;
  3. 设计线程退出逻辑时,确保所有线程能检测到退出信号并在合理的时间内退出。

这种设计不仅能防止线程在 DLL 卸载后存活,还能避免因线程未退出而导致访问已释放资源的情况。

相关推荐
坏柠26 分钟前
C++ 进阶:深入理解虚函数、继承与多态
java·jvm·c++
虾球xz2 小时前
CppCon 2017 学习:10 Core Guidelines You Need to Start Using Now
开发语言·c++·学习
南岩亦凛汀2 小时前
在Linux下使用wxWidgets进行跨平台GUI开发(三)
c++·跨平台·gui·开源框架·工程实战教程
帅_shuai_3 小时前
UE5 游戏模板 —— Puzzle 拼图游戏
c++·游戏·ue5·虚幻引擎
字节高级特工3 小时前
每日一篇博客:理解Linux动静态库
linux·运维·服务器·c语言·c++·windows·ubuntu
oioihoii3 小时前
C++11可变参数模板从入门到精通
前端·数据库·c++
多吃蔬菜!!!4 小时前
C/C++内存管理
c语言·jvm·c++
程序员如山石4 小时前
QTabWidget动态生成标签页
c++·qt
qqxhb4 小时前
零基础设计模式——总结与进阶 - 3. 学习资源与下一步
学习·设计模式·重构·代码整洁之道
我叫小白菜5 小时前
【Java_EE】设计模式
java·开发语言·设计模式