Skip to content
Go
Go 并发编程

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:核心规则

  1. 向已关闭 channel 发送 → panic
  2. 关闭 nil channel → panic
  3. 重复关闭同一个 channel → panic
  4. 从已关闭 channel 接收 → 立即返回零值(不会 panic)
  5. 只有发送方应关闭 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 int

3. 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 channel

10.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。