这是我参与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
,我们大概会做四件事:
- 声明
sync.WaitGroup
- 添加到WaitGroup队列
- 告诉我们的代码在继续之前等待WaitGroup队列达到0
- 在每个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的结果感兴趣。