主题
第09章 面试题精选 —— 并发编程
这是 Go 面试的核心——前半段几乎必问。
★ 基础题
Q1:goroutine 和线程的区别?
| OS 线程 | goroutine | |
|---|---|---|
| 栈大小 | 1-8 MB(固定) | 2KB 起步,按需扩 |
| 创建开销 | 约 1-10 μs | 约 100 ns |
| 切换开销 | 进内核态 ~1μs | 用户态 ~10 ns |
| 数量上限 | 几千 | 百万级 |
| 调度 | OS 内核 | Go runtime(GMP) |
Q2:channel 是干什么的?
答:goroutine 之间通信的"管道"。
- 发送:
ch <- v - 接收:
v := <-ch - 无缓冲:发送和接收必须同时就位
- 带缓冲:缓冲未满时发送不阻塞
Go 哲学:Don't communicate by sharing memory; share memory by communicating.
Q3:以下代码输出什么?
go
ch := make(chan int)
go func() { ch <- 1 }()
go func() { ch <- 2 }()
fmt.Println(<-ch, <-ch)答:可能是 1 2 也可能是 2 1,取决于调度器。两个 goroutine 谁先发送是不确定的。
Q4:channel 关闭后会发生什么?
go
close(ch)
// 1. 再发送 → panic
ch <- 1 // panic!
// 2. 接收剩余数据
v, ok := <-ch // ok = true,正常拿到数据
// 3. 接收完后再接收
v, ok := <-ch // v = 0(零值),ok = false重要:只让发送方关闭 channel,接收方关闭可能 panic。
Q5:用 channel 实现生产者-消费者。
go
ch := make(chan int, 10)
// 生产者
go func() {
defer close(ch)
for i := 1; i <= 5; i++ {
ch <- i
}
}()
// 消费者
for v := range ch {
fmt.Println(v)
}for range 会自动在 channel 关闭后退出。
★★ 进阶题
Q6:sync.WaitGroup 的常见错误?
go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func() {
wg.Add(1) // ❌ 应该在 go 之前 Add
defer wg.Done()
// ...
}()
}
wg.Wait() // 可能 wg 还没 Add 就 Wait,立即返回正确写法:
go
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// ...
}()
}Q7:以下代码会输出什么?
go
func main() {
var x int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
x++
}()
}
wg.Wait()
fmt.Println(x)
}答:通常 < 1000,每次都不一样。经典数据竞争。
x++ = 读 + 加 + 写,三步操作,多 goroutine 同时读到旧值,写回时发生丢失更新。
修复:
go
var mu sync.Mutex
mu.Lock(); x++; mu.Unlock()
// 或用原子操作
atomic.AddInt64(&x, 1)Q8:select 没有就绪的 case 时怎么办?
go
select {
case v := <-ch1: ...
case v := <-ch2: ...
}
// 阻塞,直到任一 case 就绪带 default:
go
select {
case v := <-ch1: ...
default:
// 立即执行
}default 让 select 变成非阻塞。
Q9:什么情况下会发生 deadlock?
最常见:
go
// 1. 无缓冲 channel 自发自收
ch := make(chan int)
ch <- 1 // 永远阻塞,没接收方
fmt.Println(<-ch)
// 2. 主 goroutine 等不存在的接收
ch := make(chan int)
fmt.Println(<-ch) // fatal error: all goroutines are asleep
// 3. WaitGroup Add 数量与 Done 不匹配
wg.Add(3)
wg.Done(); wg.Done()
wg.Wait() // 永远等Go 运行时能自动检测所有 goroutine 都阻塞的死锁,输出 fatal error: all goroutines are asleep - deadlock!。
Q10:怎么实现一个超时控制?
go
ch := make(chan Result, 1)
go func() {
ch <- doWork()
}()
select {
case r := <-ch:
return r, nil
case <-time.After(3 * time.Second):
return Result{}, errors.New("timeout")
}或者用 context:
go
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return doWork(ctx)★★★ 深度题
Q11:解释 Go 的 GMP 模型。
- G Goroutine:用户的并发单元
- M Machine:真正执行 G 的 OS 线程
- P Processor:调度上下文(含本地 G 队列),数量 =
GOMAXPROCS
调度流程:
- 每个 P 维护一个本地 G 队列
- M 从 P 上拿 G 执行
- 本地队列空了,M 去全局队列偷 / 工作窃取(work-stealing)从其他 P 偷一半
- G 阻塞时(系统调用、channel 阻塞)M 释放 P,让别的 M 接管
这种 M:N 模型让 Go 能用少量线程支撑海量协程。
Q12:什么是"逃逸分析"?什么时候 goroutine 闭包会让变量逃逸?
go
func main() {
x := 100
go func() {
fmt.Println(x) // x 被 goroutine 引用
}()
}x 本来在栈上,但因为 goroutine 可能在 main 退出后还在用它,编译器会把它移到堆上——这就是逃逸。
go build -gcflags="-m" 可以查看逃逸分析结果。
Q13:channel 底层是怎么实现的?
chan 是一个指向 hchan 结构的指针:
go
type hchan struct {
qcount uint // 当前元素数
dataqsiz uint // 缓冲容量
buf unsafe.Pointer // 环形缓冲数组
elemsize uint16
closed uint32
sendx uint // 发送索引
recvx uint // 接收索引
recvq waitq // 接收等待队列
sendq waitq // 发送等待队列
lock mutex
}发送/接收都需要先加锁,做完操作后解锁。所以 channel 不是免费的,但比互斥锁清晰。
Q14:以下代码有什么问题?
go
func process(items []int) []int {
results := make([]int, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = item * 2 // ❌
}()
}
wg.Wait()
return results
}问题:i 和 item 是循环变量,所有 goroutine 共享(Go 1.21 及之前)。可能多个 goroutine 看到同一个值。
修复:
go
go func(i, item int) {
defer wg.Done()
results[i] = item * 2
}(i, item)Go 1.22+ 已自动修复此问题。
Q15:sync.Map 和加锁的 map 哪个更好?
| 场景 | 推荐 |
|---|---|
| key 集合频繁变化 | map + sync.RWMutex |
| 读多写少且 key 集合稳定 | sync.Map |
| 同一个 key 反复修改 | map + Mutex |
| 值是结构体而非简单类型 | map + Mutex(sync.Map 性能反而差) |
sync.Map 内部用了两层 map(read + dirty),减少加锁,但通用性不如 RWMutex。
Q16:Go 1.21 引入的 sync.OnceFunc 是什么?
go
init := sync.OnceFunc(func() {
fmt.Println("init")
})
init() // 执行
init() // 不执行比 sync.Once.Do 更优雅,配合闭包用更顺手。
📌 速记口诀
"go 启程;channel 通信;select 多路;锁保数据;context 取消”
🎯 自测练习
- 用 channel 实现一个信号量,控制最多 N 个并发
- 写一个worker pool:M 个 worker 处理 N 个任务,等所有完成
- 用
context.WithTimeout实现 HTTP 请求超时 - 解释
for v := range ch什么时候会自动退出 - 用
sync.Once实现一个并发安全的"全局配置加载器"
进入第10章 🚀