news 2026/10/10 14:30:14

Go并发面试题详解:两个goroutine交替打印1到100的三种解法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go并发面试题详解:两个goroutine交替打印1到100的三种解法

1. 题目拆解:面试官到底在考什么

“两个 goroutine 轮流打印 1 到 100,一个打印奇数,一个打印偶数”——这道题在 Go 面试中出现的频率,高到几乎可以跟“反转链表”并列。我第一次在面试中被问到的时候,脑子里全是 channel 的阻塞机制,手一抖就开始写无缓冲 channel 的错误示例。后来自己在团队里负责招聘,发现这道题确实能从很薄的切入点里看出候选人对 Go 并发模型的理解深度。

先看清楚题目的完整面貌。常见变体有两种:

  1. 两个 goroutine 交替输出 1 到 100,A 打印奇数,B 打印偶数,最终输出顺序是 1, 2, 3, 4, ..., 100。
  2. 三个或更多 goroutine 轮流打印,比如三个协程循环输出 A、B、C,或者 N 个协程按序号循环输出。

无论哪种变体,核心诉求都是需要精确控制多个 goroutine 的执行顺序和交接节奏。这看起来简单,实际上把 Go 并发里的几个核心概念全都串起来了:goroutine 的调度时序、channel 的同步语义、select 和 default 的行为差异、sync.WaitGroup 的使用规范,甚至还有原子操作的适用边界。

这道题的价值,不在于题目本身有多难,而在于它是一块试金石。我作为面试官,拿到候选人的代码后通常会先看几个点:是否避免了主协程提前退出的问题;是否在无缓冲 channel 和有缓冲 channel 之间做了正确选择;是否把 channel 的 close 时机处理清楚;是否引入了不必要的 data race;还有最容易被忽略的——是否保证了打印顺序的确定性。

实际面试中有个很有意思的现象:很多候选人能背出正确的答案,但当我追问一句“为什么这里必须用无缓冲 channel”时,就卡住了。这说明他并没有真正理解 channel 的同步原理,只是把代码模板记住了。所以这篇博文不打算只给一份标准答案,我要从原理讲起,把几种解法拆开揉碎,再配合我自己在实战里踩过的坑,帮你在面试时既写得出代码,也答得上追问。

2. 核心原理解析:goroutine 调度与 channel 同步之间的微妙关系

2.1 goroutine 的调度不是“时间片轮转”,而是“协作式”

想要把轮流打印这道题彻底嚼烂,必须先建立对 goroutine 调度机制的正确理解。很多人以为 goroutine 和操作系统的线程一样,是内核按时间片抢占式调度的。实际上 Go 的运行时调度器采用的是协作式调度,一个 goroutine 只有在主动让出 CPU(比如执行 channel 收发、time.Sleep、runtime.Gosched、系统调用等操作)时,才可能发生调度切换。

这意味着什么呢?如果你的代码里没有任何会让出 CPU 的操作,goroutine 就可能一直霸占着逻辑处理器不放,另一个 goroutine 根本没有机会执行。放到轮流打印的场景里,如果两个协程只是用一个 for 循环各自打印奇数或偶数,中间没有任何同步原语,那么输出结果就是乱的,甚至可能出现一个协程一口气跑完几十个数的情况。

这个协作式调度的特性,决定了轮流打印这道题必须引入同步原语。原理上也讲得通:goroutine 之间的执行顺序没有天然保证,必须靠同步机制来人为约定。channel 在这里扮演的角色,就像一个交接棒,每一轮打印都必须等另一端确认收到后,才能继续下一轮。

2.2 无缓冲 channel 的“会合”语义是轮流打印的地基

无缓冲 channel 的收发操作有一个关键特性:发送方和接收方必须同时准备就绪,数据才能完成传递,并且在传递完成的那一刻,两端的 goroutine 都恰好完成了一次同步。这个“会合”语义,是解决轮流打印问题的地基。

举个例子。假设有一个无缓冲 channel ch,A 协程执行 ch <- 1,B 协程执行 <- ch。A 的这个发送操作会一直阻塞,直到 B 的接收操作开始执行并取走数据,A 才会解除阻塞继续往下走。同理,B 的接收操作也会阻塞,直到 A 发送数据。也就是说,一次成功的传输,天然形成了一次两个协程之间的“见面握手”。这种同步特性恰好可以用来实现轮流打印:一边打印完发出信号,另一边收到信号后打印,然后再把控制权交还回去。

有缓冲 channel 就完全不是这套语义了。带缓冲的 channel 在缓冲区未满时不会被阻塞,发送方发完数据可以立刻继续执行,接收方也可以在缓冲区非空时立即取数据。如果用带缓冲的 channel 来实现严格交替,很容易出现某一方连续执行多次打印的情况,破坏顺序。所以面试的时候,如果候选人一上来就用的是有缓冲 channel,我心里就会打个问号:是不是没理解两者对控制流的影响差异。

2.3 主协程在等待中扮演的角色不能缺位

还有一个高频错误,和 channel 本身无关,但对这道题来说是致命的。Go 程序的入口 main 函数运行在 main goroutine 中,如果 main goroutine 执行完最后一行代码,整个程序就结束了,不管还有多少个 goroutine 在后台运行。子协程还没来得及输出结果,进程就已经退出。

所以,你必须在 main goroutine 里设置一个等待机制。常见做法是使用 sync.WaitGroup,在启动子协程前把计数器加 2,每个协程执行结束后调用 Done 让计数器减 1,main goroutine 调用 Wait 阻塞到所有协程都结束。如果忘了这一步,程序大概率只打印了一部分数字就静默退出了,这是面试现场最容易翻车的地方。

2.4 严谨看待代码中的顺序确定性

面试官看到你的代码后,除了关注“能不能跑”,还会关注“运行结果是否确定”。如果你用两个 goroutine 加 channel 实现交替打印,但是因为某种原因(比如在其中一端额外加了 time.Sleep),导致某一轮出现先后顺序上的混乱,那结果就是不确定的。严格交替的程序要求任何一次运行输出都恒定,不能有时候 1 先出现,有时候 2 先出现。

要做到确定性,唯一的思路是所有顺序信息都由唯一的通道(或者等价机制)传递,不能在两端各自维护独立的节奏。这个原则不仅适用于这道面试题,在实际的流水线设计里也同样重要。

3. 解法一:双无缓冲 channel 实现严格交替打印

3.1 核心思路:两条 channel 形成乒乓交接

先给出最经典、面试中也最推荐优先展示的解法。我们利用两个无缓冲 channel 分别承载“奇数协程放行”和“偶数协程放行”的信号,本质上就是来回传递两个信号量,形成乒乓效应。为了让思路更清晰,我先把代码写出来,再进行逐步解读。

package main import ( "fmt" "sync" ) func main() { chOdd := make(chan struct{}) chEven := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) // 奇数打印协程 go func() { defer wg.Done() for i := 1; i <= 100; i += 2 { <-chOdd // 等待放行信号 fmt.Println(i) chEven <- struct{}{} // 通知偶数协程 } }() // 偶数打印协程 go func() { defer wg.Done() for i := 2; i <= 100; i += 2 { <-chEven // 等待放行信号 fmt.Println(i) chOdd <- struct{}{} // 通知奇数协程 } }() // 启动:先放行奇数协程 chOdd <- struct{}{} wg.Wait() }

这段代码的关键在哪?chOdd 和 chEven 都是无缓冲 channel,任何一次发送操作都必须被配对接收后,两端的协程才能同时继续下去。启动阶段,main goroutine 向 chOdd 发送一个信号,这个信号被奇数协程接收后,奇数协程打印 1,然后向 chEven 发送信号,偶数协程接收到后打印 2,再向 chOdd 发送信号……如此循环,直到 100 打印完毕。

你可能会问,如果两个协程几乎同时执行到各自的 channel 收发语句,顺序会不会乱?不会。因为整个系统中只有一个信号在通道之间传递。奇数协程打印完 1 之后,把信号传到 chEven,在偶数协程接收 chEven 之前,奇数协程已经处于阻塞等待 chOdd 的状态。偶数协程打印完 2 之后,把信号传到 chOdd,这时奇数协程才会解除阻塞。顺序被 channel 的同步语义死死锁住了。

3.2 为什么用 struct{} 而不是 int 或 bool

这里有个细节值得展开说一下。我见过不少候选人用 make(chan int) 或 make(chan bool) 作为信号通道,功能上也能跑,但用 struct{} 更符合语义,也更“内行”。因为信号本身就是“事件已发生”的含义,不需要携带任何额外数据。struct{} 是 Go 中零内存占用的类型,用 make(chan struct{}) 创建的通道在语义上就是通知专用,维度上还能少分配内存空间。

更重要的是,阅读代码的人一看 channel 的元素类型是 struct{},立刻就知道它是个纯信号通道,不会在里面传出具体的数据或值。这种写法是 Go 社区里常见的模式,在很多事件通知、退出信号、并发控制的代码里都能见到。比如 context.Done() 返回的 channel,元素类型就是 struct{}。面试时用这个细节能体现你对 Go 并发编程风格的理解层次。

3.3 启动信号的放置位置是个考点

刚进入函数时,两个子 goroutine 都启动,但谁先执行到 <-chOdd 或 <-chEven 是不确定的。这时就需要 main goroutine 主动发送一个“启动信号”到 chOdd,把控制权交给奇数协程。这个启动信号必须放在两个 go func() 调用之后,否则子协程可能还没运行起来,main 的发送就已经开始等待接收方,只是没有实际意义,但如果放在 go func() 之前,信号发出去没人接收,程序就会死锁。

这里还有另一个细节:启动信号发送完后,main goroutine 随后的流程是执行 wg.Wait()。wg.Wait() 会阻塞 main goroutine,所以 main 不会在发送启动信号后立刻退出。两端的交替执行会一直持续,直到偶数协程打印完 100,向 chOdd 发送最后一次信号。此时奇数协程还阻塞在 <-chOdd,但它的 for 循环由于 i 已经大于 100 而不会再次进入循环体,也就是说,这个最后一次 send 之后没有人接收了,就死锁了。

等等,上述代码真的会死锁吗?这个问题我必须说清楚。偶数协程打印完 100 后,会执行 chOdd <- struct{}{},这行代码需要奇数协程接收,但奇数协程的循环已经结束,不会再执行 <-chOdd。于是偶数协程在最后一次发送处永久阻塞,程序无法正常退出。

所以上面的示例代码有一个隐患:最后一次交接会死锁。这个问题面试官几乎一定会追问,因为它是 channel 编程里典型的“最后一只手没接住球”边界问题。这里需要引入一个退出机制来避免死锁,我给出的改进方案是:不让偶数协程在最后一轮继续等待发送,而是控制循环退出条件,在最后一次发送前直接跳过发送动作。

3.4 改进思路:谁用信号,谁负责终点判断

把控制流分析清楚后,改进办法就很简单了。核心原则是:最后一个打印动作完成后,发送方不再发信号,而是直接结束自己的生命周期。

package main import ( "fmt" "sync" ) func main() { chOdd := make(chan struct{}) chEven := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for i := 1; i <= 100; i += 2 { <-chOdd fmt.Println(i) if i < 100 { chEven <- struct{}{} } } }() go func() { defer wg.Done() for i := 2; i <= 100; i += 2 { <-chEven fmt.Println(i) if i < 100 { chOdd <- struct{}{} } } }() chOdd <- struct{}{} wg.Wait() }

奇数协程打印完 99 后,i < 100 成立,发送 chEven 信号,让偶数协程打印 100。偶数协程打印完 100 后,i 已经是 100,不满足 i < 100,于是不发信号,直接退出。整个程序所有 goroutine 正常终止,没有任何阻塞残留。

这个连面试官都会拿出来追着问的边界条件,恰恰暴露了一个真相:只靠一个无缓冲 channel 实现严格交替,代码逻辑必须在“最后一轮交接”上格外小心。这个例子里我用的是双 channel,所以边界条件是“谁最后打印,谁不再发信号”。如果用单 channel 加计数器,边界条件又会变成另一种形态,后面在第 5 节我会讲到。

4. 解法二:单 channel 加计数器,思路更轻量

4.1 核心思想:让数据在通道里“折返跑”

双 channel 方案的优点是思路直观,缺点是代码略长。还有另一种常见解法:只用一个无缓冲 channel,以及一个共享计数器。两个 goroutine 在同一把锁下交替打印。这里我把代码写出来。

package main import ( "fmt" "sync" ) func main() { var mu sync.Mutex ch := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for { <-ch mu.Lock() if counter >= 100 { mu.Unlock() return } if counter%2 == 1 { fmt.Println(counter) counter++ } mu.Unlock() ch <- struct{}{} } }() go func() { defer wg.Done() for { <-ch mu.Lock() if counter >= 100 { mu.Unlock() return } if counter%2 == 0 { fmt.Println(counter) counter++ } mu.Unlock() ch <- struct{}{} } }() ch <- struct{}{} wg.Wait() }

等等,这个解法有个大问题:如果某个 goroutine 接收信号之后发现自己不是当前应该打印的那个,就会立刻把信号发回去,这样会导致忙等下的一轮轮空转,逻辑上可以跑通,但效率很差,还会让信号在两个协程之间来回空弹,直到遇到合适的取数者。

而且这道题的本意是“严格交替”,每个协程身上只背负一个打印责任(最开始的经典形态:奇数协程打印奇数,偶数协程打印偶数)。单通道加计数器方案可以让任意多个协程都监听同一个信号通道,但“不属于自己”的轮次要直接跳过,实际上破坏了严格交替的分工结构,所以这个方案不是最优的面试答案。

4.2 更优雅的单 channel 实现:把序号传过去

其实单 channel 方案有一个更漂亮的写法,不需要 Mutex,也不需要计数器锁。思路是:把当前要打印的数字作为数据本身放进 channel,接收方打印完数字后,把下一个数字放回 channel。

package main import ( "fmt" "time" ) func main() { ch := make(chan int) go func() { for i := 1; i < 100; i += 2 { num := <-ch fmt.Println("goroutine A:", num) ch <- num + 1 } }() go func() { for i := 2; i <= 100; i += 2 { num := <-ch fmt.Println("goroutine B:", num) ch <- num + 1 } }() ch <- 1 time.Sleep(time.Second) }

这个方法把 channel 当作一个“传递中的数字”的载体,而不是纯信号。A 收到 1 打印后回传 2,B 收到 2 打印后回传 3,依次类推。代码很精简,但是有两个隐患:一是末端处理,B 打印 100 后回传 101,此时 A 的接收循环在哪?要看 A 的循环退出条件,如果 for 循环条件是 i 不大于 100,那么 A 会在下一轮接收处阻塞,因为 B 回传 101 后 A 可能仍在循环里尝试接收,但没人发送了;二是 main goroutine 的退出方式,用 time.Sleep 控制程序退出时间,这在真实项目里属于不可接受的粗糙做法。

所以又需要回到 WaitGroup 上做控制。这个精简写法思路漂亮,但边界处理细节更多,对初学者不够友好。面试时我建议优先展示双 channel 方案,它把控制流拆得更清晰,也更好解释。

5. 解法三:多协程循环输出的通用模型

5.1 N 个协程按序号循环打印如何设计

面试题不会只停留在两个协程。常见的进阶追问是:改成 3 个协程,循环输出 A、B、C;甚至 N 个协程按编号 0 到 N-1 轮流打印。这类问题有一个通用模型,就是把同步机制从“两个 channel 乒乓”升级为“一个数字信号在多个协程之间传递”。

最直观的做法,是使用一个无缓冲 channel 传递当前轮到哪个协程编号的信号。每个协程在收到“该自己干活”的信号时执行打印,结束后把信号发给下一个协程。比如 N 个协程,当前信号值为 0,协程 0 收到后打印,随后发送信号 1;协程 1 收到后打印,发送信号 2;……协程 N-1 打印完发送信号 0,形成环。

代码骨架如下:

package main import ( "fmt" "sync" ) func main() { const n = 3 const total = 30 ch := make(chan int) var wg sync.WaitGroup wg.Add(n) for i := 0; i < n; i++ { go func(id int) { defer wg.Done() for count := 0; count < total/n; count++ { token := <-ch if token == id { fmt.Printf("goroutine %d: %d\n", id, count+1) ch <- (token + 1) % n } else { ch <- token // 把不属于自己的信号传递下去 } } }(i) } ch <- 0 wg.Wait() }

这个方案的问题在上面已经提过:如果接收到的信号不是自己的编号,协程会立刻把信号传给下一个,这实际上引入了一种忙等式的空转,但在总轮数不多的情况下可以接受。真正的生产级做法应该是把每个协程都放到一个 for 循环里,在 channel 上收信号,判断是不是自己,不是则继续转发,是则工作再转发。这个模型的优点是实现简单,缺点是信号在协程间传递的链路上一旦某个协程崩溃,整个协作链条就断了。这是面试时应该主动聊到的一个缺陷。

5.2 改造方案:用 channel slice 实现环形交接,避免信号空转

要避免“信号非自己就转发”的空转,可以把每个协程都分配一个专属的入站 channel,而这个 channel 链天然就构成了一个环。每个协程只需要关心自己那条入站 channel 上的信号,收到就干活,干完活把信号发给下一个协程的入站 channel。这样不存在空转,因为信号只会精确投递到该干活的那个协程手里。

package main import ( "fmt" "sync" ) func main() { const n = 4 const total = 20 chs := make([]chan struct{}, n) for i := range chs { chs[i] = make(chan struct{}) } var wg sync.WaitGroup wg.Add(n) for i := 0; i < n; i++ { go func(id int) { defer wg.Done() next := (id + 1) % n for count := 1; count <= total/n; count++ { <-chs[id] fmt.Printf("goroutine %d: %d\n", id, count) chs[next] <- struct{}{} } // 最后一个协程负责“收尾”,但这里要小心最后一棒 }(i) } chs[0] <- struct{}{} wg.Wait() }

跟前面双 channel 的问题一样,最后一棒处理也不简单。假设 total 是 20,n 是 4,每个协程打印 5 次。最后一个动作是第 4 个协程打印完第 5 次后把信号发给 chs[0],但此时第 1 个协程已经结束循环了,没人接收,又死锁。所以最后一棒必须特殊处理:在最后一个协程的循环里,最后一次打印后不发送信号,直接退出。判断方法就是打印次数是否已经达到 total/n。

这个问题很典型。在实际生产中,“消息只有被成功消费后,生产者才会生产下一条”这种模式同样会遇到“最后一棒无消费者”的问题,处理方式也类似:最后一个生产者在生产完最后一条后,必须把下游的关闭动作作为收尾的一部分,而不是继续发送。

6. 进阶追问:如果不用 channel,还能怎么办

6.1 sync.Mutex + 条件变量或者原子操作怎么实现

如果面试官问你“不用 channel 能不能实现”,这其实是在考你对其他同步原语的理解。至少有两个方向可以回答。

第一个方向是 Mutex 加共享计数器。两个 goroutine 都用一个 for 循环,循环内部加锁判断当前计数是奇数还是偶数,决定谁打印,打印完解锁。这个方案的缺点是即使不是自己的回合,也会抢锁、判断、解锁,造成大量的无意义锁竞争。它和单 channel 加计数器的思路本质上是同类,只不过同步机制从 channel 换成了锁。

第二个方向是原子操作。用一个 int32 变量作为状态位,通过 atomic.CompareAndSwapInt32 来切换状态。任何协程都循环尝试把状态从“轮到别人”改到“轮到自己”,改成功就打印,改失败就继续自旋或者让出 CPU。这个方案能避免 Mutex 的锁开销,但是代码可读性更差,也更难向面试官解释清楚。除非面试官明确问你“能不能用原子操作”,否则不建议主动把它写成主体方案。我自己的经验是,把原子操作当成对并发的延伸理解来讨论“可用但复杂”,比直接甩一堆 CAS 出来稳妥得多。

6.2 无锁交替的理念与工程取舍

“轮流打印”这类题目,说穿了就是一个有状态的生产消费循环。无锁实现的核心哲学是:用原子变量保存状态,用 CAS 让每个协程在状态切换时竞争,谁能把状态从 0 切到 1 谁就打印。但由于多个协程都在自旋抢状态位,同一时刻只允许一个协程成功,这本质上是一个“自旋锁”的变体。

我觉得这类解法更适合用来展示你对并发的理解层次,而不是作为面试现场的优选。它绕开了 channel 的便利性,却引入了只靠 CPU 睡眠环忙等的问题。在真实项目里,如果协程数量众多、空闲协程占比大,自旋实现会让 CPU 白白空转,反而降低吞吐。选择什么同步方式,应该考量的因素包括:竞争激烈程度、锁临界区大小、是否允许阻塞挂起、代码维护成本等。这个话题可以作为面试收尾阶段和面试官对谈的切入点,给他留个“这个人会做工程权衡”的好印象。

6.3 从“轮流”到“扇出”:实际开发中更常碰到的并发模型

一条链路里多个 worker 交替做事,这种模式在中间件、流式计算里并不少见。但是工程里的“轮流”往往不会像面试题那样只有一个回合交接,更多是扇出与汇聚的组合:一个生产者把任务分发到多个 worker,每个 worker 处理完再汇聚结果。这种模型的核心问题不再是谁先谁后,而是如何保证任务不重复、不遗漏、结果不乱序。

所以,如果面试官在你答完轮流打印之后,顺势追问一句“那如果把 100 个数换成大量任务,分给多个 worker 并发处理,你会怎么设计”,别慌。这本质上就是 worker pool 的问题。你可以往 errgroup、channel + WaitGroup、动态负载均衡这些方向说。面试官的意图很可能不是要你真的写出一个高并发框架,而是看你能不能从“同步交替”的思维切换到“并发流水线”的思维。

7. 面试现场代码质量考察点

7.1 代码能跑不是终点,抗死锁测试才是分水岭

我招聘时看候选人代码,首先是本地跑一跑看输出对不对,输出对了就在心里加一分。但真正拉开档次的是第二轮:我会故意让他把数字上限从 100 改成 101、把协程数改成奇数,看看他会不会踩到死锁。这不是刁难,而是手术刀一样精准地考察边界条件意识。

如果你写的是前面那个没做最后一棒处理的代码,把 100 改成 101 之后,打印 101 的协程会试图发信号给另一个已经结束的协程,直接死锁。正确做法是无论上限是奇数还是偶数,都必须保证“最后一个打印者不再发送任何信号”。所以在写循环的时候,发送信号前必须检查当前打印的值是否已经是最后一个,或者检查循环即将退出。这个习惯放到工程里,就是管道关闭时会不会导致下游 panic 的问题。

7.2 死锁检测工具与运行时的报错信息解读

Go 的运行时内置了死锁检测机制。当所有 goroutine 都处于阻塞状态时,程序会 panic 并打印出完整的 goroutine 堆栈。这个堆栈信息对排查死锁极有价值,每一行都显示了 goroutine 阻塞在哪个文件的哪一行、在等哪个 channel。比如goroutine 1 [chan send]: main.main(),就表示 main goroutine 正卡在某行的发送操作上。

调试这类问题我自己有个小习惯:先看堆栈里的chan send和chan receive关键字,哪一端是 send 阻塞,就说明它在等一个接收者。配合打印的 goroutine 数量,基本能快速定位是“没人接最后一棒”还是“启动信号没人发”的问题。这个排查流程,远比盯着代码干瞪眼高效得多。

7.3 如何通过 channel 关闭机制优雅收尾

上面所有的方案里,我用 WaitGroup 来等待所有协程结束,但 WaitGroup 加 channel 的组合还有个更稳的收尾姿势:协调者关闭一个专用的“退出信号 channel”,所有协程在 select 中监听这个 channel 和自己的工作 channel,一旦退出信号就返还。这种方式是生产级代码的标准做法,尤其是协程数量动态变化、需要按需停止的时候。

在“轮流打印”这种固定轮次场景里,退出信号 channel 显得有些多余。但如果面试官把题变形为“两个协程无限轮流打印,直到 main 发出停止指令”,那就需要用 select 监听退出信号了。代码示范如下,这个版本也是我实际回复别人这类问题时的默认模板。

package main import ( "fmt" "sync" ) func main() { stop := make(chan struct{}) chOdd := make(chan struct{}) chEven := make(chan struct{}) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for { select { case <-stop: return case <-chOdd: fmt.Println("odd") chEven <- struct{}{} } } }() go func() { defer wg.Done() for { select { case <-stop: return case <-chEven: fmt.Println("even") chOdd <- struct{}{} } } }() chOdd <- struct{}{} for i := 0; i < 50; i++ { // 这里模拟业务运行 } close(stop) wg.Wait() }

close(stop) 是个广播操作,所有 select 了这个 channel 的协程都会立刻收到零值,并进入 return 分支。这个模式我给我的团队内部讲过很多次,真正干活的时候非常常用,建议你把它记在脑子里。

8. 高频坑位与排查技巧实录

8.1 坑位一:程序什么都没输出就退出了

这种问题几乎都是 main goroutine 没等待子协程导致的。你启动了 goroutine,但 main 函数执行完就 return 了,Go 运行时不会等子协程,直接整个进程终止。解决办法就一句话:用 sync.WaitGroup 或者一个同步 channel 让 main 阻塞到子协程完成。

另外还有一种类似情况:子协程在 channel 操作上阻塞了,main 也在 Wait 里阻塞,结果所有 goroutine 都阻塞,触发死锁 panic。这类错误会输出很明显的 goroutine dump,按我说的方法看堆栈就能定位到具体行。

8.2 坑位二:数字顺序不稳定,4123 或者乱序

如果输出顺序不确定,说明你的 channel 同步没起到链式传导作用。比如你用了两个独立的 channel,分别让奇数协程和偶数协程各发各的,而不是两个协程之间的交接握手,那么结果必然乱序。

再比如你用了带缓冲且缓冲较大的 channel,发送数据没有被立即接收确认,发送方就跑去做下一轮计算了,这也会破坏顺序。轮流打印这道题,本质上只能依赖无缓冲 channel 的会合语义,任何缓冲都会引入不确定时序。

8.3 坑位三:非常庞大的数据量下程序越来越慢

这道题如果把上限从 100 改成 100000,而且你用的是“信号非自己就转发”的 N 协程环模型,那么每个数字都要在环上绕一圈甚至几圈才能落到正确的协程手里,这个传递成本会线性放大。真实系统的应对思路是减少空转的转发次数,或者改成 worker 各自领取任务号段的方式,而不是所有 worker 竞争同一个信号源。

还有一种慢,是 channel 发送大量数据时发生的缓存失效问题。如果 channel 元素类型是很大的结构体,频繁拷贝也会拖慢速度。这个点在轮流打印里体现不出来,但在分布式任务分发里非常明显,顺便一提。

8.4 排查工具与我的个人调试习惯

本地调试并发问题时,我常用的几个手段:

go run -race main.go

-race 参数会在运行时检测数据竞争,一旦发现会在 stderr 里打印详细报告,包括哪个 goroutine 在哪个文件行访问了同一变量。这对轮流打印类问题尤其有效,因为你如果有共享计数器或共享状态变量,而没加锁或没用原子操作,-race 会立刻提示。

跑完 -race 没问题之后,我再把总次数改小、把代码里临时加的 time.Sleep 全删掉,因为它会掩盖真实的调度时序。一个 tip:写完这种交替打印程序后,连续跑 100 次,如果每次输出都完全一致,说明顺序是确定性的;只要有一次不同,就要回头找同步缺失。

9. 从“轮流打印”看 Go 并发题的出题逻辑

这道题我讲过很多次,也出过很多次。它有一个很大的优点:考察面广,代码量短。两三分钟里,候选人能不能写出正确同步、不 panic、不 deadlock 的代码,非常直观地暴露了他对 goroutine 调度和 channel 语义的理解颗粒度。

我见过很多简历里写着“精通 Go 并发”的候选人,在这道题上翻车;也见过平时写业务代码不怎么碰并发的小伙,靠扎实的基本功把这道题答得滴水不漏。这说明一个很现实的问题:并发编程的知识不能靠背题,得真的理解运行时调度、同步原语和边界条件。

所以文章收尾,我只分享一句个人的切身体会:以这道题为起点,把 Go 并发相关的关键字都铺开复习一遍——go 语句、channel、select、sync.WaitGroup、Mutex、atomic、context、-race 检测——这些确实不是背一组题能拿下的,而是要在真实项目里反复打磨的看家本领。碰到过一个并发 bug,排查半个月,之后我对 channel 的会合语义就再也不会忘记了。希望你也能通过这种小而美的面试题,把并发的底子打扎实。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 14:29:39

ArkWeb开发手记02|权限、网络白名单与页面缓存控制

搞定了 ArkWeb 基础页面搭建&#xff0c;把 Web 组件生命周期做了绑定&#xff0c;解决最头疼的内存泄漏问题。但很多同学照着代码跑通之后&#xff0c;马上遇到新麻烦&#xff1a;H5 页面调用摄像头直接拒绝、上传图片无响应&#xff1b;更换 H5 资源之后 APP 里面还是旧页面&…

作者头像 李华
网站建设 2026/10/10 14:29:22

[鼎捷 ERP] BOM批量导入、工单和采集序列号相关问题排错合集(二)

目录 E10-P-MFG-013 BOM导入主件品号或工厂错误E10-P-MFG-014 BOM变更与导入报错处理E10-P-MFG-015 工单ICD信息字段无法选择E10-P-MFG-016 库存改制入库单审核提示未采集序列号E10-P-MFG-017 生产入库单提示须存在且未审核E10-P-MFG-018 生产入库单序列号采集未完成E10-P-MFG-…

作者头像 李华
网站建设 2026/10/10 14:28:29

租赁门店押金自动原路退回系统设计与实现:以汉服礼服为例

1. 汉服礼服租赁&#xff0c;为什么押金原路退回是个“硬需求”前阵子帮一个做汉服体验馆的朋友梳理订单流程&#xff0c;聊到押金这块&#xff0c;他给我看后台的退款记录&#xff0c;基本上每周都有几笔退款纠纷。有的是顾客说“押金怎么还没到账”&#xff0c;有的是“我当时…

作者头像 李华
网站建设 2026/10/10 14:28:20

英语六级翻译纲领

目录 : &#x1f308; “主” 和 “顺” 原则 &#x1f308; 局部倒译法 &#x1f308; With/As 来相助 &#x1f308; 句子衔接 第一讲 “主” 和 “顺” 原则 一. 主语的选择决定了译文的语态 ( 主动还是被动 ) 比如 : 人们经常夸方世玉很帅 主动语态 : People often com…

作者头像 李华
网站建设 2026/10/10 14:25:23

数据结构(链表)

链表 链表是节点这种存储结构加上对节点的一套操作方法 目录 节点操作方法 add方法remove方法insert方法contains方法测试 节点 节点的结构包括存储的数据类型以及指向下一个节点的指针。 public class MyLinkedList {//节点属性&#xff1a;节点 指向下一个的指针publi…

作者头像 李华
网站建设 2026/10/10 14:24:31

滑动窗口算法从原理到模板:固定窗口、可变窗口与单调队列优化

如果说算法题里有什么是“背过模板还得跪”的&#xff0c;滑动窗口绝对算一个。很多朋友刷题的时候都遇到过这种情况&#xff1a;明明把模板抄下来了&#xff0c;也知道left和right两个指针怎么挪&#xff0c;但题目稍微一变就晕——比如窗口什么时候收缩&#xff0c;收缩到什么…

作者头像 李华