Appearance
单例模式
单例模式(Singleton)大概是 23 种设计模式里知名度最高的一个。它的意图非常简单:保证一个类只有一个实例,并提供一个全局访问点。听起来简单,但在并发环境下要写对一个单例并不容易。本章我们从 Java 的经典实现讲起,对比 Go 的几种实现方式,重点说明为什么 sync.Once 是 Go 中的最佳实践。
一、意图与适用场景
1. 意图
保证一个类型在整个程序生命周期中只有一个实例,并提供全局访问点。
2. 适用场景
- 全局配置管理器:整个程序共享一份配置。
- 数据库连接池:复用连接资源。
- 日志器:避免每个模块各开一个文件。
- 全局缓存、限流器、ID 生成器等。
3. 谨慎使用
单例是出了名的「必要之恶」。它引入了全局可变状态,让代码难以测试、难以并行。Go 社区普遍建议:能用依赖注入就不用单例。本章末尾会专门讨论单例的测试问题。
二、Java 实现回顾 vs Go 实现差异
1. Java 的经典写法
Java 里单例有多种写法,最经典的是「双重检查锁定(DCL)」:
java
// Java 伪代码,仅作对比
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}Java 单例的几个关键点:
- 构造器私有化,禁止外部
new。 - 用
volatile防止指令重排序导致「半初始化」对象泄漏。 - 双重检查避免每次获取都加锁。
2. Go 的差异
Go 没有「构造器」概念,无法把构造私有化(首字母大小写控制可见性,但同包内都能访问)。Go 的单例靠约定 + 标准库原语实现:
- 用
NewXxx工厂函数封装创建逻辑。 - 用包级变量的可见性(小写)控制外部访问。
- 用
sync.Once或init()保证只创建一次。
也就是说,Go 的单例更多是「包级单例」:同一个包内只能有一份,跨包通过导出的访问函数拿到它。这是 Go 的惯例,不是语言强制。
三、Go 实现:sync.Once(最佳实践)
sync.Once 是 Go 标准库提供的、专门用来做「只执行一次」的原语,它是 Go 实现单例的首选方式。
1. 基本用法
go
package main
import (
"fmt"
"sync"
)
type Config struct {
Env string
}
var (
config *Config
configOnce sync.Once
)
// GetConfig 返回全局唯一的 Config 实例
func GetConfig() *Config {
configOnce.Do(func() {
fmt.Println("初始化 Config(只会执行一次)")
config = &Config{Env: "production"}
})
return config
}
func main() {
for i := 0; i < 3; i++ {
c := GetConfig()
fmt.Printf("第 %d 次获取: %p, Env=%s\n", i+1, c, c.Env)
}
}运行输出:
初始化 Config(只会执行一次)
第 1 次获取: 0xc0000141a0, Env=production
第 2 次获取: 0xc0000141a0, Env=production
第 3 次获取: 0xc0000141a0, Env=production注意地址完全相同,说明三次拿到的都是同一个实例。
2. 为什么 sync.Once 是最佳实践
sync.Once 相比手写双重检查有几个优势:
- 正确性由标准库保证:
Once内部用了原子操作和互斥锁,处理了内存可见性、指令重排等问题,你不需要操心。 - 代码简洁:一个
Do(func(){...})就够了,不用写 if + lock + if。 - 延迟初始化:第一次调用才创建,符合懒加载语义。
- 可复用:
Once是一个通用原语,不只用于单例,任何「只执行一次」的场景都适用。
3. 并发安全验证
下面这个例子用 100 个 goroutine 同时获取单例,验证 sync.Once 的并发安全性:
go
package main
import (
"fmt"
"sync"
)
type DB struct {
Name string
}
var (
db *DB
once sync.Once
create int32
)
func GetDB() *DB {
once.Do(func() {
create++
db = &DB{Name: "primary"}
})
return db
}
func main() {
var wg sync.WaitGroup
instances := make([]*DB, 100)
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
instances[i] = GetDB()
}(i)
}
wg.Wait()
first := instances[0]
allSame := true
for _, inst := range instances[1:] {
if inst != first {
allSame = false
break
}
}
fmt.Printf("创建次数: %d\n", create)
fmt.Printf("100 个 goroutine 拿到的实例全相同: %v\n", allSame)
fmt.Printf("实例地址: %p\n", first)
}输出会显示创建次数为 1,所有 goroutine 拿到同一个实例。sync.Once.Do 内部保证:即使 100 个 goroutine 同时调用,构造函数也只会执行一次,所有调用都会阻塞直到构造完成。
四、Go 实现:init() 函数
init() 是 Go 的特殊函数,每个文件可以有一个或多个 init,它们在 main 之前、包级变量初始化之后自动执行。
1. 用 init 实现饿汉式单例
go
package main
import "fmt"
type Logger struct{}
var logger *Logger
func init() {
fmt.Println("init 中创建 Logger")
logger = &Logger{}
}
func GetLogger() *Logger {
return logger
}
func (l *Logger) Info(msg string) {
fmt.Println("[INFO]", msg)
}
func main() {
GetLogger().Info("hello")
GetLogger().Info("world")
}2. init 的执行时机
Go 程序的初始化顺序是:
- 导入的包按依赖顺序初始化(被依赖的先初始化)。
- 每个包内:包级变量按声明顺序求值,变量间有依赖则按依赖顺序。
- 每个文件按编译器决定的顺序执行
init()。 - 最后执行
main包的main函数。
这意味着 init 创建的单例是「饿汉式」——程序启动就准备好,无论你用不用。
3. init 的局限
- 无法懒加载:哪怕你这次运行根本不需要这个单例,它也会被创建,拖慢启动、占用内存。
- 无法传参:
init没有参数,不能根据运行时配置创建不同实例。 - 错误处理困难:
init不能返回 error,只能 panic,而启动期 panic 极难调试。 - 顺序隐晦:多个
init的执行顺序不直观,容易出 bug。
因此社区共识是:init 只用于注册逻辑(如驱动注册、协议注册),不要用于构造单例。如果一定要用,确保构造逻辑足够简单、不会失败。
五、Go 实现:包级变量
最朴素的「饿汉式」单例就是直接初始化一个包级变量:
go
package main
import "fmt"
type Counter struct {
value int
}
// 全局唯一的 Counter,包加载时即创建
var GlobalCounter = &Counter{}
func (c *Counter) Inc() {
c.value++
}
func (c *Counter) Value() int {
return c.value
}
func main() {
GlobalCounter.Inc()
GlobalCounter.Inc()
fmt.Println("count:", GlobalCounter.Value())
}注意 GlobalCounter 大写开头,意味着任何导入该包的代码都能直接访问它。这种写法简单粗暴,但有两个缺点:
- 暴露了变量本身,外部可以
GlobalCounter = nil把它干掉。 - 不能做懒加载。
更稳妥的做法是小写变量 + 导出访问函数:
go
package main
import "fmt"
type Counter struct {
value int
}
var globalCounter = &Counter{}
func CounterInstance() *Counter {
return globalCounter
}
func (c *Counter) Inc() {
c.value++
}
func (c *Counter) Value() int {
return c.value
}
func main() {
CounterInstance().Inc()
fmt.Println("count:", CounterInstance().Value())
}外部只能通过 CounterInstance() 拿到指针,无法替换变量本身。
六、懒加载 vs 饿汉式
| 维度 | 懒加载(sync.Once) | 饿汉式(init / 包级变量) |
|---|---|---|
| 创建时机 | 第一次使用时 | 程序启动时 |
| 启动开销 | 0 | 创建耗时 |
| 首次访问开销 | 较高(需创建) | 0 |
| 并发安全 | 自动保证 | 自动保证(启动期单线程) |
| 适用场景 | 创建昂贵、可能不用 | 创建廉价、一定用到 |
经验法则:
- 配置、连接池这种「一定用且创建昂贵」的,用
sync.Once懒加载。 - 简单无状态的工具对象,用包级变量即可。
- 涉及注册逻辑的(如数据库驱动),用
init。
七、线程安全分析
单例的线程安全要分两个阶段看:
1. 创建阶段
sync.Once 内部用 atomic + Mutex 保证:只有一个 goroutine 执行 Do 的函数,其他 goroutine 阻塞等待。这是创建阶段的安全保证。
手写双重检查在 Go 里也能写,但不推荐:
go
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Singleton struct{ Name string }
var (
instance *Singleton
initialized uint32
mu sync.Mutex
)
// 不推荐:手写双重检查,容易出错
func GetInstance() *Singleton {
if atomic.LoadUint32(&initialized) == 1 {
return instance
}
mu.Lock()
defer mu.Unlock()
if initialized == 0 {
instance = &Singleton{Name: "I am unique"}
atomic.StoreUint32(&initialized, 1)
}
return instance
}
func main() {
var wg sync.WaitGroup
results := make([]*Singleton, 50)
for i := 0; i < 50; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = GetInstance()
}(i)
}
wg.Wait()
first := results[0]
allSame := true
for _, r := range results[1:] {
if r != first {
allSame = false
}
}
fmt.Printf("全部相同: %v, name=%s\n", allSame, first.Name)
}这段代码是「正确」的双重检查,但它比 sync.Once 啰嗦得多,而且一旦写错(比如忘记 atomic.Load)就会出 bug。能用 sync.Once 就别手写。
2. 使用阶段
单例创建好后,它的方法是否线程安全是另一回事。sync.Once 只保证「实例唯一」,不保证「实例可被并发使用」。如果单例内部有可变状态,必须自己加锁:
go
package main
import (
"fmt"
"sync"
)
type SafeCounter struct {
mu sync.Mutex
count int
}
var (
counter *SafeCounter
counterOnce sync.Once
)
func Counter() *SafeCounter {
counterOnce.Do(func() {
counter = &SafeCounter{}
})
return counter
}
func (c *SafeCounter) Add() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
Counter().Add()
}()
}
wg.Wait()
fmt.Println("最终计数:", Counter().Value())
}把「单例唯一」和「单例线程安全」分开理解,是 Go 并发编程的基本素养。
八、单例模式在标准库中的应用
1. sync.Once 自身
最直接的应用就是 sync.Once 本身,它就是为「只执行一次」设计的。
2. database/sql 的驱动注册
database/sql 通过 sql.Register(name, driver) 注册驱动,内部用 sync.Mutex 保证注册表唯一且线程安全。每个驱动的 init() 调用 sql.Register,这就是「饿汉式 + 包级单例」的典型应用:
下面用一个可独立运行的「驱动注册表」模拟 database/sql 的注册机制(真实 sql.Register 需要 driver.Driver 接口,依赖较多,这里用简化版演示原理):
go
package main
import (
"fmt"
"sync"
)
// Driver 是数据库驱动的抽象(对应标准库的 driver.Driver)
type Driver interface {
Open(dsn string) (Conn, error)
}
// Conn 是连接的抽象(对应标准库的 driver.Conn)
type Conn interface {
Close() error
}
// 注册表:全局唯一,用 Mutex 保护
var (
driversMu sync.Mutex
drivers = make(map[string]Driver)
)
// Register 对应 sql.Register,把驱动注册到全局表
func Register(name string, driver Driver) {
driversMu.Lock()
defer driversMu.Unlock()
if _, dup := drivers[name]; dup {
panic("重复注册驱动: " + name)
}
drivers[name] = driver
}
// Open 对应 sql.Open,按名字找到驱动并建立连接
func Open(name, dsn string) (Conn, error) {
driversMu.Lock()
driver, ok := drivers[name]
driversMu.Unlock()
if !ok {
return nil, fmt.Errorf("未知驱动: %s", name)
}
return driver.Open(dsn)
}
// === 一个具体的「假」驱动 ===
type fakeDriver struct{}
func (fakeDriver) Open(dsn string) (Conn, error) {
fmt.Printf("fakeDriver 建立连接: %s\n", dsn)
return &fakeConn{}, nil
}
type fakeConn struct{}
func (fakeConn) Close() error { return nil }
// 真实项目中,驱动的 init() 会调用 Register 自动注册
func init() {
Register("fake", fakeDriver{})
}
func main() {
conn, err := Open("fake", "host=localhost port=5432")
if err != nil {
fmt.Println("打开失败:", err)
return
}
defer conn.Close()
fmt.Println("连接成功(全局注册表 + init 注册的单例模式)")
}说明:标准库
database/sql的真实实现结构完全一致——sql.Register把driver.Driver注册到全局driversmap(包级单例),sql.Open按名字查找并返回*sql.DB。每个数据库驱动包在自己的init()中调用sql.Register完成注册。
3. regexp 的编译缓存
regexp.MustCompile 返回的 *Regexp 是不可变的,可以被多个 goroutine 共享,是一种「只读单例」。
九、实战示例
1. 全局配置管理器
go
package main
import (
"fmt"
"os"
"sync"
)
type AppConfig struct {
AppName string
Port int
Debug bool
}
var (
cfg *AppConfig
once sync.Once
)
func loadConfig() *AppConfig {
once.Do(func() {
// 模拟从环境变量或文件加载
cfg = &AppConfig{
AppName: getEnv("APP_NAME", "myapp"),
Port: 8080,
Debug: os.Getenv("DEBUG") == "true",
}
})
return cfg
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
os.Setenv("APP_NAME", "awesome-service")
os.Setenv("DEBUG", "true")
c := loadConfig()
fmt.Printf("配置: %+v\n", c)
c2 := loadConfig()
fmt.Println("两次获取相同:", c == c2)
}2. 数据库连接池(懒加载 + 线程安全)
go
package main
import (
"fmt"
"sync"
"time"
)
// 模拟的 DB 连接
type Connection struct {
ID int
}
var (
connPool *Connection
poolOnce sync.Once
nextID int
idMu sync.Mutex
)
func GetConnection() *Connection {
poolOnce.Do(func() {
fmt.Println("建立数据库连接...")
nextID++
connPool = &Connection{ID: nextID}
})
return connPool
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
c := GetConnection()
fmt.Printf("worker %d 拿到连接 %d\n", i, c.ID)
}(i)
}
wg.Wait()
// idMu 在这个例子里其实没用到,留作扩展点
_ = idMu
_ = time.Second
}十、单元测试单例的挑战
单例最大的问题之一是难测试。因为全局状态跨测试用例共享,一个用例修改了状态会污染下一个。
1. 问题演示
go
package main
import "fmt"
type State struct {
Value int
}
var globalState = &State{}
func GetState() *State { return globalState }
func main() {
// 测试用例 A 改了状态
GetState().Value = 100
fmt.Println("A:", GetState().Value)
// 测试用例 B 期望初始状态,但被 A 污染了
fmt.Println("B 期望 0,实际:", GetState().Value)
}2. 解决思路
- 依赖注入替代单例:把实例作为参数传进去,测试时传 mock。这是最推荐的做法。
- 提供 Reset 方法:仅用于测试,加
//go:build test之类的约束。但 Go 没有良好的访问控制隔离测试代码,要小心。 - 用接口包装:单例实现一个接口,业务代码依赖接口,测试时传假的实现。
go
package main
import "fmt"
// 业务依赖接口,而不是单例本身
type Counter interface {
Add()
Value() int
}
// 生产实现:单例
type realCounter struct {
count int
}
func (r *realCounter) Add() { r.count++ }
func (r *realCounter) Value() int { return r.count }
var globalCounter = &realCounter{}
func CounterInstance() Counter { return globalCounter }
// 业务函数依赖接口
func doBusiness(c Counter) int {
c.Add()
return c.Value()
}
// 测试时可以传一个假的 Counter
type fakeCounter struct{ count int }
func (f *fakeCounter) Add() { f.count++ }
func (f *fakeCounter) Value() int { return f.count }
func main() {
// 生产代码用单例
fmt.Println("生产:", doBusiness(CounterInstance()))
// 测试代码用 fake
fake := &fakeCounter{}
fmt.Println("测试:", doBusiness(fake))
}这种「面向接口编程 + 单例作为默认实现」的写法,兼顾了全局便利和可测试性,是 Go 项目的常见折中。
十一、小结
- 单例保证「一个类型只有一个实例 + 全局访问点」,适用于配置、连接池、日志器等场景。
- Java 通过私有构造器 + 双重检查实现,Go 没有构造器,靠
sync.Once或init/ 包级变量。 sync.Once是 Go 单例的最佳实践:正确性由标准库保证、懒加载、并发安全、代码简洁。init()适合驱动注册等「启动期必须完成」的逻辑,不适合复杂单例构造(无法懒加载、无法传参、不能返回 error)。- 单例的线程安全分两层:创建阶段由
sync.Once保证,使用阶段要自己加锁保护可变状态。 - 单例最大的代价是「全局可变状态」导致难测试,推荐用「接口 + 依赖注入」来缓解。
下一篇我们讲工厂模式,看看 Go 里 NewXxx 函数如何承载简单工厂、工厂方法、抽象工厂三种形态。