Go语言凭借goroutine让并发编程变得异常简单,只需一个go关键字就能启动成千上万的并发任务。但简单背后隐藏着经典难题:多个goroutine同时操作同一份数据时,如果没有同步机制,就会产生数据竞争(data race),程序行为变得不可预测。Go标准库中的sync包正是为此而生,它提供了一整套并发原语,帮助开发者在共享内存模型下实现并发安全。本文将结合实例,详细讲解sync包中最常用的几个组件。

一、为什么需要sync:数据竞争的危害
先看一段存在数据竞争的典型代码:
package main
import (
"fmt"
"sync"
)
func main() {
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // 多个goroutine并发修改共享变量
}()
}
wg.Wait()
fmt.Println(count) // 大概率不等于1000
}这段代码期望输出1000,但实际运行时经常得到一个小于1000的数,甚至每次结果都不一样。原因在于count++并不是原子操作,它包含读取、加一、写回三个步骤,多个goroutine交错执行时,某些更新会互相覆盖。
可以用go run -race main.go命令开启竞态检测器,运行时会明确报告"DATA RACE"。数据竞争的可怕之处在于它不一定每次都复现,可能在测试环境安然无恙,到了生产环境高并发场景下才暴露,排查成本极高。所以在编写并发代码时,必须从设计阶段就考虑同步问题。
二、互斥锁Mutex与读写锁RWMutex
1. 使用Mutex保护共享资源
sync.Mutex是最基础的互斥锁,它保证同一时刻只有一个goroutine能访问临界区。上面的计数器问题加一把锁就能解决:
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}这里有几个实践要点值得强调。第一,锁应该作为结构体的字段存在,由结构体自己管理,而不是把锁暴露给外部调用者,这样能避免调用方忘记加锁。第二,习惯上用defer c.mu.Unlock()确保解锁一定执行,即使临界区内发生panic也不会导致死锁。第三,临界区要尽量小,只保护真正需要互斥的操作,不要把耗时的IO或计算放进锁内,否则并发的意义就荡然无存。
2. 读多写少场景用RWMutex
如果读操作远多于写操作,sync.RWMutex是更好的选择。它允许任意多个读goroutine同时持有读锁,只有在写入时才独占:
type SafeCache struct {
mu sync.RWMutex
data map[string]string
}
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}
func (c *SafeCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}需要注意的是,RWMutex并非万能药。如果写操作频繁,读写锁内部的维护开销反而比普通Mutex更高,还可能出现写饥饿问题。一般来说,读请求占九成以上时才考虑使用RWMutex,否则老老实实用Mutex就好。另外,读锁可以嵌套获取,但绝不能在读锁未释放时升级为写锁,否则会直接死锁。
三、WaitGroup、Once与原子操作
1. WaitGroup等待goroutine完成
前文示例中已经用到了sync.WaitGroup,它是协调一组goroutine的常用手段。核心是三个方法:Add增加计数,Done减少计数(内部就是Add(-1)),Wait阻塞直到计数归零。
使用时要牢记两个坑:一是Add必须在启动goroutine之前调用,如果放在goroutine内部,主流程可能先执行了Wait导致提前退出;二是Add和Done必须配对,多用或少用Done都会引发panic或永久阻塞。如果goroutine数量在编译期不确定,可以先循环Add(n)再统一启动。
2. Once保证只执行一次
sync.Once常用于单例初始化,无论多少goroutine并发调用,初始化函数只执行一次:
var (
instance *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
instance = loadConfig() // 并发调用时只会执行一次
})
return instance
}Once的优势在于它内部处理了所有同步细节,调用方无需自己加锁判断。Go 1.9之后还提供了sync.OnceFunc等便捷函数,可以更函数式地包装只执行一次的逻辑。
3. 原子操作处理简单数值
对于简单的数值加减、布尔标志、指针替换等场景,sync/atomic包比锁更轻量。前面的计数器改用原子操作后既安全又高效:
var count int64
func inc() {
atomic.AddInt64(&count, 1)
}
func getCount() int64 {
return atomic.LoadInt64(&count)
}原子操作直接依赖CPU指令,没有锁的调度开销,在计数器、限流统计、开关标志等场景性能远超Mutex。但它的适用范围有限,一旦临界区包含多条语句或复杂逻辑,仍然需要回到锁的方案。另外要注意,atomic操作的对象必须按自然边界对齐,比如int64在32位平台上需要特殊的64位对齐处理,否则可能panic。
四、实战建议与常见误区
掌握了各个工具之后,更重要的是选型思路。简单总结一下:多个goroutine写共享数据,优先考虑能否改用channel传递数据实现"不要通过共享内存来通信";读多写少的缓存类结构选RWMutex;简单的计数和标志位用atomic;需要一次性初始化用Once;等待一组任务完成用WaitGroup。Go语言有句名言:"不要通过共享内存来通信,而要通过通信来共享内存",channel在许多场景下确实比锁更优雅,但channel不是银弹,共享状态密集的场合锁依然是更直接的选择。
还有几个常见误区需要提醒。第一,锁的粒度问题:锁太粗会让并发退化成串行,锁太细又容易死锁,需要根据业务权衡。第二,避免在持有锁时调用外部未知函数,因为它内部可能又去获取同一把锁造成死锁。第三,sync.Mutex等类型不能被复制,传递包含锁的结构体时必须传指针,否则锁会失效,可以用go vet工具检测这类问题。第四,所有并发代码都应该用-race标志跑一遍测试,把数据竞争消灭在上线之前。
并发编程没有捷径,工具本身不难,难的是对执行时序的正确理解。建议在写任何涉及共享变量的代码时,先问自己一句:这个变量会被几个goroutine访问?只要养成了这个习惯,再配合sync包提供的这些原语,写出并发安全的Go程序并不困难。