Files
test/chapters/chapter-5-revised.md

366 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第五章:并发编程 —— 让程序“多任务”并行
> **👋 回顾一下:**
>
> 前四章,我们学会了基础语法、数据结构、函数和接口。现在,你已经能写出逻辑清晰、结构良好的代码了。
>
> **🤔 但是,现实世界的任务往往很复杂……**
>
> 想象一下,你是一家餐厅的老板。
> * 如果只有一个厨师(单线程),客人点菜 -> 厨师炒菜 -> 上菜。客人要等很久。
> * 如果有十个厨师(多线程/多协程),可以**同时**处理十张订单,效率翻倍!
>
> 这就是**并发 (Concurrency)** 的魅力:**宏观上同时处理多个任务**(微观上可能是交替执行,也可能是真正的并行)。
>
> Go 语言天生就是为并发而生的!它提供了**Goroutine**(轻量级线程)和**Channel**(通信管道),让并发编程变得像写串行代码一样简单。
>
> **🎯 这一章,我们要掌握 Go 并发的核心:**
> 1. **Goroutine**:如何启动“小助手”?
> 2. **GMP 模型**Go 是怎么调度的?(核心难点,有图解!)
> 3. **Channel**Goroutine 之间怎么“传纸条”?
> 4. **同步原语**Mutex、WaitGroup、Once怎么避免“打架”
> 5. **Context**:怎么统一“叫停”所有任务?
>
> **别担心,我会用大量的图解和生活类比,带你轻松攻克这个“高难度”章节!**
---
## 5.1 Goroutine —— 启动你的“小助手”
> **💡 想象一下:**
> 你有一个大任务(比如处理 1000 个订单)。如果一个个处理,太慢了。
> 于是,你雇佣了 1000 个**小助手**Goroutine每人处理一个订单。
>
> 在 Go 中,启动一个小助手只需要一个关键字:**`go`**。
### 📝 启动 Goroutine
```go
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 模型架构(图解)
```mermaid
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
```
> **📖 深度解析(老师划重点):**
> 1. **G (Goroutine)**:绿色节点。
> * **结构**包含栈2KB 起步)、指令指针、状态。
> * **轻量**:创建开销极小,可创建百万级。
> * **位置**:被放入 P 的本地队列或全局队列。
> 2. **P (Processor)**:黄色节点。
> * **作用****调度器**的核心,管理 G 队列,提供执行环境(如内存分配器)。
> * **数量**:等于 `GOMAXPROCS`(通常=CPU 核数)。**P 的数量限制了最大并行度**。
> * **本地队列**:每个 P 维护一个本地队列(最多 256 个 G优先调度减少锁竞争。
> 3. **M (Machine)**:蓝色节点。
> * **作用**:操作系统线程,**真正执行代码**的实体。
> * **绑定**M 必须绑定一个 P 才能执行 G。M 和 P 是**动态绑定**的。
> 4. **全局队列 (Global Queue)**:橙色节点。
> * **作用**:存放新创建的 G当 P 的本地队列满时或启动时G 会进入全局队列。
> * **调度频率**P 优先从本地队列取 G只有本地队列为空时才会尝试从全局队列或**窃取**其他 P 的 G。
### 🔄 调度流程:从创建到执行
```mermaid
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 阻塞(如 IOM 也会阻塞。Go 的优化P 会**脱离** M并寻找/创建新 M 继续执行其他 G。
> * **结果**P 始终有 M 执行,不会因为某个 G 阻塞而停滞。
> * **工作窃取**:当 P 的本地队列为空,会从其他 P 的队列**窃取一半 G**,实现负载均衡。
> **🎮 动手练一练:**
> 1. 运行 `runtime.GOMAXPROCS(0)` 查看当前 P 的数量。
> 2. 尝试设置 `runtime.GOMAXPROCS(1)`,观察并发性能变化。
---
## 5.3 Channel —— Goroutine 之间的“传纸条”
> **💡 想象一下:**
> 你有两个小助手GoroutineA 负责生产数据B 负责消费数据。
> 它们不能直接共享内存(会打架),怎么办?
> 于是,你放了一个**传送带**Channel在中间。A 把数据放上去B 从上面拿走。
>
> 这就是 **Channel****Goroutine 之间通信的管道**。
### 📖 Channel 的底层结构(图解)
```mermaid
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 的创建与使用
```go
// 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` 判断是否关闭。
### ⚠️ 常见陷阱:忘记关闭
```go
func leak() {
ch := make(chan int)
go func() {
ch <- 1
// 忘记关闭!接收方会一直阻塞
}()
<-ch
}
```
> **💡 老师的小提醒:**
> * **谁发送,谁关闭**:通常由发送方关闭 Channel。
> * **不要重复关闭**:会 panic。
> * **不要向已关闭的 Channel 发送**:会 panic。
> **🎮 动手练一练:**
> 1. 创建一个无缓冲 Channel启动一个 Goroutine 发送数据,主函数接收。
> 2. 创建一个容量为 3 的 Channel循环发送 5 个数据,观察阻塞行为。
---
## 5.4 同步原语 —— 避免“打架”
> **🤔 多个 Goroutine 同时修改同一个变量,会发生什么?**
> 比如两个 Goroutine 同时 `count++`,结果可能少 1。这就是**竞态条件 (Race Condition)**。
>
> 我们需要**同步原语**来保护共享资源。
### 📝 WaitGroup —— 等待所有任务完成
```go
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 —— 互斥锁
```go
var mu sync.Mutex
var count int
go func() {
mu.Lock()
count++
mu.Unlock()
}()
```
> **💡 老师的小提醒:**
> * **Lock/Unlock**:加锁/解锁。
> * **死锁**:避免嵌套锁,保持锁顺序。
> * **RWMutex**:读多写少时,用读写锁(`RLock`/`Unlock`)。
### 📝 Once —— 只执行一次
```go
var once sync.Once
func init() {
once.Do(func() {
fmt.Println("只执行一次")
})
}
```
> **🎮 动手练一练:**
> 1. 用 `WaitGroup` 启动 10 个 Goroutine统计它们的运行时间。
> 2. 用 `Mutex` 保护一个共享计数器,验证并发安全。
---
## 5.5 Context —— 统一“叫停”
> **💡 想象一下:**
> 老板(主函数)说:“停止所有任务!”
> 每个小助手Goroutine都要收到这个信号并优雅地退出。
>
> 这就是 **Context**:传递取消信号、超时控制。
### 📝 Context 的用法
```go
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 本章小结
> **🎯 我们学到了什么?**
> 1. **Goroutine**:轻量级线程,`go` 启动。
> 2. **GMP 模型**G/M/P 调度,用户态切换,高效。
> 3. **Channel**:通信管道,同步/异步,避免共享内存。
> 4. **同步原语**WaitGroup、Mutex、Once保护共享资源。
> 5. **Context**:取消信号,超时控制,树状传播。
>
> **🚀 下一步预告:**
> 学会了并发,我们怎么把这些知识**应用到实际项目**中?
>
> **下一章,我们要构建一个完整的 Web API 服务!** 我们会用 Goroutine 处理请求,用 Channel 处理异步任务,用 Context 控制超时,用 Mutex 保护共享状态。准备好迎接**实战**了吗?