3. Go 并发安全:sync、atomic 与数据竞争
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
}
锁的基本规则:
Lock后必须Unlock,通常用defer- 不要复制含锁的结构体
- 不要在持锁时调用未知外部函数
- 多把锁同时使用时,要固定加锁顺序,避免死锁
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 的特点:
- 适合短生命周期、可重用的临时对象
- 池里的对象可能随时被 GC 清掉
- 取出的对象必须重置状态
- 不要把有业务生命周期的对象放进 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 明确保护。