
先看三段代码。每段看起来都很正常——没有循环等待,没有互相持有锁不放,没有任何会导致 Go runtime 报错的东西。但每一段都能让你的服务在线上"活着但不工作"。
导读:三种 Go runtime 不会报告的隐蔽阻塞模式——RWMutex 递归读、channel send 接收方退出、context 链断裂。
代码一:
var mu sync.RWMutex
mu.RLock() // ReaderA 拿到读锁
go func() {
mu.Lock() // Writer 等写锁,blocked
mu.Unlock()
}()
time.Sleep(time.Millisecond)
mu.RLock() // ReaderB 等读锁——writer-preference 阻止它
mu.RUnlock()
问题在哪?没有死锁(没有循环等待),但第三行的 mu.RLock() 会永久阻塞。服务进程活着,健康检查通过——但这条 goroutine 卡死了。
代码二:
ch := make(chan string)
go func() {
ch <- "msg1"
ch <- "msg2" // 如果接收方提前退出,这里永久阻塞
}()
<-ch // 收到 msg1
// 接收方退出了——发送方还在等 msg2 的接收方
time.Sleep(time.Second) // 给发送方时间卡住
同样的问题:没有循环等待,没有资源竞争——就是一个 goroutine 发了条消息到 channel,但没人收了。
代码三:
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-context.Background().Done() // 永远等不到!
}()
cancel()
context 取消了,但子 goroutine 监听的不是同一个 context——它监听的是 context.Background(),永远不会关闭。
三段代码,三种模式。每一段都不到 20 行。每一段都不会触发 Go runtime 的死锁检测。但每一段在生产环境都会让你的服务"活着但不工作"。

但在开始之前,先说清楚一件事:Go runtime 只检测一种死锁——全局死锁。所谓全局死锁,就是所有 goroutine 都 asleep 了,没有任何一个能继续执行。这时 runtime 会 panic:
fatal error: all goroutines are asleep - deadlock!
但以我的经验,我在生产环境几乎没见过。真正让服务卡住的,从来不是全局死锁——而是局部阻塞。进程还活着,goroutine dump 里有几百个 goroutine 全部卡在不同的锁和 channel 上。Go runtime 不报错,因为有些 goroutine 还在正常工作(比如健康检查的 handler),不满足"所有 goroutine 都 asleep"的条件。
下面要讲的三种模式,都属于这种情况。
这些阻塞之所以"隐蔽",还有一个原因:单 goroutine 测试不会触发它们。你在本地写一个简单的 go test 跑一遍,所有代码都能通过——因为阻塞需要特定的并发时序才会暴露。开发环境只有几个 goroutine,生产环境有成百上千个请求同时进来,时序条件一下就满足了。这也是为什么这种问题总是在线上、在凌晨、在高峰期出现。
一、RWMutex:你以为的"读锁"不是读锁
RWMutex 是 Go 里最容易被误解的并发原语之一。很多开发者把它当成"性能更好的 Mutex"——允许多个 goroutine 同时读,写的时候独占。这个理解没错,但漏了最关键的一条:writer-preference。
writer-preference 是什么
Go 的 RWMutex 是 writer-preference(写者优先)设计。这意味着:当有 writer 在等待写锁时,新的 reader 不能获取读锁。
这不是 bug,是设计。Go 官方文档 sync#RWMutex 明确写了:如果一个 goroutine 持有了读锁,另一个 goroutine 又在等写锁——第三方的 goroutine 就别想拿到读锁了。这是为了防止 writer 被无限期饿死。
但问题也在这里。
Go 的 RWMutex 从 1.8 版本开始确立了 writer-preference 设计——当有 writer 在等锁时,新 reader 会被阻塞。这个设计的 trade-off 很清楚:writer 不会被饿死,但 reader 可能会被连锁阻塞。相比之下,Java 的 ReentrantReadWriteLock 选择了 reader-preference——reader 不会被 writer 阻塞,但 writer 可能被无限期饿死。Go 选择了反方向:writer 优先,reader 承担阻塞风险。在大多数场景下这是合理的——写操作通常不会太频繁,reader 稍微等一下就好了。但问题出在递归读上:如果你的代码在持有 RLock 的路径上触发了需要 Lock 的操作,那 reader 就不是"等一下"的问题了,是"永远等不到"。
递归读触发链
来看一个真实的调用链:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) GetData(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) RefreshCache() {
c.mu.Lock()
defer c.mu.Unlock()
// 全量刷新缓存
}
看起来没问题?那假设同时发生了三件事:
- ReaderA 调用
GetData,拿到了 RLock,正在做慢查询(比如缓存 miss 后的回源) - RefreshCache 被定时器触发,尝试获取 Lock——因为 ReaderA 没释放,它只能等
- ReaderB ~ ReaderN 继续调用
GetData——但 writer-preference 阻止它们获取 RLock
结果:ReaderB 到 ReaderN 全部阻塞。服务还活着(健康检查不需要锁),但所有读请求都超时了。
我跑了一段代码验证这个触发链。用 goroutine dump 看,阻塞信号非常清晰:
goroutine 7 [sync.RWMutex.Lock]: ← RefreshCache 在等写锁
sync.(*RWMutex).Lock(...)
/usr/local/go/src/sync/rwmutex.go:155
goroutine 33 [sync.RWMutex.RLock]: ← ReaderB 被阻塞
goroutine 34 [sync.RWMutex.RLock]: ← ReaderC 被阻塞
goroutine 35 [sync.RWMutex.RLock]: ← ReaderD 被阻塞
...
关键信号:大批 goroutine 卡在 sync.RWMutex.RLock。看到这个信号,第一反应就是"有人在等写锁"。

单 goroutine 测试的陷阱
还有一个容易忽视的陷阱:你在本地用单 goroutine 跑这段代码是测不出来的。
func TestCache_GetData(t *testing.T) {
cache := NewCache()
val, ok := cache.GetData("key1") // 单 goroutine,没有并发,一切正常
assert.True(t, ok)
assert.Equal(t, "value1", val)
}
单 goroutine 测试覆盖的是"有没有 bug",不是"有没有并发问题"。RWMutex 递归读阻塞只有在以下时序才会触发:
- ReaderA 先拿到 RLock
- Writer 在 ReaderA 释放前请求 Lock
- ReaderB 在 Writer 排队时请求 RLock
你写一百个单 goroutine 单元测试都测不出这个时序问题。这也是为什么这种 bug 几乎总是线上发现——本地测试环境不会同时有几百个请求在跑。我在生产环境见过类似的案例:一个缓存服务在低峰期一切正常,高峰期突然大量读请求超时,拉 dump 发现几十个 goroutine 卡在 sync.RWMutex.RLock 上,根因就是一个耗时的回源操作在持有读锁时触发了写锁。
修复方案
方案一:命名约定。不要在持有 RLock 的路径上触发需要写锁的操作。
// 坏味道:GetData 的调用链里有 RefreshCache 的触发点
func (c *Cache) GetData(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if _, ok := c.data[key]; !ok {
c.RefreshCache() // ❌ 持有读锁时请求写锁
}
return c.data[key], true
}
方案二:锁分解。如果读写频率差异大,考虑拆成两把锁——一把保护读操作,一把保护写操作。但需要注意:拆锁后读和写之间的原子性丧失——你无法保证"读到的数据是完整的"(读到一半写操作改了另一部分)。如果业务上能接受最终一致性,锁分解是可行的方案;如果需要强一致性,不要拆锁。
type Cache struct {
readMu sync.RWMutex // 保护读路径
writeMu sync.Mutex // 保护写操作(包括锁升级路径)
data map[string]string
}
方案三:超时兜底。让被阻塞的 reader 能超时退出而不是无限等待。注意要用带 buffer 的 channel 避免 goroutine leak:
func (c *Cache) GetDataWithTimeout(ctx context.Context, key string) (string, bool) {
done := make(chan struct{}, 1) // buffer=1 防止 goroutine leak
var val string
var ok bool
go func() {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok = c.data[key]
select {
case done <- struct{}{}:
default:
}
}()
select {
case <-done:
return val, ok
case <-ctx.Done():
return "", false
}
}
关键区别:done := make(chan struct{}, 1) + 非阻塞发送。即使主 goroutine 已经超时返回,子 goroutine 在获取到锁后也能安全退出,不会泄漏。
但超时只是兜底,不是根治。真正的问题是:你对 RWMutex 的心智模型错了。 RWMutex 不是"多个可以同时读的 Mutex",而是"writer 优先的读写锁"。这个区别,决定了你写的代码在生产环境会不会卡住。

二、Channel send:你发了,但没人收
如果说 RWMutex 的问题来自于"对原语理解不够",那 channel send 阻塞的问题更隐蔽——它来自于"你以为代码执行顺序和你想的一样"。
最典型的场景
Channel(通道)——Go 中 goroutine 之间通信的主要方式。一个 goroutine 把数据放进 channel,另一个 goroutine 从 channel 取出。如果有缓冲(buffered channel),发送方可以发多条再等接收方;如果没有缓冲(unbuffered channel),发送必须等接收方取走。
当接收方退出后,发送方继续发送——就是这里出问题。
func workerPool() {
tasks := make(chan int, 3) // buffer 只有 3——比总任务数少,必然会满
var wg sync.WaitGroup
process := func(task int) {
time.Sleep(50 * time.Millisecond)
}
// 启动 3 个 worker
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for task := range tasks {
if task == 5 { // 模拟第 6 个任务出错
return // worker 直接退出
}
process(task)
}
}(i)
}
// 发送 10 个任务
for i := 0; i < 10; i++ {
tasks <- i // buffer=3,发送第 4 条时就需要等 worker 消费
// 如果有 worker 退出,buffer 满后永久阻塞
}
close(tasks)
wg.Wait()
}
这段代码乍看也无懈可击。3 个 worker,10 个任务,用 channel 分发。但如果有 worker 因为错误退出了,且 tasks 的 buffer 满了——发送方就会永久阻塞在 tasks <- i 这一行。
这里有一个关键区别:有缓冲 channel 和无缓冲 channel 的阻塞条件不同。 有缓冲 channel 只在 buffer 满了之后才阻塞发送方;无缓冲 channel 则是每次发送都必须等接收方。所以你可能会想:“那把 buffer 设大一点不就好了?”
但 buffer 只是推迟问题,不是解决问题。buffer 满了之后一样会阻塞。而且更大的 buffer 意味着更长的延迟才能发现问题——因为你的服务可能正常跑了几小时甚至几天,直到某个时刻 buffer 恰好满了,阻塞才突然发生。这就是所谓的"时序敏感性问题"——问题一直存在,只是触发条件还没到。
为什么 Go 不自动处理?
这不是 Go 的缺陷——channel 本来就是通信机制,不是生命周期管理工具。发送方把消息放进 channel,它的职责就结束了。至于有没有人收,那是设计层面的事,不是语言层面的事。
但正因如此,这个问题最容易被忽视。Go 不报错,pprof 不会自动弹窗报警,goroutine dump 里只会显示一行:
goroutine 6 [chan send]:
main.workerPool.func2()
/app/main.go:25 +0x88
看到 chan send 信号时,第一件事是去查对应 channel 的接收方还在不在。

我跑了无缓冲 channel 的版本做验证,dump 输出更清晰:
goroutine 3 [chan send]:
main.main.func2()
/Users/.../chan_send_block_unbuffered.go:38 +0xe8
没有报错,没有警告——就是一行 [chan send],表示这个 goroutine 在向 channel 发送数据时卡住了。
修复方案
方案一:errgroup + context 传播
g, ctx := errgroup.WithContext(context.Background())
// 发送方监听 ctx.Done()
go func() {
defer close(tasks) // 无论正常结束还是出错,都要关闭 channel
for i := 0; i < 10; i++ {
select {
case tasks <- i:
case <-ctx.Done():
return // 有 worker 出错,退出发送
}
}
}()
方案二:select 超时
for i := 0; i < 10; i++ {
select {
case tasks <- i:
case <-time.After(5 * time.Second):
log.Println("发送任务超时,worker 可能已退出")
return
}
}
注意:time.After 在 select 中如果超时未触发(即 tasks <- i 成功),定时器会继续跑直到到期。高频调用下可能产生大量未回收的定时器,建议在循环外使用 time.NewTimer 并主动 Stop。
两种方案都可以。我个人偏好 errgroup——它不仅解决了"接收方退出"的问题,还把错误传播和 goroutine 生命周期管理统一了。
这个例子里你的心智模型是"发送方发了,接收方就会收"。大部分时候这个模型成立——但接收方可能因为各种原因提前退出。你的心智模型和实际行为之间的偏差就是那个"看起来没问题"的 goroutine。

三、Context:你以为传了 ctx,其实断了
前两种模式依赖特定的并发时序才能触发——多个 goroutine 同时操作同一个锁或 channel。但第三种模式不一样:即使串行执行,也可能出问题。
第三个模式是我认为最隐蔽的。Context 在 Go 里无处不在,几乎所有函数签名都以 ctx context.Context 开头。但正因为太普遍了,开发者很容易产生一个错觉:“只要把 ctx 传进去了,就自动能感知取消。”
这是错的。
链式传播 vs 链式断裂
Context 的取消是链式传播的:父 context 取消 → 所有继承自它的子 context 都取消。但前提是——子 goroutine 必须显式监听 ctx.Done()。
看这段代码:

func handleRequest(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// 子 goroutine 1:正确做法
go func() {
<-ctx.Done()
log.Println("子 goroutine 1 退出")
}()
// 子 goroutine 2:错误做法
go func() {
<-context.Background().Done() // 永远等不到!
log.Println("这行永远不会执行")
}()
// 子 goroutine 3:另一个错误做法
go func() {
for {
doWork() // 从不检查 ctx
}
}()
}
这段代码里:
- 子 goroutine 1 监听
ctx.Done()→ 父 context 取消时正常退出 - 子 goroutine 2 监听
context.Background().Done()→ 永远不会返回,因为 Background() 返回的 context 永远不会被取消 - 子 goroutine 3 干脆没监听 → 死循环
我跑了这个例子的 goroutine dump,可以看到:
goroutine 8 [chan receive (nil chan)]:
/Users/.../context_chain_break.go:55 +0x24
信号是 chan receive (nil chan)——context.Background().Done() 返回的就是 nil channel。nil channel 在 select 中永远不会触发 case,所以这个 goroutine 永远等不到关闭信号。值得注意的是,nil channel receive 是一个更通用的 goroutine dump 信号——任何从 nil channel 做 receive 的 goroutine 都会显示这个状态,context 链断裂只是其中最常见的一种场景。
为什么 Go 要把 Background().Done() 设计成 nil channel?答案是:nil channel 在 select 中永远不会触发 case,从而保证通过 Background() 派生的 goroutine 永远不被取消——这是一个有意的设计决策,不是疏忽。
为什么会出现这种 bug?
最常见的场景是重构留下的痕迹:一开始函数没有 context 参数,子 goroutine 用了 context.Background(),后来加了 context 但子 goroutine 忘了改;或者子 goroutine 里又启动了一个 goroutine,新 goroutine 没传 ctx。
// 重构前
func oldHandler() {
go func() {
<-context.Background().Done() // 当时没问题
}()
}
// 重构后加了 context,但忘了改子 goroutine
func newHandler(ctx context.Context) {
go func() {
<-context.Background().Done() // 还是没改!→ 链断裂
}()
}
这种 bug 在 code review 里几乎不可能被看到——谁会专门去查 Done() 是从哪个 context 来的?
注:Go 1.22+ 引入了
context.AfterFunc和context.Cause等新 API,提供了更灵活的 context 取消监听方式。但核心原则不变——子 goroutine 必须感知父 context 的取消信号。
还有一个常见的变体:在 for 循环里忘记加 ctx.Done() 检查。
for {
result, err := doWork()
if err != nil {
log.Printf("error: %v", err)
continue // 继续循环,从不检查 ctx
}
process(result)
}
这段代码看似合理——出错就重试。但如果没有 ctx.Done() 检查,context 取消后它永远不会退出。doWork 可能返回一个 error 就继续,无限循环下去。
修复方案
// 正确做法:传递 ctx,select 监听 ctx.Done()
func handleRequest(ctx context.Context) {
go func() {
select {
case <-ctx.Done():
// 正常退出
case result := <-doWork():
// 处理结果
}
}()
}
一个简单的原则:如果子 goroutine 里出现了 context.Background(),几乎总是 bug。 除非你有非常明确的理由(比如一个真正独立于请求生命周期的后台任务),否则永远从父 context 继承。
这个例子里你的心智模型是"把 ctx 传进去了,goroutine 就知道了"。但 context 不是魔法——它只是一个 channel,你必须显式监听它。你的心智模型和实际行为之间的偏差,就是那行永远不会执行的日志。
结尾:从 dump 信号到根因——你的排查框架
上面讲了三种模式,现在把它浓缩成一张对照表。下次线上出问题时,直接查:
| 阻塞模式 | dump 信号 | 根因 | 修复方案 |
|---|---|---|---|
| RWMutex 递归读阻塞 | sync.RWMutex.RLock / sync.RWMutex.Lock |
writer-preference + 递归调用 | 命名约定 / 锁分解 / 超时兜底 |
| Channel send 阻塞 | chan send |
接收方退出后发送方继续发送 | errgroup + ctx.Done() / select 超时 |
| Context 链断裂 | chan receive (nil chan) |
子 goroutine 未监听 ctx.Done() | 传递 ctx + select 多路复用 |
排查决策树
拉 goroutine dump 后,按以下顺序定位:
- 看 goroutine 的状态列:
sync.RWMutex.RLock→ 模式一;chan send→ 模式二;chan receive→ 模式三 - 看堆栈中的函数名:卡在
Lock()/RLock()→ RWMutex 问题;卡在ch <-→ channel 问题;卡在context.Background().Done()→ context 问题 - 看有多少 goroutine 卡在同一个位置:批量卡住 → 可能是模式一(RWMutex 连锁阻塞)或模式二(多个发送方阻塞在同一个 channel),需结合 goroutine 状态列进一步区分;单个 goroutine 卡住 → 检查设计问题(模式二或三)
排查时的两条经验
第一条:不要只拉一次 dump。 阻塞可能是瞬时的,也可能是永久的。如果只拉一次 dump,看到几个 goroutine 卡在 chan send 上,你没法判断它们是"刚好在发送"还是"已经卡了五分钟"。隔 10 秒再拉一次,看同样的 goroutine 是不是还在同一个位置。如果是,那就是永久阻塞。
第二条:从最异常的 goroutine 开始看。 dump 里可能有几百个 goroutine,其中大部分在正常工作(状态是 running 或 sleep)。不要从头看起——直接搜 sync.RWMutex.RLock、chan send、chan receive (nil chan) 这些关键词。最异常的 goroutine 通常就是根因所在。
三条核心原则
- RWMutex 不是"可并发读的 Mutex",是"writer 优先的读写锁"。 不要在持有 RLock 的路径上触发 Lock。
- Channel send 之前,先确认接收方还在。 errgroup 是最优雅的解法——统一管理 goroutine 生命周期。
- Context 传递了不等于监听了。 每个子 goroutine 都必须显式监听 ctx.Done(),且永远不要用
context.Background().Done()。
最后一点:排查阻塞不是在修代码,是在修你对并发原语的心智模型。 这些模式之所以"隐蔽",不是代码写得有多巧妙——是你的心智模型和实际行为之间有偏差。每次拉 dump 定位到根因,都是在校准这个偏差。
举个具体的例子:你写 mu.RLock() 的时候,心里想的是"多个 goroutine 可以同时读"还是"只有当没有 writer 在等的时候才能读"?这两个想法在大多数场景下等价,但在 writer 等待的那个时间窗口里截然不同。你能写出 bug 不是因为不懂 RWMutex 的 API,是因为你的心智模型是第一种,而 RWMutex 的行为是第二种。
但真正的防线不是工具——是理解这三种模式为什么"看起来没问题"。下次你拉 goroutine dump 看到一批卡住的 goroutine,应该能认出它们在等什么。理解这三种模式,比记住一百条排查清单更有用。

原文发布于 止语Lab