C++设计模式_12_Singleton 单件模式

2023-10-26 09:45

本文主要是介绍C++设计模式_12_Singleton 单件模式,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

在之前的博文C++57个入门知识点_44:单例的实现与理解中,已经详细介绍了单例模式,并且根据其中内容,单例模式已经可以在日常编码中被使用,本文将会再做梳理。
Singleton 单件模式可以说是最简单的设计模式,但由于多线程环境的双检查锁里的内存reorder的问题,实现时的细节并不简单,大家需要注意多线程环境下的安全做法。为了整体的一致性,本篇都称之为单件模式。

文章目录

  • 1. “对象性能”模式
    • 1.1 典型模式
  • 2. 动机(Motivation)
  • 3. Singleton 单件模式的实现版本
    • 3.1 版本1:线程非安全版本
    • 3.2 版本2:线程安全版本,但锁的代价过高
    • 3.3 版本3:双检查锁
    • 3.4 版本4:C++ 11版本之后的跨平台实现
    • 3.5 总结
  • 4. 模式定义
  • 5. 结构
  • 6. 要点总结
  • 7. 其他参考

单件模式属于一个新的类别,将其归结到“对象性能”模式,该模式的简介如下:

1. “对象性能”模式

面向对象很好地解决了“抽象”的问题,但是不可避免地要付出一定的代价(虚函数(内存等代价哒)、继承)。对于通常情况来讲,面向对象的成本大都可以忽略不计。但是某些情况(倍乘效应等),面向对象所带来的成本必须谨慎处理。

1.1 典型模式

  • Singleton
  • Flyweight

2. 动机(Motivation)

  • 在软件系统中,经常有这样一些特殊的类,必须保证它们在系统中只存在一个实例,才能确保它们的逻辑正确性、以及良好的效率
  • 如何绕过常规的构造器,提供一种机制来保证一个类只有一个实例?
  • 这应该是类设计者的责任,而不是使用者的责任

3. Singleton 单件模式的实现版本

首先将构造函数和拷贝构造函数设置为私有,如果不写的话,编译器会默认生成两个公有的,让外界不能使用它们,唯一的做法就是将其设置为私有的。然后再设置静态变量,并进行静态变量的初始化。代码如下

class Singleton{
private:Singleton();Singleton(const Singleton& other);
public:static Singleton* getInstance();static Singleton* m_instance;
};

3.1 版本1:线程非安全版本

先来看第一种写法,第一次调用的时候,m_instance == nullptr成立的情况下m_instance = new Singleton();创建对象,返回m_instance,第二次调用的话,m_instance == nullptr不成立,继续返回原先的对象,这样就可以保证永远只返回第一次的对象。

//线程非安全版本
Singleton* Singleton::getInstance() {if (m_instance == nullptr) {m_instance = new Singleton();}return m_instance;
}

上述的版本会有其他问题,它在单线程下是OK的,但是多线程环境下就不行了。假设我有2个thread,threadA进到if (m_instance == nullptr)判断第一次调用,还没执行下一行,threadB假如此时得到时间片,开始执行if (m_instance == nullptr)也是成立,就会执行下一行,此时再跳回threadA,此时就会继续执行下一行,这样就会产生2个对象,因此这是线程非安全的。为了实现多线程安全如何去做呢?

3.2 版本2:线程安全版本,但锁的代价过高

第一种常见做法:加锁

//线程安全版本,但锁的代价过高
Singleton* Singleton::getInstance() {Lock lock; //锁的局部变量,出作用域之后消失,释放锁if (m_instance == nullptr) {m_instance = new Singleton();}return m_instance;
}

TreadA进到Lock lock;开始加锁,往下执行时,另外一个线程threadB进来,但是TreadA已经获取到锁,仍未释放,threadB就需要一直等到TreadA执行完函数释放锁。等threadB进去之后m_instance == nullptr就不成立了。

3.3 版本3:双检查锁

上一种版本实现了锁的安全,但是锁的代价太高。假如对象已经创建,那么有两个线程同时访问,threadA执行到Lock lock;,threadB是无法进入的,threadB是一个读操作,当存在多个线程的情况下,读操作是不需要加锁的。读操作是没有安全问题的,这个时候加锁对于读操作的线程是一种等待时间上的浪费。假如高并发场景下,web服务是典型高并发,10万人同时访问时,锁对于读操作代价也会是十分高的。

所以发展出一种新的实现形式,double check lock 双检查锁,之前的版本是不管什么情况,先锁,现在是if(m_instance==nullptr)先问下变量是否为空值,如果为空才加锁,如果不空就不需要加锁,直接返回即可,加完锁之后再判断是否为空。这样就在读操作时减少了时间代价。

//双检查锁,但由于内存读写reorder不安全
Singleton* Singleton::getInstance() {if(m_instance==nullptr){Lock lock;if (m_instance == nullptr) {m_instance = new Singleton();}}return m_instance;
}

有人会考虑,第二个判断if (m_instance == nullptr) 是不是可以去掉,我们假设2个线程,threadA经过第一个判断进来,然后在执行Lock lock;之前,threadB也会进来到Lock lock;之前,两个线程即使加锁也就会等一等,都会执行代码m_instance = new Singleton();,这样就会有2个对象。第二个判断就是为了放置这种情况的出现。

上面的版本看起来就是没有问题的了,也相当一段时间内迷惑了计算机领域的专家,大概2000年时才被JAVA领域的专家发现漏洞:内存读写reorder导致双检查锁的失效。

什么是reorder,正常情况下,大家看到的代码会有指令序列,那么大家通常会认为,指令序列会按照你所想象的方式执行,但是实际上整个代码到了CPU的指令层次(线程是在指令层次抢时间片),指令和我们很多时候的假设很可能不一样。比如: m_instance = new Singleton();一般会有以下的假设,拆分成3个步骤,第一步是先分配内存,第二步是调用构造器(对分配的内存进行初始化),第三步把内存的地址(指针)给m_instance。但是这三步是我们假想的,但是在指令级别,很可能被编译器优化reorder,在reorder之后可能就会先第一步,第三步,最后再第三步。很多研究者在不同的CPU上做实验发现很多语言都会这种现象,这样一来就有些乱套。按照可能的reorder顺序,先分配内存,直接就赋值,m_instance就不是nullptr了,构造器还没调用,另一个线程做第一次判断时发现m_instance不是nullptr,直接返回一个对象,返回的这个对象还没有经过构造器处理,其状态不对,是不可用的。

3.4 版本4:C++ 11版本之后的跨平台实现

为了解决上面的问题,java和c#语言就加了volatile的关键字来保证按照我们的想法来执行。VC++中自己也加了volatile的关键字。

C++11之后才有了这样的机制,可以帮助可以在语言层面实现跨平台的实现。

//C++ 11版本之后的跨平台实现 (volatile)
std::atomic<Singleton*> Singleton::m_instance; //声明原子对象
std::mutex Singleton::m_mutex;Singleton* Singleton::getInstance() {Singleton* tmp = m_instance.load(std::memory_order_relaxed);//得到tmp指针,帮助实现屏蔽编译器的reorderstd::atomic_thread_fence(std::memory_order_acquire);//获取内存fence,内存reorder的屏障//tmp就不会被reorder,底下为双检查锁的操作if (tmp == nullptr) {std::lock_guard<std::mutex> lock(m_mutex);tmp = m_instance.load(std::memory_order_relaxed);if (tmp == nullptr) {tmp = new Singleton;std::atomic_thread_fence(std::memory_order_release);//释放内存fencem_instance.store(tmp, std::memory_order_relaxed);}}return tmp;
}

经过上面的操作,才在C++11之后实现跨平台的实现。

3.5 总结

总体来说,如果是单线程使用版本1已经是足够好了;如果是多线程,使用版本2是OK的,但是效率不高;双检查锁在不使用volatile不能用,是很容易出现问题的;C++11版本之后跨平台实现双检查锁就使用的是最后一个版本

4. 模式定义

保证一个类仅有一个实例,并提供一个该实例的全局访问点
–《设计模式》GoF

5. 结构

在这里插入图片描述

6. 要点总结

  • Singleton模式中的实例构造器可以设置为protected以允许子类派生
  • Singleton模式一般不要支持拷贝构造函数和Clone接口,因为这有可能导致多个对象实例,与Singleton模式的初衷违背。
  • 如何实现多线程环境下安全的Singleton?注意对双检查锁的正确实现。

7. 其他参考

C++设计模式——单例模式

这篇关于C++设计模式_12_Singleton 单件模式的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/285543

相关文章

C++使用栈实现括号匹配的代码详解

《C++使用栈实现括号匹配的代码详解》在编程中,括号匹配是一个常见问题,尤其是在处理数学表达式、编译器解析等任务时,栈是一种非常适合处理此类问题的数据结构,能够精确地管理括号的匹配问题,本文将通过C+... 目录引言问题描述代码讲解代码解析栈的状态表示测试总结引言在编程中,括号匹配是一个常见问题,尤其是在

使用C++实现链表元素的反转

《使用C++实现链表元素的反转》反转链表是链表操作中一个经典的问题,也是面试中常见的考题,本文将从思路到实现一步步地讲解如何实现链表的反转,帮助初学者理解这一操作,我们将使用C++代码演示具体实现,同... 目录问题定义思路分析代码实现带头节点的链表代码讲解其他实现方式时间和空间复杂度分析总结问题定义给定

C++初始化数组的几种常见方法(简单易懂)

《C++初始化数组的几种常见方法(简单易懂)》本文介绍了C++中数组的初始化方法,包括一维数组和二维数组的初始化,以及用new动态初始化数组,在C++11及以上版本中,还提供了使用std::array... 目录1、初始化一维数组1.1、使用列表初始化(推荐方式)1.2、初始化部分列表1.3、使用std::

C++ Primer 多维数组的使用

《C++Primer多维数组的使用》本文主要介绍了多维数组在C++语言中的定义、初始化、下标引用以及使用范围for语句处理多维数组的方法,具有一定的参考价值,感兴趣的可以了解一下... 目录多维数组多维数组的初始化多维数组的下标引用使用范围for语句处理多维数组指针和多维数组多维数组严格来说,C++语言没

c++中std::placeholders的使用方法

《c++中std::placeholders的使用方法》std::placeholders是C++标准库中的一个工具,用于在函数对象绑定时创建占位符,本文就来详细的介绍一下,具有一定的参考价值,感兴... 目录1. 基本概念2. 使用场景3. 示例示例 1:部分参数绑定示例 2:参数重排序4. 注意事项5.

使用C++将处理后的信号保存为PNG和TIFF格式

《使用C++将处理后的信号保存为PNG和TIFF格式》在信号处理领域,我们常常需要将处理结果以图像的形式保存下来,方便后续分析和展示,C++提供了多种库来处理图像数据,本文将介绍如何使用stb_ima... 目录1. PNG格式保存使用stb_imagephp_write库1.1 安装和包含库1.2 代码解

Java实现状态模式的示例代码

《Java实现状态模式的示例代码》状态模式是一种行为型设计模式,允许对象根据其内部状态改变行为,本文主要介绍了Java实现状态模式的示例代码,文中通过示例代码介绍的非常详细,需要的朋友们下面随着小编来... 目录一、简介1、定义2、状态模式的结构二、Java实现案例1、电灯开关状态案例2、番茄工作法状态案例

C++实现封装的顺序表的操作与实践

《C++实现封装的顺序表的操作与实践》在程序设计中,顺序表是一种常见的线性数据结构,通常用于存储具有固定顺序的元素,与链表不同,顺序表中的元素是连续存储的,因此访问速度较快,但插入和删除操作的效率可能... 目录一、顺序表的基本概念二、顺序表类的设计1. 顺序表类的成员变量2. 构造函数和析构函数三、顺序表

使用C++实现单链表的操作与实践

《使用C++实现单链表的操作与实践》在程序设计中,链表是一种常见的数据结构,特别是在动态数据管理、频繁插入和删除元素的场景中,链表相比于数组,具有更高的灵活性和高效性,尤其是在需要频繁修改数据结构的应... 目录一、单链表的基本概念二、单链表类的设计1. 节点的定义2. 链表的类定义三、单链表的操作实现四、

使用C/C++调用libcurl调试消息的方式

《使用C/C++调用libcurl调试消息的方式》在使用C/C++调用libcurl进行HTTP请求时,有时我们需要查看请求的/应答消息的内容(包括请求头和请求体)以方便调试,libcurl提供了多种... 目录1. libcurl 调试工具简介2. 输出请求消息使用 CURLOPT_VERBOSE使用 C