C++单例模式
- 目的
- 使用场景
- 创建方式
- 考虑DLL的情况
- 单例之间的析构顺序
- [实战踩坑案例:Windows DLL 中的线程池与静态变量析构顺序问题](#实战踩坑案例:Windows DLL 中的线程池与静态变量析构顺序问题)
-
- 背景:Windows中DLL生命周期:
- BUG出现!!!
- 解决方案:
-
- [1. DLL卸载前,主动调用线程池shutdown接口](#1. DLL卸载前,主动调用线程池shutdown接口)
- [2. 使用thread_local 管理static变量,这种static变量只有在线程退出时候才会析构](#2. 使用thread_local 管理static变量,这种static变量只有在线程退出时候才会析构)
- [总结:如何安全退出线程池并卸载 DLL](#总结:如何安全退出线程池并卸载 DLL)
设计模式的第一篇,笔者尝试结合实际工作中的经验描述设计模式的作用。
目的
创建一个全局唯一、共享的对象
- 全局唯一:一个类当且仅当有一个对象(禁止拷贝,禁止外部创建)。
- 共享:项目中多处需要使用。
使用场景
就笔者而言,使用最多的就是跟工厂模式打配合,各种工厂类就是单例,除此之外还有用于注册和线程池。
工厂模式
工厂模式的典型使用场景:
最常用的使用场景是这样:
- 存在统一的处理流程(如调用顺序或生命周期);
- 各处理步骤固定,但需要根据不同上下文,在某些步骤中插入差异化行为。
典型实现方式如下:
- 定义一个抽象基类,封装公共流程;
- 实现多个派生类,分别处理具体差异;
- 定义一个工厂类,用于根据上下文创建不同派生类实例;
- 工厂类作为单例存在;
- 所有可创建的派生类在程序初始化阶段就已注册到工厂中;
- 工厂根据上下文信息决定实例化哪个派生类,返回基类指针;
- 调用者通过基类指针使用统一接口执行操作。
为什么工厂类需要是单例:
在该模式中,工厂类不仅负责创建对象,还承担类型注册与选择逻辑的统一管理。例如可以将"类型匹配逻辑"与"具体创建方法"绑定到工厂内部,并通过静态初始化方式完成注册,可以确保:
- 所有注册只发生一次,避免重复逻辑;
- 全局一致性,避免因多份工厂状态不一致导致行为偏差;
- 注册过程与使用解耦,便于扩展和维护。
因此,工厂采用单例模式具有关键意义:确保注册机制只初始化一次,且全局可用,为类型创建提供统一、可扩展的机制。
笔者用的最多的就是这种方法,因为C++的多态能很方便的派生不同的行为,所以对于一些大部分相同,小部分有差异的事情,都习惯用工厂模式,通过不同的上下文生成一个基类去执行差异化的任务。
注册
在程序运行前或运行中,把某些信息(如类、函数、对象、配置)添加到某个中心化的管理结构中(如全局表、工厂、调度器),以便后续通过标识符动态使用它们。
例如笔者用过的方法注册,将某种函数调用方法跟类型进行绑定,实现类似RTTR的结构。
线程池
- 单例模式便于统一管理线程资源。
- 由于有统一入口,更方便维护和监控。
- 不同线程共享同一进程资源,而单例模式能确保进程唯一。
创建方式
不考虑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++标准并没有规定静态变量的析构顺序,对于有顺序依赖的析构,有两个办法:
- 主动调用注册/析构:定义资源释放函数,在程序关闭前主动调用。一般用这种方式也会在程序启动时主动调用注册方法确保注册顺序。
例如笔者开发过的项目有自己实现的RTTI,通过静态注册的方法确保派生顺序(也就是建立parent和child关系),因为这种方法在注册时需要确保parent先于child注册,析构的时候需要child咸鱼parent析构,因此对于顺序要求是非常严格的。
- 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
因此,为了确保线程池中所有线程都能安全退出,常见的做法是:
- 在 DLL 卸载前由外部主动调用一个显式的
shutdown()
方法,通知线程池停止接收新任务并等待所有线程退出; - 避免在
DllMain(DLL_PROCESS_DETACH)
中进行复杂的同步等待操作,而是让主程序在卸载 DLL 前确保线程池已经完全关闭; - 设计线程退出逻辑时,确保所有线程能检测到退出信号并在合理的时间内退出。
这种设计不仅能防止线程在 DLL 卸载后存活,还能避免因线程未退出而导致访问已释放资源的情况。