Go 并发编程
goroutine、channel、select、sync 包、context 包、原子操作、经典并发模式与并发测试/调试完整速查。
本文件以 Go 1.27 为基线:示例统一采用现代写法(循环变量直接捕获、for range n、WaitGroup.Go 优先),涉及的新 API 都标注引入版本。GMP 调度等运行时原理见 01-核心概念.md,此处不重复。
1. goroutine
goroutine 是 Go 的用户态轻量级协程,由 Go 运行时调度,创建开销极小:
// 启动一个 goroutine
go func() {
fmt.Println("Hello from goroutine")
}()
// 带参数的 goroutine
go worker(id, jobs, results)
// 常见:HTTP 服务为每个请求自动启动一个 goroutine
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 此处已经在独立的 goroutine 中
})| 特性 | goroutine | OS 线程 |
|---|---|---|
| 初始栈大小 | ~2KB(动态增长) | ~1-8MB |
| 创建开销 | 微秒级 | 毫秒级 |
| 调度 | Go 运行时(用户态) | OS 内核 |
| 切换开销 | ~200ns | ~1-10μs |
| 并发数 | 可创建数十万个 | 通常数千 |
深入原理:goroutine 的栈按需伸缩(初始 2KB,可增长到 1GB)。GMP 调度器把大量 goroutine(G)经由逻辑处理器(P)映射到少量 OS 线程(M)上(M:N 模型),阻塞系统调用时运行时会把 M 和 P 分离以保持并发。调度模型的细节(G/M/P 关系、抢占点、handoff)见
01-核心概念.md。
用法上记住一句话:go 语句随手就能写,但每个 goroutine 必须有明确的退场路径——任务做完 return、ctx 取消后解除阻塞、channel 关闭后退出循环。后文大部分并发事故,归根到底是忘了给某个 goroutine 安排"出口"。
2. Channel(通道)
Channel 是 goroutine 间通信的类型安全管道——Go 并发哲学:“不要通过共享内存来通信,而应通过通信来共享内存。”
声明与创建
// 仅声明(nil channel)
var ch chan int
// 创建无缓冲 channel(同步通信)
ch := make(chan int)
// 创建有缓冲 channel(异步通信)
ch := make(chan int, 10) // 容量 10
// 只读 / 只写 channel
func produce(out chan<- int) { // 只能发送
out <- 42
}
func consume(in <-chan int) { // 只能接收
v := <-in
}基本操作
ch := make(chan int, 2)
ch <- 1 // 发送
ch <- 2
v := <-ch // 接收
v, ok := <-ch // 接收 + 检查是否已关闭
close(ch) // 关闭(由发送方关闭!)
// 遍历 channel(直到 close)
for v := range ch {
fmt.Println(v)
}不同类型 channel 的行为
| 操作 | nil channel | 无缓冲 channel | 有缓冲 channel | 已关闭 channel |
|---|---|---|---|---|
ch <- v |
永久阻塞 | 阻塞直到有接收方 | 未满时非阻塞;满时阻塞 | panic |
<-ch |
永久阻塞 | 阻塞直到有发送方 | 非空时非阻塞;空时阻塞 | 立即返回零值,ok=false |
close(ch) |
panic | 成功关闭 | 成功关闭 | panic |
在 select 中:nil channel 对应的 case 永远不会就绪(等价于被忽略,常用来"关掉"某个分支);已关闭 channel 的接收 case 立即就绪并拿到零值。
Warning:核心规则
- 向已关闭 channel 发送 → panic
- 关闭 nil channel → panic
- 重复关闭同一个 channel → panic
- 从已关闭 channel 接收 → 立即返回零值(不会 panic)
- 只有发送方应关闭 channel(接收方关闭可能导致发送方 panic)
无缓冲 vs 有缓冲
// 无缓冲:发送方阻塞直到接收方就绪(同步)
ch := make(chan int)
go func() { ch <- 42 }() // 阻塞,直到...
v := <-ch // ...接收方接收
// 有缓冲:发送不阻塞(缓冲未满时)
ch := make(chan int, 2)
ch <- 1 // 不阻塞
ch <- 2 // 不阻塞
// ch <- 3 // 阻塞(缓冲已满)缓冲容量是"削峰"用的,不是"任务不丢"的保证——下游不消费,上游迟早照样阻塞。生产代码里拿不准就先用无缓冲,让阻塞暴露问题。
单向 Channel
// 限制函数只能发送或只能接收——编译期保证
func producer(out chan<- int) {
out <- 42
// <-out // 编译错误!
}
func consumer(in <-chan int) {
v := <-in
// in <- 42 // 编译错误!
}
// 双向 → 单向:自动转换
ch := make(chan int)
go producer(ch) // chan int → chan<- int
go consumer(ch) // chan int → <-chan int3. select
select 是多路 channel 选择器:
select {
case v := <-ch1:
fmt.Println("from ch1:", v)
case ch2 <- value:
fmt.Println("sent to ch2")
case v, ok := <-ch3:
if !ok {
fmt.Println("ch3 closed")
}
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready")
}select 特性
| 特性 | 说明 |
|---|---|
| 公平性 | 多个 case 同时就绪 → 伪随机选择一个执行 |
| 阻塞 | 无 default 的 select 阻塞,直到某 case 就绪 |
| 非阻塞 | 有 default 的 select 不阻塞 |
| nil channel | nil channel 的 case 被忽略(用于动态启用/禁用分支) |
| 空 select | select {} → 永久阻塞 |
select 常见模式
// 超时控制
select {
case res := <-ch:
handle(res)
case <-time.After(3 * time.Second):
fmt.Println("timeout")
}
// 非阻塞发送
select {
case ch <- value:
fmt.Println("sent")
default:
fmt.Println("channel full, dropping")
}
// 非阻塞接收
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("nothing available")
}
// 退出信号
for {
select {
case v := <-dataCh:
process(v)
case <-done:
return
}
}
// Fan-in(合并多个 channel)
func fanIn(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok { ch1 = nil; continue }
out <- v
case v, ok := <-ch2:
if !ok { ch2 = nil; continue }
out <- v
}
}
}()
return out
}注意:
time.After每次调用都会创建一个新计时器。写在长期for { select { ... } }循环里的高频路径上,建议改用显式的t := time.NewTimer(d)并在用完后Stop()/Reset(),生命周期更清楚,也不依赖 GC 兜底。
4. sync 包
sync.Mutex(互斥锁)
var mu sync.Mutex
var counter int
func increment() {
mu.Lock()
defer mu.Unlock() // defer 模式(推荐)
counter++ // 临界区
}Mutex 的零值就是可用的未锁定状态,不需要初始化;但一旦使用过就不可复制(复制后行为未定义,go vet 的 copylocks 会拦,见 10.5)。
sync.RWMutex(读写锁)
var rw sync.RWMutex
var data map[string]string
func read(key string) string {
rw.RLock() // 多个 goroutine 可同时持有读锁
defer rw.RUnlock()
return data[key]
}
func write(key, value string) {
rw.Lock() // 写锁:独占,等待所有读锁释放
defer rw.Unlock()
data[key] = value
}| 操作 | 是否排他 | 可否并发持有 |
|---|---|---|
| RLock | 否(可多个同时) | 可以(多个读者) |
| Lock | 是(等待所有读/写锁释放) | 不可以(独占) |
go doc sync.RWMutex.RLock 明确了两点(本地 go1.27 实测核对):写锁排队时会挡住新来的读者(“a blocked Lock call excludes new readers from acquiring the lock”),避免写者被源源不断的读者饿死;不要递归地 RLock——读者还没释放时再次 RLock,若中间夹了排队的写者就会自锁死。
sync.WaitGroup(等待一组 goroutine 完成)
现代写法(Go 1.25 起)用 Go 方法,计数全自动:
var wg sync.WaitGroup
for i := range 5 { // 1.22+:for range n 直接迭代次数
wg.Go(func() { // 1.25+:自动 Add/Done,无需手动计数
fmt.Println("worker", i) // 1.22+:循环变量每轮独立,可直接捕获
})
}
wg.Wait() // 等待全部任务完成
fmt.Println("all done")func (wg *WaitGroup) Go(f func())——在后台启动一个 goroutine 执行 f 并把该任务登记进 WaitGroup,f 返回时自动减计数。go1.27.0 实测输出:
worker 4
worker 2
worker 3
worker 1
worker 0
wg done(worker 顺序不固定,这正是并发调度的正常表现。)
经典写法仍在存量代码里随处可见,要认得:
var wg sync.WaitGroup
for i := range 5 {
wg.Add(1) // 计数 +1(必须在 go 语句之前调用)
go func(id int) {
defer wg.Done() // 计数 -1
fmt.Println("worker", id)
}(i)
}
wg.Wait()注意:
wg.Add(1)必须在go语句之前调用。如果在 goroutine 内部调用,可能发生 race——Wait提前返回而新任务还没执行Add。Go方法从机制上消灭了这类错误:计数与启动是一个原子动作。
go doc sync.WaitGroup.Go 给出的使用约束(本地实测核对):
- f 不允许 panic(panic 会打乱计数,直接让进程崩掉更干净)。
- 计数为零时,
Go必须发生在Wait之前——通常就是"先全部 Go、最后统一 Wait"。 - 计数非零时可以在任何时刻再
Go——也就是说 Go 启动的 goroutine 内部可以继续对同一个 wg 调 Go,动态展开任务没有竞态。 - 复用同一个 WaitGroup 等待多批任务时,下一批
Go必须在上一批Wait全部返回之后。
存量代码可以一键升级:go fix(Go 1.27)内置 waitgroupgo 分析器,实测能把旧模式自动重写为 wg.Go:
// 改写前
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("legacy worker", i)
}()
}
// 执行 go fix 后(实测产物)
for i := 0; i < 5; i++ {
wg.Go(func() {
fmt.Println("legacy worker", i)
})
}注意:
wg.Go是 1.25 引入的 API。如果模块的go指令低于 1.25,go test/go vet的 stdversion 检查会直接拦下(实测报错:sync.(*WaitGroup).Go requires go1.25 or later (module is go1.24))。
sync.Once(仅执行一次)
var once sync.Once
var config *Config
func GetConfig() *Config {
once.Do(func() {
config = loadConfig() // 只会执行一次
})
return config
}两个官方文档明确的语义值得记住(go doc sync.Once.Do,本地实测核对):f 里再次调用同一个 once.Do 会死锁;f panic 后 Once 视为已完成,后续 Do 不再执行 f、也不会重试——所以别把"失败后需要重来的初始化"塞进 Once。
sync.Map(并发安全的 map)
var m sync.Map
// 写入
m.Store("key", "value")
// 读取
v, ok := m.Load("key")
// 读取或写入
actual, loaded := m.LoadOrStore("key", "default")
// 删除
m.Delete("key")
// 遍历
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // 继续遍历
})官方把 sync.Map 定位成"specialized"结构——适合读多写少、key 集合基本稳定,或各 goroutine 读写不相交 key 的场景。大多数业务代码里 普通 map + sync.RWMutex 更简单也更快,不要见到并发 map 就条件反射换 sync.Map。
sync.Pool(对象缓存池)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process(data string) {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf) // 用完后归还
buf.Reset()
buf.WriteString(data)
// ...
}注意:
sync.Pool减少内存分配和 GC 压力,适合频繁创建销毁的临时对象(bytes.Buffer、strings.Builder等)。但 Pool 中的对象可能在任意 GC 周期被清除:不能假定Put之后还能Get回同一个对象,更不能借 Pool 传递"状态必须保留"的数据。归还前记得Reset(),否则下个使用者拿到脏数据。
sync.Cond(条件变量)
var mu sync.Mutex
cond := sync.NewCond(&mu)
// 等待方
mu.Lock()
for !condition() {
cond.Wait() // 释放锁 + 等待信号 + 重新获取锁
}
mu.Unlock()
// 通知方
mu.Lock()
// 改变条件
cond.Signal() // 唤醒一个等待方
// 或 cond.Broadcast() — 唤醒所有等待方
mu.Unlock()等待条件成立这件事,channel 能覆盖大部分场景;Cond 的价值在"多个等待者共享同一个谓词"(比如生产者-消费者里等待缓冲区非空/非满)。注意 cond.Wait() 必须包在 for 循环里——虚假唤醒和被 Signal 唤醒后条件又被别人消费,都要求重新检查。
5. context 包
context 用于在 goroutine 之间传递截止时间、取消信号和请求范围的值。
创建与派生
// 根 context(永不取消)
ctx := context.Background() // 入口/初始化
ctx := context.TODO() // 不确定该用哪个时(占位符)
// 派生 context
ctx, cancel := context.WithCancel(parentCtx) // 手动取消
ctx, cancel := context.WithCancelCause(parentCtx) // 手动取消 + 携带原因(1.20+)
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) // 超时
ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(5*time.Second)) // 截止时间
ctx := context.WithValue(parentCtx, key, value) // 携带值context 使用模式
func doWork(ctx context.Context) error {
for {
select {
case <-ctx.Done(): // 收到取消/超时信号
return ctx.Err() // context.Canceled or context.DeadlineExceeded
default:
// 继续工作...
}
}
}
// 超时控制
func fetchURL(ctx context.Context, url string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 及时释放资源(重要!)
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
// ...
}context 规则
| 规则 | 说明 |
|---|---|
| 函数首参数 | ctx context.Context 始终是第一个参数 |
| 只传递不存储 | 不要把 ctx 存到 struct 中长期持有 |
| 及时 cancel | defer cancel() 释放计时器资源 |
| WithValue 谨慎 | 仅传递请求范围的数据(traceID、user token),不要传业务参数 |
| key 应为未导出类型 | 避免冲突,用 type myKey struct{} |
// context 传值的最佳实践
type contextKey struct{} // 未导出类型,保证唯一
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, contextKey{}, id)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(contextKey{}).(string)
return id, ok
}注意:
defer cancel()必须调用!超时 context 在后台挂着计时器,cancel 才释放资源- 不要把 ctx 存到 struct——它是请求范围的,不是对象状态
- context 树只能向下传递,不要反向传递(不要从子传给父)
取消原因:WithCancelCause 与 Cause(Go 1.20)
ctx.Err() 只有 Canceled 和 DeadlineExceeded 两种固定答案——它回答"取消了吗",不回答"为什么取消"。当一个 context 会被多处取消(用户主动中断、鉴权过期、上游关闭),下游却只能看到一个干巴巴的 context canceled,重试策略、告警归类都没法做。Go 1.20 的 WithCancelCause 让取消可以携带一个 error 原因:
package main
import (
"context"
"errors"
"fmt"
"time"
)
var ErrAuthExpired = errors.New("auth expired")
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
go func() { // 某个后台探测器:带原因取消
time.Sleep(10 * time.Millisecond)
cancel(ErrAuthExpired)
}()
<-ctx.Done()
fmt.Println("ctx.Err():", ctx.Err())
fmt.Println("context.Cause(ctx):", context.Cause(ctx))
fmt.Println("errors.Is(cause, ErrAuthExpired):",
errors.Is(context.Cause(ctx), ErrAuthExpired))
}go1.27.0 实测输出:
ctx.Err(): context canceled
context.Cause(ctx): auth expired
errors.Is(cause, ErrAuthExpired): true要点:
- 带因取消后
ctx.Err()仍然是context.Canceled——Cause是旁路信息,不会破坏既有代码里errors.Is(err, context.Canceled)之类的判断,可以放心渐进接入。 cancel(nil)行为等同旧的WithCancel,此时 cause 就是Canceled(go doc context.WithCancelCause)。- 超时触发的取消没走
CancelCauseFunc,此时Cause(ctx)返回与Err()相同的DeadlineExceeded;父 context 先被取消时原因也随"c 或其父的第一次取消"传递(go doc context.Cause)。 - 判断原因时拿
context.Cause(ctx)作为errors.Is/errors.As的入参。别把 ctx 本身当 error 传进去——context.Context没有实现error,errors.Is(ctx, ...)直接编译失败(实测)。
典型用法:worker 收到取消后先看一眼 context.Cause(ctx)——凭据过期就触发续期重试,用户主动取消就静默退出。
6. 并发模式
6.1 生产者-消费者
func producer(out chan<- int) {
defer close(out)
for i := range 10 { // 1.22+:for range n
out <- i
}
}
func consumer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
ch := make(chan int, 3)
go producer(ch)
consumer(ch)
}6.2 Fan-Out / Fan-In
// Fan-Out:一个输入 → 多个 worker 处理
func fanOut(in <-chan int, workers int) []<-chan int {
outputs := make([]<-chan int, workers)
for i := range workers {
out := make(chan int)
go func(ch chan int) {
defer close(ch)
for v := range in {
ch <- v * 2 // 并行处理
}
}(out)
outputs[i] = out
}
return outputs
}多个输出通道用 3.x 里的 fanIn 合并回单流,就得到完整的 Fan-Out/Fan-In 拓扑。
6.3 Pipeline(流水线)
// 串联处理:gen → square → print
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
// 组合
for n := range square(square(gen(2, 3))) {
fmt.Println(n) // 16, 81
}流水线的契约是"每一段处理完上游就负责 close 下游",任何一个环节忘记 close,整条链下游的 range 就永久阻塞(见 10.6)。
6.4 Worker Pool(协程池)
func workerPool(numWorkers int, jobs <-chan Job, results chan<- Result) {
var wg sync.WaitGroup
for range numWorkers {
wg.Go(func() { // 1.25+
for job := range jobs {
results <- process(job)
}
})
}
go func() {
wg.Wait()
close(results) // 全部 worker 退出后才关闭结果通道
}()
}要点:jobs 由调用方关闭、worker 用 range jobs 自然收敛;results 由一个"监工"goroutine 在 wg.Wait() 之后关闭,下游 range results 随之结束。谁创建谁关闭,边界清清楚楚。
6.5 限流(semaphore 模式)
// 使用有缓冲 channel 作为信号量,控制并发度
func processAll(items []Item, maxConcurrency int) {
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
for _, item := range items { // 1.22+:循环变量直接捕获即可
sem <- struct{}{} // 获取令牌(满时在这里阻塞,实现节流)
wg.Go(func() { // 1.25+
defer func() { <-sem }() // 释放令牌
process(item)
})
}
wg.Wait()
}x/sync 里的 semaphore.Weighted 是同款思想的官方封装(支持一次取多个令牌);只要并发度控制,一个有缓冲 channel 就够了。
7. 原子操作(sync/atomic)
import "sync/atomic"
var counter int64
atomic.AddInt64(&counter, 1) // 原子递增
v := atomic.LoadInt64(&counter) // 原子读取
atomic.StoreInt64(&counter, 0) // 原子写入
old := atomic.SwapInt64(&counter, 10) // 原子交换
// CAS(Compare And Swap)
if atomic.CompareAndSwapInt64(&counter, oldValue, newValue) {
// 交换成功
}
// Go 1.19+:原子类型(推荐)
var count atomic.Int64
count.Add(1)
v := count.Load()
count.Store(0)1.19 引入的类型形态(atomic.Bool、atomic.Int32/Int64、atomic.Uint64、atomic.Pointer[T] 等)把"值 + 原子性"绑成一个对象:不会有忘传 & 的疏漏,对齐问题也由类型自己兜住(旧函数族在 32 位平台上要求 64 位变量 8 字节对齐,包文档专门提示过这一点,新类型不存在这个坑)。
atomic 是 CPU 指令级操作,比锁的调度开销低得多,但只保护单个值的单步读写;一旦临界区里要"读几个值、判断、再写几个值",就必须用 sync.Mutex。sync/atomic 包文档的官方建议也是:除非低层特殊用途,优先用 channel 或 sync 设施做同步。
8. errgroup(并发错误处理)
import "golang.org/x/sync/errgroup"
// 并发执行多个任务,任一错误即取消其他
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls { // 1.22+:直接捕获 url,无需 url := url
g.Go(func() error {
return fetch(ctx, url)
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}历史注记:Go 1.22 之前这里必须写 url := url 影子变量(或把 url 作参数传进闭包),否则所有任务共享同一个循环变量、全部抓到最后一次的值。1.22 起循环变量每轮独立,这行"补丁"可以删了;老代码里残留的 url := url 也会被 go fix 的 forvar 分析器当作冗余声明清理。
errgroup.Group.Go 不是 sync.WaitGroup.Go 的换皮:它接收 func() error,第一个非 nil 错误会取消 WithContext 派生的 ctx,让其余任务尽快退出,Wait 返回第一个错误。需要"并发跑一组任务 + 收集错误 + 一起取消"时用 errgroup(注意它在 golang.org/x/sync 模块,不是标准库);只是等一帮无返回值任务收尾,用 wg.Go 就够。
9. 并发测试与调试
9.1 testing/synctest:确定性地测试并发代码
并发测试的老毛病:靠 time.Sleep 赌时序。等短了偶发失败(flaky),等长了拖垮 CI,还测不出"本该 1 小时后才发生"的超时逻辑。testing/synctest 把并发测试变成确定性的:被测代码在隔离的 “bubble”(气泡)里运行,气泡内用虚拟时钟,气泡里所有 goroutine 都持久阻塞时时钟才瞬间跳到下一个能唤醒它们的时刻——跑完"12 小时"的测试真实耗时 0 秒。
版本脉络:1.24 以 GOEXPERIMENT=synctest 试验,1.25 转正,1.27 新增 Sleep。当前公开 API 就三个(实测 go doc testing/synctest):
func Test(t *testing.T, f func(*testing.T)) // 在气泡里运行测试函数
func Wait() // 阻塞直到气泡内其他 goroutine 全部持久阻塞
func Sleep(d time.Duration) // 1.27 新增:Sleep 与 Wait 的结合注意:很多早期资料(1.24 实验期文章)把入口写作
synctest.Run——go1.27 里它不存在,实测go vet直接报undefined: synctest.Run。写测试时以go doc testing/synctest为准。
气泡内虚拟时钟起点是 2000-01-01 00:00 UTC。哪些操作算"持久阻塞"(durable blocking)决定了时钟能不能前进(以下规则均摘自官方包文档):
- 计入:对气泡内创建的 channel 做阻塞收发/全阻塞分支的 select、
sync.Cond.Wait、time.Sleep、WaitGroup.Wait(Add 在气泡内发生时)。 - 不计入:锁 Mutex/RWMutex、真实网络/磁盘 IO、系统调用——它们可能被气泡外的事件唤醒。所以气泡里不要用真实网络(goroutine 卡在 socket 上时钟永远不前进),用
net.Pipe这类假连接;Go 1.27 新增的httptest.NewTestServer(t testing.TB, handler http.Handler) *Server默认就提供内存假网络,配合 synctest 测 HTTP 客户端代码很顺。
实测示例(go test -v 真实输出原样粘贴):
func TestVirtualClock(t *testing.T) {
realStart := time.Now()
synctest.Test(t, func(t *testing.T) {
start := time.Now()
fmt.Println("bubble 起点:", start.UTC())
ping := make(chan time.Time, 1)
stop := make(chan struct{})
go func() {
defer close(ping)
for {
ping <- time.Now()
select {
case <-stop:
return
default:
}
time.Sleep(time.Hour) // 气泡内的 Sleep,只推进虚拟时钟
}
}()
for range 3 {
<-ping
fmt.Println("收到 ping,虚拟流逝:", time.Since(start))
}
close(stop)
synctest.Wait() // 等其他 goroutine 全部收尾
t0 := time.Now()
synctest.Sleep(10 * time.Hour) // 1.27 新增
fmt.Println("Sleep(10h) 返回,time.Since(t0):", time.Since(t0))
fmt.Println("虚拟总流逝:", time.Since(start))
})
fmt.Println("真实总耗时:", time.Since(realStart)) // 气泡外才是真实时间
}=== RUN TestVirtualClock
bubble 起点: 2000-01-01 00:00:00 +0000 UTC
收到 ping,虚拟流逝: 0s
收到 ping,虚拟流逝: 1h0m0s
收到 ping,虚拟流逝: 2h0m0s
Sleep(10h) 返回,time.Since(t0): 10h0m0s
虚拟总流逝: 12h0m0s
真实总耗时: 0s
--- PASS: TestVirtualClock (0.00s)
PASS
ok scratch08 0.554s注意气泡内 time.Since 拿到的就是虚拟流逝(Sleep(10h) 的 10h0m0s 一行),而整条测试真实耗时为 0s——12 个小时的业务时间在 0.6 秒内"演"完了。
synctest.Wait() 的另一面是死锁即时暴露。没有它,死锁要等 go test 的 10 分钟超时才暴露;有了它,全员持久阻塞的瞬间就报错:
func TestWaitDetectsDeadlock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan int)
go func() { ch <- 1 }() // 无人接收,durable 阻塞
synctest.Wait() // 气泡内没人能唤醒它 → 死锁
})
}实测输出(testing 框架的中间栈帧略去):
--- FAIL: TestWaitDetectsDeadlock (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
...
goroutine 11 [chan send (durable), synctest bubble 1]:
scratch08.TestWaitDetectsDeadlock.func1.1()
C:/.../syn_test.go:49 +0x1e报错直接点名了是哪个 goroutine 卡在哪一行——比线上等超时再翻 dump 高效得多。
注意:气泡的隔离性有若干硬约束(均见官方包文档):气泡内创建的 channel/timer 拿到气泡外用会 panic;
WaitGroup在首次 Add/Go 时与气泡绑定,包级变量var wg sync.WaitGroup无法与气泡关联(需要时在测试里用局部变量或new(sync.WaitGroup));气泡根 goroutine(即 f)退出后时间就停止推进,后台泄漏的 goroutine 会导致 Test 报错——这恰好也是免费的泄漏检查。
9.2 goroutine 泄漏检测
泄漏的典型来源和前文一以贯之:无缓冲发送无人接收、range 一个永不 close 的 channel、select 不写 <-ctx.Done() 分支、第三方调用挂死不返回。检测手段从粗到细:
1. runtime.NumGoroutine()——监控大盘的哨兵。 只能看数量不能看是谁:
runtime.NumGoroutine() // 当前 goroutine 总数,适合周期性打点/告警数量只涨不落,就该去捞栈了。
2. goroutineleak profile——Go 1.27 转正的专用画像。 1.26 还是实验性 profile,1.27 正式可用。它只报告疑似泄漏的 goroutine,而不是全量栈。程序内实测:
package main
import (
"fmt"
"os"
"time"
_ "net/http/pprof"
"runtime/pprof"
)
func main() {
leakCh := make(chan int) // 无缓冲,无人接收
go func() { leakCh <- 1 }() // 泄漏:永久阻塞在发送上
time.Sleep(50 * time.Millisecond)
p := pprof.Lookup("goroutineleak")
fmt.Println(`pprof.Lookup("goroutineleak") != nil:`, p != nil)
p.WriteTo(os.Stderr, 1) // 文本形式输出
}go1.27.0 实测输出(profile 里恰好只有那一个泄漏 goroutine,main.go:44 即阻塞发送那一行):
pprof.Lookup("goroutineleak") != nil: true
goroutineleak profile: total 1
1 @ 0x7ff64ec66b4a 0x7ff64ebf42fc 0x7ff64ebf3ef7 0x7ff64ed88f3e 0x7ff64ec6d3e1
# 0x7ff64ed88f3d main.main.func3+0x1d C:/.../scratch-08/main.go:44服务模式(导入 _ "net/http/pprof")下走标准端点 /debug/pprof/goroutineleak(实测存在),?debug=1 直接看文本,或用 go tool pprof http://127.0.0.1:6060/debug/pprof/goroutineleak 交互分析。顺带一提:模块 go 指令 ≥1.27 时,goroutine traceback 头部默认带上 pprof labels(GODEBUG tracebacklabels,1.26 引入)——给每类 goroutine 打 label(如 pprof.Do(ctx, pprof.Labels("job", "reconcile"), ...)),泄漏报告里一眼定位来源。
3. goleak——第三方,测试收尾断言。 go.uber.org/goleak,在 TestMain 里兜底(goleak.VerifyTestMain(m)),主测试结束后还有不该活的 goroutine 就让测试失败。库生态成熟、与 9.1 的 synctest 互补:synctest 保证"测试本身确定性",goleak 保证"测试没留下泄漏"。
防,永远重于查:所有会阻塞的操作收 ctx、channel 谁发送谁关闭、worker 循环必带 case <-ctx.Done()。
10. 常见陷阱
10.1 Goroutine 泄漏
// 错误:goroutine 永远阻塞在 channel 发送上
ch := make(chan int)
go func() { ch <- 42 }() // 没有接收方,泄漏!
// 正确方向:有缓冲兜底、接收方配对,或带上 ctx 退出分支10.2 向已关闭的 channel 发送
close(ch)
ch <- 1 // panic: send on closed channel10.3 WaitGroup 复制
// 错误:WaitGroup 是值类型,传参会复制
func worker(wg sync.WaitGroup) { // 复制!Done() 不影响原始的
defer wg.Done()
}
// 正确:传指针
func worker(wg *sync.WaitGroup) {
defer wg.Done()
}10.4 循环变量捕获(1.22 前史)
// Go 1.22 起这段就是正确的:每轮迭代是独立的 i
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }() // 输出 0、1、2(顺序随机)
}历史注记:1.21 及之前,三个 goroutine 共享同一个 i,往往全打印 2;当时的两种修法——循环体首行 i := i 影子捕获,或 go f(i) 传参。Go 1.22 起循环变量每轮独立,上述补丁都不再需要(新写的代码直接用现代形态即可,别照抄 2022 年前的 StackOverflow 答案)。注意"修复"的是捕获语义,不是泄漏:如果 goroutine 活过了循环本身、之后还在读 i,循环结束后值不会再更新,这仍然要靠 ctx 或 channel 编排生命周期。
10.5 Mutex 不可复制
var mu sync.Mutex
mu2 := mu // 错误:不能复制(go vet copylocks 会报)
// Mutex 包含内部状态,复制会导致死锁或无效锁
// 结构体含 Mutex 时,应始终通过指针传递10.6 Channel 未关闭导致 range 泄漏
ch := make(chan int)
go func() {
for v := range ch { ... } // 永久阻塞直到 ch 被 close
}()
// 如果忘记 close(ch),该 goroutine 永远阻塞10.7 在持有锁时调用可能再获取锁的函数
mu.Lock()
updateCounter() // 如果 updateCounter 也 Lock → 死锁!
mu.Unlock()
// 正确:让 updateCounter 假设锁已持有(命名上区分,如 lockHeld 后缀),或拆分函数11. 实战建议
- 选同步原语的顺序:能用 channel 传所有权就用 channel;纯保护一段临界区就上
sync.Mutex;读远多于写才换RWMutex;单个计数器/标志位用atomic类型;sync.Map/sync.Pool只在官方定位的场景里用。拿不准时,Mutex 几乎永远不会错。 - 每个 goroutine 都要有退场路径:任务驱动型靠 channel + 发送方 close;服务驱动型靠 ctx 取消;所有会阻塞的 select 必带
<-ctx.Done()分支。上线服务全程传 ctx,取消原因用WithCancelCause+context.Cause(1.20)区分,重试和告警策略才有依据。 - 拥抱现代 API:启动一组任务用
wg.Go(1.25)替代 Add/Done 手工计数;存量代码go fix(1.27 的 waitgroupgo/forvar/atomictypes 等分析器)批量现代化;循环里捕获变量不再需要x := x、迭代次数用for range n(均 1.22 起)。用新 API 前确认模块go指令版本够——不够时 stdversion 检查会报requires go1.2X or later(实测),这是特性不是 bug。 - 测试三件套:CI 上永远开
go test -race;时序敏感逻辑用testing/synctest写成确定性测试(1.25,注意早期资料的synctest.Run已不存在);TestMain里挂goleak.VerifyTestMain兜底泄漏。 - 生产监控:
runtime.NumGoroutine打点告警抓趋势,/debug/pprof/goroutineleak(1.27 转正)定位元凶,goroutine 打 pprof labels 让 traceback 可读。 - 更多跨主题陷阱与上线检查清单见
11-最佳实践与陷阱.md;调度器原理与 GOMAXPROCS 调优见01-核心概念.md。