Week 1 · Chapter 2 · Go并发编程
复习难度:⭐⭐⭐ | 预计时长:2.5小时 | 重点程度:高
2.1 协程(Goroutine)详解
原理
Goroutine 是 Go 运行时管理的轻量级线程,与 OS 线程是 M:N 关系(多个 G 映射到更少的 M)。
// 启动协程
go func() {
// ...
}()
// 命名返回值 goroutine
go func(msg string) {
println(msg)
}("hello")
与 Java 线程对比:
| 维度 | Go Goroutine | Java Thread |
|---|---|---|
| 栈大小 | 2KB(可扩至1GB) | 1MB(固定) |
| 创建成本 | ~2KB | ~1MB |
| 调度 | 运行时(GMP) | OS 内核 |
| 并发数 | 可轻松创建百万级 | 受内存限制,通常千级 |
常见面试题
Q1:goroutine 什么时候会被调度出去?
答:4 种情况——① channel 阻塞/等待;② 互斥锁等待;③
runtime.Gosched()主动让出;④ 抢占式调度(Go 1.14+,被标记为可抢占的 G)
Q2:goroutine 泄漏如何排查?
答:使用
runtime.NumGoroutine()定期打点;生产环境用pprof的goroutineprofile;常见泄漏原因:channel 永久阻塞、select 缺省 default 分支永久等待。
// 泄漏示例
func leak() {
ch := make(chan int)
go func() {
ch <- 1 // 没人接收,永远阻塞
}()
// ch 永远没人读,Goroutine 泄漏
}
2.2 锁(sync 包)
原理
Go 标准库提供两类锁:
// 互斥锁(不可重入)
var mu sync.Mutex
mu.Lock()
defer mu.Unlock()
// 读写锁(读多写少场景)
var rwMu sync.RWMutex
rwMu.RLock() // 读锁,并发读
defer rwMu.RUnlock()
rwMu.Lock() // 写锁,排他
defer rwMu.Unlock()
Mutex 状态(Go 1.17 以前):
- 0:未锁
- 1:已锁定
- 2:有 waiter 在等待(Linux futex 唤醒机制)
Go 1.17+ 增强:
- 加入 starving 状态,防止 waiter 饿死
常见面试题
Q1:sync.Map 为什么比 map+RWMutex 快(读多场景)?
答:读路径直接从
sync.Map.read中无锁读取(read 是只读的 atomic Value)。只有 read 中不存在(miss)时,才加锁查 dirty。写操作两类 map 都要写,但读多场景下大部分读都不需要加锁。
Q2:RWMutex 死锁场景?
答:持有读锁时调用
Lock()会死锁,因为写锁需要等所有读锁释放;反之持有写锁时调用RLock()没问题(都是排他锁)。典型错误:go mu.RLock() mu.Lock() // ❌ 死锁!
2.3 Context
原理
context 是 Go 用来传递请求作用域、截止时间、取消信号的核心机制。
// 根 context
ctx := context.Background() // 一般用于 main 入口
// 可取消
ctx, cancel := context.WithCancel(context.Background())
cancel() // 广播取消信号
// 截止时间
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
// 超时
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
// 传递值
ctx := context.WithValue(ctx, key, value)
Context 树取消传播: 父 context 取消 → 所有子 context 都会被取消(广播机制)
// 典型用法:HTTP 服务中传递到 DB/ RPC 调用
func handler(w http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
defer cancel()
resp, err := rpc.Call(ctx, "endpoint") // context 传到 RPC 层
}
常见面试题
Q1:context.WithValue 的 key 为什么推荐用自定义类型而不是 string?
答:避免不同包用相同 key 造成冲突。自定义类型 + 非导出(首字母小写)确保唯一性:
go type key int var ctxKey key = 0 // 包内唯一
Q2:context 的 value 查找路径是怎样的?
答:从当前 context 向上逐级查找(类似原型链),直到找到对应的 key 或到达根 context。
2.4 逃逸分析(Escape Analysis)
原理
逃逸分析是编译器决定变量分配在栈还是堆的过程。堆分配需要 GC,栈分配由函数返回自动释放。
逃逸到堆的常见场景:
// 1. 返回指针
func foo() *int {
v := 10 // v 逃逸到堆
return &v // 堆上分配
}
// 2. 发送指针到 channel
ch := make(chan *int, 1)
v := 10
ch <- &v // v 逃逸(channel 生命周期超出函数)
// 3. interface 类型
var i interface{} = v // 若 v 是指针/大对象,可能逃逸
// 4. 局部变量体积过大
func bar() {
s := make([]byte, 10*1024*1024) // 大切片逃逸到堆
}
查看逃逸分析:
go build -gcflags="-m" main.go
常见面试题
Q1:逃逸分析在编译期还是运行期?
答:编译期。Go 编译器通过静态分析决定变量是否逃逸,不需要运行时信息。
Q2:如何避免不必要的堆分配?
答:① 尽量用值传递而非指针(栈复制成本 < 堆分配+GC);② 避免返回局部指针;③ 大切片/大结构体尽量在栈上;④ 合理使用
sync.Pool复用对象。
Q3:Go 的 GC 是 tracing GC 还是引用计数?
答:三色并发标记清除(tracing GC)。Go 1.5 起使用并发 GC(基于染色标记)。注意:Go 的 slice/map 的 header 是指针引用,本身不含引用计数,但指向的底层数组有 GC 管理。
2.5 同步原语综合
原理速查
| 原语 | 用途 | 关键点 |
|---|---|---|
sync.Mutex |
互斥 | 不可重入 |
sync.RWMutex |
读写锁 | 读多写少 |
sync.WaitGroup |
等待一组 goroutine | Add/Done/Wait |
sync.Once |
单次执行 | 内部用 mutex |
sync.Cond |
条件变量 | 必须先持 mutex 再 Wait |
sync.Pool |
对象池 | 无保证,GC 会清空 |
sync.Map |
并发 map | 读多写少优化 |
atomic |
原子操作 | Add/T/CompareAndSwap |
WaitGroup 典型用法
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t string) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait() // 阻塞等待所有任务完成
常见面试题
Q1:sync.Pool 会被 GC 清空,如何在高频分配场景下使用?
答:适用于短生命周期对象的复用(如每次 HTTP 请求的 buffer)。注意:不能用于连接池(连接需要保持),只能用 sync.RWMutex + channel 实现连接池。
Q2:atomic.Value 能存哪些类型?
答:只能存同一类型的值,存储非 pointer 类型时无 GC 问题;存 pointer 类型时需确保不逃逸或在写入前 copy。
📝 本章高频问题速记
| 问题 | 核心答案 |
|---|---|
| goroutine 栈大小 | 初始 2KB,最大 1GB |
| mutex 能否重入 | 不可重入 |
| RWMutex 死锁 | 持有读锁时不能加写锁 |
| context 取消传播 | 父取消 → 所有子取消(广播) |
| 逃逸分析时机 | 编译期,静态分析 |
| Go GC 类型 | 三色并发标记清除(tracing) |
| WaitGroup 关键 | Add → Done → Wait,LIFO 顺序 |