Appearance
Go 并发编程
Go 并发是面试的重中之重。GMP 调度、channel、锁、context、并发模式——掌握这些才算真正理解 Go 的核心优势。
Q1: goroutine 和线程的区别?为什么 goroutine 轻量? 「🟢 校招/初级」
考察点:对 Go 并发基石的理解,初级必问题。
参考答案:
goroutine 是 Go 运行时管理的用户态轻量级协程,和操作系统线程有本质区别。
| 维度 | goroutine | 操作系统线程 |
|---|---|---|
| 栈大小 | 初始 2KB,可动态伸缩(最大可达 GB 级) | MB 级(通常 1~8MB),固定大小 |
| 调度方式 | Go 运行时调度(用户态),M:N 映射 | 操作系统内核调度(内核态),1:1 映射 |
| 切换开销 | 极小,只保存 PC、SP 等几个寄存器 | 很大,内核态切换、缓存失效、TLB 刷新 |
| 创建销毁 | 成本极低,几纳秒级 | 成本高,需要系统调用、分配栈空间 |
| 数量上限 | 几万到几十万都没问题 | 几千个就很吃资源了 |
| 数据结构 | runtime.g,是一个结构体对象 | 内核 task_struct,是操作系统实体 |
为什么 goroutine 轻量:
- 栈小且可伸缩:初始只有 2KB,用多少分配多少,不够了再扩容(复制到更大的栈)。
- 用户态调度:由 Go runtime 在用户态调度,不经过内核,切换成本极低。
- M:N 映射:多个 goroutine 复用到少数几个 OS 线程上,不需要每个 goroutine 一个线程。
- 创建销毁快:不需要系统调用,在用户态就能完成。
追问延伸:
- goroutine 的栈是怎么增长的?(函数序言检查栈空间,不够就调用
runtime.morestack分配新栈并复制) - 一个 goroutine 占多少内存?(初始 2KB 栈 + g 结构体几十字节,总共很小)
- goroutine 调度是抢占式的吗?(Go 1.14 后是基于信号的异步抢占)
Q2: GMP 调度模型的详细原理? 「🟡 中级」
考察点:Go 运行时核心机制,中级工程师必问。
参考答案:
GMP 是 Go 的调度模型,由三部分组成:
- G(goroutine):协程,Go 并发的基本执行单元,初始栈 2KB。
- M(Machine):OS 线程,真正执行代码的载体,受操作系统调度。
- P(Processor):逻辑处理器,持有本地 G 队列,M 必须绑定 P 才能执行 G。数量默认为 CPU 核数(
GOMAXPROCS)。
核心数据结构:
- P 的本地队列:每个 P 有一个本地 G 队列(容量 256),存取不需要加锁,性能高。
- 全局队列:所有 P 共享的 G 队列,存取需要加锁。
- M 的数量:通常比 P 多,因为 M 可能因为系统调用阻塞,需要额外的 M 来绑定 P 继续运行。
调度流程:
- M 绑定一个 P,从 P 的本地队列头部取一个 G 执行。
- 如果本地队列为空:去全局队列取一批 G(拿
len(global)/GOMAXPROCS + 1个),放到本地队列。 - 如果全局队列也空:从其他 P 的本地队列偷一半的 G(work stealing 机制),均衡负载。
系统调用时的调度:
- 发起系统调用:M 和 P 解绑,P 找一个空闲的 M(或新建 M)继续执行其他 G。
- 系统调用返回:M 尝试找一个空闲的 P 绑定;如果找不到,就把 G 放到全局队列,M 进入休眠。
调度时机:
- 函数调用时的栈检查(可能触发抢占)
- channel 操作(阻塞时让出)
- 系统调用(前后都有调度点)
runtime.Gosched()主动让出- 定时调度检查(sysmon 监控线程)
go
// 查看 GOMAXPROCS
runtime.GOMAXPROCS(0) // 0 表示获取当前值,不修改追问延伸:
- 为什么要有 P 这一层?没有 P 行不行?(P 持有本地队列减少锁争用,实现 work stealing,绑定上下文资源)
- work stealing 偷哪个 P 的?偷多少?(随机选一个 P,偷它本地队列的一半)
- M 阻塞在系统调用上时,它上面的 G 怎么办?(P 和 M 解绑,P 带着队列找新 M 继续跑;旧 M 阻塞,系统调用返回后再找 P)
Q3: Go 的抢占式调度是怎么实现的? 「🔴 高级」
考察点:对调度器深入理解的标志,高级面试常考。
参考答案:
Go 的抢占式调度经历了两个阶段的演进:
Go 1.14 之前:协作式抢占
- 原理:在函数序言插入栈检查指令,如果检测到需要抢占,就调用
runtime.morestack触发调度。 - 问题:如果 goroutine 长时间执行循环且不调用函数(如纯计算的死循环),就不会有抢占点,会长时间占用 P。
- 典型场景:
for {}空循环会永久占用一个 P,无法被抢占。
Go 1.14 及以后:基于信号的异步抢占
- 原理:使用
SIGURG(紧急信号)实现真正的异步抢占。 - 关键角色:
sysmon(监控线程),独立于 GMP 之外运行,定期检查:- 检测长时间运行的 G(超过 10ms)
- 向对应的 M 发送
SIGURG信号
- 抢占流程:
- M 收到信号,触发信号处理函数
- 信号处理函数修改 G 的执行上下文,设置抢占标志
- G 在安全点(safe-point)被挂起,切换到调度器
- 调度器把 G 放回队列,调度其他 G 执行
抢占点(安全点):
- 函数序言(最常见)
- 循环的回边(对循环也能抢占了,这是 1.14 的重大改进)
- 非内联函数调用
注意事项:
- 抢占不是"立即"的:需要等到安全点才能保存和恢复状态
- 原子操作序列、极短的纯计算片段可能仍然无法被及时抢占
- 栈扫描时也需要安全点,保证指针位置可识别
go
// 演示:Go 1.14 后这个循环不会永久阻塞调度器了
func busyLoop() {
for {
// 1.14 后循环回边也可能成为抢占点
}
}追问延伸:
- 为什么用 SIGURG 而不是其他信号?(SIGURG 默认被忽略,不影响用户程序;不用于其他常规用途)
- 抢占式调度和协作式调度的优缺点?(抢占式更公平但有开销,协作式开销小但可能不公平)
- sysmon 线程还做什么?(监控 GC、netpoller 轮询、检测死锁等)
Q4: channel 的底层结构?发送和接收的过程? 「🟡 中级」
考察点:CSP 模型核心,Go 并发的精髓。
参考答案:
channel 的底层是 runtime.hchan 结构体,核心字段:
go
type hchan struct {
qcount uint // 缓冲区中元素个数
dataqsiz uint // 缓冲区大小(容量)
buf unsafe.Pointer // 环形缓冲区指针
elemsize uint16 // 元素大小
closed uint32 // 是否已关闭
elemtype *_type // 元素类型
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 接收等待队列(sudog 链表)
sendq waitq // 发送等待队列(sudog 链表)
lock mutex // 互斥锁
}发送过程(ch <- x)
有接收者等待(recvq 非空):
- 直接从 recvq 取出第一个等待的 goroutine
- 把数据直接复制给它(不走缓冲区)
- 唤醒该 goroutine,设置它的返回值
- 返回
缓冲区有空位(qcount < dataqsiz):
- 把数据放到 buf[sendx] 位置
- sendx++,如果到末尾就回绕到 0
- qcount++
- 返回
缓冲区满了(无缓冲或有缓冲但满了):
- 当前 goroutine 封装成 sudog 加入 sendq
- 挂起等待,让出 P
- 被唤醒时数据已被取走,继续执行
接收过程(x := <-ch)
有发送者等待(sendq 非空):
- 直接从 sendq 取出第一个等待的 goroutine
- 直接从它那里拿数据(无缓冲时)或从缓冲区头部拿、把发送者的数据放到尾部(有缓冲时)
- 唤醒该发送者
- 返回数据
缓冲区有数据(qcount > 0):
- 从 buf[recvx] 取数据
- recvx++,回绕
- qcount--
- 返回
缓冲区空:
- 当前 goroutine 封装成 sudog 加入 recvq
- 挂起等待
- 被唤醒时数据已就绪
关闭过程(close(ch))
- 设置 closed = 1
- 唤醒所有 recvq 中的接收者(返回零值 + ok=false)
- 如果 sendq 中有发送者,全部唤醒 → 它们会 panic(向已关闭的 channel 发送)
追问延伸:
- 为什么 channel 需要 lock?不是说 CSP 不需要锁吗?(channel 内部维护队列和缓冲区,还是需要锁保护;但对用户来说是封装好的,使用层面不用手动加锁)
- 向 nil channel 发送/接收会怎样?(永久阻塞)
- 关闭已关闭的 channel 会怎样?(panic)
- sudog 是什么?为什么不用 g 直接排队?(sudog 是 g 的包装,包含等待的元素、channel 等信息;一个 g 可能同时在多个等待队列里,所以需要 sudog)
Q5: 有缓冲 channel 和无缓冲 channel 的区别?使用场景? 「🟢 校招/初级」
考察点:channel 基础概念,初级必问。
参考答案:
| 维度 | 无缓冲 channel | 有缓冲 channel |
|---|---|---|
| 创建方式 | make(chan T) 或 make(chan T, 0) | make(chan T, n),n >= 1 |
| 发送条件 | 必须有接收者同时在等,否则阻塞 | 缓冲区未满即可发送,满了才阻塞 |
| 接收条件 | 必须有发送者同时在等,否则阻塞 | 缓冲区非空即可接收,空了才阻塞 |
| 作用 | 同步通信(握手) | 异步解耦、削峰 |
| 典型场景 | 信号传递、同步等待、保证顺序 | 生产者消费者、工作池、限流 |
无缓冲 channel 的使用场景
go
// 场景1:同步等待(握手)
done := make(chan struct{})
go func() {
// 做一些工作
close(done) // 发信号
}()
<-done // 等待完成
// 场景2:传递信号(零成本)
sig := make(chan struct{})
// 用 struct{} 不占内存有缓冲 channel 的使用场景
go
// 场景1:工作池限流
sem := make(chan struct{}, 10) // 同时最多 10 个并发
for _, task := range tasks {
sem <- struct{}{} // 占一个位置
go func(t Task) {
defer func() { <-sem }() // 释放
process(t)
}(task)
}
// 场景2:生产者消费者
queue := make(chan Item, 100)
// 生产者:往 queue 里放
// 消费者:从 queue 里取选择原则
- 需要同步、保证顺序、传递信号 → 无缓冲
- 需要异步、解耦、削峰、限流 → 有缓冲
- 不确定用哪个 → 先从无缓冲开始,它的语义更明确
追问延伸:
- 有缓冲 channel 满了之后发送方阻塞,这时候如果没人消费会怎样?(goroutine 泄漏)
- 怎么实现一个超时的 channel 操作?(
select+time.After) - 单向 channel(
chan<-和<-chan)有什么用?(限制权限,防止误用,更清晰的接口语义)
Q6: Go 中的锁有哪些?sync.Mutex 和 sync.RWMutex? 「🟡 中级」
考察点:并发编程基础工具的理解和选型能力。
参考答案:
Go 标准库提供了多种同步原语,常用的锁有以下几种:
sync.Mutex(互斥锁)
最基本的互斥锁,同一时间只能有一个 goroutine 持有。
go
var mu sync.Mutex
var count int
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}特点:
Lock()加锁,Unlock()解锁- 不可重入:同一个 goroutine 再次 Lock 会死锁(和 Java 的 ReentrantLock 不同)
- 两种模式:
- 正常模式:新来的 goroutine 和唤醒的 goroutine 竞争,可能新来的先拿到(吞吐量高)
- 饥饿模式:如果有 goroutine 等待超过 1ms,切换到饥饿模式,等待最久的 goroutine 优先获得锁(保证公平)
sync.RWMutex(读写锁)
读共享、写独占的锁,适用于读多写少的场景。
go
var rw sync.RWMutex
var data map[string]string
func read(key string) string {
rw.RLock()
defer rw.RUnlock()
return data[key]
}
func write(key, val string) {
rw.Lock()
defer rw.Unlock()
data[key] = val
}特点:
RLock()/RUnlock():读锁,多个 goroutine 可以同时持有Lock()/Unlock():写锁,独占- 写锁优先级高于读锁:有写锁在等,新的读锁会被阻塞(防止写饥饿)
- 读锁不能升级为写锁:已经持有读锁的情况下调用 Lock 会死锁
其他同步原语
| 原语 | 用途 | 特点 |
|---|---|---|
sync.Mutex | 互斥锁 | 通用,写多读少 |
sync.RWMutex | 读写锁 | 读多写少,读共享写独占 |
sync.WaitGroup | 等待一组任务完成 | Add/Done/Wait |
sync.Once | 只执行一次 | 单例、初始化 |
sync.Cond | 条件变量 | 等待/通知模式 |
atomic 包 | 原子操作 | 无锁,简单变量 |
选型建议
- 写多读少 →
sync.Mutex - 读多写少 →
sync.RWMutex - 简单计数器/标志位 →
atomic包 - 传递数据所有权 →
channel
重要注意:不要复制锁!
go
// 错误:值拷贝会复制锁状态,两个副本独立了
type BadCounter struct {
mu sync.Mutex
n int
}
func (c BadCounter) Incr() { // 值接收者,拷贝了 mu!
c.mu.Lock()
c.n++
c.mu.Unlock()
}
// 正确:用指针接收者
type GoodCounter struct {
mu sync.Mutex
n int
}
func (c *GoodCounter) Incr() { // 指针接收者
c.mu.Lock()
c.n++
c.mu.Unlock()
}追问延伸:
- Mutex 的正常模式和饥饿模式的区别?为什么要有饥饿模式?
- RWMutex 为什么写锁优先级高?可能导致读饥饿吗?(不会,写锁释放后所有等待的读锁一起放行)
- 为什么 Mutex 不可重入?设计考量是什么?(Go 的哲学是锁应该简单,重入锁容易掩盖代码问题)
sync.Locker接口是什么?(Mutex 和 RWMutex 都实现了它,只有 Lock/Unlock 两个方法)
Q7: sync.Map 的原理?和 map+RWMutex 比哪个好? 「🟡 中级」
考察点:对并发安全 map 的理解深度和选型能力。
参考答案:
sync.Map 是 Go 提供的并发安全 map,但它不是"万能药",有特定的适用场景。
内部结构
sync.Map 内部用了双 map 设计来减少锁竞争:
- read(
atomic.Value存 readOnly 结构体):只读 map,原子操作存取,不需要加锁- 包含一个
m map[any]*entry amended标记:dirty 里是否有 read 没有的 key
- 包含一个
- dirty(普通 map):读写都需要加
mu锁 - misses(int):read 未命中的次数计数
entry 的三种状态:
nil:已删除(key 在 read 里标记删除)expunged:已擦除(key 不在 dirty 中,彻底删除状态)- 正常指针:指向实际的值
读操作(Load)
- 先从 read 里找(原子读,无锁)
- 命中 → 直接返回
- 没命中 且
amended=true(dirty 有新 key) → 加锁,再检查一次 read(可能期间 read 被更新了) - read 还是没有 → 从 dirty 里找
- miss 计数 +1,达到阈值(
len(dirty))时触发 dirty 提升
写操作(Store)
- 如果 key 在 read 里,尝试直接更新(CAS 更新 entry,无锁)
- 如果 key 不在 read 里 → 加锁
- 再检查 read → 还没有就看 dirty
- dirty 里有就更新 dirty
- dirty 里没有就新增到 dirty,并设置
amended=true
删除操作(Delete)
- read 里有 → 直接把 entry 标记为 nil(延迟删除,无锁)
- read 里没有 且 amended → 加锁,从 dirty 里删除
dirty 提升(miss 太多时)
- 把 dirty 整个赋值给 read(原子替换)
- dirty 置为 nil
- misses 清零
- 下次写的时候再重建 dirty
适用场景对比
| 场景 | sync.Map | map + RWMutex |
|---|---|---|
| 读多写极少 | 好(大部分读无锁) | 也可以,但读需要加读锁 |
| 写多读少 | 差(频繁加锁 + dirty 重建开销) | 好(简单直接) |
| key 稳定(增删少) | 好(read 命中率高) | 都可以 |
| key 频繁增删 | 差(read 命中率低,频繁提升) | 好 |
| 遍历多 | 一般(需要锁) | 好(控制更灵活) |
核心结论:sync.Map 不是银弹,大部分场景普通 map + 锁反而更简单、更快。只有在读极多、写极少、key 稳定的场景才考虑 sync.Map。
追问延伸:
- sync.Map 为什么不直接用一个 map + 锁?(双 map 设计让读路径无锁,在读多写少的场景性能更好)
- entry 为什么有 expunged 状态?和 nil 有什么区别?(nil 是逻辑删除,dirty 提升时会被清理;expunged 是 key 不在 dirty 里的标记,用于区分"已删除"和"不存在于 dirty")
- sync.Map 怎么遍历?(
Range方法,遍历的时候会先把 dirty 提升为 read) - sync.Map 的大小能直接获取吗?(不能,没有 Len 方法,需要自己计数或 Range 统计)
Q8: context 的底层原理?怎么实现取消和超时? 「🟡 中级」
考察点:Go 并发编程的基础设施,必问。
参考答案:
context.Context 是 Go 中用于传递请求范围的取消信号、超时、截止时间和请求级值的接口。
Context 接口
go
type Context interface {
Deadline() (deadline time.Time, ok bool) // 返回截止时间
Done() <-chan struct{} // 返回一个 channel,取消时关闭
Err() error // 返回取消原因
Value(key any) any // 获取绑定的值
}四种实现类型
1. emptyCtx(空 context)
context.Background()和context.TODO()都是 emptyCtx- 永远不会取消,没有值,没有截止时间
- 作为根 context 使用
2. cancelCtx(可取消的 context)
go
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{} // 懒初始化
children map[canceler]struct{} // 子 context 集合
err error
}取消过程:
- 调用
cancel()→ 加锁 - 关闭
donechannel(所有监听 Done() 的 goroutine 都会收到信号) - 遍历
children,递归取消所有子 context - 从父 context 的 children 中移除自己
- 设置
err为取消原因
3. timerCtx(带超时的 context)
go
type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}- 继承
cancelCtx的所有能力 - 额外有一个
time.Timer,到时间自动调用cancel() WithTimeout和WithDeadline都是创建 timerCtx
4. valueCtx(带值的 context)
go
type valueCtx struct {
Context
key, val any
}- 包装父 context,附加一对 key-value
- 查找 key 时,递归向上找(自己没有就找父 context)
- 形成一条链式结构
取消传播机制
parent cancelCtx
├── child1 cancelCtx
│ └── grandChild valueCtx
└── child2 timerCtx当 parent 被取消时:
- parent 关闭自己的 done channel
- parent 遍历 children,逐个调用 cancel
- child1 取消 → 它再取消自己的 children → grandChild 也被取消
- child2 取消 → 停止 timer → 它的 done 也关闭
所有下游 goroutine 通过 select <-ctx.Done() 感知到取消信号。
go
// 典型用法
func handleRequest(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
select {
case result := <-doWork(ctx):
// 处理结果
case <-ctx.Done():
// 超时或被取消
log.Println(ctx.Err())
}
}追问延伸:
- 为什么 valueCtx 的 key 推荐用自定义类型而不是 string?(防止不同包之间的 key 冲突,用私有类型更安全)
- context 应该作为第一个参数还是放在结构体里?(Go 官方推荐作为函数第一个参数,不要存到结构体里)
- 取消是同步的还是异步的?(
cancel()是同步的,它会等所有子 context 都取消完再返回吗?不会,它触发取消就返回,子 goroutine 的退出是异步的) - context.Value 应该存什么?不应该存什么?(存请求级数据:trace_id、用户信息、超时配置;不应该存函数的必填参数、业务数据)
Q9: Go 中常见的并发模式? 「🟡 中级」
考察点:并发编程的实践经验和设计能力。
参考答案:
Go 有一套独特的并发编程模式,核心是 goroutine + channel 的组合。
1. 生产者消费者模式
最经典的模式,用 channel 作为队列解耦生产和消费。
go
func producer(ch chan<- int) {
for i := 0; i < 100; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int) {
for v := range ch { // channel 关闭后自动退出
fmt.Println(v)
}
}2. 工作池(Worker Pool)
固定数量 worker 从 channel 取任务,控制并发度。
go
func workerPool(tasks <-chan Task, results chan<- Result, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for task := range tasks {
results <- process(task)
}
}()
}
wg.Wait()
close(results)
}3. 扇入/扇出(Fan-in / Fan-out)
- 扇出:一个输入分发到多个 goroutine 并行处理
- 扇入:多个输出合并到一个 channel
go
// 扇出:一个输入 channel,分发给多个 worker
func fanOut(in <-chan Task, n int) []<-chan Result {
outs := make([]<-chan Result, n)
for i := 0; i < n; i++ {
outs[i] = worker(in)
}
return outs
}
// 扇入:多个输出 channel 合并成一个
func fanIn(chs ...<-chan Result) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for _, ch := range chs {
wg.Add(1)
go func(c <-chan Result) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}4. 超时控制
用 select + time.After 或 context.WithTimeout 实现超时。
go
// 方式1:time.After(简单场景)
select {
case result := <-ch:
fmt.Println(result)
case <-time.After(3 * time.Second):
fmt.Println("timeout")
}
// 方式2:context(推荐,可传播取消)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
select {
case result := <-ch:
fmt.Println(result)
case <-ctx.Done():
fmt.Println(ctx.Err())
}5. 限流(信号量模式)
用带缓冲的 channel 作为信号量。
go
sem := make(chan struct{}, 10) // 最多 10 个并发
func doWork(task Task) {
sem <- struct{}{} // 获取信号量
defer func() { <-sem }() // 释放
// 处理任务
}6. 优雅退出
多种方式实现 goroutine 的优雅退出:
go
// 方式1:done channel
func worker(done <-chan struct{}) {
for {
select {
case <-done:
return
default:
// 干活
}
}
}
// 方式2:context(推荐,支持级联取消)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// 干活
}
}
}7. errgroup(一个出错就取消所有)
golang.org/x/sync/errgroup 包提供,一个 goroutine 出错就取消其他所有的。
go
import "golang.org/x/sync/errgroup"
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
return doTask1(ctx) // ctx 被取消时这个任务应该感知并退出
})
g.Go(func() error {
return doTask2(ctx)
})
if err := g.Wait(); err != nil {
// 有一个出错了,其他的已被取消
log.Println(err)
}8. 关闭 channel 广播
关闭 channel 会唤醒所有等待的接收者,利用这个特性实现一对多的通知。
go
stop := make(chan struct{})
// 多个 goroutine 都监听同一个 stop channel
go func() {
<-stop
fmt.Println("worker 1 stopped")
}()
go func() {
<-stop
fmt.Println("worker 2 stopped")
}()
// 关闭 channel,所有监听者都会收到信号
close(stop)追问延伸:
- 工作池的 worker 数量怎么设置?(CPU 密集型设为 GOMAXPROCS,IO 密集型可以更多)
- 扇入扇出适用于什么场景?(数据处理流水线、并行计算)
- 除了 channel,还有什么方式实现优雅退出?(context、sync.Cond、atomic 标志位)
- 什么是 pipeline 模式?(多个 channel 串联,前一个的输出是后一个的输入)
Q10: sync.WaitGroup 的用法和原理? 「🟢 校招/初级」
考察点:最常用的同步原语,初级必问。
参考答案:
sync.WaitGroup 用于等待一组 goroutine 完成,类似于计数器。
基本用法
go
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 计数 +1,要在 goroutine 启动前调用
go func(i int) {
defer wg.Done() // 计数 -1
fmt.Println(i)
}(i)
}
wg.Wait() // 阻塞直到计数为 0
fmt.Println("all done")
}三个方法
| 方法 | 作用 | 注意 |
|---|---|---|
Add(n) | 计数增加 n(可以是负数) | 一般在启动 goroutine 前调用 |
Done() | 计数减 1 | 等价于 Add(-1),用 defer 最安全 |
Wait() | 阻塞直到计数变为 0 | 不能和 Add 并发使用 |
底层原理
WaitGroup 内部用一个 state1 字段(uint64)同时存储两个值:
- 高 32 位:计数器(counter)
- 低 32 位:等待者数量(waiter count)
state1 (64位):
+-----------------------+-----------------------+
| counter (32位) | waiter count (32位) |
+-----------------------+-----------------------+Add:用atomic.AddUint64原子地修改 counterWait:检查 counter,如果 > 0 就增加 waiter 计数,然后阻塞等待- 当 counter 变为 0 时,如果有等待者,就唤醒所有等待者
常见坑
坑1:Add 在 goroutine 里面调用
go
// 错误:Wait 可能在 Add 之前执行,导致提前返回
var wg sync.WaitGroup
go func() {
wg.Add(1) // 太晚了!主 goroutine 可能已经执行完 Wait 了
defer wg.Done()
}()
wg.Wait() // 可能直接返回坑2:复制 WaitGroup
go
// 错误:值拷贝会复制内部状态
func bad(wg sync.WaitGroup) { // 值传递,拷贝了一份
wg.Wait()
}
// 正确:用指针
func good(wg *sync.WaitGroup) {
wg.Wait()
}坑3:计数变为负数
go
// 会 panic:sync: negative WaitGroup counter
var wg sync.WaitGroup
wg.Done() // 初始是 0,Done 变成 -1追问延伸:
- WaitGroup 可以重用吗?(可以,计数回到 0 之后可以再次 Add 使用)
- Wait 和 Add 可以并发调用吗?(可以,但有讲究:Add 正数要在 Wait 之前;Add 负数(Done)可以和 Wait 并发)
- WaitGroup 内部为什么用 uint64 存两个值而不是两个字段?(可以用一条原子操作同时操作,效率更高)
- 为什么 WaitGroup 不能值拷贝?(拷贝后两个 WaitGroup 状态独立,可能导致不可预期的行为)
Q11: sync.Once 的原理?怎么保证只执行一次? 「🟡 中级」
考察点:单例模式和原子操作的应用。
参考答案:
sync.Once 保证某个函数只执行一次,常用于单例初始化、配置加载等场景。
基本用法
go
var once sync.Once
var config *Config
func getConfig() *Config {
once.Do(func() {
config = loadConfig() // 只会执行一次
})
return config
}底层原理
go
type Once struct {
done uint32 // 是否已执行的标记(0=未执行,1=已执行)
m Mutex // 互斥锁
}Do 方法的执行流程:
- 快速路径:原子读
done,如果是 1,直接返回(无锁,已执行过了) - 慢速路径:
done是 0,调用doSlow- 加锁
- 再次检查 done(双重检查锁定 DCL)
- 如果还是 0,执行函数 f
- 执行完后原子设置
done = 1 - 解锁
为什么加锁后还要检查一次?
因为可能有两个 goroutine 同时读到 done=0,都进入慢速路径:
- Goroutine A 先拿到锁,执行 f,设置 done=1,释放锁
- Goroutine B 拿到锁,如果不再次检查,就会再执行一次 f
- 所以加锁后必须再检查一次 done,这就是双重检查
注意事项
1. panic 也算"执行过了"
如果 f 内部 panic 了,done 仍然会被设为 1,后续的 Do 不会再执行 f。
2. 不要嵌套 Do 同一个 once
go
// 会死锁
once.Do(func() {
once.Do(func() { // 同一个 once,嵌套调用
// 想拿锁,但外层已经拿着了
})
})这会导致死锁,因为 Mutex 不可重入。
3. Once 是一次性的
一旦函数执行过(哪怕 panic 了),这个 Once 就"用完了",不能重置。
追问延伸:
- 为什么不用一个 Mutex 就完事,还要加 done 的原子检查?(快速路径无锁,性能好;如果已经执行过了,就不需要加锁了)
- sync.Once 和自己写的单例有什么区别?(更安全、更高效,标准实现)
- 如果 f 执行很慢,其他 goroutine 调用 Do 会怎样?(会阻塞在锁上,等第一个执行完)
- 怎么实现一个可重置的 Once?(标准库没有,可以自己用 Mutex + 标志位实现,但要小心并发)
Q12: Go 中 goroutine 泄漏是什么?怎么排查和避免? 「🟡 中级」
考察点:实际工程经验和问题排查能力。
参考答案:
什么是 goroutine 泄漏
goroutine 泄漏是指 goroutine 因为各种原因永远无法退出,它占用的栈内存和相关资源无法被回收,随着泄漏数量增加,最终可能导致内存耗尽、程序崩溃。
和内存泄漏类似,但泄漏的是 goroutine(每个 goroutine 至少占 2KB 栈 + g 结构体)。
常见原因
1. channel 永远阻塞
go
// 泄漏:没人往 ch 里发数据,这个 goroutine 永远等下去
func leak1() {
ch := make(chan int)
go func() {
v := <-ch // 永远等不到
fmt.Println(v)
}()
// 函数返回了,但 goroutine 还在等
}
// 泄漏:channel 满了,没人消费,发送方阻塞
func leak2() {
ch := make(chan int, 1)
go func() {
ch <- 1
ch <- 2 // 缓冲区满了,没人取,阻塞在这里
}()
}2. 死循环没有退出条件
go
// 泄漏:没有退出条件的死循环
func leak3(stop <-chan struct{}) {
go func() {
for {
// 忘了检查 stop
doWork()
}
}()
}3. 死锁
多个 goroutine 互相等待对方释放资源,谁都无法继续。
4. 阻塞在系统调用
goroutine 阻塞在某个永远不会返回的系统调用上(如网络 IO 没有超时)。
5. 忘记关闭 channel
go
// 泄漏:range 一个永远不会关闭的 channel
func leak4(ch chan int) {
go func() {
for v := range ch { // ch 永远不关闭,永远循环
fmt.Println(v)
}
}()
}排查方法
1. pprof(最常用)
go
import _ "net/http/pprof"
// 然后浏览器访问:
// http://localhost:6060/debug/pprof/goroutine?debug=1
// 或者用命令行:
// go tool pprof http://localhost:6060/debug/pprof/goroutinedebug=1 看 goroutine 数量和概览,debug=2 看所有 goroutine 的完整堆栈。
2. runtime.NumGoroutine()
go
// 监控 goroutine 数量,如果异常增长就是有泄漏
log.Printf("goroutine count: %d", runtime.NumGoroutine())3. goleak 库
go.uber.org/goleak 用于测试中检测 goroutine 泄漏:
go
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // 测试结束后检查有没有泄漏的 goroutine
}避免方法
- 用 context 控制生命周期:每个 goroutine 都应该能被取消
- 确保 channel 有退出路径:不要让 goroutine 无限制地等一个 channel
- 超时机制:IO 操作、网络请求一定要设置超时
- 工作池限制数量:不要无限制地开 goroutine
- 关闭 channel 广播退出:用
close(ch)通知所有接收者退出 - 代码审查:每个
go func()都要想清楚它怎么退出
追问延伸:
- goroutine 泄漏会导致什么后果?(内存持续增长、GC 压力大、程序变慢甚至 OOM)
- 怎么用 pprof 定位具体是哪里泄漏的?(看 goroutine 的堆栈,找到卡住的那个调用栈)
- channel 的发送方和接收方,哪边更容易导致泄漏?(都可能,但发送方阻塞在满 channel 上更隐蔽)
- 一个 goroutine 占多少内存?泄漏多少会有问题?(初始 2KB 栈,增长后更大;几千个可能不明显,上万个就有压力了)
Q13: Go 的 CSP 模型是什么?和共享内存有什么区别? 「🟡 中级」
考察点:对 Go 并发哲学的理解。
参考答案:
什么是 CSP
CSP(Communicating Sequential Processes,通信顺序进程)是一种并发编程模型,核心思想是:
不要通过共享内存来通信,而要通过通信来共享内存。
Go 的 CSP 实现 = goroutine + channel
- goroutine:独立的执行单元
- channel:goroutine 之间通信的通道,传递数据(同时也传递数据所有权)
CSP vs 共享内存+锁
| 维度 | CSP(channel) | 共享内存 + 锁 |
|---|---|---|
| 核心思想 | 通过通信共享内存 | 通过共享内存通信 |
| 数据传递 | 传递数据所有权 | 多个 goroutine 同时访问 |
| 同步方式 | channel 天然同步 | 需要手动加锁解锁 |
| 出错概率 | 低(编译器和类型系统帮忙) | 高(死锁、竞态、遗忘解锁) |
| 可组合性 | 好(channel 可以组合出各种模式) | 差(锁的组合容易死锁) |
| 调试难度 | 相对容易 | 难(竞态条件难以复现) |
| 性能 | 有 channel 开销 | 锁竞争时开销也大 |
什么时候用 channel
- 传递数据所有权:一个 goroutine 产生数据,交给另一个处理
- 任务分发:工作池、生产者消费者
- 同步信号:等待、通知、超时
- 协调多个 goroutine:扇入扇出、流水线
go
// CSP 风格:通过 channel 传递结果
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2 // 把结果发出去(传递出去就不再管了)
}
}什么时候用锁
不是说 CSP 就一定比锁好,有些场景锁更合适:
- 性能极端敏感:临界区非常短,原子操作或自旋锁更快
- 保护复杂共享数据结构:比如并发安全的缓存、计数器
- 简单的状态标记:用 atomic 就够了
- 高性能场景:channel 有拷贝和调度开销,极致性能用锁
go
// 共享内存风格:用锁保护共享变量
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Incr() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}Go 的实践建议
Go 官方的建议是:能用 channel 就用 channel,但是不要为了用 channel 而用 channel。
选择的依据:
- 数据在 goroutine 之间流转 → channel
- 数据被多个 goroutine 同时访问 → 锁
- 简单计数 → atomic
- 想不清楚 → 先选简单的,能解决问题就行
追问延伸:
- Go 为什么选择 CSP 作为主要并发模型?(CSP 更符合人类思维、更容易写出正确的并发程序、channel 类型安全)
- channel 内部不也用了锁吗?那和自己用锁有什么区别?(channel 封装了锁,对使用者透明;而且 channel 有队列、阻塞唤醒等机制,不是单纯的锁)
- CSP 和 Actor 模型有什么区别?(Actor 是通过消息传递,每个 Actor 有邮箱;CSP 是通过 channel 通信,channel 是一等公民,和 goroutine 解耦)
- "不要通过共享内存来通信"这句话绝对吗?(不是绝对的,是一种指导思想;Go 也提供了 Mutex 等工具,该用还是要用)
Q14: atomic 包是什么?和 mutex 比有什么区别? 「🟡 中级」
考察点:底层并发原语的理解和选型。
参考答案:
atomic 包简介
sync/atomic 包提供了底层的原子操作,基于 CPU 的原子指令(如 CAS)实现,是无锁编程的基础。
常用操作
go
// 原子加
atomic.AddInt64(&counter, 1)
// 原子读
val := atomic.LoadInt64(&counter)
// 原子写
atomic.StoreInt64(&counter, 100)
// 原子交换
old := atomic.SwapInt64(&counter, 200)
// 比较并交换(CAS)
swapped := atomic.CompareAndSwapInt64(&counter, 100, 200)
// 如果 counter 当前值是 100,就设为 200,返回 true
// 否则不做任何操作,返回 falseGo 1.19 引入了类型安全的泛型版本:
go
var counter atomic.Int64
counter.Add(1)
val := counter.Load()atomic vs Mutex
| 维度 | atomic | Mutex |
|---|---|---|
| 实现方式 | CPU 原子指令,无锁 | 操作系统互斥量,有锁 |
| 开销 | 低,用户态 | 高,可能内核态切换 |
| 适用场景 | 单个变量的简单操作 | 复杂临界区、多个变量 |
| 死锁风险 | 无 | 有 |
| 可组合性 | 差(只能操作单个变量) | 可以保护复杂逻辑 |
| 内存序 | 需要考虑(Go 1.19 类型安全版有明确语义) | 锁天然保证顺序 |
适用场景
atomic 适合:
- 计数器(递增、递减)
- 状态标志位(0/1 切换)
- 简单的引用计数
- 高性能场景下的无锁数据结构
- 作为其他并发原语的基础构件(WaitGroup、Once、sync.Map 内部都用了 atomic)
Mutex 适合:
- 保护复杂的数据结构
- 临界区内有多个操作
- 需要阻塞等待的场景
- 逻辑比较复杂,用锁写更清晰
CAS(Compare And Swap)
CAS 是 atomic 的核心操作,也是无锁编程的基础:
go
// 乐观锁:先检查,再更新
for {
old := atomic.LoadInt64(&val)
new := old + 1
if atomic.CompareAndSwapInt64(&val, old, new) {
break // 成功
}
// 失败了说明期间被别人改了,重试
}CAS 的问题:
- ABA 问题(值从 A 变 B 又变回 A,CAS 认为没变)
- 高并发下重试次数多,CPU 占用高
- 只能保证单个变量的原子性
注意事项
- 不要滥用 atomic:复杂场景用 Mutex 更清晰、更不容易出错
- 内存序问题:atomic 操作涉及内存可见性,Go 保证了顺序一致性,但要理解 happens-before
- 64 位对齐:在 32 位系统上,64 位原子操作需要 8 字节对齐,否则可能 panic
- Value 类型:
atomic.Value可以原子存取任意类型,但 Store 和 Load 的类型必须一致
追问延伸:
- 什么是无锁编程?有什么优缺点?(不用锁,用原子操作实现并发安全;优点是性能高、不死锁,缺点是难写难调试)
- CAS 的 ABA 问题是什么?怎么解决?(值从 A→B→A,CAS 误以为没变;用版本号或指针解决)
- WaitGroup、Once 内部哪里用了 atomic?(WaitGroup 的 state 计数用 atomic;Once 的 done 标记用 atomic)
- atomic 和 channel 哪个更快?(简单操作 atomic 快;涉及同步等待 channel 更方便,不好直接比)
- Go 的内存模型中,atomic 提供什么保证?(顺序一致性,atomic 的写 happens-before 于后续的读)