Go 并发编程:第26课 - Once与原子操作
学习目标
- 掌握
sync.Once的使用:理解其作用与实现原理,确保关键初始化代码只执行一次。 - 理解原子操作:学会使用
sync/atomic包进行高效的无锁并发数据操作。 - 认识
sync.Map:了解其设计目标与适用场景,并能与普通map区分使用。 - 实践并发安全模式:能够综合运用这些工具,编写简洁、高效且安全的并发代码。
核心概念
在上一课中,我们学习了使用 WaitGroup 来协调多个 goroutine 的执行。今天,我们将深入两个更底层的并发控制工具:单次执行 和 原子操作。
1. sync.Once:只执行一次
想象一个场景:你的程序中有多个 goroutine 都需要访问同一个昂贵的、只应初始化一次的资源(如一个全局的数据库连接池、一个配置文件或一个复杂的缓存结构)。如果每个 goroutine 都去尝试初始化,不仅浪费资源,还可能引发数据竞争。
sync.Once 就是为解决这个问题而生的。它保证在程序的生命周期内,你传入的函数只会被执行一次,无论有多少个 goroutine 同时调用它的 Do 方法。
var once sync.Once
var config *AppConfig // 假设这是一个需要加载的配置结构体
func loadConfig() {
// 模拟耗时的配置加载操作
fmt.Println("正在加载配置文件...")
time.Sleep(2 * time.Second)
config = &AppConfig{ /* ... */ }
}
func GetConfig() *AppConfig {
once.Do(loadConfig) // 无论调用多少次 GetConfig,loadConfig 只会执行一次
return config
}
2. 原子操作:无锁的并发安全
对于简单的数值类型(如 int32, int64, uint32, uint64)的读写,使用互斥锁 (sync.Mutex) 有时显得“重量级”。原子操作 是一种更轻量级、更高性能的并发安全机制。
它直接利用 CPU 指令,确保对变量的“读取-修改-写入”这一系列动作是不可分割的。sync/atomic 包提供了 Add, Load, Store, CompareAndSwap 等原子函数。
var counter int64
func increment() {
// 原子地将 counter 的值加 1,等价于 counter++ 但并发安全
atomic.AddInt64(&counter, 1)
}
func getCount() int64 {
// 原子地读取 counter 的值
return atomic.LoadInt64(&counter)
}
3. sync.Map:并发安全的映射
标准 map 在并发环境下读写是不安全的。虽然你可以用 sync.Mutex 包装它,但在读多写少的场景下,锁会导致性能瓶颈。Go 1.9 引入了 sync.Map,它专为以下两种常见并发场景优化:
- 当一个键(key)只写入一次,但会被多个 goroutine 读取多次时。
- 当多个 goroutine 读写不相交的键集合时。
var cache sync.Map
func main() {
// 存储键值对
cache.Store("key1", "value1")
cache.Store("key2", 42)
// 读取值
if v, ok := cache.Load("key1"); ok {
fmt.Println(v) // 输出: value1
}
// LoadOrStore: 如果键存在则返回旧值,否则存储并返回新值
actual, loaded := cache.LoadOrStore("key3", "default")
fmt.Println(actual, loaded) // 输出: default false (因为key3是新键)
}
代码示例
示例1:使用 sync.Once 实现单例初始化
package main
import (
"fmt"
"sync"
)
// Database 模拟一个数据库连接池
type Database struct {
ConnectionString string
}
// 实现单例模式
var dbInstance *Database
var dbOnce sync.Once
func GetDatabase() *Database {
dbOnce.Do(func() {
fmt.Println("初始化数据库连接池(只执行一次)...")
dbInstance = &Database{ConnectionString: "user:pass@tcp(localhost:3306)/db"}
})
return dbInstance
}
func main() {
var wg sync.WaitGroup
// 启动10个goroutine来获取数据库实例
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
db := GetDatabase()
fmt.Printf("Goroutine %d 获取到数据库实例: %v\n", id, db)
}(i)
}
wg.Wait()
}
// 运行输出:
// 初始化数据库连接池(只执行一次)...
// Goroutine X 获取到数据库实例: ... (多个goroutine输出相同实例)
示例2:使用原子操作实现并发安全的计数器
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var count int64
var wg sync.WaitGroup
// 启动1000个goroutine,每个对count加1
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&count, 1)
}()
}
wg.Wait()
fmt.Printf("最终计数值: %d\n", atomic.LoadInt64(&count)) // 输出: 1000
}
实践练习
-
基础题(单例模式):
- 创建一个
Logger结构体,它有一个Write(message string)方法。 - 使用
sync.Once确保在整个程序中,Logger的实例只被创建一次。 - 在多个 goroutine 中并发调用获取
Logger的方法并写入日志,验证初始化只发生一次。
- 创建一个
-
进阶题(无锁计数器):
- 不使用
sync.Mutex,仅使用sync/atomic包中的函数,实现一个可以并发读写的AtomicCounter结构体。 - 它需要提供
Increment(),Decrement(),Get() int64方法。 - 编写测试,使用多个 goroutine 并发调用这些方法,验证结果的正确性。
- 不使用
-
综合题(配置管理器):
- 结合
sync.Once和sync.Map设计一个简单的配置管理器。 - 配置数据应通过一个
sync.Once保证的函数从某个数据源(可以模拟)加载一次。 - 加载后的配置应存储在一个
sync.Map中,支持并发地根据键查询配置值。 - 编写并发测试代码进行验证。
- 结合
常见错误
-
复制
Once:var o1 sync.Once o2 := o1 // 错误!Once 包含状态,不能复制。应该通过指针传递。 o2.Do(...) // 行为未定义修正:始终使用指针传递
sync.Once,或将其放在结构体中作为字段。 -
在
Do的函数中 panic 或阻塞:Do会等待传入的函数执行完毕(即使它 panic 或永久阻塞)。如果函数 panic,后续调用Do的 goroutine 可能会得到错误的状态。确保传入的函数是健壮的。 -
误用原子操作:
- 原子操作只能用于特定的整数类型(
int32,int64等)。不能用于float64、string或复杂结构体。对于复杂操作,仍需使用锁。 - 尝试对一个非原子变量进行原子读取:
atomic.LoadInt64(&myNonAtomicVar),这会导致编译错误。必须确保变量本身是原子安全的类型。
- 原子操作只能用于特定的整数类型(
-
滥用
sync.Map:sync.Map并非万能。在以下情况,使用map配合sync.RWMutex可能更简单高效:- 键值对需要频繁更新。
- 需要遍历所有键值对(
sync.Map的Range方法效率不高)。 - 键的集合相对稳定,读写分布均匀。
小结
sync.Once是实现单次初始化模式的理想工具,它简单、安全,能有效防止资源的重复创建和竞态条件。- 原子操作 (
sync/atomic) 为简单的数值类型提供了高性能、无锁的并发安全方案,适用于计数器、状态标志等场景。 sync.Map是专门为特定并发模式(读多写少、键不冲突)优化的并发安全映射,在适用场景下性能优于带锁的普通map。- 选择正确的工具取决于具体的并发模式。理解
Once、原子操作和sync.Map的适用场景,能帮助你编写出既高效又清晰的并发代码。在下一课中,我们将学习如何使用Context来管理 goroutine 的生命周期和传播取消信号。