一文读懂线程、协程、守护线程
1. 线程的调度
在 Java 线程的生命周期一文中提到了就绪状态的线程在获得 CPU 时间片后变为运行中状态,否则就会在可运行状态或者阻塞状态,那么系统是如何分配线程时间片以及实现线程的调度的呢?下面我们就来讲讲线程的调度策略。
线程调度是指系统为线程分配 CPU 执行时间片 的过程,主要调度方式有两种:协同式线程调度(Cooperative Threads-Scheduling) 和 抢占式线程调度(Preemptive Threads-Scheduling)。
1.1 协同式线程调度
使用协同式线程调度的多线程系统,线程的执行时间由线程本身来控制,某一线程执行完毕之后,会主动通知系统切换到另外一个线程上执行。
使用协同式线程调度的最大好处就是实现简单,而且由于线程获取执行时间和切换由自己控制,切换操作对线程自己是可知的,所以没有线程同步的问题。
当然,缺点也很明显:如果一个线程出了问题,则程序就会一直阻塞
!一直不通知系统进行线程切换,进程一直不让出 CPU 执行时间,严重时可能导致整个系统崩溃。
1.2 抢占式线程调度
使用抢占式线程调度的多线程系统,每个线程的执行时间和是否切换由系统决定。这种实现线程调度的方式,线程的执行时间是不可控的(由系统控制),所以不会有 「一个线程导致整个进程阻塞」 的问题出现。
Java 的线程调度就是抢占式线程调度。
为什么 Java 线程调度是抢占式调度?这个我们在后面的线程的实现模型分析。
1.3 设置线程的优先级
在 Java 中,Thread.yield() 可以让出 CPU 执行时间,但是对于获取执行时间,线程本身是没有办法的。对于获取 CPU 执行时间,线程唯一可以使用的手段是设置线程优先级,Java 通过一个整型成员变量 priority 来控制优先级,优先级的范围从 1~10,在线程构建的时候可以通过 setPriority(int) 方法来修改优先级,默认优先级是 5,优先级高的线程分配时间片的数量要多于优先级低的线程。
设置线程优先级时,针对频繁阻塞(休眠或者 I/O 操作)的线程需要设置较高优先级,而偏重计算(需要较多 CPU 时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。在不同的 JVM 以及操作系统上,线程规划会存在差异,有些操作系统甚至会忽略对线程优先级的设定。
2. 线程的实现模型和协程
为什么 Java 线程调度是抢占式调度?这需要我们了解 Java 中线程的实现模式。
我们已经知道线程其实是操作系统层面的实体,Java 中的线程怎么和操作系统层面对应起来呢?
任何语言实现线程主要有三种方式:使用内核线程
实现(1:1 实现),使用用户线程
实现(1:N 实现),使用用户线程加轻量级进程混合
实现(N:M 实现)。
2.1 内核线程实现
内核线程模型即完全依赖操作系统内核提供的内核线程(Kernel-Level Thread ,KLT)来实现多线程。这种线程由操作系统内核(Kernel, 下称内核) 来完成线程切换, 内核通过操纵调度器(Scheduler) 对线程进行调度, 并负责将多个线程的任务映射到各个 CPU 上去执行。每个线程由内核调度器独立的调度,所以如果一个线程阻塞则不影响其他的线程。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口 - 轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间 1:1 的关系称为一对一的线程模型。
优点:在多核处理器的硬件的支持下,内核空间线程模型支持了真正的并行,当一个线程被阻塞后,允许另一个线程继续执行,所以并发能力较强。
缺点:每创建一个用户级线程都需要创建一个内核级线程与其对应,这样创建线程的开销比较大,会影响到应用程序的性能。
2.2 用户线程实现
从广义上来讲,一个线程只要不是内核线程,就可以认为是用户线程(User Thread,UT),因此,从这个定义上来讲,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,效率会受到限制。
使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如 “阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上” 这类问题解决起来将会异常困难,甚至不可能完成。
因而使用用户线程实现的程序一般都比较复杂,此处所讲的 “复杂” 与 “程序自己完成线程操作”,并不限制程序中必须编写了复杂的实现用户线程的代码,使用用户线程的程序,很多都依赖特定的线程库来完成基本的线程操作,这些复杂性都封装在线程库之中,除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少了,Java、Ruby 等语言都曾经使用过用户线程,最终又都放弃使用它。
2.3 混合实现
线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式。在这种混合实现下,既存在用户线程,也存在轻量级进程。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,即为 N:M 的关系。许多 UNIX 系列的操作系统,如 Solaris、HP-UX 等都提供了 N:M 的线程模型实现。
2.4 Java 线程的实现
Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前),是用户线程实现的,但从 JDK 1.3 起, 主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1: 1 的线程模型。
以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构, 所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。
所以,这就是我们说 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,操作系统中线程的优先级有时并不能和 Java 中的一一对应,所以 Java 优先级并不是特别靠谱。
2.5 协程
2.5.1 出现的原因
1:1 的内核线程模型是如今 Java 虚拟机线程实现的主流选择。内核线程的调度成本主要来自于用户态与核心态之间的状态转换
,而这两种状态转换的开销主要来自于响应中断、保护和恢复执行现场的成本
。系统能容纳的线程数量也很有限。
随着互联网行业的发展,微服务架构的兴起,以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的。 但现在在每个请求本身的执行时间变得很短、服务数量变得很多的前提下,用户本身的业务线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。
这样的话,对 Java 语言来说,用户线程的重新引入成为了解决上述问题一个非常可行的方案。
2.5.2 什么是协程
为什么用户线程又被称为协程呢?我们知道,内核线程的切换开销是来自于保护和恢复现场的成本, 那如果改为采用用户线程, 这部分开销就能够省略掉吗? 答案还是“不能”。 但是,一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,则可以通过很多手段来缩减这些开销。
由于最初多数的用户线程是被设计成协同式调度(Cooperative Scheduling)的,所以它有了一个别名 - “协程”(Coroutine) 完整地做调用栈的保护、恢复工作,所以今天也被称为“有栈协程”(Stackfull Coroutine)。
协程的主要优势是轻量, 无论是有栈协程还是无栈协程, 都要比传统内核线程要轻量得多。如果进行量化的话, 那么如果不显式设置,则在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗 16KB 内存。与之相对的, 一个协程的栈通常在几百个字节到几 KB 之间, 所以 Java 虚拟机里线程池容量达到两百就已经不算小了, 而很多支持协程的应用中, 同时并存的协程数量可数以十万计。
协程当然也有它的局限, 需要在应用层面实现的内容(调用栈、 调度器这些)特别多,同时因为协程基本上是协同式调度,则协同式调度的缺点自然在协程上也存在。
总的来说,协程机制适用于被阻塞的,且需要大量并发的场景(网络 IO),不适合大量计算的场景,因为协程提供规模(更高的吞吐量),而不是速度(更低的延迟)。
2.5.3 Java19 虚拟线程 - 协程的复苏
2022-09-20,JDK 19 发布了 GA 版本,备受瞩目的协程功能也算尘埃落地,不过,此次 GA 版本并不没有以协程来命名,而是使用了 VirtualThread(虚拟线程),并且还是 preview 预览版本。
在 JDK 19 源码中,官方直接在 java.lang 包下新增一个 VirtualThread 类来表示虚拟线程,和现有的 Thread类并驾齐驱,为了更好的区分虚拟线程和原有Thread 线程,官方又给 Thread 类赋予了一个高大上的名字:平台线程。
大家有想具体学习的可以参考:Java终于发布了"协程"–虚拟线程,原来上手这么简单!
3. 守护线程(后台线程)
Daemon(守护)线程是一种支持型线程,因为它主要被用作程序中后台调度以及支持性工作。这意味着,当一个 Java 虚拟机中不存在非 Daemon 线程的时候,Java 虚拟机将会退出。可以通过调 Thread.setDaemon(true) 将线程设置为 Daemon 线程。我们一般用不上,比如垃圾回收线程就是 Daemon 线程。
Daemon 线程被用作完成支持性工作,但是在 Java 虚拟机退出时 Daemon 线程中的 finally 块并不一定会执行。在构建 Daemon 线程时,不能依靠 finally 块中的内容来确保执行关闭或清理资源的逻辑
。