Revised Chapter 5: Concurrency - Friendly Tone, Deep Diagrams, Analogies, and Interactive Exercises
This commit is contained in:
365
chapters/chapter-5-revised.md
Normal file
365
chapters/chapter-5-revised.md
Normal file
@@ -0,0 +1,365 @@
|
||||
# 第五章:并发编程 —— 让程序“多任务”并行
|
||||
|
||||
> **👋 回顾一下:**
|
||||
>
|
||||
> 前四章,我们学会了基础语法、数据结构、函数和接口。现在,你已经能写出逻辑清晰、结构良好的代码了。
|
||||
>
|
||||
> **🤔 但是,现实世界的任务往往很复杂……**
|
||||
>
|
||||
> 想象一下,你是一家餐厅的老板。
|
||||
> * 如果只有一个厨师(单线程),客人点菜 -> 厨师炒菜 -> 上菜。客人要等很久。
|
||||
> * 如果有十个厨师(多线程/多协程),可以**同时**处理十张订单,效率翻倍!
|
||||
>
|
||||
> 这就是**并发 (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 阻塞(如 IO),M 也会阻塞。Go 的优化:P 会**脱离** M,并寻找/创建新 M 继续执行其他 G。
|
||||
> * **结果**:P 始终有 M 执行,不会因为某个 G 阻塞而停滞。
|
||||
> * **工作窃取**:当 P 的本地队列为空,会从其他 P 的队列**窃取一半 G**,实现负载均衡。
|
||||
|
||||
> **🎮 动手练一练:**
|
||||
> 1. 运行 `runtime.GOMAXPROCS(0)` 查看当前 P 的数量。
|
||||
> 2. 尝试设置 `runtime.GOMAXPROCS(1)`,观察并发性能变化。
|
||||
|
||||
---
|
||||
|
||||
## 5.3 Channel —— Goroutine 之间的“传纸条”
|
||||
|
||||
> **💡 想象一下:**
|
||||
> 你有两个小助手(Goroutine),A 负责生产数据,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 保护共享状态。准备好迎接**实战**了吗?
|
||||
Reference in New Issue
Block a user