Go 同步原语、atomic 与 errgroup
# Go 同步原语、atomic 与 errgroup
在掌握 Goroutine、Channel 和 Context 后,进一步理解 Mutex、Once、WaitGroup、Cond、atomic 与 errgroup 各自解决什么问题,并能为共享状态选择正确工具。
# 先记住一句话
同步工具不是按性能高低选择,而是按要维护的不变量选择:锁保护一组共享状态,atomic 只适合独立单值,Channel 传递所有权,errgroup 负责一组可能失败的并发任务。
# 同步原语是什么
同步原语(synchronization primitive)是语言或标准库提供的基础并发协调工具。这里的 “同步” 不是指代码只能同步执行,而是协调多个 Goroutine 的执行顺序和共享数据访问;“原语” 表示可以继续组合出更复杂并发逻辑的基础能力,不是新的 Go 语法。
常见工具分别解决不同问题:
- Mutex:同一时刻只允许一个 Goroutine 修改受保护的共享状态;
- RWMutex:允许多个读取者并发访问,写入时仍然独占;
- WaitGroup:等待一组 Goroutine 全部结束;
- Once:保证一段初始化逻辑只执行一次;
- Cond:等待某个共享状态发生变化;
- atomic:不可分割地读写一个简单值;
- Channel:在 Goroutine 之间传递数据并协调执行。
对于前端开发,可以把 Promise.all() 等待任务、AbortSignal 通知取消理解为相似的异步协调需求;但 Go 还会让多个 Goroutine 并行访问共享内存,因此需要 Mutex 和 atomic 等工具。
errgroup 严格来说不是底层同步原语,而是建立在 Goroutine、Context 和等待机制之上的高层并发辅助工具。它放在本篇一起介绍,是因为实际项目经常用它统一等待并发任务、传播首个错误和联动取消。
# 先看选择表
| 工具 | 主要职责 | 典型场景 | 关键风险 |
|---|---|---|---|
sync.Mutex | 互斥保护一组状态 | Map、缓存和多字段不变量 | 忘记解锁、锁内执行慢操作 |
sync.RWMutex | 区分读锁和写锁 | 读多写少的共享状态 | 不一定比 Mutex 快,仍需测量 |
sync.WaitGroup | 等待一组任务结束 | 已确定数量且不返回错误的任务 | 不能传播错误和取消 |
sync.Once | 整个进程内只执行一次 | 懒加载不可变资源 | 首次失败后不会自动重试 |
sync.Cond | 条件变化时唤醒等待者 | 复杂共享状态条件 | 必须在循环中重新检查条件 |
sync/atomic | 原子读写一个值 | 指标、开关和指针快照 | 无法保护跨字段不变量 |
errgroup.Group | 等待、传播首个错误和取消 | 并发调用多个下游 | 任务必须响应 Context |
# Mutex 保护的是不变量
sync.Mutex (opens new window) 的全称是 Mutual Exclusion Lock(互斥锁)。它不是 Go 独有的概念,而是并发编程中常见的同步工具:同一时刻只允许一个 Goroutine 持有这把锁并进入受保护的代码区域。
先区分声明锁、加锁和解锁:
package counter
import "sync" // 提供零值可用的 Mutex。
var (
mu sync.Mutex // 声明一把锁;Mutex 的零值可以直接使用,此时还没有加锁。
count int // 由所有 Increment 调用共享的计数值,零值为 0。
)
func Increment() {
mu.Lock() // 加锁;其他调用同一把锁 Lock() 的 Goroutine 必须等待。
defer mu.Unlock() // 当前函数返回前解锁,避免提前返回时忘记释放锁。
count++ // 临界区:这是一次读出、加一、写回的复合操作,必须由同一把锁保护。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mu sync.Mutex:只是声明并准备一把锁;mu.Lock():真正加锁;mu.Unlock():解锁,让等待同一把锁的其他 Goroutine 继续;- 临界区:从加锁到解锁之间,受这把锁保护的共享数据操作。
如果使用 sync.RWMutex,还可以区分读锁和写锁:
package state
import "sync" // RWMutex 提供读锁 RLock 和写锁 Lock。
var (
mu sync.RWMutex // 保护下面的共享 value。
value int // 可能被多个 Goroutine 同时读取或修改。
)
func Read() int {
mu.RLock() // 加读锁:允许多个读取者同时进入。
defer mu.RUnlock() // 解读锁。
return value // 持有读锁期间读取共享值,返回前 defer 会自动释放读锁。
}
func Write(next int) {
mu.Lock() // 加写锁:写入期间排斥其他读取和写入。
defer mu.Unlock() // 解写锁。
value = next // 持有写锁期间替换共享值。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
所有访问同一份共享数据的代码都必须遵守同一套加锁规则;只给部分入口加锁,仍然可能发生数据竞争。Mutex 使用后也不应再被复制,通常应和它保护的数据放在同一个 Struct 中。
锁的目的不是 “让所有代码一次只跑一个” ,而是保证一组共享状态始终满足业务规则。
// 文件位置:internal/quota/quota.go
package quota
import "sync"
type Quota struct {
// Mutex 保护 remaining,避免多个 Goroutine 同时检查和扣减造成超额。
mu sync.Mutex
remaining int
}
// New 创建指定初始额度的 Quota,并返回指针供多个调用方共享使用。
func New(limit int) *Quota {
return &Quota{remaining: limit} // remaining 从 limit 开始递减。
}
// TryTake 尝试原子地完成 “检查是否有额度并扣减一次”。
func (q *Quota) TryTake() bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.remaining == 0 {
return false
}
q.remaining-- // 检查和扣减必须处于同一个临界区。
return true
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
如果先读取再单独原子减一,两个 Goroutine 可能同时看到剩余额度大于零,破坏 “额度不能为负” 的整体规则。这种跨步骤不变量通常更适合 Mutex。
锁内应只保留修改共享状态所需的最短代码。数据库、HTTP 请求、Channel 阻塞发送和不可控回调通常不要放在锁内,否则会放大延迟甚至死锁。
# WaitGroup 只等待,不处理错误
// 文件位置:examples/wait_group.go
package syncpatterns
import "sync" // WaitGroup 用计数器等待一组 Goroutine 全部调用 Done。
// RunAll 并发执行所有无返回值任务,并在它们全部结束后返回。
func RunAll(tasks []func()) {
// WaitGroup 是并发计数器,用于等待一组 Goroutine 全部结束。
var group sync.WaitGroup
group.Add(len(tasks)) // Add 必须在启动 Goroutine 前完成。
for _, task := range tasks {
currentTask := task // 为当前 Goroutine 保存本轮任务,避免意外引用变化的循环变量。
go func() {
defer group.Done() // 无论任务怎样正常返回,都减少计数。
currentTask()
}()
}
group.Wait() // Wait 只表示全部结束,不知道其中是否失败。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
WaitGroup 不能代替 Context,也不会收集错误。任务会失败或需要首错取消时,优先考虑 errgroup。
# sync.Once 只执行一次,包括失败
// 文件位置:internal/catalog/catalog.go
package catalog
import (
"errors" // 创建初始化失败时返回的错误。
"sync" // Once 保证初始化函数最多执行一次。
)
type Catalog struct {
Items []string // 加载后的目录条目。
}
// var (...) 把相关包级变量集中声明;它们共同保存只初始化一次的结果。
var (
loadOnce sync.Once
loadResult *Catalog
loadErr error
)
func Load() (*Catalog, error) {
// 多个 Goroutine 同时调用 Load 时,也只有一个能真正执行 Do 中的函数。
loadOnce.Do(func() {
// 示例用固定数据代替文件或网络读取,保证代码可以独立运行。
loadResult = &Catalog{Items: []string{"Go", "Python"}}
if len(loadResult.Items) == 0 {
loadErr = errors.New("catalog is empty")
}
})
return loadResult, loadErr // 后续调用直接返回第一次执行保存的结果和错误。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Once 保证传入函数最多执行一次,而不是保证初始化成功。如果初始化可能因网络抖动失败并需要重试,就不能直接用一个永久 Once 包住请求;应在启动阶段失败退出,或显式设计带退避和状态机的重试流程。
# atomic 适合独立单值
sync/atomic 提供底层原子内存操作。现代 Go 代码优先使用 atomic.Int64、atomic.Bool 和 atomic.Pointer[T] 等带类型包装,减少地址和值类型用错的机会。
// 文件位置:internal/metrics/counters.go
package metrics
import "sync/atomic" // 提供带类型的原子整数、布尔值和指针。
type Counters struct {
// atomic 类型提供单个值的无锁原子读写,不能自动保护跨字段整体一致性。
requests atomic.Int64
failures atomic.Int64
ready atomic.Bool
}
func (c *Counters) Record(success bool) {
c.requests.Add(1) // Add 是一个不可分割的读改写操作。
if !success {
c.failures.Add(1) // 只有失败请求才增加 failures。
}
}
func (c *Counters) SetReady(ready bool) {
c.ready.Store(ready) // Store 原子替换布尔值,其他 Goroutine 可安全 Load。
}
func (c *Counters) Snapshot() (requests int64, failures int64, ready bool) {
// 三次 Load 各自原子,但不保证来自完全相同的时间点。
return c.requests.Load(), c.failures.Load(), c.ready.Load()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
上面适合监控指标,因为允许轻微时间差。如果业务要求 “requests、failures 和 ready 必须属于同一个一致快照” ,就要用锁保护整个快照,或原子替换一个不可变 Struct 指针。
atomic 不是无锁版 Mutex
atomic 能保证单次原子操作不被打断,但不会自动把多行代码变成事务。只要规则涉及多个变量或 “先检查再更新” 的组合条件,就要重新评估 Mutex、Channel 或状态机。
# errgroup:并发执行并传播首个错误
golang.org/x/sync/errgroup 不属于标准库,需要在 Go Module 中安装:
# 在项目根目录执行,把依赖写入 go.mod 和 go.sum。
go get golang.org/x/sync/errgroup
2
下面示例同时加载多篇笔记,并把并发数限制为 4。任一任务失败后,派生 Context 会取消,其他调用应尽快退出。
// 文件位置:internal/note/batch.go
package note
import (
"context" // 把上层取消信号传给所有并发 Repository 调用。
"fmt" // 为单篇 Note 加载错误补充具体 ID。
"golang.org/x/sync/errgroup"
)
type Note struct {
ID string // 笔记唯一标识。
Title string // 笔记标题。
}
// Repository 描述批量加载逻辑真正依赖的单篇查询能力。
type Repository interface {
Get(context.Context, string) (Note, error)
}
// LoadAll 按 ids 原顺序并发加载 Note,同时把最大并发数限制为 4。
func LoadAll(ctx context.Context, repository Repository, ids []string) ([]Note, error) {
// WithContext 返回任务组和派生 Context;任一任务失败会取消同组 Context。
group, groupContext := errgroup.WithContext(ctx)
group.SetLimit(4) // 限制同时访问数据库或远程服务的数量。
// 预先创建等长切片,让每个 Goroutine 按自己的 index 写入,最终顺序仍与 ids 一致。
results := make([]Note, len(ids))
for index, id := range ids {
// 为本轮创建独立副本,避免 Goroutine 捕获随后变化的循环变量。
currentIndex, currentID := index, id
group.Go(func() error {
current, err := repository.Get(groupContext, currentID)
if err != nil {
return fmt.Errorf("load note %s: %w", currentID, err)
}
// 每个 Goroutine 只写独立下标,不会同时修改同一个元素。
results[currentIndex] = current
return nil
})
}
if err := group.Wait(); err != nil {
return nil, err // Wait 返回第一个非 nil 错误。
}
return results, nil // 所有任务成功后才返回完整结果。
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
取消是否有效取决于 Repository.Get 是否真的向数据库或 HTTP 客户端传递 groupContext。如果底层忽略 Context,group.Wait() 仍可能一直等待。
# Cond 为什么少见
sync.Cond (opens new window) 允许 Goroutine 等待某个共享条件,并在条件可能变化时被唤醒。创建 Cond 时必须传入一把实现了 sync.Locker 的锁,通常就是 *sync.Mutex。
package queue
import "sync" // 提供 Mutex 和基于 Locker 构建的 Cond。
var (
mu sync.Mutex // 保护 items 及其 “是否为空” 的条件。
items []string // Add 和 Take 共享访问的内存队列。
// NewCond 把 &mu 保存到 Cond 的 L 字段中,因此 condition.L 就是这把锁。
condition = sync.NewCond(&mu)
)
// Add 添加数据,并通知一个正在等待的 Goroutine:队列状态可能已经变化。
func Add(item string) {
condition.L.Lock()
defer condition.L.Unlock()
items = append(items, item) // 必须持有 condition.L 才能修改共享队列。
condition.Signal() // 唤醒一个等待者;Broadcast() 可以唤醒全部等待者。
}
// Take 在队列为空时等待,出现数据后取出第一个元素。
func Take() string {
condition.L.Lock()
defer condition.L.Unlock()
for len(items) == 0 {
// Wait 会先释放 condition.L,让 Add 能取得锁并写入数据;
// 被 Signal 唤醒后,Wait 会重新取得同一把锁再返回。
condition.Wait()
}
item := items[0] // 取出队头元素。
items = items[1:] // 切掉已经取出的第一个元素,更新共享队列。
return item
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
condition.L 不是额外声明的变量:L 是 sync.Cond 用来保存关联锁的公开字段,来源就是 sync.NewCond(&mu) 传入的 &mu。
Wait 必须放在循环中重新检查条件。Signal 只表示条件可能已经变化;当前 Goroutine 真正重新取得锁之前,数据可能已经被另一个等待者取走。
普通任务传递通常用 Channel 更直观。只有多个等待者围绕复杂共享状态进行协调,并且 Channel 会让模型更绕时,才考虑 Cond。
# 验证并发代码
# go test:编译并运行测试。
# -race:启用 Race Detector,检查本次测试实际执行路径中的数据竞争。
# ./...:递归测试当前 Module 下的所有包。
go test -race ./...
# -count=50:把每个包的测试重复运行 50 次,增加偶发并发问题的暴露概率。
# ./internal/...:只递归测试 internal 目录及其子目录中的包。
go test -race -count=50 ./internal/...
2
3
4
5
6
7
8
Race Detector 只能发现本次执行覆盖到的数据竞争,不代表通过一次就证明并发逻辑完全正确。测试还应覆盖取消、超时、部分失败、重复关闭和服务退出。检测原理、开销和使用边界见 Race Detector 只能发现跑到的竞争。
# 高频面试题与回答
1. Mutex 和 atomic 应该怎样选择?参考答案
独立计数器、布尔开关或不可变指针快照可以考虑 atomic;涉及多个字段、复合检查和更新、Map 或业务不变量时优先使用 Mutex。选择依据是状态模型,不是简单认为 atomic 一定更快。
2. WaitGroup 和 errgroup 有什么区别?参考答案
WaitGroup 只等待一组任务结束,不处理返回错误和取消;errgroup 允许任务返回 error,能返回首个错误,并通过 WithContext 把首错转换为取消信号。任务是否及时结束仍取决于底层是否响应 Context。
3. sync.Once 初始化失败后会自动重试吗?参考答案
不会。Once 只保证函数最多执行一次,不关心它是否成功。需要重试的初始化应显式设计重试状态,或者在启动阶段失败退出并由进程管理器重新启动。
# 接下来学什么
下一篇学习 Go 数据库:database/sql、连接池与事务,把 Context、并发上限和连接池共同放进数据访问链路中理解。