目录

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. 奇数协程先执行,打印 1 后向通道发送信号,然后阻塞等待接收;
  2. 偶数协程接收信号后被唤醒,打印 2 后向通道发送信号,再阻塞等待接收;
  3. 奇数协程接收信号后唤醒,打印 3,以此循环,直到奇数打印到 99 后关闭通道;
  4. 偶数协程打印 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的读取、修改操作是原子的”,不会出现两个协程同时修改的情况。

执行流程:

  1. 初始化 isOddTurn为true,让奇数协程先执行;
  2. 奇数协程加锁后判断:如果是自己的回合且 count 是奇数,就打印并自增 count,然后把回合交给偶数;
  3. 偶数协程加锁后判断:如果是自己的回合且 count 是偶数,就打印并自增 count,然后把回合交给奇数;
  4. 两个协程循环“加锁→判断→执行/不执行→解锁”,直到 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/