三种多线程模型

操作系统的线程由内核的线程调度器负责调度,不同的编程语言对内核线程的封装不同,有以下三个模型:

  • N:1模型: N个用户空间线程运行在1个内核空间线程上。优点是上下文切换快,缺点是无法有效利用多核特性。 在该模型下,线程管理由用户空间的线程库进行,因此上下文切换效率高,但如果某个用户线程执行阻塞系统调用(syscall),其他用户线程也将会阻塞。因为任一时间只能有一个内核空间线程访问内核,所以多个用户线程也就无法并行运行在多核系统上。 由于无法有效利用系统多核特性,所以现在几乎没有编程语言使用这个模型。
  • 1:1模型: 1个用户空间线程运行在1个内核空间线程上。每次调度都要在用户态和内核态之间切换,因此该模型的缺点是上下文切换很慢,而且每创建一个用户线程就需要创建一个内核线程,因此可以创建的用户线程数量也会受限制。 在该模型下,一个用户线程执行阻塞系统调用时,能够允许其他用户线程继续执行,多个用户线程也可以并行的运行在多核系统上。使用此模型的编程语言有Java。
  • M:N模型: 多个用户空间线程运行在多个内核空间线程上。M:N模型综合了N:11:1模型的优点,多路复用多个用户空间线程到一定数量的内核空间线程。这种模型的缺点是调度实现起来十分复杂。Go采用的这种模型,用户空间线程在Go中对应的是Goroutine。

Goroutine调度器和G-M-P模型

在Go中用户线程的调度和生命周期管理都是用户层面的,由Go语言自己实现,不依赖OS系统调用,减少系统资源消耗。 Go实现了一个自己的运行时调度器用于调度Goroutine(轻量级的用户空间线程)。

由于创建的内核线程的代价比较高,在M:N模型中,将M个Goroutine映射到N个内核线程上去执行。Goroutine在用户空间创建起来代价很小,因此可以在一个进程中创建几十万甚至上百万的Gorouine。

runtime/proc.go中是Go调度器的源码。Go的调度器经过从初始版本开始的多个版本的发展,从Go 1.1开始形成了目前的G-M-P模型。

  • G - Goroutine 轻量级的用户线程,表示一个待执行的任务
  • M - worker thread, or machine, 内核线程(操作系统线程),
  • P - processor, 逻辑处理器,可以理解为内核线程上运行的本地调度器 (P的数量是固定的,默认是系统逻辑CPU数量)

Go在运行时会根据系统的逻辑CPU数量创建固定数量的逻辑处理器P,每个Goroutine G将会在分配了逻辑处理器P内核线程M上运行。

一图胜千言:

go-scheduler.png

Goroutine调度器维护了两种不同类型的运行队列:

  • globrunq: 全局运行队列,尚未分配给P的G
  • runq: 本地运行队列,每个P都有一个本地运行队列,用来管理分配给P执行的G

关于Goroutine的状态在runtime/runtime2.go中以常量形式定义。 Gorutine状态的作用除了指示状态外还类似Goroutine栈上的锁,因此它能够执行用户代码。

  • _Gidle(闲置) 表示G已经分配,但还未被初始化
  • _Grunnable(就绪) 表示G已经在一个runq(全局或本地运行队列)中,但当前还未拥有栈,还未运行用户代码
  • _Grunning(正在运行) 表示G正在运行用户代码,G有自己的栈。G不在runq中,且被分配给M和P
  • _Gsyscall 表示G正在执行一个系统调用,而没有在执行用户代码。G拥有栈,G不在runq中,G被分配给M
  • _Gwaiting 表示G被运行时阻塞,而没有在执行用户代码,G不在runq中,但应该被记录在某个地方(例如一个channel的等待队列)
  • _Gdead 表示G当前没有被使用,它可能它可能是刚刚退出,处于空闲列表中,或者刚刚初始化。
  • _Gcopystack 表示G的栈正在被移动,G没有执行用户代码,G也不在runq中。对应栈的扩容或gc收缩

关于栈,系统线程初始栈为8MB(Linux);Go中每个goroutine的栈采用了动态扩容的方式,初始为2KB,按需扩容最大为1GB,但扩容是有代价的,数据被copy到新的栈中,所以初始的2KB可能会有性能问题,另外在GC时会收缩栈空间。

Goroutine调度器根据事件进行上下文切换:

  • go关键字创建一个新的Goroutine,需要给它找一个P去执行
  • GC垃圾回收, 因为GC也是Goroutine,运行时也需要P
  • 系统调用(syscall),阻塞当前的G
  • sync包内同步机制会阻塞当前G

Goroutine调度的终极目标就是防止内核线程M的阻塞、空闲以及系统级别的进程切换(被OS挂起syscall),白话就是看着M让M给我好好干活,榨干M。 从下面三个场景中理解一下:

  • 异步调用:
    1. G1正在M1上执行,P1的本地运行队列中还有其他的G
    2. G1要进行网络IO,Linux下通过epoll实现,这是异步的,表示M1不需要阻塞可以继续执行。所以此时G1将被移走并放到netpoller(网络轮询器)。 此时从P1的本地运行队列runq中取出其他的G如G1,M1继续执行
    3. 当G1的网络IO结束时, G1会被重新放回P1的本地运行队列中,等待被取出执行
  • 同步调用:
    1. G1正在M1上执行,P1的本地运行队列中还有其他的G
    2. G1要进行同步调用,需要阻塞M
    3. 此时Go调度器只能将M1与P1分离,这样M1下就只有G1, 而没有P1
    4. 接下来找到空闲的M2,将P1和M2绑定,并从P1的本地运行队列中取出G2在M2上继续执行
    5. G1同步阻塞结束后被一会runq,M1变成空闲备用
  • 任务窃取(防止M闲置):
    1. P1和P2关联M1和M2
    2. P1的runq中的G都执行完了,此时P1就会开始任务窃取
    3. 如果P2的runq中还有G,则P1会从P2的runq中窃取一半的G
    4. 如果没有了,则P1会从全局运行队列globrunq中窃取G

参考