7.1 Goroutine的使用

上一章已经对Go语言的并发编程模型中与我们最接近的核心元素G(即Goroutine)进行了详细的讲解。其中包括了G在模型中的位置和作用、生命周期、状态转换和调度方法等内部运作细节。我们说过,读者可以跳过这些内容而直接阅读本章。所以,如果你在阅读本节的过程中需要了解一些细节,那么上一章的最后一节一定是你最应该查阅的。

说到Goroutine,就不得不提到Go语言特有的关键字go。它是我们启用Goroutine的唯一途径。接下来,我们就开始学习使用go关键字并编写go语句。

7.1.1 go语句与Goroutine

一条go语句意味着一个函数或方法的并发执行。go语句是由go关键字和表达式组成的。对表达式的详细讲解请参见第3章。简单来说,表达式就是用于描述针对若干操作数的计算方法的式子。Go语言的表达式有很多种,其中包括了调用表达式。调用表达式所表达的即是针对函数或方法的调用。其中,函数可以是命名的,也可以是匿名的。可以明确地讲,能够被称为表达式语句的调用表达式是我们创建go语句时唯一可以合法使用的表达式。还记得吗?针对如下函数的调用表达式不能被称为表达式语句:appendcapcompleximaglenmakenewrealunsafe.Alignof unsafe.Offsetofunsafe.Sizeof。在这11个函数中,前8个函数是Go语言的内建函数,而最后3个函数则是标准库代码包unsafe中的函数。

可见,go语句的编写规则并不复杂。它与defer语句的编写规则有很多相似之处。接下来,我们要真正地编写几条go语句。

我们使用go关键字和一个针对内建函数println的调用表达式组成了一条go语句:

  1. go println("Go! Goroutine!")

22,如果在go关键字后面的是针对匿名函数的调用表达式,那么go语句就会像这样:

  1. go func() {
  2. println("Go! Goroutine!")
  3. }()

注意,无论是否需要传递参数值给匿名函数,我们都不要忘了最后的那对圆括号。它们代表了对函数的调用行为。否则,这就成了一个函数字面量,而不是go语句需要的调用表达式了。另外还有一点需要注意,在go关键字后面的调用表达式是不能被圆括号括起来的。这些都与defer语句的构建规则相同。

Go语言对go语句中的函数或方法及其参数的求值顺序并没有任何特别之处。但是,反直觉的是对它们的执行方式。Go语言的运行时系统对go语句中的函数或方法(以下简称go函数)的执行是并发的。更确切地说,当go语句被执行的时候,其中的go函数会被单独地放入到一个Goroutine中。在这之后,该go函数的执行会独立于当前Goroutine的运行。在一般情况下,在当前Goroutine中的、在某条go语句后面的那些语句并不会等到相应的go函数被执行完成之后才被执行。甚至,在该go函数真正被执行之前,运行时系统往往就已经开始执行后面的语句了。

另一方面,当go函数被执行完毕的时候,相应的Goroutine也会暂时进入到死亡状态(Gdead)。这标志着该Goroutine的一次运行的完成。此外,作为go函数的函数或方法是可以有结果声明的。但是,它们返回的结果值会在它们被执行完成的时候被丢弃。也就是说,即使它们返回了结果值也是没有任何意义的。这些结果值不会被传送到任何地方。那么,如果我们想把go函数中的结果值或者其他值传递给其他程序(或者说在其他Goroutine中的程序)的话,应该怎样去做呢?不要着急,我们在讲Channel的时候就会揭晓这个答案。

我们现在已经基本知晓了Go语言的运行时系统对go函数的执行方式。下面来看几个例子。假设有这样一个命令源码文件:

  1. package main
  2. func main() {
  3. go println("Go! Goroutine!")
  4. }

当我们使用go命令去运行这个源码文件的时候,标准输出上会出现什么内容呢?读者可能会认为该程序输出的内容会是:

  1. Go! Goroutine!

但是,实际上,这行内容并不会出现。这是为什么呢?我们刚刚说过,运行时系统会并发地执行go函数。正因为如此,运行时系统在使用一个Goroutine封装go函数并把它放入到相应的队列中之后,会立即继续执行在相应的go语句后面的语句。至于这个新的可运行G什么时候会被运行,就要看调度器的实时调度情况了(请见上一章中的解释)。而在本例中,go语句之后没有任何语句。因此,main函数此时即被执行完毕。这也意味着该Go程序的运行的结束。可是,这个时候,main函数中的那个go函数还没来得及被执行。换句话说,封装这个go函数的那个Goroutine还没有来得及被调度并运行。这种情况几乎总是会发生。所以我们不要对这种并发执行的先后顺序有任何假设,也不要指望main函数所在的G总是最后一个被运行完毕。如果我们确实希望如此,就必须通过额外的手段去实现。

Go语言为我们提供了很多这里所说的额外手段。其中,最简陋的一个手段是使用time包中的Sleep函数,像这样:

  1. package main
  2. import (
  3. "time"
  4. )
  5. func main() {
  6. go println("Go! Goroutine!")
  7. time.Sleep(time.Millisecond)
  8. }

函数time.Sleep的作用是让调用它的Goroutine暂停(进入Gwaiting状态)一段时间。在这里,我们让main函数所在的Goroutine暂停了1毫秒。在理想的情况下,运行该源码文件会如我们所愿地在标准输出上打印出Go! Goroutine!。但是,请注意,情况并不总是这样的。调度器的实时调度是我们无法控制的,所以上例所示的这个手段是非常不保险的。我们不应该在这种情形下使用time.Sleep函数。在这里,用调用语句runtime.Gosched()替换对time.Sleep函数的调用是一种更保险的方式。我在之前说过,runtime.Gosched函数的作用是让其他Goroutine有机会被运行。这种手段在这里施展是再适合不过的。但是,实际的情况往往要比这复杂得多。那时,runtime.Gosched函数就会变得不适用。当然,我们依然可以实现我们的需求。至于实现的具体方式,我会在后面披露。

下面我们来看更复杂一些的例子,如下:

  1. package main
  2. import (
  3. "fmt"
  4. "runtime"
  5. )
  6. func main() {
  7. name := "Eric"
  8. go func() {
  9. fmt.Printf("Hello, %s.\n", name)
  10. }()
  11. name = "Harry"
  12. runtime.Gosched()
  13. }

请读者试想一下,在我们运行内容如上的命令源码文件之后,标准输出上打印出怎样的内容?是Hello, Eric.还是Hello, Harry.?通过多次试验得知,答案是后者。这进一步说明了这种执行的并发性。在赋值语句name = "Harry"被执行之后,它上面的go函数才得以执行。更有甚者,如果我们在这里不在最后加入表达式语句runtime.Gosched(),那么go函数根本就没有被执行的机会。我们在前面已经说明了造成这种情况的原因。现在,我们把main函数中的最后两条语句互换一下位置,像这样:

  1. runtime.Gosched()
  2. name = "Harry"

那么,标准输出上会打印出内容吗?或者说,打印出的内容会与之前有什么不同?由runtime.Gosched的作用可知,打印出的内容会是:

  1. Hello, Eric.

因为我们在改变变量name的值之前就给那个go函数被执行的机会了。或者说,Go语言运行时系统应我们的要求抢先执行了该go函数。

现在情况变得更复杂了。我们要同时向多个人问候。问候目标的名单如下:

  1. names := []string{"Eric", "Harry", "Robert", "Jim", "Mark"}

要同时问候这5个人,最简单的方式就是连续编写出5条go语句。不过,这样好像太繁琐了,会写出很多冗余代码。既然我们把名单作为一个切片类型值呈现,那么我们用for语句来实现同时的(或者说并发的)问候应该会更好。代码如下(我们仅仅改造了一下前面示例中的main函数):

  1. func main() {
  2. names := []string{"Eric", "Harry", "Robert", "Jim", "Mark"}
  3. for _, name := range names {
  4. go func() {
  5. fmt.Printf("Hello, %s.\n", name)
  6. }()
  7. }
  8. runtime.Gosched()
  9. }

请读者运行一下这个源码文件。标准输出上的新增内容可能会让你感到诧异。它是这样的:

  1. Hello, Mark.
  2. Hello, Mark.
  3. Hello, Mark.
  4. Hello, Mark.
  5. Hello, Mark.

我们的朋友Mark可能要应接不暇了。这到底是怎么回事?要弄清楚这个问题,我们首先要知道go函数中所使用的标识符name到底代表了什么。在运用本书第4章中讲到的知识对此进行分析之后可知,这个标识符name其实就是在该go语句外层的for语句中声明的那个迭代变量name。这会有什么问题吗?这里的细节是:随着迭代的进行,每一次被获取出的迭代值(这里是名单中的单个名字)都会被赋给相应的迭代变量(这里是name)。也就是说,迭代变量name会依次被赋予"Eric""Harry""Robert""Jim""Mark"这5个值。注意,"Mark"是最后一个被赋给变量name的值。隐约感觉出问题所在了吗?事实上,在这里被并发执行的5个go函数(确切地讲,是被5个Goroutine分别封装的同一个函数)中,name的值都是"Mark"。这是因为它们都是在for语句被执行完毕之后才被执行的,而name在那时指代的值已经是"Mark"了。这也有for语句非常简单、瞬间就可以被执行完成的原因在里面。不过,即使for语句很复杂,这种情况也有可能发生。还是那句话,不要对go函数的执行时机做任何假设,除非你确实能做出让这种假设成为绝对事实的保证。

现在,我们来考虑一下解决上面问题的方案。有两种思路。第一种思路是,让5个go函数在每次迭代完成之前被执行完毕。按照这种思路,我们使用go语句发出问候有一点画蛇添足了。因为顺序的执行这些代码就可以达到目的,而且会简单得多。不过,这样实在就算不上是同时问候了,即使for语句的每次迭代都会以极快的速度完成。那么应该怎么办呢?其实很简单,我们在每次迭代完成之前给予之前的go函数一个被执行的机会就可以了。我们把上面的for修改成下面这样:

  1. for _, name := range names {
  2. go func() {
  3. fmt.Printf("Hello, %s.\n", name)
  4. }()
  5. runtime.Gosched()
  6. }

使用这一方案解决本例中的这个问题是简单而有效的。但是,如果我们的go函数比较复杂,并且在那条打印语句之前还有很多其他语句,那么这个方案就不一定会带来正确的结果。为了看清此问题,我们需要对go函数稍加修改,如下:

  1. go func() {
  2. time.Sleep(10 * time.Nanosecond)
  3. fmt.Printf("Hello, %s.\n", name)
  4. }()

为了不喧宾夺主,我们只使用针对time.Sleep函数的调用来代表执行若干条语句所需的时间。假设这些语句的执行总共需要耗费10纳秒。这已经是一个非常短暂的时间了。可是,这依然会使我们的需求无法实现。在我的计算机上运行这个源码文件后,不会打印出任何内容。也许,在你的计算机上可以打印出内容,但也绝对不是正确的结果。因为10纳秒足以让如此简单的for循环完成若干次迭代了。如此一来,我们可能会少问候一个或几个人,而另外的人可能会被问候几次。显然,这样的解决方案并不总是可行的。它会受到go函数以及for语句的执行时间的影响。

下面我们来考量第二种思路。如果我们在go函数中使用的name的值不会随外部变量的变化的影响,那么就可以既保证go函数的独立执行,又不用担心它们的正确性受到破坏。显然,如果这样的设想被实现了,那么这里的go函数就可以被称作可重入函数。

我们已经知道,go函数可以有结果声明(虽然这没有任何意义)。但是却还没有提到,go函数也和普通的函数一样可以有参数声明。如果把迭代变量name的值作为参数传递给go函数,那么也就实现了我们上面的设想。

能够如此轻易地实现该设想的根本原因是,name变量的类型string是一个非引用类型。我们在把一个值作为参数传递给函数或方法的时候,该值会被复制。对于引用类型(比如切片类型和字典类型)的值来说,由于它类似于指向真正数据的指针,所以即使它被复制了,之后在外部对该值的修改也会被反映到该函数或方法的内部。而对于非引用类型的值来说,这种修改就不会对函数体内部的操作产生影响。因为这样的两个值已经被完全分离了。

言归正传,实现此设想的main函数会是这样:

  1. func main() {
  2. names := []string{"Eric", "Harry", "Robert", "Jim", "Mark"}
  3. for _, name := range names {
  4. go func(who string) {
  5. fmt.Printf("Hello, %s.\n", who)
  6. }(name)
  7. }
  8. runtime.Gosched()
  9. }

请注意,我们为go函数添加了一个参数声明。该参数的名称为who。相应地,我们在go函数中不再使用外部变量name,而仅仅使用参数who。因为有了这样一个参数声明,所以我们在编写对它的调用表达式的时候,就需要在最后的圆括号“(”和“)”中放入参数值。在这里,我们把变量name的值作为参数值传递给go函数。在我们分析这条for语句的迭代之后会发现,在每次迭代的起始,name变量都会被赋予names的某一个元素值,紧接着这个元素值会被传入go函数。在传入的过程中,该值会被复制并在go函数中由参数who指代。此后,name的值的改变与go函数完全无关。我们运行包含此main函数的源码文件之后总会得到正确的结果。由此,根据第二种思路产出的解决方案是完全可行并且总是正确的。

再次强调,无论哪一种解决方案,它们最多只能保证go函数执行的正确性,而却无法保证这些go函数总会先于main函数被执行完成。后者相当于保证多个Goroutine的执行顺序,属于同步的范畴。

通过这一系列的示例,我们已经对go语句的使用方法和技巧有了足够的了解。作为一个理论补充,我们将会在下一小节简述封装main函数的Goroutine从“诞生”到“死亡”的全过程。

7.1.2 Goroutine的运作过程

在上一章,我们已经讲述了很多与Go语言的并发编程模型、运行时系统和调度器有关的知识。其中,我们详细地解释了Goroutine的状态以及它在这些状态之间的转换规则和时机。同时,我们也从调度器的角度说明了一个Goroutine是怎样被调度和运行的。如果读者还没有看过这些内容,我强烈建议读者在阅读本小节的内容之前先去了解一下它们。

我们说过,封装main函数的Goroutine是Go语言运行时系统创建的第一个Goroutine(也可被称为主Goroutine)。主Goroutine是在runtime.m0上被运行的。我们在上一章中讲过,封装了引导程序的runtime.g0就是在runtime.m0被运行的。实际上,在runtime.m0在运行完runtime.g0中的引导程序之后,会接着运行主Goroutine。

主Goroutine所做的事情并不是执行main函数那么简单。它首先要做的,是设定每一个Goroutine所能申请的栈空间的最大尺寸。在32位的计算机系统下,这个最大尺寸为250MB,而在64位的计算机系统中,此尺寸为1GB。如果有某个Goroutine申请的栈空间总尺寸大于了这个限制,那么运行时系统就会发起一个“栈溢出”(stack overflow)的运行时恐慌。这在正在运行的Go程序上的表现就是发生一个运行时恐慌。随即,该Go程序的运行也会被终止。

在设定好Goroutine的最大栈尺寸之后,主Goroutine会启动系统监测器。我们已经知道,系统监测器的作用就是对调度器的工作进行查缺补漏。这也是让系统监测器的启动先于main函数的执行的原因之一。

此后,主Goroutine会进行一系列的初始化工作。由于这些工作的重要性和特殊性,主Goroutine会在此期间与当前M(即runtime.m0)锁定在一起。这里所涉及的工作内容如下。

  • 创建一个特殊的defer语句,以执行主Goroutine退出时必要的善后处理。实际上,这里的善后处理即是指主Goroutine与当前M的解锁操作。因为,主Goroutine也可能会非正常地结束,所以这一点很有必要。

  • 检查当前M是否是runtime.m0。如果不是,那么就说明之前的程序出现了某种问题。这时,主Goroutine会立即抛出异常。这也意味着Go程序启动的失败。

  • 创建定时垃圾回收器(scavenger)。主Goroutine会创建一个专门的Goroutine来封装这个定时垃圾回收器,并把它放入到当前M的可运行G队列中。注意,此Goroutine即是Go语言运行时系统创建的第二个Goroutine。不过,创建它的方式与我们使用go语句创建一个用户级别的Goroutine的方式几乎无二。顺便提一下,定时垃圾回收器会定时(当前是2分钟一次)的执行垃圾回收任务,并在必要时促使一些M协助它进行一些垃圾回收工作。

  • 执行main包的init函数。我们在第2章讲过,每个代码包都可以有若干个代码包初始化函数。这些代码包初始化函数都必须是无任何参数声明和结果声明且名称为init的函数。当然,对于main包来说也是如此。在一个可运行的Go程序中,只可能有一个被用于启动Go程序的、属于main包的命令源码文件。因此,main包的init函数与main函数一样,指的是存在于这个源码文件中的同名函数。

  • 对之前创建的那个特殊的defer语句进行最后的检查和设置,并在必要时抛出异常。

如果上述初始化工作成功完成,那么主Goroutine就会去执行main函数。在执行完main函数之后,它还会检查是否有Goroutine发生了运行时恐慌,并进行必要的处理。最后,主Goroutine会结束自己以及当前进程的运行。

以上就是主Goroutine从始至终的运行过程。在main函数被执行期间,运行时系统会根据我们编写的go语句复用或新建Goroutine来封装go函数。这些Goroutine都会被放入到相应的P的可运行G队列中,然后等待调度器的调度。这样的等待时间通常会很短暂。但是有时如此短的时间也是不容忽视的。就像我们在上一小节举例说明的那样,它可能会使Goroutine错过甚至永远失去运行时机。

7.1.3 runtime包与Goroutine

我们已经知道,Go语言的标准库中有一个名为runtime的代码包。其中的程序实体提供了各种可以使应用程序与Go语言运行时系统进行交互的功能。我们在前面的章节中已经提及过很多这样的API。在本小节,我们主要说明那些可以获取到Goroutine信息或者能够直接或间接地控制Goroutine的运行的API。为了汇总它们,我们也会把一些已经讲过的函数罗列在这里。不过,对于这样的函数,我们只会进行概括性的描述。

1. runtime.GOMAXPROCS函数

通过调用runtime.GOMAXPROCS函数,应用程序可以在运行期间设置运行时系统中的P的最大数量。但由于这样做会引起“Stop the world”,所有我强烈建议应用程序应该尽量早地(在main函数的开始处,甚至在main包的init函数中)调用它。并且,请记住,最好的设置P最大数量的方式是在运行Go程序之前设置好操作系统的环境变量GOMAXPROCS,而不是在程序中调用runtime.GOMAXPROCS函数。

最后,请记住,无论我们传递给该函数怎样的整数值,运行时系统中的P最大数量总会在1~256的范围内。

2. runtime.Goexit函数

函数runtime.Goexit被调用之后会立即使调用它的Goroutine的运行被终止,但其他Goroutine并不会受此影响。runtime.Goexit函数在终止调用它的Goroutine的运行之前会先执行该Goroutine中所有还未被执行的defer语句。我们知道,defer语句的执行会被延迟至它所在的函数或方法被执行完毕之前。所以runtime.Goexit函数中的这一善后处理非常重要。

该函数会把被终止运行的Goroutine置于Gdead状态,并将其放入调度器的自由G列表。这样,调度器可以在有需要时重新启用此Goroutine。最后,应用程序对runtime.Goexit函数的调用还会触发调度器的一轮调度流程。

3. runtime.Gosched函数

我们在前面的示例中多次用到了runtime.Gosched函数。该函数的作用是暂停调用它的Goroutine的运行。调用它的Goroutine会被重新置于Grunnable状态,并被放入到调度器的可运行G队列中。这也是使用“暂停”这个词的原因。因为经过调度器的调度,该Goroutine不久就会再次被运行。这样做完全是为了让其他Goroutine立即有被运行的机会。

4. runtime.NumGoroutine函数

函数runtime.NumGoroutine在被调用后会返回运行时系统中的处于特定状态的Goroutine的数量。这里的特定状态是指Grunnable、Grunning、Gsyscall和Gwaiting。处于这些状态的Goroutine即被看作是活跃的或者说正在被调度的。注意,如果定时垃圾回收器所在的Goroutine的状态也在此范围内的话,那么也会被纳入到该计数当中。

5. runtime.LockOSThread函数和runtime.UnlockOSThread函数

我们在上一章说明过这两个函数的功能。前者使调用它的Goroutine与当前运行它的M锁定在一起,而后者则会解除这样的锁定。多次调用前者不会造成任何问题,但是只有最后一次调用的效果会被保留下来。即使在之前没有调用过前者,对后者的调用也不会产生任何副作用。对后者的多次调用也会是这样。

6. runtime/debug.SetMaxStack函数

函数runtime/debug.SetMaxStack的功能是约束单个Goroutine所能申请的栈空间的最大尺寸。我们已经知道,在main函数以及main包的init函数真正被执行之前,主Goroutine会对此进行默认的设置。默认的设定值对于绝大多数程序来说是适合的。所以,确实需要调用该函数的场景极少。

该函数接收一个int类型的参数。该参数的含义是欲设定的栈空间最大字节数。它在被执行完毕的时候会把之前的设定值作为结果返回。这有利于我们对该项设置的记录和恢复。

在对该函数的调用完成之后,如果运行时系统在为某个Goroutine增加栈空间的时候发现其栈空间所占用的总字节数已经超过了相关的设定值,那么就会发起一个运行时恐慌并终止程序的运行。

可以看出,该函数的作用主要是预防因执行了某些有问题的代码(比如无限的递归)而导致的栈空间的无限增长。不过,需要注意的是,该函数并不会像runtime.GOMAXPROCS函数那样对传入的参数值进行检查和纠正。所以,我们应该在调用它的时候保持足够的警惕。尤其是,即使我们设定了一个过小的值,相关的问题也一般不会在程序的运行初期就显现出来。因为运行时系统仅会在增长Goroutine的栈空间的时候才会对它占用的总字节数进行检查。这样,错误设置就像给程序埋下了一个定时炸弹。其造成的后果想必也是无法忽略的。

7. runtime/debug.SetMaxThreads函数

函数runtime/debug.SetMaxThreads的作用是对Go语言的运行时系统所使用的内核线程的数量(更确切地说,是M的数量)进行设置。在引导程序中,该数量被设置为了10000。这对于操作系统和Go程序来说都已经是一个足够大的值了。

该函数接受一个int类型的值,也会返回一个int类型的值。前者代表欲设定的新值,而后者则代表之前设定的旧值。我在前面说过,如果调用此函数时给定的新值比运行时系统当前正在使用的M的数量还要小的话,该调用就会引发一个运行时恐慌。另一方面,在对此函数的调用完成之后,我们设定的新值就会立即发挥作用。每当运行时系统新建一个M(即向操作系统索取一个新的内核线程)的时候,就会检查它当前所持的M的数量。如果该数量大于了M最大数量的现有设定,那么运行时系统就会发起一个同样的运行时恐慌,并终止Go程序的运行。

如果在运行时系统需要一个M去运行G的时候却发现现有的M都正在忙碌(可能它们正在进行系统调用、cgo调用或正在等待与其锁定在一起的那个G),那么它就会新建一个M以满足使用需要。虽然有些繁琐,但我们确实可以在一定程度上对这种需要进行不精确的预测。此后,我们就可以通过调用runtime/debug.SetMaxThreads函数来限制M的实际数量,以确保操作系统不会因Go程序对内核线程的无节制使用而被拖垮。换句话说,此设置可以让Go程序在计算机因此宕机之前被终止。当然,如果我们能够确定M的数量会在一个合理的范围内的话,不进行此设置也是完全可以的。

8. 与垃圾回收相关的一些函数

由于运行时系统在进行垃圾回收的时候会促使所有调度工作停止,所以说我们对垃圾回收的控制也会间接地影响到Goroutine的运行。

确切地讲,垃圾回收任务包括了两项工作,即垃圾收集和垃圾清扫。前者发现垃圾并记录它们的位置,后者清除垃圾并把它们所占用的内存归还给操作系统。在这之中,只有垃圾收集工作会导致“Stop the world”,并可能会征调一些M来并发的执行辅助任务。而垃圾清扫工作则每次仅会由某一个M来运行,并且不会对调度工作产生任何明显的影响。

下面,我们就介绍几个可以发起或控制这两项工作的函数。

  • 函数runtime.GC会让运行时系统进行一次强制性的垃圾收集。所谓的强制性的垃圾收集就是不论怎样都要进行一次垃圾收集操作。相对应地,非强制性的垃圾收集只会在一定的条件下才进行垃圾收集操作。更具体地说,这个条件是运行时系统自上次垃圾收集之后新申请的堆内存的单元(也被称为堆内存单元增量)达到指定的数值。这个数量是可以由应用程序控制的。这需要用到runtime/debug.SetGCPercent函数。

  • 函数runtime/debug.SetGCPercent被用于设置一个比率(以下称垃圾收集比率)。前面所说的指定的堆内存单元增量与前一次垃圾收集时的堆内存的单元数量和此垃圾收集比率有关,具体计算公式如下:

<触发垃圾收集的堆内存单元增量> = <上一次垃圾收集完成后的堆内存单元数量> * (<垃圾收集比率> / 100)

可以看到,垃圾收集比率就是应该触发垃圾收集的堆内存单元增量相对于之前的堆内存单元总数量的一个百分比。我们还可以把通过此公式计算出的数值简称为增量下限值。只有在堆内存的单元增量达到了这个增量下限值的情况下,运行时系统发起的非强制性的垃圾收集才不会被忽略。增量下限值会在每次垃圾收集完成之后被重新计算。也就是说,我们在Go程序被运行期间通过调用runtime/debug.SetGCPercent函数对垃圾收集比率的修改是可以影响到之后的所有垃圾收集任务的执行的。

我们在调用runtime/debug.SetGCPercent函数之后会得到一个结果值。这个结果值即是之前的垃圾收集比率。如果我们没有对此比率进行过任何显式的设置,那么这个结果值就会等于预设值。运行时系统内部对此比率的预设值是100。该预设值是可以被改变的。Go语言为我们提供的改变此预设值的唯一途径是设置操作系统的环境变量GOGC。如果我们在Go程序被运行之前(更确切地讲,是在第一次垃圾收集被发起之前)对此环境变量进行了设置,那么上述的预设值就会是我们指定的值。GOGC的有效值是任何整数和字符串off。这里有两点需要注意。第一点,如果我们为GOGC设置了一个无效的值,那么垃圾收集比率的预设值就会被设定为0。这意味着在默认情况下,非强制性的垃圾收集总会被进行。第二点,如果我们把GOGC的值设置为了负整数或off,那么就会导致垃圾回收器忽略一切垃圾收集操作。这与我们调用runtime/debug.SetGCPercent函数并传入一个负整数的效果是一样的。另一方面,如果该函数的结果值是一个负整数,那么就说明垃圾收集操作在此前的一段时间内是被忽略的。长时间的忽略垃圾收集操作对于在非测试环境中运行的程序来说是非常危险的。我们应该特别注意这一点。

有些时候,垃圾收集操作也会被自动地忽略。例如,引导程序还未被执行完成的时候。又例如,运行时恐慌正在爆发的时候。不过,这些情景都是合理且短暂的,并不会对垃圾回收任务的执行产生明显的影响。

上述两个函数都是与垃圾收集工作有关的。它们并不会发起或影响到垃圾清扫工作。在默认情况下,垃圾清扫工作会由垃圾回收器定时地进行。但是,我们可以通过调用runtime/debug. FreeOSMemory函数手动地进行一次垃圾清扫。当然,该函数在清扫垃圾之前会先试图把垃圾都收集起来。此处的垃圾收集操作相当于我们对runtime.GC函数进行了一次调用。前文所说的外部设置也会影响到这一操作的有效性。而之后的垃圾清扫操作的意义仅在于,尽最大可能地将程序已经不用的内存归还给操作系统。

本小节讲到的这些API都可以让我们在一定程度上了解、微调和变更Go语言的运行时系统的行为。通过它们,我们可以根据实际情况对Go程序的运行环境进行调整和优化。对于本小节讲到的所有设置,Go语言的运行时系统都给予了默认值。因此,我们应该仅在程序性能不能满足需要的时候再去考虑调整它们。这里的唯一例外是对P最大数量的设置,因为它的默认值是1。这会影响Go程序在多核CPU或多CPU的计算环境下的运行性能。我们应该根据计算环境中逻辑CPU的数量(也就是所有CPU的核数的总和)来设置它。不过要注意,过多的P会对调度器的效率产生负面影响。

7.1.4 Happens Before

在本节的最后,我们来说说Go语言中的“happens before”原则。该原则为多Goroutine程序运行的正确性提供了可以依照的准则。更具体地讲,“happens before”原则描述了使(针对全局变量的)读写操作的结果在全局(即程序中的所有Goroutine)可见的充分条件。

这里有两个基本的描述方法需要了解。

  • 如果事件1先于事件2发生,那么就可以说事件2后于事件1发生。

  • 如果事件1未先于且未后于事件2发生,那么就可以说事件1和事件2是同时发生的。

对于只有一个Goroutine(即仅有主Goroutine)的程序来说,“happens before”原则并没有什么特别之处。因为其中任何的读写操作的实际执行顺序都一定不会改变程序原本的意图。即使编译器或运行时系统为了优化程序性能而对其进行了代码重排,也同样会保持这一点。但是,对于拥有多个Goroutine的程序来说就完全不同了。因为所有的Goroutine都是被并发地运行的,所以这些在共享内存上的操作可能会使不同的Goroutine对它们的感知有所不同(比如观察到了不同的操作执行顺序)。从而导致某个或某些Goroutine对另一个Goroutine的意图或行为的错误理解。最终,程序在被运行的过程当中就可能会发生不可预知且排查困难的异常。我们在讲解多进程和多线程编程的时候也讨论过类似的问题。

然而,如果程序可以满足“happens before”原则中的那些充分条件的话,那么这些问题就不会发生。Go语言的运行时系统会对此做出保证。下面我们就来看看“happens before”原则中都有哪些内容。

首先,如果要让一个对变量v的写操作w所产生的结果能够被对该变量的读操作r观察到,那么需要同时满足如下两个条件。

  • 读操作r未先于写操作w发生。

  • 没有其他对此变量的写操作后于写操作w且先于读操作r发生。

第一个条件应该很好理解。写操作是不可能被在它之前发生的读操作观察到的。而第二个条件的意思是:如果在写操作w发生之后又有其他写操作作用于该变量,那么之后发生的读操作r读到的就必定不是写操作w所产生的结果。因为在它们之间发生的写操作会覆盖掉w的结果。

此外,为了使对变量v的读操作r能够观察到特定的写操作wv的改变,那么就要保证w是唯一允许r观察的写操作。也就是说,若要保证r能够观察到w所产生的结果,就需要同时满足如下两个条件。

  • 写操作w一定要发生在读操作r之前。

  • 任何其他对共享变量v的写操作都只能发生在w之前或r之后。

相比于第一对充分条件,第二对充分条件的约束力更强。因为它们要求不能有与wr同时发生的其他写操作。更具体地讲,第二对充分条件既不保证与r同时发生的写操作能被r观察到,也不保证与写操作w或读操作r同时发生的其他写操作不会被r观察到。换言之,Go语言不对同时发生的针对同一个变量的读写操作所产生的相互作用和最终结果做出任何假设。

对于单Goroutine的程序来说,这两对充分条件是等价的。因为在这样的程序中不存在并发的情况,也不可能有两个操作同时发生。这时,这两对充分条件可以被凝练为一句话,即读操作能且仅能观察到在它之前发生且离它最近的那个针对相同变量的写操作对该变量产生的结果。

相应地,对于多Goroutine的程序来说,应用程序在采取必要的同步措施之前肯定是无法满足上述充分条件的。因此,采用同步方法是实现对共享变量的安全访问的唯一途径。除了互斥量和条件变量之外,Go语言还提供了Channel这种既能实现操作同步又能满足通讯需要的高级方法。我们会在后面讲到它们。

注意,对一个已被声明的变量的第一次初始化操作形同于对该变量的写操作。这意味着,只有访问变量的操作在初始化它的操作之后发生才算是满足上述的“happens before”条件。

此外,针对长度超过一个机器字长的值的读写操作相当于多个对长度为一个机器字长的值的读写操作,并且其读或写的顺序是无法在语言层面保证的。举个例子,我们在32位计算架构的计算机上写入一个64位的整数,相当于分别写入两个32位的整数。因为在这样的计算机上,一条CPU运算指令最多只能修改一个长度为32位(与该计算机的字长相同)的数据。对于一个64位的数据来讲,只能通过两条指令分别修改它的高32位和低32位。虽然这两条指令分别写入的部分数据在逻辑上有高低之分,但执行它们的顺序却是不确定的。在这种情况下,如果存在与这个写操作并发的读操作,那么很有可能会读取到只修改了一半的数据。这比读取到一个旧数据更加糟糕。因此,我们更应该对这些操作进行同步。这也是Go语言的“happens before”原则不对同时发生的读写操作所产生的结果做任何假设的原因之一。

我们在使用同步方法的时候,应该使相应的操作满足上述的“happens before”条件。只有这样,Go语言才能够帮助我们实现对共享数据的安全访问。这是在编程过程中必须要注意的。在后面的内容中,我们会陆续介绍Go语言提供的各种同步方法是怎样辅助我们满足“happens before”条件的。