Go 中的并发调度
1、概述
go 的并发调度示意图为:
首先是 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进入调度循环,不停获取并执行任务,所以我们才能创建成千上万个并发任务。
2、连续栈
连续栈将调用堆栈所有栈帧分配在一个连续内存空间。当空间不足时,另分配2x内存块,并拷贝当前栈全部数据,以避免分段栈链表结构在函数调用频繁时可能引发的切分热点问题。
3、暂停操作
Gosched
可被用户调用的 runtime.Gosched 将当前G任务暂停,重新放回全局队列,让出当前M去执行其他任务。我们无须对G做唤醒操作,因为它总归会被某个M重新拿到,并从“断点”恢复。
gopark
与 Gosched 最大的区别在于,gopark 并没将G放回待运行队列。也就是说,必须主动恢复,否则该任务会遗失。
notesleep
相比 gosched、gopark,反应更敏捷的 notesleep既不让出M,也就不会让G重回任务队列。它直接让线程休眠直到被唤醒,更适合 stopm、goMark 这类近似自旋的场景。
Goexit
用户可调用 runtime.Goexit 立即终止G任务,不管当前处于调用堆栈的哪个层次。在终止前,它确保所有 G.defer 被执行。