单例模式中的隐藏陷阱:你真的了解单例吗?

单例模式中的隐藏陷阱:你真的了解单例吗?

引言

单例模式是日常开发中较常用的一种设计模式,它能够确保一个类只有一个实例,并提供一个全局访问入口。

在之前的开发过程中,观察到进程退出时偶现 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 来确保只有一个实例。
  • 构造函数中,简单输出创建信息。
  • 析构函数中,调用 SingletonBdoSomething 接口。
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
  • 尽管可以通过控制单例调用的顺序来规避此类问题。但这种方式并非最优解,而且大型项目中难以实施。另外,程序健壮性不应依赖这种非确定性的语言特性,而应通过设计优化从根本上避免此类问题。
  • 最后,代码运行的行为和结果应是可预见的,不应依赖特殊操作或非确定性行为。若有此类依赖,应是程序设计缺陷,该加以优化。
相关推荐
前端付豪6 分钟前
揭秘网易统一日志采集与故障定位平台揭秘:如何在亿级请求中1分钟定位线上异常
前端·后端·架构
陈随易23 分钟前
Lodash 杀手来了!es-toolkit v1.39.0 已完全兼容4年未更新的 Lodash
前端·后端·程序员
未来影子41 分钟前
SpringAI(GA):Nacos3下的分布式MCP
后端·架构·ai编程
Hockor1 小时前
写给前端的 Python 教程三(字符串驻留和小整数池)
前端·后端·python
码农之王1 小时前
记录一次,利用AI DeepSeek,解决工作中算法和无限级树模型问题
后端·算法
Wo3Shi4七1 小时前
消息不丢失:生产者收到写入成功响应后消息一定不会丢失吗?
后端·kafka·消息队列
爱上语文1 小时前
MyBatisPlus(3):常用配置
java·后端·mybatis
编程乐趣1 小时前
C#实现Stdio通信方式的MCP Server
后端
程序猿本员1 小时前
线程池精华
c++·后端
袁煦丞1 小时前
电子书阅读器界的"万能工具"Koodo Reader :cpolar内网穿透实验室第593个成功挑战
前端·后端·远程工作