4、单例模式实现(二):懒汉式单例的Python实现、双重检查锁机制
上一节我们聊了饿汉式单例。说白了就是程序一启动,实例就乖乖等着了。但现实开发中,很多时候我们并不希望它这么积极——万一这个对象初始化特别重,而用户可能根本用不上呢?
这时候,懒汉式就派上用场了。它的核心思想就一句话:等到真正需要的时候,再创建实例。
懒汉式的基本实现
先看一个最朴素的版本。我刚开始学设计模式时,第一个写的懒汉式大概长这样:
class SingletonLazy:
_instance = None
def __init__(self):
if SingletonLazy._instance is not None:
raise Exception("哥们,别重复创建了,用 get_instance() 吧")
# 这里放真正的初始化逻辑
self.data = "我是懒汉式单例"
@classmethod
def get_instance(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
嗯,逻辑上没问题。第一次调用 get_instance() 时,检查 _instance 是不是 None,是就创建,不是就直接返回。
但这里有个坑。我在一个高并发项目里用过这个写法,结果线上出了诡异的问题——两个线程同时调 get_instance(),都发现 _instance is None,然后各自创建了一个实例。单例不单了,你说尴尬不尴尬?
加锁解决线程安全
最简单的解决办法,就是给方法加一把锁。Python 里用 threading.Lock 就行:
import threading
class SingletonThreadSafe:
_instance = None
_lock = threading.Lock()
def __init__(self):
if SingletonThreadSafe._instance is not None:
raise Exception("别重复创建了")
self.data = "线程安全的懒汉式"
@classmethod
def get_instance(cls):
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
这样确实安全了。但代价是什么?每次调用 get_instance() 都要拿锁。你想想看,在高并发场景下,成千上万个线程抢同一把锁,性能损耗可不小。
我记得有一次压测,这个加锁版本比不加锁的慢了将近 10 倍。虽然单例创建只有一次,但读操作每次都要等锁释放,太冤了。
双重检查锁机制(Double-Checked Locking)
那有没有办法既保证线程安全,又避免每次调用都加锁?有,就是双重检查锁。
思路很简单:先不加锁检查一次,如果实例已经存在,直接返回。只有发现实例为 None 时,才去拿锁。拿到锁之后,再检查一次——这就是「双重检查」的由来。
import threading
class SingletonDCL:
_instance = None
_lock = threading.Lock()
def __init__(self):
if SingletonDCL._instance is not None:
raise Exception("别重复创建了")
self.data = "双重检查锁单例"
@classmethod
def get_instance(cls):
# 第一次检查:不加锁
if cls._instance is None:
with cls._lock:
# 第二次检查:加锁后
if cls._instance is None:
cls._instance = cls()
return cls._instance
这个模式在 Java 里非常流行。Python 里用起来也顺手。为什么它能提升性能?因为大部分情况下,实例已经创建好了,第一次检查就直接返回了,根本不需要碰锁。
Python 中的特殊注意事项
这里我要多说一句。Python 的 threading.Lock 本身是可靠的,但双重检查锁在 Python 中有一个微妙的问题——指令重排。
在 C++ 或 Java 里,编译器或 CPU 可能会对指令进行重排序,导致一个线程看到 _instance 不为 None,但对象还没完全初始化。Python 的 GIL(全局解释器锁)在一定程度上缓解了这个问题,但并不是完全免疫。
我个人习惯的做法是:如果对性能要求极高,而且确定是 Python 3.2+,可以用 threading.local 或者干脆用模块级别的单例。但如果你就是想用双重检查锁,也没问题——绝大多数场景下,Python 的 GIL 会帮你兜底。
__new__ 方法。但双重检查锁的写法更通用,跨语言迁移时更容易理解。
懒汉式 vs 饿汉式:怎么选?
我遇到过不少团队在选型时纠结这个问题。其实没那么复杂,我列个表你就明白了:
| 对比维度 | 饿汉式 | 懒汉式(双重检查锁) |
|---|---|---|
| 实例创建时机 | 类加载时 | 首次调用时 |
| 线程安全 | 天然安全 | 需要额外处理 |
| 启动速度 | 较慢(要初始化) | 较快(延迟加载) |
| 运行时性能 | 无锁,极快 | 首次加锁,后续无锁 |
| 适用场景 | 实例轻量、一定会用 | 实例重量、可能不用 |
说白了,如果你的单例对象初始化很轻,而且程序启动后几乎一定会用到它,那就用饿汉式,省心。如果对象初始化很重(比如加载配置文件、建立数据库连接池),或者不确定用户会不会用,那就用懒汉式加双重检查锁。
懒汉式单例的核心逻辑图
下面这张图,把整个流程串了一遍。我建议你仔细看看,尤其是「第一次检查」和「第二次检查」的分支逻辑:
从图上你能看到,整个流程有两个关键判断点。第一次判断是为了「快速失败」——实例已经有了就直接走人。第二次判断是为了「防止重复创建」——两个线程都通过了第一次检查,但只有拿到锁的那个才能创建实例。
避坑指南
最后,分享几个我踩过的坑:
- 别在
__init__里做太多事:我曾经把一个数据库连接池的初始化放在__init__里,结果第一次调用时卡了好几秒。建议把重量级初始化放到一个单独的方法里,按需调用。 - 小心序列化破坏单例:如果你的单例需要序列化,记得实现
__reduce__或使用__new__控制。否则反序列化时会创建新实例。 - 不要滥用单例:我见过一个项目,几乎每个类都是单例。最后代码耦合得一塌糊涂,改一个地方牵一发动全身。单例虽好,可别贪杯。
嗯,懒汉式单例和双重检查锁就聊到这儿。这个模式在实际项目中非常实用,尤其是那些「可能用也可能不用」的重型资源。记住核心思路:延迟加载 + 双重检查,既省资源又保安全。
公众号:蓝海资料掘金营,微信deep3321