ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【设计模式精讲】4.单例模式(Singleton)

【设计模式精讲】4.单例模式(Singleton) 【设计模式精讲】4.单例模式Singleton【摘要】全局配置、日志器、线程池这类对象在整个进程里只需要一份。多数人的第一反应是定义一个全局变量随后便陷入初始化顺序失控、重复构造、多线程竞争的泥潭。本文从全局变量的三宗罪讲起给出单例模式的意图与结构重点拆解 C 中线程安全单例的三种写法——Meyers’ Singleton、双检锁的坑与修正、std::call_once并比较它们的行为差异。文章最后讨论单例的代价与替代方案提醒读者单例是受限的全局状态能少用就少用。【关键词】单例模式、Meyers’ Singleton、双检锁、std::call_once、C1. 一个全局配置对象引发的血案新项目开工你写了一个全局配置// 说明性片段省略 Config 的定义// config.hConfig g_config;// ❌ 定义在头文件里// a.cpp / b.cpp 都 include 了 config.h链接器先报了重复定义改成extern Config g_config;后程序又在启动时随机崩溃——Logger的构造函数在g_config初始化之前就读取了它。静态对象的初始化顺序在跨编译单元时是未定义的这正是 C 著名的「静态初始化顺序惨案」static initialization order fiasco。等团队把配置挪进函数、日志改成懒加载多线程模块上线后又出现了两份实例各写各的缓存。三宗罪初始化顺序不可控、构造时机不可控、多线程下不唯一。单例模式要解决的就是「一个类恰好一个实例 全局可访问」这两件事并且把「何时构造」的决定权收回到类自己手里。2. 模式意图与定义一句话定义GoF 原文意图的译文Ensure a class only has one instance, and provide a global point of access to it——保证一个类仅有一个实例并提供一个访问它的全局访问点。Refactoring Guru 中文版亦称「单件模式」。解决的问题Refactoring Guru 指出单例同时解决两个问题——控制实例数量控制数据库连接、文件系统这类共享资源的访问第二次「创建」拿到的是首次的那个对象和提供全局访问节点像全局变量一样好用但实例被类保护、无法被外部代码覆盖。代价是它一身兼两职天然违反单一职责原则。GoF 的动机举例更直观系统里可以有多台打印机但打印后台spooler只能有一个一个数字滤波器只能有一块 A/D 转换器。全局变量做不到「阻止你创建第二个」所以更优解是让类自己负责看管自己的唯一实例。GoF 的原始结构是把构造函数设为私有、静态成员instance()返回唯一实例。RG 把实现步骤归纳为四条私有静态成员存实例、公有静态方法取实例、方法内做延迟初始化、构造函数设私有。经典写法是判断指针是否为空本文第 4 节会看到它在 C 多线程下的问题与修正。3. UML 图 结构说明调用 instance()Singleton-Singleton$ instance-~configData : std::string-Singleton()-operator(Singleton)instance() : Singleton-doWork() : voidClient构造/拷贝全部私有instance() 是唯一入口角色只有一个但三道锁缺一不可私有构造与析构外界无法new、无法创建栈对象禁用拷贝与赋值否则「唯一」会被拷贝绕过静态instance()唯一访问点同时负责「首次构造」。4. 传统 C 写法C11 之前先按 GoF 原书时代的形态实现一遍。那时没有 delete禁拷贝的惯用法是「声明为 private 且不给出定义」// 经典懒汉式C98/03 写法classConfig{public:staticConfig*instance(){if(s_inst0){// 两个线程可能s_instnewConfig();// 同时通过判断}// 造成重复构造returns_inst;}private:Config(){}// 私有构造Config(constConfig);// ❌ 只声明不定义Configoperator(constConfig);// 即可禁用拷贝staticConfig*s_inst;};Config*Config::s_inst0;这一版的问题有两个。其一多线程下不安全线程 A 执行到new Config()分配了内存、还没构造完线程 B 看到s_inst非空就拿去用——读到半成品对象。其二即使构造受锁保护普通指针赋值与构造完成之间没有顺序保证直觉的修法是给整个函数加锁但每次访问都加锁太贵于是有了「双检锁」DCLP。以下以 POSIXpthread接口为例Linux 服务端的老代码里随处可见// POSIX 平台片段pthread 双检锁#includepthread.hclassConfig{public:staticConfig*instance(){if(s_inst0){// 第一道检查pthread_mutex_lock(s_mtx);// 无实例才抢锁if(s_inst0){// 第二道检查s_instnewConfig();// 抢到锁后再确认}pthread_mutex_unlock(s_mtx);}returns_inst;}private:Config(){}Config(constConfig);Configoperator(constConfig);staticpthread_mutex_t s_mtx;staticConfig*s_inst;};pthread_mutex_t Config::s_mtxPTHREAD_MUTEX_INITIALIZER;Config*Config::s_inst0;严谨地说这版仍不完美new可能被编译器重排成「先赋值指针、后执行构造」第一道无锁检查就可能读到半成品。POSIX 给出的正解是pthread_once——干脆不做双检// POSIX 平台片段Config 定义同上略#includepthread.hstaticpthread_once_t s_oncePTHREAD_ONCE_INIT;staticConfig*s_cfg0;staticvoidinitConfig(){s_cfgnewConfig();}Config*getConfig(){pthread_once(s_once,initConfig);returns_cfg;}可以看到前 C11 时代想写对一个懒汉单例要么绑死平台接口要么踩内存模型的坑——用std::atomic的acquire/release语义堵住重排漏洞的标准 C 版双检锁也存在但正确写法已经繁琐到需要逐行注释才能读懂。这正是下一节 Meyers’ Singleton 胜出的原因C11 把这些补丁全部收进了语言本身。5. 现代 C 进阶写法首选Meyers’ Singleton。C11 起标准保证局部静态变量的初始化是线程安全的「如果控制流并发地进入声明并发执行应当等待初始化完成」[stmt.dcl]。于是双检锁的全部代码塌缩成三行// ✅ C11 起推荐的写法#includeiostream#includemutex#includestringclassLogger{public:staticLoggerinstance(){staticLogger inst;// 线程安全的首懒初始化returninst;}voidlog(conststd::stringmsg){std::lock_guardstd::mutexlk(m_mtx);std::cout[log] msg\n;}private:Logger()default;~Logger()default;Logger(constLogger)delete;Loggeroperator(constLogger)delete;std::mutex m_mtx;};使用方式Logger::instance().log(hello);。要点有三返回引用而不是指针——指针可以被delete或被复制引用语义上就是「那一个」 delete显式禁拷贝C11比 GoF 时代私有不定义的 hack 清晰得多析构如需参与退出顺序把析构也设为private并提供destroy()或干脆用std::shared_ptr管理静态实例。备选std::call_once。当构造逻辑无法塞进一个静态局部变量比如要按参数选择子类时// 节选Config 定义见上文略#includemutexstaticstd::once_flag s_flag;staticConfig*s_cfgnullptr;ConfiggetConfig(){std::call_once(s_flag,[]{s_cfgnewConfig();// 任意复杂的一次性构造});return*s_cfg;}三者怎么选默认 Meyers’构造有分支/依赖注入需求用call_once双检锁仅在被迫维护旧代码或需要返回指针接口时出现新代码不必手写。还剩一个所有懒汉写法都绕不开的话题销毁。局部静态变量会在进程退出时、按与构造相反的顺序析构听起来很美但这个顺序只在「同一个线程先后触发初始化」时才成立。若单例 A 的析构函数里调用了单例 B而 B 恰好在 A 之后析构退出阶段就翻车了更隐蔽的是exit()之后仍有后台线程在访问单例。工程上有两种成熟对策一是不死单例leaky singleton刻意new出来永不析构把回收交给操作系统——进程级日志器、指标收集器常用这招反正退出后资源都会被回收二是降级空实现析构时把内部指针置空、后续调用变成安全空操作。大型框架几乎都选其一第 7 节的三个真实库给出了各自的答案。6. 优缺点与适用场景✅ 优点严格保证唯一实例懒加载未用到不付出构造成本访问点统一接口集中。❌ 缺点它就是全局状态——隐藏依赖、妨碍并行测试单例无法在两个测试间替换、跨编译单元的销毁顺序仍可能出问题「唯一」约束在将来需求变化突然要支持多租户/多实例时是硬伤。 适用场景进程级天然唯一的资源——日志器、配置中心、线程池、硬件设备抽象。判断标准是「领域本身就是唯一的」而不是「我图省事只想建一个」。〔辨析〕单例 vs 全局变量全局变量在main之前构造、顺序不可控单例把构造推迟到首次访问顺序由使用关系决定且能挡住拷贝。单例 vs 静态类静态成员函数全是过程的「伪单例」无法实现接口、无法虚函数分发也拿不到实例语义。关于测试再多说两句。单例妨害测试的根源是「类自己决定依赖谁」TaxCalculator内部直接调Config::instance()测试就无法替它换上一份假配置。缓解办法有层次的差别最轻的是给单例留一个「重置/注入」接口仅供测试编译期开启更彻底的是把唯一性上移——只在使用一根装配线的main里构造一份往下用构造参数传递引用。后者其实就是依赖注入类的代码看不出任何单例痕迹「全局唯一」变成部署决策而不是类的设计可测试性随之回归。这也解释了为什么 GoF 之后不少流派如 Google 的代码可读性指南都建议单例能不进类就不进类。7. 开源项目中的身影三个真实 C 工程库对单例的处理恰好对应本文讲过的三条技术路线也各自暴露了不同的取舍。AOSPAndroid锁保护的全局指针以及一份「自我否定」。Android 系统层的 native 库 libutils 提供了android::SingletonT模板供 HAL、Binder 服务等系统组件复用节选并简化自源码// 节选自 AOSP system/core/libutils// include/utils/Singleton.h有简化namespaceandroid{templatetypenameTYPEclassSingleton{public:staticTYPEgetInstance(){Mutex::Autolock_l(sLock);TYPE*instancesInstance;if(instancenullptr){instancenewTYPE();sInstanceinstance;}return*instance;}staticboolhasInstance(){Mutex::Autolock_l(sLock);returnsInstance!nullptr;}protected:~Singleton();private:Singleton();staticMutex sLock;staticTYPE*sInstance;};};// namespace android实现要点与本文第 4 节的 pthread 版几乎是同一套思路Mutex::Autolock是 RAII 风格的lock_guard构造放锁内、返回引用挡住外部delete配套宏ANDROID_SINGLETON_STATIC_INSTANCE(TYPE)负责在 cpp 文件里定义静态成员绕开头文件重复定义问题。值得学习的反而是它的结局这个头文件从 Android 9P起被标记废弃官方注释直接建议改用 C11 的局部静态变量写法——一个维护了十年的自研模板最终被语言标准收编。这是「能上 Meyers’ 就别手写锁」最有说服力的工程证据。Boost用 Meyers’但补上了「析构追踪」。Boost.Serialization 里有一个被多个库借用的boost::serialization::singletonT核心骨架节选并简化// 节选自 boost/serialization/singleton.hpp// 有简化namespaceboost{namespaceserialization{templateclassTclasssingleton{public:staticTget_instance(){// 局部静态线程安全依赖 C11// 旧编译器另有锁兜底分支staticdetail::singleton_wrapperTt;returnstatic_castT(t);}// 供序列化框架在退出阶段查询// 单例是否已被析构staticboolis_destroyed(){returndetail::singleton_wrapperT::is_destroyed();}// ...细节略};}// namespace serialization}// namespace boost它用的正是本文推荐的 Meyers’ 路线但多包了一层singleton_wrapperT包装类的析构函数会把一个静态标志位置真is_destroyed()据此回答「现在还能不能用」。为什么需要这个序列化框架的全局类型注册表活到进程尾声而退出阶段各静态对象的析构顺序不可控——某个延迟的序列化操作可能撞上已析构的注册表。Boost 用「可查询的死亡状态」把未定义行为变成可检测的错误这提醒我们单例的麻烦一半在构造另一半在析构而后者 Meyers’ 也只解决一半。POCO不设防的SingletonHolder把责任交还给使用者。POCO 基础库的风格截然不同它的Poco::SingletonHolderS模板短得可以整段贴进来节选自源码略去版权注释// 节选自 POCO Foundation include/Poco/SingletonHolder.hnamespacePoco{templateclassSclassSingletonHolder{public:SingletonHolder():_pS(0){}~SingletonHolder(){delete_pS;}S*get(){if(_pS0)_pSnewS();return_pS;}private:SingletonHolder(constSingletonHolder);SingletonHolderoperator(constSingletonHolder);S*_pS;};}// namespace Poco注意get()里没有任何锁——它不是线程安全的。这不是疏忽而是设计取舍POCO 把这个模板定位为「单例语义的脚手架」线程安全由具体使用方按需包一层例如Poco::Util::Application的instance()在其上层另行保证。它同时用析构函数delete持有的指针把生命周期收在 holder 手里与 Boost「故意 leak 或追问死亡状态」是两种相反的哲学。三份代码放在一起看同一个模式在不同工程里分别长成了「加锁」「包 wrapper」「裸模板」三种形态——模式给出意图实现细节永远由具体工程的风险点决定。反模式提醒收尾如果发现单例只是为了让「到处都能调到」先用依赖注入把Logger作为参数传进去重写一遍试试——很多「必须单例」的类其实只是「懒得接线」。AOSP 的废弃通知、Boost 的死亡追踪、POCO 的不设防说的都是同一件事单例写对只是及格线生命周期与并发边界才是真正的考题。本篇小结单例模式的全部价值浓缩在instance()一个函数里把唯一性、构造时机与线程安全收进类内部。C11 后首选 Meyers’ Singleton局部静态 引用返回 delete拷贝需要复杂初始化时用std::call_once双检锁留给历史代码。而比「怎么写对」更重要的是「要不要写」单例是受限的全局状态用在天然唯一的资源上而不是绕过设计的捷径。下一篇工厂方法我们把注意力从「唯一实例」转向「实例从哪里来」。本文模式定义与实现步骤参考了 Refactoring Guru《设计模式》中文版「单例」一章意图译文与动机示例参考了 GoF《Design Patterns》第 3 章 Singleton 一节。
返回列表