并发goroutine
# 第 12 章 并发:goroutine 与 context
goroutine 是 Go 的"招牌"——一行
go f()启动协程,配合context做取消传播。 关键词:go关键字、GMP 速览、runtime.GOMAXPROCS、context.Context、取消传播、sync.WaitGroup
# 目录介绍
- 12.1 本章学习目标
- 12.2 goroutine 起步:
go f() - 12.3 GMP 模型速览(卷三详讲)
- 12.4
runtime.GOMAXPROCS与 P 数量 - 12.5
sync.WaitGroup:等所有 goroutine 完成 - 12.6
context包:取消、超时、传值 - 12.7 goroutine 泄漏的 5 种模式
- 12.8 主 goroutine 退出 = 程序退出
- 12.9 综合示例:并发抓取 N 个 URL(带超时)
- 12.10 本章底层原理(简介)
- 12.11 Go 新手陷阱 Top 5
- 12.12 思考题
- 12.13 推荐阅读
# 12.1 本章学习目标
学完本章你应当能够:
- ✅ 能用
go f()启动 goroutine,解释它和 OS 线程的区别 - ✅ 能解释 GMP 三个角色的关系(G=任务、M=线程、P=调度器)
- ✅ 能用
sync.WaitGroup等待一组 goroutine 完成 - ✅ 能用
context.WithTimeout/context.WithCancel做超时和取消控制 - ✅ 能识别 5 种常见的 goroutine 泄漏模式,并写出修复方案
- ✅ 能写出"并发抓取 N 个 URL 并合并结果"的完整程序
- ✅ 知道
GOMAXPROCS的作用,以及何时需要调它
本章是 Go 并发的入口。学完本章你会写并发程序;第 13 章 channel 和卷三"专栏博客"会带你深入底层。
# 12.2 goroutine 起步:go f()
goroutine 是 Go 的轻量级"线程"——由 Go runtime 调度,而非操作系统。
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go say("goroutine") // ① 启动一个新 goroutine——非阻塞
say("main") // ② 主 goroutine 同步执行
}
// 输出(交错):
// main
// goroutine
// main
// goroutine
// main
// goroutine
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
goroutine vs 线程:
| goroutine | OS 线程 | |
|---|---|---|
| 创建成本 | ~2KB 栈 + 极少的 runtime 开销 | ~1MB 栈 + OS 系统调用 |
| 调度者 | Go runtime(用户态) | 操作系统内核 |
| 创建速度 | ~μs 级 | ~μs-ms 级 |
| 并发数上限 | 百万级 | 数千级 |
| 上下文切换 | 用户态,~几十 ns | 内核态,~1-2 μs |
go 关键字的行为:
- 把函数调用包装成一个 goroutine
- 放进当前 P 的本地运行队列(
runq) - 立即返回——不等待被调函数执行
func main() {
go fmt.Println("Hello") // 启动了 goroutine——但主函数可能先退出
// 主 goroutine 退出 → 整个程序退出 → goroutine 被杀
}
// 这行可能永远不打印
2
3
4
5
⚠️ 关键认知:go f() 不阻塞——这也是泄漏和竞态的根源。你必须用同步机制(WaitGroup / channel / context)来协调 goroutine 之间的执行顺序。
# 12.3 GMP 模型速览(卷三详讲)
Go 的调度器用 GMP 三个角色来描述并发模型:
┌───────────────────────────────────────────────────┐
│ Go Runtime │
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ G │ │ G │ │ G │ ← goroutine │
│ │ │ │ │ │ │ (用户代码) │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────┐ │
│ │ P │ ← Processor │
│ │ (本地队列 + 调度上下文) │ (调度器) │
│ └───────────┬─────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────┐ │
│ │ M │ ← Machine │
│ │ (OS 线程) │ (执行者) │
│ └─────────────────────────┘ │
└───────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- G(Goroutine):你的
go f()创建的就是一个 G。它包含栈、指令指针、调度信息 - M(Machine):OS 线程的封装。G 必须绑定到 M 才能执行
- P(Processor):调度上下文——持有 G 的本地运行队列。数量 =
GOMAXPROCS
核心公式:M 的数量 ≥ GOMAXPROCS,P 的数量 = GOMAXPROCS。任何时刻最多有 GOMAXPROCS 个 G 在并行执行。
卷三第 08 篇 GMP 协程调度器机制 会深入 GMP 的调度循环、工作窃取、抢占式调度。
# 12.4 runtime.GOMAXPROCS 与 P 数量
GOMAXPROCS 决定了同时有多少个 goroutine 可以真正并行执行:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("CPU 核心数:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 默认值 = CPU 核心数
}
2
3
4
5
6
7
8
9
10
11
12
# 环境变量覆盖
GOMAXPROCS=2 go run main.go
# 代码中设置
runtime.GOMAXPROCS(4)
2
3
4
5
什么时候调?
- 默认不要调:
GOMAXPROCS = NumCPU是 Go 团队的优化结论 - 容器环境可能需要调:如果 K8s 限制了 CPU 但
NumCPU返回的是宿主机核数——用GOMAXPROCS或automaxprocs库 - I/O 密集型可以大于 CPU 核数:让更多 G 有机会在 I/O 等待期间被调度
# 12.5 sync.WaitGroup:等所有 goroutine 完成
WaitGroup 是最简单的 goroutine 协调工具——等待一组 goroutine 完成任务:
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // ③ 完成时计数减一
fmt.Printf("Worker %d 开始\n", id)
// 模拟工作...
fmt.Printf("Worker %d 完成\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 5; i++ {
wg.Add(1) // ① 启动前计数加一
go worker(i, &wg) // ② 启动 goroutine
}
wg.Wait() // ④ 阻塞直到计数归零
fmt.Println("所有 worker 完成")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
WaitGroup 的三步操作:
| 操作 | 谁调用 | 时机 |
|---|---|---|
wg.Add(n) | 主 goroutine | 启动 goroutine 之前 |
wg.Done() | worker goroutine | 工作完成时(通常 defer) |
wg.Wait() | 主 goroutine | 等待所有 worker 完成 |
⚠️ 常见错误:
// ❌ 错误 1:Add 在 goroutine 内部——可能还没 Add 就 Wait 返回了
go func() {
wg.Add(1) // 太晚了!
defer wg.Done()
// ...
}()
wg.Wait() // 可能 Add 没执行就通过了
// ✅ 正确:Add 在 go 之前
wg.Add(1)
go func() {
defer wg.Done()
// ...
}()
wg.Wait()
// ❌ 错误 2:忘记传指针——每个 goroutine 拿了 WaitGroup 的副本
func worker(wg sync.WaitGroup) { // 值拷贝!
defer wg.Done()
}
// → 永远不会 Done 到原始的 WaitGroup
// ✅ 正确:传指针
func worker(wg *sync.WaitGroup) {
defer wg.Done()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 12.6 context 包:取消、超时、传值
context.Context 是 Go 并发程序的"信号管道"——在 goroutine 之间传递取消信号、超时、请求级别的数据。
# 12.6.1 四种 Context
import "context"
// ① 根 Context——不取消、不超时、不值
ctx := context.Background() // 最常用
ctx := context.TODO() // 不确定用什么时占位
// ② 可取消——主动调用 cancel() 通知所有下游
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // ★ 必须调用——否则资源泄漏
// ③ 超时——到时间自动取消
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// ④ 截止时间——在指定时刻自动取消
deadline := time.Now().Add(10 * time.Minute)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
// ⑤ 传值——携带请求级别的数据(慎用)
ctx = context.WithValue(ctx, "trace_id", "abc123")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
defer cancel() 的必要性:
// ❌ 没有 cancel——即使超时已过,context 内部资源不释放
ctx, _ := context.WithTimeout(parent, time.Second)
// ✅ 标准写法——defer cancel 确保资源释放
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
2
3
4
5
6
# 12.6.2 context.WithCancelCause(Go 1.20+)
Go 1.20 引入的 WithCancelCause 让取消带上原因:
var ErrTimeout = errors.New("处理超时")
ctx, cancel := context.WithCancelCause(context.Background())
go func() {
time.Sleep(3 * time.Second)
cancel(ErrTimeout) // 取消并带上原因
}()
select {
case <-ctx.Done():
fmt.Println("取消原因:", context.Cause(ctx)) // 处理超时
}
2
3
4
5
6
7
8
9
10
11
12
13
# 12.6.3 取消传播链
一个 context 取消,所有子 context 全部取消——这是一棵树:
Background
│
├── WithTimeout(30s) ← 请求级超时
│ │
│ ├── WithTimeout(3s) ← DB 查询超时
│ ├── WithTimeout(5s) ← HTTP 调用超时
│ └── WithCancel ← 某步骤失败,手动取消剩余步骤
│
└── WithValue("trace_id") ← 跨请求传递
2
3
4
5
6
7
8
9
func handleRequest(parent context.Context) {
// 请求级超时 30 秒
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
// 三个子任务——任意一个失败/超时 → cancel → 其他子任务感知
ctx1, cancel1 := context.WithCancel(ctx)
ctx2, cancel2 := context.WithCancel(ctx)
go fetchFromDB(ctx1)
go fetchFromCache(ctx2)
// 等第一个返回
// → 另一个的 ctx.Done() 立刻触发 → 清理
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 12.7 goroutine 泄漏的 5 种模式
goroutine 泄漏 = goroutine 永远阻塞在某个地方,无法退出。
# 12.7.1 模式 1:channel 无人接收
// ❌ goroutine 往一个无缓冲 channel 发数据——但外面没有接收者
func leak() {
ch := make(chan int)
go func() {
ch <- 42 // 永远阻塞在这——因为没人读
}()
// ch 没有接收者 → goroutine 泄漏
}
2
3
4
5
6
7
8
# 12.7.2 模式 2:channel 无人发送
// ❌ goroutine 从一个 channel 读数据——但外面不会发
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 永远阻塞在这——因为没人发
fmt.Println(val)
}()
// ch 没有发送者 → goroutine 泄漏
}
2
3
4
5
6
7
8
9
# 12.7.3 模式 3:没有设置超时
// ❌ HTTP 请求没有超时——服务端卡住 → goroutine 永远等待
func fetch(url string) {
resp, err := http.Get(url) // 永远不会返回
// ...
}
// ✅ 带超时
func fetchWithTimeout(url string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 12.7.4 模式 4:死锁(循环依赖)
// ❌ 两个 goroutine 互相等待——永远解不开
func deadlock() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
<-ch1
ch2 <- 1 // 等 ch1 —但 ch1 没人发
}()
go func() {
<-ch2
ch1 <- 1 // 等 ch2 —但 ch2 没人发
}()
}
// → fatal error: all goroutines are asleep - deadlock!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 12.7.5 模式 5:select 永远走不到退出分支
// ❌ select 没有 ctx.Done() 分支 → 永远退不出去
func leak(ctx context.Context) {
for {
select {
case data := <-dataCh:
process(data)
// 没有 case <-ctx.Done()——永远退不出循环
}
}
}
// ✅ 加上退出分支
func noLeak(ctx context.Context) {
for {
select {
case <-ctx.Done():
return // ← 退出路径
case data := <-dataCh:
process(data)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 12.8 主 goroutine 退出 = 程序退出
Go 程序的退出规则:当主 goroutine(main 函数)返回时,所有其他 goroutine 立即被杀死——不管它们在做什么。
func main() {
go func() {
time.Sleep(5 * time.Second)
fmt.Println("这条永远不会打印")
}()
fmt.Println("main 退出")
// main 返回 → 程序退出 → goroutine 被杀死
}
2
3
4
5
6
7
8
9
正确做法——主 goroutine 需要等其他 goroutine:
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(time.Duration(id) * time.Second)
fmt.Printf("Worker %d 完成\n", id)
}(i)
}
wg.Wait() // ★ 等所有 worker
fmt.Println("所有 worker 完成,程序退出")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
注意:panic 在子 goroutine 中不会影响主 goroutine——除非用 recover 捕获。
# 12.9 综合示例:并发抓取 N 个 URL(带超时)
package main
import (
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
)
type Result struct {
URL string
BodySize int
Err error
}
// 并发抓取——带超时 + 取消传播 + 结果合并
func fetchAll(ctx context.Context, urls []string) []Result {
// 每个请求的超时——比总超时短
reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var wg sync.WaitGroup
results := make([]Result, len(urls))
for i, url := range urls {
wg.Add(1)
go func(i int, url string) {
defer wg.Done()
results[i] = fetchOne(reqCtx, url)
}(i, url)
}
wg.Wait()
return results
}
func fetchOne(ctx context.Context, url string) Result {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return Result{URL: url, Err: err}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return Result{URL: url, Err: err}
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return Result{URL: url, Err: err}
}
return Result{URL: url, BodySize: len(body)}
}
func main() {
urls := []string{
"https://go.dev",
"https://golang.org",
"https://pkg.go.dev",
}
// 总超时 30 秒
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
results := fetchAll(ctx, urls)
for _, r := range results {
if r.Err != nil {
fmt.Printf("❌ %s: %v\n", r.URL, r.Err)
} else {
fmt.Printf("✅ %s: %d bytes\n", r.URL, r.BodySize)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
程序流程:
- 主 goroutine 遍历 URL 列表——为每个 URL 启动一个 goroutine
- 所有 goroutine 并发执行
fetchOne——WaitGroup 等它们全部完成 - 每个请求有独立的 10s 超时(子 context)
- 整个任务有 30s 总超时(父 context)——超时后所有子请求自动取消
- 结果收集到切片——主 goroutine 打印汇总
# 12.10 本章底层原理(简介)
本章涉及的底层机制在**卷三"专栏博客"**中详细展开:
| 本章概念 | 卷三对应篇 |
|---|---|
go f() 如何被调度 | 08.GMP 协程调度器机制 |
| G、M、P 的生命周期 | 08.GMP 协程调度器机制 |
| 连续栈扩容(2KB→1GB) | 01.内存模型与栈堆布局 |
context 取消传播实现 | 23.上下文取消与传播 |
| goroutine 泄漏排查 | 15.协程泄漏排查与修复 |
sync.WaitGroup 源码 | 10.sync 同步原语剖析 |
| goroutine 抢占式调度 | 21.抢占式调度器原理 |
# 12.11 Go 新手陷阱 Top 5
① ❌ go func(i int) { use(i) }(i) 传参写错
// ❌ 闭包捕获循环变量——所有 goroutine 读到同一个 i
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }()
}
// 输出: 5 5 5 5 5 (Go 1.21-)或每次的 i(Go 1.22+)
// ✅ 通过参数传入——全版本安全
for i := 0; i < 5; i++ {
go func(i int) { fmt.Println(i) }(i)
}
2
3
4
5
6
7
8
9
10
② ❌ 主 goroutine 不等其他 goroutine 就退出
// ❌ main 退出 → 程序结束 → goroutine 被杀
go doWork()
// main 立即返回
// ✅ WaitGroup 或 channel 等待
var wg sync.WaitGroup
wg.Add(1)
go func() { defer wg.Done(); doWork() }()
wg.Wait()
2
3
4
5
6
7
8
9
③ ❌ context 存到 struct 字段——反模式
// ❌ context 不应该作为 struct 字段
type Server struct {
ctx context.Context // 反模式
}
// ✅ context 作为函数第一个参数
func (s *Server) Handle(ctx context.Context, req *Request)
2
3
4
5
6
7
④ ❌ WithTimeout / WithCancel 不调用 cancel()
// ❌ 计时器不回收——资源泄漏
ctx, _ := context.WithTimeout(parent, time.Second)
// ✅ defer cancel——即使超时已过也要调
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
2
3
4
5
6
⑤ ❌ 共享变量没加锁
// ❌ 多个 goroutine 同时写 counter——数据竞争
var counter int
for i := 0; i < 100; i++ {
go func() { counter++ }()
}
// ✅ 加锁 或 用 sync/atomic
var mu sync.Mutex
var counter int
mu.Lock()
counter++
mu.Unlock()
// 或 atomic
var counter atomic.Int64
counter.Add(1)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 12.12 思考题
- 为什么
go f()是"非阻塞"的?如果想让调用方等待 goroutine 完成,有几种方式? GOMAXPROCS=1时,多个 goroutine 是"并发"还是"并行"?GOMAXPROCS=4呢?WaitGroup和errgroup(golang.org/x/sync/errgroup)有什么区别?什么时候该换 errgroup?- 写一个程序:用
WithTimeout启动 3 个 goroutine 并发下载文件,任意一个超时则取消另外两个。 - 为什么 Go 不让
recover跨 goroutine 生效?(提示:栈隔离)
# 12.13 推荐阅读
- 入门卷:第 13 章 channel——channel 是 goroutine 间的通信工具
- 入门卷:第 14 章 sync 包——Mutex、RWMutex、Once、Pool
- 卷三:08.GMP 协程调度器机制——GMP 三者的协作全流程
- 卷三:23.上下文取消与传播——context 的底层实现
- 卷三:15.协程泄漏排查与修复——pprof 分析泄漏 + 5 种修复模式
- Go Concurrency Patterns - Rob Pike (opens new window)——经典演讲
- The Go Memory Model (opens new window)——happens-before 规则