三行代码就能卡住你的 Go 服务——不可见的并发阻塞模式

三种 Go runtime 不会报告但线上常见的隐蔽阻塞模式:RWMutex writer-preference 导致的递归读阻塞、channel send 接收方退出后的永久阻塞、context 链断裂。从 goroutine dump 信号反推根因的排查框架。

封面

先看三段代码。每段看起来都很正常——没有循环等待,没有互相持有锁不放,没有任何会导致 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 不报错"


但在开始之前,先说清楚一件事: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()
    // 全量刷新缓存
}

看起来没问题?那假设同时发生了三件事:

  1. ReaderA 调用 GetData,拿到了 RLock,正在做慢查询(比如缓存 miss 后的回源)
  2. RefreshCache 被定时器触发,尝试获取 Lock——因为 ReaderA 没释放,它只能等
  3. 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。看到这个信号,第一反应就是"有人在等写锁"。

RWMutex 三角阻塞环——ReaderA 持有 RLock → Writer 等待 Lock → ReaderB-R 被 writer-preference 阻塞

单 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 递归读阻塞只有在以下时序才会触发:

  1. ReaderA 先拿到 RLock
  2. Writer 在 ReaderA 释放前请求 Lock
  3. 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 优先的读写锁"。这个区别,决定了你写的代码在生产环境会不会卡住。

RWMutex 代码对比——左侧问题代码(RLock 路径上触发 Lock),右侧修复代码(锁分解/超时兜底)


二、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 send 阻塞流程对比——上方正常流程(Worker 接收→处理→完成),下方异常流程(Worker 退出→发送方阻塞→请求积压)

我跑了无缓冲 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。

Channel send 修复代码对比——左侧问题代码(直接 ch <- i),右侧修复代码(select 监听 ctx.Done())


三、Context:你以为传了 ctx,其实断了

前两种模式依赖特定的并发时序才能触发——多个 goroutine 同时操作同一个锁或 channel。但第三种模式不一样:即使串行执行,也可能出问题。

第三个模式是我认为最隐蔽的。Context 在 Go 里无处不在,几乎所有函数签名都以 ctx context.Context 开头。但正因为太普遍了,开发者很容易产生一个错觉:“只要把 ctx 传进去了,就自动能感知取消。”

这是错的。

链式传播 vs 链式断裂

Context 的取消是链式传播的:父 context 取消 → 所有继承自它的子 context 都取消。但前提是——子 goroutine 必须显式监听 ctx.Done()

看这段代码:

Context 传播链示意图——parent → child → grandchild,标注断裂点(grandchild 使用 context.Background())

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.AfterFunccontext.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 后,按以下顺序定位:

  1. 看 goroutine 的状态列sync.RWMutex.RLock → 模式一;chan send → 模式二;chan receive → 模式三
  2. 看堆栈中的函数名:卡在 Lock() / RLock() → RWMutex 问题;卡在 ch <- → channel 问题;卡在 context.Background().Done() → context 问题
  3. 看有多少 goroutine 卡在同一个位置:批量卡住 → 可能是模式一(RWMutex 连锁阻塞)或模式二(多个发送方阻塞在同一个 channel),需结合 goroutine 状态列进一步区分;单个 goroutine 卡住 → 检查设计问题(模式二或三)

排查时的两条经验

第一条:不要只拉一次 dump。 阻塞可能是瞬时的,也可能是永久的。如果只拉一次 dump,看到几个 goroutine 卡在 chan send 上,你没法判断它们是"刚好在发送"还是"已经卡了五分钟"。隔 10 秒再拉一次,看同样的 goroutine 是不是还在同一个位置。如果是,那就是永久阻塞。

第二条:从最异常的 goroutine 开始看。 dump 里可能有几百个 goroutine,其中大部分在正常工作(状态是 runningsleep)。不要从头看起——直接搜 sync.RWMutex.RLockchan sendchan receive (nil chan) 这些关键词。最异常的 goroutine 通常就是根因所在。

三条核心原则

  1. RWMutex 不是"可并发读的 Mutex",是"writer 优先的读写锁"。 不要在持有 RLock 的路径上触发 Lock。
  2. Channel send 之前,先确认接收方还在。 errgroup 是最优雅的解法——统一管理 goroutine 生命周期。
  3. Context 传递了不等于监听了。 每个子 goroutine 都必须显式监听 ctx.Done(),且永远不要用 context.Background().Done()

最后一点:排查阻塞不是在修代码,是在修你对并发原语的心智模型。 这些模式之所以"隐蔽",不是代码写得有多巧妙——是你的心智模型和实际行为之间有偏差。每次拉 dump 定位到根因,都是在校准这个偏差。

举个具体的例子:你写 mu.RLock() 的时候,心里想的是"多个 goroutine 可以同时读"还是"只有当没有 writer 在等的时候才能读"?这两个想法在大多数场景下等价,但在 writer 等待的那个时间窗口里截然不同。你能写出 bug 不是因为不懂 RWMutex 的 API,是因为你的心智模型是第一种,而 RWMutex 的行为是第二种。

但真正的防线不是工具——是理解这三种模式为什么"看起来没问题"。下次你拉 goroutine dump 看到一批卡住的 goroutine,应该能认出它们在等什么。理解这三种模式,比记住一百条排查清单更有用。

三模式速查对照表——阻塞模式 × dump 信号 × 根因 × 修复的完整矩阵


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →