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:单例线程安全解决方案选型
8 Go 实现方案
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 顺序要注意。
9 Python 实现方案
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 项目的「全局配置/连接」都该这么写。
10 Go vs Python 对照
维度 Go Python
并发模型 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 由于有真正并行,必须用 atomic 或 sync.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。
想看更多设计模式的可视化演示,可回到 设计模式总览 。