M

runtime/proc.go 文件中可以看到函数 newm,跟踪进去会发现其实 M 就是系统线程。

init

  • 所有 init 函数都在同一个 goroutine 内执行。
  • 所有 init 函数结束后才会执行 main.main 函数。

内存分配

基本策略

  • 每次从操作系统申请一大块内存(比如 1MB),以减少系统调用。
  • 将申请到的大块内存按照特定大小预先切分成小块,构成链表。
  • 为对象分配内存时,只需从大小合适的链表提取一个小块即可。
  • 回收对象内存时,将该小块内存重新归还到原链表,以便复用。
  • 如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销。

内存分配器只管理内存块,并不关心对象状态。且不会主动回收内存,由垃圾回收器在完成清理操作后,触发内存分配器回收操作。
go 使用 tcmalloc 框架

三色标记法

这是让标记和用户代码并发的基本保障,基本原理:

起初所有对象都是白色。
扫描找出所有可达对象,标记为灰色,放入待处理队列。
从队列提取灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。
写屏障监视对象内存修改,重新标色或放回队列。

当完成全部扫描和标记工作后,剩余不是白色就是黑色,分别代表要待回收和活跃对象,
清理操作只需将白色对象内存收回即可。

并发调度

内置运行时,在进程和线程的基础上做更高层次的抽象是现代语言最流行的做法。虽然算不上激进,但 Golang 也设计了全新架构模型,将一切都基于并发体系之上,以适应多核时代。刻意模糊线程或协程概念,通过三种基本对象相互协作,来实现在用户空间管理和调度并发任务。

基本关系示意图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                            +-------------------- sysmon ---------------//------+
| |
| |
+---+ +---+-------+ +--------+ +---+---+
go func() ---> | G | ---> | P | local | <=== balance ===> | global | <--//--- | P | M |
+---+ +---+-------+ +--------+ +---+---+
| | |
| +---+ | |
+----> | M | <--- findrunnable -----+---steal<--//--+
+---+
|
| 1. 语句 go func() 创建 G
+---- execute <----schedule 2. 放入 P 本地队列(或平衡到全局队列)
| | 3. 唤醒或新建 M 执行任务
| | 4. 进入调度循环 schedule
+---> G.fn --> goexit--+ 5. 竭力获取待执行 G 任务并执行
6. 清理现场,重新进入调度循环

首先是 Processor(简称 P),其作用类似 CPU 核,用来控制可同时并发执行的任务数量。每个工作线程都必须绑定一个有效 P 才被允许执行任务,否则只能休眠,直到有空闲 P 时被唤醒。P 还为线程提供执行资源,比如对象分配内存、本地任务队列等。线程独享所绑定的 P 资源,可在无锁状态下执行高效操作。
基本上,进程内的一切都在以 goroutine(简称 G)方式运行,包括运行时相关服务,以及main.main 入又函数。需要指出,G 并非执行体,它仅仅保存并发任务状态,为任务执行提供所需栈内存空间。G 任务创建后被放置在 P 本地队列或全局队列,等待工作线程调度执行。

实际执行体是系统线程(简称 M),它和 P 绑定,以调度循环方式不停执行 G 并发任务。M 通过修改寄存器,将执行栈指向 G 自带栈内存,并在此空间内分配堆栈帧,执行任务函数。当需要中途切换时,只要将相关寄存器值保存回 G 空间即可维持状态,任何 M 都可据此恢复执行。线程仅负责执行,不再持有状态,这是并发任务跨线程调度,实现多路复用的根本所在。
尽管 P/M 构成执行组合体,但两者数量并非一一对应。通常情况下,P 数量相对恒定,默认与 CPU 核数量相同,但也可能更多或更少,而 M 则是调度器按需创建。举例来说,当M 因陷入系统调用而长时间阻塞时,P 就会被监控线程抢回,去新建(或唤醒)一个 M 执行其他任务,如此 M 的数量就会增长。
因为 G 初始栈仅有 2KB,且创建操作只是在用户空间简单的对象分配,远比进入内核态分配线程要简单得多。调度器让多个 M 进入调度循环,不停获取并执行任务,所以我们才能创建成千上万个并发任务。

G 状态转换

1
2
3
4
-- gfree -----+
|
--> IDLE --> DEAD ------> RUNNABLE ---> RUNNING ---> DEAD --- ... --> gfree -->
新建 初始化前 初始化后 调度执行 执行完毕

M 使用注意点

我们允许进程里有成千上万的并发任务 G,但最好不要有太多的 M。且不说通过系统调用创建线程本身就有很大的性能损耗,大量闲置且不被回收的线程、M 对象、g0 栈空间都是资源浪费。好在这种情形极少出现,不过还是建议在生产部署前做严格测试。下面是利用 cgo 调用 sleep syscall 来生成大量 M 的示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// test.go
package main
import (
"sync"
"time"
)
// #include <unistd.h>
import "C"
func main() {
var wg sync.WaitGroup
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
C.sleep(1)
wg.Done()
}()
}
wg.Wait()
println("done!")
time.Sleep(time.Second * 5)
}

利用 GODEBUG 输出调度器状态,你会看到大量闲置线程。

1
2
3
4
5
6
7
8
9
10
$go build -o test test
$ GODEBUG="schedtrace=1000" ./test
SCHED 0ms: gomaxprocs=2 idleprocs=1 threads=3 spinningthreads=0 idlethreads=0 runqueue=0 [0 0]
SCHED 1006ms: gomaxprocs=2 idleprocs=0 threads=728 spinningthreads=0 idlethreads=0 runqueue=125 [113 33]
SCHED 2009ms: gomaxprocs=2 idleprocs=2 threads=858 spinningthreads=0 idlethreads=590 runqueue=0 [0 0]
done!
SCHED 3019ms: gomaxprocs=2 idleprocs=2 threads=858 spinningthreads=0 idlethreads=855 runqueue=0 [0 0]
SCHED 4029ms: gomaxprocs=2 idleprocs=2 threads=858 spinningthreads=0 idlethreads=855 runqueue=0 [0 0]
SCHED 5038ms: gomaxprocs=2 idleprocs=2 threads=858 spinningthreads=0 idlethreads=855 runqueue=0 [0 0]
SCHED 6048ms: gomaxprocs=2 idleprocs=2 threads=858 spinningthreads=0 idlethreads=855 runqueue=0 [0 0]

除线程数量外,程序执行时间(user, sys)也有很大差别,可以简单对比一下。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var wg sync.WaitGroup
wg.Add(1000)
for i := 0; i < 1000; i++ {
go func() {
C.sleep(1) //1
// time.Sleep(time.Second) //
wg.Done()
}()
}
wg.Wait()
}

测试:

1
2
3
4
5
6
7
8
$ go build -o test1 test.go && time ./test1
real 0m1.159s
user 0m0.056s
sys 0m0.105s
$ go build -o test2 test.go && time ./test2
real 0m1.022s
user 0m0.006s
sys 0m0.006s

标准库封装的 time.Sleep 针对 goroutine 进行了改进,并未使用 syscall。当然,这个示例和测试结果也仅用于演示,具体问题具体对待。

系统调用

为支持并发调度,专门对 syscall、cgo 进行了包装,以便在长时间阻塞时能切换执行其他任务。标准库 syscall 包里,将相关系统调用函数分为 Syscall 和 RawSyscall 两类。最大的不同在于 Syscall 增加 entrysyscall/exitsyscall,这就是允许调度的关键所在。监控线程 sysmon 对 syscall 非常重要,因为它负责将因系统调用而长时间阻塞的 P 抢回,用于执行其他任务。否则,整体性能会严重下降,甚至整个进程被冻结。
cgo 使用了相同的封装方式,因为它同样不受调度器管理。
cgocall.go :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

func cgocall(fn, arg unsafe.Pointer) int32 {
/*
* Announce we are entering a system call
* so that the scheduler knows to create another M to run goroutines while we are in
* the foreign code.
*
* The call to asmcgocall is guaranteed not to split the stack and does not allocate
* memory, so it is safe to call while "in a system call", outside the $GOMAXPROCS
* accounting.
*/
entersyscall(0)
errno := asmcgocall(fn, arg)
exitsyscall(0)
}

抢占调用

所谓抢占调度要比你想象的简单许多,远不是你以为的 “抢占式多任务操作系统” 那种样子。因为 Golang 调度器并没有真正意义上的时间片概念,只是在目标 G 上设置一个抢占标志,当该任务调用某个函数时,被编译器安插的指令就会检查这个标志,从而决定是否暂停当前任务。

runtime.Goexit

用户可调用 runtime.Goexit 立即终止 G 任务,不管当前处于调用堆栈的哪个层次。在终止前,它确保所有 G.defer 被执行。

1
2
3
4
5
6
7
8
9
10
// panic.go
func Goexit() {
gp := getg()
for {
d := gp._defer
...
freedefer(d)
}
goexit1()
}

比较有趣的是在 main goroutine 里执行 Goexit,它会等待其他 goroutine 结束后才会崩溃。

chan

channel 中数据类型大小不能超过 64K。
sudog 是 channel 与 G 的桥梁,需要使用 sudog 唤醒相应的 G。

defer

延迟调用(defer) 最大优势是,即便函数执行出错,依然能保证回收资源等操作得以执行。编译器将 defer 处理成两个函数调用,deferproc 定义一个延迟调用对象,然后在函数结束前通过 deferreturn 完成最终调用。

对象析构示例

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

import (
"runtime"
"time"
)

func main() {
x := 123
runtime.SetFinalizer(&x, func(x *int) {
println(x, *x, "finalizer.")
})
runtime.GC()
time.Sleep(time.Minute)
}

sync.Pool