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() 定期打点;生产环境用 pprofgoroutine profile;常见泄漏原因: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 顺序