Go并发编程-goroutine
goroutine
本文是书籍: 《 Go 并发编程实战 》的读书笔记.
基本使用
goroutine, 也就是 GMP 模型里的 G. Go 通过关键字 go
启动一个goroutine.
一条 go
语句意味着一个函数或者方法的并行执行. go
语句由 go
关键字和表达式组成.
简单地讲表达式就是用于描述针对若干操作数的计算方法的式子.
Go 的表达式有多种, 其中一种是调用表达式, 调用表达式表达的是针对函数或者方法的调用, 其中的函数可以是命名也可是匿名. 能成为表达式语句的调用表达式是创建 go
语句时唯一合法的表达式.
创建go 语句时唯一合 法的表达式。针对如下丽数的调用表达式不能称为表达式语句: append
、cap
、 complex
、 imag
、 len
、 make
、 new
、 real
、 unsafe.Alignof
、 unsafe.Offsetof
和 unsafe. Sizeof
. 前8个函数是Go语言的内建函数,而最后3个函数则是标准库代码包 unsafe
中的函数。
下面是一个使用 go
关键字的例子:
go println("Go! Goroutine")
在这使用匿名函数调用则是:
go func() {println("Go! Goroutine")
}()
最后的一对圆括号, 代表了对函数的调用行为, 是调用表达式的必要组成部分.
另外, go
关键字后面的调用表达式不能用圆括号括起来. 这一构建规则和 defer
语句一致.
Go 运行时系统对 go
函数 (go
语句中的函数的简称) 的执行是并发的, 也就是, go
语句执行时, 其中的 go
函数将被单独放入一个 goroutine 中, 此后该 go
函数执行会独立于当前 goroutine 运行.
一般情况下, go
语句之后的语句不会等到前者 go
函数被执行完后才开始执行, 甚至在该 go
函数真正执行前, 运行时系统可能就已经开始执行其后面的语句了, 也就是说: go
函数是并发执行, 但是先后次序是无法确定的.
go
函数可以有返回值, 但是其返回值会在执行完成时被丢弃, 也就是说即使有返回值, 也没什么意义.
如果需要将 go
函数的计算结果传递给其他函数, 应当使用 channel
配合.
示例1
package mainfunc main() {go println("Go! Goroutine!")
}
第一眼看来, 可能以为标准输出上会出现:
Go! Goroutine!
但是事实上什么也不会出现. 想想之前的 GMP 模型, 运行时系统会并发地执行 go
函数, 使用一个 G 来封装 go
函数, 然后放入可运行 G 队列中, 但是这个新的 G 的运行时刻则是看调度器的实时调度情况, 在本例中, go
语句后没有任何语句, main
函数执行结束立刻就返回, 这个 Go 程序也就结束运行了, 这里的打印语句还没来得及执行.
从这个例子中, 我们就要知道, 不能确定 main
函数所在的 G 会是最后一个运行完毕的, 这时候可以加 Sleep()
函数来实现等待.
示例2
package mainimport "time"func main() {go println("Go! Goroutine!")time.Sleep(time.Millisecond * 1)
}
此处调用函数 Sleep()
, 让调用该函数的 goroutine 暂停 (此时进入 Gwaiting状态) 一段时间. 此处是暂停了 1ms.
理想情况下, 加入该语句后, 标准输出会打印出我们期望的语句, 但是真实情况不会总是如此, 毕竟谁能知道到底要睡多长时间才能轮到需要的 goroutine 被调度呢? 此时最好把函数替换为 runtime.Gosched()
.
调用 runtime.Gosched()
会暂停调用该函数的 G, 让其他的 G 有机会运行, 在这里可以用, 但是实际情况一般比这复杂许多, 此时该函数也会失效.
示例3
package mainimport ("fmt""time"
)func main() {name := "dagoujiao"go func() {fmt.Printf("Hello, %s!", name)}()name = "hajimi"time.Sleep(time.Millisecond)
}
运行该程序, 标准输出将是:
Hello, dagoujiao!
Hello, hajimi!
两者取其一, 但是多数情况下为后者, 这更说明了并发性的问题, 在执行了 name = "hajimi"
之后才执行上面的 go
函数,
然后把最后两条语句更换位置看看结果:
package mainimport ("fmt""time"
)func main() {name := "dagoujiao"go func() {fmt.Printf("Hello, %s!", name)}()time.Sleep(time.Millisecond)name = "hajimi"
}
现在将会打印:
Hello, dagoujiao!
这里原因就比较显而易见了.
然后再是更复杂的情况, 如果我要同时问候多个人要怎么办?
示例4
package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func() {fmt.Printf("Hello, %s! \n", name)}()}time.Sleep(time.Millisecond)
}
这里将会乱序输出.
Hello, jiangangma!
Hello, dagoujiao!
Hello, hajimi!
Hello, daishuji!
Hello, dingdongji!
这里同样说明了, 对于 go
函数的执行实际不能做任何期望, 这里用了一个循环, 循环将会执行 5 次 go
函数, 由 5 个 G 分别封装运行, 此时就看出问题了, 没人能知道具体执行到了哪一个迭代器.
第一种方案是, 将 time.Sleep(time.Millisecond)
语句提前到 for
循环内就可以解决了:
package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func() {fmt.Printf("Hello, %s! \n", name)}()time.Sleep(time.Millisecond)}
}
但是这种方法, 虽然简单但是也有问题, 一旦 for
循环复杂了, 或者go
函数复杂了, 打印顺序仍然无法保证.
然后再考虑第二种思路. 如果在 go
函数中使用的 name
的值不会受到外部变量变化影响, 这样既能保证 go
函数的独立执行, 也不用担心其正确性受破坏. 我们将实现了改设想的函数称为 “可重入函数”.
go
函数和普通函数一样有参数生命, 可以将迭代变量 name
的值作为参数传递给 go
函数, 这样可以实现上述的设想.
示例5
package mainimport ("fmt""time"
)func main() {names := []string{"dagoujiao", "hajimi", "daishuji", "dingdongji", "jiangangma"}for _, name := range names {go func(who string) {fmt.Printf("Hello, %s! \n", who)}(name)}time.Sleep(time.Millisecond)
}
按照上面的说法, 这里这样编写即可, 在每次迭代开始时, name
变量的值都是 names
中的某一个元素值, 之后这个值将会被传入 go
函数, 传入过程中, 该值将会被复制, 然后在 go
函数中有一个参数 who
指代. 此后 name
值的改变和 go
函数就没关系了.
主 goroutine 的运作
封装 main
函数的 goroutine 就是主 goroutine. 主 goroutine 由 runtime.m0
负责运行
主 goroutine 不只是执行 main
函数这一个功能.
首先要做的: 设定每一个 goroutine 所能申请的栈空间的最大尺寸. 32位计算机中该最大尺寸为250MB, 64位计算机中该最大尺寸为1GB. 如果某个 goroutine 栈空间大于此限制, 那么运行时系统立马就发出 stack overflow
(栈溢出) panic
. 随后终止程序运行.
在设定完 goroutine 最大栈尺寸之后, 主 goroutine 在当前 M 的 g0 上执行系统检测任务. 系统检测任务作用就是为调度器查漏补缺. 这也是让系统检测任务执行先于 main
函数的原因.
之后, 主 goroutine 将进行一系列初始化工作, 涉及内容大体如下:
-
检查当前 M 是否是
runtime.m0
. 如果不是, 说明之前的程序出现了某问题, 此时主 goroutine 立即抛出异常, 意味着 Go 程序启动失败. -
创建一个特殊的
defer
语句, 用于在主 goroutine 退出时做必要的善后处理. 因为主 goroutine 可能是非正常结束, 所以这点很重要. -
启用专用于在后台清扫内存垃圾的 goroutine, 设置 GC 可用的标识.
-
执行
main
包中的init
函数.
在上述初始化工作成功完成后, 主 goroutine 执行 main
函数. 执行完 main
函数后, 还会检查主 goroutine 是否引发了运行时恐慌, 然后进行必要的处理. 最后, 主 goroutine 会结束自己以及当前进程运行.
main
函数执行期间, 运行时系统会根据 Go 程序中的 go
语句, 复用或者新建 goroutine 来封装 go
函数.
这使得等待时间非常短暂, 但是也足够长, 以至于会使得某些更小时间片的 goroutine 错过甚至永远失去运行时机.
runtime 包与 goroutine
runtime
包提供了许多可以使用户程序与 Go 运行时系统交互的功能, 然后是一些可以获得 goroutine 信息的 API, 为了汇总讲函数罗列于此:
runtime.GOMAXPROCS
调用该函数, 用户程序可以在运行期间设定常规运行时系统中的 P 的最大数量, 不过这会引起 stw (stop the world), 所以应该尽量早运行这个函数, 当然最好的做法就是设置环境变量 GOMAXPROCS
.
Go 运行时系统中 P 最大数量范围总是在 1 ~ 256
runtime.Goexit
调用该函数后, 会立即使当前 goroutine 的运行终止,而其他 goroutine 并不会受此影响.
runtime . Goexit
函数在终止当前 goroutine 之前,会先执行该 goroutine 中所有还未执行的 defer
语句.
该函数会把被终止的 goroutine 置于 Gdead 状态,并将其放入本地 P 的自由 G 列表,然后触发调度器的一轮调度流程.
注意,千万不要在主 goroutine 中调用 runtime .Goexit
函数,否则会触发 panic。
runtime .Gosched
该函数的作用是暂停当前 goroutine 的运行。当前 goroutine 会被置为 Grunnable 状态,并放入调度器的可运行G队列;
这也是使用“暂停”这个词的原因. 经过调度器的调度,该 goroutine 马上就会再次运行.
runtime . NumGoroutine
调用该函数后,会返回当前 Go 运行时系统中处于非 Cdead 状态的用户 G 的数量.
这些 goroutine 被视为“活跃的”或者“可被调度运行的”.
该函数的返回值总会大于等于1.
runtime . LockOSThread 和 runtime .UnlockOSThread
对前者的调用会使当前 goroutine 与当前 M 锁定在一起,而对后者的调用则会解除这样的锁.
多次调用前者不会造成任何问题,但是只有最后一次调用会生效,可以想象成对同一个变量的多次赋值. 另一.方面,即使在之前没有调用过前者,对后者的调用也不会产生任何副作用.
runtime/debug. SetMaxStack函数
该函数的功能是约束单个 goroutine 所能申请栈空间的最大尺寸.
已知,在 main
函数及 init
函数真正执行之前,主 goroutine
会对此数值进行默认设置.
250MB 和 1GB 分别是在 32 位和 64 位的计算机系统下的默认值.
该函数接收一个 int
类型的参数, 该参数是需要设定的栈空间最大字节数.
该函数在执行完毕的时候,会把之前的设定值作为结果返回.
如果运行时系统在为某个 goroutine 增加栈空间的时候,发现它的实际尺寸已经超过了设定值,就会发起一个panic
并终止程序的运行.
需要注意的是,该函数并不会像 runtime.COMAXPROCS 函数那样对传入的参数值进行检查和纠正. 所以,我们应该在调用它的时候保持足够的警惕.
尤其是,即使我们设定了一个过小的值,相关的问题也一般不会在程序的运行初期就显现出来,因为运行时系统仅在增长 goroutine 的栈空间时,才会对它的实际尺寸进行检查.
这样,错误设置就像给程序埋下了一颗定时炸弹,造成的后果也会很严重.
runtime/debug. SetMaxThreads函数
该函数的作用是对 Go 运行时系统所使用的内核线程的数量(也可以认为是 M 的数量)进行设置. 在引导程序中该数量被设置成了 10000.
这对于操作系统和 Go 程序来说,都已经是一个足够大的值了. 该函数接受一个 int
类型的值,也会返回一一个 int
类型的值. 前者代表欲设定的新值,而后者则代表之前设定的旧值.
前面说过,如果调用此函数时给定的新值比运行时系统当前正在使用的 M 的数量还要小的话,就会引发一个panic.
另一方面, 在对此函数的调用完成之后,我们设定的新值就会立即发挥作用.
每当运行时系统新建一个 M 时,就会检查它当前所持 M 的数量。如果该数量大于 M 最大数量的设定, 运行时系统就会发起一个同样的 panic
.
GC 相关的函数
runtime/debug.SetGCPercent
, runtime.GC
, runtime/debug.FreeOSMemory
.
第一个函数用于设定触发自动 GC 的条件, 后两者用于发起手动 GC.
自动 GC 在默认情况下为并发运行, 但是手动 GC 总是串行运行.
这也意味着, 后两个函数执行期间, 调度是停止的.
另外, 函数 runtime/debug.FreeOSMemory
比函数 runtime.GC
多了一个 GC 之后清扫一次堆内存的行为.