
前言单例的几种写法各自能跑通但「能跑通」和「在各种情况下都不出问题」是两件事。本文不重复讲每种写法怎么写而是做一件更实用的事把这些写法放在容易翻车的场景里横向对比——多线程同时首次访问、被继承、被序列化与反序列化、模块被重复导入——看看它们在哪些地方行为不一致以及为什么在很多工程里最后的选择往往不是单例而是依赖注入。先破一个最常见的误解「Python 有 GIL所以单例天然线程安全。」这句话不成立。GIL 保证的是「同一时刻只有一个线程在执行字节码」它不等于「一段由多条字节码组成的逻辑是原子的」。单例的创建典型写法是「检查实例是否存在不存在就创建」这是标准的「检查再动作」check-then-act线程完全可能在检查之后、赋值之前被切换出去于是两个线程各造一个实例。所以要不要加锁取决于这段逻辑是不是原子的而不是取决于有没有 GIL。本文讨论的前提是 CPython。顺带说明CPython 3.13 起提供了实验性的自由线程no-GIL构建但它不是默认构建、需要单独安装所以「GIL 会消失」目前还不是既成事实。一、多线程竞态是怎么发生的先看没加锁的版本会怎样。# 适用于 Python 3.8import threadingclass Unsafe:_instance Nonedef __new__(cls):if cls._instance is None: # 线程 A 和 B 可能同时看到 Nonecls._instance super().__new__(cls)return cls._instanceresults []def build():results.append(Unsafe())threads [threading.Thread(targetbuild) for _ in range(8)]for t in threads:t.start()for t in threads:t.join()print(len({id(r) for r in results})) # 理论上可能大于 1这段代码的意图是「只造一个」但只要两个线程都通过了is None的判断就会各造一个对象最后_instance是后写入的那个而先写入的那个已经被某个线程拿走了。多出来的实例从此游离在外跟「唯一」的承诺矛盾。上面注释里说「可能」大于 1是因为是否触发取决于线程调度这类问题不能用「我跑了几次没复现」来判断。修复方式是加锁并且用「双检锁」double-checked locking。# 适用于 Python 3.8import threadingclass SafeSingleton:_instance None_lock threading.Lock()def __new__(cls):if cls._instance is None: # 第一次检查避免每次都抢锁with cls._lock: # 只有一个线程能进来if cls._instance is None: # 第二次检查进来后确认还没被造出来cls._instance super().__new__(cls)return cls._instance两次检查各有分工。外层那次是为了「创建完成之后就不再抢锁」属于性能考虑内层那次才是正确性保证——因为可能有多个线程都在外层通过、排队等锁第一个造完后后面的必须在内层被挡住。只写一次检查的「单检锁」是错的所有线程都会排队但后面的线程拿到锁后仍会重新创建。需要额外说明的是双检锁这套写法在 Java 早期的内存模型里有争议涉及指令重排而 Python 里with lock会建立明确的同步点_instance的读写不会绕过锁的语义。所以在 CPython 里这个写法是自洽的但并非在所有 Python 实现上都得出同样的细节结论跨实现时要按语言规范而非 CPython 内部行为来推理。二、继承谁共享了谁的实例写法子类拿到的实例说明__new__ 类变量通常是父类那个子类不重新定义就继承同一份元类 按类分键的字典每个类各一份_cache[cls]按类区分装饰器无法继承Logger已经是函数不是类模块级单例不涉及继承只是模块里一个对象用__new__方案时_instance挂在父类上子类class Sub(Base)如果不重新定义_instanceSub()会返回父类的实例——因为cls._instance沿属性查找链找到了父类的那一份。有时这正是你想要的整个家族共享一个有时则不是。元类方案里的cls not in cls._instances是按类作为键的父类和子类各占一个槽位逻辑上更符合「每个类一个单例」的直觉。装饰器方案在这一项上直接出局被装饰后的名字指向一个函数class Sub(Logger)根本走不通。三、反序列化绕过单例的后门这是最容易被忽略的一类破坏。pickle在还原一个对象时不会走「调用类」这条路——它不会执行__init__也不经过元类的__call__。对元类方案来说这意味着拦截点在反序列化时被完全绕开还原出来的可能是另一个新对象唯一性就此失效。# 适用于 Python 3.8import pickleclass CachedType(type):_cache {}def __call__(cls, *args, **kwargs):found cls._cache.get(cls)if found is None:found super().__call__(*args, **kwargs)cls._cache[cls] foundreturn foundclass Settings(metaclassCachedType):def __init__(self, themelight):self.theme themes1 Settings(dark)blob pickle.dumps(s1)s2 pickle.loads(blob)print(s1 is s2) # 通常为 False反序列化绕过了 __call__ 这个拦截点要让反序列化也返回既有实例得显式实现__reduce__让 pickle 用一个「返回单例的函数」来还原# 适用于 Python 3.8import pickleclass Settings(metaclassCachedType):def __init__(self, themelight):self.theme themedef __reduce__(self):# 还原时不再新建对象而是返回既有的单例return (Settings, ())s1 Settings(dark)s2 pickle.loads(pickle.dumps(s1))print(s1 is s2) # True注意__reduce__返回的第二个元素是构造参数元组这里传空元组配合单例语义刚好。这个方法会覆盖状态的还原过程也就是说s2不会带回s1当时的theme值而是沿用当前单例的状态——用之前要想清楚这是不是你要的语义。四、多次导入一个小心就会造出两个模块级单例的前提是「同一模块在同一进程内只执行一次」。这个前提在两种情况下会被打破。第一种是用不同的模块名导入同一个文件。比如项目里同时存在import config和from mypackage import configPython 会把它们当成两个不同的模块对象各自执行一次顶层代码于是得到两个「单例」。解决办法是统一导入路径保证全项目只有一种写法并让包结构清晰。第二种是手动重新加载。importlib.reload(module)会重新执行模块的顶层代码模块级的那个对象会被重新创建。此时任何之前已经持有旧对象引用的代码仍然指向旧对象而新代码拿到的是新对象——同一个「单例」在内存里变成了两份。# 适用于 Python 3.8import importlibimport mymoduleold mymodule.INSTANCEimportlib.reload(mymodule)new mymodule.INSTANCEprint(old is new) # False重新加载后是两个不同的对象所以「重新加载」和单例是天然冲突的单例的价值建立在「一份且不变」上而 reload 的语义就是「重做一遍」。五、为什么依赖注入常是更好的选择把上面几节连起来看会发现单例的每一个附加要求——加锁、处理继承、实现__reduce__、防止重复导入——都是在为「全局可变状态」这个设计买单。而依赖注入换了个思路不控制实例的个数控制实例的传递方式。# 适用于 Python 3.8class Settings:def __init__(self, themelight, timeout5):self.theme themeself.timeout timeoutclass ReportService:def __init__(self, settings):self.settings settings # 依赖显式出现在构造签名里def describe(self):return ftheme{self.settings.theme}, timeout{self.settings.timeout}# 生产代码只建一份传进去shared Settings(dark, 30)service ReportService(shared)print(service.describe()) # themedark, timeout30# 测试代码换成一份独立的、定制的配置互不污染test_service ReportService(Settings(light, 1))print(test_service.describe()) # themelight, timeout1这样做的收益是明确的依赖在签名里可见测试可以传入任意定制对象用例之间天然隔离生命周期由创建方决定想共享就共享、想每次新建就新建不需要改动被依赖的类。唯一性从「类强制」变成了「创建方决定」灵活性高得多。常见坑点认为有 GIL 就不用加锁。❌ 在__new__里直接写「检查为None就创建」指望 GIL 保证原子性。✅ GIL 只保证单条字节码的原子性「检查再动作」由多条字节码组成必须用threading.Lock保护。双检锁只写一次检查。❌ 只有进入锁之前的那次is None判断锁内不再检查——多个排队线程会逐个重新创建。✅ 锁外检查一次省开销锁内必须再检查一次保证正确性。以为反序列化也会走单例的拦截。❌ 类用元类实现了单例就以为pickle.loads拿回来的还是同一个对象。✅ pickle 不走「调用类」这条路需要显式实现__reduce__让它返回既有实例。手动 reload 模块后期望单例不变。❌importlib.reload(mymodule)之后旧引用和新引用指向两个不同对象状态不同步。✅ 单例与热重载天然冲突要么不 reload要么在 reload 后重新获取所有引用。同一模块用两种名字导入。❌ 项目里混用import config和from mypackage import config出现两个模块对象、两个单例。✅ 统一全项目的导入路径靠规范的包结构避免同名多路径。只给创建过程加了锁别的路径却绕过它。❌ 加锁的__new__之外代码里又直接调object.__new__(Singleton)或用了copy.copy造出额外实例。✅ 所有实例来源都要收敛到同一条路径上必要时限制类的公开构造方式。把「单例线程安全」等同于「状态线程安全」。❌ 单例创建加了锁就认为内部那个共享字典的并发读写也安全了。✅ 单例只保证唯一不保证内部状态的并发安全共享可变状态仍需各自的同步措施。认为自由线程构建意味着 GIL 已经消失。❌ 以「Python 已经没有 GIL」为前提重写并发方案。✅ CPython 3.13 起提供的是实验性自由线程构建非默认、需单独安装默认构建仍带 GIL。总结场景会出什么问题应对多线程首次创建可能造出多个实例双检锁 threading.Lock被继承子类可能共享父类实例按类分键或每类各自定义被 pickle绕过拦截唯一性失效实现__reduce__返回既有实例模块被 reload旧引用与新引用不是同一对象避免热重载或重载后重取引用同模块多名字导入出现两个模块对象统一导入路径长期演进全局可变状态越滚越大改用依赖注入把这几条按「会不会踩到」排个序多线程和反序列化是正确性问题必须处理继承和重复导入是设计问题取决于你怎么用而依赖注入是方向问题——它不解决单例的某个具体缺陷而是让那些缺陷根本没有出现的场景。选单例还是选依赖注入本质上是在问你需要的到底是「强制唯一」还是「只创建一份然后传下去」