12 KiB
第五章:并发编程 —— 让程序“多任务”并行
👋 回顾一下:
前四章,我们学会了基础语法、数据结构、函数和接口。现在,你已经能写出逻辑清晰、结构良好的代码了。
🤔 但是,现实世界的任务往往很复杂……
想象一下,你是一家餐厅的老板。
- 如果只有一个厨师(单线程),客人点菜 -> 厨师炒菜 -> 上菜。客人要等很久。
- 如果有十个厨师(多线程/多协程),可以同时处理十张订单,效率翻倍!
这就是并发 (Concurrency) 的魅力:宏观上同时处理多个任务(微观上可能是交替执行,也可能是真正的并行)。
Go 语言天生就是为并发而生的!它提供了Goroutine(轻量级线程)和Channel(通信管道),让并发编程变得像写串行代码一样简单。
🎯 这一章,我们要掌握 Go 并发的核心:
- Goroutine:如何启动“小助手”?
- GMP 模型:Go 是怎么调度的?(核心难点,有图解!)
- Channel:Goroutine 之间怎么“传纸条”?
- 同步原语:Mutex、WaitGroup、Once(怎么避免“打架”?)。
- Context:怎么统一“叫停”所有任务?
别担心,我会用大量的图解和生活类比,带你轻松攻克这个“高难度”章节!
5.1 Goroutine —— 启动你的“小助手”
💡 想象一下: 你有一个大任务(比如处理 1000 个订单)。如果一个个处理,太慢了。 于是,你雇佣了 1000 个小助手(Goroutine),每人处理一个订单。
在 Go 中,启动一个小助手只需要一个关键字:
go。
📝 启动 Goroutine
func sayHello(name string) {
fmt.Println("你好,", name)
}
func main() {
go sayHello("Alice") // 启动一个小助手
go sayHello("Bob")
go sayHello("Charlie")
// 主函数如果退出,所有小助手都会消失!
// 所以需要等待(后面会讲 WaitGroup)
time.Sleep(1 * time.Second)
}
💡 老师的小揭秘:
- 轻量级:一个 Goroutine 只占 2KB 内存(操作系统线程要 1-8MB)。
- 数量巨大:可以轻松创建百万级 Goroutine。
- 非阻塞:
go启动后,主程序立刻继续执行,不会等待小助手完成。
⚠️ 老师的小提醒:
- 主函数
main退出,所有 Goroutine 都会强制终止。- 所以,如果需要等待 Goroutine 完成,必须用同步机制(如
time.Sleep、WaitGroup、Channel)。
🎮 动手练一练: 写一个程序,启动 5 个 Goroutine,每个打印“我是第 X 个助手”。观察输出顺序(是不是每次都不一样?)。
5.2 GMP 模型 —— Go 的“调度魔法” ⭐ 核心重点
🤔 为什么 Go 这么快? 因为 Go 的调度器(Scheduler)非常聪明!它不像操作系统那样直接调度线程,而是自己管理Goroutine,把任务分配给操作系统线程。
这就是著名的 GMP 模型。
📖 GMP 模型架构(图解)
graph TB
subgraph "Go Runtime (用户态调度)"
P1[P1: 逻辑处理器]
P2[P2: 逻辑处理器]
Q1[本地 G 队列 Local]
Q2[本地 G 队列 Local]
P1 --- Q1
P2 --- Q2
GlobalQ[全局 G 队列 Global]
GlobalQ -.->|偶尔调度 | P1
GlobalQ -.->|偶尔调度 | P2
end
subgraph "操作系统内核 (Kernel)"
M1[M1: 线程]
M2[M2: 线程]
M1 <-->|绑定 | P1
M2 <-->|绑定 | P2
end
subgraph "Goroutine 实体"
G1[G1: 协程]
G2[G2: 协程]
G3[G3: 协程]
G1 --> Q1
G2 --> Q1
G3 --> GlobalQ
end
style P1 fill:#ffeb3b,stroke:#333,stroke-width:2px
style M1 fill:#2196f3,stroke:#333,stroke-width:2px,color:#fff
style G1 fill:#4caf50,stroke:#333,stroke-width:2px,color:#fff
style GlobalQ fill:#ff9800,stroke:#333
📖 深度解析(老师划重点):
- G (Goroutine):绿色节点。
- 结构:包含栈(2KB 起步)、指令指针、状态。
- 轻量:创建开销极小,可创建百万级。
- 位置:被放入 P 的本地队列或全局队列。
- P (Processor):黄色节点。
- 作用:调度器的核心,管理 G 队列,提供执行环境(如内存分配器)。
- 数量:等于
GOMAXPROCS(通常=CPU 核数)。P 的数量限制了最大并行度。- 本地队列:每个 P 维护一个本地队列(最多 256 个 G),优先调度,减少锁竞争。
- M (Machine):蓝色节点。
- 作用:操作系统线程,真正执行代码的实体。
- 绑定:M 必须绑定一个 P 才能执行 G。M 和 P 是动态绑定的。
- 全局队列 (Global Queue):橙色节点。
- 作用:存放新创建的 G,当 P 的本地队列满时,或启动时,G 会进入全局队列。
- 调度频率:P 优先从本地队列取 G,只有本地队列为空时,才会尝试从全局队列或窃取其他 P 的 G。
🔄 调度流程:从创建到执行
sequenceDiagram
participant App as 应用程序
participant P as P (逻辑处理器)
participant M as M (线程)
participant OS as 操作系统
App->>P: 创建 G,放入本地队列
Note over P: 本地队列未满
P->>M: M 已绑定 P,检查本地队列
M->>G: 取出 G 并执行
alt G 阻塞 (如 IO)
G->>OS: 发起系统调用
OS-->>M: M 阻塞
Note over P: P 脱离 M,继续调度其他 G
P->>P: 寻找新 M (或创建新 M)
P->>NewM: 绑定新 M,继续执行
else G 完成
G->>M: 执行完毕
M->>P: 归还 P
end
📖 深度解析(关键优化):
- 阻塞处理:当 G 阻塞(如 IO),M 也会阻塞。Go 的优化:P 会脱离 M,并寻找/创建新 M 继续执行其他 G。
- 结果:P 始终有 M 执行,不会因为某个 G 阻塞而停滞。
- 工作窃取:当 P 的本地队列为空,会从其他 P 的队列窃取一半 G,实现负载均衡。
🎮 动手练一练:
- 运行
runtime.GOMAXPROCS(0)查看当前 P 的数量。- 尝试设置
runtime.GOMAXPROCS(1),观察并发性能变化。
5.3 Channel —— Goroutine 之间的“传纸条”
💡 想象一下: 你有两个小助手(Goroutine),A 负责生产数据,B 负责消费数据。 它们不能直接共享内存(会打架),怎么办? 于是,你放了一个传送带(Channel)在中间。A 把数据放上去,B 从上面拿走。
这就是 Channel:Goroutine 之间通信的管道。
📖 Channel 的底层结构(图解)
graph LR
subgraph "hchan 结构体"
qcount[qcount: 数据量]
dataqsiz[dataqsiz: 缓冲区大小]
buf[buf: 环形缓冲区]
sendx[sendx: 发送索引]
recvx[recvx: 接收索引]
sendq[sendq: 发送等待队列]
recvq[recvq: 接收等待队列]
lock[lock: 互斥锁]
qcount --> dataqsiz
dataqsiz --> buf
sendx --> sendq
recvx --> recvq
lock -.-> qcount
lock -.-> sendx
lock -.-> recvx
end
style hchan fill:#ffeb3b,stroke:#333
style lock fill:#f44336,stroke:#333,color:#fff
📖 深度解析:
- buf:环形缓冲区,存储数据。
- sendx/recvx:发送/接收索引,环形移动。
- sendq/recvq:等待队列,存放阻塞的 Goroutine。
- lock:互斥锁,保证并发安全。
📝 Channel 的创建与使用
// 1. 无缓冲 Channel (同步)
ch1 := make(chan int)
go func() { ch1 <- 42 }()
val := <-ch1 // 阻塞直到有数据
// 2. 有缓冲 Channel (异步)
ch2 := make(chan int, 2)
ch2 <- 1
ch2 <- 2
// ch2 <- 3 // 阻塞(缓冲区满)
val1 := <-ch2
val2 := <-ch2
💡 老师的小揭秘:
- 无缓冲:发送和接收必须同时就绪(同步通信)。
- 有缓冲:发送直到缓冲区满,接收直到缓冲区空(异步通信)。
- 关闭:
close(ch),接收方用val, ok := <-ch判断是否关闭。
⚠️ 常见陷阱:忘记关闭
func leak() {
ch := make(chan int)
go func() {
ch <- 1
// 忘记关闭!接收方会一直阻塞
}()
<-ch
}
💡 老师的小提醒:
- 谁发送,谁关闭:通常由发送方关闭 Channel。
- 不要重复关闭:会 panic。
- 不要向已关闭的 Channel 发送:会 panic。
🎮 动手练一练:
- 创建一个无缓冲 Channel,启动一个 Goroutine 发送数据,主函数接收。
- 创建一个容量为 3 的 Channel,循环发送 5 个数据,观察阻塞行为。
5.4 同步原语 —— 避免“打架”
🤔 多个 Goroutine 同时修改同一个变量,会发生什么? 比如两个 Goroutine 同时
count++,结果可能少 1。这就是竞态条件 (Race Condition)。我们需要同步原语来保护共享资源。
📝 WaitGroup —— 等待所有任务完成
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("任务", id, "完成")
}(i)
}
wg.Wait() // 等待所有任务完成
fmt.Println("所有任务完成")
💡 老师的小揭秘:
Add(n):增加计数器。Done():减 1(通常用defer)。Wait():阻塞直到计数器为 0。
📝 Mutex —— 互斥锁
var mu sync.Mutex
var count int
go func() {
mu.Lock()
count++
mu.Unlock()
}()
💡 老师的小提醒:
- Lock/Unlock:加锁/解锁。
- 死锁:避免嵌套锁,保持锁顺序。
- RWMutex:读多写少时,用读写锁(
RLock/Unlock)。
📝 Once —— 只执行一次
var once sync.Once
func init() {
once.Do(func() {
fmt.Println("只执行一次")
})
}
🎮 动手练一练:
- 用
WaitGroup启动 10 个 Goroutine,统计它们的运行时间。- 用
Mutex保护一个共享计数器,验证并发安全。
5.5 Context —— 统一“叫停”
💡 想象一下: 老板(主函数)说:“停止所有任务!” 每个小助手(Goroutine)都要收到这个信号,并优雅地退出。
这就是 Context:传递取消信号、超时控制。
📝 Context 的用法
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go func() {
select {
case <-time.After(3 * time.Second):
fmt.Println("任务完成")
case <-ctx.Done():
fmt.Println("任务取消:", ctx.Err())
}
}()
time.Sleep(3 * time.Second)
💡 老师的小揭秘:
WithTimeout:超时自动取消。WithCancel:手动取消。- 树状传播:父 Context 取消,所有子 Context 自动取消。
🎮 动手练一练: 写一个函数,模拟耗时任务,用 Context 设置 1 秒超时,观察任务是否被取消。
5.6 本章小结
🎯 我们学到了什么?
- Goroutine:轻量级线程,
go启动。- GMP 模型:G/M/P 调度,用户态切换,高效。
- Channel:通信管道,同步/异步,避免共享内存。
- 同步原语:WaitGroup、Mutex、Once,保护共享资源。
- Context:取消信号,超时控制,树状传播。
🚀 下一步预告: 学会了并发,我们怎么把这些知识应用到实际项目中?
下一章,我们要构建一个完整的 Web API 服务! 我们会用 Goroutine 处理请求,用 Channel 处理异步任务,用 Context 控制超时,用 Mutex 保护共享状态。准备好迎接实战了吗?