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++ // 临界区:这是一次读出、加一、写回的复合操作,必须由同一把锁保护。
}
1
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 // 持有写锁期间替换共享值。
}
1
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
}
1
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 只表示全部结束,不知道其中是否失败。
}
1
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 // 后续调用直接返回第一次执行保存的结果和错误。
}
1
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()
}
1
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
1
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 // 所有任务成功后才返回完整结果。
}
1
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
}
1
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/...
1
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、并发上限和连接池共同放进数据访问链路中理解。

# 参考资料

上次更新时间: 2026年09月18日 02:14:27