学习目标
完成本章学习后,你将能够:
- 画出 GMP 调度模型并讲清 G、M、P 三者的关系、本地队列(256)/全局队列/work stealing 的工作流程,能在面试白板上默写。
- 管理 goroutine 生命周期,使用 channel 通知和 context.Cancel 正确关闭 goroutine,能获取返回值并避免泄露。
- 正确使用 sync.Mutex/RWMutex,理解正常模式(自旋+排队)与饥饿模式(1ms 阈值切换)的机制,能根据读写比例选择锁策略。
- 掌握 atomic 五种原子操作(Add/Load/Store/Swap/CompareAndSwap)的代码写法与适用场景,能区分 atomic 与 Mutex 的性能差异。
- 识别并避免死锁,说出死锁四个必要条件,能用固定加锁顺序、超时等手段预防死锁,能用 pprof 定位 goroutine 泄露。
前置知识:
- Go 基本语法(变量、函数、struct、interface)
- channel 的基本用法(发送、接收、关闭)
- 知道
go关键字能启动 goroutine - 操作系统线程与进程的基本概念
动手做 3 件事:
- 在本地新建一个
.go文件,把本章"GMP 调度时机"的代码敲一遍并运行,观察 goroutine 数量变化。 - 故意写一个 goroutine 泄露程序,用
runtime.NumGoroutine()和pprof定位泄露位置并修复。 - 分别用 Mutex 和 atomic 实现一个并发计数器,用
time.Since()对比两者性能差异。
1、GMP 调度模型
1.1 用生活类比先建立直觉
把 Go 的调度想象成一家大型餐厅的厨房:
- G(goroutine) = 菜单上的每一道菜(任务),数量可以成千上万
- M(Machine) = 厨师(真正干活的人,对应操作系统线程)
- P(Processor) = 灶台(厨师干活必须占用的工位,数量有限)
每个灶台旁边贴着一张任务便签条(本地队列),最多 256 张。厨师从便签条上取菜做。如果一个灶台的便签条空了,厨师不会闲着——他会去别的灶台偷便签条(work stealing),或者去公共公告栏(全局队列)拿。
graph TD
GQ["全局队列
Global Queue"] --> P1["P1 灶台1
本地队列 256"]
GQ --> P2["P2 灶台2
本地队列 256"]
GQ --> P3["P3 灶台3
本地队列 256"]
P1 --> M1["M1 厨师1
OS线程"]
P2 --> M2["M2 厨师2
OS线程"]
P3 --> M3["M3 厨师3
OS线程"]
P1 -.->|"work stealing"| P2
P2 -.->|"work stealing"| P3
M1 --> Kernel["操作系统内核"]
M2 --> Kernel
M3 --> Kernel桥接到工程:P 是 Go 调度的核心抽象,它持有本地 G 队列,把"任务调度"和"线程执行"解耦。M 只是执行载体,P 才决定"下一个执行哪个 G"。这种设计让 Go 能在用户态完成大部分调度,减少内核态切换开销,从而支撑数十万 goroutine 并发。
1.2 工程要点
G、M、P 三者的定义
| 组件 | 全称 | 本质 | 数量 |
|---|---|---|---|
| G | goroutine | 用户态协程,包含栈和执行状态 | 可达数十万 |
| M | Machine | 操作系统线程,真正执行 G 的载体 | 动态创建,默认上限 10000 |
| P | Processor | 逻辑处理器,持有本地 G 队列 | GOMAXPROCS,默认=CPU 核数 |
本地队列、全局队列与 work stealing
每个 P 持有一个本地队列,容量 256。新建 goroutine 时优先放入当前 P 的本地队列;队列满了则一半放入全局队列。调度时 P 优先消费本地队列,空了就执行 work stealing:
graph TD
Check["检查本地队列"] -->|"非空"| Run["执行 G"]
Check -->|"空"| Steal["从其他 P 偷取
work stealing"]
Steal -->|"偷到"| Run
Steal -->|"没偷到"| Global["从全局队列取"]
Global -->|"取到"| Run
Global -->|"也没有"| Park["M 休眠
等待唤醒"]调度时机
goroutine 会在以下情况让出执行权:
| 调度时机 | 说明 | 是否切换线程 |
|---|---|---|
| 系统调用阻塞 | M 陷入内核等待,P 与 M 解绑 | 是(一定发生线程切换) |
| channel 阻塞 | G 被挂起,P 执行下一个 G | 否(用户态切换) |
| 时间片用完 | 调度器抢占,G 被放回队列 | 否(用户态切换) |
| runtime.Gosched() | 主动让出执行权 | 否(用户态切换) |
⚠️ 新手必踩的坑: 很多人以为 channel 阻塞会切换操作系统线程。实际上 channel 阻塞只切换 goroutine(用户态),M 和 P 不解绑。只有系统调用阻塞才会导致 M 和 P 解绑,P 去找另一个 M 继续执行其他 G。这是面试中区分"懂调度"和"背概念"的关键点。
什么时候一定发生线程上下文切换
当 goroutine 发起系统调用(如文件 IO、网络底层 syscall)时,流程如下:
- M 被阻塞在内核态
- 调度器将 P 与 M 解绑
- P 绑定另一个 M(或新建 M)继续执行队列中的 G
- 原始 M 系统调用返回后,尝试获取空闲 P;没有空闲 P 则把 G 放入全局队列,M 休眠
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 步骤1:设置 P 的数量为 2
runtime.GOMAXPROCS(2)
// 步骤2:启动一个会阻塞的 goroutine(模拟系统调用)
go func() {
// time.Sleep 底层会调用系统调用,M 会阻塞
time.Sleep(2 * time.Second)
fmt.Println("阻塞 goroutine 完成")
}()
// 步骤3:启动一个普通计算的 goroutine
go func() {
sum := 0
for i := 0; i < 1000000; i++ {
sum += i
}
fmt.Println("计算 goroutine 完成, sum =", sum)
}()
// 步骤4:等待所有 goroutine 完成
time.Sleep(3 * time.Second)
fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
}
GOMAXPROCS
GOMAXPROCS 决定 P 的数量,即同时执行 Go 代码的操作系统线程数。
// 步骤1:获取当前 GOMAXPROCS(传 0 表示不修改,只返回当前值)
fmt.Println("默认 P 数量:", runtime.GOMAXPROCS(0))
// 步骤2:设置为 4
runtime.GOMAXPROCS(4)
// 步骤3:再次获取
fmt.Println("设置后 P 数量:", runtime.GOMAXPROCS(0))
goroutine 栈
| 属性 | 值 |
|---|---|
| 初始大小 | 2KB |
| 扩容方式 | 拷贝式扩容(分配更大的栈,复制旧栈内容) |
| 最大大小 | 1GB(64 位系统) |
| 栈方向 | 向下生长 |
goroutine 的栈是可增长的。初始只有 2KB,当栈空间不足时,运行时会分配一个两倍大的新栈,把旧栈内容拷贝过去。这比操作系统线程固定栈(通常 1MB~8MB)更节省内存,所以 Go 可以轻松创建数十万个 goroutine。
package main
import (
"fmt"
"sync"
)
// 步骤1:递归函数,每层占用约 1KB 栈空间
func deepRecursion(n int) int {
if n <= 0 {
return 0
}
var buf [1024]byte // 每层分配 1KB 栈空间
buf[0] = byte(n % 256)
return int(buf[0]) + deepRecursion(n-1)
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
// 步骤2:在 goroutine 中深度递归,触发栈扩容
go func() {
defer wg.Done()
result := deepRecursion(10000)
fmt.Println("递归结果:", result)
// 步骤3:goroutine 栈从 2KB 开始,按需扩容到足够大小
// 不会像 C 语言那样 stack overflow
}()
wg.Wait()
fmt.Println("goroutine 初始栈: 2KB, 最大: 1GB")
}
2、Goroutine 生命周期管理
2.1 用生活类比先建立直觉
把 goroutine 想象成公司里的员工:
- 启动 goroutine = 招聘一个员工并分配任务
- channel 退出信号 = 经理喊"下班了,可以走了"
- context.Cancel = 老板下达"项目取消,全员停止"通知
- WaitGroup = 项目经理站在门口数"还有几个人没交活"
- goroutine 泄露 = 员工被困在会议室出不来,但没人发现
graph TD
Start["启动 goroutine"] --> Work["执行任务"]
Work --> Check["检查退出信号"]
Check -->|"收到信号"| Exit["return 退出"]
Check -->|"未收到信号"| Work
Work -->|"任务完成"| Done["Done 通知 WaitGroup"]
Done --> Wait["Wait 等待全部完成"]
Exit --> Wait桥接到工程:每个 goroutine 都应该有明确的退出路径。启动 goroutine 时就要想好两个问题——“它什么时候结束?“和"如果出错了,它还能退出吗?"。
2.2 工程要点
goroutine 使用场景
package main
import (
"io"
"net/http"
"sync"
)
// 场景1:并发 IO(同时请求多个 API)
func fetchConcurrent(urls []string) []string {
results := make([]string, len(urls))
var wg sync.WaitGroup
for i, url := range urls {
// 步骤1:Add 必须在 goroutine 外部调用
wg.Add(1)
go func(idx int, u string) {
defer wg.Done() // 步骤2:goroutine 结束时通知
resp, _ := http.Get(u)
body, _ := io.ReadAll(resp.Body)
results[idx] = string(body)
}(i, url)
}
wg.Wait() // 步骤3:等待所有请求完成
return results
}
// 场景2:并发计算(并行求和)
func parallelSum(data []int, numWorkers int) int {
chunkSize := len(data) / numWorkers
results := make(chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
// 步骤1:每个 worker 处理一个数据分片
go func(start int) {
sum := 0
end := start + chunkSize
if end > len(data) {
end = len(data)
}
for j := start; j < end; j++ {
sum += data[j]
}
// 步骤2:结果通过 channel 传回
results <- sum
}(i * chunkSize)
}
// 步骤3:汇总所有 worker 的结果
total := 0
for i := 0; i < numWorkers; i++ {
total += <-results
}
return total
}
用 channel 控制退出
package main
import (
"fmt"
"time"
)
func worker(stop <-chan struct{}) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
// 步骤1:监听退出信号
case <-stop:
fmt.Println("worker 收到退出信号,正在停止...")
return
// 步骤2:正常工作逻辑
case t := <-ticker.C:
fmt.Println("worker 工作中:", t.Format("15:04:05"))
}
}
}
func main() {
// 步骤3:创建退出信号 channel
stop := make(chan struct{})
go worker(stop)
// 步骤4:运行 3 秒后发送退出信号
time.Sleep(3 * time.Second)
close(stop) // close 后所有接收者都能收到零值
time.Sleep(500 * time.Millisecond)
fmt.Println("主程序退出")
}
用 context 控制退出
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int) {
for {
select {
// 步骤1:监听 context 取消
case <-ctx.Done():
fmt.Printf("worker %d: 收到取消信号, 原因: %v\n", id, ctx.Err())
return
default:
// 步骤2:模拟工作
fmt.Printf("worker %d: 工作中...\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
// 步骤3:创建可取消的 context
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保最终会取消
// 步骤4:启动多个 worker
for i := 1; i <= 3; i++ {
go worker(ctx, i)
}
// 步骤5:运行 2 秒后取消所有 worker
time.Sleep(2 * time.Second)
cancel()
time.Sleep(500 * time.Millisecond)
fmt.Println("主程序退出")
}
获取 goroutine 返回值
package main
import (
"sync"
"time"
)
// 方式1:channel 传回(推荐)
func computeAsync(n int) <-chan int {
ch := make(chan int, 1)
go func() {
// 步骤1:计算结果
result := n * n
// 步骤2:通过 channel 返回
ch <- result
}()
return ch
}
// 使用:ch := computeAsync(42); result := <-ch
// 方式2:WaitGroup + 闭包
func computeWithWG(n int) int {
var wg sync.WaitGroup
var result int
wg.Add(1)
go func() {
defer wg.Done()
result = n * n
}()
wg.Wait()
return result
}
// 方式3:Future 模式
type Future struct {
result chan int
}
func NewFuture(n int) *Future {
f := &Future{result: make(chan int, 1)}
go func() {
// 步骤1:异步执行计算
time.Sleep(100 * time.Millisecond)
f.result <- n * n
}()
return f
}
func (f *Future) Get() int {
// 步骤2:按需获取结果(阻塞直到完成)
return <-f.result
}
⚠️ 新手必踩的坑: 方式2中
result变量被 goroutine 写入、主 goroutine 读取。虽然 WaitGroup 保证了时序(先写后读),但严格来说这是隐式数据共享。更安全的做法是用 channel 或 atomic 传递结果。另外,闭包捕获循环变量时要小心——Go 1.22+ 已修复循环变量捕获问题,但旧版本需要显式传参。
goroutine 同步控制方式对比
| 方式 | 适用场景 | 特点 |
|---|---|---|
| sync.WaitGroup | 等待一组 goroutine 全部完成 | 简单,但不传数据 |
| channel | 传递数据 + 同步 | 灵活,Go 推荐 |
| sync.Cond | 等待/通知机制 | 适合生产者-消费者 |
| context | 超时/取消传播 | 适合树状 goroutine 管理 |
3、Goroutine 泄露
3.1 用生活类比先建立直觉
想象一栋大楼里的电梯:
- goroutine = 电梯里的乘客
- channel = 电梯门
- goroutine 泄露 = 乘客进了电梯,但门一直不打开,永远困在里面
更准确地说:你启动了一个 goroutine 等待从 channel 接收数据,但永远不会有人往这个 channel 发送数据,也没有人关闭这个 channel。这个 goroutine 就永远阻塞,无法退出,占用内存直到程序结束。
graph TD
Main["主 goroutine"] --> Launch["启动子 goroutine"]
Launch --> Wait["子 goroutine
阻塞在 channel 接收"]
Wait -->|"无人发送或关闭"| Stuck["永久阻塞"]
Main --> Return["主 goroutine 继续"]
Return --> Forget["忘记子 goroutine"]
Forget --> Leak["goroutine 泄露
内存不释放"]
Stuck --> Leak桥接到工程:每次写 go func() 时,问自己两个问题——“这个 goroutine 什么时候退出?“和"如果出错了,它还能退出吗?“如果答不上来,大概率会泄露。
3.2 工程要点
什么是 goroutine 泄露
goroutine 泄露是指 goroutine 启动后,因为某种原因永远阻塞,既无法继续执行,也无法被回收,直到程序结束。随着泄露累积,内存持续增长,最终导致 OOM。
泄露的常见原因
// 原因1:channel 发送无接收者
func leakSend() {
ch := make(chan int) // 无缓冲 channel
go func() {
// 步骤1:永远阻塞,因为 main 没有接收
ch <- 42
fmt.Println("这行永远不会执行")
}()
// 步骤2:函数返回,ch 无人引用
// 但 goroutine 还在等接收者
}
// 原因2:channel 接收无发送者
func leakReceive() {
ch := make(chan int)
go func() {
// 步骤1:永远阻塞,等待数据
val := <-ch
fmt.Println("收到:", val)
}()
// 步骤2:函数返回,没人往 ch 发数据
}
// 原因3:context 未取消
func leakContext() {
ch := make(chan struct{})
go func() {
select {
case <-ch:
// 步骤1:等待信号,但没人关闭 ch
}
}()
// 步骤2:忘记关闭 ch 或没有 context 超时
}
泄露的正确修复
package main
import (
"context"
"fmt"
"time"
)
// 修复版:使用 context 超时
func safeWorker(ctx context.Context) {
ch := make(chan int, 1)
go func() {
// 步骤1:模拟耗时操作
time.Sleep(2 * time.Second)
ch <- 42
}()
select {
case val := <-ch:
// 步骤2:正常收到结果
fmt.Println("收到结果:", val)
case <-ctx.Done():
// 步骤3:超时或取消,goroutine 可以退出
fmt.Println("超时退出:", ctx.Err())
}
}
func main() {
// 步骤4:设置 1 秒超时
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
safeWorker(ctx)
time.Sleep(500 * time.Millisecond)
}
⚠️ 新手必踩的坑: 修复后的代码中,如果
time.Sleep(2*time.Second)的 goroutine 在超时后才完成,它仍然会往ch发送数据。由于ch是有缓冲的(make(chan int, 1)),发送不会阻塞,goroutine 可以正常退出。如果用的是无缓冲 channel,goroutine 仍然会泄露。所以要么用有缓冲 channel,要么在 goroutine 内部也监听ctx.Done()。
如何定位 goroutine 泄露
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
"time"
)
func leakyFunc() {
ch := make(chan int)
go func() {
<-ch // 永久阻塞
}()
}
func main() {
// 步骤1:记录初始 goroutine 数量
fmt.Println("初始 goroutine 数:", runtime.NumGoroutine())
// 步骤2:反复调用泄露函数
for i := 0; i < 100; i++ {
leakyFunc()
}
time.Sleep(time.Second)
fmt.Println("泄露后 goroutine 数:", runtime.NumGoroutine())
// 步骤3:导出 goroutine profile
f, _ := os.Create("/tmp/goroutine.prof")
defer f.Close()
pprof.Lookup("goroutine").WriteTo(f, 2)
// 步骤4:用 go tool pprof /tmp/goroutine.prof 分析
// 在 pprof 交互界面输入: top, list leakyFunc
}
goroutine 可能引发的问题
| 问题 | 描述 | 危害等级 |
|---|---|---|
| 泄露 | goroutine 永久阻塞无法退出 | 高(内存持续增长) |
| 泛滥 | 创建速度远超消费速度 | 高(资源耗尽) |
| 数据竞争 | 多个 goroutine 同时读写共享变量 | 高(结果不确定) |
| 死锁 | goroutine 互相等待对方释放资源 | 高(程序挂起) |
协程使用注意两个方面
- 泄露:每个 goroutine 都要有退出路径(channel 关闭或 context 取消)
- 并发安全:访问共享变量必须加锁或使用 atomic,或用 channel 传递数据
4、sync.Mutex 与锁
4.1 用生活类比先建立直觉
把 Mutex 想象成公共厕所的门锁:
- 加锁 = 进去后锁门
- 解锁 = 出来后开门
- 其他人来了发现门锁着 = 阻塞等待
- 自旋 = 不停推门看看开了没(最多推 4 次)
- 饥饿模式 = 有人等太久(超过 1ms),直接把钥匙递给排在最前面的人
悲观锁就像”先占坑再办事"——不管有没有人抢,先锁门再说。
乐观锁就像”先办事再检查"——先无锁操作,提交时检查中间有没有人改过(CAS)。
graph TD
TryLock["尝试获取锁"] -->|"正常模式"| Spin["自旋等待
最多 4 次"]
Spin -->|"自旋成功"| Acquire["获得锁"]
Spin -->|"自旋失败"| Queue["加入等待队列"]
Queue -->|"等待超过 1ms"| Starve["切换到饥饿模式"]
Starve --> Handoff["直接交给队首
不自旋"]
Handoff --> Acquire
Acquire -->|"队首获取成功且
等待时间小于 1ms"| Normal["切回正常模式"]
Queue -->|"正常获取"| Acquire
Normal --> TryLock桥接到工程:Go 的 Mutex 在"公平"和"性能"之间做了权衡。正常模式偏性能(自旋减少切换),饥饿模式偏公平(防止饿死)。理解这个切换逻辑是面试加分项。
4.2 工程要点
Mutex 是乐观锁还是悲观锁
Mutex 是悲观锁。每次访问共享资源前先加锁,确保独占访问,操作完成后才解锁。
乐观锁 vs 悲观锁
| 对比项 | 悲观锁 (Mutex) | 乐观锁 (CAS/atomic) |
|---|---|---|
| 核心思想 | 先加锁再访问 | 先操作再验证 |
| 实现机制 | 操作系统信号量 | CPU 原子指令 (CAS) |
| 适用场景 | 写多读少、临界区长 | 读多写少、临界区短 |
| 性能 | 有锁开销和上下文切换 | 无锁,但竞争激烈时重试开销大 |
| 公平性 | 可实现公平(饥饿模式) | 无公平性保证 |
Mutex 的两种模式
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var counter int
// 步骤1:模拟正常模式下的高并发竞争
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 步骤2:加锁-操作-解锁
mu.Lock()
counter++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("最终计数:", counter) // 1000
}
正常模式:
- 新来的 goroutine 会先尝试自旋(最多 4 次)
- 自旋成功就直接获取锁,不用排队
- 优点:性能好(减少上下文切换)
- 缺点:队列中的 goroutine 可能被"插队"导致饿死
饥饿模式:
- 当一个 goroutine 等待超过 1ms 仍未获取锁时触发
- 锁释放时直接交给队列首部的 goroutine(不自旋)
- 优点:保证公平性
- 缺点:性能下降(不能自旋)
- 当队首 goroutine 获取锁后等待时间小于 1ms,切回正常模式
Mutex 最多支持多少协程排队
Mutex 没有硬性上限。等待队列通过 Go 运行时的信号量(semaphore)实现,理论上只受内存限制。但实际中,如果一个 Mutex 有数千个 goroutine 排队,说明设计有问题,应该考虑用其他并发模式(如 channel、分片锁)。
RWMutex 读写锁
package main
import (
"fmt"
"sync"
"time"
)
type SafeCache struct {
mu sync.RWMutex
data map[string]string
}
func NewSafeCache() *SafeCache {
return &SafeCache{
data: make(map[string]string),
}
}
// 步骤1:读操作用 RLock(多读并发)
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
// 步骤2:写操作用 Lock(独占)
func (c *SafeCache) Set(key, val string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = val
}
func main() {
cache := NewSafeCache()
// 步骤3:并发读
for i := 0; i < 5; i++ {
go func(id int) {
for {
if val, ok := cache.Get("name"); ok {
fmt.Printf("reader %d: %s\n", id, val)
}
time.Sleep(100 * time.Millisecond)
}
}(i)
}
// 步骤4:并发写
for i := 0; i < 3; i++ {
go func(id int) {
for {
cache.Set("name", fmt.Sprintf("writer-%d", id))
time.Sleep(500 * time.Millisecond)
}
}(i)
}
time.Sleep(3 * time.Second)
}
| 对比项 | Mutex | RWMutex |
|---|---|---|
| 读并发 | 不支持(读也要互斥) | 支持(多个读可并发) |
| 写并发 | 不支持 | 不支持 |
| 适用场景 | 读写都多或写多读少 | 读多写少 |
| 性能 | 简单,开销小 | 读多时性能更好,但锁本身更重 |
map 手动加锁 vs sync.Map
// 方式1:map + RWMutex(适合读多写少)
type SafeMap struct {
mu sync.RWMutex
data map[string]interface{}
}
// 方式2:sync.Map(适合 key 稳定、读远多于写)
var m sync.Map
m.Store("key", "value") // 存储
val, ok := m.Load("key") // 读取
m.Delete("key") // 删除
⚠️ 新手必踩的坑: 原生 map 并发读写会 panic(
fatal error: concurrent map read and map write)。这不是普通的 data race,而是 Go 运行时主动检测并终止程序。必须用 RWMutex 包裹或使用 sync.Map。sync.Map 的详细对比见本系列第一章。
5、atomic 原子操作
5.1 用生活类比先建立直觉
把 atomic 操作想象成银行柜台的无锁保险箱:
- Load = 查看保险箱里有多少钱
- Store = 直接放进去一笔钱
- Add = 往里面加钱(一步到位,不会被人打断)
- Swap = 拿出新钱放进去,同时拿走旧钱
- CompareAndSwap (CAS) = “如果里面是我上次看到的金额,就换成新金额”
CAS 就像你先偷看一眼保险箱里有 100 元,然后跟柜员说:“如果里面还是 100 元,就帮我换成 200 元。” 柜员打开一看,如果确实 100 元就换;如果被人改过了就说"不好意思,变了,请重新看一眼”。
graph TD
Read["读取当前值 old"] --> Compare{"当前值等于 old?"}
Compare -->|"相等"| Write["写入新值 new
返回 true"]
Compare -->|"不相等"| Retry["重新读取当前值"]
Retry --> Read
Write --> Done["操作完成"]桥接到工程:atomic 利用 CPU 的原子指令(如 x86 的 LOCK CMPXCHG),在硬件层面保证操作的不可分割性,比 Mutex 轻量得多,不需要进入内核态。
5.2 工程要点
atomic 的五种操作
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// 步骤1:Add — 原子加法
atomic.AddInt64(&counter, 1)
}()
}
wg.Wait()
// 步骤2:Load — 原子读取
fmt.Println("Add 结果:", atomic.LoadInt64(&counter)) // 1000
// 步骤3:Store — 原子写入
atomic.StoreInt64(&counter, 42)
fmt.Println("Store 后:", atomic.LoadInt64(&counter))
// 步骤4:Swap — 原子交换,返回旧值
old := atomic.SwapInt64(&counter, 100)
fmt.Println("Swap 旧值:", old, "新值:", atomic.LoadInt64(&counter))
// 步骤5:CompareAndSwap — 原子比较并交换
success := atomic.CompareAndSwapInt64(&counter, 100, 200)
fmt.Println("CAS 第一次 (100->200):", success, "值:", atomic.LoadInt64(&counter))
// 步骤6:CAS 失败(期望值不匹配)
success = atomic.CompareAndSwapInt64(&counter, 100, 300)
fmt.Println("CAS 第二次 (100->300):", success, "值:", atomic.LoadInt64(&counter))
}
CAS 自旋实现安全计数器
package main
import (
"fmt"
"sync"
"sync/atomic"
)
// 用 CAS 实现原子加法(模拟 atomic.Add 的底层逻辑)
func casAdd(addr *int64, delta int64) {
for {
// 步骤1:读取当前值
old := atomic.LoadInt64(addr)
// 步骤2:计算新值
newVal := old + delta
// 步骤3:尝试 CAS,成功则返回
if atomic.CompareAndSwapInt64(addr, old, newVal) {
return
}
// 步骤4:CAS 失败,说明有其他 goroutine 抢先修改,重试
}
}
func main() {
var counter int64
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
casAdd(&counter, 1)
}()
}
wg.Wait()
fmt.Println("CAS 计数器结果:", counter) // 10000
}
atomic.Value 存储任意类型
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Host string
Port int
}
func main() {
var config atomic.Value
// 步骤1:首次存储配置
config.Store(&Config{Host: "localhost", Port: 8080})
var wg sync.WaitGroup
// 步骤2:并发读取
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 步骤3:Load 返回 interface{},需要类型断言
c := config.Load().(*Config)
fmt.Printf("reader %d: %s:%d\n", id, c.Host, c.Port)
}(i)
}
// 步骤4:并发更新
config.Store(&Config{Host: "0.0.0.0", Port: 9090})
wg.Wait()
}
⚠️ 新手必踩的坑:
atomic.Value第一次Store什么类型,之后必须Store相同类型,否则会 panic。而且不能Storenil。如果需要存 nil,可以用指向 nil 的指针。Go 1.19+ 推荐使用atomic.Pointer[T]替代atomic.Value,类型更安全。
atomic vs Mutex
| 对比项 | atomic | Mutex |
|---|---|---|
| 底层机制 | CPU 原子指令 (CAS) | 操作系统信号量 |
| 适用场景 | 简单计数器、标志位 | 复杂临界区、多操作组合 |
| 性能 | 极高(无锁,纳秒级) | 较高(有锁开销,百纳秒级) |
| 功能 | 单个变量的原子读写 | 任意代码块的互斥 |
| 公平性 | 无 | 有(饥饿模式) |
| 代码复杂度 | 简单 | 需注意 Lock/Unlock 配对 |
atomic 应用场景
// 场景1:并发安全的标志位(Go 1.19+ 使用 atomic.Bool)
type Service struct {
running atomic.Bool
}
func (s *Service) Start() {
// 步骤1:CAS 设置为 running
if !s.running.CompareAndSwap(false, true) {
return // 已经在运行
}
// 步骤2:执行启动逻辑
}
func (s *Service) Stop() {
s.running.Store(false)
}
func (s *Service) IsRunning() bool {
return s.running.Load()
}
// 场景2:并发安全计数器
type Counter struct {
count atomic.Int64
}
func (c *Counter) Inc() int64 { return c.count.Add(1) }
func (c *Counter) Get() int64 { return c.count.Load() }
// 场景3:sync.Once 底层就是 atomic + Mutex(见第七章)
6、sync.WaitGroup
6.1 用生活类比先建立直觉
把 WaitGroup 想象成聚餐等人的计数器:
Add(3)= 还有 3 个朋友没到- 每个
Done()= 一个朋友到了,打一个勾 Wait()= 站在门口等所有人到齐才开吃
graph TD
Add["Add 3
counter = 3"] --> G1["启动 goroutine 1"]
Add --> G2["启动 goroutine 2"]
Add --> G3["启动 goroutine 3"]
G1 --> D1["Done
counter = 2"]
G2 --> D2["Done
counter = 1"]
G3 --> D3["Done
counter = 0"]
D1 --> Wait["Wait 阻塞中"]
D2 --> Wait
D3 -->|"counter == 0"| Release["释放信号量
Wait 返回"]
Wait --> Release桥接到工程:WaitGroup 的核心是"计数器 + 信号量”。Add 改计数,Done 减计数,Wait 在计数归零时被信号量唤醒。
6.2 工程要点
底层原理
WaitGroup 内部有三个关键字段:
| 字段 | 作用 | 操作方式 |
|---|---|---|
| counter | 记录未完成的 goroutine 数 | Add(delta) 原子修改 |
| waiter | 记录等待的 goroutine 数 | Wait() 时原子递增 |
| sema | 信号量 | Wait 阻塞,counter 归零时释放 |
Add / Done / Wait 的使用
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
// 步骤1:Add 必须在 goroutine 外部调用
wg.Add(3)
// 步骤2:启动 3 个 goroutine
for i := 1; i <= 3; i++ {
go func(id int) {
// 步骤3:defer Done 确保一定会执行
defer wg.Done()
fmt.Printf("goroutine %d 完成\n", id)
}(i)
}
// 步骤4:Wait 阻塞直到 counter 归零
wg.Wait()
fmt.Println("所有 goroutine 完成")
}
常见坑
package main
import (
"sync"
)
// 坑1:Add 在 goroutine 内部调用(竞争条件!)
func badExample() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // 错误!可能在 Wait 之后才执行
defer wg.Done()
}()
}
wg.Wait() // 可能提前返回
}
// 正确写法
func goodExample() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 正确!在启动 goroutine 前调用
go func() {
defer wg.Done()
}()
}
wg.Wait()
}
// 坑2:Done 调用次数超过 Add 会导致 counter 为负
func negativeExample() {
var wg sync.WaitGroup
wg.Add(1)
wg.Done()
wg.Done() // panic: sync: negative WaitGroup counter
}
// 坑3:Wait 之后可以复用,但要确保之前的 Wait 已返回
func reuseExample() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
}()
// 步骤1:正确等待第一轮
wg.Wait()
// 步骤2:复用,开始第二轮
wg.Add(1)
go func() {
defer wg.Done()
}()
wg.Wait()
}
⚠️ 新手必踩的坑: 最常见的错误就是在 goroutine 内部调用
wg.Add(1)。因为 goroutine 的调度顺序不确定,Wait()可能在Add(1)执行前就发现 counter 为 0 而提前返回。记住铁律:Add 在外,Done 在内(用 defer)。
底层实现详解
// Add 的简化逻辑(实际实现见 runtime/sema.go)
func (wg *WaitGroup) Add(delta int) {
// 步骤1:用 atomic 原子更新 counter(高 32 位)
state := atomic.AddUint64(&wg.state1, uint64(delta)<<32)
v := int32(state >> 32) // counter
w := uint32(state) // waiter
if v == 0 {
// 步骤2:counter 归零,释放所有 waiter 的信号量
for ; w != 0; w-- {
runtime_Semrelease(&wg.sema, false, 0)
}
}
}
// Wait 的简化逻辑
func (wg *WaitGroup) Wait() {
// 步骤1:原子递增 waiter 计数(低 32 位)
state := atomic.AddUint64(&wg.state1, 1)
v := int32(state >> 32) // counter
if v > 0 {
// 步骤2:counter > 0,阻塞在信号量上
runtime_Semacquire(&wg.sema)
}
}
// Done 的简化逻辑
func (wg *WaitGroup) Done() {
// 步骤1:counter 减 1
wg.Add(-1)
}
7、sync.Once 与 sync.Cond
7.1 用生活类比先建立直觉
sync.Once 就像公司的开业剪彩:不管多少人来,剪彩动作只发生一次,来晚了的人直接看到"已开业"状态,不会重复剪彩。
sync.Cond 就像餐厅叫号系统:
Wait()= 拿号坐下等(先交出座位/锁,等叫号)Signal()= 叫一个号Broadcast()= 全部叫号(如"停电了,大家都走”)
桥接到工程:Once 保证初始化代码只执行一次;Cond 提供"等待-通知"机制,适合生产者-消费者场景。
7.2 工程要点
sync.Once
package main
import (
"fmt"
"sync"
)
type Singleton struct {
name string
}
var (
instance *Singleton
once sync.Once
)
// 步骤1:GetInstance 保证只初始化一次
func GetInstance() *Singleton {
once.Do(func() {
// 步骤2:这段代码只会执行一次
fmt.Println("初始化 Singleton...")
instance = &Singleton{name: "我是唯一的实例"}
})
return instance
}
func main() {
var wg sync.WaitGroup
// 步骤3:并发调用 GetInstance
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
s := GetInstance()
fmt.Printf("goroutine %d: %s\n", id, s.name)
}(i)
}
wg.Wait()
// 输出中 "初始化 Singleton..." 只出现一次
}
sync.Once 底层实现(简化版,展示双检查锁逻辑):
// 简化的 Once 底层逻辑
type Once struct {
done atomic.Uint32 // 标志位:0=未执行, 1=已执行
m sync.Mutex // 互斥锁
}
func (o *Once) Do(f func()) {
// 步骤1:快速路径——atomic 检查是否已执行(无锁)
if o.done.Load() == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 步骤2:双检查——防止多个 goroutine 同时通过第一次检查
if o.done.Load() == 0 {
// 步骤3:执行目标函数
f()
// 步骤4:标记为已执行
o.done.Store(1)
}
}
sync.Cond
package main
import (
"fmt"
"sync"
"time"
)
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
}
func NewQueue() *Queue {
q := &Queue{}
// 步骤1:cond 必须关联一个 Mutex
q.cond = sync.NewCond(&q.mu)
return q
}
// 步骤2:消费者——等待数据
func (q *Queue) Consume() int {
q.mu.Lock()
defer q.mu.Unlock()
// 步骤3:队列为空时等待(必须用 for,不能用 if)
for len(q.items) == 0 {
// Wait 内部会:释放锁 -> 阻塞 -> 被唤醒 -> 重新获取锁
q.cond.Wait()
}
item := q.items[0]
q.items = q.items[1:]
return item
}
// 步骤4:生产者——添加数据并通知
func (q *Queue) Produce(item int) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
// 步骤5:唤醒一个等待的消费者
q.cond.Signal()
// 步骤6:如果要唤醒所有等待者,用 Broadcast()
// q.cond.Broadcast()
}
func main() {
q := NewQueue()
// 步骤7:启动两个消费者
var wg sync.WaitGroup
for i := 1; i <= 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
val := q.Consume()
fmt.Printf("消费者 %d 收到: %d\n", id, val)
}(i)
}
// 步骤8:生产者往队列添加数据
time.Sleep(500 * time.Millisecond)
q.Produce(42)
q.Produce(100)
wg.Wait()
}
⚠️ 新手必踩的坑:
cond.Wait()必须在for循环中调用,不能在if中。因为被唤醒后,可能其他 goroutine 已经抢先消费了数据(虚假唤醒)。必须在循环中重新检查条件。这也是 Go 官方文档强调的。
sync.Cond vs channel
| 对比项 | sync.Cond | channel |
|---|---|---|
| 通信模型 | 共享内存 + 等待通知 | 消息传递 |
| 适用场景 | 条件变量等待(如队列非空) | 数据传递、信号通知 |
| 复杂度 | 较高(需要配合 Mutex) | 较低 |
| Go 推荐 | 优先用 channel | 首选方案 |
8、死锁
8.1 用生活类比先建立直觉
把死锁想象成十字路口的四辆车:
- 车A 等车B 走
- 车B 等车C 走
- 车C 等车D 走
- 车D 等车A 走
没有一辆车能让步,所有人永远等下去——这就是循环等待。
graph LR
G1["goroutine 1
持有锁 A"] -->|"请求锁 B"| G2["goroutine 2
持有锁 B"]
G2 -->|"请求锁 A"| G1
G1 -->|"永远等待"| Dead["死锁
程序挂起"]
G2 -->|"永远等待"| Dead桥接到工程:死锁的根源是"互相持有对方需要的资源"。只要打破四个必要条件中的任何一个,就能避免死锁。
8.2 工程要点
死锁的四个必要条件
| 条件 | 含义 | 打破方法 |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个 goroutine 使用 | 无法打破(锁的本质) |
| 持有等待 | 持有资源的同时等待另一个资源 | 一次性获取所有锁 |
| 不可剥夺 | 不能强行夺走 goroutine 持有的锁 | 使用带超时的锁 |
| 循环等待 | 形成等待环 | 固定加锁顺序 |
死锁示例
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var lockA, lockB sync.Mutex
// 步骤1:goroutine 1 先锁 A 再锁 B
go func() {
lockA.Lock()
fmt.Println("goroutine 1: 获得锁 A")
time.Sleep(100 * time.Millisecond) // 制造时序
lockB.Lock() // 等待 goroutine 2 释放锁 B
fmt.Println("goroutine 1: 获得锁 B")
lockB.Unlock()
lockA.Unlock()
}()
// 步骤2:goroutine 2 先锁 B 再锁 A(顺序相反!)
go func() {
lockB.Lock()
fmt.Println("goroutine 2: 获得锁 B")
time.Sleep(100 * time.Millisecond)
lockA.Lock() // 等待 goroutine 1 释放锁 A
fmt.Println("goroutine 2: 获得锁 A")
lockA.Unlock()
lockB.Unlock()
}()
// 步骤3:程序死锁,永远无法结束
// Go runtime 会检测到并 panic: "all goroutines are deadlock!"
time.Sleep(2 * time.Second)
}
⚠️ 新手必踩的坑: 上面的程序运行后,Go runtime 会检测到所有 goroutine 都在等待,打印
fatal error: all goroutines are deadlock!然后 crash。这是 Go 的内置死锁检测,但它只能检测所有 goroutine 都阻塞的情况。如果只有部分 goroutine 死锁,runtime 不会报错,程序会静默挂起。
如何避免死锁
package main
import (
"sync"
"time"
)
type Account struct {
Balance int
}
// 方法1:固定加锁顺序(推荐)
func safeTransfer(from, to *Account, amount int, muA, muB *sync.Mutex) {
// 步骤1:按地址排序,保证所有 goroutine 的加锁顺序一致
first, second := muA, muB
if &muA > &muB { // 用地址作为排序依据
first, second = muB, muA
}
first.Lock()
defer first.Unlock()
second.Lock()
defer second.Unlock()
// 步骤2:安全操作
from.Balance -= amount
to.Balance += amount
}
// 方法2:使用带超时的锁(避免永久等待)
func tryLockWithTimeout(mu *sync.Mutex, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
mu.Lock()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false // 超时未获取到锁
}
}
// 方法3:避免嵌套锁(减小锁粒度)
func noNesting(mu *sync.Mutex, data map[string]int) int {
// 步骤1:只锁必要部分
mu.Lock()
val := data["key"]
mu.Unlock()
// 步骤2:不持锁的情况下做耗时操作
result := process(val)
// 步骤3:需要写入时再锁
mu.Lock()
data["result"] = result
mu.Unlock()
return result
}
func process(val int) int {
return val * 2
}
如何识别死锁
| 识别方法 | 说明 | 适用场景 |
|---|---|---|
| runtime 自动检测 | 所有 goroutine 阻塞时 panic | 全局死锁 |
| pprof goroutine | 导出 goroutine 调用栈 | 部分死锁 |
| NumGoroutine 监控 | goroutine 数持续增长 | 疑似死锁 |
| go-deadlock 库 | 运行时检测锁顺序 | 开发测试环境 |
package main
import (
"fmt"
"os"
"runtime"
"runtime/pprof"
"time"
)
func detectDeadlock() {
// 步骤1:监控 goroutine 数量
ticker := time.NewTicker(time.Second)
go func() {
for {
<-ticker.C
fmt.Println("当前 goroutine 数:", runtime.NumGoroutine())
}
}()
// 步骤2:导出 goroutine profile 供分析
go func() {
time.Sleep(5 * time.Second)
f, _ := os.Create("/tmp/goroutine.prof")
pprof.Lookup("goroutine").WriteTo(f, 2)
f.Close()
fmt.Println("profile 已导出到 /tmp/goroutine.prof")
}()
}
9、map/slice 未初始化的 panic
9.1 用生活类比先建立直觉
把 nil 想象成一个还没装修的毛坯房:
- nil map = 毛坯房里没有柜子,你往墙上挂衣服 -> 墙塌了(panic)
- nil slice = 毛坯房里没有柜子,但你搬了个新柜子进来放东西 -> 可以(append 分配底层数组)
- nil channel = 一根两头都不通的管子,往里面倒水永远倒不进去,也流不出来(永久阻塞)
桥接到工程:Go 的 nil 不是"空值"那么简单,不同类型对 nil 的行为完全不同,这是面试常考的陷阱题。
9.2 工程要点
nil map 写操作 panic
package main
import "fmt"
func main() {
// 步骤1:nil map 写入会 panic
var m map[string]int // 声明但未初始化,m == nil
// m["key"] = 1 // panic: assignment to entry in nil map
// 步骤2:正确做法——先 make 初始化
m2 := make(map[string]int)
m2["key"] = 1 // 正常
fmt.Println("初始化后写入:", m2)
// 步骤3:nil map 的读取是安全的(返回零值)
var m3 map[string]int // nil map
val := m3["key"] // 不 panic,返回 0
fmt.Println("nil map 读取:", val)
}
| 操作 | nil map | 已初始化 map |
|---|---|---|
写入 m[k]=v | panic | 正常 |
读取 m[k] | 返回零值 | 正常 |
删除 delete(m,k) | 不 panic(无操作) | 正常 |
遍历 range m | 不 panic(0 次) | 正常 |
长度 len(m) | 0 | 实际长度 |
nil slice 的 append
package main
import "fmt"
func main() {
// 步骤1:nil slice 可以 append(会分配底层数组)
var s []int // s == nil
s = append(s, 1, 2, 3)
fmt.Println("nil slice append:", s) // [1 2 3]
// 步骤2:nil slice 的其他操作
var s2 []int
fmt.Println("len:", len(s2)) // 0
fmt.Println("cap:", cap(s2)) // 0
fmt.Println("nil?:", s2 == nil) // true
// 步骤3:nil slice 遍历安全
for _, v := range s2 {
fmt.Println(v) // 不会执行
}
}
⚠️ 新手必踩的坑:
var s []int和s := []int{}是不同的。前者是 nil slice(底层指针为 nil),后者是空 slice(底层指针非 nil,长度为 0)。大多数场景两者行为一致,但在 JSON 序列化时:nil slice 序列化为null,空 slice 序列化为[]。API 返回时要注意这个差异。
nil channel 永久阻塞
package main
import (
"fmt"
"time"
)
func main() {
var ch chan int // ch == nil
// 步骤1:nil channel 发送永久阻塞
go func() {
ch <- 42 // 永远阻塞在这里
}()
// 步骤2:nil channel 接收永久阻塞
go func() {
<-ch // 永远阻塞在这里
}()
// 步骤3:nil channel 在 select 中的妙用
// 利用 nil channel 在 select 中"禁用"某个分支
ch1 := make(chan int, 1)
ch1 <- 1
var ch2 chan int = nil // 故意设为 nil
select {
case val := <-ch1:
fmt.Println("从 ch1 收到:", val)
case val := <-ch2:
// 步骤4:ch2 为 nil,这个分支永远不会被选中
fmt.Println("从 ch2 收到:", val)
}
time.Sleep(100 * time.Millisecond)
}
各类型 nil 行为总结
| 类型 | nil 的行为 | 是否安全 |
|---|---|---|
| map | 写入 panic,读取返回零值 | 写入不安全 |
| slice | append 安全(分配数组),读取返回零值 | 安全 |
| channel | 发送/接收永久阻塞 | 不安全(但可利用) |
| pointer | 解引用 panic | 不安全 |
| interface | 调用方法 panic | 不安全 |
| function | 调用 panic | 不安全 |
// 最佳实践:统一用 make 初始化
func initCollections() {
// 步骤1:map 用 make 初始化
m := make(map[string]int)
// 步骤2:slice 声明 nil 可以,需要时 append
var s []int
s = append(s, 1)
// 步骤3:channel 用 make 初始化
ch := make(chan int, 10)
_ = m
_ = s
_ = ch
}
10、CSP 模型与共享变量通信
10.1 用生活类比先建立直觉
同一个办公室要维护一份"今日订单总数",有两种做法:
- 共享变量派:墙上挂一块公共白板,谁要改数字,先去抢那支唯一的马克笔(锁),改完把笔放回去。改的人越多,抢笔的时间越长,而且总有人忘了放笔(忘 Unlock)、或者两个人各拿一支笔互相等对方(死锁)。
- CSP 派:白板锁进一个人的办公室,只有他能改。其他人要加数就往门缝塞一张纸条(channel 发消息),要查数就塞一张"请把结果写在这张回执上"的纸条。数据从头到尾只被一个 goroutine 摸过,所以根本不需要笔,也就不存在抢笔问题。
graph TD
subgraph SM["共享内存派:共享变量 + 锁"]
A1["goroutine A"] -->|"抢锁后改"| W["公共白板 counter"]
A2["goroutine B"] -->|"抢锁后改"| W
A3["goroutine C"] -->|"排队等锁"| W
end
subgraph CSPG["CSP 派:消息传递"]
B1["goroutine A"] -->|"发消息"| CH["channel 传送带"]
B2["goroutine B"] -->|"发消息"| CH
CH --> OWN["数据所有者 goroutine
counter 是它的局部变量"]
end这张图在讲:两派的分歧不在"用什么工具",而在数据的所有权归谁——是大家共有(需要锁来仲裁),还是独属于一个 goroutine(用消息排队,天然串行)。
桥接到工程:CSP 全称 Communicating Sequential Processes(Hoare, 1978),是一套并发理论模型——进程之间不共享内存,只通过消息通道通信。Go 把它落地成两个语言级设施:goroutine(顺序执行的进程)+ channel(通信通道)。所以那句 Go 谚语该这么读:“Do not communicate by sharing memory; instead, share memory by communicating”——别靠共享内存来通信,要靠通信来共享内存。
10.2 工程要点
两种通信模型的本质区别
| 对比项 | 共享变量通信(共享内存) | CSP 通信(消息传递) |
|---|---|---|
| 数据所有权 | 多个 goroutine 共有 | 同一时刻只归一个 goroutine |
| 同步手段 | sync.Mutex / RWMutex / atomic | channel 的发送与接收 |
| 正确性依赖 | 依赖程序员"每处访问都记得加锁" | 依赖"数据不逃出所有者"这一结构约束 |
| 典型故障 | 数据竞争、忘解锁、死锁、锁粒度过大 | channel 泄露、死锁(无人收/无人发)、goroutine 泄露 |
| 可组合性 | 差:多个锁组合就要考虑加锁顺序 | 好:select 天然能组合多路事件 + 超时 |
| 关注点 | 保护"临界区" | 编排"数据流动" |
| 性能 | 单变量高频更新更快(尤其 atomic) | 有调度与拷贝开销,但可控且易扩展 |
| Go 中的定位 | 底层基石(channel 内部也用它实现) | 上层推荐范式 |
同一需求的两种写法
先看共享变量派:
// 写法 A:共享变量 + Mutex(共享内存派)
type CounterMutex struct {
mu sync.Mutex
n int
}
func (c *CounterMutex) Inc() {
c.mu.Lock() // 步骤1:抢"那支唯一的笔"
c.n++ // 步骤2:改公共白板
c.mu.Unlock() // 步骤3:把笔放回去(漏了这步,全程序卡死)
}
func (c *CounterMutex) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n // 步骤4:读也必须加锁,否则是数据竞争
}
再看 CSP 派——注意 n 变成了某个 goroutine 的局部变量,全程没有任何锁:
// 写法 B:CSP —— 数据只归一个 goroutine 所有,别人通过 channel 请求
type CounterCSP struct {
incCh chan struct{} // 写请求通道
readCh chan chan int // 读请求通道:把"回执信封"一起递进去
}
func NewCounterCSP(ctx context.Context) *CounterCSP {
c := &CounterCSP{
incCh: make(chan struct{}, 128), // 带缓冲,削峰
readCh: make(chan chan int),
}
// 步骤1:唯一的"数据所有者" goroutine
go func() {
n := 0 // 步骤2:n 是局部变量,除它之外没人能碰 —— 天然无竞争
for {
select {
case <-c.incCh:
n++ // 步骤3:串行处理,不需要任何锁
case reply := <-c.readCh:
reply <- n // 步骤4:读也走消息,把快照回传给请求方
case <-ctx.Done():
return // 步骤5:明确的退出路径,避免所有者 goroutine 泄露
}
}
}()
return c
}
func (c *CounterCSP) Inc() { c.incCh <- struct{}{} }
func (c *CounterCSP) Get() int {
reply := make(chan int) // 步骤6:每次请求自带回执 channel,结果不会串号
c.readCh <- reply
return <-reply
}
⚠️ 新手必踩的坑:CSP 不等于"channel 就是快的、安全的"。三个常见误解:
- channel 底层也是锁。
hchan结构里有一把lock,收发都要抢。所以单个 int 计数器用atomic比用 channel 快一个数量级,别为了"信仰 CSP"把计数器改成消息传递。- channel 传指针 = 又回到共享内存。
ch <- ptr之后如果发送方还继续读写*ptr,数据竞争一分不少。CSP 的前提是所有权随消息转移,发出去就别再碰。- CSP 也会死锁。无缓冲 channel 双方互等、
select里所有分支都不可能就绪,照样卡死;只是故障形态从"忘解锁"变成了"没人收/没人发"。
什么时候用哪个
Go 官方 FAQ 的态度并非"channel 万能",而是 “Use whichever is more expressive”(哪个表达力强用哪个)。落到实践上:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 计数器、开关标志位、统计指标 | atomic | 单变量、临界区极短,无锁最快 |
| 缓存、配置表等"结构体状态 + 读多写少" | RWMutex | 保护的是一坨字段,改成消息传递反而绕 |
| 任务分发、流水线、事件驱动、扇入扇出 | channel(CSP) | 关注点是数据流动与编排,select 可组合超时/取消 |
| 复杂状态机(如连接状态、会话状态) | CSP:单一所有者 goroutine | 状态只被一个 goroutine 修改,逻辑天然串行、好推理 |
| 需要超时、取消、优先级 | channel + context | 锁没有超时语义,channel 有 |
一句面试可以直接说的总结:锁是"保护数据不被同时访问",CSP 是"让数据压根不被同时访问"。前者治标,后者改结构;Go 提供了两套,共享内存是地基,CSP 是推荐的门面。
11、消息处理协程池(Worker Pool)
11.1 用生活类比先建立直觉
一家外卖店突然涌进 10000 单,两种应对方式:
- 来一单招一个厨师(
for range msgs { go handle(msg) }):厨房瞬间挤进 10000 个人,谁都动不了——对应到工程里就是 goroutine 数量失控,内存暴涨、调度器被打满、下游数据库连接被瞬间打爆。 - 固定 3 个厨师 + 一条点单传送带(worker pool):订单排在传送带上(jobs channel),3 个厨师循环从传送带取单做菜,做完把餐盒放到出餐台(results channel)。传送带满了,前台就先接不了单——这就是天然的背压(back pressure)。
graph LR
P1["生产者 1
接单"] --> JQ["jobs channel
缓冲队列 = 背压阀门"]
P2["生产者 2
接单"] --> JQ
JQ --> W1["worker 1"]
JQ --> W2["worker 2"]
JQ --> W3["worker 3"]
W1 --> RQ["results channel
出餐台"]
W2 --> RQ
W3 --> RQ
RQ --> C["汇总 goroutine
写日志 / 落库"]这张图在讲:worker pool 的三个要件——一条有界的任务队列、数量固定的消费者、一个独立的结果消费方。三者缺一都会出问题。
桥接到工程:协程池解决的不是"goroutine 太贵"(它很便宜),而是并发度必须有上限——下游的数据库连接数、第三方接口 QPS、本机内存都是有限资源。池子的 workers 数就是你对下游承诺的并发上限。
11.2 工程要点
完整可运行的消息处理协程池
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Job 一条待处理消息
type Job struct {
ID int
Payload string
}
// Result 处理结果(成功/失败都往回报,便于统计)
type Result struct {
JobID int
Output string
Err error
}
type Pool struct {
jobs chan Job
results chan Result
wg sync.WaitGroup
workers int
}
// 步骤1:queueSize 决定缓冲深度 —— 有界队列才有背压,别用无界 slice 当队列
func NewPool(workers, queueSize int) *Pool {
return &Pool{
jobs: make(chan Job, queueSize),
results: make(chan Result, queueSize),
workers: workers,
}
}
// 步骤2:Start 一次性启动固定数量的 worker,goroutine 总数从此可控
func (p *Pool) Start(ctx context.Context) {
for i := 1; i <= p.workers; i++ {
p.wg.Add(1) // Add 必须在 go 之前(见第 6 章)
go p.worker(ctx, i)
}
}
func (p *Pool) worker(ctx context.Context, id int) {
defer p.wg.Done()
for {
select {
case job, ok := <-p.jobs:
// 步骤3:ok == false 说明 jobs 已被生产者 close 且取空,正常退场
if !ok {
return
}
p.results <- p.handle(id, job)
case <-ctx.Done():
// 步骤4:外部超时/服务下线,立刻停手,不再取新活
return
}
}
}
// 步骤5:单条消息的处理逻辑。必须用 defer recover 兜住业务 panic,
// 否则一条脏数据引发的 panic 会带崩整个进程
func (p *Pool) handle(workerID int, job Job) (r Result) {
defer func() {
if e := recover(); e != nil {
r = Result{JobID: job.ID, Err: fmt.Errorf("panic: %v", e)}
}
}()
time.Sleep(50 * time.Millisecond) // 模拟业务耗时
return Result{
JobID: job.ID,
Output: fmt.Sprintf("worker-%d 处理了 %s", workerID, job.Payload),
}
}
// 步骤6:投递消息。队列满时这里会阻塞,压力自然回传给上游 —— 这是特性不是 bug
func (p *Pool) Submit(job Job) { p.jobs <- job }
// 步骤7:优雅关闭的固定套路:先 close(jobs) → 等 worker 全退 → 再 close(results)
// 顺序反了会 "send on closed channel" panic
func (p *Pool) Stop() {
close(p.jobs)
p.wg.Wait()
close(p.results)
}
func (p *Pool) Results() <-chan Result { return p.results }
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pool := NewPool(3, 16) // 3 个 worker,队列容量 16
pool.Start(ctx)
// 步骤8:结果消费必须与投递并发进行。
// 如果先投完再来收,results 写满 16 条后 worker 会全部卡在发送上(死锁)
var collect sync.WaitGroup
collect.Add(1)
go func() {
defer collect.Done()
for r := range pool.Results() { // close(results) 后循环自动结束
if r.Err != nil {
fmt.Printf("job %d 失败: %v\n", r.JobID, r.Err)
continue
}
fmt.Println(r.Output)
}
}()
// 步骤9:生产者投递消息
for i := 1; i <= 10; i++ {
pool.Submit(Job{ID: i, Payload: fmt.Sprintf("msg-%d", i)})
}
pool.Stop() // 步骤10:投递方负责关闭 jobs
collect.Wait() // 步骤11:等结果收完再退出 main
fmt.Println("全部消息处理完毕")
}
⚠️ 新手必踩的坑(四条铁律):
- close 只能由发送方做,绝不能让 worker 去
close(p.jobs)——多个 worker 重复 close 直接panic: close of closed channel。- results 必须有人在并发地收。很多人写完 pool 后先
Submit完再for range results,结果队列写满、worker 全阻塞在p.results <- ...,程序静默挂死。- worker 里必须 recover。池里的 goroutine panic 不会被 main 的 recover 捕获,整个进程直接退出。
- 别忘了 ctx 分支。只监听
<-p.jobs的 worker,在上游卡住时永远退不出去,就是第 3 章讲的 goroutine 泄露。
轻量替代:用 buffered channel 当信号量限并发
如果任务是一批一次性的(不需要常驻 worker),没必要建池子,用带缓冲 channel 做信号量更简单:
// 限制最大并发为 limit,跑完一批就结束
func runWithLimit(tasks []func(), limit int) {
sem := make(chan struct{}, limit) // 步骤1:容量 = 并发上限,即"令牌总数"
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
sem <- struct{}{} // 步骤2:拿一个令牌,令牌用光就在这里阻塞排队
go func(f func()) {
defer wg.Done()
defer func() { <-sem }() // 步骤3:无论成功失败都要归还令牌
defer func() {
if e := recover(); e != nil { /* 步骤4:兜住 panic */ }
}()
f()
}(task)
}
wg.Wait() // 步骤5:等这一批全部结束
}
两种方案怎么选
| 维度 | 常驻 Worker Pool | Semaphore 限并发 |
|---|---|---|
| goroutine 生命周期 | 长期常驻,复用 | 每个任务一个,用完即弃 |
| 适用场景 | 长期运行的消息消费(MQ 消费者、日志处理) | 一次性批量任务(批量拉取、批量导出) |
| 队列语义 | 有界队列 + 背压 + 可观测队列长度 | 无显式队列,靠阻塞排队 |
| 优雅退出 | 支持(close + WaitGroup + ctx) | 靠 WaitGroup 自然结束 |
| 复杂度 | 高,但可扩展(限流、重试、指标) | 低,二十行搞定 |
工程上还有两个进阶点值得知道:动态扩缩容(监控 len(p.jobs),队列持续积压时临时多起几个 worker,空闲一段时间后自动退出)和失败重试(Result 带 retryCount,未超限就重新 Submit,注意要防止重试风暴打满队列)。生产环境也可以直接用成熟库(如 ants),但面试要求手写这份骨架。
12、多服务并发读写同一份数据的正确性保障
12.1 用生活类比先建立直觉
同一个仓库要防止两个人同时搬走最后一箱货:
- 同一间办公室(单进程多 goroutine):门上挂一把钥匙就够了——这就是
RWMutex。钥匙在内存里,大家看得见同一把。 - 两栋不同的楼(多个服务实例 / 多个 Pod):A 楼的钥匙管不了 B 楼的人,因为进程内存不共享。这时必须去楼下物业前台领唯一的通行证——物业就是 Redis / 数据库 / etcd,谁领到证谁进仓库,这就是分布式锁。
- 更聪明的办法:不领证,直接在出库单上写"我看到库存是 7,请在库存仍为 7 时扣减"。物业照单核对,对不上就退单让你重来——这就是乐观锁(版本号 CAS)。
graph TD
Q{"数据在哪个层面被并发访问?"} -->|"同一进程内的单个变量"| L1["atomic:CAS 无锁更新"]
Q -->|"同一进程内的一坨状态"| L2["RWMutex / 分片锁
或 channel 交给单一所有者"]
Q -->|"多进程 / 多服务实例"| L3["把裁决权交给共享存储"]
L3 --> DB1["DB 乐观锁
version 字段 CAS"]
L3 --> DB2["DB 悲观锁
SELECT FOR UPDATE"]
L3 --> RD["Redis 分布式锁
SET NX PX + Lua 解锁"]
L3 --> ET["etcd / ZooKeeper
强一致 + lease 自动续期"]这张图在讲:先问清"并发发生在哪一层",再选工具。层次判断错了,方案必然错——单机用分布式锁是浪费,多实例用 Mutex 是纯粹的自欺欺人。
桥接到工程:面试被问"多个服务并发读写同一份数据怎么保证正确性",答题主线就是这张图:单进程内靠内存同步原语,跨进程靠外部存储做唯一裁决。锁的本质从来不是"锁",而是"所有竞争者都认同的同一个裁判"。
12.2 工程要点
层次一:单进程内 —— RWMutex 保护共享状态
读多写少的共享缓存,用 RWMutex 就是标准答案(完整代码见第 4 章 SafeCache)。竞争特别激烈时用分片锁降低粒度:
// 分片锁:把一把大锁拆成 N 把小锁,按 key 哈希打散,冲突概率降到 1/N
const shardCount = 32
type ShardedMap struct {
shards [shardCount]struct {
mu sync.RWMutex
data map[string]int
}
}
func NewShardedMap() *ShardedMap {
m := &ShardedMap{}
for i := range m.shards {
m.shards[i].data = make(map[string]int) // 步骤1:每片独立初始化
}
return m
}
// 步骤2:用 key 的哈希决定落在哪一片,不同片的读写完全互不阻塞
func (m *ShardedMap) shard(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32()) % shardCount
}
func (m *ShardedMap) Set(key string, val int) {
s := &m.shards[m.shard(key)]
s.mu.Lock() // 步骤3:只锁这一片
defer s.mu.Unlock()
s.data[key] = val
}
func (m *ShardedMap) Get(key string) (int, bool) {
s := &m.shards[m.shard(key)]
s.mu.RLock() // 步骤4:读锁,同片内多读并发
defer s.mu.RUnlock()
v, ok := s.data[key]
return v, ok
}
层次二:单进程内 —— 把数据交给唯一所有者(CSP 串行化)
如果操作逻辑复杂(读-改-写要跨多个字段、还要发通知),锁很容易漏。这时用第 10 章的思路:让一个 goroutine 独占数据,所有请求排队进来,天然串行、无需加锁,也不可能死锁。
层次三:跨进程 / 多服务 —— 数据库乐观锁与悲观锁
最容易被忽略的事实:如果数据本身在数据库里,数据库自己就是那个"唯一裁判",很多时候根本不需要额外的分布式锁。
-- 方案 A:乐观锁(version 字段做 CAS)。冲突时受影响行数为 0,业务层重试
UPDATE stock
SET count = count - 1, version = version + 1
WHERE id = 1001 AND version = 7;
-- 方案 A+:更省事的写法 —— 把业务校验直接写进 WHERE,由数据库保证单条 UPDATE 的原子性
-- 这一条就能防超卖,不需要任何锁
UPDATE stock SET count = count - 1 WHERE id = 1001 AND count >= 1;
-- 方案 B:悲观锁。事务内先锁住行,适合"临界区里要做多次读写"的场景
BEGIN;
SELECT count FROM stock WHERE id = 1001 FOR UPDATE; -- 锁行,其他事务在此阻塞
UPDATE stock SET count = count - 1 WHERE id = 1001;
COMMIT; -- 提交即释放锁
Go 侧配合乐观锁的重试逻辑:
// 乐观锁重试:CAS 失败就重读重试,注意一定要有重试上限
func deductStock(ctx context.Context, db *sql.DB, id int) error {
const maxRetry = 3
for i := 0; i < maxRetry; i++ {
// 步骤1:读出当前值和版本号
var count, version int
err := db.QueryRowContext(ctx,
"SELECT count, version FROM stock WHERE id = ?", id).Scan(&count, &version)
if err != nil {
return err
}
if count <= 0 {
return errors.New("库存不足")
}
// 步骤2:带版本号条件更新 —— 这就是数据库层面的 CompareAndSwap
res, err := db.ExecContext(ctx,
"UPDATE stock SET count = count - 1, version = version + 1 WHERE id = ? AND version = ?",
id, version)
if err != nil {
return err
}
// 步骤3:影响行数为 0 说明版本变了(被别人抢先改过),退回重试
if n, _ := res.RowsAffected(); n > 0 {
return nil
}
}
return errors.New("并发冲突,重试次数已用尽")
}
层次三:跨进程 / 多服务 —— Redis 分布式锁
数据不在单一数据库里(比如要保护"扣库存 + 发消息 + 写缓存"这一整套跨资源操作)时,才需要显式分布式锁:
package lock
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
// 步骤1:解锁脚本 —— "比对 value 再删除"必须是一个原子动作。
// 分成 GET + DEL 两条命令的话,中间锁过期被别人拿到,你的 DEL 就删了别人的锁
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
type DistLock struct {
rdb *redis.Client
key string
token string // 步骤2:本次持锁的唯一凭证,防止误删他人锁
ttl time.Duration
}
func New(rdb *redis.Client, key string, ttl time.Duration) *DistLock {
return &DistLock{rdb: rdb, key: key, token: uuid.NewString(), ttl: ttl}
}
func (l *DistLock) TryLock(ctx context.Context) (bool, error) {
// 步骤3:SET key token NX PX ttl —— 一条命令同时完成"不存在才设"和"带过期时间"。
// TTL 是保命符:持锁者崩溃了,锁也能自动释放,否则全局永久死锁
return l.rdb.SetNX(ctx, l.key, l.token, l.ttl).Result()
}
// 步骤4:带重试的阻塞获取,必须响应 ctx 取消,否则调用方无法超时退出
func (l *DistLock) Lock(ctx context.Context, retryInterval time.Duration) error {
for {
ok, err := l.TryLock(ctx)
if err != nil {
return err
}
if ok {
return nil
}
select {
case <-ctx.Done():
return errors.New("获取分布式锁超时: " + ctx.Err().Error())
case <-time.After(retryInterval):
// 继续下一轮重试
}
}
}
func (l *DistLock) Unlock(ctx context.Context) error {
// 步骤5:只删自己的锁
return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err()
}
⚠️ 新手必踩的坑:分布式锁的四个致命细节
- 必须设过期时间,且要用
SET NX PX一条命令完成。先SETNX再EXPIRE是两步,中间宕机就留下一把永不释放的锁。- value 必须唯一(UUID/请求ID)。用固定值时,A 的锁超时过期、B 拿到锁,A 执行完一个
DEL就把 B 的锁删了,互斥彻底失效。- 解锁必须用 Lua。
GET判断和DEL之间存在时间窗,非原子就等于第 2 条的坑没堵上。- 业务耗时可能超过 TTL。要么 TTL 给足余量,要么起一个"看门狗" goroutine 定期
EXPIRE续期(redsync、go-zero都是这么做的),并在 Unlock 时停掉看门狗。另外,Redis 主从异步复制期间发生主从切换,锁可能同时被两个客户端持有——Redis 锁只保证"大概率互斥",钱相关的强一致场景请用 etcd/ZooKeeper 或数据库事务兜底。
选型速查表
| 并发范围 | 方案 | 适用场景 | 代价 |
|---|---|---|---|
| 单进程 · 单变量 | atomic | 计数器、标志位 | 无 |
| 单进程 · 一组状态 | Mutex / RWMutex | 缓存、配置、连接池 | 锁竞争 |
| 单进程 · 热点激烈 | 分片锁 | 高 QPS 大 map | 内存翻倍、无法全局遍历 |
| 单进程 · 逻辑复杂 | channel 串行化(CSP) | 状态机、会话管理 | 有调度开销、吞吐受单 goroutine 限制 |
| 多实例 · 数据在 DB | DB 乐观锁 / WHERE 条件更新 | 扣库存、改余额(冲突少) | 冲突需重试 |
| 多实例 · 临界区含多次读写 | DB 悲观锁 FOR UPDATE | 转账、对账(冲突多) | 持锁占事务,易放大慢查询 |
| 多实例 · 跨多个资源 | Redis 分布式锁 | 定时任务防重跑、跨资源操作 | 依赖 Redis 可用性,非强一致 |
| 多实例 · 要求强一致 | etcd / ZooKeeper | 选主、配置变更、金融级互斥 | 部署与运维成本高 |
一条工程经验值得记住:能用一条带条件的 UPDATE 解决的,就别上分布式锁。锁是最后的手段,不是第一反应——每引入一个锁,就多引入一个死锁点和一个可用性依赖。
13、Mutex 内部状态位与自旋条件
15.1 用生活类比先建立直觉
把 Mutex 想象成厕所门上的三块指示灯,全部焊在一块小显示屏(一个 int32 变量)上:
- locked 灯(mutexLocked):亮 = 有人正在用,新来者必须排队
- woken 灯(mutexWoken):亮 = 已经有人在门口"叫醒"队首排队者了,别再去重复喊(避免惊群式重复唤醒)
- starving 灯(mutexStarving):亮 = 已经有人等超过 1ms,进入饥饿模式,新来者直接去队尾排队、不再抢
这样设计的好处是:所有状态塞进一个 32 位整数,加锁解锁时用一次原子读写就能同时看到"是否被持有 + 是否饥饿 + 是否有等待者被唤醒 + 还有几个人在排队",不用额外字段、不用额外加锁。
graph LR
S["state: int32
一个字段装下所有状态"]
S --> L["bit0
mutexLocked
1=被持有"]
S --> W["bit1
mutexWoken
1=已唤醒排队者"]
S --> G["bit2
mutexStarving
1=饥饿模式"]
S --> Q["高 29 位
waiterCount
等待者数量"]桥接到工程:这就是第 4 章讲"正常模式/饥饿模式"的底层实现。模式切换、唤醒、排队计数,全部靠这几个位标志 + 位运算完成。
15.2 工程要点
Mutex 的三种状态位
// runtime 中 Mutex.state 的位布局(简化示意)
const (
mutexLocked = 1 << 0 // 第 0 位:是否被持有
mutexWoken = 1 << 1 // 第 1 位:是否有 waiter 已被唤醒
mutexStarving = 1 << 2 // 第 2 位:是否进入饥饿模式
mutexWaiterShift = 3 // 第 3 位起:等待者计数
)
// locked/woken/starving 三个标志位,加上高 29 位的 waiter 数量,
// 全部打包在一个 int32 里,用 atomic 位运算读写
| 标志位 | 含义 | 谁设置 / 清除 |
|---|---|---|
mutexLocked | 锁当前是否被持有 | Lock 成功置 1,Unlock 清 0 |
mutexWoken | 已经有一个等待者被唤醒,别人别再发唤醒信号 | 抢锁者/释放者设置,后续清除 |
mutexStarving | 已有等待者超过阈值,进入公平模式 | 等待 >1ms 置 1,队首拿到锁且等待 <1ms 清 0 |
| 高 29 位 | 当前在队列里等锁的 goroutine 数量 | 入队 +1,出队 -1 |
Mutex 允许自旋的条件
第 4 章提到"新来的 goroutine 会先尝试自旋(最多 4 次)",但自旋不是随便就能转的。只有满足下面全部条件,才会进入自旋:
graph TD
C1{"多核? GOMAXPROCS > 1"} -->|"否"| NoSpin["不自旋
直接排队"]
C1 -->|"是"| C2{"当前 P 本地队列为空?"}
C2 -->|"否"| NoSpin
C2 -->|"是"| C3{"自旋次数 < 上限
主动自旋 4 次 / 激进 30 次?"}
C3 -->|"超过"| NoSpin
C3 -->|"未超"| C4{"还有别的 P 在跑且没闲着?"}
C4 -->|"否"| NoSpin
C4 -->|"是"| Spin["自旋
忙等一小会儿再试抢锁"]具体条件(来自 sync_runtime_canSpin):
- 运行的 CPU 核数 > 1 且
GOMAXPROCS > 1——单核自旋纯浪费。 - 当前 P 的本地运行队列为空——否则该去调度别的 G,而不是空转等锁。
- 自旋次数未超上限:普通自旋最多
active_spin = 4次,激进自旋最多25次(锁已被持有且持有者正在运行)。 - 至少有一个 P 正在运行且未空闲——说明"锁的持有者很可能马上就释放",值得等。
⚠️ 新手必踩的坑: 自旋是"用户态忙等",只发生在正常模式且锁临界区很短的场景。如果临界区长,自旋的 goroutine 只是在烧 CPU,反而拖慢整体。饥饿模式下完全禁自旋——因为已经有人等太久,锁释放后必须直接交给队首。
考点总结: Mutex 用一个 int32 打包 locked/woken/starving + waiter 计数;自旋是正常模式下的优化,必须满足多核、本地队列空、次数未超、有 P 在跑四个条件,且只用于临界区极短的场景。
14、RWMutex 实现与注意事项
16.1 用生活类比先建立直觉
把 RWMutex 想象成图书馆阅览室:
- 读者(RLock):可以多人同时进来看书
- 整理书架的管理员(写锁 Lock):必须等所有读者离场、且独占阅览室才能干活
- 写锁饥饿:读者一波接一波地来,管理员永远等不到"全场清空"那一刻,一直进不去
- 不能升级:你手里拿着"读者证"(已 RLock),不能当场变成"管理员证"(再 Lock)。必须先把读者证还了,重新排队申请管理员证——否则你会卡在门口等"所有读者离场",而你自己就是那个不走的人,死锁
graph TD
R["读者 RLock x N"] -->|"readerCount > 0"| Block["写者 Lock 阻塞
等 readerCount 归零"]
Block -->|"所有读者 RUnlock"| W["写者独占
writerSem 放行"]
W -->|"Unlock"| Wake["唤醒等待的读者
readerSem 放行"]
Upgrade["已持 RLock 又调 Lock"] -->|"自己也在 readerCount 里"| Dead["死锁
永远等不到归零"]16.2 工程要点
RWMutex 的内部实现
RWMutex 不是"一把读锁 + 一把写锁"那么简单,内部有五个核心字段:
| 字段 | 作用 |
|---|---|
w Mutex | 保护写者之间的互斥(多个写者借此串行) |
writerSem | 写者等所有读者退场时,阻塞在这个信号量上 |
readerSem | 读者等写者释放时,阻塞在这个信号量上 |
readerCount int32 | 当前持有读锁的读者数(负数表示该值减了 RWMutexMaxReaders,表示有写者等待) |
readerWait int32 | 写者等待离场的读者数量 |
// 简化逻辑:写锁 Lock
func (rw *RWMutex) Lock() {
rw.w.Lock() // 步骤1:先和其他写者互斥
r := atomic.AddInt32(&rw.readerCount, -rwmutexMaxReaders) // 步骤2:把读者计数整体"扣掉",标记有写者等待
if r != 0 { // 步骤3:还有读者没走
atomic.Wait(&rw.writerSem) // 步骤4:写者睡在 writerSem 上,等读者全退场
}
}
// 简化逻辑:读锁 RLock
func (rw *RWMutex) RLock() {
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// 步骤1:读者计数变负,说明有写者正在等待 -> 读者也要排队
atomic.Wait(&rw.readerSem) // 步骤2:读者睡在 readerSem 上,等写者释放
}
}
写锁饥饿
RWMutex 没有 Mutex 那种 1ms 饥饿切换机制。如果读者源源不断(每个 RLock 之间几乎无缝),readerCount 很难归零,写者会一直卡在 writerSem 上——这就是写锁饥饿。读多写少场景下这是预期行为;但若写操作有延迟敏感要求,要考虑用普通 Mutex 或分片降低单把锁的读竞争。
不能升级(RLock 无法直接变 Lock)
Go 的 RWMutex 不支持锁升级。如果一个 goroutine 先 RLock() 再调用 Lock():
func badUpgrade(rw *sync.RWMutex) {
rw.RLock()
defer rw.RUnlock()
// 危险:在持有读锁时申请写锁
rw.Lock() // 写者会把 readerCount 整体扣掉,但本 goroutine 的 +1 还在里面
defer rw.Unlock()
// 结果:写者永远等不到 readerCount 归零(自己就是那个读者)-> 死锁
}
正确做法:先 RUnlock() 释放读锁,再按需 Lock()(注意释放后共享数据可能已被别人改动,需要重新读取校验)。
考点总结: RWMutex 用 readerCount(负数标记写者等待)+ 两个信号量(writerSem/readerSem)实现读写互斥;它没有公平性切换,写者可能饿死;并且禁止读锁升级为写锁,否则死锁。
15、Cond 的 Signal 与 Broadcast 区别
17.1 用生活类比先建立直觉
把 Cond 的唤醒想象成餐厅叫号系统:
- Signal() = 叫"下一个号":只放一个正在等待的顾客进门
- Broadcast() = 广播"打烊清场 / 全场有座":把所有正在等待的顾客一次性全唤醒
差别的关键在于:Signal 适合"生产了一个资源,交给一个等待者即可";Broadcast 适合"状态发生了全局变化"(比如队列被清空、连接已关闭),所有等待者都需要重新检查条件。
17.2 工程要点
两者行为对比
| 方法 | 唤醒数量 | 典型场景 |
|---|---|---|
Signal() | 唤醒一个等待者 | 往队列放入 1 个元素,只需 1 个消费者来取 |
Broadcast() | 唤醒所有等待者 | 关闭队列 / 配置刷新 / 条件全局翻转 |
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var mu sync.Mutex
cond := sync.NewCond(&mu)
// 步骤1:启动 3 个等待者
for i := 1; i <= 3; i++ {
go func(id int) {
mu.Lock()
defer mu.Unlock()
cond.Wait() // 进入等待(Wait 会先释放锁再阻塞)
fmt.Printf("等待者 %d 被唤醒\n", id)
}(i)
}
time.Sleep(300 * time.Millisecond)
// 步骤2:Signal 只唤醒 1 个 -> 只有 1 个等待者打印
mu.Lock()
cond.Signal()
mu.Unlock()
time.Sleep(200 * time.Millisecond) // 此时只有 1 个打印
// 步骤3:Broadcast 唤醒剩余全部 -> 剩下 2 个一起打印
mu.Lock()
cond.Broadcast()
mu.Unlock()
time.Sleep(300 * time.Millisecond)
}
⚠️ 新手必踩的坑:
Wait()必须在for循环中检查条件(见第 7 章)。Broadcast()唤醒所有等待者后,它们会逐个重新抢锁、重新检查条件——这叫"惊群",但配合for循环检查能保证正确性。如果只用if,虚假唤醒时会误以为条件满足了。
考点总结: Signal() 唤醒一个等待者,Broadcast() 唤醒全部;放入单个资源用 Signal,全局状态变化用 Broadcast;两者都必须配合 for 循环中的条件检查。
16、sync.Pool 临时对象复用
18.1 用生活类比先建立直觉
把 sync.Pool 想象成公司楼下的共享雨伞架:
- 下雨(高频创建临时对象)时,从伞架领一把(Get),用完还回去(Put)给下一个人用
- 不用"每人买一把新伞"(每次都
new+ 等 GC 回收),既省钱又减轻保洁阿姨(GC)的负担 - GC 来大扫除时,伞架会被清空——Pool 里的对象不保证长期存活,下次 Get 可能拿到一把"新伞"(甚至 nil)
graph TD
G["goroutine 需要临时对象"] -->|"pool.Get"| P["sync.Pool 伞架"]
P -->|"有空闲"| Reuse["复用旧对象"]
P -->|"为空/nil"| New["调用 New 新建"]
G -->|"用完 pool.Put"| P
GC["GC 触发"] -->|"清空伞架"| P桥接到工程:Pool 的核心价值是降低分配压力和 GC 频率,不是"缓存"(因为对象随时可能被回收)。典型用途是 bytes.Buffer、fmt 包、JSON 编解码里的临时 buffer 复用。
18.2 工程要点
用法
package main
import (
"bytes"
"fmt"
"sync"
)
// 步骤1:定义一个 Pool,必须提供 New 函数
// New 在 Get 拿不到可复用对象时调用,保证不会返回 nil
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer) // 返回一个新 buffer
},
}
func process(data string) string {
// 步骤2:Get —— 优先复用,没有才走 New
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // 步骤3:归还前清空,避免脏数据
bufPool.Put(buf) // 步骤4:Put 还回去
}()
buf.WriteString("processed: ")
buf.WriteString(data)
return buf.String()
}
func main() {
fmt.Println(process("hello"))
fmt.Println(process("world"))
}
注意事项
| 注意点 | 说明 |
|---|---|
| 对象随时被 GC 回收 | Pool 不阻止对象被回收,Get 可能拿到 nil(所以必须提供 New) |
| 不能存需显式释放的资源 | 别往里放 os.File、*sql.DB 等需要 Close 的对象,GC 不会帮你 Close |
| Put 前必须 Reset | 否则下一个人拿到带脏数据的对象 |
| 适合"无状态临时对象" | 如 buffer、slice、临时 struct,不适合长期状态 |
⚠️ 新手必踩的坑: 有人把 Pool 当"全局缓存"用,存了业务关键状态——结果 GC 一跑对象没了,程序逻辑出错。记住:Pool 是性能优化手段,不是存储。另外
Get返回的可能是别人Put的旧对象,必须在使用前Reset或重新初始化。
考点总结: sync.Pool 用于临时对象复用,降低分配与 GC 压力;对象可能被 GC 随时回收,不能当缓存;使用必须提供 New、Put 前 Reset。
17、GM 调度模型(Go 1.0 之前)
19.1 用生活类比先建立直觉
把调度模型的变化想象成厨房的进化:
- GM 模型(早期) = 只有一块大公共黑板(全局队列),所有厨师(M)都挤在黑板前抢任务,而且黑板前还要排队领号(一把全局大锁)。
- GMP 模型(现在) = 每个厨师配了一个自己的小工作台(P + 本地队列),优先在自己台面上干活,台面空了才去别处偷或去公共板拿。
GM 模型的问题就出在"一块黑板 + 一把大锁":人一多,大家全卡在抢锁上,黑板成了瓶颈。
graph TD
subgraph GM["GM 模型(早期)"]
GQ1["全局队列
单队列"] -->|"全局锁竞争"| M1["M1"]
GQ1 -->|"全局锁竞争"| M2["M2"]
GQ1 -->|"全局锁竞争"| M3["M3"]
end
subgraph GMP["GMP 模型(现在)"]
P1["P1+本地队列"] --> MM1["M1"]
P2["P2+本地队列"] --> MM2["M2"]
GQ2["全局队列
溢出时才用"] -.-> P1
GQ2 -.-> P2
end19.2 工程要点
GM 模型的问题
早期 Go(1.0 及之前,Goroutine 调度从 1.1 起改为 GMP)只有 G 和 M,没有 P 这个中间层:
- 单一全局队列:所有 goroutine 都塞进一个全局队列
- 全局大锁:每次调度都要抢这把锁,多核下锁竞争极其激烈
- M 频繁创建销毁:系统调用阻塞时 M 被拖进内核,P 没有中间层缓存本地任务,恢复时又要重新调度
- Cache 不亲和:G 在不同 M 间飘,CPU cache 经常失效
- 负载不均:没有 work stealing,某些 M 忙死、某些闲死
为什么引入 P(GMP)
引入 P(Processor,逻辑处理器)后:
- 每个 P 持有本地队列,绝大多数调度在用户态、无锁完成
- 只有本地队列满/空才碰全局队列,全局锁竞争大幅减少
- work stealing 让负载自动均衡
- M 与 P 解绑后,P 可快速绑到另一个 M 上,减少线程抖动
⚠️ 新手必踩的坑: 面试被问"为什么要有 P"时,核心答案就是:P 把’任务队列’和’执行线程’解耦,用本地队列把全局锁竞争降下来,并让 work stealing 成为可能。没有 P,Go 的多核扩展性会非常差。
考点总结: GM 模型只有全局队列 + 全局锁,多核下锁竞争成为瓶颈;Go 1.1 引入 P,用本地队列 + work stealing 把调度下沉到用户态、化解全局锁竞争。
18、抢占式调度:协作式与基于信号
20.1 用生活类比先建立直觉
“抢占"就是调度器能不能强行把执行权从 goroutine 手里夺回来。两种手段:
- 协作式(函数序言栈检测,Go < 1.14) = 每次进一个函数前,厨师先探头问一句"我是不是该下班了”。如果有个厨师写了个空 for 死循环(从不进任何函数),调度器永远没机会问这句话,就饿死整个 P——其他 goroutine 全卡住。
- 基于信号(async preemption,Go 1.14+) = 调度器直接朝厨师背后扔个信号弹(SIGURG),厨师正在干任何事都会被中断,立刻把手头活放下去接受调度。这就治好了死循环饿死 P 的问题。
graph TD
Coop["协作式抢占
函数序言检查"] -->|"函数调用间隙"| Check["检查是否需要调度"]
Check -->|"需要"| Yield["让出 P"]
Check -->|"无函数调用"| Stuck["死循环 for{}
永不检查 -> 饿死 P"]
Sig["信号式抢占 1.14+"] -->|"sysmon 发 SIGURG"| Interrupt["中断任意执行点"]
Interrupt --> Yield20.2 工程要点
协作式抢占(函数序言栈检测)
Go 在 1.14 之前采用协作式抢占。编译器在每个函数的序言插入一段栈检查代码(原本是为栈扩容准备的 morestack 检查),借这个检查顺带判断"当前 G 是否该被抢占":
- 优点:实现简单,依赖已有的栈增长机制
- 缺点:必须发生函数调用才能触发检查。如果一个 G 执行
for {}或长时间不调用函数,调度器无法插入,该 P 被独占,同 P 上的其他 G 全部饿死
基于信号的抢占(async preemption)
Go 1.14 引入:
- sysmon 监控线程发现某个 G 运行时间过长,在它的
preempt标志位打标 - 向该 G 所在的 M 发送
SIGURG信号 - M 被信号中断,陷入信号处理逻辑,调用
asyncPreempt - 把当前寄存器状态保存为"被打断的现场",切换到
g0栈,调用调度器把 G 放回队列 - G 之后被重新调度时,从保存的现场恢复,像什么都没发生过
// 伪代码:信号抢占的关键路径(概念示意)
// sysmon 中:
if 某G运行时间 > 10ms {
g.preempt = true // 步骤1:打抢占标记
preemptM(g.m, SIGURG) // 步骤2:发信号中断 M
}
// M 收到 SIGURG:
// 步骤3:保存寄存器 -> 切到 g0 -> schedule() -> G 回队列
⚠️ 新手必踩的坑: 基于信号的抢占解决了"死循环饿死 P"的问题,但汇编函数、某些不可中断的临界区仍可能延迟抢占。不过对普通 Go 业务代码而言,1.14+ 之后基本不会再因为
for {}把整个 P 拖死。
考点总结: 协作式抢占靠函数序言检查,遇死循环会饿死 P;Go 1.14 引入基于 SIGURG 信号的异步抢占,可中断任意执行点,根治该问题。
19、GMP 中的阻塞类型与 sysmon 监控
21.1 用生活类比先建立直觉
把 G 在调度中被"叫停"想象成厨师干活时被打断的四种情形:
- 系统调用阻塞 = 厨师去仓库取货,卡在门口(陷入内核),灶台(P)得让给别人用
- 网络 IO 阻塞 = 厨师等外卖平台回执,Go 用 netpoll 异步等,不占灶台
- channel 阻塞 = 厨师等的食材没送来,他先去歇着(G 挂起),灶台换别的菜做
- 抢占 = 经理定时喊"换人",保证没人独占灶台太久
而 sysmon 就是那个不炒菜、只巡场的店长:盯着谁炒太久(该抢占)、谁卡系统调用太久(该把 P 抢回来)、GC 到点没、网络回执到了没。
graph TD
B1["系统调用阻塞
M 陷内核, P 解绑"] --> Retake["sysmon retake
把 P 抢回"]
B2["网络 IO
netpoll 异步等"] --> Net["sysmon 轮询 netpoll"]
B3["channel 阻塞
G 挂起, 用户态切换"] --> Exec["P 换 G 执行"]
B4["抢占
运行超 10ms"] --> Preempt["sysmon 打标 + 发信号"]
Sys["sysmon 店长
监控一切"] --> Retake
Sys --> Net
Sys --> Preempt21.2 工程要点
GMP 调度中的四类阻塞
| 阻塞类型 | 是否解绑 P | 恢复方式 |
|---|---|---|
| 系统调用(文件 IO、阻塞 syscall) | 是,M 陷内核,P 找别的 M | 系统调用返回后尝试重新获取 P |
| 网络 IO | 否,交给 netpoll 异步等待 | netpoll 就绪后 G 重新入队 |
| channel 收发 | 否,G 挂起 | 对端发送/接收后唤醒 G |
| 抢占(运行过久) | 否,G 被放回队列 | 调度器切换到下一个 G |
重点区分:只有系统调用阻塞才解绑 P;网络 IO 由 netpoll 接管,并不占用 M 在内核傻等,这是 Go 高并发网络服务的基石。
sysmon 的作用
sysmon 是一个不需要绑定 P 的特殊 M,在后台循环运行,职责包括:
- retake(抢占与夺回 P):标记运行超时的 G 需要抢占;把卡在系统调用里太久的 M 上的 P 强行夺回(hand off),让 P 去服务别的 G
- 触发 GC:到时间就启动垃圾回收
- netpoll 轮询:定期轮询网络轮询器,把就绪的网络 G 放回运行队列
- 回收 M:清理长时间阻塞、已无用的 M
// 伪代码:sysmon 主循环(概念示意)
func sysmon() {
for {
// 步骤1:检查运行过久的 G -> 打抢占标记
retake() // 抢回卡系统调用的 P + 标记超时 G
// 步骤2:到时间就触发 GC
if 该GC了 { gcStart() }
// 步骤3:轮询网络
netpoll(0) // 把就绪的 G 放回队列
// 步骤4:清理长时间阻塞的 M
retake() // 顺带回收
usleep(一小段时间)
}
}
⚠️ 新手必踩的坑: sysmon 不依赖 P,所以即使所有 P 都忙、所有 G 都在跑,sysmon 依然在后台运转——这正是异步抢占(第 18 章)能生效的前提:是 sysmon 负责发现"该抢占的 G"并发信号。
考点总结: GMP 中四类阻塞(系统调用解绑 P、网络走 netpoll、channel 用户态挂起、抢占回队列);sysmon 是不绑 P 的监控 M,负责 retake 夺回 P、触发 GC、轮询 netpoll、回收 M。
20、锁的常见误用:不可重入、不可复制、读也要加锁
22.1 用生活类比先建立直觉
锁就像更衣室的存衣柜钥匙:
- 不可重入:你拿着钥匙进了柜子,又想在柜子里再开一个子柜——但子柜锁和外面是同一把。你把自己锁在里面,钥匙还在自己兜里,外面的人进不来,你也出不去。
- 不可复制:你把"柜子+钥匙"整体复印了一份,原件你拿、复印件给别人。结果两个人各开各的,锁的计数乱套,谁都以为自己独占。
- 读也要加锁:你以为"我就看一眼柜子里有什么、不拿东西"不用锁门。但柜子另一头有人正往里塞东西(写),你看的瞬间正好他改到一半,看到半新半旧的数据,Go 运行时直接把程序 terminate 掉。
这三个错误都源于"把锁当成普通变量随手用"。
22.2 Mutex 不可重入(同一 goroutine 重复加锁自死锁)
Go 的 sync.Mutex 是非可重入锁:同一个 goroutine 已持有锁,再调一次 Lock() 会阻塞在"等自己释放锁"上——自己等自己,永远等不到,整个程序卡死。runtime 最终打印 fatal error: all goroutines are deadlock!。
package main
import "sync"
var mu sync.Mutex
var chain string
func A() {
mu.Lock()
defer mu.Unlock()
chain = chain + " --> A"
B()
}
func B() {
chain = chain + " --> B"
C()
}
func C() {
mu.Lock() // 步骤1:同一 goroutine 再次加锁 -> 死锁!
defer mu.Unlock()
chain = chain + " --> C"
}
func main() {
chain = "main"
A() // 永远卡在 C 的第二次 Lock
}
sequenceDiagram
participant G as 同一 goroutine
G->>mu: A() 中 Lock() 成功(持锁)
G->>mu: B()/C() 中再次 Lock()
Note over G,mu: C 阻塞等待锁释放
Note over G: 但锁的持有者就是自己,
自己不可能再 Unlock -> 死锁⚠️ 新手必踩的坑: Mutex 没有"递归锁"语义。如果业务逻辑天然需要嵌套加锁(如函数调用链每层都加锁),要么把锁提取到最外层只加一次,要么改成"先 Unlock 再调内部函数"或拆出不带锁的私有函数
_C。
22.3 RWMutex 嵌套读锁 + 写者等待死锁
RWMutex 允许多个读并存,但有两个雷区:
- 读锁嵌套读锁:一个 goroutine 先
RLock(),调用链里又RLock()。单独看没问题(读可并存),但一旦有写者在等,新读者会被"写者优先"机制挡住,于是本 goroutine 的第一次 RLock 永远等不到自己释放——死锁。 - 本质上和 22.2 同源:你"占着一个读名额"又在等"所有读者离场",而你自己就是那个不走的人。
package main
import (
"fmt"
"sync"
"time"
)
var mu sync.RWMutex
var count int
func A() {
mu.RLock()
defer mu.RUnlock()
B()
}
func B() {
time.Sleep(5 * time.Second) // 模拟处理中
C()
}
func C() {
mu.RLock() // 步骤1:此时 main 的写锁已在等待
defer mu.RUnlock() // 新读者被"写者优先"挡住 -> 死锁(hang)
}
func main() {
go A()
time.Sleep(2 * time.Second)
mu.Lock() // 步骤2:写者加入等待,后续 RLock 会被阻塞
defer mu.Unlock()
count++
fmt.Println(count)
}
graph TD
A1["goroutine: A RLock 持有读名额"] --> A2["调用 B -> C"]
A2 --> A3["C 再次 RLock"]
M["main: mu.Lock 写锁等待
readerCount 归零"]
M -->|"写者优先: 阻塞新 RLock"| A3
A3 -->|"永远等不到归零
(自己占着读名额)"| Dead["死锁 hang"]⚠️ 新手必踩的坑: 这是第 14 章"读锁不能升级为写锁"之外第二个 RWMutex 死锁源。实践铁律:持有 RLock 时不要调用可能再次 RLock/Lock 的函数;需要递归读就只加一次锁,把内部逻辑做成"不重复加锁"的私有函数。
22.4 Mutex 不可值复制(复制破坏锁状态)
sync.Mutex 内部有状态(第 13 章讲过的 state + sema)。把它整体按值拷贝,两个副本是完全独立的锁;若拷贝时原锁正被持有,副本的"已持有"状态丢失,锁等于失效。
package main
import (
"fmt"
"sync"
)
type MyMutex struct {
count int
sync.Mutex
}
func main() {
var mu MyMutex
mu.Lock()
var mu2 = mu // 步骤1:值拷贝!mu2 是一把全新的锁
mu.count++
mu.Unlock()
mu2.Lock() // 步骤2:mu2 与原锁无关,可再次 Lock
mu2.count++
mu2.Unlock()
fmt.Println(mu.count, mu2.count) // 各自独立计数,锁未保护共享
}
上面代码能跑通,但 mu 和 mu2 各自是一把独立锁——原本想用一把锁保护 count,结果锁被复制后保护失效。更严重的是,拷贝一把"已加锁"的 Mutex 会触发 go vet 的 copylocks 警告,且运行时行为未定义。
⚠️ 新手必踩的坑: Mutex/RWMutex 必须按指针或作为结构体的嵌入字段(通过指针接收者操作),绝不能按值传递或赋值。把锁放进 struct 时,struct 本身也要用指针传递(
&MyMutex{}),否则一旦被值拷贝就悄无声息地失效。go vet能静态抓出大多数 copylocks 问题,但别依赖它——写代码时就养成"锁只通过指针用"的习惯。
22.5 map 读操作也必须加锁
很多人的直觉是"读又不改,读 map 不用加锁"。错。sync.Map 之外,普通 map 的并发读 + 写会触发运行时 panic——注意是"读 + 写"同时发生就崩,不是"写 + 写"。
package main
import "sync"
type UserAges struct {
ages map[string]int
sync.Mutex
}
func (ua *UserAges) Add(name string, age int) {
ua.Lock()
defer ua.Unlock()
ua.ages[name] = age // 步骤1:写,已加锁
}
func (ua *UserAges) Get(name string) int {
// 步骤2:BUG!读 map 没有加锁
if age, ok := ua.ages[name]; ok {
return age
}
return -1
}
// 并发调用 Add 与 Get -> fatal error: concurrent map read and map write
graph TD
W["写 goroutine: Add 加锁写 map"] -->|"与读并发"| R["读 goroutine: Get 无锁读 map"]
R -->|"map 内部检测到
读+写同时进行"| P["runtime panic
concurrent map read and map write"]
P -->|"程序崩溃"| Crash["fatal error"]正确做法:读也要在同一把锁保护下。读多写少用 RWMutex,Get 用 RLock/RUnlock(见第 4 章 SafeCache)。
⚠️ 新手必踩的坑: map 的"读"不是只读视图——Go 在 map 增长、扩容(哈希重排)时会改写内部指针,所以读操作同样会触碰这些指针。一旦扩容与并发读撞上,运行时主动
panic而非给你错误数据。记住:普通 map 要么全程加锁(含读),要么用sync.Map,没有"只读不用锁"的中间态。
考点总结: ① sync.Mutex/RWMutex 都不是可重入锁,同一 goroutine 二次 Lock 必死锁;② RWMutex 持有读锁时再嵌套读锁,若已有写者等待会死锁;③ 锁是值类型语义敏感对象,按值拷贝 = 锁失效,必须用指针;④ 普通 map 的读和写都必须加锁,否则 concurrent map read and map write panic。
21、线程安全集合的 Iter:RWMutex 保护遍历 + 只读 channel 流式返回
23.1 用生活类比先建立直觉
遍历一个被多 goroutine 读写的集合,就像清点仍在营业的仓库库存:
- 你进仓库时先挂"盘点中"牌(
RLock),保证清点期间没人往里搬进搬出(写被挡) - 你一边清点一边把每件货的名字念到对讲机里(往 channel 发),外面的人拿着耳机听(消费),不用闯进仓库
- 清完把对讲机频道关掉(
close(ch)),外面听到关闭声就知道到头了 - 最关键:对讲机是单向的(返回
←chan interface{}),外面的人只能听、不能往里塞,避免有人乱插话破坏清点
23.2 工程要点
返回只读 channel 的安全迭代器
这是"读多写少集合"对外暴露遍历的常见写法:在 goroutine 里加读锁、遍历、逐条发送、关闭 channel、释放读锁。调用方用 for v := range set.Iter() 消费,天然安全且不会持锁过久。
package main
import (
"fmt"
"sync"
)
type threadSafeSet struct {
s []interface{}
mu sync.RWMutex
}
// Iter 返回一个只读 channel,调用方只能接收,不能发送
func (set *threadSafeSet) Iter() <-chan interface{} {
ch := make(chan interface{}) // 步骤1:无缓冲,逐条推送
go func() {
set.RLock() // 步骤2:加读锁,保证遍历期间无写
defer set.RUnlock() // 步骤4:遍历完释放读锁
defer close(ch) // 步骤5:关闭 channel,range 自然结束
for _, elem := range set.s { // 步骤3:在锁保护下遍历
ch <- elem
}
}()
return ch
}
func main() {
set := &threadSafeSet{s: []interface{}{"a", "b", "c"}}
for v := range set.Iter() { // 只读消费,安全
fmt.Println(v)
}
}
graph TD
Caller["调用方 for range Iter"] -->|"接收"| CH["只读 channel"]
G["后台 goroutine"] -->|"RLock 保护"| Map["遍历 set.s"]
Map -->|"逐条 ch <- elem"| CH
Map -->|"遍历完 close(ch)"| CH
CH -->|"range 结束"| Done["调用方退出循环"]⚠️ 新手必踩的坑: 这个写法有两个隐藏约束:① channel 用无缓冲时,如果调用方不消费(没
range),后台 goroutine 会卡在ch <- elem上,读锁一直不释放,其他写者被永久挡住——这就是把"遍历"和"消费"绑死的风险。真实项目里通常给 channel 加缓冲,或在 goroutine 里select一个donechannel 以便提前取消。②RUnlock必须放在close(ch)之后或 defer 中,保证遍历结束才放锁;顺序写反同样会出问题。
为什么返回只读 channel 而不是切片副本
| 方案 | 优点 | 缺点 |
|---|---|---|
返回 []T 副本 | 调用方完全脱离锁 | 大集合拷贝开销大,瞬时内存翻倍 |
返回 ←chan T | 流式、内存恒定、调用方只读 | 需消费完,否则后台 goroutine 卡锁 |
持有锁 for 直接遍历 | 最简单 | 持锁时间长,阻塞写者 |
考点总结: 线程安全集合的遍历要在读锁保护下进行;通过返回只读 channel(←chan)把"遍历"与"消费"解耦,调用方只能接收、不能破坏集合;注意无缓冲 channel 下消费方不读取会导致后台 goroutine 持锁挂起。
22、WaitGroup 超时等待(WaitTimeout)
24.1 用生活类比先建立直觉
WaitGroup.Wait() 就像站在门口死等所有人到齐——不管等多久,人不齐就不走。但现实里你可能只想等 5 秒,超时就先走(“不等了,先开会”)。标准库 WaitGroup 没有内置超时,需要自己用 channel + time.AfterFunc 包一层。
24.2 工程要点
用 channel + 计时器实现 WaitTimeout
核心思路:起一个 goroutine 调 wg.Wait(),谁先完成谁先往 ch 写结果——要么 Wait() 返回(false,全部完成),要么 time.AfterFunc 到点(true,超时)。
package main
import (
"fmt"
"sync"
"time"
)
func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
ch := make(chan bool, 1)
// 步骤1:一个 goroutine 专门等 WaitGroup 完成
go func() {
wg.Wait()
ch <- false // 步骤2:正常完成,信号 false
}()
// 步骤3:另一个计时器,到点发 true
time.AfterFunc(timeout, func() {
ch <- true // 步骤4:超时信号 true
})
// 步骤5:谁先到谁赢,取第一个结果
return <-ch
}
func main() {
wg := sync.WaitGroup{}
c := make(chan struct{})
for i := 0; i < 10; i++ {
wg.Add(1)
go func(num int, close <-chan struct{}) {
defer wg.Done()
<-close // 阻塞,直到被 close 唤醒
fmt.Println(num)
}(i, c)
}
if WaitTimeout(&wg, time.Second*5) {
fmt.Println("timeout exit") // 5 秒内没唤醒 -> 超时
} else {
close(c) // 完成 -> 唤醒所有 goroutine
}
time.Sleep(time.Second * 10)
}
graph TD
WG["wg.Wait goroutine"] -->|"全部 Done"| Ch["ch <- false"]
T["time.AfterFunc 计时器"] -->|"到点"| Ch["ch <- true"]
Ch -->|"return <-ch 取先到者"| R["返回 true=超时 / false=完成"]⚠️ 新手必踩的坑:
time.AfterFunc注册的计时器即使没触发也会在到点时执行一次,所以它一定会往ch写一次true。如果Wait()先完成并消费了false,那个true会留在带缓冲(cap=1)的 channel 里——但这里ch只被return <-ch读一次,滞留的true会被 GC 回收,无副作用。注意ch必须带缓冲make(chan bool, 1),否则无缓冲 channel 上AfterFunc的发送会永远阻塞在没人接收的状态(虽然不影响主流程,但 goroutine 泄露)。
考点总结: 标准 WaitGroup 无超时,用 go wg.Wait() + time.AfterFunc 两个信号竞速、取先到者实现 WaitTimeout;ch 用容量为 1 的缓冲避免发送方 goroutine 泄露;返回 true 表示超时、false 表示全部完成。
23、runtime.Gosched 主动让出与 byte 溢出陷阱
25.1 用生活类比先建立直觉
runtime.Gosched() 就像你在跑步机上主动**按了一下"暂停让别人先跑"**的按钮:你让出当前 CPU 时间片,调度器去跑别的 goroutine,下一轮再轮到你。它和"系统调用阻塞"不同——你只是礼貌地让一下,马上还会回来。
而 byte 是 uint8 的别名,取值范围 0~255。如果写 for i := byte(0); i <= 255; i++,i 到 255 后 i++ 会回绕成 0,循环条件永远成立——这是个永不结束的死循环。
25.2 工程要点
Gosched 的作用与死循环陷阱
下面这个 goroutine 想"打印完一轮就让出 CPU",但因为 i 是 byte、i <= 255 恒成立,循环根本停不下来:
package main
import (
"fmt"
"runtime"
)
func main() {
go func() {
var i byte
for i = 0; i <= 255; i++ {
fmt.Println("Dropping mic")
runtime.Gosched() // 步骤1:每轮主动让出,给别的 goroutine 机会
runtime.GC() // 步骤2:顺便强制 GC(演示用)
}
fmt.Println("Done") // 步骤3:永远不会执行
}()
// 主 goroutine 也主动让一下,让子 goroutine 有机会先跑
runtime.Gosched()
}
graph TD
Loop["for i byte=0; i<=255; i++"] -->|"i 到 255 后 i++ 回绕为 0"| Loop
Loop -->|"每轮 runtime.Gosched()"| Yield["让出 P 给别的 G"]
Yield --> Loop
Note["i<=255 永远成立
循环永不退出"]⚠️ 新手必踩的坑: 两个考点叠在一起:①
byte是uint8,自增到 255 会回绕,写i <= 255当终止条件 = 死循环。需要"0~255 遍历"时正确写法是用int循环,或明确i != 0配合回绕语义。②runtime.Gosched()只是"建议让出",不是抢占、也不是退出。在GOMAXPROCS=1且有个死循环 goroutine 时,Gosched 能让同 P 上的其它 G 喘口气;但 Go 1.14+ 的异步抢占(见第 18 章)本就能打断这种循环,所以真实生产里死循环一般会被 sysmon 抢占,不会真饿死整个进程——但逻辑上的无限循环依然是 bug,必须修。
考点总结: runtime.Gosched() 主动让出当前 P、把执行权交还给调度器(用户态、不切换线程);byte/uint8 取值范围 0~255,i<=255 作循环终止条件会造成无限回绕死循环;两者结合是经典陷阱题。
24、无限递归与 goroutine 栈溢出上限
26.1 用生活类比先建立直觉
第 1 章说过 goroutine 初始栈只有 2KB,能拷贝式扩容到 1GB,所以"深递归"一般没事。但注意前提是递归会终止。如果递归永远不收敛,栈会一层层往下长,直到撞上 1GB 的硬上限——运行时直接 fatal error: stack overflow,整个程序崩掉。
最隐蔽的一种:在 String() 方法里用 %v 去格式化自己的指针,而 %v 又会回调 String(),于是 String() 调 Sprintf 调 String()……无限套娃。
26.2 工程要点
fmt.String() 自引用导致的栈溢出
package main
import "fmt"
type ConfigOne struct {
Daemon string
}
// 错误:用 %v 格式化 c,而 c 实现了 String(),%v 会再调用 String()
func (c *ConfigOne) String() string {
return fmt.Sprintf("print: %v", c) // 步骤1:Sprintf 发现 c 有 String 方法
// 步骤2:于是又调 c.String() -> 又 Sprintf -> 又 String() ... 无限递归
}
func main() {
c := &ConfigOne{}
c.String() // 步骤3:runtime: goroutine stack exceeds 1000000000-byte limit
}
graph TD
S["c.String()"] -->|"Sprintf %v 发现 c 实现了 String"| S2["再次调用 c.String()"]
S2 -->|"又 Sprintf %v"| S3["再调用 c.String() ..."]
S3 -->|"栈帧层层累加"| OF["撞 1GB 上限
fatal error: stack overflow"]⚠️ 新手必踩的坑: 修正方式是用
%+v(仅打印字段,不会回调String())或Printf("print: %s", c.Daemon)直接拼字段,而不是把c整体交给%v:func (c *ConfigOne) String() string { return fmt.Sprintf("print: %s", c.Daemon) // 只取字段,不再触发 String 回调 }顺带纠正第 1 章的一个印象:goroutine 栈不会溢出"只适用于递归能终止"的情况。任何无限递归(不止
String()自引用,还包括忘了终止条件的递归函数)最终都会撑爆 1GB 栈上限。写递归时第一件事就是确认"一定会在某层 return"。
考点总结: goroutine 栈可扩容到 1GB,但无限递归仍会撑爆上限触发 stack overflow;典型陷阱是 String() 方法内用 %v 格式化自身指针导致自引用无限递归,应改用 %+v 或直接拼接字段。
25、死锁的预防、检测与线上定位
27.1 用生活类比先建立直觉
类比:死锁就像四辆车在十字路口互相等对方先走——A 等 B、B 等 C、C 等 D、D 等 A,谁都不动,整条路堵死。在 Go 里,“车"是 goroutine,“路口"是锁(Mutex / channel)。只要四个条件同时满足,路就堵死了。
对应到工程:死锁不是"程序报错”,而是一群 goroutine 全部卡在等锁 / 等 channel,谁都动不了。最危险的是"部分死锁”——只有一小撮 goroutine 卡住,其余还在跑,程序表面正常,但那块功能悄悄失效,日志里半天看不出问题。
graph TD
G1["goroutine 1
持有锁A 等锁B"] --> G2["goroutine 2
持有锁B 等锁A"]
G2 --> G1
G1 --> D["死锁
双方永久阻塞"]
G2 --> D
Note["四条件: 互斥 / 持有等待 / 不可剥夺 / 循环等待"]桥接:预防死锁 = 从根上破坏四个条件之一;检测死锁 = 在程序卡住时"看出谁在等谁";线上定位 = 用 pprof / 日志快速找到那几个阻塞的 goroutine。下面分别讲。
27.2 工程要点
死锁四条件与破坏策略
| 条件 | 含义 | 如何破坏(预防) |
|---|---|---|
| 互斥 | 资源同一时刻只能被一个 goroutine 用 | 无法破坏(锁的本质) |
| 持有等待 | 持锁的同时等另一把锁 | 一次性申请所有锁,或"拿不到就全释放重来" |
| 不可剥夺 | 不能强行抢走别人手里的锁 | 用带超时的锁(try-lock),超时即放弃 |
| 循环等待 | 形成等待环 A→B→A | 固定全局加锁顺序(最常用、最稳) |
package main
import (
"sync"
"unsafe"
)
type Account struct{ Balance int }
// 步骤1:固定加锁顺序——永远按地址从小到大加锁,杜绝循环等待
func transfer(a, b *Account, muA, muB *sync.Mutex, amount int) {
first, second := muA, muB
// 步骤2:用地址排序,保证所有 goroutine 的加锁顺序一致
if uintptr(unsafe.Pointer(muA)) > uintptr(unsafe.Pointer(muB)) {
first, second = muB, muA
}
first.Lock()
second.Lock() // 步骤3:顺序固定,不可能出现 A 等 B 同时 B 等 A
a.Balance -= amount
b.Balance += amount
second.Unlock()
first.Unlock()
}
⚠️ 新手必踩的坑: 破坏"循环等待"用固定顺序最可靠,但前提是所有加锁点都用同一套排序规则。只要有一处漏了(比如某个函数按相反顺序加锁),环就又形成了。大型项目里建议把"加锁顺序"收敛到一个工具函数里,别让业务代码各自为政。
Go 中检测死锁的方法
flowchart LR
A["死锁发生"] --> B{"所有 goroutine
都阻塞?"}
B -->|"是"| C["runtime 自动 panic
all goroutines are deadlocked"]
B -->|"否(部分死锁)"| D["net/http/pprof
goroutine dump"]
D --> E["看哪些 goroutine 卡在
sync.(*Mutex).Lock / chan recv"]
E --> F["定位持有锁的代码"]runtime 自动检测(全局死锁):当所有 goroutine 都阻塞(典型如主 goroutine 在等一个永远不会发的 channel),Go runtime 会主动 panic:
fatal error: all goroutines are deadlocked!。这是"免费"的检测,但只能发现全员卡死,部分死锁它不报错。pprof goroutine dump(部分死锁的主力):线上开启
net/http/pprof,死锁时抓取 goroutine 栈:
// 步骤1:在 main 里挂上 pprof(生产建议绑定内网端口)
import _ "net/http/pprof"
func init() {
go func() { _ = http.ListenAndServe("localhost:6060", nil) }() // 步骤2:导出 /debug/pprof/goroutine
}
# 步骤3:抓取 goroutine 调用栈(?debug=2 看完整栈,含阻塞原因)
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 交互界面: top 看数量最多的栈;traces 看完整调用链
curl 'http://localhost:6060/debug/pprof/goroutine?debug=2' > goroutine.prof
关于 GODEBUG=deadlock:⚠️ Go 并没有这个 GODEBUG 选项,网上流传的说法不可信。真正有用的是:
GODEBUG=schedtrace=1000:每 1 秒打印一次调度器概览(runqueue 长度、goroutine 总数),能看出 goroutine 数是否在疯涨;GODEBUG=scheddetail=1(配合 schedtrace)打印每个 P 的本地队列详情。 它们不是"死锁检测器",而是辅助判断"是不是有 goroutine 堆积"的观测手段。
日志 + 监控兜底:在加锁前打一行带 goroutine 标识的日志,死锁时这行日志"只报了上锁、没报解锁",就能反推出卡点。再配合
runtime.NumGoroutine()定时上报,数量持续不降就告警。
线上死锁时 CPU 指标特点与快速定位步骤
CPU 指标特点:死锁时 goroutine 都在等锁 / 等 channel,处于阻塞态(Gwaiting),不消耗 CPU——所以你会观察到:
- CPU 使用率反而骤降(卡住的 goroutine 不干活),和"高 CPU"的活锁 / 自旋完全不同;
- 但对应的业务接口 QPS 跌零、延迟飙到超时;
- 进程内存可能缓慢上涨(阻塞的 goroutine 持有栈 + 对象不释放,若持续有新请求进来堆积)。
快速定位五步法:
flowchart TD
S1["1. 看监控: QPS跌零+CPU反降
怀疑死锁"] --> S2["2. 查 goroutine 数是否异常增长"]
S2 --> S3["3. curl pprof goroutine?debug=2
抓全量栈"]
S3 --> S4["4. 搜 sync.(*Mutex).Lock / chan receive
找长期阻塞的栈"]
S4 --> S5["5. 顺栈找到持有锁的代码行
修复加锁顺序/加超时"]- 看监控:业务 QPS 突然跌零、延迟全超时,但 CPU 不高 → 先怀疑死锁(而非 CPU 瓶颈)。
- 查 goroutine 数:
runtime.NumGoroutine()是否远高于基线、且长时间不回落。 - 抓栈:
curl '.../debug/pprof/goroutine?debug=2'拿全量栈。 - 找阻塞点:搜索
sync.(*Mutex).Lock、semacquire、chan receive等关键帧,定位那些"停留时间异常长"的 goroutine。 - 看调用链:顺着阻塞 goroutine 的栈往上,找到它在等哪把锁,再找到现在持有那把锁的 goroutine(也卡在等另一把锁)——锁的等待环就显形了。
package main
import (
"log"
"runtime"
"time"
)
// 步骤4:自动化兜底——goroutine 数异常时自动 dump 栈,方便事后排查
func watchDeadlock(alert func()) {
ticker := time.NewTicker(10 * time.Second)
var prev int
for range ticker.C {
n := runtime.NumGoroutine()
// 步骤5:数量翻倍且偏高 → 疑似泄漏/死锁
if prev != 0 && n > prev*2 && n > 500 {
buf := make([]byte, 1<<20)
stack := runtime.Stack(buf, true) // 步骤6:导出所有 goroutine 栈到日志
log.Printf("疑似死锁, goroutines=%d:\n%s", n, buf[:stack])
alert() // 步骤7:触发告警
}
prev = n
}
}
⚠️ 新手必踩的坑: 死锁和活锁(livelock)指标相反——死锁 CPU 低(都睡着了),活锁 CPU 高(都在空转重试)。定位前先确认 CPU 特征,能少走很多弯路。另外 pprof 抓栈是瞬时快照,间歇性死锁要在复现时抓,最好配上面的"数量异常自动 dump"。
26、自测题与动手练习
自测题(5 道)
1. GMP 模型中,P 的本地队列容量是多少?满了之后新创建的 goroutine 去哪里?P 的本地队列空了之后怎么获取新的 G?
2. goroutine 发生 channel 阻塞时,M 和 P 会解绑吗?什么情况下才会解绑?解绑后 P 怎么办?
3. Mutex 的正常模式和饥饿模式有什么区别?什么条件触发模式切换?切回正常模式的条件是什么?
4. 下面代码会死锁吗?为什么?
var mu sync.Mutex
mu.Lock()
mu.Lock()
5. atomic.CompareAndSwapInt64 和 atomic.AddInt64 有什么区别?CAS 在什么场景下比 Add 更合适?请举一个 CAS 适用但 Add 不适用的例子。
6. 死锁的四个必要条件是什么?其中哪几个"无法破坏"、哪几个"可以破坏"?用一句话说明"固定加锁顺序"为什么能预防死锁。
7. Go 程序线上出现了"业务 QPS 跌零、接口全部超时,但 CPU 使用率反而很低"的现象,你怀疑是死锁。请写出你的排查步骤,并说明为什么"GODEBUG=deadlock"不是一个真实可用的选项。
动手练习(3 个)
练习 1:实现一个带超时的并发任务执行器
要求:
- 接收一组任务函数,并发执行
- 支持整体超时(用
context.WithTimeout) - 限制最大并发数(用 buffered channel 做 semaphore)
- 返回所有成功结果和失败原因
提示:组合 context + WaitGroup + buffered channel
练习 2:制造并修复 goroutine 泄露
要求:
- 写一个会泄露 50 个 goroutine 的程序
- 用
runtime.NumGoroutine()验证泄露 - 用
pprof导出 goroutine profile 定位泄露位置 - 修复泄露(用 context 超时或 channel 关闭)
- 验证修复后 goroutine 数量恢复正常
练习 3:实现线程安全的计数器,对比 Mutex 和 atomic 性能
要求:
- 用
sync.Mutex实现一个计数器 - 用
sync/atomic实现同样的计数器 - 两者各启动 1000 个 goroutine,每个递增 1000 次
- 用
time.Now()和time.Since()测量耗时 - 输出对比结果,分析性能差异
28、本章小结
本章从 Go 并发编程的核心知识出发,系统覆盖了面试高频考点:
调度层面:GMP 模型是 Go 并发的基石。G 是用户态协程,M 是操作系统线程,P 是逻辑处理器。P 持有 256 容量的本地队列,通过 work stealing 实现负载均衡。只有系统调用才会导致 M 和 P 解绑,channel 阻塞只是用户态的 goroutine 切换。GOMAXPROCS 控制 P 的数量,默认等于 CPU 核数。goroutine 初始栈 2KB,可拷贝式扩容到 1GB。
生命周期层面:每个 goroutine 都必须有明确的退出路径。用 channel 的 close 或 context.Cancel 通知退出。获取返回值用 channel 传回或 future 模式。goroutine 泄露是隐蔽的内存杀手——用 runtime.NumGoroutine() 和 pprof 定位,用 context 超时预防。
锁层面:Mutex 是悲观锁,有正常模式(自旋+排队)和饥饿模式(直接交接)两种模式,1ms 是切换阈值。RWMutex 适合读多写少。atomic 利用 CPU 原子指令实现无锁操作,五种操作(Add/Load/Store/Swap/CAS)覆盖了简单计数器场景。WaitGroup 底层是 counter + waiter + semaphore,Add 必须在 goroutine 外调用。
死锁层面:四个必要条件是互斥、持有等待、不可剥夺、循环等待。避免死锁的核心方法是固定加锁顺序、避免嵌套锁、使用超时。Go runtime 能检测全局死锁,部分死锁需要 pprof 定位。
类型安全层面:nil map 写入会 panic,nil slice 的 append 安全,nil channel 读写永久阻塞。牢记用 make 初始化 map 和 channel。
面试时能画出 GMP 模型图、讲清调度流程、区分 Mutex 两种模式、说出死锁四条件,就覆盖了 Go 并发面试的 80% 考点。
- GMP 调度本质:Go runtime 是用户态线程调度器,G(协程)排队等 M(OS 线程),P(处理器)维护本地工作队列。work stealing 让空闲 P 从忙 P 那里"偷"一半任务,自动负载均衡。
- Mutex 自旋优化:等待 < 10μs 时自旋(不进入内核),避免上下文切换开销;超过阈值转入睡眠队列,防止 CPU 空转。这是"延迟 vs 吞吐量"的经典权衡。
- goroutine 泄露 = 内存泄漏:泄露的 goroutine 永远不会被 GC,必须通过
runtime.NumGoroutine()监控和 pprof 定位。 - 死锁四条件缺一不可:互斥、持有且等待、不可剥夺、循环等待——破坏任一即可避免。最实用的是固定加锁顺序。
系统调用(如文件 I/O):goroutine 陷入内核态,M(OS 线程)被阻塞,runtime 创建新 M 接替 P,原 M 和 P 解绑。
Channel 阻塞:发生在用户态,goroutine 被挂入 sendq/recvq 队列,P 继续执行其他 goroutine,无需创建新 M。
这意味着:channel 操作的代价远低于系统调用。一个 M 可以同时服务数百个 channel 上的 goroutine,但只能服务极少数 I/O 操作。
面试加分点:提到 Go 1.14+ 的 netpoller 把网络 I/O 也从内核态移到了用户态(epoll/kqueue),进一步扩大了 channel 阻塞 vs 系统调用的优势差距。