Go 高级教程:其他并发工具

如果说 Goroutine 和 Channel 是 Go 并发的“常规武器”,那么 sync 包里的工具就是“特种装备”。虽然不常用,但关键时刻能救命(榨干 CPU 的最后一点性能)。

除了 这里 介绍的诸多基础并发工具外,Go 标准库还提供了一些高级并发工具,下面介绍几个比较常用的。

1. 减轻 GC 压力:sync.Pool

我们在讲 GC 的时候提过,如果你频繁申请和销毁大对象(比如 HTTP Response 对象,或者大的 byte buffer),GC 会鸭梨山大。

sync.Pool 就是为了对象复用而生的。

1.1 示例代码

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6)
 7
 8// 定义池子
 9var bufPool = sync.Pool{
10    // New 函数:当池子里没存货时,调用它创建一个新的
11    New: func() interface{} {
12        fmt.Println("Creating new buffer")
13        return make([]byte, 1024)
14    },
15}
16
17func main() {
18    // 1.Get(): 借一个对象
19    buf := bufPool.Get().([]byte)
20
21    // 用完它...
22
23    // 2. Put(): 还回去,下次给别人用
24    // 注意:还之前最好重置一下状态(比如清空)
25    bufPool.Put(buf)
26
27    // 再次 Get,就不会触发 New,而是直接复用刚才那个
28    buf2 := bufPool.Get().([]byte)
29    _ = buf2
30}

1.2 注意事项

sync.Pool 里的对象随时可能被 GC 回收!所以绝对不要用它存数据库连接、Socket 连接这种必须长久保持的资源。它只适合存“临时垃圾”。

sync.Pool 工作原理

(图片来源网络)

2. 并发安全的 Map:sync.Map

原生的 map并发不安全的!如果多个 Goroutine 同时读写一个 map,程序会直接 Panic 崩溃。

虽然你可以用 sync.RWMutex 加锁保护 map,但在读多写少的场景下(比如缓存配置),锁的开销还是大。

Go 1.9 推出了 sync.Map,专门解决这个问题(并发安全的 Map)。

  • 优点:读多写少场景下性能极高(内部使用了空间换时间的策略,一份只读,一份可写)。
  • 缺点:API 不如原生 map 好用(全是 interface{},需要断言)。

示例代码:

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6)
 7
 8func main() {
 9    var m sync.Map
10
11    // 1. Store: 存储键值对
12    m.Store("name", "Hank")
13    m.Store("age", 18)
14
15    // 2. Load: 读取
16    val, ok := m.Load("name")
17    if ok {
18        fmt.Printf("Found name: %v\n", val)
19    }
20
21    // 3. LoadOrStore: 读写结合(原子操作)
22    // 如果 key 存在,返回 (value, true)
23    // 如果 key 不存在,存入并返回 (value, false)
24    actual, loaded := m.LoadOrStore("city", "Beijing")
25    fmt.Printf("City: %v, Loaded: %v\n", actual, loaded)
26
27    // 4. Delete: 删除
28    m.Delete("age")
29
30    // 5. Range: 遍历 (注意:无法像 for-range 那样 break,需返回 false 停止)
31    m.Range(func(key, value interface{}) bool {
32        fmt.Printf("Key: %v, Value: %v\n", key, value)
33        return true
34    })
35}

3. 极致性能:Atomic (原子操作)

锁(Mutex)是基于操作系统的,抢锁失败会发生线程上下文切换,成本较高。

如果你的并发操作仅仅是给一个整数 +1,用锁简直是杀鸡用牛刀。这时候该 CAS (Compare And Swap) 登场了。

Go 的 sync/atomic 包提供了 CPU 指令级的原子操作。

 1package main
 2
 3import (
 4    "fmt"
 5    "sync"
 6    "sync/atomic"
 7)
 8
 9func main() {
10    var count int64
11    var wg sync.WaitGroup
12
13    for i := 0; i < 1000; i++ {
14        wg.Add(1)
15        go func() {
16            defer wg.Done()
17            // 原子加 1,绝对线程安全,且比 Mutex 快得多
18            atomic.AddInt64(&count, 1)
19        }()
20    }
21    wg.Wait()
22    fmt.Println(count) // 1000
23}

Atomic 操作不是靠软件层面的锁实现的,而是直接对应 CPU 的 CAS (Compare-And-Swap) 指令(如 x86 的 LOCK CMPXCHG)。 CPU 保证了在这个指令执行期间,内存总线是锁定的,其他核心无法同时修改这块内存。因此极快,且没有线程切换开销。

3.1 思考与局限

问题:Atomic 虽然快,但它只能同步一个变量。如果我要保证 User 结构体里的 NameAge 字段同时被原子更新(要么都变,要么都不变),Atomic 还能搞定吗?

解答:不能。Atomic 只能保证单个整数或指针的原子性。这种跨多个字段的原子性更新,必须使用 sync.Mutex 加锁,或者使用 atomic.Value / atomic.Pointer[T] (Go 1.19+) 整体替换结构体指针(类似于 Java 的 AtomicReference)。

4. 隐形杀手:Data Race (数据竞争)

并发程序 bug 最难以调试。有时候程序跑了一整天都正常,突然半夜崩了。这通常是 数据竞争 导致的, 关于数据竞争和竞态条件,可以看 这里

定义:两个以上的 Goroutine 同时访问同一块内存,且至少有一个在写,还没有加锁。

Go 提供了一个神器来检测它:-race

检测实战

写一段有 Bug 的代码:

1func main() {
2    i := 0
3    go func() { i++ }() // 协程写
4    fmt.Println(i)      // 主程读
5}

直接运行可能没报错。但加上 -race 运行:

1$ go run -race main.go
2
3WARNING: DATA RACE
4Read at 0x00c00001c0b8 by main goroutine:
5...
6Previous write at 0x00c00001c0b8 by goroutine 7:
7...
8Found 1 data race(s)

强烈建议:在开发和测试环境(CI/CD)中,始终开启 -race 编译你的程序。虽然会慢一点,但能救命。

5. 总结

  • sync.Pool:想减轻 GC 压力,复用临时对象时用。
  • sync.Map:读多写少的超高并发缓存场景用。
  • atomic:简单的计数器、标志位修改,用它替代 Mutex。
  • -race:一定要在测试阶段开启,它是并发 Bug 的照妖镜。

相关阅读