线程实现模型:用户级线程与内核级线程
上一篇我们说"线程是 CPU 调度的基本单位"。但这里藏着一个一直没点破的问题:这个"调度",到底是谁在做?是操作系统内核在调度,还是程序自己在用户态调度?
这不是抠字眼。它直接决定了三件极其现实的事:创建一个线程有多贵、一个线程阻塞了会不会拖住别人、以及你的程序能不能真正用满多核 CPU。同样叫"线程",Java 早期的"绿色线程"、现在的平台线程、Go 的 goroutine,背后的实现模型完全不同,性能特征也天差地别。
所以"线程"其实不是一个单一的东西。它至少有两种实现位置——用户态和内核态——以及它们之间的几种映射方式。这一篇我们先把这两种线程在机制上各自怎么运转、各自的调度发生在哪一层讲清楚,再回答:为什么会演化出不同模型、现代语言运行时为什么大多走上了"两层调度"的路。
一、先分清两个问题:线程在哪管理、谁来调度
理解线程模型,关键是抓住两个独立的问题:
- 线程的管理(创建、销毁、切换、调度)发生在哪一层? 用户态的运行库里,还是内核里?
- 内核眼里看到的"可调度实体"有几个? 内核是否知道这些线程的存在?
这两个问题的不同答案,就组合出了不同的线程模型。我们先把两种最基础的"纯种"线程搞清楚。
二、机制:用户级线程怎么运转
用户级线程(User-Level Thread, ULT):线程的全部管理都在用户态的线程库里完成,内核完全不知道这些线程的存在。
它怎么运转?想象一个进程,内核只给它分配了一个可执行实体(在内核看来就是一个普通的单线程进程)。而在用户态,线程库自己维护着多个线程的现场(每个线程的栈、寄存器、状态),并自己实现一个调度器:
- 创建线程:线程库在用户态分配一个栈和线程结构,纯用户态操作,不进内核;
- 切换线程:线程库自己保存当前线程的寄存器、恢复另一个线程的寄存器——这一步完全在用户态完成,不需要陷入内核;
- 调度:由线程库的调度器决定下一个跑哪个线程,内核毫不知情。
用户级线程的切换不经过内核,没有第二、三篇讲的特权级切换开销,所以快得多。但代价是:内核根本不知道这些线程,它只看到"一个进程"。
这个"内核看不见"是它一切优点和致命缺点的总根源。优点是切换极快、创建极轻、可以开成千上万个。但缺点也由此而来,看下一节。
三、机制:用户级线程的两个致命问题
"内核看不见线程"直接导致两个严重问题,理解它们是理解整个线程模型演化的关键。
问题一:一个线程阻塞,整个进程跟着阻塞。
假设某个用户级线程调用了一个会阻塞的系统调用(比如读磁盘)。从内核视角,是"这个进程发起了阻塞系统调用",于是内核把整个进程挂起(进入阻塞态)。可问题是,这个进程里其它用户级线程明明还能干活,却因为内核把整个进程都挂了,全部被连累停摆。
因为内核以进程为单位阻塞,而它看不到进程内的其它线程,所以一个线程的阻塞系统调用会冻住全部线程。这是纯用户级线程最致命的硬伤。
问题二:用不了多核。
内核只给这个进程分配了一个可调度实体,意味着同一时刻只有一个用户级线程能在 CPU 上跑。哪怕机器有 16 个核,这个进程的所有用户级线程也只能挤在一个核上轮流跑,无法真正并行。
这两个问题决定了:纯用户级线程模型,不适合有阻塞 I/O 和需要多核并行的场景。
四、机制:内核级线程怎么运转
内核级线程(Kernel-Level Thread, KLT):线程由操作系统内核直接管理和调度,内核为每个线程都维护一个调度实体(在 Linux 里,线程和进程都用 task_struct 表示,线程就是共享地址空间的 task_struct)。
它怎么运转?
- 创建线程:要通过系统调用(如
clone)进内核,由内核建立线程结构。开销比用户级线程大; - 切换线程:由内核调度器完成,要经历内核态的上下文切换(即使是同进程内的线程,切换也走内核);
- 调度:内核直接调度每一个线程,它看得见每个线程。
正因为内核看得见每个线程,上一节那两个问题迎刃而解:
- 一个线程阻塞,不影响别的线程:某线程发起阻塞系统调用,内核只把这个线程挂起,进程里其它线程照常被内核调度运行;
- 能用多核:内核可以把同一进程的多个线程分派到不同 CPU 核上真正并行执行。
内核级线程的优点全部来自"内核看得见每个线程":阻塞隔离到线程粒度、能利用多核。代价是创建和切换都要进内核,比用户级线程重。
我们现在用的主流线程(Linux 的 pthread、Java 的平台线程)基本都是内核级线程(一对一模型)。
五、机制:三种映射模型
把"用户线程"和"内核调度实体"如何对应起来,就得到三种经典映射模型。用一张图看清:
- 多对一(N:1):多个用户线程映射到一个内核实体。就是纯用户级线程,切换快但有第三节那两个硬伤。早期 Java 绿色线程属于此类。
- 一对一(1:1):每个用户线程对应一个内核实体。就是纯内核级线程,阻塞隔离好、能用多核,但线程数受限于内核(每个都占内核资源,开几十万个会吃不消)。现代 Linux pthread、Java 平台线程走这条路。
- 多对多(M:N):M 个用户线程映射到 N 个内核实体(N 通常约等于核数)。试图兼得两者之长:大量轻量用户线程在少量内核实体上调度。Go 的 goroutine 调度器就是这个思路的代表。
三种模型本质是在"切换成本、阻塞隔离、多核利用、可创建数量"这几个指标间做权衡。没有哪个全优,选型取决于场景。
六、为什么会演化出多对多模型
机制铺完,回答最核心的设计动机:既然一对一已经解决了阻塞和多核问题,为什么还要复杂的多对多?
因为一对一有个扩展性瓶颈:每个用户线程都对应一个内核线程,而内核线程是有成本的——每个都要占内核内存、都要参与内核调度器的管理。当你想开几十万条并发执行流时(比如海量连接的服务器),一对一模型会让内核不堪重负。
多对多想要的是"我全都要":
- 用大量用户级线程(轻量、创建切换便宜,能开几十万个)来表达并发;
- 用少量内核线程(约等于核数,能真正并行、能被内核分到各核)来承载实际执行;
- 用户态调度器负责把这一大堆用户线程,灵活地铺到这几个内核线程上跑。
关键还要解决用户级线程那个"阻塞拖垮全体"的硬伤。Go 的运行时就做了精巧处理:当一个 goroutine 要执行阻塞系统调用时,运行时会把它和它占用的内核线程"剥离",再调度别的 goroutine 到其它内核线程上继续跑,从而避免一个阻塞拖住一片。
多对多模型的目标,是用"少量内核线程保证多核与阻塞隔离 + 大量用户线程保证轻量与高并发"的两层结构,同时拿到两边的好处。代价是运行时调度器的实现极其复杂。
这就解释了 goroutine 为什么能"开几十万个还跑得飞快"——它本质是 M:N 模型下的轻量用户级执行流,规避了一对一线程的内核开销,又通过运行时调度躲开了纯用户级线程的阻塞陷阱。
七、为什么理解模型能看懂语言运行时
把这套模型框架装进脑子,再看各语言的并发就通透了:
- Java 平台线程:一对一,每个
Thread对应一个内核线程。所以"线程是宝贵资源"、要用线程池复用、开几万个就吃不消——根源在一对一的内核成本。 - Java 虚拟线程(Loom):本质转向 M:N,大量虚拟线程映射到少量载体线程上,让你能"像开对象一样开线程"。它解决的正是一对一的扩展性瓶颈。
- Go goroutine:M:N 的典型,运行时自带调度器,把 goroutine 铺到
GOMAXPROCS个内核线程上,并在阻塞时智能腾挪。 - 为什么"阻塞调用"在不同运行时表现不同:在一对一里,一个线程阻塞只是占住一个内核线程;在 M:N 里,运行时必须特殊处理阻塞,否则会拖住一整批用户线程——这就是为什么有些运行时要求用"非阻塞"或"运行时感知"的 I/O 接口。
看懂线程模型,你就能预判一门语言的并发能开多大规模、阻塞调用会不会出问题、多核能不能用满。这些都不是语言玄学,而是底层映射模型决定的。
八、和工程实践 / 后端开发的联系
- 并发规模的上限:用一对一线程的语言/框架,并发数受内核线程成本限制,必须靠线程池约束;用 M:N 的(Go、虚拟线程),可以放心开海量并发执行流。选型前先搞清你的运行时是哪种模型。
- 阻塞调用的陷阱:在 M:N 运行时里调用了"运行时不感知"的阻塞操作(比如某些 JNI、老式阻塞库),可能拖住载体线程、引发性能塌方。理解模型才能避开这类坑。
- 多核利用率排查:如果程序在多核机器上 CPU 利用率上不去,要怀疑是不是被卡在了 N:1 式的单一调度实体上(比如配置不当、
GOMAXPROCS设成 1、或全局锁把并行退化成串行)。 - 协程的本质:协程就是用户态可挂起/恢复的执行流,是 M:N 中"用户线程"那一层的体现。理解它在内核眼里"不可见",才能明白它为什么轻、以及它为什么需要运行时配合处理阻塞。
学习这一部分最容易踩的坑
1. 以为用户级线程一定更好(因为它"快")
用户级线程切换快,但有"一个阻塞拖垮全体""用不了多核"两大硬伤。脱离场景说它好,会在有阻塞 I/O 和多核需求时栽大跟头。快只是一个维度。
2. 以为内核级线程"更强"就该无脑用
一对一内核级线程阻塞隔离好、能用多核,但每个都占内核资源,开几十万个会拖垮系统。它"强"在隔离与并行,"弱"在扩展规模。强弱要分维度看。
3. 把语言的"线程"都当成内核线程
goroutine、虚拟线程、协程在内核看来往往不是独立调度实体,它们是 M:N 模型里的用户级执行流。把它们等同于内核线程,就理解不了它们为什么能开几十万个。
4. 忽略 M:N 里阻塞调用的特殊性
M:N 模型必须靠运行时特殊处理阻塞系统调用,否则一个阻塞会拖住一批用户线程。在这类运行时里乱用阻塞库,是真实会发生的生产事故。
总结
线程不是单一实现。只有理解用户级与内核级线程的差别、以及它们之间的映射模型,才能真正看懂不同并发模型背后的性能与工程选择。
- 两个关键问题:线程在哪一层管理/调度、内核能看见几个调度实体;
- 用户级线程全在用户态管理,切换快但内核看不见,导致"一个阻塞拖垮全体"“用不了多核”;
- 内核级线程由内核管理,阻塞隔离到线程、能用多核,代价是创建切换都进内核、较重;
- 三种映射:N:1(快但有硬伤)、1:1(隔离好但规模受限)、M:N(兼顾轻量与多核,但实现复杂);
- M:N 演化的动机,是用少量内核线程保多核与隔离、大量用户线程保高并发,并靠运行时处理阻塞;
- 看懂模型,就能预判一门语言/框架的并发规模、阻塞行为和多核利用——Go、Java 平台线程与虚拟线程的差异,根子都在这里。
至此,进程与线程的"是什么、怎么实现"已经讲完。但线程共享地址空间带来的那个大麻烦——并发为什么会出错——我们一直推到现在。下一篇就正面拆解它。
参考资源:
- 《现代操作系统》(Andrew S. Tanenbaum)线程实现章节
- 《操作系统导论》(OSTEP)线程章节
- Go 调度器设计文档(GMP 模型)
- 《Java 并发编程实战》线程模型相关章节