关于 go-concurrency-review
面向 Go 并发代码实现、审查和故障排查的技能,提供生命周期、同步、通道与上下文指引,并附有深入参考资料。
主要功能
- 01实现、差异审查与泄漏排查模式
- 02检查 Goroutine 生命周期和退出路径
- 03通道、互斥锁与原子操作指引
- 04检查上下文传递和共享状态
- 05要求使用竞态检测器测试
适用场景
- 01审查 Go 代码变更中的并发风险
- 02排查 Goroutine 泄漏或数据竞态
- 03审计通道所有权与关闭路径
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-concurrency-review安装后在 Claude Code 中输入/go-concurrency-review确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/channels.mdgoroutine-patterns.mdmutex-and-atomics.md详细介绍
Go 并发审查
Go 并发功能强大,但很容易在不知不觉中出错。 这些模式可以防止 goroutine 泄漏、数据竞争和死锁。
详细参考资料,按需加载:
references/channels.md— 容量设置、信号通知、生产者关闭。references/mutex-and-atomics.md— 互斥锁位置、锁的作用范围、原子操作、sync.Once、RWMutex 常见问题、sync.Map、sync.Pool、singleflight。references/goroutine-patterns.md— goroutine 边界处的 panic 恢复、流水线、有界扇出、定时器。
只有在下文内容不足时才阅读参考文件。
工作模式
开始前,选择与请求相符的模式:
- 实现 — 编写新的并发代码。按照下方模式编写代码。
- 差异审查(默认)— 根据每个部分检查变更的代码,特别留意新增的
go语句和共享状态。 - 泄漏/竞态排查 — 已观察到异常现象(goroutine 数量增长、
-race报告、死锁)。从“审查大型代码库”和竞态检测部分开始,定位问题。在 Go 1.27+ 中,先阅读goroutineleakprofile:它会列出阻塞在任何操作都无法解除的 goroutine。排查流程由 go-troubleshooting skill 负责。
审查大型代码库
进行完整的并发审查时,分别执行以下检查,而不是按顺序通读代码:
- Goroutine 生命周期: 找出每一条
go语句 (grep -rn "go func\|go [a-zA-Z]" --include="*.go"),并确认每条语句都有终止路径(context、已关闭的 channel、WaitGroup)。 - 共享状态: 找出被多个 goroutine 访问的包级变量和结构体字段;确认它们受到互斥锁/原子操作保护。
- Channel 拓扑: 梳理每个 channel 的生产者/消费者;确认只关闭一次,且不存在向已关闭 channel 发送的路径。
- Context 传播: 确认阻塞调用接受并遵循
context.Context。
如果你的环境支持将工作委派给并行子 Agent 或任务,则为每项检查分配一个;否则按顺序执行。审查结果必须注明 file.go:line。最后务必运行 go test -race ./...。
选择原语
| 需求 | 使用 | 原因 |
|---|---|---|
| 在一个 goroutine 与另一个 goroutine 之间传递数据 | Channel | 发送操作会转移所有权 |
| 保护共享结构体的字段 | sync.Mutex / RWMutex | 临界区短,所有权明确 |
| 计数器或标志 | atomic.Int64、atomic.Bool | 无需锁,不会发生撕裂读取 |
| 计算一次,多次读取 | sync.OnceValue | 即使存在竞争也只计算一次 |
| 合并并发的相同调用 | singleflight.Group | 每个键只调用一次后端,而不是 N 次 |
| 在热点路径上复用短生命周期缓冲区 | sync.Pool | 减少分配——仅在分析 profile 后使用 |
| 等待 goroutine | 使用 |
|---|---|
| 结果不会出错 | sync.WaitGroup(wg.Go,Go 1.25+) |
| 关注第一个错误 | errgroup.Group |
| 第一个错误会取消其余任务 | errgroup.WithContext |
| 限制同时运行的数量 | errgroup + SetLimit(n)——不要手动编写工作池 |
1. Goroutine 生命周期管理
每个 goroutine 都必须有明确的终止路径。禁止启动后不管。
使用 errgroup 协调 goroutine:
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return fetchUsers(ctx)
})
g.Go(func() error {
return fetchOrders(ctx)
})
if err := g.Wait(); err != nil {
return fmt.Errorf("fetch data: %w", err)
}
长时间运行的 goroutine 必须遵循 context:
func (w *Worker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job := <-w.jobs:
if err := w.process(job); err != nil {
w.logger.Error("process job", slog.Any("error", err))
}
}
}
}
在所有者处启动 goroutine,而不是在被调用方处:
// ✅ Good — caller controls lifecycle
go worker.Run(ctx)
// ❌ Bad — function secretly starts goroutine
func NewWorker() *Worker {
w := &Worker{}
go w.run() // hidden goroutine — caller has no control
return w
}
在 goroutine 边界处恢复 panic
任何 goroutine 中的 panic 都会终止整个进程。HTTP 中间件中的 recover 只覆盖请求 goroutine,不覆盖它启动的 goroutine。运行依赖输入或第三方代码的工作线程和后台任务,应在自身的延迟函数中恢复 panic、记录堆栈,并将 panic 转换为错误。不要通过恢复来掩盖自己代码中的 bug——应在崩溃会连带影响无关工作的地方进行恢复。
示例:references/goroutine-patterns.md。
2. 通道模式
-
缓冲大小为 0 或 1。无缓冲通道是同步点;缓冲大小为 1 则用于交接。任何更大的缓冲区都需要注释说明选择该大小的理由——随意写个
100,等到生产环境比预发布环境更慢时就会成为一个 bug。 -
信号通道使用
struct{},而close(done)会同时向所有接收方广播。 -
由生产者拥有通道,并且只有生产者负责关闭通道,在生产 goroutine 中使用
defer close(ch)。每次发送都必须放在与ctx.Done()对比的select中,否则消费者一旦退出,就会导致生产者泄漏。 -
流水线:每个阶段关闭自己的输出;使用
errgroup.SetLimit限制扇出;单 goroutine 链应使用iter.Seq,而不是通道。
示例见 references/channels.md 和 references/goroutine-patterns.md。
3. 互斥锁和原子操作
- 零值
sync.Mutex和sync.RWMutex可直接使用。*sync.Mutex字段总是不对的。 - 将互斥锁直接声明在其保护的字段上方,并用注释说明它们之间的关系。声称互斥锁保护“整个结构体”,却没有明确保护对象,等于什么也没保护。
- 临界区应尽可能小——持锁时绝不要调用外部服务,也不要再获取第二把锁。
- 🔴 绝不要复制包含互斥锁的值(
c2 := *c1):副本会带有原锁的状态。go vet能捕获其中大多数问题;相信它。 - 计数器和标志应使用
sync/atomic类型,而不是用互斥锁保护int64。 - 对必须且仅需执行一次的延迟初始化,使用
sync.Once。 RWMutex不能升级锁,也不能递归读锁;sync.Map只适用于两类工作负载;sync.Pool中的对象必须重置并限制大小;singleflight使用第一个调用者的上下文——应将其分离。详见references/mutex-and-atomics.md。
示例见 references/mutex-and-atomics.md。
4. 上下文传播
上下文始终是第一个参数,绝不能是结构体字段;每个阻塞操作都必须监听 ctx.Done():
// ✅ Good
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return nil, ctx.Err()
}
// ❌ Bad — blocks forever if the context is cancelled
result := <-ch
每次外部调用都应派生一个带有独立超时的子上下文,并立即执行 defer cancel()。完整规则见 go-context 技能。
5. 避免可变全局变量
// ❌ Bad — mutable global, not safe for concurrent access
var db *sql.DB
// ✅ Good — pass as dependency
type Server struct {
db *sql.DB
}
竞态检测
CI 期间始终使用竞态检测器运行测试:
go test -race ./...
这一点不容妥协。测试套件在没有 -race 的情况下通过,无法证明并发正确性。
危险信号检查清单
- 🔴 启动 goroutine 时没有关闭路径
- 🔴 通道从未关闭(可能导致 goroutine 泄漏)
- 🔴 按值复制互斥锁
- 🔴 将上下文存储在结构体字段中
- 🔴 有可用的父上下文,却使用
context.Background() - 🔴 阻塞操作中的
select缺少ctx.Done()分支 - 🔴 共享的 map/slice 未经同步就被访问
- 🟡 缓冲通道的大小随意设得很大
- 🟡 使用
time.Sleep进行同步,而不是使用恰当的信号通知 - 🟡 在
init()或构造函数中启动 goroutine,却没有生命周期控制 - 🟡 后台 goroutine 运行依赖输入的代码,却没有
recover