26·并发编程进阶

Once 与原子操作

onceatomicsync-map

Go 并发编程:第26课 - Once与原子操作

学习目标

  1. 掌握 sync.Once 的使用:理解其作用与实现原理,确保关键初始化代码只执行一次。
  2. 理解原子操作:学会使用 sync/atomic 包进行高效的无锁并发数据操作。
  3. 认识 sync.Map:了解其设计目标与适用场景,并能与普通 map 区分使用。
  4. 实践并发安全模式:能够综合运用这些工具,编写简洁、高效且安全的并发代码。

核心概念

在上一课中,我们学习了使用 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
}

实践练习

  1. 基础题(单例模式)

    • 创建一个 Logger 结构体,它有一个 Write(message string) 方法。
    • 使用 sync.Once 确保在整个程序中,Logger 的实例只被创建一次。
    • 在多个 goroutine 中并发调用获取 Logger 的方法并写入日志,验证初始化只发生一次。
  2. 进阶题(无锁计数器)

    • 不使用 sync.Mutex,仅使用 sync/atomic 包中的函数,实现一个可以并发读写的 AtomicCounter 结构体。
    • 它需要提供 Increment(), Decrement(), Get() int64 方法。
    • 编写测试,使用多个 goroutine 并发调用这些方法,验证结果的正确性。
  3. 综合题(配置管理器)

    • 结合 sync.Oncesync.Map 设计一个简单的配置管理器。
    • 配置数据应通过一个 sync.Once 保证的函数从某个数据源(可以模拟)加载一次。
    • 加载后的配置应存储在一个 sync.Map 中,支持并发地根据键查询配置值。
    • 编写并发测试代码进行验证。

常见错误

  1. 复制 Once

    var o1 sync.Once
    o2 := o1 // 错误!Once 包含状态,不能复制。应该通过指针传递。
    o2.Do(...) // 行为未定义
    

    修正:始终使用指针传递 sync.Once,或将其放在结构体中作为字段。

  2. Do 的函数中 panic 或阻塞Do 会等待传入的函数执行完毕(即使它 panic 或永久阻塞)。如果函数 panic,后续调用 Do 的 goroutine 可能会得到错误的状态。确保传入的函数是健壮的。

  3. 误用原子操作

    • 原子操作只能用于特定的整数类型(int32, int64等)。不能用于 float64string 或复杂结构体。对于复杂操作,仍需使用锁。
    • 尝试对一个非原子变量进行原子读取:atomic.LoadInt64(&myNonAtomicVar),这会导致编译错误。必须确保变量本身是原子安全的类型。
  4. 滥用 sync.Mapsync.Map 并非万能。在以下情况,使用 map 配合 sync.RWMutex 可能更简单高效:

    • 键值对需要频繁更新。
    • 需要遍历所有键值对(sync.MapRange 方法效率不高)。
    • 键的集合相对稳定,读写分布均匀。

小结

  • sync.Once 是实现单次初始化模式的理想工具,它简单、安全,能有效防止资源的重复创建和竞态条件。
  • 原子操作 (sync/atomic) 为简单的数值类型提供了高性能、无锁的并发安全方案,适用于计数器、状态标志等场景。
  • sync.Map 是专门为特定并发模式(读多写少、键不冲突)优化的并发安全映射,在适用场景下性能优于带锁的普通 map
  • 选择正确的工具取决于具体的并发模式。理解 Once、原子操作和 sync.Map 的适用场景,能帮助你编写出既高效又清晰的并发代码。在下一课中,我们将学习如何使用 Context 来管理 goroutine 的生命周期和传播取消信号。

练习编辑器

go
Loading...

继续学习

完成本课后,建议继续学习下一课「Context 与取消传播」 以巩固所学知识。