go协程是如何启动和退出的

注:该文章基于Go 1.14

在Go语言中,go协程是携带当前运行程序信息(诸如栈、程序计数器、或其当前OS线程)的结构体。Go调度器依据这些信息给其分配运行时间。调度器也需要关注这些协程的开始与退出,这两个阶段需要非常细致的管理。

关于栈和程序计数器,推荐阅读作者的另一篇文章Go: What Does a Goroutine Switch Actually Involve?

协程启动

启动协程非常简单。用代码举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "sync"

func main() {
var wg sync.WaitGroup
wg.Add(1)

go func(){
println("goroutine is running...")
wg.Done()
}()

println("main is running...")

wg.Wait()
}

main程序在打印之前启动了一个协程。由于Go协程有其自己的运行时间,Go通知运行时(runtime)创建一个新的协程即:

  • 创建stack
  • 收集当前程序的计数器或调用方的数据。
  • 更新协程的内部数据,如ID状态

但协程并不会立即获得运行时间。新创建的协程会入队到本地队列列首,并在调度器的下一轮调度时执行。下图是该状态的图示:

将其放置在队首使得该协程在当前协程结束后会首先被执行。该协程要么在当前线程被执行,要么在其他线程执行(若发生了线程窃取)。

协程创建过程在汇编代码中也能找到:

一旦协程被创建且入队后,就继续执行main函数的后续代码。

协程退出

一个协程终止时,为避免浪费CPU时间,Go必须调度其他协程继续工作。也会保留这个协程以便之后重用

Go需要一种方式来感知到协程的退出。该控制方法在协程创建时已确定。当协程创建时,在将程序计数器指向真实被调函数前,Go在栈中设置了goexit函数。则巧妙的使得协程终止工作时一定会调用goexit函数。下面的代码可以观察这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"runtime"
"sync"
)

func main() {
var wg sync.WaitGroup
wg.Add(1)

go func(){
var skip int

for {
_, file, line, ok := runtime.Caller(skip)
if !ok {
break
}
fmt.Printf("%s:%d\n", file, line)
skip++
}
wg.Done()
}()

println("main is running...")

wg.Wait()
}

输出结果为其调用栈信息:

1
2
/app/straysh/docs/translation/1.go:17
/home/uos/.gvm/gos/go1.14/src/runtime/asm_amd64.s:1373

src/runtime/asm_amd64.s的代码如下

然后Go会切换到go去调度另一个协程。

也可以通过手动调用runtime.Goexit()来结束一个协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"runtime"
"sync"
)

func main() {
var wg sync.WaitGroup
wg.Add(1)

go func(){
defer wg.Done()
runtime.Goexit()
println("never executed")
}()

println("main is running...")

wg.Wait()
}

该函数会首先执行defer函数,然后继续执行调用该协程前的函数。