Goroutine 的那些事
并发与并行
并发未必并行,“并发”指的是程序的结构,“并行”指的是程序运行时的状态
并行指物理上同时执行,并发指能够让多个任务在逻辑上交织执行的程序设计
并行
物理上的同时发生
并行(parallelism)是指同时发生的两个并发事件,具有并发的含义,而并发则不一定并行。
并行,就是同时执行的意思,无需过度解读。判断程序是否处于并行的状态,就看同一时刻是否有超过一个“工作单位”在运行就好了。所以,单线程永远无法达到并行状态。
要达到并行状态,最简单的就是利用多线程和多进程。
并发
逻辑上的并行(逻辑上的同时发生)
并发性(concurrency),又称共行性,是指能处理多个同时性活动的能力,并发事件之间不一定要同一时刻发生。
task1, task2 是两段不同的代码,比如两个函数,其中黑色块代表某段代码正在执行。注意,这里从始至终,在任何一个时间点上都只有一段代码在执行,但是,由于 task1 和 task2 在重叠的时间段内执行,所以这是一个支持并发的设计。与并行不同,单核单线程能支持并发。
来个比喻:并发和并行的区别就是一个人同时吃三个馒头和三个人同时吃三个馒头。
了解更多
golang 并发概述
- Processor(简称P)
作用类似CPU核,用于控制可同时并发执行的任务数量,每个工作线程都必须绑定一个有效P才被允许执行任务,否则只能休眠,直到有空闲的P时才被唤醒。P还为线程提供执行资源,比如对象分配内存,本地任务队列等。线程独享所绑定的P资源,可在无锁状态下执行高效操作。
- Goroutine(简称G)
基本上,线程内的一切都是以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进入调度循环,不停获取并执行任务,所以我们才能创建成千上万个并发任务
初始化
调度器初始化函数(schedinit) 除了内存分配、垃圾回收等操作外,针对自身的初始化:设置MaxMcount(最大M数量1.6wei)、GOMAXPROCS(最大P数量)。
1.5之后GOMAXPROCS由默认的1改为CPU Cores
schedinit 内需要调整P数量(procesize) , 默认也只有schedinit, 以及startTheWorld会调用procesize函数。在调度器初始化阶段,所有P对象都是新建的。除分配给主线程的外,其他都被放在空闲链表内。而startTheWorld会激活全部有本地任务的所有P对象。 在完成调度器初始化后,引导过程才创建并运行main goroutine
在运行的过程中也可以通过runtime.GOMAXPROCS函数修改P的数量,但是代价很大 ,需要STW,然后在startTheWold,并激活所有有任务的P
任务
编译器将go func 翻译成newproc调用
package main
func add(x, y int) int {
z := x + y
return z
}
func main() {
x := 0x100
y := 0x200
go add(x, y)
}
go build -o test test.go
go tool objdump -s "main\.main" test
没看懂 ............
type g struct {
stack stack // 执行栈
sched gobuf // 用于保存执行现场
goid inti64 // 唯一序号
gopc uintptr // 调用者 PC/IP
startpc uintptr // 任务函数
}
newproc 先获取第一参数地址,然后获取调用方PC/IP寄存器值 ,接着用G0栈创建G(newproc1), newproc1 负责创建G(具体过程我也看不太懂) 。首先G对象默认会复用,除去P本地的复用链表外,还有全局链表在多个P之间共享
当goroutine 执行完毕,调度器相关函数会将G对象放回P复用链表
默认使用2K栈空间,并且都被allg引用。为了垃圾回收遍历扫描需要,以便获取指针引用,收缩栈空间。
G复用方式 ,G不释放,由垃圾回收调用shrinkstack将其栈空间回收
在获取G对象后, newproc1会进行一系列初始化操作, 毕竟不管新建还是复用,这些参数都必须争取设置。同时, 相关执行参数会被拷贝到G的栈空间, 因为它和当前任务不在有任何关系,各自使用独立的栈空间。 毕竟"go func(...)" 语句仅仅创建并发任务,当前流程会继续自己的逻辑 。
创建完毕的G任务会优先放入P本地队列等待执行, 这属于无锁操作 。 如果P本地过队列满了,就会放在全局队列,因为需要加锁,所有速度比较慢
任务队列从分为三级,按优先级从高到低分别是P.runnext(优先队列) , P.runq(本地队列) , Sched.runq 有点CPU多级缓存的意思
往全局队列添加任务,需要加锁,runqputslow 慢
如果本地队列已满, 一次性转移半数到全局队列。因为其他P可能正饿着呢。这也正好解释了newproc1最后常识wakep唤醒其他M/P去执行任务的意图,重复利用多核优势
G状态切换过程
线程
当newproc1 成功创建G任务后,会尝试wakep唤醒M执行任务
与G对象复用类似, 这个过程同样闲置和新建两种方式
type m struct {
g0 *g // 提供系统栈空间
mstartfn func() // 启动函数
cury *g // 当前运行 G
p puintptr // 绑定 P
nextp puintptr // 临时存放 P
spinning bool /自旋状态 (不懂啥意思)
park note // 休眠锁
schedlink muintptr // 链表
}
M 最特别的就是自带一个名为g0,默认8KB栈内存的G对象属性。 它的栈内存地址被传给newosproc函数, 作为系统线程默堆栈空间(并非所有系统都支持)
在进程执行过程中,有两类代码需要运行。一:用户逻辑,直接使用G栈内存,二: 运行时管理指令,它并不方便直接使用用户栈上执行,因为这需要处理与用户逻辑现场有关的一大堆事务
例如, G线程可在中途暂停,放回队列后由其他M获取执行。 如不更改执行栈,那可能会造成多个线程共享内存,从而引发混乱。 另外,在执行垃圾回收操作的时候 , 如何收缩依旧被线程持有的G栈空间?为此, 当需要执行管理指令时,会将线程临时切换到g0, 与用户逻辑彻底隔离
M初始化操作会检查已有数量, 如超出最大限制(默认 10000)会导致进程崩溃。所有M被添加到allm链表,且不被释放
执行
M 执行G并发任务有两个起点:线程启动函数mstart, 还有就是stopm休眠后再度回复调度循环
准备进入工作状态的M必须绑定一个有效的P, nextp临时持有待绑定P对象。因为在未正确执行前,并不适合设置相关属性。P为M提供cache,以便为执行提供对象内存分配
一切就绪后, M进入核心调度循环,这是一个由schedule,execute,goroutine fn, goexitt 函数构成的逻辑循环。就算M在休眠后,也只是从“断点”恢复
调度函数获得可用的G后,交给execute去执行。同时,还检查环境开关来决定是否参与垃圾回收
执行结束后,清理操作,然后在此进入调度循环,
findrunnable
为了找到可以运行的G任务,findrunnable 可谓费尽心机。本地队列、全局队列、网络任务,甚至从其他P任务队列偷取。所有目的就是为了尽快的完成所有任务,充分发挥多核并行能力。
按查找流程,我们依次查看不同优先级的获取方式。首先是本地队列 , 其中P.runnext 优先级最高 。
在检查全局队列时,除了返回一个可用的G外, 还会批量转移一批到P本地队列 ,毕竟不能每次加锁去操作全局队列
通过引入P,实现了一种叫做work-stealing的调度算法:
- 每个P维护一个G队列;
- 当一个G被创建出来,或者变为可执行状态时,就把他放到P的可执行队列中;
- 当一个G执行结束时,P会从队列中把该G取出;如果此时P的队列为空,而且全局也队列也无法获取G,即没有其他G可以执行, 就随机选择另外一个P,从其可执行的G队列中偷取一半。
执行过程总结
Goroutine调度是在P中进行,每当runtime需要进行调度时,会调用schedule()函数, 该函数在proc.go文件中定义。
schedule()函数首先调用runqget()从当前P的队列中取一个可以执行的G。 如果队列为空,继续调用findrunnable()函数。findrunnable()函数会按照以下顺序来取得G:
- 调用runqget()从当前P的队列中取G(和schedule()中的调用相同);
- 调用globrunqget()从全局队列中取可执行的G;
- 调用netpoll()取异步调用结束的G,该次调用为非阻塞调用,直接返回;
- 调用runqsteal()从其他P的队列中“偷”。
如果以上四步都没能获取成功,就继续执行一些低优先级的工作:
- 如果处于垃圾回收标记阶段,就进行垃圾回收的标记工作;
- 再次调用globrunqget()从全局队列中取可执行的G;
- 再次调用netpoll()取异步调用结束的G,该次调用为阻塞调用。
如果还没有获得G,就停止当前M的执行,返回findrunnable()函数开头重新执行。 如果findrunnable()正常返回一个G,shedule()函数会调用execute()函数执行该G。 execute()函数会调用gogo()函数(在汇编源文件asm_XXX.s中定义,XXX代表系统架构),gogo() 函数会从G.sched结构中恢复出G上次被调度器暂停时的寄存器现场(SP、PC等),然后继续执行。
连续栈
实现方式也是先分配一块固定大小的栈,在栈空间不足时,分配一块更大的栈,并把旧的栈全部拷贝到新栈中。 这样避免了Split Stacks方法可能导致的频繁内存分配和释放。
系统调用
监控
- 释放闲置超过5分钟的span物理内存
- 如果超过2分钟没有垃圾回收,强制执行
- 将长时间未处理的netpoll结果添加到任务队列
- 向长时间运行到G任务发出抢占调度
- 收回因syscall长时间阻塞的P
在进入垃圾回收状态时,sysmon会自动进入休眠,所以我们才会在syscall里看到很多唤醒指令。另外,startTheWorld也会做唤醒处理。保证监控线程正常运行。对内存分配、垃圾回收和并发调度都非常重要
抢占调度
所谓抢占调度要比你想象的简单许多,远不实你以为的“抢占式多任务操作系统”那种样子。因为Golang调度器并没有真正意义的时间片概念,只是在目标G上设置一个抢占标志,当该任务调用某个函数时,被编译器安插的指令就会检查这个标志,从而决定是否暂停当前任务