抛砖引玉
假设,在我们的程序中启用了4个Goroutine,分别是G1、G2、G3和G4。其中,G2、G3和G4是由G1中的代码启用并被用于执行某些特定任务的。G1在启用这3个Goroutine之后要等待这些特定任务的完成。在这种情况下,我们有两个方案。
方案1. 通道解决
例如,我们在启用G2、G3和G4之前声明这样一个通道:
sign := make(chan byte, 3)
然后,在G2、G3和G4执行的任务完成之后立即向该通道发送代表了某个任务已被执行完成的元素值:
go func() { // G2
// 省略若干条语句
sign }()
go func() { // G3
// 省略若干条语句
sign }()
go func() { // G4
// 省略若干条语句
sign }()
最后,在启用这几个Goroutine之后,我们还要在G1执行的函数中添加类似这样的代码以等待相关的任务完成信号:
for i := 0; i < 3; i++ {
fmt.Printf("G%d is ended.\n", <-sign)
}
这样的方法固然是有效的。上面的这条for语句会等到G2、G3和G4都被运行结束之后才会被执行结束,继而其后面的语句才会得以执行。sign通道起到了协调这4个Goroutine的运行的作用。
不过,对于这样一个简单的协调工作来说,使用通道是否过重了?或者说,通道sign是否被大材小用了?通道的实现中包含了很多专为并发安全的数据而建立的数据结构和算法。原则上说,我们不应该把通道当做互斥锁或信号灯来说用。在这里使用它并没有体现出它的优势,反而会在代码易读性和程序性能方面打一些折扣。
方案2. WaitGroup
该需求的第二个方案就是使用 sync.WaitGroup 等待组进行多个任务的同步。
- sync.WaitGroup类型的值也是开箱即用的。该类型有三个指针方法,即
- Add
- 等待组的计数器 +1,虽然Add方法接受一个int类型的值,并且我们也可以通过该方法减少计数值,但是我们一定不要让计数值变为负数。因为这样会立即引发一个运行恐慌。
- Done
- 完成一个Goroutine,等待组的计数器 -1
- Wait。
- 阻塞等待所有的Goroutine完成,当等待组计数器不等于 0 时阻塞直到变 0完成。
- Add
- 简单使用就是在创建一个任务的时候wg.Add(1), 任务完成的时候使用wg.Done()来将任务减一。使用wg.Wait()来阻塞等待所有任务完成。
- 值得说明的是,在sync.WaitGroup类型及其方法中也用到了互斥锁、原子操作和信号灯机制。这使得我们总是可以在任意个Goroutine中并发的调用同一个sync.WaitGroup类型值的那些方法。也就是说,它们都是并发安全的。
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
// 方法传递的不是地址,那么就会做一个拷贝,要不调用的wg根本就不是同一个对象,所以需要传递地址
go func(a int, wg1 *sync.WaitGroup) {
defer wg1.Done()
fmt.Println("run-start:",a)
time.Sleep(3 * time.Second)
fmt.Println("run-ok:",a)
}(i,&wg)
}
// 阻塞,直到所有wg.Done
wg.Wait()
fmt.Println("run-all-ok!")
}
run-start: 1
run-start: 0
run-start: 3
run-start: 4
run-start: 2
run-ok: 2
run-ok: 3
run-ok: 4
run-ok: 1
run-ok: 0
run-all-ok!