面试里常见一道题:两个 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 同时 GetPut,不能简单地让所有 Get 都拿读锁。

保守实现可以用同一把互斥锁覆盖查表、摘链、插入和淘汰,先保证操作原子性。只有压测确认锁竞争成为瓶颈后,才需要考虑分片、近似 LRU 或其他策略。

接下来还要问:容量为 0 怎么处理?更新已有 key 是否提升热度?淘汰回调能不能在锁内执行?容量按条数还是字节?是否有 TTL?外部拿到的是副本还是可变指针?

并发测试除了检查结果,还应该跑一次:

go test -race ./...

从交替打印到 LRU,考查的其实是同一种能力:把正常路径之外的等待关系、退出条件和同时发生的操作看清楚。线上问题很少来自完全错误的代码,更多时候来自两个单独正确的动作碰巧同时发生。