DESIGN PATTERN · CREATIONAL

到底什么是单例模式?
为什么它会有线程安全问题

单例(Singleton)保证「一个类整个进程里只有一个实例」。但一旦进入多线程,那条看似无害的 if (instance == null) instance = new ... 就会出事。本文用图 + 可交互演示,把「为什么会出事、什么情况发生、Go / Python 各怎么解决」一次讲透。

1什么是单例模式

单例是一种创建型设计模式,核心约定只有一条:整个进程(或整个程序运行期)内,某个类最多只能存在一个实例,并且大家都要通过这个唯一的全局访问点来拿到它。

🎯

唯一实例

无论你在代码哪个角落调用,拿到的都是同一个对象。多次 new 也只会产生一个。

🌐

全局访问点

通常叫 getInstance() / GetInstance(),不用到处传引用就能拿到它。

延迟初始化

多数实现「第一次用才创建」(懒汉式),不用就不占资源。

💡 什么时候该用单例? 当「多个实例会出问题或没意义」时:配置中心(Config)、日志器(Logger)、数据库连接池、线程池、设备驱动句柄、缓存管理器等。它们的本质都是「全局只有一份共享状态」。
⚠️ 反过来说 单例会让依赖关系变「隐式」,单元测试时很难替换(mock)。能用「依赖注入」传一个共享对象时,不必为了「全局只有一个」硬上单例。这是后面第 11 节的坑点。

2单例在做什么(懒汉式)

最经典的「懒汉式」单例就两步:检查有没有 → 没有就 new 一个 → 返回。第一次调用时创建,之后调用直接返回已有的。

伪代码
function getInstance(): if instance == null: // ① 检查 instance = new Singleton() // ② 创建 ← 危险窗口 return instance
开始 instance == null? new 实例 (危险窗口) return 实例 否 → 是 ↓
图:懒汉式 getInstance 流程
✅ 单线程没问题 只有一个执行流时,① 和 ② 之间不会被别人打断:第一个调用创建了实例,第二个调用看到 instance 已非空,直接返回。约定成立。问题出在「多线程」。

3线程安全问题:竞态窗口

多线程同时第一次调用 getInstance() 时,上面那句「检查」和「创建」会被 CPU 拆开、交错执行——多个线程都以为自己是「第一个」,于是各自 new 了一个,约定被打破。

竞态条件(Race Condition)一句话

多个线程交叉执行「检查」和「创建」,都通过 == null 判断,结果创建了多个对象。

时间 → 线程A 检查 null? → 是 new Singleton (0x1A) return 0x1A 线程B 检查 null? → 是 new Singleton (0x2B) return 0x2B 两个检查重叠 → 都看到 null
图 1:双线程竞态导致创建两个不同实例(0x1A 与 0x2B)
💥 会带来什么后果? 单例的意义在于「全局共享同一份状态」。一旦创建出多个实例,配置不统一、计数器重复累加、连接池被建出多份耗尽资源、日志写到不同文件……所有依赖「唯一」的逻辑都会悄悄出错,而且这种 bug 在测试环境往往难以复现(取决于调度时机)。

4交互演示:模拟多线程竞态

下面用 JS 模拟多个线程并发调用 getInstance()。先点「不加锁」感受问题,再打开「加锁」看它是如何被解决的。

不加锁 加锁
点击「运行并发演示」开始。每条横线代表一个线程的执行时间线。
🔍 怎么看这个演示
  • 不加锁:所有线程「几乎同时」通过 null 检查,于是各自创建实例 → 出现多个不同地址(红色)。
  • 加锁:线程必须排队进入临界区,第一个创建后,后面的检查都看到「已存在」→ 大家拿到同一个地址(绿色),实例数 = 1。

5为什么「加一句 if」不够

很多人的第一反应是:「那我在检查外面/里面再包一层 if 不就行了?」——不行,因为问题不在「if 写得够不够多」,而在「检查和创建这两步不是原子的」。

if (instance == null) // 步骤 ①:读 instance = new Singleton(); // 步骤 ②:写
① 非原子操作 CPU 可以在 ① 和 ② 之间把线程切走。线程 A 读完「是 null」还没来得及写,线程 B 也被调度进来读,也读到「是 null」——两个线程都通过检查。
② 指令重排 / 内存可见性(更隐蔽) 在 C++/JVM 这类语言里,new 实际分三步:分配内存 → 构造对象 → 把指针指过去。编译器/CPU 可能重排成「先给指针赋值、再构造」。此时别的线程看到指针非空,但对象还没构造完——拿到一个「半成品单例」。这叫双重检查锁定(DCL)的经典坑
🧠 所以正确的「双重检查」必须同时满足三件事
  • 外层无锁检查(实例已存在时走快速路径,不抢锁);
  • 内层加锁后再查一次(避免重复创建);
  • 原子变量 + 正确的内存序(acquire / release)或语言内置保证,确保「看到指针时,对象已完整构造」。
光写两层 if 不加原子/内存屏障,照样是错的。

6饿汉式为什么通常更安全

「饿汉式」不等第一次调用,而是在程序启动 / 模块加载 / 包初始化时就直接把实例创建好;之后 getInstance() 只负责返回,不再执行 new

// 实例在「启动阶段」就存在了,getInstance 里没有任何 new var instance = &Singleton{} // 程序启动时创建一次 func GetInstance() *Singleton { return instance // 纯读取,不可能创建第二个 }
✅ 为什么安全 启动/初始化阶段是单线程的(或由运行时保证只跑一次),不存在「多个线程同时检查」的窗口,自然没有竞态。Go 的包初始化、Python 的模块导入、C++11 的静态局部变量初始化,都属于这一类。
⚠️ 代价 即使用不到也会构造(浪费资源、拖慢启动);如果构造很重或依赖其它模块,还要注意初始化顺序。所以「饿汉式更安全」是有前提的——能接受提前创建。

7解决思路总览

无论哪种语言,思路都是同一棵「决策树」:要么让「检查+创建」变原子(加锁 / 原子变量),要么让创建只发生一次且由运行时保证(once / 静态初始化 / 启动即创建)。

「检查 + 创建」如何不被打断? A. 串行化(加锁) B. 运行时只创建一次 互斥锁 每次都抢锁 (简单正确) 双重检查锁定 原子+锁 (快速路径) sync.Once / call_once (推荐) 启动即创建 饿汉/模块级 (Python 首选)
图 2:单例线程安全解决方案选型

8Go 实现方案

Go 的并发原语很贴心:sync.Once 几乎就是为单例量身定做的。下面从「错」到「对」逐一看。

❌ 反例:非线程安全(懒汉式裸写)

package singleton type Singleton struct { Data string } var instance *Singleton func GetInstance() *Singleton { if instance == nil { // ① 检查 time.Sleep(time.Millisecond) // 模拟「窗口」,让出调度 instance = &Singleton{} // ② 创建 ← ①②之间可被切换 } return instance } // 多个 goroutine 同时首次调用 → 可能创建多个实例
为什么错 if 与赋值之间不是原子的。两个 goroutine 都读到 nil,都进入分支,各 new 一个——instance 最终只保留最后一个,但前面那些对象已经分配出去了(如果你在窗口里就把地址返回了,就会拿到不同实例)。

✅ 方案一:互斥锁(简单正确)

var ( instance *Singleton mu sync.Mutex ) func GetInstance() *Singleton { mu.Lock() defer mu.Unlock() if instance == nil { instance = &Singleton{} } return instance }
评价每次调用都抢锁,绝对正确,但即使实例早已存在也要排队拿锁,性能有开销。适合并发不高、或不在乎这点开销的场景。

✅✅ 方案二:sync.Once(Go 首选)

var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance }
🏆 为什么是首选 sync.Once.Do(f) 保证 f 在整个程序生命周期里只执行一次,即使有 100 个 goroutine 同时调用。它内部已经用原子变量 + 互斥锁正确处理了内存可见性,你完全不用操心 DCL 的坑。写起来最短、最不容易写错。

✅ 方案三:双重检查锁定 + atomic

import ("sync" "sync/atomic" "unsafe") var ( instance unsafe.Pointer // 实际是 *Singleton mu sync.Mutex ) func GetInstance() *Singleton { if p := (*Singleton)(atomic.LoadPointer(&instance)); p != nil { return p // 快速路径:已存在,无锁直接返回 } mu.Lock() defer mu.Unlock() if p := (*Singleton)(atomic.LoadPointer(&instance)); p != nil { return p // 二次确认(加锁后) } newInst := &Singleton{} atomic.StorePointer(&instance, unsafe.Pointer(newInst)) // release 语义 return newInst }
评价Go 的内存模型下,atomic.StorePointer 带 release 语义、LoadPointer 带 acquire 语义,保证「别的 goroutine 看到指针时,对象已完整构造」。性能最好(实例存在时零锁),但代码最啰嗦,一般不如直接用 sync.Once

✅ 方案四:包级变量 / init(饿汉式)

var instance = &Singleton{} // 包初始化时创建,单线程,发生在 main 之前 // 或显式: // func init() { instance = &Singleton{} } func GetInstance() *Singleton { return instance }
评价Go 的包初始化由运行时保证只执行一次、且在 main 之前完成,天然线程安全。缺点是「饿汉」:用不到也创建;且若依赖别的包的 init 顺序要注意。

9Python 实现方案

Python 最常见的坑是:「有 GIL 就等于线程安全」是个误解。GIL 只保证「单条字节码」原子,但 if _instance is None 和赋值之间有两条字节码,而且 time.sleep / I/O 会释放 GIL——线程照样能在中间切进来。

🔥 GIL 误区澄清 即使没有 sleep,CPython 默认每 5ms(sys.setswitchinterval)也会强制切换线程。所以「两个线程都读到 None 再都赋值」完全可能发生。GIL ≠ 单例自动安全。

❌ 反例:非线程安全

import threading, time class UnsafeSingleton: _instance = None def __new__(cls): if cls._instance is None: # ① 检查 time.sleep(0.001) # 释放 GIL → 其他线程可切入 cls._instance = super().__new__(cls) # ② 创建 return cls._instance # 16 个线程并发 → len({id(x) for x in results}) 可能 > 1

✅ 方案一:加锁的 __new__

class LockedSingleton: _instance = None _lock = threading.Lock() def __new__(cls): with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self): if getattr(self, "_inited", False): return self.value = 0 self._inited = True # 防止 __init__ 被多次调用
⚠️ Python 特有坑:__new__ 保证唯一,但 __init__ 不保证只跑一次 加锁保证了只创建一个对象,但每次 SomeClass() 都会对同一个对象再调一次 __init__。如果 __init__ 里有副作用(连数据库、累加计数),要用 _inited 标志挡掉重复初始化。

✅ 方案二:双重检查锁定

class DclSingleton: _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
评价实例存在时走外层无锁快速路径,性能比「每次加锁」好。注意内层锁是必须的——光有两层 if 没有锁,在 Python 里仍然不是原子的。

✅ 方案三:装饰器

def singleton(cls): instances, lock = {}, threading.Lock() def get_instance(*args, **kwargs): with lock: if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance @singleton class Config: def __init__(self): self.debug = True
评价用字典按类缓存实例,加锁保证只创建一次。装饰器写法优雅,适合给多个类统一加单例能力。

✅✅ 方案四:模块级单例(Pythonic 首选)

# config_singleton.py class _Config: def __init__(self): self.host = "127.0.0.1" self.port = 8080 config = _Config() # import 时创建一次;Python 模块导入有锁,天然只初始化一次 # 使用 from config_singleton import config print(config.host)
🏆 为什么是 Python 首选 Python 的模块是单例的:import 同一个模块只会执行一次(导入过程由导入锁保护)。把实例放在模块顶层,根本不用手写任何锁或 __new__,最 Pythonic,也最不容易出错。多数 Python 项目的「全局配置/连接」都该这么写。

10Go vs Python 对照

维度GoPython
并发模型goroutine + channel / sync 包threading(受 GIL 限制)
首选方案sync.Once模块级对象(import 即单例)
「每次加锁」方案sync.Mutex 包裹 ifthreading.Lock() 包裹 __new__
双重检查atomic + 正确内存序两层 if + 锁即可(无指令重排坑)
饿汉式包级变量 / init()模块顶层实例
常见误区裸写 if+new 非原子以为「有 GIL 就安全」
特殊坑__init__ 可能被多次调用
🧩 一个关键差异Python 没有 C++/JVM 那种「指令重排导致半成品对象」的问题(CPython 的 GIL + 引用计数让对象构造是顺序可见的),所以 Python 的 DCL 比 C++ 简单;但 Go 由于有真正并行,必须用 atomicsync.Once 来保证可见性。两种语言都不要裸写「if nil 就 new」。

11实战选型与常见坑

选型建议

🐹

Go 默认

sync.Once。代码最短、语义最清晰、不易写错。

🐍

Python 默认

模块级对象。无需锁、无需 __new__,import 即单例。

极致性能(Go)

双重检查 + atomic,实例存在时零锁。但别为了这点性能牺牲可读性。

🍱

构造很重且启动可接受

直接用饿汉式(包级变量 / 模块级),彻底消灭窗口。

两个容易忽略的坑

坑 1:实例唯一 ≠ 内部数据线程安全 单例只保证「只有一个对象」。多个线程同时读写这个对象的成员(比如 counter++、往 list 里 append),仍然要自己加锁或用并发安全容器。单例解决的是「创建」,不是「访问」。
坑 2:单例让依赖变隐式 任何地方都能 GetInstance(),导致代码隐式依赖全局状态,单元测试时难以替换(mock)。当你只是想要「全局共享一份」时,可以考虑依赖注入:把同一个实例通过参数/构造函数传进去,效果一样,但可测性更好。

12总结

📌 一句话记住 单例的线程安全问题,本质是「判断为空」和「创建实例」之间存在一个可被切走的窗口;多线程同时穿过这个窗口,就会创建多个对象。
🐹

Go 怎么解决

sync.Once(推荐)· 互斥锁 · 双重检查 + atomic · 包级变量(init)

🐍

Python 怎么解决

模块级对象(推荐)· 锁 + __new__(防 __init__ 重复)· 装饰器 · 双重检查

🔑 三件必须做对的事
  • 原子性:检查+创建要么整体加锁,要么用语言机制保证只执行一次;
  • 内存可见性:别让别的线程看到「半成品」(用 atomic / once / 运行时保证);
  • 只初始化一次:DCL 一定要内层二次检查 + 锁,别裸写两层 if。

想看更多设计模式的可视化演示,可回到 设计模式总览