go语言中的并发模式:sync.WaitGroup

69 阅读4分钟

这是我参与2022首次更文挑战的第27天,活动详情查看:2022首次更文挑战

本文为译文,原文链接:www.calhoun.io/concurrency…

在处理并发代码时,大多数人注意到的第一件事是,他们的其余代码不会等待并发代码完成后才继续。例如,假设我们想在关闭之前向几个服务发送一条消息,我们从下面的代码开始:

package main

import (
	"fmt"
	"math/rand"
	"time"
)

func notify(services ...string) {
	for _, service := range services {
		go func(s string) {
			fmt.Printf("Starting to notifing %s...\n", s)
			time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
			fmt.Printf("Finished notifying %s...\n", s)
		}(service)
	}
	fmt.Println("All services notified!")
}

func main() {
	notify("Service-1", "Service-2", "Service-3")
	// Running this outputs "All services notified!" but we
	// won't see any of the services outputting their finished messages!
}

如果我们在适当的位置使用一些sleep来运行这段代码以模拟延迟,我们将看到一个“All services notified!”消息输出,但是没有一个“Finished notifications…”消息会被打印出来,这表明我们的应用程序在关闭之前没有等待这些消息发送。这将是一个问题!

解决这个问题的一种方法是使用sync.WaitGroup。这是由标准库提供的一种类型,它使我们可以很容易地说,“我有N个需要并发运行的任务,等待它们完成,然后继续我的代码。”

为了使用sync.WaitGroup,我们大概会做四件事:

  1. 声明sync.WaitGroup
  2. 添加到WaitGroup队列
  3. 告诉我们的代码在继续之前等待WaitGroup队列达到0
  4. 在每个goroutine中,将队列中的项目标记为done

下面的代码显示了这一点,我们将在您阅读后讨论代码。

func notify(services ...string) {
	var wg sync.WaitGroup

	for _, service := range services {
		wg.Add(1)
		go func(s string) {
			fmt.Printf("Starting to notifing %s...\n", s)
			time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
			fmt.Printf("Finished notifying %s...\n", s)
			wg.Done()
		}(service)
	}

	wg.Wait()
	fmt.Println("All services notified!")
}

在代码的最开始,我们通过声明sync.WaitGroup来实现(1)。我们在调用任何goroutines之前执行这个操作,以便它对每个goroutines可用。

接下来,我们需要将项目添加到WaitGroup队列中。我们通过调用Add(n)来实现,其中n是我们想要添加到队列中的条目数。这意味着,如果我们知道要等待5个任务,我们可以调用Add(5)一次,或者在本例中,我们选择在每次循环迭代时调用Add(1)。这两种方法都很好,上面的代码可以很容易地被替换成如下内容:

wg.Add(len(services))
for _, service := range services {
  go func(s string) {
    fmt.Printf("Starting to notifing %s...\n", s)
    time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
    fmt.Printf("Finished notifying %s...\n", s)
    wg.Done()
  }(service)
}

无论哪种情况,我都建议在并发代码之外调用Add(),以确保它立即运行。 如果我们要把它放在goroutine中,有可能程序将在goroutine运行之前到达wg.Wait()行,在这种情况下,wg.Wait()将没有任何东西需要等待,我们将处于与之前相同的位置。

我们还需要将WaitGroup队列中的项目标记为完成。为此,我们调用Done(),与Add()不同,它不接受参数,需要为WaitGroup队列中尽可能多的条目调用。因为这依赖于在goroutine中运行的代码,所以对Done()的调用应该在我们想要等待的goroutine中运行。如果我们在for循环中而不是在goroutine中调用Done(),它会在goroutine实际运行之前将每个任务标记为完成。

最后,我们需要等待WaitGroup中排队的所有项目完成。我们通过调用Wait()来实现这一点,这将导致我们的程序在那里等待,直到WaitGroup的队列被清除。

值得注意的是,当我们不关心从goroutines收集任何结果时,这个模式工作得最好。如果我们发现自己处于需要从每个goroutine返回数据的情况,使用通道channel来传递信息可能会更容易。例如,下面的代码与等待组示例非常相似,但是它使用一个channel通道来接收每个goroutine完成后的消息。

func notify(services ...string) {
	res := make(chan string)
	count := 0

	for _, service := range services {
		count++
		go func(s string) {
			fmt.Printf("Starting to notifing %s...\n", s)
			time.Sleep(time.Duration(rand.Intn(3)) * time.Second)
			res <- fmt.Sprintf("Finished %s", s)
		}(service)
	}

	for i := 0; i < count; i++ {
		fmt.Println(<-res)
	}

	fmt.Println("All services notified!")
}

为什么我们不一直使用channel呢?基于最后一个例子,我们可以通过sync.WaitGroup和channel来做所有事情,那么为什么是新类型呢?

简单的答案就是当我们不关心从go协程返回的数据时,sync.WaitGroup就更清楚了。它向其他开发人员表明,我们只是想等待一组goroutines完成,而channel则表示我们对这些goroutines的结果感兴趣。