原文参考我的个人公众号文章(欢迎关注!): 点此链接进入
为什么要从 Linux内核线程角度出发去分析开发语言的线程模型?
这里且不说 Linux 为什么是流行的操作系统,每当提到这个话题,因为Linux开源、稳定等等,这里不说那么多题外话。之所以从 Linux 内核角度出发,是因为操作系统是每个热衷于技术的程序员心之所向的地方,并且很多开发语言都或多或少对操作系统有依赖。
为什么本文选择 Java 和 Go 来分析其线程模型?
所有与 IT 相关的科班专业,如计算机科学与技术、网络工程等等,必修 C 语言,C 语言是最贴近硬件的语言,是很多操作系统的开发语言。也正因为如此 C 语言对与开发人员来说并不是那么友好,比如组织大型的项目,比如融入更多的设计模式等。Java 和 Go 抛开目前作为主流开发语言的原因,Java 和 Go 都在高并发编程层面有各自的建树,尤其是 Go 语言号称是专为并发而生的开发语言。
Linux 内核线程
内核线程是直接有内核本身启动的进程。内核线程实际上是将内核函数委托给独立的进程,与其他进程是并行执行的(同时也并行与内核自身的执行)。内核线程经常被称为(内核)守护进程。
内核进程的两个特点:
- 他们在cpu的管理态执行(supervisor mode),而并不是用户态
- 他们只可以访问虚拟地址空间的内核部分,但不能访问用户空间
内核线程其他的细节这里就暂不细说,只需要知道其两个特点我们就可以进一步分析线程的实现,如果感兴趣可以就虚拟地址空间的内核部分、以及针对下面线程实现方式中 用户态 与 内核态 切换 去进一步探究(这里就不细说了,默认大家都知道,就不老生常谈了)。
线程的三种实现方式
- 使用内核线程实现,用户线程数和内核线程数为1:1
- 使用用户线程实现,用户线程数和内核线程数为N:1,每个用户线程对应一个轻量级线程(内核线程高级接口:轻量级线程LWP)
- 使用用户线程+轻量级线程实现,用户线程和轻量级线程为N:M
- 内核实现
- 用户线程实现
- 混合实现
Java 线程模型
Java 线程的实现不受 Java 虚拟机规范的约束,在早期 JVM 上(JDK1.2以前),通过一种“绿色线程”(Green Threads)的用户线程实现的,但从 JDK1.3 开始,主流商用虚拟机模型普遍采用操作系统与安生线程模型来实现 ,即采用操作系统内核线程实现。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
Go 线程模型
Go 作为专为并发而生的语言,当然有其独有的东西:两级线程模型。
两级线程模型
用户线程与 内核线程 为多对多(N:M)的映射关系。两级线程模型吸收前两种线程模型的优点并且尽量规避了它们的缺点,区别于用户级线程模型,两级线程模型中的进程可以与多个内核线程 关联,也就是说一个进程内的多个线程可以分别绑定一个自己的 内核线程,这点和内核级线程模型相似;其次,又区别于内核级线程模型,它的进程里的线程并不与内核线程 唯一绑定,而是可以多个用户线程映射到同一个 内核线程,当某个 内核线程 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 内核线程 绑定运行。所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是一种自身调度与系统调度协同工作的中间态,即用户调度器实现用户线程到 内核线程 的调度,内核调度器实现 内核线程 到 CPU 上的调度。
Go 特有的两级线程模型 GMP
由 Go 调度器实现 Goroutine 到 KSE 的调度,由内核调度器实现 KSE 到 CPU 上的调度。Go 的调度器使用 G、M、P 三个结构体来实现 Goroutine 的调度,也称之为GMP 模型。
G:表示 Goroutine。每个 Goroutine 对应一个 G 结构体,G 存储 Goroutine 的运行堆栈、状态以及任务函数,可重用。当 Goroutine 被调离 CPU 时,调度器代码负责把 CPU 寄存器的值保存在 G 对象的成员变量之中,当 Goroutine 被调度起来运行时,调度器代码又负责把 G 对象的成员变量所保存的寄存器的值恢复到 CPU 的寄存器
M:OS 底层线程的抽象,它本身就与一个内核线程进行绑定,每个工作线程都有唯一的一个 M 结构体的实例对象与之对应,它代表着真正执行计算的资源,由操作系统的调度器调度和管理。M 结构体对象除了记录着工作线程的诸如栈的起止位置、当前正在执行的 Goroutine 以及是否空闲等等状态信息之外,还通过指针维持着与 P 结构体的实例对象之间的绑定关系
P:表示逻辑处理器。对 G 来说,P 相当于 CPU 核,G 只有绑定到 P(在 P 的 local runq 中)才能被调度。对 M 来说,P 提供了相关的执行环境(Context),如内存分配状态(mcache),任务队列(G)等。它维护一个局部 Goroutine 可运行 G 队列,工作线程优先使用自己的局部运行队列,只有必要时才会去访问全局运行队列,这可以大大减少锁冲突,提高工作线程的并发性,并且可以良好的运用程序的局部性原理
一个 G 的执行需要 P 和 M 的支持。一个 M 在与一个 P 关联之后,就形成了一个有效的 G 运行环境(内核线程+上下文)。每个 P 都包含一个可运行的 G 的队列(runq)。该队列中的 G 会被依次传递给与本地 P 关联的 M,并获得运行时机。
M 与 KSE 之间总是一一对应的关系,一个 M 仅能代表一个内核线程。M 与 KSE 之间的关联非常稳固,一个 M 在其生命周期内,会且仅会与一个 KSE 产生关联,而 M 与 P、P 与 G 之间的关联都是可变的,M 与 P 也是一对一的关系,P 与 G 则是一对多的关系。