线程的实现

主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作

的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。我

们注意到Thread类与大部分的Java类库API有着显著差别,它的所有关键方法都被声明为Native。在

Java类库API中,一个Native方法往往就意味着这个方法没有使用或无法使用平台无关的手段来实现

(当然也可能是为了执行效率而使用Native方法,不过通常最高效率的手段也就是平台相关的手

段)。

实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),

使用用户线程加轻量级进程混合实现(N:M实现)。

内核线程实现

使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由

操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调

度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视

为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核

(Multi-Threads Kernel)。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light

Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个

内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1

的关系称为一对一的线程模型。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程

在系统调用中被阻塞了,也不会影响整个进程继续工作。轻量级进程也具有它的局限性:首先,由于

是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调

用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。其次,每个

轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈

空间),因此一个系统支持轻量级进程的数量是有限的。

用户线程实现

使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认

为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻

量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不

具备通常意义上的用户线程的优点。

而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存

在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如

果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持

规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之

间1:N的关系称为一对多的线程模型,如图12-4所示。

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都

需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操

作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处

理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序通

常都比较复杂[1],除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要

支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。Java、Ruby等语言都曾经使

用过用户线程,最终又都放弃了使用它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支

持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。

混合实现

线程除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。

用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以

支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,

这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来

完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量

比是不定的,是N:M的关系,如图12-5所示,这种就是多对多的线程模型。

许多UNIX系列的操作系统,如Solaris、HP-UX等都提供了M:N的线程模型实现。在这些操作系

统上的应用也相对更容易应用M:N的线程模型。

Java线程的实现

以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间

没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理

器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统

全权决定的。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式

(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行

完了之后,要主动通知系统切换到另外一个线程上去。协同式多线程的最大好处是实现简单,而且由

于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么

线程同步的问题。Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控

制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在

那里。很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,那是相当不稳定的,只要

有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃。

如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线

程本身来决定。譬如在Java中,有Thread::yield()方法可以主动让出执行时间,但是如果想要主动获取

执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控

的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式

调度。与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占式来实现多进程

的,当一个进程出了问题,我们还可以使用任务管理器把这个进程杀掉,而不至于导致系统崩溃。

虽然说Java线程调度是系统自动完成的,但是我们仍然可以“建议”操作系统给某些线程多分配一

点执行时间,另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。Java

语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两

个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。

不过,线程优先级并不是一项稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到

系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。尽管现代的操作系统基本都

提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有

2147483648(2的31次幂)种优先级,但Windows中就只有七种优先级。如果操作系统的优先级比Java

线程优先级更多,那问题还比较好处理,中间留出一点空位就是了,但对于比Java线程优先级少的系

统,就不得不出现几个线程优先级对应到同一个操作系统优先级的情况了。表12-1显示了Java线程优

先级与Windows线程优先级之间的对应关系,Windows平台的虚拟机中使用了除

THREAD_PRIORITY_IDLE之外的其余6种线程优先级,因此在Windows下设置线程优先级为1和2、3

和4、6和7、8和9的效果是完全相同的。

线程优先级并不是一项稳定的调节手段,这不仅仅体现在某些操作系统上不同的优先级实际会变

得相同这一点上,还有其他情况让我们不能过于依赖线程优先级:优先级可能会被系统自行改变,例

如在Windows系统中存在一个叫“优先级推进器”的功能(Priority Boosting,当然它可以被关掉),大

致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它分配执行时间,从

而减少因为线程频繁切换而带来的性能损耗。因此,我们并不能在程序中通过优先级来完全准确判断

一组状态都为Ready的线程将会先执行哪一个。

状态转换

Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并

且可以通过特定的方法在不同状态之间转换。这6种状态分别是:

·新建(New):创建后尚未启动的线程处于这种状态。

·运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可

能正在执行,也有可能正在等待着操作系统为它分配执行时间。

·无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线

程显式唤醒。以下方法会让线程陷入无限期的等待状态:

■没有设置Timeout参数的Object::wait()方法;

■没有设置Timeout参数的Thread::join()方法;(join是等待另一个线程执行完成)

■LockSupport::park()方法。(使线程挂起,可使用unpark唤醒)

·限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待

被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状

态:

■Thread::sleep()方法;

■设置了Timeout参数的Object::wait()方法;

■设置了Timeout参数的Thread::join()方法;

■LockSupport::parkNanos()方法;(挂起多少纳秒,可使用unpark提前唤醒)

■LockSupport::parkUntil()方法。(挂起多少毫秒,可使用unpark提前唤醒)

·阻塞(Blocked):线程被阻塞了,“阻塞状态”与“等待状态”的区别是“阻塞状态”在等待着获取到

一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时

间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

·结束(Terminated):已终止线程的线程状态,线程已经结束执行。

上述6种状态在遇到特定事件发生的时候将会互相转换。

Java与协程

内核线程的局限

今天对Web应用的服务要求,不论是

在请求数量上还是在复杂度上,与十多年前相比已不可同日而语,这一方面是源于业务量的增长,另

一方面来自于为了应对业务复杂化而不断进行的服务细分。现代B/S系统中一次对外部业务请求的响

应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复

杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。这要

求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一

个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等

待。

Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟

机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统

能容纳的线程数量也很有限。以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程

切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下,

用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

传统的Java Web服务器的线程池的容量通常在几十个到两百之间,当程序员把数以百万计的请求

往线程池里面灌时,系统即使能处理得过来,但其中的切换损耗也是相当可观的。现实的需求在迫使

Java去研究新的解决方案。

协程的复苏

内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要

来自于响应中断、保护和恢复执行现场的成本。请读者试想以下场景,假设发生了这样一次线程切

换:

线程A -> 系统中断 -> 线程B

处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码的

组合体,代码执行时还必须要有上下文数据的支撑。而这里说的“上下文”,以程序员的角度来看,是

方法调用过程中的各种局部的变量与资源;以线程的角度来看,是方法的调用栈中存储的各类信息;

而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。物理硬件的

各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执

行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B

挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。这种保护和恢复现场的工

作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。

线程

  1. 定义: 线程是操作系统调度的基本单位,每个线程都有自己的栈、寄存器等,独立于其他线程运行。

  2. 并行性: 线程可以在多核处理器上实现真正的并行执行。

  3. 切换成本: 线程切换由操作系统管理,涉及上下文切换,开销较大。(涉及用户态内核态的转换)

  4. 资源共享: 同一个进程内的线程共享内存空间和资源,可以直接访问彼此的内存,但需要使用锁等机制来避免竞态条件。

  5. 使用场景: 适用于需要利用多核CPU的计算密集型任务,例如大型数据处理、图像渲染等。

协程

  1. 定义: 协程是一种用户态的轻量级线程,协程的切换由程序员在代码中显式控制,不需要操作系统的干预。

  2. 并发性: 协程通过协作式调度实现并发,通常在单线程内运行,因此不能实现真正的并行执行。

  3. 切换成本: 协程切换在用户态完成,不涉及操作系统的上下文切换,开销很小。(实现过程由用户决定,可以理解为多个协程使用了一个操作系统的线程。)

使用场景: 适用于I/O密集型任务,例如网络请求处理、高并发服务器等。

对比总结

  • 并行 vs 并发: 线程可以并行执行,而协程一般实现并发。

  • 切换成本: 线程切换成本高,协程切换成本低。

  • 资源共享: 线程间共享内存,需要锁机制;协程一般在单线程内执行,编程更简单。

  • 调度方式: 线程由操作系统调度,协程由用户代码调度。

协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。如果进行量化的话,那么如果不显式设置-Xss或-XX:ThreadStackSize,则在64位Linux上HotSpot的线程栈

容量默认是1MB,此外内核数据结构(Kernel Data Structures)还会额外消耗16KB内存。与之相对

的,一个协程的栈通常在几百个字节到几KB之间,所以Java虚拟机里线程池容量达到两百就已经不算

小了,而很多支持协程的应用中,同时并存的协程数量可数以十万计。

具体到Java语言,还会有一些别的限制,譬如HotSpot这样的虚拟机,Java调用栈跟本地调用栈是

做在一起的。如果在协程中调用了本地方法,还能否正常切换协程而不影响整个线程?另外,如果协

程中遇传统的线程同步措施会怎样?譬如Kotlin提供的协程实现,一旦遭遇synchronize关键字,那挂起

来的仍将是整个线程。(因为同步策略仍然得靠操作系统基本的临界区等,还是得由操作系统实现)

Java的解决方案

对于有栈协程,有一种特例实现名为纤程(Fiber),OpenJDK在2018年创建了Loom项

目,这是Java用来应对本节开篇所列场景的官方解决方案。

Loom项目背后的意图是重新提供对用户线程的支持,但与过去的绿色线程不同,这些新功能不是

为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机中并存,可以在程

序中同时使用。新模型有意地保持了与目前线程模型相似的API设计,它们甚至可以拥有一个共同的

基类,这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发

编程模型。Loom团队在JVMLS 2018大会上公布了他们对Jetty基于纤程改造后的测试结果,同样在

5000QPS的压力下,以容量为400的线程池的传统模式和每个请求配以一个纤程的新并发处理模式进行

对比,前者的请求响应延迟在10000至20000毫秒之间,而后者的延迟普遍在200毫秒以下。

Loom项目目前仍然在进行当中,还没有明确的发布日期。