3. Go 并发安全:sync、atomic 与数据竞争

并发代码最容易出问题的地方不是 goroutine 启动,而是多个 goroutine 同时访问同一份数据。只要至少一个访问是写入,并且没有同步保护,就可能出现数据竞争。

什么是数据竞争

下面的代码有数据竞争:

var count int

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            count++
        }()
    }

    wg.Wait()
    fmt.Println(count)
}

count++ 不是一个原子操作,它至少包含三步:

读取 count
加 1
写回 count

多个 goroutine 交错执行时,会互相覆盖结果。最后打印的值可能小于 1000。

用 race detector 检查

Go 内置 race detector:

go test -race ./...
go run -race main.go

它会在运行时检测数据竞争,并打印冲突的读写位置。只要项目里有并发共享状态,-race 就应该成为本地和 CI 的常用检查。

注意:race detector 只能发现实际运行到的路径。测试没覆盖的并发路径,它也发现不了。

Mutex:保护临界区

sync.Mutex 是最常用的并发安全工具:

type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}

临界区要尽量小,只包住必须互斥的部分:

func (c *Cache) Get(key string) (Value, bool) {
    c.mu.Lock()
    v, ok := c.items[key]
    c.mu.Unlock()

    // 后续耗时处理不要放在锁里
    return v, ok
}

锁的基本规则:

  1. Lock 后必须 Unlock,通常用 defer
  2. 不要复制含锁的结构体
  3. 不要在持锁时调用未知外部函数
  4. 多把锁同时使用时,要固定加锁顺序,避免死锁

RWMutex:读多写少

sync.RWMutex 允许多个读者同时进入,但写者独占:

type Config struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Config) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

func (c *Config) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

RWMutex 适合读远多于写、读操作相对耗时的场景。如果临界区很小、写入也不少,普通 Mutex 可能更简单,也不一定更慢。

不要在持有读锁时升级成写锁:

// ❌ 容易死锁:RLock 后再 Lock
c.mu.RLock()
if c.data[key] == "" {
    c.mu.Lock()
    c.data[key] = value
    c.mu.Unlock()
}
c.mu.RUnlock()

正确做法是释放读锁后重新获取写锁,并重新检查条件:

c.mu.RLock()
_, ok := c.data[key]
c.mu.RUnlock()

if !ok {
    c.mu.Lock()
    if _, ok := c.data[key]; !ok {
        c.data[key] = value
    }
    c.mu.Unlock()
}

Once:只执行一次

sync.Once 用于并发安全的懒初始化:

type Client struct {
    once sync.Once
    conn *Conn
    err  error
}

func (c *Client) Conn() (*Conn, error) {
    c.once.Do(func() {
        c.conn, c.err = dial()
    })
    return c.conn, c.err
}

无论多少 goroutine 同时调用 Conn()dial() 都只会执行一次。Once 还保证初始化完成后的内存可见性:其他 goroutine 看到的是初始化完成后的结果。

注意:如果 Do 里的函数 panic,Once 仍然认为它已经执行过。后续调用不会重试。

Cond:条件变量

sync.Cond 用于”等待某个条件成立”。它比 channel 更底层,业务代码里不算常见,但适合复杂状态机或队列。

type Queue struct {
    cond  *sync.Cond
    items []int
}

func NewQueue() *Queue {
    q := &Queue{}
    q.cond = sync.NewCond(&sync.Mutex{})
    return q
}

func (q *Queue) Push(v int) {
    q.cond.L.Lock()
    q.items = append(q.items, v)
    q.cond.Signal()
    q.cond.L.Unlock()
}

func (q *Queue) Pop() int {
    q.cond.L.Lock()
    defer q.cond.L.Unlock()

    for len(q.items) == 0 {
        q.cond.Wait()
    }

    v := q.items[0]
    q.items = q.items[1:]
    return v
}

Wait 必须放在 for 循环里,因为被唤醒不等于条件一定成立。可能有多个消费者竞争,也可能出现虚假唤醒式的状态变化。

Pool:复用临时对象

sync.Pool 用来复用临时对象,减少 GC 压力:

var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func encode(v any) []byte {
    buf := bufPool.Get().(*bytes.Buffer)
    defer bufPool.Put(buf)

    buf.Reset()
    json.NewEncoder(buf).Encode(v)
    return append([]byte(nil), buf.Bytes()...)
}

Pool 的特点:

  1. 适合短生命周期、可重用的临时对象
  2. 池里的对象可能随时被 GC 清掉
  3. 取出的对象必须重置状态
  4. 不要把有业务生命周期的对象放进 Pool

atomic:低层原子操作

sync/atomic 适合简单计数器、开关状态、无锁读写:

var count atomic.Int64

func Inc() {
    count.Add(1)
}

func Value() int64 {
    return count.Load()
}

布尔开关:

var closed atomic.Bool

func Close() {
    closed.Store(true)
}

func IsClosed() bool {
    return closed.Load()
}

atomic 很快,但也更容易写出难懂代码。只要状态不止一个字段,或者需要维护多个变量之间的不变量,优先用 mutex:

// 需要同时维护 map 和计数,mutex 更清楚
type Store struct {
    mu    sync.Mutex
    items map[string]Item
    size  int
}

不要把 atomic 当成”更高级的锁”。它适合小而独立的状态,不适合复杂业务状态。

map 不是并发安全的

Go 的普通 map 不能并发读写:

// ❌ fatal error: concurrent map read and map write
var m = map[string]int{}

go func() {
    m["a"] = 1
}()

go func() {
    fmt.Println(m["a"])
}()

修复方式:

type SafeMap struct {
    mu sync.RWMutex
    m  map[string]int
}

func (s *SafeMap) Get(key string) (int, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

func (s *SafeMap) Set(key string, value int) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = value
}

标准库还有 sync.Map,适合特定场景:读多写少、key 集合相对稳定,或者多个 goroutine 访问互不相交的 key。普通业务缓存通常还是 map + mutex 更可控。

内存可见性

并发安全不只是”不崩溃”,还包括一个 goroutine 写入的数据,另一个 goroutine 能不能看到。

下面的代码不安全:

var ready bool
var data string

go func() {
    data = "done"
    ready = true
}()

for !ready {
}

fmt.Println(data)

即使看起来先写 data 再写 ready,另一个 goroutine 也不一定可靠看到这个顺序。应该用 channel、锁或 atomic 建立同步关系:

done := make(chan struct{})
var data string

go func() {
    data = "done"
    close(done)
}()

<-done
fmt.Println(data)

channel close 与接收之间建立了 happens-before 关系,接收方能看到关闭前的写入。

小结

并发安全的核心问题是:共享数据必须有同步边界

常用选择:

  • 简单共享状态:sync.Mutex
  • 读多写少:sync.RWMutex
  • 只初始化一次:sync.Once
  • 简单计数或开关:sync/atomic
  • 任务通信和完成通知:channel
  • 等待一组 goroutine:sync.WaitGroup
  • 发现数据竞争:go test -race

能不用共享内存时,用 channel 传递所有权;必须共享内存时,用锁或 atomic 明确保护。