引言
? 單例模式是日常開發(fā)中較常用的一種設(shè)計模式,它能夠確保一個類只有一個實例,并提供一個全局訪問入口。
? 在之前的開發(fā)過程中,觀察到進(jìn)程退出時偶現(xiàn) crash 現(xiàn)象。由于該問題僅發(fā)生在生命周期末端且未影響核心功能,并未深入排查原因。近期看到技術(shù)公眾號分析單例模式潛在問題的,意識到此前的crash大致就是因此導(dǎo)致的。
場景列舉
問題
? 先看代碼,看看執(zhí)行會發(fā)生什么?
main 函數(shù)
- 首先獲取
SingletonB的實例。 - 然后獲取
SingletonA的實例。 - 最后輸出退出信息。
int main() {
SingletonA::getInstance();
SingletonB::getInstance();
std::cout << "exe exit!" << std::endl;
return 0;
}
單例類 SingletonB
-
SingletonB類使用靜態(tài)局部變量instance來確保只有一個實例。 - 構(gòu)造函數(shù)中,
mpBuf分配了內(nèi)存,并將字符串"hello"復(fù)制到該內(nèi)存中。 - 析構(gòu)函數(shù)中,釋放了
mpBuf所占用的內(nèi)存,并置為nullptr。
// 單例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]; // 創(chuàng)建資源
memcpy(mpBuf, "hello", 5);
mpBuf[5] = '\0';
}
private:
char *mpBuf;
};
單例類 SingletonA
-
SingletonA類同樣使用靜態(tài)局部變量instance來確保只有一個實例。 - 構(gòu)造函數(shù)中,簡單輸出創(chuàng)建信息。
- 析構(gòu)函數(shù)中,調(diào)用
SingletonB的doSomething接口。
// 單例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;
}
};
輸出
程序在退出時,發(fā)生crash了。
$ ./a.out
SingletonA created.
SingletonB created.
exe exit!
SingletonB destroyed.
SingletonA destroy begin. call A
Segmentation fault
代碼分析
?上述的實現(xiàn),很明顯可以看出因為調(diào)用了已析構(gòu)的對象,導(dǎo)致異常crash。
?但是在實際項目開發(fā)過程中,單例模式的調(diào)用分散在各個文件中,由于初始化順序難以控制,很容易出現(xiàn)調(diào)用已析構(gòu)對象的情況,進(jìn)而導(dǎo)致程序崩潰。這類問題一旦發(fā)生,往往難以迅速定位和解決。
? 看看AI怎么評價這段代碼:
Me: 魔鏡啊魔鏡,上述代碼有什么問題?
魔鏡:來了,老弟!這代碼毛病可不小吶~
那 `SingletonA` 析構(gòu)的時候,還跑去調(diào)用 `SingletonB` 的方法。
程序結(jié)束的時候,靜態(tài)對象是后構(gòu)造的先析構(gòu),這 SingletonB 不妥妥先析構(gòu)了,mpBuf也回收了。
SingletonA 再去調(diào)用 SingletonB::doSomething,指定下機(jī)啊!
哪個損粗寫的這代碼吶!回爐重造吧,趕緊的啊~
Me: 鄉(xiāng)村愛情看多了吧你,口音聽的上頭。
正經(jīng)點,那怎么規(guī)避這類問題?
魔鏡:閑來無事,網(wǎng)上爬了幾集,真好看~
有很多種方式可以避免這種問題,我就說兩種,好好聽,好好學(xué)!
① 避免單例對象析構(gòu),如下寫法:
SingletonA* SingletonA::getInstance() {
static SingletonA *instance = new (std::nothorw) SingletonA();
return instance;
}
② 增加全局變量,檢測對象是否析構(gòu)。每次獲取實例時,特別是析構(gòu)中獲取單例時,先判空。
static std::atomic<bool> gObjAlive(true);
SingletonA::~SingletonA() {
gObjAlive = false;
}
SingletonA* SingletonA::getInstance() {
if (!gObjAlive) {
return nullptr;
}
static SingletonA instance;
return &instance;
}
? 通過與AI的友好溝通,規(guī)避的方案有很多。上述的兩種方案,各有利弊:
- 第一種方案
優(yōu)點:寫法簡潔。
缺點:由于不會調(diào)用析構(gòu)函數(shù),除內(nèi)存會隨進(jìn)程退出釋放外,析構(gòu)函數(shù)中涉及的硬件資源釋放等操作無法執(zhí)行,可能引發(fā)潛在問題。 - 第二種方案
優(yōu)點:安全性較高,可自動釋放所持資源。
缺點:用時不夠便捷,每次調(diào)用單例對象(尤其是在析構(gòu)階段)都需要進(jìn)行判空操作。
總結(jié)
- 結(jié)合全文,
crash的問題根源在于單例析構(gòu)函數(shù)中調(diào)用其他單例對象的接口。
進(jìn)程退出階段,全局單例對象的析構(gòu)順序未明確,可能導(dǎo)致調(diào)用了已釋放的單例對象接口,引發(fā)crash。 - 盡管可以通過控制單例調(diào)用的順序來規(guī)避此類問題。但這種方式并非最優(yōu)解,而且大型項目中難以實施。另外,程序健壯性不應(yīng)依賴這種非確定性的語言特性,而應(yīng)通過設(shè)計優(yōu)化從根本上避免此類問題。
- 最后,代碼運(yùn)行的行為和結(jié)果應(yīng)是可預(yù)見的,不應(yīng)依賴特殊操作或非確定性行為。若有此類依賴,應(yīng)是程序設(shè)計缺陷,該加以優(yōu)化。