1. Goroutine
1.1 进程, 线程和协程和 goroutine
- 进程,线程: 内核态调度,抢占式 CPU 调度,
- 协程: 用户态调度, 协作式 CPU 调度,需要协程主动让出 CPU 控制权.
goroutine 本质上就是协程, 不过 golang 对 goroutine 调度进行了封装和处理,当遇到长时间或者进行系统调用时,会主动让出当前 goroutine 的 CPU 控制权,让其他 goroutine 被调度.
总结:
goroutine | 线程 | |
---|---|---|
栈大小 | 2KB | 8MB |
调度 | 用户态调度 | 内核态调度 |
CPU | 协作式 | 抢占式 |
1.2 实现原理
一般情况网络服务器会有大量的连接需要处理,且总是大量的 I/O + 少量的计算,一个连接建立一个线程会消耗大量系统资源(1. 每个线程栈大小为 8MB, 2.频繁的 I/O 会阻塞当前线程使别的线程被调度,线程切换会带来大量的开销),所以这样不可行.而使用基于事件模式的异步编程模型(select, poll, epoll)可以使用少量的线程来服务大量的网络连接和 I/O 操作,但是需要开发人员手动管理每个连接的上下文,大幅度提高程序的复杂度.
goroutine是在应用层实现的类似于线程的编程模型,程序员可以像编写多线程代码一样编写协程代码,但协程运行时却和异步编程模型相同.
golang 对各种 I/O函数进行了封装,其内部调用了操作系统的异步 I/O 函数,当这些异步函数返回 busy 或者 blocking 时,golang 就将现有的执行序列保存,让程序去拉另外一个goroutine(就绪的)的代码来执行,当 I/O就绪时(基于epoll,select 等)继续在重新调度刚才的goroutine.
1.3 goroutine 调度
- 协作式调度即非抢占式,程序的切换方式取决于程序本身
- 抢占式调度: 程序的切换由操作系统(processor)控制
Go1.14 之前 Golang 为协作式的,在一下情况会进行 goroutine 切换
- 由于系统调用而等待
- 等待读取或写入未缓冲的通道
- 由于 time.Sleep() 而等待
- mutex, select, waitGroup等
此外Go 会启动一个 sysmon 协程,该函数实现了抢占式调度的功能.sysmon
运行在 M,切不需要 P.
当sysmon
发现 M 已经运行一个 G 10ms 以上时, 他会将该 G 的内部参数preempt
设置为true
,当该 G 进行函数调用时, G 会检查自己的 preempt 标志,如果为true
, 则它将自己与 M 分离并推入"全局队列". 现在,抢占就成功完成.但是foo {}
仅是一个死循环,其并不会发生抢占.
1.3.1 异步抢占
在 Go1.14 中引入 非协作式抢占(异步抢占),是一种使用信号的简单有效的算法.
首先, sysmon
仍然会检测到运行了 10 ms 以上的 G.然后 sysmon
向运行 G 的 P 发送信号(SIGURG
). Go 的信号处理程序会调用 P 上的 gsignal
的 goroutine
来处理该信号,将其映射到 M 而不是 G,并使其检查该信号. gsignal 看到抢占信号,停止正在运行的 G.使用GODEBUG=asyncpreemptoff=1
可以禁止异步抢占.
全局队列是与“本地队列”不同的队列,本地队列是存储 P 具有的 G。全局队列有以下几个作用。
存储那些超过本地队列容量(256)的 G
存储由于各种原因而等待的 G
存储由抢占标志分离的 G
1.4 GPM
- G goroutine go 协程
- P processor 执行器
- M machine 工作线程
1.4.1 M 工作线程
主线程 M0
运行 runtime.main, 单个实例,
sysmon 线程
创建了新的 m, 这个 m 不需要绑定 p 就可以执行. 与整个调度系统脱离.
- checkdead, 检查是否所有 goroutine 都已经 锁死, 如果锁死直接调用 runtime.throw,强制退出. 这个操作只在启动的时候做一次
- 将 netpoll 返回的结果注入到全局 sched 的任务队列
- 收回因为 syscall 而长时间阻塞的 p, 同时抢占哪些执行时间过长的 g
- 如果 span 内存闲置超过 5min,那么释放掉
普通线程
就是 GPM 模型里的 M, M 对应的就是操作系统的线程.
M 会从绑定的 p 的本地队列、sched 中的全局队列、netpoll 中获取可运行的 g,实在找不着还会去其它的 p 那里去偷。
2. map
golang 的 map 是 hashmap(c++ stl 是红黑树)
- 拉链法解决冲突,每个 bucket 可以存储 8 对 key/value
- 会预先分配一些 bucket 用于溢出
- 使用高位 hash 与运算来选择桶
- 使用渐进式 hash 扩容hash 表
- count/(2B) > 6.5 即翻倍扩容
- 当 overflow >= 2^B 是触发等量扩容
3. 内存分配
3.1 内存分配核心思想
- 每次从操作系统申请一大块内存(并不实际分配)
- 采用 TMalloc 算法, 将内存对象切分为多级,以降低锁粒度
- 回收对象内存时,并没有将其真正释放掉,只是放回预先分配的大块内存中,以便复用.
3.2 内存结构
spans | bitmap | arena |
---|---|---|
512MB | 16GB | 512GB |
存放spans指针,每个 spans 指向page | 保存 arena 对应的某个地址是否存在对象,以及对象是否被 gc 扫描过 | 有 8kb 的 page 组成 |
3.2.1 arena
arena 即我们通常意义上的heap, 由连续的 page 组成, 多个 page 组成spans,spans 用于具体分配内存,对象分配时会选择大小相近的 span,即一种大小的 span 只给一种确定大小的对象分配,这样来减少内存碎片的产生.
3.2.2 bitmap
bitmap 即位图,一个 byte 标记 4 个对象,分别标记对应地址是否有存在对象和此对象是否被 gc 标记过.
3.2.3
参考资料
https://xargin.com/go-scheduler/
https://blog.csdn.net/lsj1342/article/details/119797966