湘乡彭于晏学习前端
Goroutine
2023-03-22Go系列1094

Goroutine 是 Golang 中非常有用的功能,但是在使用中我们经常碰到下面的场景:如果希望等待当前的 goroutine 执行完成,然后再接着往下执行,该怎么办?本文尝试介绍这类问题的解决方法。

没有等待的情况

让我们运行下面的代码,并关注输出的结果:

复制
package main import ( "time" "fmt" ) func say(s string) { for i := 0; i < 3; i++ { time.Sleep(100 * time.Millisecond) fmt.Println(s) } } func main() { go say("hello world") fmt.Println("over!") }

输出的结果为:over! 因为 goroutine 以非阻塞的方式执行,它们会随着程序(主线程)的结束而消亡,所以程序输出字符串 "over!" 就退出了,这可不是我们想要的结果。

使用 Sleep 函数等待

要解决上面的问题,最简单、直接的方式就是通过 Sleep 函数死等 goroutine 执行完成:

复制
func main() { go say("hello world") time.Sleep(1000 * time.Millisecond) fmt.Println("over!") }

运行修改后的程序,结果如下:

hello world

hello world

hello world

over!

结果符合预期,但是太 low 了,我们不知道实际执行中应该等待多长时间,所以不能接受这个方案!

使用 channel

通过 channel 也可以达到等待 goroutine 结束的目的,运行下面的代码:

复制
func main() { done := make(chan bool) go func() { for i := 0; i < 3; i++ { time.Sleep(100 * time.Millisecond) fmt.Println("hello world") } done <- true }() <-done fmt.Println("over!") }

输出的结果也是:

hello world

hello world

hello world

over!

这种方法的特点是执行多少次 done <- true 就得执行多少次 <-done,所以也不是优雅的解决方式。

标准答案

Golang 官方在 sync 包中提供了 WaitGroup 类型来解决这个问题。其文档描述如下:

A WaitGroup waits for a collection of goroutines to finish. The main goroutine calls Add to set the number of goroutines to wait for. Then each of the goroutines runs and calls Done when finished. At the same time, Wait can be used to block until all goroutines have finished.

大意为:WaitGroup 用来等待单个或多个 goroutines 执行结束。在主逻辑中使用 WaitGroup 的 Add 方法设置需要等待的 goroutines 的数量。在每个 goroutine 执行的函数中,需要调用 WaitGroup 的 Done 方法。最后在主逻辑中调用 WaitGroup 的 Wait 方法进行阻塞等待,直到所有 goroutine 执行完成。

使用方法可以总结为下面几点:

  • 创建一个 WaitGroup 实例,比如名称为:wg
  • 调用 wg.Add(n),其中 n 是等待的 goroutine 的数量
  • 在每个 goroutine 运行的函数中执行 defer wg.Done()
  • 调用 wg.Wait() 阻塞主逻辑

运行下面的代码:

复制
package main import ( "time" "fmt" "sync" ) func main() { var wg sync.WaitGroup wg.Add(2) say2("hello", &wg) say2("world", &wg) fmt.Println("over!") } func say2(s string, waitGroup *sync.WaitGroup) { defer waitGroup.Done() for i := 0; i < 3; i++ { fmt.Println(s) } }

输出的结果如下:

hello

hello

hello

world

world

world

over!

下面是一个稍稍真实一点的例子,检查请求网站的返回状态。如果要在收到所有的结果后进一步处理这些返回状态,就需要等待所有的请求结果返回:

复制
package main import ( "fmt" "sync" "net/http" ) func main() { var urls = []string{ "https://www.baidu.com/", "https://www.cnblogs.com/", } var wg sync.WaitGroup for _, url := range urls { wg.Add(1) go fetch(url, &wg) } wg.Wait() } func fetch(url string, wg *sync.WaitGroup) (string, error) { defer wg.Done() resp, err := http.Get(url) if err != nil { fmt.Println(err) return "", err } fmt.Println(resp.Status) return resp.Status, nil }

运行上面的代码,输出的结果如下:

200 OK

200 OK

context.Context

上面的场景是等待多个小任务都执行完毕之后退出。下面考虑这种场景:有一个持续性的任务,一般是常驻的不退出,比如说监控任务。但有时可能因为特殊需求,需要根据条件判断后让其退出。因为该监控任务和其他任务没有数据连接(channel之类的),但靠sync.WaitGroup是不能使其退出的。 很自然的,我们能想到应该通过一个媒介,来通知监控任务退出。全局变量可以充当这个媒介,但鉴于安全性很差,这里不予考虑(代码中一般来说不允许出现全局变量)。

chan通知

复制
func main() { stop := make(chan bool) go func() { for { select { case <-stop: fmt.Println("监控退出,停止了...") return default: fmt.Println("goroutine监控中...") time.Sleep(2 * time.Second) } } }() time.Sleep(10 * time.Second) fmt.Println("可以了,通知监控停止") stop <- true //为了检测监控过是否停止,如果没有监控输出,就表示停止了 time.Sleep(5 * time.Second) }

输出结果:

复制
goroutine监控中... goroutine监控中... goroutine监控中... goroutine监控中... goroutine监控中... 可以了,通知监控停止 监控退出,停止了...

这种方式还是比较优雅的,但还是有很大的局限性。如果我们需要控制很多的goruntine结束怎么办,难道我们要搞出很多个chan?又或者子goruntine中又衍生出更多的子goruntine怎么办?如果一层层的无穷尽的 goroutine 呢?这就非常复杂了,即使我们定义很多 chan 也很难解决这个问题,因为 goroutine 的关系链就导致了这种场景非常复杂。

初识Context

上面说的这种场景是存在的,比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其它的 goroutine。所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制它们的目的,这就是Go语言为我们提供的 Context,称之为上下文非常贴切,它就是 goroutine 的上下文。

利用context.Context重写上面示例:

复制
func main() { ctx, cancel := context.WithCancel(context.Background()) go func(ctx context.Context) { for { select { case <-ctx.Done(): fmt.Println("监控退出,停止了...") return default: fmt.Println("goroutine监控中...") time.Sleep(2 * time.Second) } } }(ctx) time.Sleep(10 * time.Second) fmt.Println("可以了,通知监控停止") cancel() //为了检测监控过是否停止,如果没有监控输出,就表示停止了 time.Sleep(5 * time.Second) }

Context 控制多个 goroutine

复制
func main() { ctx, cancel := context.WithCancel(context.Background()) go watch(ctx, "【监控1】") go watch(ctx, "【监控2】") go watch(ctx, "【监控3】") time.Sleep(10 * time.Second) fmt.Println("可以了,通知监控停止") cancel() //为了检测监控过是否停止,如果没有监控输出,就表示停止了 time.Sleep(5 * time.Second) } func watch(ctx context.Context, name string) { for { select { case <-ctx.Done(): fmt.Println(name, "监控退出,停止了...") return default: fmt.Println(name, "goroutine监控中...") time.Sleep(2 * time.Second) } } }