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

12 KiB
Raw Blame History

第五章:并发编程 —— 让程序“多任务”并行

👋 回顾一下:

前四章,我们学会了基础语法、数据结构、函数和接口。现在,你已经能写出逻辑清晰、结构良好的代码了。

🤔 但是,现实世界的任务往往很复杂……

想象一下,你是一家餐厅的老板。

  • 如果只有一个厨师(单线程),客人点菜 -> 厨师炒菜 -> 上菜。客人要等很久。
  • 如果有十个厨师(多线程/多协程),可以同时处理十张订单,效率翻倍!

这就是并发 (Concurrency) 的魅力:宏观上同时处理多个任务(微观上可能是交替执行,也可能是真正的并行)。

Go 语言天生就是为并发而生的!它提供了Goroutine(轻量级线程)和Channel(通信管道),让并发编程变得像写串行代码一样简单。

🎯 这一章,我们要掌握 Go 并发的核心:

  1. Goroutine:如何启动“小助手”?
  2. GMP 模型Go 是怎么调度的?(核心难点,有图解!)
  3. ChannelGoroutine 之间怎么“传纸条”?
  4. 同步原语Mutex、WaitGroup、Once怎么避免“打架”
  5. 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.SleepWaitGroupChannel)。

🎮 动手练一练: 写一个程序,启动 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

📖 深度解析(老师划重点):

  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。

🔄 调度流程:从创建到执行

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 从上面拿走。

这就是 ChannelGoroutine 之间通信的管道

📖 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。

🎮 动手练一练:

  1. 创建一个无缓冲 Channel启动一个 Goroutine 发送数据,主函数接收。
  2. 创建一个容量为 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("只执行一次")
    })
}

🎮 动手练一练:

  1. WaitGroup 启动 10 个 Goroutine统计它们的运行时间。
  2. 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 本章小结

🎯 我们学到了什么?

  1. Goroutine:轻量级线程,go 启动。
  2. GMP 模型G/M/P 调度,用户态切换,高效。
  3. Channel:通信管道,同步/异步,避免共享内存。
  4. 同步原语WaitGroup、Mutex、Once保护共享资源。
  5. Context:取消信号,超时控制,树状传播。

🚀 下一步预告: 学会了并发,我们怎么把这些知识应用到实际项目中?

下一章,我们要构建一个完整的 Web API 服务! 我们会用 Goroutine 处理请求,用 Channel 处理异步任务,用 Context 控制超时,用 Mutex 保护共享状态。准备好迎接实战了吗?