单例模式:从 naive 到工业级¶
今天可以来谈谈单例模式。大家可能或多或少都听说过设计模式,小糖读书的时候,就听说要先学编程语言、再学数据结构、然后再学设计模式,最终走向架构师。设计模式容易让人感觉非常的高级和神秘。
其实小唐一直把设计模式,当作写代码的“套路”。用好设计模式,可以避免掉很多项目迭代过程中的坑。但是另外一方面,如果没有踩过坑,又很难理解设计模式的精妙。反而容易被各类设计模式的多层抽象给弄晕。所以最好的方式,就是根据真实的项目学设计模式。今天我们就从大型C++工程、深度学习框架 OneFlow 中,学习单例模式。
什么是单例模式¶
在实际场景中,经常有 “某个类只能有一个实例化对象” 的需求。如全屏游戏运行时应该只有一个窗口、系统日志由一个日志管理器统一记录,再比如,分布式深度学习框架 OneFlow 中的全局环境信息、线程池等都设计为单例。
《设计模式》一书中,给出了单例模式的简单实现:
```c++ // singleton.h class CSingleton{ public: static CSingleton* Instance(){ if (_instance == nullptr){ _instance = new CSingleton; } return _instance; } //该类的其它接口 ... protected: CSingleton(){} private: static CSingleton* _instance; };
// singleton.cpp CSingleton* CSingleton::_instance; ```
它的实现要点是:
- 将构造函数设置为非 public 的权限
- 使用一个类静态成员保存唯一的实例
- 实现一个 public 的静态方法,用于获取唯一的对象实例
但是,这种示例代码,仍然有一些缺陷。比如拷贝构造、移动构造还未delete,所以用户可能有其它方式构造对象实例。不过,这个相信大家都会自己注意和修改。
更重要的是,它还其它缺陷,满足不了工业级的需求:
- 不同的类想要变成单例,都需要重复添加
Instance
方法及静态成员,比较冗余。 - 不能处理一个类的构造存在重载的情况。更没有办法统一优雅地解决不同类的构造函数的参数个数、参数类型可能不同的问题。这个版本直接回避了这个问题,举例用的是无参构造。
- 无法精确控制单例对象的顺序依赖关系。比如建建学校,要先有校长。这就必需保证校长在学校构造前已经被实例化。 但是这个朴素版本,把创建对象和获取对象的逻辑放在了一起,无法精确控制单例的生命周期。
我们看看 OneFlow 中是怎么克服以上缺陷的。
OneFlow 中的工业级单例模式¶
OneFlow 的单例模式代码在 global.h 中。它的核心代码如下(我简化了和异常处理、错误检查等代码):
```c++
template
template<typename... Args>
static void New(Args&&... args) {
CHECK(Get() == nullptr); //不为 nullptr 将抛异常
*GetPPtr() = new T(std::forward<Args>(args)...);
}
static void Delete() {
if (Get() != nullptr) {
delete Get();
*GetPPtr() = nullptr;
}
}
private: static T** GetPPtr() { static T* ptr = nullptr; return &ptr; } }; ```
我们看到,它提供了三个重要的公开方法:New
、Get
、Delete
。其中 New
是用来实例化单例、Delete
用来销毁单例、Get
用来获取全局唯一的单例对象。
我们看到,与之前朴素版本的单例模式相比,Global
专门设计了 New
接口和 Delete
接口。它的好处在于,不再把单例对象的生命周期管理交给运行时和编译器。而是可以非常精确地控制单例对象的生命周期。
比如这里的代码,这个线程池单例,就依赖了 ResourceDesc
单例。因为可以精确控制生命周期,我们可以确保线程池实例化时,ResourceDesc
已经存在了。
c++
Global<ThreadPool>::New(Global<ResourceDesc, ForSession>::Get()->ComputeThreadPoolSize());
New 接口实现¶
我们看看 New
接口的实现。已经掌握了完美转发和变参模板的同学,可以很容易看懂,这里就是做了一个完美转发,
并且支持任意个数、任意类型参数的转发。这使得 OneFlow
的单例非常灵活,可以用 New
接口统一地创建各种各样的类。
c++
template<typename... Args>
static void New(Args&&... args) {
CHECK(Get() == nullptr); //不为 nullptr 将抛异常
*GetPPtr() = new T(std::forward<Args>(args)...);
}
不过创建好的对象放哪去了呢?这和方法 GetPPtr
有关系。读懂 GetPPtr
的实现,牵涉到二级指针的使用技巧。
c++
static T** GetPPtr() {
static T* ptr = nullptr;
return &ptr;
}
熟悉命名规范的同学知道,其实 GetPPtr
的名字就暗示了它和二级指针有关。我们看 GetPPtr
中,准备了一个静态变量,它是一个指针。但是这个函数并不是直接返回这个静态变量。而是返回这个变量的地址。因此这个函数的返回类型是 二级指针。
而实例化对象的过程,就需要先定位到这个静态变量。然后把创建的对象地址给放进去。
c++
*GetPPtr() = new T(std::forward<Args>(args)...);
获取单例时也类似,是先通过 GetPPtr
定位到了那个静态变量。然后通过解引用,得到静态变量中的内容,也就是单例对象的地址。
c++
static T* Get() { return *GetPPtr(); }
单例的同步问题¶
在不少单例模式的示范中,都喜欢在单例设计中,统一处理多线程同步问题。其实这很容易带来性能的损失。实际上,同步问题和单例模式是2个独立的问题,小糖认为最好不要将他们混为一谈。同步问题应该是具体情况具体对待的。
以 OneFlow
为例,所有 Global
单例对象的创建和销毁都在主线程里,并不存在并发,这样就不需要特别的同步操作。在 OneFlow
这种情况下,如果 Global
单例类中的某一个成员存在并发问题,那就单独对那个成员加锁解决就可以了。在通常的编程实践中,锁的粒度要尽可能小。
总结¶
总结起来,OneFlow
这种工业级代码里的单例模式有以下优势:
- 任意一个类都可以通过
Global
模板成为单例 - 可以用统一的
New
接口创建各种类单例、并支持构造重载 - 可以精确地控制单例对象的生命周期和依赖关系
- 把细粒度地解决同步问题的权力交给了开发者