17 | go语句及其执行规则(下)

你好,我是郝林,今天我们继续分享go语句执行规则的内容。

在上一篇文章中,我们讲到了goroutine在操作系统的并发编程体系,以及在Go语言并发编程模型中的地位和作用等一系列内容,今天我们继续来聊一聊这个话题。

知识扩展

问题1:怎样才能让主goroutine等待其他goroutine?

我刚才说过,一旦主goroutine中的代码执行完毕,当前的Go程序就会结束运行,无论其他的goroutine是否已经在运行了。那么,怎样才能做到等其他的goroutine运行完毕之后,再让主goroutine结束运行呢?

其实有很多办法可以做到这一点。其中,最简单粗暴的办法就是让主goroutine“小睡”一会儿。

  1. for i := 0; i < 10; i++ {
  2. go func() {
  3. fmt.Println(i)
  4. }()
  5. }
  6. time.Sleep(time.Millisecond * 500)

for 语句的后边,我调用了 time 包的 Sleep 函数,并把 time.Millisecond * 500 的结果作为参数值传给了它。 time.Sleep 函数的功能就是让当前的goroutine(在这里就是主goroutine)暂停运行一段时间,直到到达指定的恢复运行时间。

我们可以把一个相对的时间传给该函数,就像我在这里传入的“500毫秒”那样。 time.Sleep 函数会在被调用时用当前的绝对时间,再加上相对时间计算出在未来的恢复运行时间。显然,一旦到达恢复运行时间,当前的goroutine就会从“睡眠”中醒来,并开始继续执行后边的代码。

这个办法是可行的,只要“睡眠”的时间不要太短就好。不过,问题恰恰就在这里,我们让主goroutine“睡眠”多长时间才是合适的呢?如果“睡眠”太短,则很可能不足以让其他的goroutine运行完毕,而若“睡眠”太长则纯属浪费时间,这个时间就太难把握了。

你可能会想到,既然不容易预估时间,那我们就让其他的goroutine在运行完毕的时候告诉我们好了。这个思路很好,但怎么做呢?

你是否想到了通道呢?我们先创建一个通道,它的长度应该与我们手动启用的goroutine的数量一致。在每个手动启用的goroutine即将运行完毕的时候,我们都要向该通道发送一个值。

注意,这些发送表达式应该被放在它们的 go 函数体的最后面。对应的,我们还需要在 main 函数的最后从通道接收元素值,接收的次数也应该与手动启用的goroutine的数量保持一致。关于这些你可以到demo39.go文件中,去查看具体的写法。

其中有一个细节你需要注意。我在声明通道 sign 的时候是以 chan struct{} 作为其类型的。其中的类型字面量 struct{} 有些类似于空接口类型 interface{},它代表了既不包含任何字段也不拥有任何方法的空结构体类型。

注意, struct{} 类型值的表示法只有一个,即: struct{}{}。并且,它占用的内存空间是 0 字节。确切地说,这个值在整个Go程序中永远都只会存在一份。虽然我们可以无数次地使用这个值字面量,但是用到的却都是同一个值。

当我们仅仅把通道当作传递某种简单信号的介质的时候,用 struct{} 作为其元素类型是再好不过的了。顺便说一句,我在讲“结构体及其方法的使用法门”的时候留过一道与此相关的思考题,你可以返回去看一看。

再说回当下的问题,有没有比使用通道更好的方法?如果你知道标准库中的代码包 sync 的话,那么可能会想到 sync.WaitGroup 类型。没错,这是一个更好的答案。不过具体的使用方式我在后边讲 sync 包的时候再说。

问题2:怎样让我们启用的多个goroutine按照既定的顺序运行?

在很多时候,当我沿着上面的主问题以及第一个扩展问题一路问下来的时候,应聘者往往会被这第二个扩展问题难住。

所以基于上一篇主问题中的代码,怎样做到让从 09 这几个整数按照自然数的顺序打印出来?你可能会说,我不用goroutine不就可以了嘛。没错,这样是可以,但是如果我不考虑这样做呢。你应该怎么解决这个问题?

当然了,众多应聘者回答的其他答案也是五花八门的,有的可行,有的不可行,还有的把原来的代码改得面目全非。我下面就来说说我的思路,以及心目中的答案吧。这个答案并不一定是最佳的,也许你在看完之后还可以想到更优的答案。

首先,我们需要稍微改造一下 for 语句中的那个 go 函数,要让它接受一个 int 类型的参数,并在调用它的时候把变量 i 的值传进去。为了不改动这个 go 函数中的其他代码,我们可以把它的这个参数也命名为 i

  1. for i := 0; i < 10; i++ {
  2. go func(i int) {
  3. fmt.Println(i)
  4. }(i)
  5. }

只有这样,Go语言才能保证每个goroutine都可以拿到一个唯一的整数。其原因与 go 函数的执行时机有关。

我在前面已经讲过了。在 go 语句被执行时,我们传给 go 函数的参数 i 会先被求值,如此就得到了当次迭代的序号。之后,无论 go 函数会在什么时候执行,这个参数值都不会变。也就是说, go 函数中调用的 fmt.Println 函数打印的一定会是那个当次迭代的序号。

然后,我们在着手改造 for 语句中的 go 函数。

  1. for i := uint32(0); i < 10; i++ {
  2. go func(i uint32) {
  3. fn := func() {
  4. fmt.Println(i)
  5. }
  6. trigger(i, fn)
  7. }(i)
  8. }

我在 go 函数中先声明了一个匿名的函数,并把它赋给了变量 fn。这个匿名函数做的事情很简单,只是调用 fmt.Println 函数以打印 go 函数的参数 i 的值。

在这之后,我调用了一个名叫 trigger 的函数,并把 go 函数的参数 i 和刚刚声明的变量 fn 作为参数传给了它。注意, for 语句声明的局部变量 igo 函数的参数 i 的类型都变了,都由 int 变为了 uint32。至于为什么,我一会儿再说。

再来说 trigger 函数。该函数接受两个参数,一个是 uint32 类型的参数 i, 另一个是 func() 类型的参数 fn。你应该记得, func() 代表的是既无参数声明也无结果声明的函数类型。

  1. trigger := func(i uint32, fn func()) {
  2. for {
  3. if n := atomic.LoadUint32(&count); n == i {
  4. fn()
  5. atomic.AddUint32(&count, 1)
  6. break
  7. }
  8. time.Sleep(time.Nanosecond)
  9. }
  10. }

trigger 函数会不断地获取一个名叫 count 的变量的值,并判断该值是否与参数 i 的值相同。如果相同,那么就立即调用 fn 代表的函数,然后把 count 变量的值加 1,最后显式地退出当前的循环。否则,我们就先让当前的goroutine“睡眠”一个纳秒再进入下一个迭代。

注意,我操作变量 count 的时候使用的都是原子操作。这是由于 trigger 函数会被多个goroutine并发地调用,所以它用到的非本地变量 count,就被多个用户级线程共用了。因此,对它的操作就产生了竞态条件(race condition),破坏了程序的并发安全性。

所以,我们总是应该对这样的操作加以保护,在 sync/atomic 包中声明了很多用于原子操作的函数。

另外,由于我选用的原子操作函数对被操作的数值的类型有约束,所以我才对 count 以及相关的变量和参数的类型进行了统一的变更(由 int 变为了 uint32)。

纵观 count 变量、 trigger 函数以及改造后的 for 语句和 go 函数,我要做的是,让 count 变量成为一个信号,它的值总是下一个可以调用打印函数的 go 函数的序号。

这个序号其实就是启用goroutine时,那个当次迭代的序号。也正因为如此, go 函数实际的执行顺序才会与 go 语句的执行顺序完全一致。此外,这里的 trigger 函数实现了一种自旋(spinning)。除非发现条件已满足,否则它会不断地进行检查。

最后要说的是,因为我依然想让主goroutine最后一个运行完毕,所以还需要加一行代码。不过既然有了 trigger 函数,我就没有再使用通道。

  1. trigger(10, func(){})

调用 trigger 函数完全可以达到相同的效果。由于当所有我手动启用的goroutine都运行完毕之后, count 的值一定会是 10,所以我就把 10 作为了第一个参数值。又由于我并不想打印这个 10,所以我把一个什么都不做的函数作为了第二个参数值。

总之,通过上述的改造,我使得异步发起的 go 函数得到了同步地(或者说按照既定顺序地)执行,你也可以动手自己试一试,感受一下。

总结

在本篇文章中,我们接着上一篇文章的主问题,讨论了当我们想让运行结果更加可控的时候,应该怎样去做。

主goroutine的运行若过早结束,那么我们的并发程序的功能就很可能无法全部完成。所以我们往往需要通过一些手段去进行干涉,比如调用 time.Sleep 函数或者使用通道。我们在后面的文章中还会讨论更高级的手段。

另外, go 函数的实际执行顺序往往与其所属的 go 语句的执行顺序(或者说goroutine的启用顺序)不同,而且默认情况下的执行顺序是不可预知的。那怎样才能让这两个顺序一致呢?其实复杂的实现方式有不少,但是可能会把原来的代码改得面目全非。我在这里提供了一种比较简单、清晰的改造方案,供你参考。

总之,我希望通过上述基础知识以及三个连贯的问题帮你串起一条主线。这应该会让你更快地深入理解goroutine及其背后的并发编程模型,从而更加游刃有余地使用 go 语句。

思考题

  1. runtime 包中提供了哪些与模型三要素G、P和M相关的函数?(模型三要素内容在上一篇)

戳此查看Go语言专栏文章配套详细代码。