M
在 runtime/proc.go
文件中可以看到函数 newm
,跟踪进去会发现其实 M
就是系统线程。
init
- 所有 init 函数都在同一个 goroutine 内执行。
- 所有 init 函数结束后才会执行 main.main 函数。
内存分配
基本策略
- 每次从操作系统申请一大块内存(比如 1MB),以减少系统调用。
- 将申请到的大块内存按照特定大小预先切分成小块,构成链表。
- 为对象分配内存时,只需从大小合适的链表提取一个小块即可。
- 回收对象内存时,将该小块内存重新归还到原链表,以便复用。
- 如闲置内存过多,则尝试归还部分内存给操作系统,降低整体开销。
内存分配器只管理内存块,并不关心对象状态。且不会主动回收内存,由垃圾回收器在完成清理操作后,触发内存分配器回收操作。
go 使用 tcmalloc 框架
三色标记法
这是让标记和用户代码并发的基本保障,基本原理:
起初所有对象都是白色。
扫描找出所有可达对象,标记为灰色,放入待处理队列。
从队列提取灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。
写屏障监视对象内存修改,重新标色或放回队列。
当完成全部扫描和标记工作后,剩余不是白色就是黑色,分别代表要待回收和活跃对象,
清理操作只需将白色对象内存收回即可。
并发调度
内置运行时,在进程和线程的基础上做更高层次的抽象是现代语言最流行的做法。虽然算不上激进,但 Golang 也设计了全新架构模型,将一切都基于并发体系之上,以适应多核时代。刻意模糊线程或协程概念,通过三种基本对象相互协作,来实现在用户空间管理和调度并发任务。
基本关系示意图:
1 | +-------------------- sysmon ---------------//------+ |
首先是 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 | -- gfree -----+ |
M 使用注意点
我们允许进程里有成千上万的并发任务 G,但最好不要有太多的 M。且不说通过系统调用创建线程本身就有很大的性能损耗,大量闲置且不被回收的线程、M 对象、g0 栈空间都是资源浪费。好在这种情形极少出现,不过还是建议在生产部署前做严格测试。下面是利用 cgo 调用 sleep syscall 来生成大量 M 的示例。
1 | // test.go |
利用 GODEBUG 输出调度器状态,你会看到大量闲置线程。
1 | $go build -o test test |
除线程数量外,程序执行时间(user, sys)也有很大差别,可以简单对比一下。
1 | func main() { |
测试:
1 | go build -o test1 test.go && time ./test1 |
标准库封装的 time.Sleep 针对 goroutine 进行了改进,并未使用 syscall。当然,这个示例和测试结果也仅用于演示,具体问题具体对待。
系统调用
为支持并发调度,专门对 syscall、cgo 进行了包装,以便在长时间阻塞时能切换执行其他任务。标准库 syscall 包里,将相关系统调用函数分为 Syscall 和 RawSyscall 两类。最大的不同在于 Syscall 增加 entrysyscall/exitsyscall,这就是允许调度的关键所在。监控线程 sysmon 对 syscall 非常重要,因为它负责将因系统调用而长时间阻塞的 P 抢回,用于执行其他任务。否则,整体性能会严重下降,甚至整个进程被冻结。
cgo 使用了相同的封装方式,因为它同样不受调度器管理。
cgocall.go :
1 |
|
抢占调用
所谓抢占调度要比你想象的简单许多,远不是你以为的 “抢占式多任务操作系统” 那种样子。因为 Golang 调度器并没有真正意义上的时间片概念,只是在目标 G 上设置一个抢占标志,当该任务调用某个函数时,被编译器安插的指令就会检查这个标志,从而决定是否暂停当前任务。
runtime.Goexit
用户可调用 runtime.Goexit 立即终止 G 任务,不管当前处于调用堆栈的哪个层次。在终止前,它确保所有 G.defer 被执行。
1 | // panic.go |
比较有趣的是在 main goroutine 里执行 Goexit,它会等待其他 goroutine 结束后才会崩溃。
chan
channel 中数据类型大小不能超过 64K。
sudog 是 channel 与 G 的桥梁,需要使用 sudog 唤醒相应的 G。
defer
延迟调用(defer) 最大优势是,即便函数执行出错,依然能保证回收资源等操作得以执行。编译器将 defer 处理成两个函数调用,deferproc 定义一个延迟调用对象,然后在函数结束前通过 deferreturn 完成最终调用。
对象析构示例
1 | package main |