单例模式中的隐藏陷阱:你真的了解单例吗?
引言
单例模式是日常开发中较常用的一种设计模式,它能够确保一个类只有一个实例,并提供一个全局访问入口。
在之前的开发过程中,观察到进程退出时偶现 crash
现象。由于该问题仅发生在生命周期末端且未影响核心功能,并未深入排查原因。
近期看到技术公众号分析单例模式潜在问题的,意识到此前的crash
大致就是因此导致的。
场景列举
问题
先看代码,看看执行会发生什么?
main
函数
- 首先获取
SingletonB
的实例。 - 然后获取
SingletonA
的实例。 - 最后输出退出信息。
c++
int main() {
SingletonA::getInstance();
SingletonB::getInstance();
std::cout << "exe exit!" << std::endl;
return 0;
}
单例类 SingletonB
SingletonB
类使用静态局部变量instance
来确保只有一个实例。- 构造函数中,
mpBuf
分配了内存,并将字符串"hello"
复制到该内存中。 - 析构函数中,释放了
mpBuf
所占用的内存,并置为nullptr
。
c++
// 单例B
class SingletonB {
public:
~SingletonB() {
std::cout << "SingletonB destroyed." << std::endl;
delete []mpBuf; // 回收资源
mpBuf = nullptr;
}
static SingletonB* getInstance() {
static SingletonB instance;
return &instance;
}
void doSomething() {
printf("mpBuf[0] = %c\n", mpBuf[0]);
}
private:
SingletonB() {
std::cout << "SingletonB created." << std::endl;
mpBuf = new (std::nothrow) char[10]; // 创建资源
memcpy(mpBuf, "hello", 5);
mpBuf[5] = '\0';
}
private:
char *mpBuf;
};
单例类 SingletonA
SingletonA
类同样使用静态局部变量instance
来确保只有一个实例。- 构造函数中,简单输出创建信息。
- 析构函数中,调用
SingletonB
的doSomething
接口。
cpp
// 单例A
class SingletonA {
private:
SingletonA() {
std::cout << "SingletonA created." << std::endl;
}
~SingletonA() {
std::cout << "SingletonA destroy begin. call A" << std::endl;
SingletonB::getInstance()->doSomething();
std::cout << "SingletonA destroyed end." << std::endl;
}
public:
static SingletonA* getInstance() {
static SingletonA instance;
return &instance;
}
};
输出
程序在退出时,发生crash
了。
c++
$ ./a.out
SingletonA created.
SingletonB created.
exe exit!
SingletonB destroyed.
SingletonA destroy begin. call A
Segmentation fault
代码分析
上述的实现,很明显可以看出因为调用了已析构的对象,导致异常crash
。
但是在实际项目开发过程中,单例模式的调用分散在各个文件中,由于初始化顺序难以控制,很容易出现调用已析构对象的情况,进而导致程序崩溃。这类问题一旦发生,往往难以迅速定位和解决。
看看AI怎么评价这段代码:
c++
Me: 魔镜啊魔镜,上述代码有什么问题?
魔镜:来了,老弟!这代码毛病可不小呐~
那 `SingletonA` 析构的时候,还跑去调用 `SingletonB` 的方法。
程序结束的时候,静态对象是后构造的先析构,这 SingletonB 不妥妥先析构了,mpBuf也回收了。
SingletonA 再去调用 SingletonB::doSomething,指定下机啊!
哪个损粗写的这代码呐!回炉重造吧,赶紧的啊~
Me: 乡村爱情看多了吧你,口音听的上头。
正经点,那怎么规避这类问题?
魔镜:闲来无事,网上爬了几集,真好看~
有很多种方式可以避免这种问题,我就说两种,好好听,好好学!
① 避免单例对象析构,如下写法:
SingletonA* SingletonA::getInstance() {
static SingletonA *instance = new (std::nothorw) SingletonA();
return instance;
}
② 增加全局变量,检测对象是否析构。每次获取实例时,特别是析构中获取单例时,先判空。
static std::atomic<bool> gObjAlive(true);
SingletonA::~SingletonA() {
gObjAlive = false;
}
SingletonA* SingletonA::getInstance() {
if (!gObjAlive) {
return nullptr;
}
static SingletonA instance;
return &instance;
}
通过与AI
的友好沟通,规避的方案有很多。上述的两种方案,各有利弊:
- 第一种方案
优点:写法简洁。
缺点:由于不会调用析构函数,除内存会随进程退出释放外,析构函数中涉及的硬件资源释放等操作无法执行,可能引发潜在问题。 - 第二种方案
优点:安全性较高,可自动释放所持资源。
缺点:用时不够便捷,每次调用单例对象(尤其是在析构阶段)都需要进行判空操作。
总结
- 结合全文,
crash
的问题根源在于单例析构函数中调用其他单例对象的接口。
进程退出阶段,全局单例对象的析构顺序未明确,可能导致调用了已释放的单例对象接口,引发crash
。 - 尽管可以通过控制单例调用的顺序来规避此类问题。但这种方式并非最优解,而且大型项目中难以实施。另外,程序健壮性不应依赖这种非确定性的语言特性,而应通过设计优化从根本上避免此类问题。
- 最后,代码运行的行为和结果应是可预见的,不应依赖特殊操作或非确定性行为。若有此类依赖,应是程序设计缺陷,该加以优化。