Skip to content

单例模式

单例模式(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.Onceinit() 保证只创建一次。

也就是说,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 程序的初始化顺序是:

  1. 导入的包按依赖顺序初始化(被依赖的先初始化)。
  2. 每个包内:包级变量按声明顺序求值,变量间有依赖则按依赖顺序。
  3. 每个文件按编译器决定的顺序执行 init()
  4. 最后执行 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.Registerdriver.Driver 注册到全局 drivers map(包级单例),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.Onceinit / 包级变量。
  • sync.Once 是 Go 单例的最佳实践:正确性由标准库保证、懒加载、并发安全、代码简洁。
  • init() 适合驱动注册等「启动期必须完成」的逻辑,不适合复杂单例构造(无法懒加载、无法传参、不能返回 error)。
  • 单例的线程安全分两层:创建阶段由 sync.Once 保证,使用阶段要自己加锁保护可变状态。
  • 单例最大的代价是「全局可变状态」导致难测试,推荐用「接口 + 依赖注入」来缓解。

下一篇我们讲工厂模式,看看 Go 里 NewXxx 函数如何承载简单工厂、工厂方法、抽象工厂三种形态。