蛮荆

goroutine 交替打印奇偶数

2023-04-20

题目

使用两个 goroutine 交替并且顺序地打印数字 1…10, 要求其中一个 goroutine 打印奇数,另一个 goroutine 打印偶数。

交替打印奇偶数

本文尝试通过 Go 标准库提供的 API 来给出几种解决方案,虽然这看上去有点儿 “八股文” 的味道,但是对于提高 goroutine + channel 编码技巧还是非常有帮助的。

channel 方法

我们可以直接使用 goroutine + channel 的方式,写出直观的代码:

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)

	// 打印奇数
	go func() {
		for n := range ch1 {
			fmt.Printf("channel-1 print %d\n", n)
			// 奇数 + 1 等于下一个要打印的偶数
			// 发送到 channel 2
			ch2 <- n + 1

			if n == 9 {
				break
			}
		}
		close(ch1)
	}()

	// 打印偶数
	go func() {
		for n := range ch2 {
			fmt.Printf("channel-2 print %d\n", n)
			if n == 10 {
				break
			}

			// 偶数 + 1 等于下一个要打印的奇数
			// 发送到 channel 1
			ch1 <- n + 1
		}
		close(ch2)
	}()

	ch1 <- 1

	// 等待 goroutine 执行完成
	time.Sleep(time.Second)
}

运行代码打印如下:

channel-1 print 1
channel-2 print 2
channel-1 print 3
channel-2 print 4
channel-1 print 5
channel-2 print 6
channel-1 print 7
channel-2 print 8
channel-1 print 9
channel-2 print 10

channel 优化版本

上面的代码虽然可以正确运行,但是存在两个小问题:

  1. 两个 goroutine 代码块堆叠在一起,有些冗余和重复,可以重构为一个函数
  2. 使用 time.Sleep 方法等待 goroutine 执行完成,不够优雅且存在很多潜在问题 (例如真实场景下我们得知 goroutine 的具体运行时间),可以使用 sync.WaitGroup 同步原语进行重构

优化过的代码如下:

package main

import (
	"fmt"
	"sync"
)

// rev  接收 channel
// send 发送 channel
// no   channel 编号 (提高打印消息的可读性)
// last channel 接收的最后一个数字, 超过该数字时结束打印
func printChar(rev <-chan int, send chan<- int, no, last int) {
	for i := range rev {
		if i > last {
			break
		}
		fmt.Printf("channel-%d print %d\n", no, i)
		send <- i + 1
	}
	close(send)
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)

	var wg sync.WaitGroup
	wg.Add(2)

	// 打印奇数
	go func() {
		defer wg.Done()
		printChar(ch1, ch2, 1, 9)
	}()

	// 打印偶数
	go func() {
		defer wg.Done()
		printChar(ch2, ch1, 2, 10)
	}()

	ch1 <- 1
	wg.Wait()
}

通过将打印过程封装为一个函数,然后交替传递 channel 参数,再使用 sync.WaitGroup 同步原语控制打印何时结束,避免了 time.Sleep 方法这种有明显缺陷的方案。

运行代码打印如下:

channel-1 print 1
channel-2 print 2
channel-1 print 3
channel-2 print 4
channel-1 print 5
channel-2 print 6
channel-1 print 7
channel-2 print 8
channel-1 print 9
channel-2 print 10

sync.Mutex 互斥锁

除了最基础的 channel 之外, 还可以使用标准库提供的同步原语来解决这个问题。

  • 当打印奇数的 goroutine 获取到锁时,打印奇数并切换下一次打印的 goroutine 编号,最后通过解锁操作来唤醒偶数 goroutine
  • 当打印偶数的 goroutine 获取到锁时,打印偶数并切换下一次打印的 goroutine 编号,最后通过解锁操作来唤醒奇数 goroutine
package main

import (
	"fmt"
	"sync"
)

// cond sync.Mutex 互斥锁
// cur  指定当前执行的 goroutine 编号
// no   channel 编号 (提高打印消息的可读性)
// last channel 接收的最后一个数字, 超过该数字时结束打印
func printChar(mu *sync.Mutex, cur *int, no, last int) {
	for i := no; i <= last; {
		mu.Lock()
		if *cur != no {
			// 如果当前执行 goroutine 编号不是自身
			mu.Unlock()
			continue
		}

		fmt.Printf("channel-%d print %d\n", no, i)

		// 指定当前执行的 goroutine 编号为另外一个 goroutine
		*cur = 3 - *cur
		i = i + 2
		// 唤醒另外一个 goroutine
		mu.Unlock()
	}
}

func main() {
	mu := &sync.Mutex{}

	// 当前执行的 goroutine 编号
	cur := 1

	var wg sync.WaitGroup
	wg.Add(2)

	// 打印奇数
	go func() {
		defer wg.Done()
		printChar(mu, &cur, 1, 9)
	}()

	// 打印偶数
	go func() {
		defer wg.Done()
		printChar(mu, &cur, 2, 10)
	}()

	wg.Wait()
}

sync.Cond 条件变量

在互斥锁的代码基础上稍加改造,即可适配 sync.Cond 条件变量同步原语代码。

package main

import (
	"fmt"
	"sync"
)

// cond sync.Cond 条件变量
// cur  指定当前执行的 goroutine 编号
// no   channel 编号 (提高打印消息的可读性)
// last channel 接收的最后一个数字, 超过该数字时结束打印
func printChar(cond *sync.Cond, cur *int, no, last int) {
	for i := no; i <= last; i = i + 2 {
		cond.L.Lock()
		for *cur != no {
			// 如果当前执行 goroutine 编号不是自身
			// 等待被唤醒
			cond.Wait()
		}

		fmt.Printf("channel-%d print %d\n", no, i)

		// 指定当前执行的 goroutine 编号为另外一个 goroutine
		*cur = 3 - *cur
		// 唤醒另外一个 goroutine
		cond.Signal()
		cond.L.Unlock()
	}
}

func main() {
	cond := sync.NewCond(&sync.Mutex{})

	// 当前执行的 goroutine 编号
	cur := 1

	var wg sync.WaitGroup
	wg.Add(2)

	// 打印奇数
	go func() {
		defer wg.Done()
		printChar(cond, &cur, 1, 9)
	}()

	// 打印偶数
	go func() {
		defer wg.Done()
		printChar(cond, &cur, 2, 10)
	}()

	wg.Wait()
}

小结

本文通过一个笔试题,给出了 Go 中 goroutine + channel 组合使用时常见的同步解决方案,希望能帮助读者在扩展 goroutine + channel 的基础应用同时,深入理解各同步原语之间的差异及其使用场景。

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。