写出能“同时做很多事”的 Go 程序并不难,难的是回答三个更具体的问题:哪些状态被多个 goroutine 共享?一个 goroutine 的写入何时能被另一个 goroutine 看见?程序在不同调度顺序下,是否仍然满足同一组业务约束?
这几个问题不能靠“压测时没出错”来回答。调度器、处理器缓存、编译器优化和真实负载都会改变事件发生的时机。我们需要的是一套可以推理、可以测试、也能落到代码审查中的方法。
这一章会从竞态条件出发,逐步建立共享状态的设计边界,再讲清 Mutex、RWMutex、sync.Once、sync/atomic、race detector、并发缓存以及 goroutine 调度。示例都尽量保持完整,并把“为什么正确”说清楚。
并发安全不是某一行代码的属性,而是一个操作在所有允许的并发交错下都保持约束的能力。判断代码是否安全时,要看完整操作、共享状态和同步关系,不能只看单次读写。
这两个词经常被混用,但它们关注的层次不同。
竞态条件(race condition)是逻辑层面的错误:程序结果依赖事件交错,其中至少一种合法交错会破坏需求。它不一定包含对同一内存位置的无同步读写。例如,两次都并发安全的“检查库存”和“扣减库存”如果被拆开调用,仍可能出现超卖。
数据竞争(data race)是内存访问层面的冲突:两个 goroutine 并发访问同一内存位置,至少一个是普通写,并且它们之间没有建立足以排序这些访问的 happens-before 关系。数据竞争本身就是程序错误;它还可能让多字段值出现彼此不一致的组合。
所以两者的关系可以这样记:数据竞争经常造成竞态条件,但消除数据竞争不等于业务操作已经原子化。锁住每一个小函数,仍可能留下跨函数的“先检查、后执行”竞态。

下面的 total += price 看起来像一步,实际要先读取旧值、计算新值,再写回。两个 goroutine 都读到 0 时,一个可能写回 50,另一个写回 30,最终结果不会是期望的 80。
package main
import (
"fmt"
"sync"
)
func main() {
var total int
var wg sync.WaitGroup
start := make(chan struct{})
add := func(price int) {
defer wg.Done()
<-start
total += price // 故意保留数据竞争,供 go run -race 观察
}
wg.Add(2)
go add(50)
go add(30)
close(start)
wg.Wait()
fmt.Println(total)
}这段程序的某次输出即使碰巧是 80,也不能证明它正确。数据竞争是否存在,与这一次打印的值是否“看起来没问题”是两回事。
假设 Balance 和 Withdraw 各自都在内部正确加锁:
if account.Balance() >= 100 {
account.Withdraw(100)
}两次方法调用之间锁已经释放。另一个 goroutine 可以在检查后先提款,使当前 goroutine 的判断过期。正确设计是提供一个单独的 TryWithdraw(100) bool,让“检查并扣减”在同一个临界区中完成。
动手题:把上面的计数程序分别改成 sync.Mutex 和 atomic.Int64 版本,再执行 go run -race。
面对共享变量,我们先不要条件反射地加锁。更好的决策顺序是:能否不再修改?能否只让一个 goroutine 拥有?确实需要共同访问时,再用同步原语序列化冲突。

如果一份数据在并发开始前已经完整构造,之后所有 goroutine 只读,那么不需要为普通读取加锁。这里的关键不是 map 或 slice 变成了特殊的“只读类型”,而是程序遵守了不再修改底层状态的约束。
var statusText = map[int]string{
200: "成功",
404: "未找到",
500: "服务异常",
}
func LookupStatus(code int) string {
return statusText[code] // 并发读安全,前提是任何地方都不再写
}如果要更新,可以构造一份新快照,再用锁或 atomic.Value 一次性发布;不要一边读一边原地修改旧 map。
另一条路是 goroutine confinement:共享的不是变量本身,而是请求。其他 goroutine 通过 channel 请状态所有者执行查询或更新。
package counter
type request struct {
delta int
reply chan int
}
type Counter struct {
requests chan request
}
func New() *Counter {
c := &Counter{requests: make(chan request)}
go func() {
这种设计把状态转移、排队和所有权写得很明显,适合事件循环、连接管理器或需要按顺序处理的状态机。代价是要设计关闭、取消和背压;如果所有请求都做慢操作,所有者也会成为队头阻塞点。
流水线还可以把指针从一个阶段传给下一阶段。发送方在发送后不再触碰对象,接收方才取得修改权。channel 提供同步边,团队约定提供所有权边;两者缺一不可。
把指针放进 channel 不会自动禁止发送方继续访问。串行移交是否安全,取决于代码是否真的遵守“发出后不再使用”的所有权纪律。
设计题:一个在线房间维护成员表,成员加入/离开频繁,还要广播事件。你会先选锁还是状态所有者?
sync.Mutex 的零值可直接使用。Lock 获取独占访问权;锁已被占用时,调用者会阻塞;Unlock 释放。一次 Unlock 与之后成功的 Lock 之间会建立同步关系,因此临界区中的写入能被后续持锁者观察到。
type Account struct {
mu sync.Mutex
balance int
}
func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
}
func (a *Account) TryWithdraw(
这里保护的约束是“余额不会被并发读改写打断,并且提款成功后余额仍合法”。锁的范围因此要覆盖检查和扣减,而不只是 a.balance -= amount。

把 mutex 和它保护的状态封装进同一个结构体,字段不导出,并用注释写清 mu guards ...。这比全局散落的锁更容易审查。内部辅助方法可以约定调用者已持锁,命名或注释必须明确:
// withdrawLocked 要求调用者已经持有 a.mu。
func (a *Account) withdrawLocked(amount int) bool {
if amount < 0 || a.balance < amount {
return false
}
a.balance -= amount
return true
}Go 的 Mutex 不是可重入锁。已经持有 a.mu 的方法若再调用一个会 Lock 同一把锁的方法,会把自己永久阻塞。拆出 ...Locked 辅助函数就是常见解法。
含有 Mutex、RWMutex、Once 等同步状态的值,在首次使用后不能复制。值接收者、结构体赋值、按值返回或把值追加到会搬迁的容器,都可能得到一份新的锁副本;新锁与原状态失去共同的同步边界。
因此,含锁类型的方法通常使用指针接收者,实例也通过指针传递。go vet 的 copylocks 检查能发现不少此类问题。
缩短临界区不是把一次业务原子操作随意切碎。正确步骤是先找出必须共同保持的不变量,再把与共享状态无关的昂贵工作移到锁外。
常见做法是“锁内复制快照,锁外做慢操作”:
func (s *Service) NotifyAll(ctx context.Context) error {
s.mu.Lock()
users := append([]User(nil), s.users...)
s.mu.Unlock()
// 网络调用不再占用 s.mu。
for _, u := range users {
if err := s.sender.Send
持锁期间应谨慎执行网络 I/O、磁盘 I/O、无界 channel 操作、调用未知回调或等待另一个 goroutine。这些动作耗时不可控,还可能绕回当前对象再次取锁。
defer mu.Unlock() 通常最清楚,但它把临界区延伸到函数返回。如果函数后半段还有慢操作,就应拆成更小函数,或在完成共享状态更新后显式解锁。不要为了使用 defer 而扩大锁范围。
代码审查题:为什么 Snapshot 返回内部 map 可能让锁保护失效?
sync.RWMutex 允许多个读者同时持有 RLock,写者使用 Lock 获得独占访问。它适合读临界区占多数、锁确实存在争用、读操作足够长的情况。没有争用或临界区极短时,额外记账可能让它比普通 Mutex 更慢。
type Catalog struct {
mu sync.RWMutex
items map[string]Item
}
func (c *Catalog) Get(id string) (Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[id]
return item, ok
访问器如果顺便更新命中计数、LRU 顺序或懒加载缓存,就必须使用写锁。判断标准是临界区是否写了任何受同一锁保护的状态,而不是方法名字是否叫 Get。
RWMutex 不支持从 RLock 原地升级到 Lock,也不支持从写锁原地降级成读锁。需要升级时必须先 RUnlock,再 Lock,并在获得写锁后重新检查条件,因为中间状态可能已被别人改变。
当写者正在等待时,新的读者会被阻塞,让写者最终有机会获得锁。这是一项具体语义,但不能把它扩大解释为“所有 goroutine 都严格公平地轮流获得锁”。Go 并未给出通用的严格公平调度保证,应用也不该依赖某种恰好观察到的次序。
基准题:为同一份 map 写 Mutex 和 RWMutex 两版,按 50:50、95:5、100:0 的读写比分别跑并行基准。
锁不仅阻止两个 goroutine 同时进入临界区,它还建立内存可见性。没有同步时,不能把并发执行简单想成“把每个 goroutine 的源码行随机交错”。编译器和处理器只需保持单个 goroutine 内可观察语义;另一个 goroutine 何时看见写入,要由内存模型决定。

可以把 happens-before 理解成两类边的传递闭包:
Once 等同步动作建立的跨 goroutine 关系。如果写 W happens-before 读 R,并且中间没有另一写入覆盖它,R 才能依规则观察到 W。对于没有数据竞争的程序,Go 提供 DRF-SC 保证:结果可以用 goroutine 操作的某种顺序一致交错来解释。
日常代码最常用这些规则:
go 语句 synchronized-before 新 goroutine 开始执行。因此,启动前写好的参数和状态可被它观察到。Mutex/RWMutex 的解锁与之后成功获得相应锁之间建立顺序。Once 中函数返回 synchronized-before 任意 Do 调用返回。反过来,goroutine 退出本身不会自动与其他事件同步。下面的代码不能用 Sleep 修成正确程序:
var message string
func main() {
go func() { message = "完成" }()
time.Sleep(time.Millisecond)
fmt.Println(message) // 仍是数据竞争
}应改用 channel、WaitGroup 或锁。WaitGroup.Wait 解决的是明确的完成同步;“大概等够了”没有任何内存顺序保证。
推理题:生产者先填充结构体,再 close(ready);消费者 <-ready 后读取结构体,为什么无需再给结构体字段单独加锁?
下面这种“见到 nil 就初始化”的写法不是并发安全的:两个 goroutine 可能同时初始化,更严重的是,另一个 goroutine 看到非 nil 时,不代表对象的所有字段都已通过同步安全发布。
// 错误:检查与发布之间没有同步。
if config == nil {
config = loadConfig()
}sync.Once 把只执行一次和内存可见性绑在一起:第一次 Do 调用执行函数,其他调用在初始化完成前不会返回。初始化函数的返回 happens-before 所有 Do 返回。
var (
loadOnce sync.Once
config *Config
loadErr error
)
func CurrentConfig() (*Config, error) {
loadOnce.Do(func() {
config, loadErr = readConfig()
})
return config, loadErr
}Go 1.21 起还提供 sync.OnceFunc、sync.OnceValue 和 sync.OnceValues,适合把“一次计算”封装成可并发调用的函数:
var currentConfig = sync.OnceValues(func() (*Config, error) {
return readConfig()
})
func CurrentConfig() (*Config, error) {
return currentConfig()
}Once.Do 中的函数如果 panic,这次调用仍被视为已经执行,之后的 Do 不会重试。OnceFunc/OnceValue 系列则会让后续调用以同样的 panic 值再次 panic。它们都不是“失败自动重试器”。
如果初始化失败后允许重试,需要另行设计状态机、退避和并发合并;不要试图重置或复制 Once。初始化函数也不能递归调用同一个 Once.Do,否则会自锁死。
设计题:配置加载失败后希望 5 秒内复用错误,之后允许一个调用者重试,其他调用者等待。该不该用单个 Once?
Go 的竞态检测器会给内存访问和同步事件插桩,在运行时寻找没有同步排序的冲突访问。它通常会报告两次冲突访问的栈,以及相关 goroutine 的创建栈。
常用命令是:
go test -race ./...
go test -race -run TestCache -count=20 ./internal/cache
go run -race ./cmd/server
go build -race ./cmd/serverGORACE 可控制日志路径、发现首个错误后是否停止等行为,例如:
GORACE="halt_on_error=1 strip_path_prefix=$(pwd)/" go test -race ./...先找 Read by goroutine 与 Previous write(或两次 write)的源代码位置,确认它们是否访问同一逻辑状态;再看 goroutine creation stack,追踪并发从哪里产生。修复时建立真实同步边,不要用 Sleep、扩大测试超时或仅屏蔽报告。
检测器只能报告这一次执行实际走到的竞争。没覆盖到的错误路径、罕见时序和未运行的平台代码不会被发现。它也不检查死锁、业务竞态、泄漏或公平性。带 -race 的程序还会明显增加 CPU 和内存开销,因此更适合 CI、测试和有代表性的预发布流量。
实验题:给一个并发缓存测试加 -race -count=50,为什么同时还要让多个 goroutine 访问相同 key 与不同 key?
sync/atomic 提供不可分割的读、写、加法、交换和 CAS。现代代码优先使用类型化封装,如 atomic.Int64、atomic.Bool、atomic.Pointer[T] 和 atomic.Value,可减少地址与类型误用。
type Metrics struct {
requests atomic.Uint64
stopping atomic.Bool
}
func (m *Metrics) Record() {
m.requests.Add(1)
}
func (m *Metrics) Snapshot() (uint64, bool) {
return m.requests.
Go 的原子操作整体表现为某个顺序一致的次序。若原子操作 A 的效果被 B 观察到,A synchronized-before B。但这不表示两次独立 Load 自动构成一致快照:上例返回的 requests 与 stopping 可能来自不同时间点。
适合原子的典型对象是独立计数器、单一开关、单指针快照发布,以及经过严密论证的无锁算法。若操作需要同时维护余额与流水号、队列头与长度、map 与统计字段,就应优先使用锁,让不变量在一个临界区里完成。
CAS 循环还要考虑重试成本、ABA 类问题、饥饿与可读性。无锁不等于无等待,更不自动等于更快。标准库文档也建议:除少数底层场景外,优先使用 channel 或 sync 中的工具。
同一个变量不要一部分访问用 atomic,另一部分用普通读写。同步约定必须覆盖每一次访问,否则普通访问仍会与原子写形成数据竞争。
改造题:一个状态包含 value int64 与 updatedAt time.Time,读者要求两者必须对应同一次更新,应该怎么发布?
缓存的目标不只是保护 map。一个实用的并发缓存通常还希望满足:同一个 key 的昂贵计算只执行一次;等待者拿到完全初始化的相同结果;不同 key 的计算能并行;取消不会让共享状态永远卡住。
最粗糙的方案是在整个 Get 期间持有全局锁。它没有数据竞争,但慢加载会串行化所有 key。另一种方案是查 map 后立即解锁,加载完成再加锁写回;不同 key 并行了,但相同 key 可能重复加载。

下面的实现让 map 只在短临界区内访问;第一次请求创建 entry 并负责加载,后续同 key 请求等待 ready 关闭。不同 key 各自加载,不会互相占住全局锁。
package cache
import (
"context"
"sync"
)
type Loader[K comparable, V any] func(context.Context, K) (V, error)
type result[V any] struct {
value V
e.res 只有负责加载的 goroutine 写,等待者在 <-e.ready 完成后读。结果写入 sequenced-before close(e.ready),关闭 synchronized-before 等待者的接收,因此不需要再给 res 单独加锁。
上面的最小版本故意把策略留白。生产代码至少要决定:
ready 最终关闭。可以在负责加载的路径中用 defer 完成清理,或明确让进程失败,不能留下永久等待。如果项目只需要请求合并,可以评估 golang.org/x/sync/singleflight,但仍要自己决定缓存、取消和结果生命周期。sync.Map 也不是这套语义的自动替代品;它解决特定访问模式下的并发 map 操作,不自动合并昂贵计算。
健壮性题:如何避免 loader panic 后所有等待者永远阻塞?
goroutine 是 Go 运行时管理的并发执行单元,操作系统线程由内核调度。运行时把大量 goroutine 复用到较少的线程上,因此创建一个 goroutine 通常不要求创建一条新线程。

goroutine 从较小栈开始,按需要增长与收缩,这让程序可以创建远多于“每个执行单元固定分配大栈”方案的 goroutine。但 goroutine 仍有真实成本:栈、调度元数据、引用对象和可能持有的资源都会占用内存。无界地为每个输入启动 goroutine,仍可能造成内存上涨与下游过载。
理解运行时调度时,可以用 G-M-P 模型:G 是 goroutine,M 是操作系统线程,P 是执行 Go 代码所需的处理器资源。可运行的 G 需要被某个持有 P 的 M 执行。这个模型帮助我们理解“goroutine 多于线程”和工作窃取等行为,但应用不应依赖某个 G 必然在某个 M 上执行。
goroutine 在 channel、mutex、定时器或网络轮询上阻塞时,运行时可以改为运行其他 goroutine。线程陷入阻塞系统调用时,运行时也可以让 P 与其他 M 结合继续执行可运行的 Go 代码,所以阻塞线程数不受 GOMAXPROCS 同一限制。
只有必须依赖线程局部状态的系统服务或非 Go 库才应使用 runtime.LockOSThread。普通业务逻辑不应尝试获取 goroutine ID 或把状态藏在线程局部存储里;把请求相关值显式放进参数和 context.Context 更清楚。
GOMAXPROCS 限制可同时执行用户级 Go 代码的最大并行度。它不限制 goroutine 总数,也不等于进程线程总数。
当前运行时在没有显式设置时,会综合逻辑 CPU 数、CPU affinity,以及 Linux cgroup 的 CPU 吞吐配额选择默认值,并会周期性响应这些条件的变化。Go 1.25 起容器感知成为新语言版本的默认行为。若通过环境变量或 runtime.GOMAXPROCS 手动固定值,自动更新会停用;可调用 runtime.SetDefaultGOMAXPROCS 恢复默认管理。
不要把 GOMAXPROCS 当成通用的“越大越快”旋钮。CPU 密集任务应以基准和延迟目标选择;I/O 密集任务的瓶颈常在下游并发限制、连接池和队列。
实验题:写一个 CPU 密集并行基准,在 GOMAXPROCS=1,2,4,... 下比较吞吐与尾延迟。
这三类故障都可能表现为“程序不往前走”,原因却不同。
Go 的同步原语提供具体的唤醒和内存顺序语义,但不要虚构“每个等待者一定严格 FIFO”之类的通用公平保证。正确性不能依赖恰好观察到的调度顺序。
线上卡住时,先获取 goroutine dump,观察大量 goroutine 阻塞在什么栈上。未恢复 panic 的运行时栈、runtime.Stack、net/http/pprof 的 goroutine profile 都能提供入口。
接着按现象选择工具:
go tool trace 观察 goroutine 的运行、阻塞、系统调用与处理器利用。GODEBUG=schedtrace=1000,scheddetail=1 可短时查看调度状态,输出量较大,不宜长期无控制开启。排查锁死时画出“谁持有什么、还在等什么”的等待图。只要出现闭环,就要统一加锁顺序、合并锁,或重构所有权。排查活锁则关注高 CPU、CAS/重试计数和状态反复变化;排查饥饿要比较等待时间分布,而不是只看平均吞吐。
排查题:测试偶发超时,dump 显示一个 goroutine 持有配置锁并等待回调返回,回调又调用读取配置的方法。根因是什么?
面对一段并发代码,可以按这个顺序检查:
列出被多个 goroutine 触达的状态,包括指针指向的底层数组、map、缓存、统计字段和生命周期标志。不要只看变量名是否相同。
写出必须始终成立的不变量,例如“余额与流水一起提交”“entry 就绪后结果不再变化”“关闭后不接受新请求”。
为每份状态选一个所有权模型:初始化后只读、单 goroutine 所有、串行移交、Mutex/RWMutex,或适合单值的 atomic。
画出发布与读取之间的 happens-before 路径,确认所有访问遵守同一同步约定。时间延迟、日志先后和“通常先执行”都不算边。
好的并发设计读起来往往很朴素:状态归谁、谁能修改、何时发布、如何停止都能从结构上看出来。同步原语只是把这些约束落到代码里的工具。
综合编程题:并发资料缓存
实现一个 Get(ctx, key) 缓存,要求同 key 合并加载、不同 key 并行、等待者可单独取消、失败不缓存、Close 后拒绝新请求,并提供并发测试。
最后留一个判断标准:如果你无法用几句话说清共享状态的所有者、不变量和发布路径,代码通常还不够简单。先把设计收拢,再谈更细的锁优化。
检查锁的复制、获取顺序、持锁慢操作、回调重入、关闭、取消、panic 清理和 goroutine 退出路径。
用普通测试、go vet、go test -race、并行基准和 profile 分别验证功能、静态误用、数据竞争与性能。每种工具回答的问题不同。