线程和多线程模型

引入进程解决了”多个活动并发推进”的问题,但留下一个新麻烦:进程太重了。这一节讲的就是怎么把它变轻,以及变轻之后带来的一整套新边界。

机制

进程为什么重

进程既是资源分配的单位,又是调度的单位。这两个身份绑在一起,导致每次切换进程都要付出资源层面的代价:

切换进程时,除了保存寄存器现场,还必须切换地址空间——换页表基址寄存器,随之而来的是 TLB 全部失效,Cache 命中率也会掉。之后新进程每访问一个页都要重新走一遍地址转换、重新填 TLB。这部分开销远大于保存那几十个寄存器。

而现实中大量场景其实不需要换地址空间。一个浏览器要同时渲染页面、下载资源、响应点击,这三件事共享同一份数据,本来就该在同一个地址空间里。为它们各开一个进程,等于每次切换都白白付一次页表和 TLB 的代价。

拆分:资源归进程,调度归线程

解决办法是把进程的两个身份拆开:

进程仍是资源分配的基本单位,线程成为调度的基本单位。

这是引入线程后最核心的一句话,几乎所有考点都从它推出来。

于是同一进程内的多个线程:

  • 共享:地址空间(代码段、数据段、堆)、打开的文件、信号处理方式等资源
  • 私有:程序计数器、一组寄存器、栈,以及线程控制块 TCB

私有的这三样正是”执行流”的最小必要状态——每条执行流得知道自己跑到哪(PC)、算到一半的中间值在哪(寄存器)、函数调用链是什么(栈)。除此之外的东西都可以共享。

因为地址空间不变,同进程内的线程切换不需要换页表,TLB 不失效,开销比进程切换小一个量级。这就是线程”轻”的全部来源。

用户级线程与内核级线程

线程可以在两个层次上实现,判据只有一个:内核知不知道它的存在。

flowchart TB
    subgraph ULT["用户级线程 ULT 内核不知情"]
        direction TB
        U1(线程1) & U2(线程2) & U3(线程3) --> LIB["线程库<br/>(用户态)"]:::user
        LIB --> UP["内核眼中:<br/><b>一个</b>进程"]:::kern
    end

    subgraph KLT["内核级线程 KLT 内核直接调度"]
        direction TB
        K1(线程1) --> KA["内核<br/>TCB1"]:::kern
        K2(线程2) --> KB["内核<br/>TCB2"]:::kern
        K3(线程3) --> KC["内核<br/>TCB3"]:::kern
    end

    classDef user fill:#dbeafe,stroke:#2563eb,color:#1e3a5f
    classDef kern fill:#fef3c7,stroke:#d97706,color:#78350f

用户级线程(ULT):完全由用户态的线程库管理,TCB 也在用户空间。内核只看到一个进程,对里面有几条线程一无所知。

  • 优点:切换不需要进内核,就是用户态的一次函数调用级别的操作,极快。而且线程管理策略可以由应用自己定制。
  • 缺点有两条,都很致命:
    • 一个线程阻塞,整个进程阻塞。 因为内核只认进程——某个线程发起阻塞式 I/O,内核把整个进程挂起,同进程内其他本可以运行的线程也跟着停了。
    • 无法利用多核并行。 内核以进程为单位分配 CPU,一个进程同一时刻只会被分到一个核。

内核级线程(KLT):由内核管理和调度,每条线程在内核里有自己的 TCB。

  • 优点:一个线程阻塞不影响同进程其他线程;能真正并行,内核可以把同一进程的多个线程分派到不同核上。
  • 缺点:所有线程管理操作(创建、切换、同步)都要通过系统调用进内核,开销比 ULT 大。

疑问点:JVM 的线程属于哪一种模型

用户级线程与内核级线程在实际系统中如何划分? JVM 的平台线程是否为一对一模型,虚拟线程是否为多对多模型?

关联对照:JVM 平台线程与虚拟线程

该对应关系完全成立,且是三种模型最好的现实例证。

  • Java 的平台线程(Platform Thread)确实是 1:1 的。现代 JVM(HotSpot)在 Linux 上,一个 java.lang.Thread 直接对应一个内核线程(NPTL 的 pthread)。因此 Java 线程能真正并行、能被内核抢占,代价是创建线程需陷入内核并分配栈空间(默认 1MB),开销较大——这正是线程池存在的原因。
  • 虚拟线程(Virtual Thread,Loom)则是 M:N 模型。成千上万个虚拟线程由 JVM 在用户态调度到少数几个平台线程(载体线程)之上。创建成本极低,因为不需进入内核。

虚拟线程还完整复现了 ULT 的经典缺陷及其解法:虚拟线程若执行阻塞式的本地调用(例如在 synchronized 块中阻塞,或调用某些旧式 JNI 方法),会将载体线程一并钉住(pinning),即”一个线程阻塞拖垮一片”的现代形态。JDK 的对策是把 java.net 等标准库的阻塞点改写为非阻塞并挂起虚拟线程,使载体线程得以转去执行其他任务——这与教材中 ULT 采用**非阻塞 I/O 加套管程序(jacketing)**缓解阻塞的思路一致。

对应关系的失效处:教材中的 ULT 是纯用户态线程库、内核零参与;JVM 虚拟线程之下仍压着真实的内核线程池,属于工程上的混合实现。考试须按教材的纯粹模型作答。

多线程模型

ULT 和 KLT 的映射关系有三种(→ 多线程模型):

多对一:多个 ULT 映射到 1 个 KLT。就是纯用户级线程。任一线程阻塞则全进程阻塞,不能并行。

一对一:每个 ULT 对应 1 个 KLT。就是纯内核级线程。并发度最高,但创建线程开销大,且内核线程数量通常有上限。

多对多:m 个 ULT 映射到 n 个 KLT(m ≥ n)。兼顾两者——既有 ULT 的低开销,又因为有多个 KLT 而能并行、且单个线程阻塞不会拖垮全部。

判断题的固定套路:问”哪种模型下一个线程阻塞会导致整个进程阻塞”→ 多对一。

边界

只有内核级线程才是”调度的基本单位”

这句话要加限定。用户级线程对内核不可见,内核调度的仍是进程。 所以”线程是调度的基本单位”这个说法,严格来说只在内核级线程的语境下成立。

考题喜欢在这里设陷阱:问”用户级线程是不是处理机调度的单位”,答案是不是。

什么事件会导致用户级线程切换

疑问点:哪类事件才能引起用户级线程切换

  1. 下列选项中,可能导致用户级线程切换的事件是()。 A. 系统调用 B. I/O 请求 C. 异常处理 D. 线程同步

答案是 D 线程同步。

这道题的判据是:ULT 的切换由用户态线程库完成,必须是”在用户态就能决定并执行”的事件。

逐个看前三个为什么不是:

  • A 系统调用:会陷入内核。内核根本不知道 ULT 的存在,它处理完系统调用后,只会把控制权还给这个进程,不会去切换进程内部的某条用户级线程。
  • B I/O 请求:本身就是通过系统调用发出的,同上。而且在纯 ULT 模型下,I/O 阻塞的是整个进程——连切换的机会都没有了。
  • C 异常处理:异常由硬件检测、内核处理,同样绕不开内核,也不涉及 ULT 的调度。

而 D 线程同步——比如线程 1 去获取一把用户态实现的锁,发现锁被占用,于是线程库让它让出 CPU,在用户态直接切到线程 2。全程不进内核,这正是 ULT 切换的典型场景。

一句话:能在用户态自己做主的,才可能引起 ULT 切换;一旦进内核,内核就只认进程。

线程和”为每个应用配一个线程”的措辞陷阱

疑问点:该选项错在何处,正确表述应当如何

C. 键盘驱动程序为每个正在运行的应用配备一个线程,用以响应该应用的键盘输入

该项为错误表述,需明确其错误所在及正确的说法。

这个选项错在把驱动程序的职责搞反了。

键盘是一台设备,它产生的中断是全局的。键盘驱动程序做的事情是:响应中断、把扫描码取出来、转换成字符、放进当前获得焦点的那个终端/应用的输入缓冲区。

驱动不会也不需要为每个应用各开一个线程。 那样做既浪费(大部分线程永远等不到属于自己的输入),也没有意义(中断只有一个源)。

正确的表述大致是:“键盘驱动程序响应键盘中断,将输入字符送入相应进程的输入缓冲区,由该进程自行读取。”

此类题目的通用识别方法:遇到”为每个 X 配备一个线程/进程”的描述,先追问是否真的需要这么多。 硬件设备通常只有一台,服务它的执行流也就只需一条。

对照速查

进程线程
资源分配单位✅❌
调度单位(引入线程前是)✅(指内核级线程)
私有地址空间、资源PC、寄存器组、栈、TCB
共享—同进程的地址空间、文件
切换开销大(换页表、TLB 失效)小(不换页表)
用户级线程 ULT内核级线程 KLT
内核是否知情❌✅
切换是否进内核❌ 快✅ 慢
一个线程阻塞全进程阻塞只阻塞该线程
能否多核并行❌✅
TCB 位置用户空间内核空间
模型并行单线程阻塞的影响
多对一❌全进程阻塞
一对一✅只影响该线程
多对多✅只影响该线程

考点

  • 资源分配单位 = 进程,调度单位 = 线程(一切的起点)
  • 线程私有的三样:PC、寄存器组、栈
  • ULT 的两大缺陷:全进程阻塞、不能并行
  • 可能导致 ULT 切换的是”线程同步”,系统调用/异常/IO 都不是
  • 多对一模型下一个线程阻塞会拖垮整个进程

链接