一.协程的引入
1.通过案例文章引入并发,协程概念
2.内核线程态和用户态
(1).线程切换方面
1).内核态线程切换
2).用户态线程切换
实现用户态线程的方式
typedef struct ucontext
{
unsigned long int uc_flags;
struct ucontext *uc_link;
stack_t uc_stack;
mcontext_t uc_mcontext;
__sigset_t uc_sigmask;
} ucontext_t;
- 用户态线程运行时各寄存器的值,其中就包括eip和esp,eip指向代码运行到何处,esp指向栈指针指向何处
- 用户态线程使用的栈信息,当某个进程中有多个用户态线程时,各线程使用独立的栈,以使彼此互不影响
int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);
void makecontext(ucontext_t *ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t *oucp, ucontext_t *ucp);
- getcontext用于获取当前的用户上下文,并保存进ucp中
- setcontext用于将ucp设置为当前上下文
- makecontext用于修改getcontext获得的用户上下文ucp
- swapcontext将当前的用户上下文保存到oucp,将ucp指向的用户上下文设置为当前上下文
(2).竞态方面
(3).调度器
二.协程讲解
1.协程概念
2.GMP 模型
(1).概念
- G:
- 代表Go协程Goroutine,包含自己的执行栈信息、状态、任务函数、程序计数器等信息
- G的数量无限制,理论上只受内存的影响,创建一个G的初始栈大小为2-4K,配置一般的机器也能简简单单开启数十万个 Goroutine ,而且Go语言在 G 退出的时候还会把 G 清理之后放到 P 本地或者全局的闲置列表 gFree 中以便复用
- M:
- Machine,对操作系统线程(OS thread)的封装,代表操作系统内核级线程
- G中的代码就是在M上运行的
- 一个 M 对应一个线程
- 想要在CPU上执行代码必须有线程,通过系统调用 clone 创建,M在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复
- M 并不保留 G 状态,这是 G 可以跨 M 调度的基础,M的数量有限制,默认数量限制是 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略
- 可以通过 debug.SetMaxThreads() 方法进行设置,如果有M空闲,那么就会回收或者睡眠
- P:
- Processor,指虚拟处理器(调度器),M执行G所需要的资源和上下文,主要用途是用来执行 Goroutine,维护一个 Goroutine 队列,同时还有一个全局队列,它是一个联通M与G的桥梁
- 每一个运行的 M 都必须绑定一个 P,就像线程必须在一个 cpu 核上执行一样,这样才能让 P 的 runq 中的 G 真正运行起来
- P的数量决定了系统内最大可并行的G的数量,P的数量受本机的CPU核数影响,可通过环境变量GOMAXPROCS或在runtime.GOMAXPROCS()来设置,默认为CPU核心数
- 线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列,M运行G,G执行之后,M会从P获取下一个G,不断重复下去
- Sched:代表调度器,维护 M 和 G 的全局队列和状态信息
(2).调度流程
(3).核心代码分析
//src/runtime/runtime2.go
type g struct {
goid int64 // 唯一的goroutine的ID
sched gobuf // goroutine切换时,用于保存g的上下文
stack stack // 栈
atomicstatus
gopc // pc of go statement that created this goroutine
startpc uintptr // pc of goroutine function
...
}
type p struct {
lock mutex
id int32
status uint32 // one of pidle/prunning/...
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32 // 本地队列队头
runqtail uint32 // 本地队列队尾
runq [256]guintptr // 本地队列,大小256的数组,数组往往会被都读入到缓存中,对缓存友好,效率较高
runnext guintptr // 下一个优先执行的goroutine(一定是最后生产出来的),为了实现局部性原理,runnext中的G永远会被最先调度执行
...
}
type m struct {
g0 *g // 一类特殊的调度协程,不用于执行用户函数,负责执行 g 之间的切换调度. 与 m 的关系为 1:1;每个M都有一个自己的G0,不指向任何可执行的函数,在调度或系统调用时,M会切换到G0,使用G0的栈空间来调度
curg *g // 当前正在执行的G
mOS
...
}
type schedt struct {
...
runq gQueue // 全局队列,链表(长度无限制)
runqsize int32 // 全局队列长度
...
}
const(
_Gidle = itoa // 0 为协程开始创建时的状态,此时尚未初始化完成;
_Grunnable // 1 协程在待执行队列中,等待被执行;
_Grunning // 2 协程正在执行,同一时刻一个 p 中只有一个 g 处于此状态;
_Gsyscall // 3 协程正在执行系统调用;
_Gwaiting // 4 协程处于挂起态,需要等待被唤醒. gc、channel 通信或者锁操作时经常会进入这种状态;
_Gdead // 6 协程刚初始化完成或者已经被销毁,会处于此状态
_Gcopystack // 8 协程正在栈扩容流程中;
_Gpreempted // 9 协程被抢占后的状态
)
type schedt struct {
// ...
lock mutex
// ...
runq gQueue
runqsize int32
// ...
}
(4).调度器
抢占式调度
- M 注册SIGURG信号(该信号其他地方用的很少)的处理函数sighandler
- GC工作(GC工作意味着某些线程停了),然后sysmon启动后会间隔性的进行监控,最长间隔10ms,最短间隔20us,如果发现某协程独占P超过10ms,会给M发送抢占信号
- M 收到信号后,内核执行sighandler函数把当前协程的状态从_Grunning正在执行改成 _Grunnable可执行,把抢占的协程放到全局队列里,M继续寻找其他goroutine来运行
- 被抢占的G再次调度过来执行时,会继续原来的执行流
- 抢占分为
_Prunning
和_Psyscall
_Psyscall
抢占通常是由于阻塞性系统调用引起的,比如磁盘io、cgo_Prunning
抢占通常是由于一些类似死循环的计算逻辑引起的
调度器的设计思想
调度流程
调度器生命周期
package main
import "fmt"
func main() {
fmt.Println("Hello world")
}
Go调度器调度场景过程全解析
场景 1
场景 2
场景 3
场景 4
场景 5
场景 6
场景 7
场景 8
场景 9
场景 10
场景 11