并发模型
这一课你将能做到
- 用
go 关键字启动 goroutine,并用 sync.WaitGroup 正确等待一组任务结束
- 用 channel 在 goroutine 间传递数据,说清无缓冲与有缓冲、close 与 range 的规则
- 组合 select、worker pool、Mutex、context 解决四类典型并发问题
为什么要学
并发是 Go 的招牌,也是面试的重灾区。Java 起一个线程默认 1MB 栈、创建销毁都昂贵;Go 的 goroutine 初始栈只有约 2KB,由运行时调度(GMP 模型)在少量系统线程上复用百万级任务,go f() 一行就能启动。
Go 的并发哲学一句话:不要通过共享内存来通信,而要通过通信来共享内存。channel 是通信的管道,Mutex 是共享的锁,context 是取消的开关——这一课把这套工具链全部打通。
先建立图像
go f() 启动
main ────────────────▶ goroutine A ─┐
│ │ channel(先进先出管道)
│ goroutine B ────┤ ch <- 数据 (发送)
▼ │ v := <-ch (接收)
主流程继续跑,不等 goroutine └──▶ 另一端取到数据
有缓冲 channel: [ ][ ][ ][ ] 装满后发送方阻塞
无缓冲 channel: 两端必须同时就位,像接力交接
两个心智要点:go 之后主流程立刻往下走,不等待;channel 是「带锁的队列 + 阻塞语义」,收发两端速度不一致时自动等待。
跟着做
步骤 1:第一次启动 goroutine——以及它的陷阱
package main
import (
"fmt"
"time"
)
func say(msg string) {
fmt.Println("goroutine 说:", msg)
}
func main() {
go say("hello") // 启动后立刻返回
go say("world")
time.Sleep(time.Second) // 丑陋的等待,仅演示
fmt.Println("main 结束")
}
问题:main 一退出,所有 goroutine 被直接终止。time.Sleep 猜等待时间不可靠,工程做法是 WaitGroup。
步骤 2:sync.WaitGroup——等待一组任务
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 3. 结束时 Done(务必 defer,防 panic 漏调)
fmt.Printf("worker %d 干活\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1) // 1. 启动前 Add
go worker(i, &wg) // 2. 必须传指针!传值会拷贝计数器
}
wg.Wait() // 4. 阻塞直到计数归零
fmt.Println("全部完成")
}
三步口诀:启动前 Add,结束 defer Done,主流程 Wait。最常见的事故是 go worker(i, wg) 传了值——计数器被拷贝,Done 减的是副本,Wait 永远等不到。
步骤 3:无缓冲 channel——同步接力
package main
import "fmt"
func main() {
ch := make(chan int) // 无缓冲
go func() {
ch <- 42 // 发送:等接收方就位才继续
fmt.Println("已送出 42")
}()
v := <-ch // 接收:等发送方送来
fmt.Println("收到:", v)
// 由于发送阻塞到接收完成,这里"已送出"一定在"收到"之后打印
}
无缓冲 channel 的收发是同步点:谁先到谁阻塞,像两人握手交接。这也天然是一种同步手段。
步骤 4:有缓冲 channel、close 与 range
package main
import "fmt"
func main() {
ch := make(chan int, 3) // 缓冲 3
ch <- 1 // 缓冲没满,立即返回
ch <- 2
ch <- 3
// ch <- 4 // 取消注释会死锁:缓冲满且无接收者
close(ch) // 由发送方关闭,广播"没有更多数据了"
for v := range ch { // range 会自动在 close 且取完后退出
fmt.Println(v)
}
// 关闭后再接收:立即返回零值 + false
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
规则:只有发送方需要 close;接收方关闭会引发发送 panic;重复 close 同样 panic。绝大多数场景甚至不必 close——channel 会被 GC 回收,close 只在「用 range 收尾」或「广播结束信号」时需要。
步骤 5:select——多路复用与超时
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() { time.Sleep(100 * time.Millisecond); ch1 <- "来自 ch1" }()
go func() { time.Sleep(200 * time.Millisecond); ch2 <- "来自 ch2" }()
for i := 0; i < 2; i++ {
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
}
}
// 经典超时模式
select {
case msg := <-ch1:
fmt.Println("及时:", msg)
case <-time.After(1 * time.Second):
fmt.Println("超时了")
}
}
select 等多个 channel 操作,谁就绪执行谁;多个同时就绪则随机选一个(防饿死)。time.After 是最常用的超时触发器。
步骤 6:worker pool——固定并发的任务池
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs { // jobs 关闭后循环自动结束
fmt.Printf("worker %d 处理 %d\n", id, j)
results <- j * 10
}
}
func main() {
const numWorkers = 3
const numJobs = 9
jobs := make(chan int) // 任务队列
results := make(chan int, numJobs) // 结果收集(容量=任务数,防阻塞)
var wg sync.WaitGroup
for i := 1; i <= numWorkers; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs) // 关闭任务队列 → worker 的 range 退出
wg.Wait()
close(results)
for r := range results {
fmt.Println("结果:", r)
}
}
worker pool 是生产环境控制并发数的标配:goroutine 数固定为 numWorkers,任务多到多少都不会压垮系统。
步骤 7:共享内存就用 Mutex
不是所有场景都该用 channel。多个 goroutine 改同一变量时,加锁最直接:
package main
import (
"fmt"
"sync"
)
func main() {
var (
counter int
mu sync.Mutex
wg sync.WaitGroup
)
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++ // 不加锁:结果随机且大概率小于 1000(数据竞争)
mu.Unlock()
}()
}
wg.Wait()
fmt.Println(counter) // 稳定输出 1000
}
先用 go run -race main.go 复现数据竞争警告,再上锁修复,-race 是排查并发 bug 的第一工具。
步骤 8:context——取消与超时的标准姿势
package main
import (
"context"
"fmt"
"time"
)
func slowWork(ctx context.Context) {
select {
case <-time.After(3 * time.Second): // 模拟耗时 3 秒
fmt.Println("工作完成")
case <-ctx.Done(): // 取消/超时信号
fmt.Println("被取消:", ctx.Err()) // context deadline exceeded
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 惯例:拿到 cancel 必须 defer,防泄漏
slowWork(ctx)
}
Go 的数据库、HTTP 客户端全部支持 ctx 贯穿,超时取消一路传递,这是写服务必备素养。
讲清楚要点
- goroutine 轻在栈和调度:2KB 起步栈可伸缩,GMP 模型把大量 goroutine 复用到少量 OS 线程上;创建成本约是线程的千分之一。
- channel 三规则:发送方 close;向 nil channel 收发永久阻塞;向已 close 的 channel 发送 panic。
- WaitGroup 必须传指针;Add 必须在 go 语句之前,避免 Wait 抢跑。
- select 是随机公平的;空 select(无 case)永久阻塞,可作 sleep 到永远。
- channel 传递是拷贝:传大结构体不如传指针或通知信号;方向可约束
chan<-(只发)、<-chan(只收),编译期防呆。
- 能用锁就别硬上 channel:保护共享状态(计数器、缓存)用 Mutex;传递数据流、任务分发用 channel。
你可能会懵的地方
-
goroutine 和线程到底差在哪?
内核线程由 OS 调度、栈 MB 级、切换进内核;goroutine 是运行时用户态调度、栈 KB 级、切换只是函数调用级别开销。所以单机百万 goroutine 可行,百万线程直接压垮内存。
-
什么时候用 channel,什么时候用 Mutex?
「拥有数据的所有权转移」用 channel(任务分发、结果汇聚、流水线);「多个执行流频繁读写同一份状态」用 Mutex。Go 官方建议:能想清楚谁拥有数据就选 channel,想不清就先用锁保平安。
-
goroutine 泄漏是什么?怎么发现?
goroutine 阻塞在永远无人收发的 channel 上,永不退出,随请求累积耗尽内存。典型源头:只发不收、忘记 close、忘记 ctx 取消。用 runtime.NumGoroutine 监控或 pprof 排查。
-
GMP 是什么?(概念级)
G=goroutine,M=OS 线程,P=调度上下文(处理器)。P 持有本地运行队列,M 绑定 P 执行 G;G 阻塞在系统调用时 P 会被移交其他 M,避免整体卡死。
-
Go 1.22 对循环变量做了什么改动?
旧版本 for 循环变量全轮次共享,goroutine 闭包捕获会踩同一个变量;1.22 起每轮迭代都是新变量,老坑已消除。但显式传参的写法仍然推荐,兼容且清晰。
错法对照
| 错法 | 问题 | 正解 |
|---|
go worker(i, wg) 传值 WaitGroup | 计数器被拷贝,Wait 永远阻塞 | &wg 传指针 |
| 在接收方 close(ch) | 发送方再发就 panic | 只由发送方关闭 |
| 用 time.Sleep 等 goroutine | 时长不可控,竞态 | WaitGroup / channel 同步 |
| 多 goroutine 直接读写 map | fatal error: concurrent map writes,无法 recover | Mutex 包住或 sync.Map |
| 忘记读取 channel 结果 | 发送方永久阻塞 → goroutine 泄漏 | 缓冲、select + 超时,或明确消费方 |
| recover 想接住 map 并发错误 | fatal error 不是 panic,接不住 | 预防为主:加锁 |
卡点急救
fatal error: all goroutines are asleep - deadlock!:所有 goroutine 都在等 channel。检查是否漏了发送方/接收方、是否忘 close 导致 range 不退出。
- 数据竞争结果不对:立刻
go run -race .,它会精确打印冲突的读写位置,按提示加锁或改传 channel。
- goroutine 数量持续上涨:泄漏。排查阻塞的 channel 收发和未取消的 context。
- Wait 不返回:Add 与 Done 次数不匹配;确认 Done 用 defer、且传的是指针。
面试常问
-
channel 的底层实现?有缓冲与无缓冲的区别?
底层是带互斥锁的环形队列(hchan 结构:缓冲数组、send/recv 等待队列)。无缓冲:收发必须同时就位,是同步点;有缓冲:缓冲未满发送不阻塞,语义从「同步」变成「异步投递」。
-
如何控制 goroutine 并发数?
worker pool(固定数量 goroutine 从 jobs channel 取任务);或带缓冲 channel 作信号量,sem <- struct{}{} 占位、<-sem 释放。
-
sync.Map 和 Mutex+map 怎么选?
sync.Map 适合读多写少、key 集合稳定的场景;写多时它内部优化的开销反而不划算,常规业务用 Mutex + map 更简单可控。
-
如何优雅退出所有 goroutine?
统一传 context,goroutine select 监听 ctx.Done();或关闭一个广播用 channel。配合 http.Server.Shutdown 完成排空(第 12 课实战)。
自测三问
- 无缓冲 channel 上先发送后接收,发送方会怎样?换成有缓冲 cap=1 呢?
- 写出 WaitGroup 的三步口诀,并说明为什么必须传指针。
- 一个 goroutine 向无缓冲 channel 发数据但没有任何接收者,程序会发生什么?如何用 select+超时规避?
小结一句话
goroutine 负责「并发地跑」,channel 负责「安全地传」,Mutex 负责「锁住共享」,context 负责「叫得停」——四件套各司其职,就是 Go 并发的全部骨架。
延伸