当我刚进入游戏行业的时候,所编写的代码,使用了不少的单例,但随着后来的很多bug,我开始意识到单例模式的弊端。
1 什么是单例模式
“确保一个类只有一个实例,并为其提供一个全局访问入口。单例模式提供了在编译期就能确保某个类只有一个实例的方法。
单例的定义:
(1)构造函数私有化,防止外界创建出对象;
(2)使用一个唯一的变量指向该类的唯一实例;
(3)提供一个开放接口供外部使用。
2 创建单例的几种方法
(1)Lazy Singleton(懒汉):单例在第一次使用时才会被创建出来。
class Singleton final {
private:
//私有化构造器
Singleton() {};
~Singleton() {};
public:
//对外公开的静态方法
static std::shared_ptr < Singleton> GetInstance()
{
if (instance)
return instance;
else
return (instance = std::make_shared());
}
private:
//内部创建对象实例
static std::shared_ptr instance;
};
传统Lazy Singleton存在内存泄漏的问题,这里使用智能指针,可以避免内存没有释放的bug。除了使用智能指针,也可以使用静态的嵌套类,让该类的实例在析构时,进行Singleton内存释放。
除了内存泄漏的问题,还有多线程不安全的问题。因为Lazy Singleton是使用时才进行创建,当同时有多个线程并发调用GetInstance()函数,就会出现同时判定instance为空,然后进行实例化对象。
如何保证线程安全,首先想到的就是锁,这显然是正确的,我们可以改造上面的GetInstance()函数,得到如下版本:
//对外公开的静态方法
static std::shared_ptr < Singleton> GetInstance()
{
if (nullptr == instance)
{
std::lock_guard lock{ instance_mutex };
if (nullptr == instance)
return (instance = std::make_shared());
}
return instance;
}
使用双检测锁来解决多线程的安全问题,当调用GetInstancce()函数时,会先判断是否存在实例,如果不存在,则需要进行构造(这时再进行锁的申请,可以避免每次获取都要申请锁资源),这时,就算其它的线程同时进行构造,由于无法获取到锁资源而失败,从而获取到已经创建好的实例。
但并不是这样就完事了,修改后的版本还存在着一个问题:内存访问安全。上面我们只是解决了构造时只构造一个的问题,但是访问时,如何保证正确性以及安全性呢?一些优化,会让instance = std::make_shared
使用atomic关键字!
class Singleton final {
private:
//私有化构造器
Singleton() {};
~Singleton() {};
public:
//对外公开的静态方法
static std::shared_ptr < Singleton> GetInstance()
{
if (nullptr == instance)
{
std::lock_guard lock{ instance_mutex };
if (nullptr == instance)
return (instance = std::make_shared());
}
return instance;
}
private:
//内部创建对象实例
static std::atomic< std::shared_ptr> instance;
static std::mutex instance_mutex;
};
不过智能指针的原子性,是C++20才支持的,如果想要在多线程使用,可以将智能指针换成普通指针。
避免指针,使用变量!
C++11对于多线程的局部静态变量的初始化,是要求了线程安全的,描述如下:
such a variable is initialized the first time control passes through its declaration; such a variable is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization. If control re-enters the declaration recursively while the variable is being initialized, the behavior is undefined.
class Singleton final {
private:
//私有化构造器
Singleton() {};
~Singleton() {};
public:
//对外公开的静态方法
static Singleton& GetInstance()
{
static Singleton instance;
return instance;
}
};
(2)Eager Singleton(饿汉):单例在程序运行时就会被立即执行初始化
class Singleton final {
private:
//私有化构造器
Singleton() {};
~Singleton() {};
public:
//对外公开的静态方法
static Singleton& GetInstance()
{
return instance;
}
private:
static Singleton instance;
};
Singleton Singleton::instance
instance会在main函数之前就初始化,所以不存在线程安全的问题。写法简单,使用方便,但是会造成内存浪费:假如程序中从未使用该实例或者很晚很晚才会用到。
3 为何要使用单例
在有些情况下,一个类如果有多个实例就不能正常运转。最常见的就是,这个类与一个维持着自身全局状态的外部系统进行交互的情况。比如一个封装了底层文件API的类,比如游戏服务器中经常各个地方会使用到的日志管理类,再或者一些玩家会话(session)管理类。
单例模式简单而又强大,对于很多游戏开发来说,短期内构建一个强大而完善的服务器系统是关键的。
4 为何避免使用单例
(1)让一个代码逻辑变得复杂。
游戏行业充斥着人员的来来往往,不知道什么时候,你就需要接受你同事的代码,然后在接下来的日子里,对其出现的bug进行修复。那么麻烦开始了。
当你看到一个函数的时候,你不能简单地去理解这个函数是干嘛用的,因为它的内部充斥了一些单例。你要去理解这些单例的用途,以及它们的值,在什么条件下会发生什么变化。不同的值对于这个执行,会发生什么样的问题是一个很复杂的东西。这些都会让一个bug的修复变得难以复现、难以修复,带来大量的时间成本。
甚至你最后都不一定能修好它,如果它发生的概率太低,需要游戏上线后大量玩家涌入才会让触发变得频繁。
看到这里,想必一些人脑海中已经浮现了一个概念:纯函数。
它应始终返回相同的值。不管调用该函数多少次,无论今天、明天还是将来某个时候调用它。 自包含(不使用全局变量)。
它不应修改程序的状态或引起副作用(修改全局变量)。
这样,在修复bug的时候,我们只需要关注该函数本身即可,这可简单了。
(2)全局变量促进了耦合。
如果一个代码中使用了大量的单例,那么也就意味着这份代码的所在的模块,和其它的模块耦合性太高。在开发过程中,低耦合是一个比较关键的原则,程序者应该尽量使得模块与模块之间透明,如果需要使用其它模块的化,使用其它的方式进行调用,比如事件。
(3)对性能不友好。
单例意味着多核下,需要进行各种限制。读取还好,对于一些会改变单例内部数据的,需要加上锁。
5 如何做
(1)非必要不用。
当我们想要使用一个单例时,需要思考我们真的需要一个单例吗?如果不是非要不可,请使用其它的方式。
(2)限制范围。
如果你要创建一个单例,那么尽可能地取限定它的使用范围。例如,你想创建一个游戏单位的管理者,那么可以在游戏单位的基类里定义一个静态变量,通过它来实现单例的功能。同时在外部,别人并不能调用它。
(3)将若干个单例放在一起管理。
我们可以通过将全局对象类包装到现有类里面来减少它们的数量。这样,我们可以只将一个单例全局可见。其余的单例被该单例管理。(当然,这违背了最少知识原则,但我觉得可以接受,并在工作中实际地使用了这种方式)