Java 并发基础 - 线程模型

271

一、线程定义

线程,可以把一个进程的资源调度和执行调度分开,线程可以共享进程资源,并独立调度

Java 里面的线程是处理器资源调度的最基本的单位,实验性的 Loom 项目,可能未来会为 Java 引入 Fiber ,也就是纤程,那么这段定义就会有所不同了

二、线程模型

线程的实现可以通过三种方式:

  1. 内核线程
  2. 用户线程
  3. 用户线程混合轻量级进程

2.1 内核线程

2.1.1 实现

可以使用内核线程( KLT,Kernel-Level Thread )实现线程,这种方式的意思就是直接由操作系统内核内置的线程实现直接支持实现,支持多线程的内核也称之为多线程内核(Multi-Threads Kernel),内核将完成以下操作任务:

  1. 负责线程切换
  2. 操纵调度器来进行线程调度
  3. 负责线程的任务映射到各个处理器

这样一看,每个内核线程如同内核的一个分身,这样操作系统就有能力同时做多件事了

而真正应用程序实现的时候,不会直接使用内核线程,而是使用内核线程的一种高级接口,叫做轻量级进程(LWP,Ligth Weight Process),每个 LWP 代表一个 KLT 这也是我们通常意义上所讲的线程:

  • 应用程序可以启动为多个进程 --- 程序 & P1:n
  • 每个进程可由多个轻量级进程支持 --- P & LWP (1:n)
  • 每个轻量级进程都由一个内核线程支持 --- LWP & KLT1:1

该线程模型如下:

2.1.2 特点

可以发现,内核方式实现,这样的应用程序在 LWP 被阻塞的情况下,也不会影响整个进程工作

这种线程实现方式的核心就是靠 LWPKLTLWPKLT 的关系是 1:1,因此这也被称之为是: 1:1 线程实现用户进程的一个任务调度对应内核的一次调度

这种方式的优点是实现简单,直接调用内核接口,很多具体细节不用自己写

这种实现方式的缺点是,线程的各种任务操作都是内核控制的,那么每次一旦要操控线程,例如同步,创建,析构之类的,就要从用户态切换到内核态,这个过程性能代价比较高

每个轻量级进程都对应一个内核线程,这个需要消费内核资源,例如内核栈,所以系统支持一个轻量级进程数量是有限的

2.2 用户线程

2.2.1 实现

用户线程(User Thread,UT),可以从两个角度来定义

  • 广义角度定义:非内核线程的线程,可以简单地认为就是用户线程
  • 狭义角度定义:完全建立在用户控制的线程库上,内核不能感知用户线程的存在以及如何实现,用户线程的建立,同步,析构等操作都在用户态完成,不需要内核的帮助

那么,从狭义的角度,我们可以知道:

  • 应用程序可以启动为多个进程 --- 程序 & P1:n
  • 每个进程可由多个用户线程支持 --- P & UT (1:n)
  • 多个用户线程由一个内核线程支持 --- UT & KLTn:1

该线程模型如下:

2.2.2 特点

可以发现,一个用户进程的各种任务靠着用户线程就可以完成了

因为这里的线程实现方式是靠用户线程来实现的,多个用户进程由一个内核线程支持执行,内核线程与用户线程的关系是 1:n ,因此这也被成为是1:N 线程实现用户进程的一个任务调度对应一次用户线程的调度,而多个进程的多次用户线程调度由一个 CPU 即可完成

这种方式的优点在于:不需要内核支持,可以支持大规模线程数量的应用

这种方式的缺点在于:

  1. 实现复杂度:很多实现细节都要自己做,例如:阻塞处理、多处理器的线程处理器映射、线程的调度、切换、析构,都要自己实现
  2. 阻塞问题:多个用户线程对应同一个内核线程,如果其中一个用户线程阻塞,其他用户线程也就无法执行了

Java 、 Ruby 都曾使用过用户线程方式实现线程,后面都放弃它了

而近年来,像 Go、Erlang,这些都重新开始支持了,Python 的 gevent 也是基于这种模型实现的 !

2.3 混合实现

2.3.1 实现

混合内核线程和用户线程方式,即存在用户线程,也存在轻量级进程的接口调用

该线程模型如下:

2.3.2 特点

可以发现,一个用户进程的各种任务还是靠用户线程实现,但同时也跟轻量级进程配合使用了

因为这里的线程实现方式是靠用户线程 & 轻量级进程来实现的,用户进程的任务调度核心还是靠用户线程调度实现,但它又有轻量级进程的辅助,而这里的用户线程和轻量级进程n : m 的关系,因此这也被称之为是 n:m 线程实现

这种方式弥补了用户线程实现方式的缺点:

  1. 复杂性的平衡:一些比较难实现的线程任务操作,如:处理器与线程调度映射可以通过轻量级进程接口调用实现,而一些比较容易做的线程操作,如:线程创建、切换、析构,可以在用户线程自己实现,从而在性能和实现复杂度做了一个平衡
  2. 用户线程方式的阻塞问题:由于对应了多个内核线程,某一个用户线程阻塞,其他用户线程依然可以执行!

但也有缺点:这种模型实现起来具有高度复杂性

现在的 UNIX 内核的操作系统,如 SolarisHP-UX 都提供了 n : m 线程模型的实现

其中 Go 的协程,也就是基于 PMG 模型的 goroutine ,也是基于这种模型实现的

2.4 总结

做个小总结,加深理解,以下都是个人观点

2.4.1 混合方式

“Go 协程的实现!”

  1. 平衡与性能结合混合方式实现的线程模型是比较理想的实现方案
    • 用户线程可以实现尽量少的系统调用
    • 轻量级接口可以避免阻塞问题,可以实现多线程并行,可以减少任务调度的复杂性
  2. 编码难度大:编码往往比较麻烦,就像异步 IO 好处可见,为什么现在大多数用的还是 IO 多路复用,一部分原因就是编码难度,后期可能不好维护

2.4.2 用户线程

“抽象出来的线程库,可以为多进程模型的设计提供多线程模型实现!”

  1. 高并发与小的线程切换代价用户线程在效率和并发量来看是更好的
  2. 依旧需要系统调用
    • 系统调用必要性:某些线程的调度、线程到处理器的映射难以实现,那么无可奈何还是要通过与该进行关联的内核线程来进行系统调用,所以该线程模型依旧需要内核线程配合系统调用
    • 线程库与系统调用:线程库的存在一是可以避免很多系统调用,二是让我们自己决定什么时候进行系统调用了,所以用户线程的线程库写的好,性能和效率会高很多,尽可能地避免系统调用,而如果一旦进程的某个用户线程触发系统调用,进行了中断和上下文切换,那么代价其实和内核线程一样。但是意义就是库写的好,可以尽量避免很多系统调用,提高了效率
  3. 非并行:多个用户线程映射到一个内核线程,也就可以发现,该种模型其实解决了并发问题,但并非并行的,如果是多核处理器环境下,只有一个 CPU 能被利用到!
  4. 阻塞进程问题:如果某个用户线程的操作委托给该进行关联的内核线程系统调用阻塞了,整个进程的其他用户线程都得等待

2.4.3 内核线程

“稳定,注意问题相对较少的实现!”

  1. 真正的并行:可以看到进程的调用都通过轻量级进程接口委托给一个内核线程,再由任务调度器进行系统分配调度,多核环境下可以有效地利用到多个 CPU
  2. 非阻塞:由于每个进程的每个任务调用都有一个内核线程支持,因此不同的任务调用,阻塞是不会收到影响的
  3. 效率低下:调用全都交给内核线程来控制和进程,意味着非常多的系统调用和中断系统状态切换,线程的上下文切换代价较大

三、Java 线程模型

3.1 API 层次

每个主流的操作系统都提供了自己的线程实现,而 Java 则是屏蔽了平台差异,提供了统一的对线程的实现,也就是 java.lang.Thread 类的一个实例,调用了其 start() ,这样的一个运行状态,代表了一个线程

不过,如果进入该类,可以发现该类的许多方法都是 native 方法,也就是说并没有屏蔽平台相关性,这么做好处是效率性能更好,坏处是跨平台性更差了

3.2 线程模型层次

Java 虚拟机规范不会限定 Java 线程使用哪种模型,操作系统支持什么样的模型很大程度会影响线程模型的映射

  • JDK 1.2 前,采用的是用户线程方式
  • JDK 1.3 起,主流 JVM 都采用内核线程方式
  • Solaris 平台的 HotSpot 虚拟机,支持用户线程方式和混合方式方式,该平台的虚拟机专门提供了两个虚拟机参数选择哪种线程模型:-XX:UseWPSynchronization-XX:UseBoundThreads

注意的是,线程模型只对线程的并发和操作成本产生影响,对 Java 程序的编码和运行是无差异透明的

参考