线程

内核态,操作系统的最小调度单元,创建销毁,调度都需要由内核来完成,可以充分利用多核,实现并行

协程

  • 用户态,是线程的子集,在线程的基础上,对线程进行二次加工得到,从属于某一个线程,与线程存在映射关系比例是m : 1。
  • 某一个协程陷入阻塞的话,这个阻塞会上升到整个线程

对操作系统而言,协程对它是透明的,它只能看到线程阻塞了
协程没法做到真正意义上的并行,只是在用户视角下的并发

goruntine

  • golang对协程的优化,在goruntine和线程之间加了一个中间层,调度器,让协程不在依附于线程,实现动态组合关联,与线程的映射关系为M:N

  • 继承了协程的优点,创建、销毁、调度都由用户态来完成,无需内核介入,更轻量

  • 可以做到真正意义上的并行

  • 某一个陷入阻塞的时候,会被调度器感知到,调度器会实现goruntine与线程的解绑释放资源
    为什么协程无法做到真正意义上的并行,而goruntine可以做到

  • 普通的协程是N:1关系,操作系统只看得到这一个线程,开了1000个协程,也只分配一个cpu核心的时间片,同一时刻只有一个协程在运行,属于并发但不是并行

  • go程序在启动的时候,会根据cpu核心数创建多个 P (Processor,逻辑处理器)。
    每个 P 都会绑定一个系统线程 M (Machine)。
    关键点:如果有 8 个 CPU 核心,Go 就会创建 8 个系统线程同时工作。调度器会把成千上万个 Goroutine 分发给这 8 个线程。
    结果:物理上确实有 8 个 Goroutine 在同一纳秒内同时运行

GMP

G:goruntine M:machine(内核视角下的线程) P:processor
对g而言,p相当于它的cpu,对m而言,p相当于它的执行代理,m要和p结合之后才能执行调度goruntine
对于每一个p而言,它会先尝试从自己的本地队列获取gorunine,每个p私有队列的话,可以设计到更少的并发,接近于无锁化,如果私有队列没有了,就去全局队列获取。如果本地队列以及全局队列都没有的话,就会尝试从其他的P的私有队列窃取,这涉及到并发问题,所以还是要加锁,所以说是接近无锁化

核心数据结构

G

1
2
3
4
5
6
type g struct {
m *m0,
///...
sched buf,
///...
}

m0

m0是Go程序创建的第一个系统线程,

  • 唯一性:在整个 Go 进程的生命周期中,m0 是全局唯一的(它是定义在全局变量中的)。
  • 静态分配:普通的 M 是在需要时动态创建(malloc)的,而 m0 是在程序启动之初就定义好的全局变量,不需要动态分配内存
    作用:
  • 负责Go程序的启动流程,调度器初始化,内存管理器初始化,垃圾回收器等,负责启动第一个goruntine执行main.main函数,之后参与正常的调度循环,与其他的m没区别。
  • 在程序退出时还负责处理清理工作,比如等待其他goroutine结束、执行defer函数等

goruntine的生命周期

1
2
3
4
5
						  __ _Gsyscall  __
/ \
_Gidle --> _Gdead --> _GRunnable --> _GRunning --> _Gdead
\ /
-- _Gwaiting --

_ Gsyscall: 系统调用
_ Gwaiting:等待中,G被阻塞了(channel读写, select,mutex),此时的g不在队列中,必须被唤醒才能到grunnable

1
2
3
4
5
6
7
8
9
10
type m struct {
g0 *g,//不执行函数,做其他的g的调度
}
type p struct {
runhead uint32,
runtail uint32,
runq [256]guintptr,
runnext guintptr,
///...
}

g0

m0是全局唯一的,但是g0不是,每一个M(线程)都有一个属于自己的 g0
g0不执行用户的代码,负责执行调度逻辑,包括决定下一个运行哪个g,创建新g,
g0有较大的栈8MB,普通的g只有2kb,因此,当g要进行以下操作时,必须先切换到g0:
寻找下一个g,调用newproc创建新的g,用户栈溢出时进行扩容,垃圾回收

g0与g的转换:g0 -> g: gogo() ,
g -> g0 :mcall(),systemstack()

调度类型

主动调度

runtime.Gosched():用户显式调用,G 的状态从 _Grunning 变为 _Grunnable,然后被放入全局队列。注意,主动放弃的 G 通常不放回本地队列,而是放全局,避免它立刻又被拿出来运行

被动调度

当 G 执行某些阻塞操作时,运行时会调用 gopark 把当前 G 的状态从 _Grunning 改为 _Gwaiting,并将其从 M 上移除。M 此时变为空闲,可以去执行队列里的下一个 G

正常调度

goruntine的代码逻辑正常执行完了,最后进行正常的销毁,调用m_call把执行权给到g0

抢占调度

某一个goruntine执行了太久时间,被全局监控者monitor g监控到,被强制和p解绑

宏观调度视角

schedule():调用findRunnable(),找到下一个要被执行的goruntine

1
2
3
4
5
func schedule() {
gp, inheritTime, tryMakeP := findRunnable()
//...
execute(gp, inheritTime)
}

findRunnable找m的顺序

  1. 插队的,看看P的runnext字段有没有G,(每经历61次调度之后,需要优先处理一次全局队列防止饥饿)
  2. 看看P的runq本地队列里面有没有g,(CAS无锁,访问比较快)
  3. 去全局队列里面找,需要加锁
  4. 检查网络轮询器,看看有没有 G 是因为网络 I/O 阻塞的,现在已经准备好了(Ready)。如果有,把它变成 _Grunnable 拿过来
  5. 本地/全局/网络都没有,就去窃取别的P的本地私有队列,偷一半
  6. 还是没有找到,进入休眠状态,需要唤醒