面试里常见一道题:两个 goroutine 轮流打印 1 2 3 ... 10。
代码并不长:
package main
import (
"fmt"
"sync"
)
func main() {
odd := make(chan struct{})
even := make(chan struct{})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 1; i <= 9; i += 2 {
<-odd
fmt.Println(i)
even <- struct{}{}
}
}()
go func() {
defer wg.Done()
for i := 2; i <= 10; i += 2 {
<-even
fmt.Println(i)
if i < 10 {
odd <- struct{}{}
}
}
}()
odd <- struct{}{}
wg.Wait()
}
两个 Channel 传递的不是数字,而是执行权。odd 收到信号后打印奇数,再把执行权交给 even。最后一次不再回传,否则接收方已经退出,发送方会永久阻塞。
这道题真正有价值的地方不是背写法,而是检查几个并发问题:
- 谁负责启动第一步?
- 谁拥有下一次执行权?
- 最后一次发送有没有接收者?
- 主 goroutine 如何等待任务结束?
- 把上限改成奇数后,退出条件是否仍正确?
Channel 很适合表达事件和所有权转移。如果只是保护一个共享计数器,互斥锁可能更直接;如果要求严格交替,Channel 会让顺序关系更清楚。
写并发代码时,先画出“谁在等谁”,通常比先敲代码更快。大多数死锁都藏在最后一次发送、异常退出和无人接收里。
从一道题走到真实功能
轮流打印的状态很少,顺序也完全确定。真实功能不会这么客气。
例如实现 LRU 缓存,标准结构是哈希表加双向链表:哈希表负责 O(1) 定位节点,链表头表示最近使用,容量满时淘汰尾部。算法写完后,还要面对并发和异常边界。
最容易误判的是 Get。它看起来是读操作,命中后却要把节点移到链表头,因此会修改共享结构。如果多个 goroutine 同时 Get 和 Put,不能简单地让所有 Get 都拿读锁。
保守实现可以用同一把互斥锁覆盖查表、摘链、插入和淘汰,先保证操作原子性。只有压测确认锁竞争成为瓶颈后,才需要考虑分片、近似 LRU 或其他策略。
接下来还要问:容量为 0 怎么处理?更新已有 key 是否提升热度?淘汰回调能不能在锁内执行?容量按条数还是字节?是否有 TTL?外部拿到的是副本还是可变指针?
并发测试除了检查结果,还应该跑一次:
go test -race ./...
从交替打印到 LRU,考查的其实是同一种能力:把正常路径之外的等待关系、退出条件和同时发生的操作看清楚。线上问题很少来自完全错误的代码,更多时候来自两个单独正确的动作碰巧同时发生。