go 实现两个协程交替打印1-100的奇偶数
之前在面试的时候被问到一个面试题:用两个协程交替打印 1-100 的奇偶数
觉得这挺有意思的,这也是挺考验面试者对 go 协程 的理解以及开发思路
今天就记录分享下我对这道题的实现方法,如果你也遇到过同样的问题,希望本章对你有所帮助。
核心问题是什么?
在 Go 里,协程(goroutine)是轻量级线程,由 Go runtime 调度,默认情况下调度顺序是不确定的。
两个协程同时操作“打印”这个行为时,会出现两个核心问题:
- 执行顺序混乱:CPU 可能先调度奇数协程打印 1,再调度偶数协程打印 2,也可能先调度偶数协程(但此时还没奇数,会打印 0?),完全不可控;
- 资源竞争风险:如果两个协程共享一个计数器变量(比如当前要打印的数字),可能出现两个协程同时读取到同一个值,导致重复打印或跳过数字。
所以,实现交替打印的核心就是“同步控制”——让两个协程按照“奇数→偶数→奇数→偶数”的顺序依次执行,同时避免计数器的资源竞争。常
用的同步手段有四种:通道(channel)、互斥锁(sync.Mutex)、条件变量(sync.Cond)、原子操作(sync/atomic)
方案一:通道
通道是 Go 并发的核心特性,天生支持“同步”和“通信”。
利用无缓冲通道的“阻塞特性”,可以轻松控制两个协程的执行顺序。
原理很简单:用一个通道作为“开关”,一个协程执行完后通过通道通知另一个协程开始,形成“交替触发”的逻辑。
实现代码如下:
package main
import "fmt"
func main() {
// 定义无缓冲通道,用于协程间同步
ch := make(chan struct{})
// 奇数协程:打印1,3,5...99
go func() {
for i := 1; i <= 100; i += 2 {
// 先打印奇数
fmt.Printf("奇数协程:%d\n", i)
// 发送信号,通知偶数协程执行(无缓冲通道会阻塞,直到对方接收)
ch <- struct{}{}
// 等待偶数协程执行完的信号(再次阻塞,等待对方发送)
<-ch
}
// 奇数打印完毕后,关闭通道,让偶数协程退出循环
close(ch)
}()
// 偶数协程:打印2,4,6...100
go func() {
for i := 2; i <= 100; i += 2 {
// 等待奇数协程的信号(阻塞,直到奇数协程发送)
_, ok := <-ch
if !ok { // 通道关闭则退出
return
}
// 打印偶数
fmt.Printf("偶数协程:%d\n", i)
// 发送信号,通知奇数协程执行
ch <- struct{}{}
}
}()
// 主协程等待子协程执行完毕(避免主协程提前退出)
var wg sync.WaitGroup
wg.Add(2)
// 上面两个协程里分别加 wg.Done(),这里简化用for循环阻塞(实际开发用WaitGroup更规范)
for i := 1; i <= 100; i++ {
if i%100 == 0 {
break
}
}
}无缓冲通道的关键特性是“发送方和接收方必须同时准备好,否则会阻塞”。
这里的执行流程是:
- 奇数协程先执行,打印 1 后向通道发送信号,然后阻塞等待接收;
- 偶数协程接收信号后被唤醒,打印 2 后向通道发送信号,再阻塞等待接收;
- 奇数协程接收信号后唤醒,打印 3,以此循环,直到奇数打印到 99 后关闭通道;
- 偶数协程打印 100 后发送信号,下次接收时发现通道关闭,退出循环。
优缺点:
优点是代码简洁,不用手动加锁,利用通道天生的同步特性;
缺点是通道的“一对一”通知机制比较固定,如果后续要扩展多个协程交替打印,修改起来比较麻烦。
方案二:互斥锁+标志位
互斥锁(sync.Mutex)的作用是“保证同一时间只有一个协程执行临界区代码”。
我们可以用一个标志位(比如 isOddTurn)控制当前该哪个协程执行,配合互斥锁实现交替逻辑。
这个方案能帮你更直观理解“同步控制”的本质。
示例代码:
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex // 互斥锁,保护临界区
isOddTurn bool // 标志位:true表示该奇数协程执行,false表示该偶数协程
wg sync.WaitGroup // 等待组,等待两个协程执行完毕
count int // 计数器,记录当前要打印的数字
)
wg.Add(2)
// 奇数协程
go func() {
defer wg.Done()
for count <= 100 {
mu.Lock() // 加锁,进入临界区
// 只有当轮到奇数且count是奇数时才打印
if isOddTurn && count%2 == 1 {
fmt.Printf("奇数协程:%d\n", count)
count++ // 计数器自增
isOddTurn = false // 切换到偶数协程的回合
}
mu.Unlock() // 解锁,离开临界区
}
}()
// 偶数协程
go func() {
defer wg.Done()
for count <= 100 {
mu.Lock()
// 只有当轮到偶数且count是偶数时才打印
if !isOddTurn && count%2 == 0 {
fmt.Printf("偶数协程:%d\n", count)
count++
isOddTurn = true // 切换到奇数协程的回合
}
mu.Unlock()
}
}()
// 初始化标志位:先让奇数协程执行
isOddTurn = true
// 等待两个协程执行完毕
wg.Wait()
}互斥锁保证了“标志位 isOddTurn 和计数器count的读取、修改操作是原子的”,不会出现两个协程同时修改的情况。
执行流程:
- 初始化 isOddTurn为true,让奇数协程先执行;
- 奇数协程加锁后判断:如果是自己的回合且 count 是奇数,就打印并自增 count,然后把回合交给偶数;
- 偶数协程加锁后判断:如果是自己的回合且 count 是偶数,就打印并自增 count,然后把回合交给奇数;
- 两个协程循环“加锁→判断→执行/不执行→解锁”,直到 count 超过 100。
优缺点:
优点是逻辑直观,容易理解“锁+标志位”的同步模式,扩展多个协程时只需增加标志位;
缺点是存在“忙等”问题——协程会反复加锁判断,即使不是自己的回合也会占用CPU资源。
方案三:条件变量
方案二的“忙等”问题在高并发场景下会浪费 CPU 资源,而条件变量(sync.Cond)可以解决这个问题:协程在不是自己的回合时,会进入“等待状态”,释放 CPU,直到被通知后再唤醒执行。
这是生产环境中常用的同步方案。
Cond 实现:
package main
import (
"fmt"
"sync"
)
func main() {
var (
mu sync.Mutex // 条件变量必须配合互斥锁使用
cond = sync.NewCond(&mu) // 初始化条件变量
isOddTurn bool // 标志位
wg sync.WaitGroup
count int
)
wg.Add(2)
// 奇数协程
go func() {
defer wg.Done()
for count <= 100 {
mu.Lock()
// 循环等待:不是自己的回合或count不是奇数时,进入等待
for !isOddTurn || count%2 != 1 {
cond.Wait() // 释放锁并等待,被唤醒后重新加锁
}
// 满足条件,打印并更新状态
fmt.Printf("奇数协程:%d\n", count)
count++
isOddTurn = false
cond.Signal() // 唤醒一个等待的协程(这里唤醒偶数协程)
mu.Unlock()
}
}()
// 偶数协程
go func() {
defer wg.Done()
for count <= 100 {
mu.Lock()
// 循环等待:不是自己的回合或count不是偶数时,进入等待
for isOddTurn || count%2 != 0 {
cond.Wait()
}
// 满足条件,打印并更新状态
fmt.Printf("偶数协程:%d\n", count)
count++
isOddTurn = true
cond.Signal() // 唤醒奇数协程
mu.Unlock()
}
}()
// 初始化:让奇数协程先执行
isOddTurn = true
cond.Signal() // 唤醒第一个协程(奇数协程)
wg.Wait()
}条件变量的核心是“等待-通知”机制,关键 API 有三个:
cond.Wait():释放当前持有的锁,让协程进入等待队列,被唤醒后会重新获取锁;cond.Signal():唤醒等待队列中的一个协程;cond.Broadcast():唤醒等待队列中的所有协程(本例用不到)。
执行流程:初始化后唤醒奇数协程,奇数协程打印后唤醒偶数协程,偶数协程打印后再唤醒奇数协程,循环直到结束。
相比互斥锁方案,协程等待时不会占用 CPU,性能更优。
方法四:原子操作
如果只是简单的计数器同步,还可以用原子操作(sync/atomic)——通过 CPU 指令保证操作的原子性,不需要加锁,性能是四种方案中最好的。
适合场景:同步逻辑简单,仅需控制计数器或标志位。
实战代码:atomic 实现
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var (
count int32 // 原子计数器(必须用int32/int64等原子类型)
wg sync.WaitGroup
)
wg.Add(2)
// 奇数协程
go func() {
defer wg.Done()
for {
// 原子读取count的值
current := atomic.LoadInt32(&count)
if current > 100 {
return
}
// 是奇数且当前值等于计数器(避免被其他协程修改)
if current%2 == 1 {
fmt.Printf("奇数协程:%d\n", current)
// 原子自增count(CAS操作更安全,这里简化用Add)
atomic.AddInt32(&count, 1)
}
}
}()
// 偶数协程
go func() {
defer wg.Done()
for {
current := atomic.LoadInt32(&count)
if current > 100 {
return
}
if current%2 == 0 {
fmt.Printf("偶数协程:%d\n", current)
atomic.AddInt32(&count, 1)
}
}
}()
// 初始化count为1(奇数先开始)
atomic.StoreInt32(&count, 1)
wg.Wait()
}原子操作通过 CPU 的原子指令实现,不需要加锁,直接对内存进行操作,保证“读取-修改-写入”的原子性。
这里用了三个核心 API:
atomic.LoadInt32:原子读取 int32 值,避免读取时被其他协程修改;atomic.AddInt32:原子自增 int32 值;atomic.StoreInt32:原子存储 int32 值。
注意:简化版用 Add 可能存在极端情况(比如两个协程同时读取到同一个值),生产环境建议用atomic.CompareAndSwapInt32( CAS 操作)保证安全性。
方案对比
对比如下:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 通道 | 代码简洁,天生同步,无资源竞争 | 扩展多个协程麻烦 | 新手入门,两个协程简单交替 |
| 互斥锁+标志位 | 逻辑直观,易扩展 | 存在忙等,浪费CPU | 学习同步原理,低并发场景 |
| 条件变量 | 无忙等,性能优,易扩展 | API稍复杂,需配合锁使用 | 生产环境,高并发场景 |
| 原子操作 | 性能最优,无锁开销 | 仅适合简单同步(计数器/标志位) | 简单计数器同步,高性能需求 |
常见问题
Q1. 协程执行顺序混乱,出现重复或跳过数字
现象:运行代码后出现“奇数协程:1→偶数协程:3”(跳过2),或“奇数协程:5→奇数协程:7”(两个奇数连续)。
原因:没有做好同步控制,两个协程同时读取或修改了计数器,导致资源竞争。
比如奇数协程读取到 count=5 后还没打印,偶数协程就把 count 改成了 6,导致奇数协程打印 5 后自增到 7,跳过 6。
解决方案:使用上述四种同步方案中的任意一种,保证计数器的“读取-修改-打印”操作是原子的。
新手优先用通道。
Q2. 主协程提前退出,子协程没执行完
现象:运行代码后只打印了几个数字就结束了,比如只打印到 10 就退出。
原因:Go 的主协程退出后,所有子协程都会被强制终止。
如果没有同步机制让主协程等待子协程,子协程还没执行完就被终止了。
解决方案:用 sync.WaitGroup 等待子协程执行完毕,这是标准做法。
避免用 time.Sleep(睡眠时间不好控制,容易出现等待不足或过长)。
Q3. 通道死锁,程序卡住不动
现象:运行通道方案的代码后,程序卡住,没有任何输出,终端提示“fatal error: all goroutines are asleep - deadlock!”。
原因:通道的发送和接收没有配对。比如奇数协程发送信号后,偶数协程没有接收;或者通道关闭后,发送方还在发送数据。
解决方案:确保发送和接收的次数一致,关闭通道后不再发送数据。通道方案中,奇数协程打印到 99 后关闭通道,偶数协程打印 100 后接收时发现通道关闭,正常退出,避免死锁。
Q4. 条件变量 Wait 后没有被唤醒,协程一直阻塞
现象:条件变量方案运行后,只打印了 1 就卡住,偶数协程一直没被唤醒。
原因:忘记调用 cond.Signal() 唤醒等待的协程,或者唤醒的时机不对。
比如奇数协程打印后没有调用 cond.Signal(),偶数协程一直卡在 cond.Wait()。
解决方案:在修改完标志位后,必须调用 cond.Signal() 唤醒对应的协程。
同时,Wait() 要放在循环中判断条件(避免“虚假唤醒”——操作系统可能会无故唤醒等待的协程)。
总结
其实两个协程交替打印奇偶数的问题,本质是“并发环境下的顺序控制和资源竞争问题”——这和生产环境中“多个协程交替处理订单”“多线程读写数据库”的核心逻辑是一样的。
最后再给一个建议:不要一开始就追求“最优雅”的方案,先从通道和互斥锁方案入手,理解“同步控制”的核心是“让多个协程按照预期顺序执行,避免资源竞争”;熟练后再学习条件变量和原子操作,根据场景选择合适的工具。
如果大家在并发同步中遇到其他问题,比如多个协程交替打印 ABC、协程池的实现等,欢迎在评论区交流~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-goroutine-chan-case-1/
备用原文链接: https://blog.fiveyoboy.com/articles/go-goroutine-chan-case-1/