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 中并发执行。
核心特性:
- 极轻量:初始栈只有 2KB(对比:OS 线程通常 1-8MB)
- 动态栈:栈空间不够时自动扩展,不需要时自动收缩
- 用户态调度:由 Go 运行时调度,不依赖 OS 线程 1:1 映射
- 创建成本极低:创建一个 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(delta int):增加计数器(通常在启动 goroutine 前调用)Done():减少计数器(等价于Add(-1),通常用 defer)Wait():阻塞直到计数器为 0
关键规则: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
- _Gidle:刚分配,未初始化
- _Grunnable:在运行队列中等待执行
- _Grunning:正在某个 M 上执行
- _Gsyscall:正在执行系统调用(M 被阻塞)
- _Gwaiting:被阻塞在某个操作上(channel、锁、timer 等)
- _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) // 扩栈后重新执行函数
当检测到栈空间不足时:
- 分配一个更大的栈(通常是当前的 2 倍)
- 将旧栈的内容复制到新栈
- 调整所有指向旧栈的指针(这就是为什么不能对 goroutine 栈上的地址取指针传给 cgo)
- 释放旧栈
这种机制称为 连续栈(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 章),这里先做概览:
- G (Goroutine):goroutine 本身,包含栈、PC、状态等
- M (Machine):OS 线程,执行 goroutine 代码的载体
- P (Processor):逻辑处理器,持有本地运行队列和运行 G 需要的资源
┌─────────────────────────────────────────────┐
│ 全局运行队列 (GRQ) │
│ [G5] [G6] [G7] ... │
└─────────────────────────────────────────────┘
↓ 窃取 ↓ 窃取
┌──────────────┐ ┌──────────────┐
│ P0 │ │ P1 │
│ 本地队列: │ │ 本地队列: │
│ [G1][G2][G3] │ │ [G4] │
│ │ │ │
│ ↓ 执行 │ │ ↓ 执行 │
│ M0 │ │ M1 │
│ (OS Thread) │ │ (OS Thread) │
│ 当前: G1 │ │ 当前: G4 │
└──────────────┘ └──────────────┘
关键设计决策:
- P 的数量默认等于 CPU 核数(
GOMAXPROCS),这决定了最大并行度 - 每个 P 有本地队列,减少锁竞争(本地队列操作无锁)
- Work Stealing:当 P 的本地队列为空时,从其他 P 或全局队列窃取 G
- 系统调用时 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 只在特定点让出执行权:
- 函数调用(栈检查)
- Channel 操作
- 系统调用
runtime.Gosched()
这导致一个问题——没有函数调用的死循环会独占 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):
- 程序的结构(structure)
- 同时处理(dealing with)多个事情的能力
- 关于组合(composition)
并行(Parallelism):
- 程序的执行(execution)
- 同时做(doing)多个事情
- 关于效率(efficiency)
类比:一个人同时管理多个邮件窗口是并发——实际只有一双手(一个 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.
关键词:
- 独立的(independent):goroutine 之间没有隐含的通信机制
- 并发的(concurrent):不保证并行执行
- 线程控制(thread of control):有自己的执行序列
- 同一地址空间(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 的核心思想:
- 进程是基本的并发单元(对应 Go 的 goroutine)
- 进程之间不共享内存,通过消息传递通信(对应 Go 的 channel)
- 通信是同步的——发送方和接收方必须同时就绪(对应 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 做出以下保证:
- 创建顺序:
go f()语句的效果——创建新 goroutine——happens before 新 goroutine 的执行开始 - 无序执行:多个 goroutine 的执行顺序未定义,除非通过同步原语建立 happens-before 关系
- 非抢占保证:规范不保证 goroutine 在任何特定点让出执行权(但实践中 Go 1.14+ 有异步抢占)
- 内存可见性:goroutine 之间的数据可见性遵循 Go Memory Model(
sync、atomic、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 中解释):
- 防止 goroutine-local storage 反模式:如果有 goroutine ID,开发者会创建类似线程局部存储(TLS)的东西,破坏 Go 的并发模型
- 鼓励显式传参:需要传递的值应该通过函数参数或 context.Context 显式传递
- 避免调试依赖: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 的优势:
- 错误传播:任何一个 goroutine 返回错误,ctx 被取消,所有其他 goroutine 可以感知
- 并发限制:
SetLimit(n)控制最大并发 - 等待所有完成:
Wait()等待所有 goroutine 完成 - Context 集成:自动管理 context 取消
面试题解析
题目 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。原因:
- context 可以携带取消原因(
context.Cause) - context 可以设置超时(
WithTimeout) - context 可以在整个调用链中传递
- context 是 Go 标准的取消信号传播机制
题目 3:goroutine 泄漏的典型表现是什么?如何诊断?
答案:
- 表现:内存持续增长(
runtime.NumGoroutine()单调递增)、程序运行一段时间后 OOM - 诊断:
runtime.NumGoroutine()监控pprofgoroutine profile(/debug/pprof/goroutine)go.uber.org/goleak在测试中检测- 生产环境:Prometheus 指标
go_goroutines
题目 4:100 万个 goroutine 各 sleep 1 秒,需要多少内存?需要多少时间?
答案:
- 内存:约 4GB(每个 goroutine ~4KB,包含 2KB 栈 + 运行时元数据)
- 时间:约 1 秒。所有 goroutine 并发 sleep,不是串行的。Go 运行时用 timer 堆管理 sleep,不需要为每个 sleeping goroutine 分配 OS 线程。
真实案例
案例 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 最佳实践总结
- 始终确保 goroutine 有退出路径——使用 context 或 done channel
- 用 errgroup 管理一组 goroutine——比手动 WaitGroup + error channel 更简洁安全
- 控制并发数量——无限制地启动 goroutine 会导致资源耗尽
- 不要依赖 goroutine 的执行顺序——即使 GOMAXPROCS=1
- 每个 goroutine 应该有 recover——至少长期运行的 goroutine 需要
- 优先使用 channel 通信——而不是共享内存 + 锁
- 测试中检测 goroutine 泄漏——使用 goleak 或手动检查 NumGoroutine
- HTTP response body 始终关闭——即使不读取 body 内容
- 长时间运行的 goroutine 记得定期检查 context——不要让 goroutine 在 context 取消后继续做无用功
- GOMAXPROCS 通常不需要手动设置——默认值(CPU 核数)在大多数场景下最优