第 9 章

Goroutine:轻量级并发

Goroutine:轻量级并发

如果说 Go 的错误处理体现了它的工程哲学,那么 goroutine 就是它的技术灵魂。Go 语言的并发原语——goroutine 和 channel——不是后来加上去的库特性,而是从语言设计之初就内建的核心机制。Rob Pike 在 2012 年的演讲 "Concurrency is not Parallelism" 中说:

"Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once."

翻译:并发是关于同时处理很多事情。并行是关于同时很多事情。

这个区分至关重要。Go 的设计目标不是让程序运行得更快(那是并行的事),而是让程序更好地组织并发逻辑(这是并发的事)。goroutine 是实现这个目标的基础构件。

本章将深入 goroutine 的机制:从基础使用到泄漏预防,从调度模型预览到并发控制模式。

Level 1:你需要知道的

什么是 Goroutine

Goroutine 是 Go 运行时管理的轻量级执行单元。从程序员的角度看,它就是一个并发执行的函数:

func main() {
    go sayHello() // 启动一个新的 goroutine
    time.Sleep(time.Second) // 等待(粗糙方式)
}

func sayHello() {
    fmt.Println("Hello from goroutine!")
}

go 关键字是唯一的语法——把它放在函数调用前面,该函数就在新的 goroutine 中并发执行。

核心特性:

  1. 极轻量:初始栈只有 2KB(对比:OS 线程通常 1-8MB)
  2. 动态栈:栈空间不够时自动扩展,不需要时自动收缩
  3. 用户态调度:由 Go 运行时调度,不依赖 OS 线程 1:1 映射
  4. 创建成本极低:创建一个 goroutine 约 ~0.3μs,创建一个 OS 线程约 ~10μs

启动 Goroutine

基本用法

// 启动具名函数
go processRequest(req)

// 启动匿名函数
go func() {
    fmt.Println("anonymous goroutine")
}()

// 启动带参数的匿名函数
go func(msg string) {
    fmt.Println(msg)
}("hello")

注意最后的 () —— go 后面跟的是函数调用表达式,不只是函数名。

常见陷阱:循环变量捕获

// 错误(Go 1.22 之前):所有 goroutine 共享循环变量 i
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i) // 可能全部打印 5
    }()
}

// 正确:通过参数传值
for i := 0; i < 5; i++ {
    go func(n int) {
        fmt.Println(n) // 打印 0,1,2,3,4(顺序不确定)
    }(i)
}

好消息:从 Go 1.22 开始,循环变量默认每次迭代创建新副本,这个陷阱不再存在。但对于维护旧代码仍需了解。

main goroutine 退出即全部终止

func main() {
    go func() {
        time.Sleep(time.Second)
        fmt.Println("done") // 永远不会执行
    }()
    // main 返回,程序结束,所有 goroutine 被强制终止
}

Go 程序的生命周期与 main goroutine 绑定。main 函数返回时,所有其他 goroutine 立即被终止,不会执行 defer,不会有清理机会。

等待 Goroutine 完成

sync.WaitGroup

最常用的等待机制:

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // 计数器 +1
        go func(id int) {
            defer wg.Done() // 计数器 -1
            fmt.Printf("goroutine %d done\n", id)
        }(i)
    }

    wg.Wait() // 阻塞直到计数器归零
    fmt.Println("all done")
}

WaitGroup 的三个方法:

关键规则Add() 必须在对应的 go 语句之前调用,否则存在竞态——Wait() 可能在 Add() 之前返回。

Channel 同步

func main() {
    done := make(chan struct{})

    go func() {
        // 做一些工作...
        fmt.Println("work complete")
        close(done) // 通知完成
    }()

    <-done // 阻塞直到 channel 被关闭
    fmt.Println("main exits")
}

context.Context 控制生命周期

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    go worker(ctx)

    <-ctx.Done()
    fmt.Println("timeout, shutting down")
}

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            fmt.Println("worker: context cancelled")
            return
        default:
            // 执行工作...
            time.Sleep(500 * time.Millisecond)
            fmt.Println("worker: tick")
        }
    }
}

Goroutine 的轻量特性

为什么说 goroutine 是"轻量"的?具体数字:

属性 Goroutine OS 线程
初始栈大小 2 KB 1-8 MB
创建时间 ~0.3 μs ~10 μs
上下文切换 ~100 ns (用户态) ~1-10 μs (内核态)
内存占用 ~4 KB (含运行时元数据) ~1 MB+
最大数量 数百万 数千

这意味着你可以放心地创建数十万个 goroutine,而这对 OS 线程来说是不可能的。以下代码完全合法:

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1_000_000; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            time.Sleep(time.Second)
        }(i)
    }
    wg.Wait()
}

100 万个 goroutine 大约消耗 4GB 内存(每个 ~4KB)——在现代服务器上完全可行。100 万个 OS 线程需要 1TB+ 内存——根本不可能。

常见错误与修复

错误 1:忘记等待 goroutine

// 错误:main 可能在 goroutine 完成前退出
func main() {
    go doWork()
}

// 正确:使用 WaitGroup 或 channel 等待
func main() {
    done := make(chan struct{})
    go func() {
        doWork()
        close(done)
    }()
    <-done
}

错误 2:在 goroutine 中使用 t.Fatal

func TestSomething(t *testing.T) {
    go func() {
        if err := doSomething(); err != nil {
            t.Fatal(err) // panic! t.Fatal 必须在测试 goroutine 中调用
        }
    }()
}

// 正确:通过 channel 传递错误
func TestSomething(t *testing.T) {
    errCh := make(chan error, 1)
    go func() {
        errCh <- doSomething()
    }()
    if err := <-errCh; err != nil {
        t.Fatal(err)
    }
}

错误 3:无限制地启动 goroutine

// 错误:可能导致 OOM(Out of Memory)
func handleRequests(requests []Request) {
    for _, req := range requests {
        go process(req) // 如果 requests 有百万个?
    }
}

// 正确:使用 worker pool 限制并发
func handleRequests(requests []Request) {
    sem := make(chan struct{}, 100) // 最多 100 个并发
    var wg sync.WaitGroup

    for _, req := range requests {
        wg.Add(1)
        sem <- struct{}{} // 获取令牌
        go func(r Request) {
            defer wg.Done()
            defer func() { <-sem }() // 释放令牌
            process(r)
        }(req)
    }
    wg.Wait()
}

Level 2:它是怎么运行的

Goroutine 的内部表示

在 Go 运行时中,每个 goroutine 用一个 runtime.g 结构体表示(定义在 runtime/runtime2.go 中):

// 简化版 runtime.g 结构体
type g struct {
    stack       stack   // goroutine 栈空间描述
    stackguard0 uintptr // 栈溢出检查哨兵值
    m           *m      // 当前绑定的 M (OS 线程)
    sched       gobuf   // 调度上下文(SP、PC、BP 等寄存器)
    atomicstatus uint32 // goroutine 状态
    goid         int64  // goroutine ID
    gopc         uintptr // 创建此 goroutine 的 go 语句的 PC
    // ... 还有更多字段
}

type gobuf struct {
    sp   uintptr // 栈指针
    pc   uintptr // 程序计数器
    g    guintptr
    ret  uintptr
    bp   uintptr // 帧指针(用于栈回溯)
}

Goroutine 状态机

一个 goroutine 在其生命周期中经历以下状态:

_Gidle → _Grunnable → _Grunning → _Gsyscall / _Gwaiting → _Gdead

动态栈(Stack Growth)

Goroutine 的栈是动态增长的。初始只有 2KB(在 runtime/stack.go 中定义的 _StackMin = 2048),但可以按需增长到 1GB(默认最大值,可通过 runtime/debug.SetMaxStack 修改)。

栈增长机制

Go 编译器在每个函数入口插入栈检查序言(stack check prologue)

// 编译器生成的伪汇编
TEXT ·someFunction(SB), NOSPLIT, $frameSize
    MOVQ (TLS), CX         // 获取当前 g
    CMPQ SP, 16(CX)        // 比较 SP 和 g.stackguard0
    JLS  morestack          // 如果 SP < stackguard0,需要扩栈
    // 正常函数体...
    RET
morestack:
    CALL runtime·morestack_noctxt(SB)
    JMP  ·someFunction(SB)  // 扩栈后重新执行函数

当检测到栈空间不足时:

  1. 分配一个更大的栈(通常是当前的 2 倍)
  2. 将旧栈的内容复制到新栈
  3. 调整所有指向旧栈的指针(这就是为什么不能对 goroutine 栈上的地址取指针传给 cgo)
  4. 释放旧栈

这种机制称为 连续栈(contiguous stack)——Go 1.4 引入,替代了之前的分段栈(segmented stack)。分段栈有"热分裂(hot split)"问题:当函数调用恰好在栈边界来回跳动时,会反复分配和释放栈段,导致性能抖动。

栈收缩

栈不只会增长,也会收缩。当 GC 发现一个 goroutine 的栈使用率低于 25% 时,会在下次调度时将其栈缩小一半。这避免了长时间运行的 goroutine 占用不必要的内存。

Goroutine 泄漏

Goroutine 泄漏是 Go 程序中最常见的资源泄漏形式。泄漏的 goroutine 永远不会结束,持续占用内存。

原因 1:Channel 未关闭/未读取

// 泄漏:发送方永远阻塞
func leak1() {
    ch := make(chan int)
    go func() {
        val := doExpensiveWork()
        ch <- val // 如果没人读取 ch,这里永远阻塞
    }()
    // 函数返回,ch 不再可达,但 goroutine 还在等待发送
}

// 修复:使用 buffered channel 或 select + context
func fixed1(ctx context.Context) {
    ch := make(chan int, 1) // buffered:即使没人读也不阻塞
    go func() {
        val := doExpensiveWork()
        select {
        case ch <- val:
        case <-ctx.Done():
            return
        }
    }()
}
// 泄漏:接收方永远阻塞
func leak2() {
    ch := make(chan int)
    go func() {
        for val := range ch { // ch 永远不会被关闭
            process(val)
        }
    }()
    // 发送方停止发送但不 close(ch)
}

// 修复:确保发送方关闭 channel
func fixed2() {
    ch := make(chan int)
    go func() {
        for val := range ch {
            process(val)
        }
    }()
    // 发送完毕后关闭
    for _, item := range items {
        ch <- item
    }
    close(ch) // 通知接收方结束
}

原因 2:Context 未取消

// 泄漏:context 永远不会被取消
func leak3() {
    ctx := context.Background() // 永不过期
    go worker(ctx) // worker 永远运行
}

// 修复:使用可取消的 context
func fixed3() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 函数退出时取消 context

    go worker(ctx)
    // ... 做一些工作 ...
} // cancel() 在这里执行,worker 收到取消信号后退出

原因 3:死锁

// 泄漏:互相等待
func leak4() {
    ch1 := make(chan int)
    ch2 := make(chan int)

    go func() {
        val := <-ch1  // 等待 ch1
        ch2 <- val    // 然后发送到 ch2
    }()

    go func() {
        val := <-ch2  // 等待 ch2
        ch1 <- val    // 然后发送到 ch1
    }()
    // 两个 goroutine 互相等待,永远不会完成
}

检测 Goroutine 泄漏

方法 1:runtime.NumGoroutine()

func TestNoLeak(t *testing.T) {
    before := runtime.NumGoroutine()

    // 执行被测试代码
    doSomething()

    time.Sleep(100 * time.Millisecond) // 等待 goroutine 结束
    after := runtime.NumGoroutine()

    if after > before {
        t.Errorf("goroutine leak: before=%d, after=%d", before, after)
    }
}

方法 2:goleak 库(Uber 开源)

import "go.uber.org/goleak"

func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}

func TestSomething(t *testing.T) {
    defer goleak.VerifyNone(t)
    // 测试代码...
}

方法 3:pprof goroutine profile

import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()
    // 访问 http://localhost:6060/debug/pprof/goroutine?debug=2
    // 查看所有 goroutine 的调用栈
}

Goroutine 调度模型预览(GMP 模型)

Go 的调度器使用 GMP 模型(详细内容在第 14 章),这里先做概览:

┌─────────────────────────────────────────────┐
│              全局运行队列 (GRQ)                │
│  [G5] [G6] [G7] ...                         │
└─────────────────────────────────────────────┘
        ↓ 窃取                    ↓ 窃取
┌──────────────┐           ┌──────────────┐
│     P0       │           │     P1       │
│ 本地队列:     │           │ 本地队列:     │
│ [G1][G2][G3] │           │ [G4]         │
│              │           │              │
│   ↓ 执行     │           │   ↓ 执行     │
│   M0         │           │   M1         │
│ (OS Thread)  │           │ (OS Thread)  │
│ 当前: G1     │           │ 当前: G4     │
└──────────────┘           └──────────────┘

关键设计决策:

  1. P 的数量默认等于 CPU 核数GOMAXPROCS),这决定了最大并行度
  2. 每个 P 有本地队列,减少锁竞争(本地队列操作无锁)
  3. Work Stealing:当 P 的本地队列为空时,从其他 P 或全局队列窃取 G
  4. 系统调用时 M 脱离 P:当 goroutine 进入系统调用时,M 阻塞在内核中,P 可以绑定到新的 M 继续执行其他 G

P 的数量通过 runtime.GOMAXPROCS(n) 设置:

import "runtime"

func main() {
    // 设置使用 4 个逻辑处理器
    runtime.GOMAXPROCS(4)

    // 查看当前设置
    fmt.Println(runtime.GOMAXPROCS(0)) // 0 表示查询不修改
    fmt.Println(runtime.NumCPU())       // CPU 核数
}

协作式 vs 抢占式调度

Go 1.14 之前,goroutine 调度是协作式的——goroutine 只在特定点让出执行权:

这导致一个问题——没有函数调用的死循环会独占 M:

// Go 1.14 之前:这个 goroutine 永远不会被抢占
go func() {
    for {
        // 紧密循环,没有函数调用
        // 其他 goroutine 被饿死
    }
}()

Go 1.14 引入了异步抢占(asynchronous preemption)——运行时通过向 M 发送 SIGURG 信号实现抢占,即使 goroutine 在紧密循环中也能被中断。

Level 3:规范怎么定义的

并发 vs 并行(Rob Pike 2012)

Rob Pike 在 2012 年 Heroku 的 Waza 大会上做了经典演讲 "Concurrency is not Parallelism"。这个演讲定义了 Go 社区对并发的理解:

并发(Concurrency)

并行(Parallelism)

类比:一个人同时管理多个邮件窗口是并发——实际只有一双手(一个 CPU),但能处理多件事。两个人同时各写一封信是并行——实际有两双手(两个 CPU)同时做。

Go 的 goroutine 首先是并发工具——它们帮你把程序组织成独立的、可组合的执行单元。并行只是并发的一个可能结果——当 GOMAXPROCS > 1 时,多个 goroutine 可能同时在不同 CPU 上执行。

Rob Pike 的核心观点:

"Concurrency is not parallelism, although it enables parallelism. If you have only one processor, your program can still be concurrent but it cannot be parallel."

翻译:并发不是并行,尽管并发使并行成为可能。如果你只有一个处理器,你的程序可以是并发的,但不能是并行的。

Go 语言规范中对 goroutine 的定义

Go 语言规范(The Go Programming Language Specification)对 goroutine 的说明:

A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space.

关键词:

规范还指出:

The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete.

即:函数值和参数在调用方 goroutine 中求值,但不等待函数完成。这解释了为什么下面的代码是安全的:

x := computeValue()
go process(x) // x 在 go 语句执行前已经求值完成

CSP 模型(Communicating Sequential Processes)

Go 的并发模型深受 CSP(Communicating Sequential Processes)的影响。CSP 由 Tony Hoare 在 1978 年的论文中提出(后来在 1985 年发展为专著)。

CSP 的核心思想:

  1. 进程是基本的并发单元(对应 Go 的 goroutine)
  2. 进程之间不共享内存,通过消息传递通信(对应 Go 的 channel)
  3. 通信是同步的——发送方和接收方必须同时就绪(对应 Go 的无缓冲 channel)

Go 谚语(来自 Effective Go):

"Do not communicate by sharing memory; instead, share memory by communicating."

翻译:不要通过共享内存来通信;相反,通过通信来共享内存。

这不是说 Go 禁止共享内存——sync.Mutex 仍然存在且广泛使用。但 Go 鼓励的默认思维方式是:用 channel 传递数据所有权,而不是用锁保护共享数据。

历史脉络:从 CSP 到 Go

1978: Tony Hoare — CSP 原始论文
  ↓
1985: Tony Hoare — CSP 专著
  ↓
1980s-90s: occam 语言(Transputer 并行处理器的编程语言)
  ↓
1995: Rob Pike & Dennis Ritchie — Plan 9 的 alef 语言
  ↓
2000: Rob Pike — Limbo 语言(Inferno OS)
  ↓
2007: Rob Pike, Ken Thompson, Robert Griesemer — 开始设计 Go
  ↓
2009: Go 开源,goroutine + channel 作为核心特性

Rob Pike 在 alef(1995)和 Limbo(2000)中都实验过类似 goroutine 的并发原语。Go 的 goroutine 和 channel 是这些前身的成熟版本。

Goroutine 的规范保证

Go 语言规范对 goroutine 做出以下保证:

  1. 创建顺序go f() 语句的效果——创建新 goroutine——happens before 新 goroutine 的执行开始
  2. 无序执行:多个 goroutine 的执行顺序未定义,除非通过同步原语建立 happens-before 关系
  3. 非抢占保证:规范不保证 goroutine 在任何特定点让出执行权(但实践中 Go 1.14+ 有异步抢占)
  4. 内存可见性:goroutine 之间的数据可见性遵循 Go Memory Model(syncatomic、channel 操作建立 happens-before)

Level 4:边界与陷阱

Goroutine 和线程的区别(面试高频题)

维度 Goroutine OS 线程
创建者 Go 运行时 操作系统内核
调度 Go 调度器(用户态) OS 调度器(内核态)
栈大小 2KB 初始,动态增长 固定 1-8MB
切换开销 ~100ns(保存/恢复少量寄存器) ~1-10μs(陷入内核、TLB 刷新)
创建开销 ~0.3μs ~10μs(系统调用)
通信方式 Channel(首选)、共享内存 共享内存 + 锁、信号量
身份标识 无暴露的 goroutine ID 有线程 ID
数量级 百万级 千级
抢占 协作式 + 异步抢占(Go 1.14+) 时间片抢占

为什么 Go 不暴露 goroutine ID?

Go 刻意隐藏了 goroutine ID,虽然内部有 goid 字段。原因(Andrew Gerrand 在 Go Blog 中解释):

  1. 防止 goroutine-local storage 反模式:如果有 goroutine ID,开发者会创建类似线程局部存储(TLS)的东西,破坏 Go 的并发模型
  2. 鼓励显式传参:需要传递的值应该通过函数参数或 context.Context 显式传递
  3. 避免调试依赖:goroutine 应该是匿名的、可替换的

如果确实需要追踪:

// 不推荐但可行:从 runtime stack 中提取 goroutine ID
func getGoroutineID() uint64 {
    var buf [64]byte
    n := runtime.Stack(buf[:], false)
    // "goroutine 123 [...]" 格式
    id, _ := strconv.ParseUint(strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0], 10, 64)
    return id
}

如何控制 Goroutine 数量

方法 1:Semaphore 模式(Channel 作为令牌桶)

func processItems(items []Item, maxConcurrency int) {
    sem := make(chan struct{}, maxConcurrency)
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{} // 获取令牌,如果满则阻塞

        go func(it Item) {
            defer wg.Done()
            defer func() { <-sem }() // 完成后释放令牌
            process(it)
        }(item)
    }
    wg.Wait()
}

方法 2:Worker Pool 模式

func workerPool(jobs <-chan Job, results chan<- Result, numWorkers int) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                results <- process(job)
            }
        }()
    }
    wg.Wait()
    close(results)
}

// 使用
func main() {
    jobs := make(chan Job, 100)
    results := make(chan Result, 100)

    go workerPool(jobs, results, 10) // 10 个 worker

    // 发送任务
    for _, j := range allJobs {
        jobs <- j
    }
    close(jobs)

    // 收集结果
    for r := range results {
        handleResult(r)
    }
}

方法 3:errgroup(推荐)

golang.org/x/sync/errgroup 包提供了带错误传播和并发限制的 goroutine 管理:

import "golang.org/x/sync/errgroup"

func processAll(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // 最多 10 个并发 goroutine

    for _, item := range items {
        item := item // Go 1.22 前需要
        g.Go(func() error {
            return processItem(ctx, item)
        })
    }

    return g.Wait() // 返回第一个错误(如果有)
}

errgroup 的优势:

面试题解析

题目 1:以下代码输出什么?

func main() {
    runtime.GOMAXPROCS(1)
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            fmt.Print(n)
        }(i)
    }
    wg.Wait()
}

答案:输出 0-4 的某个排列(如 40132),顺序不确定。即使 GOMAXPROCS=1(只有一个 P),goroutine 的执行顺序仍然不保证——它们被放入运行队列的顺序和被调度执行的顺序可能不同。

题目 2:如何优雅地停止一个 goroutine?

// 方案 A:done channel
func worker(done <-chan struct{}) {
    for {
        select {
        case <-done:
            return
        default:
            doWork()
        }
    }
}

// 方案 B:context(推荐)
func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            doWork()
        }
    }
}

答案:推荐使用 context。原因:

  1. context 可以携带取消原因(context.Cause
  2. context 可以设置超时(WithTimeout
  3. context 可以在整个调用链中传递
  4. context 是 Go 标准的取消信号传播机制

题目 3:goroutine 泄漏的典型表现是什么?如何诊断?

答案

题目 4:100 万个 goroutine 各 sleep 1 秒,需要多少内存?需要多少时间?

答案

真实案例

案例 1:Cloudflare 的 goroutine 泄漏事故(2019)

Cloudflare 的一个 DNS 服务出现内存泄漏。排查发现是由于一个 HTTP 客户端在收到错误响应后没有关闭 response body:

// 泄漏代码
resp, err := http.Get(url)
if err != nil {
    return err
}
if resp.StatusCode != 200 {
    return fmt.Errorf("bad status: %d", resp.StatusCode)
    // 忘记 resp.Body.Close()!
    // HTTP transport 中的读取 goroutine 永远等待 body 被读完
}

修复:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close() // 始终关闭 body
// 即使不读 body,也要 drain 它
io.Copy(io.Discard, resp.Body)

案例 2:Go 运行时的 SIGURG 抢占引发的 Bug

Go 1.14 引入异步抢占时使用 SIGURG 信号。但某些使用 signal.Notify(ch, syscall.SIGURG) 的程序开始收到意外信号。Go 团队在 1.14.1 中修复了这个问题——运行时发送的 SIGURG 不再传递给用户注册的信号处理器。

案例 3:goroutine 数量控制不当导致数据库连接池耗尽

// 问题代码:每个请求启动 goroutine 查数据库,无并发限制
func handler(w http.ResponseWriter, r *http.Request) {
    var results []Result
    var mu sync.Mutex

    for _, id := range getIDs(r) {
        go func(id int) {
            // 数据库连接池只有 100 个连接
            // 如果同时有 1000 个请求,每个查 10 个 ID = 10000 并发查询
            result, _ := db.Query("SELECT * FROM items WHERE id = ?", id)
            mu.Lock()
            results = append(results, result)
            mu.Unlock()
        }(id)
    }
}

修复:使用 errgroup + SetLimit:

func handler(w http.ResponseWriter, r *http.Request) {
    g, ctx := errgroup.WithContext(r.Context())
    g.SetLimit(10) // 每个请求最多 10 个并发查询

    var results []Result
    var mu sync.Mutex

    for _, id := range getIDs(r) {
        id := id
        g.Go(func() error {
            result, err := db.QueryContext(ctx, "SELECT * FROM items WHERE id = ?", id)
            if err != nil {
                return err
            }
            mu.Lock()
            results = append(results, result)
            mu.Unlock()
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    // 使用 results...
}

Goroutine 最佳实践总结

  1. 始终确保 goroutine 有退出路径——使用 context 或 done channel
  2. 用 errgroup 管理一组 goroutine——比手动 WaitGroup + error channel 更简洁安全
  3. 控制并发数量——无限制地启动 goroutine 会导致资源耗尽
  4. 不要依赖 goroutine 的执行顺序——即使 GOMAXPROCS=1
  5. 每个 goroutine 应该有 recover——至少长期运行的 goroutine 需要
  6. 优先使用 channel 通信——而不是共享内存 + 锁
  7. 测试中检测 goroutine 泄漏——使用 goleak 或手动检查 NumGoroutine
  8. HTTP response body 始终关闭——即使不读取 body 内容
  9. 长时间运行的 goroutine 记得定期检查 context——不要让 goroutine 在 context 取消后继续做无用功
  10. GOMAXPROCS 通常不需要手动设置——默认值(CPU 核数)在大多数场景下最优
本章评分
4.7  / 5  (47 评分)

💬 留言讨论