程序员通常提到的线程都是用户线程,而操作系统是不知道用户线程这个东西的,操作系统跑的是内核线程,那么这两者之间就必须要有一种联系。所以会有以下几种常见的线程模型

一、线程模型

一对一线程模型

一对一线程模型,也叫内核级线程模型。一个内核线程对应一个用户线程,内核负责每个线程的调度。

实现简单,但是线程的创建、删除和切换的代价都由 CPU 完成,代价较高。

Java用的就是这种模型。

多对一线程模型

多对一线程模型,又叫做用户级线程模型,即多个用户线程对应到同一个内核线程上,线程的生命周期由用户的线程库来处理。这种模型是在一对一线程模型的基础上做的优化。

优点:协程的切换、调度都是在用户态完成的,不需要用户态和内核态的频繁切换,比较轻量

缺点:由于多个用户线程对应到同一个内核线程,如果其中一个用户线程阻塞,那么该其他用户线程也无法执行;

内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等;

Python中的gevent用的就是这种模型。

多对多线程模型

多对多线程模型,又叫两级线程模型。

这种模型是以上两种模型的结合,实现起来也最复杂。

Golang就是这种模型的代表。

二、go语言的协程goroutine

goroutine 是 golang 能在语言层面实现高并发的重要原因。goroutine 是 go 中的微线程,也可以理解为协程,让一组可复用的函数运行在一组线程之上,它非常轻量,一个goroutine只占了几KB,这样go能在有限的内存空间中支持大量的goroutine,支持更多的并发。而一个系统线程一般需要1-8MB。并且调度也是有GMP实现的,对开发人员来说是无感知的。

三、被抛弃的GM模型

GM调度模型

  • G:Goroutine,每次执行go func()就代表一个G,数量无限制,但是受内存影响。使用 runtime.g,包含当前goroutine的堆栈、状态、上下文。
  • M:Machine,每个Machine对应一个内核线程(OS thread),使用 runtime.m,所有的M都是线程栈的。默认数量限制是10000,可以通过debug.SetMaxThreads修改

每个M对应一个系统线程,M想要执行,就必须去全局队列里获取一个G,多线程操作同一个资源需要加互斥锁,所有全局队列是有互斥锁保护的。

这种模型存在性能问题,用了4年左右就被抛弃了,在2012年重新设计了。

四、GMP调度模型

GM模型里只使用一个全局队列,需要靠全局互斥锁来操作它,带来了性能问题。

那么怎么解决这个问题呢?

软件设计有问题,就加个中间层,还不行就再加一层。这种指导思想下,在M和G中间加了一层,引入了 P 的概念。

  • P:Processor,表示Go代码执行所需要的资源,用来帮助 M 调度 G,也帮助 G 寻找或创建可用的 M。当 M 要执行 go 的代码时,就需要先关联一个 P,当 M 处于空闲或系统调用时,也需要 P。P 的数量由 GOMAXPROCS 参数指定,在go1.5版本之后,被默认设置为CPU可用的核数,也可以自己设置。

面试官:为什么会有 P ? P 有数量限制吗?G 有数量限制吗?多了会有影响吗?

答案可以参考上面的解释。

  • 全局队列(Global Queue):存放等待运行的G
  • P的本地队列:也是存放等待运行的G,存放的数量有限,不超过256个。新建G时,G 优先加入到 P 的本地队列;如果本地队列满了,则会把本地队列中的一半 G 移到到全局队列中
  • P 列表:程序启动时,会创建 GOMAXPROCS 个 P,保存在数组中,每个 P 都有一个关联的本地队列
  • M:线程要运行任务,就要获取 P。查找G时,先从 P 关联的本地队列获取 G,P 队列为空,M 从其他 P 的本地队列窃取一半放到自己的 P 的本地队列,其他P队列也没有,M 会再尝试去全局队列拿一批 G 放到 P 的本地队列中。M 运行 G,G 执行完之后,M 会从 P 再获取下一个 G,不断重复下去

P 的数量

P的数量由启动时环境变量 $GOMAXPROCS 决定,所以程序执行的任意时刻最多也只有 $GOMAXPROCS 个goroutine在执行。

M 的数量

M 和 P 之间没有绝对的数量关系,一个 M 堵塞了,P 就会去创建或者寻找另一个 M。M和P绑定后才可运行,多余的M处于休眠状态。

Goroutine调度器和OS调度器是通过M关联起来的,每个 M 代表一个内核线程,OS调度器负责把内核线程分配到 CPU 的核上执行。

M0 和 G0

  • M0:程序启动后编号为0的主线程,M0 对应的实例在权利变量runtime.m0中,不需要在 heap 中分配。M0负责做程序初始化操作,和启动第一个G,之后就跟其他的 M 一样了。
  • G0:每次启动一个 M 都会第一个创建的 goroutine,G0 仅用于负责调度的 G,G0 不指向任何执行性的函数,每个 M 都会有一个自己的 G0。在调度或者系统调用时才会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

调度器两种核心机制

最大的好处就是复用了线程,避免了频繁的创建和销毁线程

1. working stealing机制

当本线程无可运行的 G 时,M 会从其他 P 的本地队列窃取 G ,而不是销毁线程。当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

2. hand off 机制

当 M 因为 G 进行系统调用堵塞时,M 会释放绑定的 P,把 P 转移给其他空闲的 M 执行。

与其他语言 coroutine 不同的是,在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,而在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 饿死。

问题1:在 Go 中,是怎么防止一个 G 运行时间过长,导致队列中后面的 G 无法运行?

答:Go 程序启动的时候,会专门创建一个线程 sysmon,用来监控和管理,内部是一个循环。首先,记录每个 P 中 G 的任务计数(schedtick),schedtick 在每执行一个 G 任务后递增。如果检测到 schedtick 一直没有递增,说明 P1 一直在执行同一个 G1 任务,如果超过 10ms,就会在 G1 的栈信息里加一个标记。假设 M1 在处理 G1,系统就会让 M1 线程休眠,挂起这个协程 G1,唤醒其他线程 M2 来继续处理 P1 队列中其他 G,等到 M1 系统调用结束,就会再寻找一个空闲的 P2,将 G1 加入 P2 的本地队列等待调用。

问题2:一个 G 因为系统调用堵塞时,M 将 P 转移给空闲的 M执行,此后是怎么恢复运行的?

答:中断的时候,会将寄存器中的栈信息,保存到 G 对象中,当再次系统调用结束或者回调时,将 G 对象保存的栈信息复制到寄存器中,就可以恢复运行了。

Goroutine 的调度流程

  1. 执行 go func() 创建一个goroutine
  2. 新创建出来的 G 会保存到 P 的本地队列中,如果 P 的本地队列(256个)满了,就会保存到 全局队列中
  3. G 只能运行在 M 中,M 会从关联的 P 的本地队列中弹出一个可执行状态的 G 来调度执行,如果 P 的本地队列为空,会从其他队列执行 working stealing,窃取 G 到来执行,其他队列也没有会从全局队列中获取
  4. M 调度 G 执行的过程是一个循环机制,调用G对象->执行->清理线程→继续找新的Goroutine执行
  5. 当 M 执行某一个 G 发生阻塞时,线程M 会被休眠,G 协程会被挂起,runtime会把这个线程 M 从 P 中摘除,然后再复用一个空闲的线程来服务这个 P,没有空闲线程,就会再创建一个新的
  6. 当 M 系统调用结束时,这个 G 会尝试获取一个空闲的 P ,加入到这个 P 的本地队列中。如果获取不到 P,那么这个线程 M 会被休眠,加入到空闲线程中,然后这个 G 会被放入全局队列中

总结

Golang 的这种混合线程模型,结合了内核线程模型和用户线程模型的优缺点,从 2012年之前的 GM 模型发展到现在的 GMP 模型,从语言层面提供了天然的高并发支持,是一套非常优秀的设计。