いと・スレッド

线程的本质

进程给了它一个可以居住的世界,
却没有给它一个可以独处的房间。
它共享一切——地址、文件、信号、名字,
唯独寄存器与那一小段栈,是它自己的。
这份笔记,把教材说的话,和机器真正在做的事,分开来讲。

408 · 第二章 1000题 22–33 应试口径 × 真实实现 Linux / NT / Go / Loom
SCROLL
はじめに

两套语言,一件事情

做这一章的题,最容易踩的坑不是"不会",而是"会得太多"或"会得太少"。
教材给你一套干净的二分法:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。这套说法必须背,考场按它作答。
但真实的 Linux 里,根本没有"线程"这种内核对象——只有 task_struct,进程和线程只是共享程度不同的同一种东西。
这份笔记全程双轨:粉色 = 应试口径青色 = 真实工程。冲突处会明确告诉你考场该写哪个。

应试教材的世界

进程 = 资源容器 + PCB;线程 = 执行流 + TCB。一个 PCB 下挂若干 TCB。用户级线程在用户态被线程库调度,内核不可见;内核级线程被内核调度,内核可见。

模型干净、边界清晰、便于命题。这就是你要背的。

真实机器的世界

Linux 只有 task_structfork()vfork()pthread_create() 走的是同一个 clone() 系统调用,只是标志位不同。

"进程"是把一组 task 用 tgid 绑成一束,再让它们指向同一份 mm_struct / files_struct / signal_struct——语义是出来的。

两套模型的对照
教材模型 进程 P(资源分配单位) PCB 地址空间 / 文件表 TCB1PC/寄存器栈指针 TCB2PC/寄存器栈指针 TCB3PC/寄存器栈指针 Linux 真实模型 thread group(tgid = 1024) task_structpid 1024tgid 1024 task_structpid 1025tgid 1024 task_structpid 1026tgid 1024 共享 mm_struct / files_struct / signal_struct (靠引用计数指向同一份,不是复制)
左:干净的两层结构 右:一束共享程度极高的 task
ほんしつ

线程的本质

要理解线程,最快的路径是理解 clone()。因为在 Linux 眼里,"创建进程"和"创建线程"是同一个动作,区别只在于——你告诉内核,新的执行流要和旧的共享多少东西。

1 · clone() 才是唯一的真相

FORK — 几乎什么都不共享
clone(child_stack=0,
      flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
      child_tidptr=0x7fec70651a10)   // SIGCHLD:子进程死时通知父进程
PTHREAD_CREATE — glibc NPTL 的标志组合
const int clone_flags = (CLONE_VM      // 共享地址空间 ← 最本质的一条
                    | CLONE_FS      // 共享 cwd / root / umask
                    | CLONE_FILES   // 共享文件描述符表
                    | CLONE_SYSVSEM // 共享 SysV 信号量 undo
                    | CLONE_SIGHAND // 共享信号处理函数表
                    | CLONE_THREAD  // 加入同一 thread group ← POSIX「同一进程」
                    | CLONE_SETTLS  // 设置 TLS(fs base)
                    | CLONE_PARENT_SETTID
                    | CLONE_CHILD_CLEARTID | 0);
一个 clone(),两种结局
clone(flags, ...) kernel/fork.c: copy_process() 不带 CLONE_VM / CLONE_THREAD 复制一份 mm_struct(写时复制) 新的 pid,新的 tgid 独立 fd 表、独立信号处理 父 mm_struct页表 Amm_users=1 子 mm_struct(新)页表 B(COW)mm_users=1 = 进程 带 CLONE_VM + CLONE_THREAD 指向同一个 mm_struct(仅加引用) 新的 pid,tgid 沿用组长 共享 fd 表、共享信号处理 同一 mm_struct同一份页表mm_users = 2 = 线程
"是线程还是进程",全由这几个 flag 决定
约束记忆点 自 Linux 2.5.35 起,CLONE_THREAD 必须同时带 CLONE_SIGHAND,否则返回 EINVAL;自 2.6.0 起 CLONE_SIGHAND 又要求带 CLONE_VM。所以三者事实上绑成一串。
反过来,只带 CLONE_VM 不带 CLONE_THREAD 是合法的:共享地址空间,但不属于同一线程组、信号语义独立。这是"线程 / 进程"之间真实存在的中间态——教材的二分法在这里就已经不够用了。

2 · tgid 与 pid:POSIX 语义是怎么拼出来的

概念内核字段用户态接口多线程进程中的值
POSIX 线程 IDtask_struct.pidgettid()每个线程各不相同
POSIX 进程 IDtask_struct.tgidgetpid()全组相同 = 主线程的 pid
线程组组长group_leader指向主线程的 task_struct
进程级信号信息signal_struct整组共享一份
信号处理函数表sighand_structsigaction()整组共享一份
信号屏蔽字blockedpthread_sigmask()每线程独立

单线程进程里 pid == tgid;多线程进程里主线程 pid == tgid,其余线程各有独立 pidtgid 相同。getpid() 的内核实现返回的其实是 current->signal->pids[PIDTYPE_TGID](历史上写作 group_leader->pid,2018 年由 Eric Biederman 引入 PIDTYPE_TGID 重构)。

调试常识:eBPF 里 bpf_get_current_pid_tgid() 返回 64 位,低 32 位是 pid(线程),高 32 位是 tgid(进程)。名字和直觉正好相反,这是新手最常翻车的地方。

3 · Windows NT:线程是真正的一等公民

Windows 的四层对象
EPROCESS · 执行体进程对象 DirectoryTableBase(顶层页表物理地址) VadRoot(虚拟地址描述符树) ObjectTable(句柄表)· Token(安全上下文) UniqueProcessId · Peb(进程环境块) KPROCESS(内嵌于 EPROCESS.Pcb) 线程链表头 · 基准优先级 · 亲和性掩码 · CPU 时间 ETHREAD · 执行体线程对象 IRP 列表 · 模拟访问令牌 唯一线程 ID · 指向 TEB 的指针 用户态栈 · 内核态栈 KTHREAD(内嵌于 ETHREAD.Tcb) 调度器直接操作的就是它 线程栈 · 调度信息 · APC · 优先级 · 执行时间
EPROCESS 回答"这是谁、它拥有什么";KPROCESS 回答"它的线程该怎么跑"

创建流程(WinDbg 下打断点可见):PspAllocateThreadPspCreateThread(设起始 RIP)→ PspInsertThread(链入进程线程链表)→ KeInitThreadKeStartThread(标记 READY 并插入调度器)→ KiStartUserThread

考场用法 当题目说"线程是调度单位、进程是资源分配单位",你脑子里应该浮现的是 Windows,而不是 Linux。Linux 只是用"共享程度"模拟出了这套语义。

4 · Solaris:M:N 曾经真的活过,然后死了

Solaris 9 之前的 T1 libthread 是历史上最著名的 M:N 实践:M 个用户线程复用在 N 个 LWP(轻量级进程) 上,映射关系是流动的——一个用户线程运行到一半都可能被换到另一个 LWP,而线程库对此无感知。

结果就是:priocntl() 只能改 LWP 的优先级,而用户线程与 LWP 的关系不稳定,于是你根本无法可靠地给某个用户线程设优先级。JVM 改了 LWP 优先级,线程转头就被挪到别的 LWP 上去了。

Solaris 9 起默认换成 T2 libthread,即"更简单、更健壮的 1:1 模型"。旧的 MxN 实现被体面地退役,连术语都废掉了——bound thread / unbound thread 的区分不复存在,因为每个用户线程都有专属 LWP

かんり

进程怎么管理线程

"管理"这个词在教材里是 PCB 挂 TCB 的树形结构;在 Linux 里,它其实是一组引用计数。谁还在用这份地址空间、谁还在用这张文件表——数清楚了,管理就完成了。

1 · PCB / TCB:不存在的两个结构体

应试PCB + TCB

PCB 保存进程级信息(地址空间、文件表、账户信息);TCB 保存线程级信息(寄存器组、栈指针、线程状态、优先级)。一对多关系。

真实只有 task_struct

所有"进程级共享信息"都是多个 task_struct 用指针指向同一个带引用计数的结构体mm_structfiles_structfs_structsignal_structsighand_struct

mm_struct 的双计数器:为什么要两个?

字段含义操作函数归零时
mm_users真实用户数:有多少个用户空间线程在用这份地址空间mmget() / mmput()释放一次 mm_count
mm_count主引用计数:所有 mm_users 整体只算 1,再加上所有"惰性借用者"mmgrab() / mmdrop()才真正 free 掉 mm_struct
两个计数器与 lazy TLB
mm_struct(一份) mm_users = 2 mm_count = 2 线程 A(用户态)task->mm != NULL 线程 B(用户态)task->mm != NULL 内核线程 kthreadtask->mm = NULL active_mm 惰性借用(mmgrab) 为什么要两个计数器 内核线程没有自己的地址空间, 但运行时仍需页表访问内核区, 于是借用上一个 task 的 mm, 记在 active_mm 里并 mmgrab。 借用期间绝不能被释放
mm_users 归零 ≠ 释放;mm_count 归零才释放
Linus 的原话可以当口诀记:mm_users 是"真实的地址空间使用者",mm_count 是"惰性(匿名)使用者的数量,若存在任何真实使用者则再加一"。
(2020 年内核文档还专门修过一个笔误:懒惰 mm 的"僵尸"是在 mm_count 归零时释放,旧文档误写成了 mm_users。)

2 · 线程创建的完整路径

pthread_create 从用户态到内核态
用户态 · glibc NPTL 内核态 1 pthread_create读 attr / 栈大小 2 分配线程栈mmap 匿名映射 + guard 3 布置 TCBstruct pthread / dtv 4 create_thread拼装 clone_flags syscall 陷入 5 clone()kernel/fork.c 6 copy_process()task_struct + 内核栈16KB 7 按 flags 共享资源mm/files/sighand 只加引用 8 入队wake_up_new_task
注意第 7 步:共享的资源"只加引用计数",这才是线程比进程轻的根本原因

3 · pthread_join 的真相:一个 futex

man 手册的原文逻辑:当一个 clear_child_tid 非空的线程终止时,若它与其他线程共享内存,内核就在该地址写入 0,并执行 futex(clear_child_tid, FUTEX_WAKE, 1, ...)
join 的时序
主线程 futex 变量 子线程 tid = 1025 pthread_join → FUTEX_WAIT(值仍为 1025 则阻塞) 主线程睡眠 子线程退出 → 内核清零 + FUTEX_WAKE exit → clear_child_tid tid = 0 主线程被唤醒,join 返回
没有魔法,只有一个整数和一次 futex 唤醒
细节彩蛋 如果线程是被信号杀死的(PF_SIGNALED)而不是正常 _exit,内核不会clear_child_tid——为的是让 core dump 保留 NPTL 数据结构的完整状态。

4 · 线程组内的信号

类别粒度说明
信号处理函数(disposition)进程级共享共享 sighand_struct。未被处理的信号会影响整个线程组(终止 / 停止 / 继续 / 忽略)
信号屏蔽字(blocked mask)每线程私有sigprocmask() / pthread_sigmask()
进程定向信号 kill()投给线程组未阻塞该信号的线程中任选一个投递
线程定向信号 tgkill()投给特定线程精确定位到某个 tid
alternate signal stack每线程私有sigaltstack()

退出也分两级:_exit() 只终止当前线程exit_group() 终止整个线程组。C 库的 exit()mainreturn 触发的都是后者——这就是"进程退出时所有线程一起死"的机制。

5 · 线程栈:不是常数,是 RLIMIT_STACK

常见误记 "glibc 线程栈默认 8MB"——这句话只在 RLIMIT_STACK 取常见默认值 8MB 时成立。真相是:非主线程的栈大小由主线程的 RLIMIT_STACK 决定,实际落在 2–10 MB 区间。可用 pthread_attr_setstacksize() 修改。
线程数上限来源影响
/proc/sys/kernel/threads-max系统级 task 总数上限全局
vm.max_map_count每进程 VMA 数上限每个线程栈是一个映射
虚拟地址空间耗尽32 位系统尤其致命用户态只有 2GB 时,8MB 栈大约 200–400 个线程就满了;64 位基本无此限
内存开销速记 x86-64 内核栈 THREAD_SIZE = 16 KB(2 页);task_struct 本身约 6–10 KB。对照:Go 的 goroutine 起始栈只有 2 KB。差了三个数量级——这就是第 23 题 B 项的量化依据。
きょうゆう と こどく

共享的,与私有的

这一节是本章的分数密集区。要记的不只是清单,更是"私有"这个词的两种含义——有些是硬件隔离的私有(不同进程的地址空间),有些只是约定上的私有(同进程线程的栈)。分不清这两者,第 32 题和第 33 题就会错。

1 · 完整清单

资源归属真实实现 / 备注
代码段 / 数据段 / BSS共享同一 mm_struct
堆 heap共享malloc 出来的内存所有线程可见
mmap 区(含所有线程的栈共享栈也在这里
全局变量 / 静态变量共享
打开文件描述符表共享files_structCLONE_FILES
cwd / 根目录 / umask共享fs_structCLONE_FS
信号处理函数共享sighand_struct
用户 ID / 组 ID共享进程级凭证
System V IPC 资源共享共享内存 / 信号量 / 消息队列
程序计数器 PC / 通用寄存器组私有切换时保存恢复
用户态栈私有(逻辑上)物理上在共享地址空间内
内核态栈私有每个 task 独立,x86-64 默认 16 KB
TLS 线程局部存储私有%fs / TPIDR_EL0
errno私有本质就是一个 TLS 变量
信号屏蔽字私有
调度优先级 / 调度策略私有教材常误说成进程级!Linux 里每个 task 独立设置
alternate signal stack私有sigaltstack()

2 · 关键辨析:线程栈到底能不能互相访问?

教材口径"无法共享栈空间"

线程有自己私有的栈,各线程之间无法共享栈空间。
(1000题第 32 题第 IV 项就是这么说的,而它是错误选项。)

严格结论物理上完全可以

所有线程栈都是同一地址空间里的普通匿名映射。只要拿到指针(比如把局部变量地址传给另一个线程),读写合法且可行,没有任何硬件保护阻止它。

栈是逻辑上 / 约定上私有的——每个线程有独立的栈指针,正常编程不互相访问;
物理上并不隔离,不存在地址空间层面的保护。
对比进程:不同进程的栈在不同地址空间,无法直接互访,必须走 IPC。
同一地址空间里的三个栈
进程的虚拟地址空间(一份 mm_struct) 代码段 .text(共享,只读) 数据段 .data / .bss(共享,全局变量在此) 堆 heap(共享,malloc 的去处) mmap 区域 —— 三个线程栈都在这里 线程 A 栈+ guard page 线程 B 栈+ guard page 线程 C 栈+ guard page 拿到指针 就能互相读写 (无硬件保护) 私有的只有: PC / 寄存器 栈指针 SP 内核栈 TLS / errno 信号屏蔽字
"私有"在这里只是一种纪律,不是一堵墙

3 · TLS:私有是怎么做出来的

架构线程指针载体访问方式
x86-64%fs 段寄存器 / FS_BASE MSR%fs:offset 寻址;内核用 arch_prctl(ARCH_SET_FS,…) 设置,也可用 rdfsbase
ARM64TPIDR_EL0 专用寄存器mrs <reg>, TPIDR_EL0,即 __builtin_thread_pointer()

编译器把 __thread 变量放进 .tdata(已初始化)和 .tbss(零初始化)节,链接器汇成 PT_TLS 程序头(type = 7)。x86-64 使用 Variant II 布局:

x86-64 TLS 变体 II 内存布局
%fs 指向此处 thread pointer (TP) 动态库 TLS 块module id = 3 主程序 TLS 块module id = 1 struct pthread(TCB / 线程描述符) tcbhead_t: tcb, dtv, self, stack_guard… dtv 动态线程向量按 module id 索引 ← 负偏移方向(TPOFF 为负) 正偏移方向 → errno 的地址 = TP + errno@tpoff(负偏移) errno 宏 → *__errno_location(),返回的正是这个地址
dtv[0] 是计数器,其余是各模块 TLS 块的指针;主程序 module id 恒为 1
历史八卦 VMware 当年不得不改造 x86 分段虚拟化,正是因为 NPTL 用段寄存器负偏移访问 TLS,触发了保护错误。一个"私有变量"的实现,逼着虚拟化厂商改架构——这是理解 TLS 有多底层的最好例证。

4 · 共享 fd 带来的并发陷阱

操作产生什么offset 是否共享
dup(fd)新 fd 号,指向同一个 open file description共享 offset 与状态标志(不共享 close-on-exec)
两次 open() 同一文件两个独立的 open file description各自独立的 offset
CLONE_FILES(创建线程)共享整张 fd 表开关操作、标志变化双方都可见
工程注意 内核对单次 send / recv 系统调用是原子的,但对面向流的 TCP,多线程并发 write 同一 socket 可能导致逻辑消息交织——跨多次调用的消息边界必须由应用层加锁或分片来保证。
ふたつのせかい

用户级线程 与 内核级线程

这一节讲一个失败与复活的故事。M:N 模型在 1990–2000 年代被寄予厚望,然后在 Solaris、FreeBSD、Linux 上全线溃败;二十年后,它以 Go 的 goroutine、Java 的虚拟线程的形态彻底赢了。读懂这个反转,你就真正懂了用户级线程的优势和代价分别在哪。

1 · 三种模型

用户态:线程库自己调度 U1 U2 U3 U4 唯一 KSE 内核只看见一个调度实体 → 只能占用一个 CPU 核
切换极快,但一个阻塞全体停摆,且无法多核并行
  • 调度完全在用户态,不需要模式切换,切换代价极小。
  • 致命伤一:某线程执行阻塞系统调用 → 内核把这唯一的 KSE 挂起 → 整个进程停摆
  • 致命伤二:缺页异常同理,也会陷入内核阻塞唯一 KSE。
  • 致命伤三:只有一个内核实体,无法多核并行。
  • 历史缓解:jacketing(包裹技术)——把阻塞调用包装成"先非阻塞试探,会阻塞就切换到别的用户线程";以及非阻塞 I/O + select/poll 事件循环。
用户线程 U1 U2 U3 U4 KSE KSE KSE KSE 内核看见 4 个调度实体 → 可同时占用 4 个 CPU 核
现代主流:Linux NPTL、Windows、Solaris 9+、FreeBSD 7+
  • 真正并行;单线程阻塞不影响其他线程。
  • 代价:创建、切换、同步都要陷入内核,每线程还要 16 KB 内核栈 + task_struct。
  • 内核能感知单个线程并为其单独分配时间片——这是第 30 题 B 项的依据。
M 个用户线程 U1 U2 U3 U4 U5 U6 KSE KSE KSE N 个内核实体可被内核同时调度到 N 个 CPU 上 → 确实能并行
N ≤ M;能并行的用户线程数上限 = min(N, 物理核数)
高频陷阱 "多对多模型中多个线程不能同时在多个处理机上运行" —— 这是错的。M:N 的全部意义就在于那 N 个内核实体可以被内核同时调度到多个 CPU。只有 M:1 才真的无法多核并行。第 29 题 C 项正是把这句话安在 M:1 头上,所以 C 是对的。

2 · 量化:切换到底差多少

两个数量级的鸿沟
内核级线程上下文切换 约 3.4 微秒(直接成本) Li / Ding / Shen, ExpCS 2007 实测(Linux 2.6.17) 用户态协程 / 纤程切换 约 12 纳秒 CS 301 (UAF) 在 x86-64 Sandy Bridge 上实测,OS 切换约慢 100 倍
同一张图上按比例画,用户态切换那条几乎看不见

为什么用户态切换能快 100 倍?因为它本质上就是一次普通函数调用。遵循 ABI 调用约定,caller-saved 寄存器已由编译器在调用点处理好,切换代码只需保存 / 恢复 callee-saved 寄存器 + 栈指针 + 返回地址。x86-64 上就是 rbx, rbp, r12–r15 加一个 rsp

3 · M:N 的溃败:为什么两层调度器活不下去

LinuxNGPT 输给 NPTL

IBM/Intel 的 NGPT 走 M:N,Red Hat 的 NPTL(Drepper & Molnar)走 1:1。NPTL 胜出。

设计文档给的理由干脆利落:拖慢线程性能的内核问题可以被消除(这正是 Molnar 的工作:O(1) 调度器、futex),而 "M:N 实现需要两个调度器"

Dr. Dobb's 的评价:当 Molnar 把 O(1) 调度器合进内核,"争论基本就结束了"。

FreeBSDKSE 被放弃

FreeBSD 5 引入 M:N 的 KSE(Kernel Scheduled Entities),思想类似 Scheduler Activations,5.3 起成为默认。

但这条路并没有取得预期的成功,FreeBSD 7.0(2008)改回 1:1 的 libthr。当年的 man page 直言:libthr 针对系统级线程语义优化,相比 N:M 的 libkse 能带来显著性能提升。

NetBSD 也实现过 Scheduler Activations,同样最终放弃转向 1:1。

M:N 的四道死结
1 信号语义 信号该投给哪个用户 线程?如何在用户态 正确分发? Solaris 需要专门的 信号处理线程,带来 序列化与额外开销 2 两层调度器打架 内核调度器与用户态 调度器互不知情 优先级反转 调度决策失配 无法可靠设优先级 3 阻塞调用难处理 必须有 upcall 机制 内核才能告诉用户态 "你有个线程阻塞了" 实现难度极高,内核 与用户态都要大改 4 调试地狱 用户线程与内核实体 的映射是流动的 调试器难以对应 "这个栈属于谁" 成了哲学问题

4 · Scheduler Activations:最优雅的失败

Anderson, Bershad, Lazowska, Levy —
《Scheduler Activations: Effective Kernel Support for the User-Level Management of Parallelism》
SOSP '91 / ACM TOCS 10(1), Feb 1992, pp. 53–79 DOI 10.1145/121132.121151

核心思想:让内核与用户态线程库协作——内核向用户态发起 upcall(上调),机制类似 UNIX 信号,在一个已知入口地址激活用户态运行时。这个载体就叫 scheduler activation(SA)

upcall 的工作方式
用户态运行时(线程库调度器) 内核 T1 T2 T3 T1 在 read() 中阻塞 内核发起 upcall "T1 阻塞了,再给你一个虚拟处理器" 用户态调度器被激活 在新 SA 上调度 T2 继续跑 关键点 内核不需要知道 用户态用什么结构 表示并行性 SA 总数由应用控制 阻塞线程标记 blocked
既保留用户级线程的低开销,又能正确处理阻塞——理论上完美

为什么它还是没被工业界采纳

5 · 复活:现代运行时怎么绕开死结

历史 M:N 死于"两层调度器打架 + 阻塞 syscall 难处理"。现代语言运行时的破解方式是——不再和内核抢调度,而是把所有阻塞点都改造成让出点

Go 的 G-M-P

goroutine / machine / processor
P(逻辑处理器,数量 = GOMAXPROCS) P0 本地运行队列 runnext 单槽优先 P1 本地运行队列 空了 → 去偷一半 work stealing M(OS 线程,上限 sched.maxmcount = 10000) M0 M1 M2(阻塞中) netpoller —— 把阻塞 I/O 变成非阻塞 集成 epoll(Linux) / kqueue(BSD) 做网络 I/O 的 G 被 park,fd 注册到 netpoller M 不阻塞,转去跑别的 G;就绪后再唤醒 真的阻塞在 syscall 里怎么办 把 P 从阻塞的 M 上剥离,交给另一个 M(必要时新建) sysmon —— 不带 P 的守护线程 负责抢占、netpoll、触发 GC G:起始栈仅 2 KB,连续栈 + morestack 增长
M 必须先拿到 P 才能执行 Go 代码;G 是工作单元

Go 抢占的演进:从"永远卡住"到 SIGURG

阶段机制缺陷 / 突破
Go 1.14 之前协作式抢占:只在函数序言的栈增长检查点(morestack_noctxtnewstack)检查抢占标志 stackguard0 == stackPreempt紧密循环(无函数调用)永不让出,for{} 会永久霸占一个 P
Go 1.14 起基于信号的异步抢占:sysmon 发现某 G 跑超 10 ms(源码 forcePreemptNS = 10 * 1000 * 1000)→ preemptone() → 用 tgkill 给该 M 发 SIGURG信号处理器 → doSigPreempt() → 查 PCDATA 表判断是否异步安全点 → 注入 runtime.asyncPreemptmcall 切到 g0 栈 → gopreempt_m()schedule()
为什么选 SIGURG 不干扰调试器、不被 libc 使用、可以被无害地虚假触发、且能覆盖没有实时信号的平台。一个信号的选择也能讲出这么多讲究——这是运行时设计的典型质感。

Java 虚拟线程(Project Loom, JEP 444)

别记反 虚拟线程对 I/O 密集型(大量并发阻塞)吞吐提升巨大;对 CPU 密集型任务不适合——那受限于物理核心数。对照:平台线程默认约 1 MB 栈(-Xss),虚拟线程栈小得多。
ErlangBEAM

默认每核一个调度器线程,用 reduction 计数实现抢占(执行一定数量归约后强制让出)。进程极轻量、消息传递无共享——M:N 的另一条成功路径。

Rustasync / tokio

async 编译成状态机(无栈协程),tokio 是多线程 work-stealing 运行时,靠 mio 封装 epoll/kqueue。与 Go/Loom 的区别:无栈——编译期生成状态机,而非运行时保存栈。

Windows:Fiber 与 UMS

Fiber(纤程)UMS(用户态调度)
调度方式协作式、非抢占,必须手动 SwitchToFiber 让出用户态调度器可在用户态切换,无需系统调度器介入
线程上下文共享所在线程的上下文每个 UMS 线程有自己的线程上下文
内核阻塞时整个宿主线程阻塞能把控制权交回用户态调度器——这正是 M:N 的精髓
APIConvertThreadToFiberCreateFiberSwitchToFiberUMS 系列 API
可用范围广泛仅 64 位(AMD64/Itanium),Arm64 与 32 位不支持
现状仍可用Windows 11 起移除,调用返回 ERROR_NOT_SUPPORTED
时间线澄清 网上常说"UMS 很早就废弃了",不准确。更早(Visual Studio 2013)被弃用的是并发运行时 ConcRT 对 UMS 的内部使用;而操作系统层的 UMS API 一直保留到 Windows 10 21H2 / Server 2022,Windows 11 才真正移除。两件事要分开。
きりかえ

切换的本质

"线程切换比进程切换开销小"——这句话每个人都会背,但小在哪里才是考点。答案只有一句:同进程线程切换不换页表,所以不刷 TLB。其余的寄存器保存恢复,两者是一样的。

1 · 三个概念必须分清

模式切换 · 上下文切换 · 进程切换
模式切换 mode switch 用户态 ↔ 内核态的特权级变化 触发:syscall / 中断 / 异常 不换 task 开销相对小 系统调用陷入内核 ≠ 上下文切换 上下文切换 context switch 内核决定换一个 task 来跑 保存旧 task 上下文 恢复新 task 上下文 同进程内 → 不换 CR3 next->mm == prev->mm → 跳过 switch_mm → 不刷 TLB 进程切换 process switch 上下文切换的一种,但 新旧 task 属于不同进程 必须换 CR3(页表基址) TLB 中旧翻译失效 靠 PCID / ASID 缓解全量刷新
考题最爱在"syscall 算不算上下文切换"上设陷阱

2 · 同进程内核级线程切换:内核代码路径

schedule() context_switch() if (next->mm != prev->mm) switch_mm_irqs_off() → 换 CR3 跨进程才走这条 switch_to(prev, next, last) → __switch_to_asm → __switch_to 同进程线程只走这条
线程切换比进程切换便宜,差的就是上面那条粉色分支
ARCH/X86/ENTRY/ENTRY_64.S
ENTRY(__switch_to_asm)
    /* 保存 prev 的 callee-saved 寄存器 */
    pushq %rbp
    pushq %rbx
    pushq %r12
    pushq %r13
    pushq %r14
    pushq %r15

    /* 换栈:这两行就是"切换"的全部本质 */
    movq %rsp, TASK_threadsp(%rdi)   # rdi = prev,存旧 rsp
    movq TASK_threadsp(%rsi), %rsp   # rsi = next,载入新 rsp

    /* 栈保护 canary、RETPOLINE RSB 填充等 */

    /* 恢复 next 的 callee-saved 寄存器 */
    popq %r15
    popq %r14
    popq %r13
    popq %r12
    popq %rbx
    popq %rbp
    jmp __switch_to       # C 函数,处理 FPU / 调试寄存器等
END(__switch_to_asm)
为什么只存 6 个寄存器 内核也遵循 C 调用约定,caller-saved 寄存器已在调用点由编译器处理。
为什么不存 EFLAGS 2014 / 2019 年 LKML 上反复讨论过,结论是内核态 flags 总是干净的(NT 标志已清),最终去掉了 pushfq/popfq
switch_to 为什么有三个参数 切换完成后"当前 task"已经是 next 了,需要 last 参数把真正的 prev 传回来——一个著名的小魔术。

3 · 跨进程切换:CR3、PCID 与 KPTI

KPTI 开销的量化(Brendan Gregg 实测 / USENIX ;login: 2018)
开销 syscall 速率 → 50k/s2% 跑 Apache4.5% 工作集>10MB7% 10M/s 无PCID >800% 一半 CPU 周期都在 page walk IPC 从 0.86 掉到 0.10
工作集大小对切换代价的放大效应,比"多存几个寄存器"重要得多

4 · 用户级线程切换:换栈,仅此而已

典型 X86-64 协程切换 SWAP(OLD, NEW)
swap64:
    ; 保存 callee-saved 寄存器到当前(old)栈
    push rdi
    push rbp
    push rbx
    push r12
    push r13
    push r14
    push r15

    mov  [rdi], rsp        ; *old = rsp   ← 保存旧栈指针
    mov  rsp, [rsi]        ; rsp  = *new  ← 载入新栈指针

    ; 从新(new)栈恢复
    pop  r15
    pop  r14
    pop  r13
    pop  r12
    pop  rbx
    pop  rbp
    pop  rdi
    ret                    ; 返回到新栈上的返回地址 = 新的 PC
第 25 题 D 项的靶心 "用户级线程由于不需要内核支持,因此切换时完全不需要保存寄存器上下文"——
必须保存 callee-saved 寄存器和栈指针,否则新旧执行流的状态就乱了。真正的区别只是:不需要保存全部寄存器(caller-saved 由编译器按 ABI 保证),且不需要跨越用户态到内核态。"少保存一些" ≠ "不保存"。

补充:ucontext_t 结构较大(x86-64 Linux 上数百字节,因为还要存信号掩码,这需要一次系统调用),所以 Boost.Context 的 fcontext(纯汇编、不存信号掩码)比 ucontext 快约两个数量级

5 · 隐性开销:真正贵的不是寄存器

成本类型量级来源
直接成本 c1:寄存器保存 / 恢复 + syscall + 调度器约 3.4 μsLi/Ding/Shen 实测(Linux 2.6.17)
总成本 c2:含 cache 冷启动几 μs ~ 数百 μs随数据集大小增长
数据集恰好填满 L1+L2 时6.0 ~ 18.6 μs(均值 12.1 μs)cache 污染最严重的区间
数据集远超 cache 时不再随之增长因为本来就会 miss
间接成本 = c2 − c1可达数十 μsL1/L2/LLC 冷启动 + TLB miss + 分支预测器状态失效

6 · 时间片到底给谁:本章最大的教材陷阱

教材口径时间片给进程

"进程时间片用完,其包含的所有内核级线程的状态均由执行态变为就绪态。"

这就是第 22 题 C 项。它是错误选项。

真实时间片给线程

Linux 调度的实体是 sched_entity,对应一个 task(线程)。时间片 / 虚拟运行时属于单个 task。

某线程时间片用完,只有那一个线程从执行态变就绪态。

唯一接近"给进程分时间片"的机制做什么注意
cgroup v2 cpu 控制器层面按权重分配 CPU 周期。cpu.weight 取值 [1, 10000],默认 100,按比例分给活跃子组若一个 cgroup 里有多个进程,CPU 时间在其成员间再分配
autogroup按会话(setsid())自动分组,改善桌面交互性父子进程放同一 task group

但请注意:这些是在"进程 / 组"粒度做 CPU 份额约束底层被调度、被计时的仍然是单个 task

7 · 缺页时的行为差异

内核级线程缺页 T1 T2 T3 T1 阻塞,T2 / T3 照常被调度 因为它们是各自独立的调度实体 用户级线程缺页 U1 U2 U3 缺页陷入内核,阻塞了唯一的 KSE 整个进程(全部用户线程)停摆
第 22 题 B 项考的就是左图
かこもん

1000题 22–33 逐题拆解

点击任意一题展开完整解析。青底 = 正确表述,粉底 = 错误表述。

22
某系统支持用户级线程和内核级线程。关于进程和线程的状态转换,下列说法错误的是
C
A ✓若一个用户级线程执行了阻塞型系统调用,会导致该进程及其包含的所有用户级线程均被阻塞。
纯用户级线程下,内核只看见一个调度实体,它一阻塞全体停摆。
B ✓若一个内核级线程发生缺页异常而阻塞,同进程内的其他内核级线程依然可以被调度执行。
每个内核级线程是独立调度实体,互不牵连。
C ✗当进程的时间片用完时,该进程包含的所有内核级线程的状态均由执行态变为就绪态。
本题答案。两处硬伤:① 时间片是分配给线程(调度实体)而非进程的,Linux 里调度实体是 sched_entity(对应 task);② 多线程进程的线程可能同时处于执行态、就绪态、阻塞态,不可能"全部由执行态变为就绪态"。
D ✓若进程被操作系统挂起,则其包含的所有内核级线程均无法参与 CPU 调度。
挂起 / SIGSTOP 是整个线程组级别的:所有线程进入 TASK_STOPPED,全部退出调度。freezer cgroup 同理。
23
在支持多线程的操作系统中,有两种实现线程的方式:内核支持的线程和用户级线程。以下哪种说法是错误的?
D
A ✓用户级线程的上下文切换代价相对较小。
不需模式切换,只换 callee-saved 寄存器与栈指针。约 12 ns vs 内核 3.4 μs。
B ✓内核支持的线程通常需要更多的内存空间来存储线程控制块和上下文信息,而用户级线程只需要存储少量的信息。
每个内核级线程有独立内核栈(x86-64 THREAD_SIZE = 16 KB)+ task_struct(约 6–10 KB)。对照 goroutine 起始栈仅 2 KB。
C ✓内核支持的线程能够实现更好的多核性能和负载均衡。
内核可见每个线程,才能做跨核负载均衡与并行。
D ✗用户级线程的调度和同步可以更快速和灵活,并且公平性更好
本题答案。前半句对,后半句完全反了:用户态线程库看不到全局,无法在进程间做公平调度;而且一个进程内的所有用户级线程整体只占一个内核调度实体的份额——线程越多,每个分到的越少。公平性反而更差。
24
下面关于内核级线程的描述中,正确的有
I. 相较于进程,线程切换的系统开销更小 II. 需要操作系统内核支持
III. 线程阻塞会导致该进程的其他线程一同阻塞 IV. 在多处理器系统上,同一进程的多个线程可以并行执行
C
I ✓同进程线程切换不换页表、不刷 TLBcontext_switch()next->mm == prev->mm 则跳过 switch_mm),比进程切换便宜。
II ✓内核级线程由内核创建、维护、调度,当然需要内核支持。
III ✗这是用户级线程的特征。内核级线程各自是独立调度实体,一个阻塞不牵连其他。
IV ✓内核可把它们分派到不同 CPU,真正并行。

答案 C(I、II、IV)。III 是本题唯一的干扰项,考的就是 ULT 与 KLT 的分界。

25
在支持多线程的操作系统中,关于线程上下文切换的开销,下列叙述错误的是
D
A ✓同一进程内的两个用户级线程之间切换,不需要跨越用户态到内核态的模式切换。
用户级线程切换全程在用户态,就是一次"换栈 + 换寄存器"的函数调用。
B ✓同一进程内的两个内核级线程之间切换,其开销小于不同进程间的内核级线程切换。
差在换不换 CR3、刷不刷 TLB。
C ✓从内核级线程 A 切换到同一进程的内核级线程 B 时,CPU 不需要刷新快表(TLB)。
同一个 mm_struct → 同一份页表 → CR3 不变 → TLB 完全有效。这是"线程切换更快"的唯一实质原因
D ✗用户级线程由于不需要内核支持,因此其在线程切换时完全不需要保存寄存器上下文
本题答案。必须保存 callee-saved 寄存器(x86-64:rbx, rbp, r12–r15)和栈指针 rsp,否则新旧执行流状态会错乱。正确表述是"不需要保存全部寄存器"。
26
下列关于用户级线程优缺点的描述中,正确的有
I. 操作系统的调度单位依旧是进程 II. 线程的调度由用户空间的线程库完成,而非操作系统内核
III. 支持不同应用程序采用不同的调度算法 IV. 在多处理器系统上,同一进程的多个线程可以实现并行执行
B
I ✓内核对用户级线程一无所知,它眼中的调度单位仍是进程(那个唯一的内核调度实体)。
II ✓这是用户级线程的定义本身。
III ✓经典优点:线程库在用户态,应用可以自定义调度策略(轮转、优先级、协作式…)。
IV ✗纯用户级线程只有一个内核调度实体,操作系统只能把它放到一个 CPU 上——无法多核并行

答案 B(I、II、III)。把 24 题和 26 题并排看:III 和 IV 在两题里正好互换了对错,这就是 ULT 与 KLT 的分水岭——谁被内核看见,谁就能并行;谁不被看见,谁就一起阻塞

27
设系统中有 4 个 CPU 核心。一个进程采用"多对多"线程模型,在用户态创建了 6 个用户级线程,映射到 3 个内核级线程上。某时刻有 2 个用户级线程因执行阻塞系统调用而进入等待状态,该进程中最多能有多少个用户级线程同时在不同 CPU 核心上并发运行?
A

答案 A:1 个。

推理链

  1. 能并行的用户线程数,上限是可用内核级线程数物理核数的较小者。本题 KLT 只有 3 个,核心有 4 个,瓶颈在 KLT。
  2. 用户级线程执行阻塞系统调用时会陷入内核,把它当时所在的那个内核级线程一并阻塞
  3. 关键一步:同一时刻,一个内核级线程上只能运行一个用户级线程。既然这 2 个用户线程是同时处于系统调用中的,它们必然各自占据一个不同的 KLT
  4. 所以 3 个 KLT 里有 2 个被阻塞,剩余可用 KLT = 3 − 2 = 1
6 个用户级线程 U1 U2 U3 U4 U5 U6 U1、U2 阻塞在系统调用中 各占用一个 KLT KLT-1 阻塞 KLT-2 阻塞 KLT-3 可用 4 个 CPU 核心,但只有 1 个 KLT 能跑 最多 1 个用户级线程并发运行
为什么不是 2 有人会想"如果两个阻塞线程恰好在同一个 KLT 上呢"——不可能。一个 KLT 同一时刻只能执行一个 ULT,两个 ULT 若同时处在阻塞系统调用中,就必然分居两个 KLT。这个论证让答案 A 无懈可击。
28
关于线程控制块(TCB)的管理边界,下列说法正确的是
B
A ✗操作系统内核为每个用户级线程和内核级线程都分配独立的 TCB。
内核看不见用户级线程,怎么可能为它分配 TCB。用户级线程的控制块由用户态线程库维护。
B ✓内核级线程的 TCB 由内核创建与维护,包含寄存器值、调度状态与内核栈指针等。
本题答案。对应 Linux 的 task_struct、Windows 的 KTHREAD
C ✗用户级线程的 TCB 保存在内核空间,以便操作系统直接参与调度决策。
正好说反了:用户级线程的 TCB 在用户空间,内核不参与其调度。这句话自相矛盾——若内核能直接参与调度,它就不是用户级线程了。
D ✗PCB 与 TCB 相互独立,PCB 中不保存任何线程级状态信息。
PCB 至少要保存线程列表 / 线程数,两者是包含关系而非"完全独立"。真实系统里更彻底:Linux 压根没有分开的 PCB 和 TCB。
29
将内核支持线程(KST)与用户级线程(ULT)组合,可实现组合方式的 ULT/KST 线程。下列关于多线程模型,说法错误的是
D
A ✓组合方式中,一些 KST 对应多个 ULT,这是 ULT 通过分时多路复用 KST 来实现的。
这正是 M:N 的机制描述——多路复用。
B ✓多对一模型与一对一模型相比,前者的线程管理开销小、效率高,后者的并发性能更好。
M:1 全在用户态管理,开销小;1:1 能真正并行。两句都对。
C ✓多对一模型中多个线程不能同时在多个处理机上运行;一对一模型中允许多个线程并行地运行在多处理机系统上。
注意主语是多对一——这就对了。若把"不能并行"安到多对多头上则是错的。
D ✗多对多模型中,KST 的数目可以比 ULT 数少,也可以多于或等于 ULT 数量。
本题答案。M:N 的定义是"M 个用户线程复用在 N 个内核实体上,N ≤ M"。若 KST 多于 ULT,就有内核实体空转,模型失去意义。实践佐证:Go 的 P 数量默认 = CPU 核数,远少于 goroutine 数。
30
关于用户级线程(ULT)和内核级线程(KLT)的实现机制,下列叙述错误的是
C
A ✓采用 ULT 时,线程的创建、撤销和同步都不需要通过系统调用陷入内核。
全部由用户态线程库完成,这正是 ULT 快的原因。
B ✓采用 KLT 时,操作系统内核能够感知到单个线程的存在并为其分配 CPU 时间片。
呼应第 22 题:时间片是给线程的。
C ✗无论采用 ULT 还是 KLT,同一进程下的各线程都拥有各自独立的 TCB 和内核栈
本题答案。用户级线程没有自己的内核栈——内核根本不知道它们存在,整个进程在内核眼里只有一个调度实体、一个内核栈。只有内核级线程才每个配一个内核栈(x86-64 默认 16 KB)。另外 TCB 也需分层:ULT 的控制块在用户空间由线程库维护,KLT 的在内核空间。
D ✓无论采用 ULT 还是 KLT,同一进程下的各线程都共享该进程的全局变量和堆空间。
共享地址空间是"线程"这个概念的定义性特征,与实现方式无关。
31
已知某操作系统中每创建一个用户线程都需要创建一个对应的内核线程,则在某进程中创建新的用户线程时,需要新分配的资源包括
I. 页表 II. 线程控制块 III. 用户态堆栈 IV. 内核态堆栈
C

题干"每个用户线程对应一个内核线程"= 1:1 模型。逐项判断:

I ✗ 页表不需要。同进程的线程共享同一个地址空间CLONE_VM → 指向同一个 mm_struct,仅 mm_users 加一),不产生新页表。这也正是"线程比进程轻"最核心的一条。
II ✓ 线程控制块必须新建。对应 Linux 的 task_struct(约 6–10 KB)。
III ✓ 用户态堆栈必须新建。glibc 用 mmap 分配匿名映射 + guard page,大小由 RLIMIT_STACK 决定(常见 2–10 MB)。
IV ✓ 内核态堆栈必须新建。1:1 下每个内核调度实体都要有自己的内核栈(x86-64 THREAD_SIZE = 16 KB)。

答案 C(II、III、IV)。记忆口诀:地址空间共享,栈和控制块独立——两个栈都要新的,一个用户态一个内核态。

32
下列关于进程与线程的说法中,正确的有
I. 进程总是资源分配的基本单位,线程只拥有少量资源、无法脱离进程运行
II. 线程是一部分程序段,多个线程构成一个进程
III. 线程之间的通信主要利用管道、共享存储、消息传递等机制
IV. 线程之间可以共享进程的地址空间,但无法共享各线程之间的栈空间
A
I ✓教材标准表述。线程只拥有寄存器、栈等少量必需资源,必须依附进程存在。
II ✓教材把线程视为进程中的一段执行流 / 程序段,多个线程共同构成一个进程的执行部分。
这句话在真实系统里其实相当粗糙(进程还包含资源,不只是线程的集合),但在本题的选项集合里它必须为真——因为 III、IV 都明显错,唯一可选的组合只有 A。做题时用排除法更稳。
III ✗这是进程间通信(IPC)的手段。同进程线程直接共享地址空间,通信就是读写共享变量,只需互斥锁、条件变量、读写锁、信号量、原子操作来同步。
线程当然也能用管道,但那不是"主要"方式,反而是绕远路。
IV ✗前半句对,后半句错。所有线程栈都在同一地址空间的 mmap 区里,只要拿到指针就能读写别的线程的栈。
正确表述是"栈逻辑上私有(各有独立栈指针),但物理上不隔离"。

答案 A(I、II)。

33
进程 P 创建主线程 T0。T0 通过系统调用打开一个网络套接字,获得描述符 sock_fd。随后 T0 创建子线程 Ta 和 Tb 处理该套接字上的收发。下列哪些是 Ta 和 Tb 可以共享的资源或信息?
I. 进程 P 的全局变量 II. 线程 T0 的局部变量存储区 III. 套接字描述符 sock_fd IV. 线程 Ta 和 Tb 各自独立的程序计数器
A
I ✓ 全局变量位于数据段 / BSS,同一地址空间内所有线程共享。最典型的线程共享资源。
II ✗ T0 的局部变量存储区局部变量在 T0 的上,属于线程私有区域,不是设计上供共享的资源。
但要小心理解这个"不能":它是约定层面的不共享,不是物理层面的不可访问。若 T0 主动把某个局部变量的地址传给 Ta,Ta 完全能读写它——这也正是"把局部变量地址传给子线程"成为经典 bug 来源的原因(T0 一旦返回,那块栈就失效,变成悬垂指针)。考场按不共享作答;工程上要知道它可访问但危险
III ✓ sock_fd同进程线程共享整张文件描述符表CLONE_FILES),fd 号在所有线程中都有效,指向同一个 struct file / struct socket。这正是"多线程共用一个 socket 收发"能成立的基础。
IV ✗ 各自独立的程序计数器题干自己就说了"各自独立"——独立即私有,谈何共享。PC 是线程私有上下文的第一项。

答案 A(I、III)。

延伸:共享 fd 的真实风险 Ta 和 Tb 共享 sock_fd 意味着共享同一个 struct file 的状态。对文件而言这意味着共享 f_pos(应改用 pread/pwrite);对 TCP socket 而言,多线程并发 write 可能让逻辑消息交织——内核只保证单次系统调用原子,消息边界要应用层自己保证。
まとめ

速查与易错点

一页纸对照表

命题应试标准答案真实系统结论
进程是资源分配单位、线程是调度单位正确(必背)Linux 无独立线程对象,调度实体是 sched_entity(task);Windows 才是这套模型的现实对应
ULT 执行阻塞系统调用会阻塞整个进程正确纯 M:1 下正确;Go/Loom/tokio 等现代运行时不成立
KLT 缺页阻塞,同进程其他 KLT 仍可调度正确正确
进程被挂起时其 KLT 能否参与调度不能正确,SIGSTOP 作用于整个线程组
ULT "公平性更好"错误用户库看不到全局,整进程只占一份额,公平性反而更差
KLT 需要更多内存存 TCB / 上下文正确每 task 有 16 KB 内核栈 + task_struct(6–10 KB)
线程通信主要靠管道 / 共享存储 / 消息传递错误直接共享内存 + 锁 / 条件变量 / 原子操作
线程无法共享栈空间错误同一地址空间,拿到指针即可互访;只是"逻辑私有"
M:N 中多线程不能并行错误只有 M:1 不能并行
时间片用完,进程所有线程变就绪错误时间片属线程;Linux 6.6 起用 EEVDF,仍以 task 为实体

六个必须警觉的瞬间

警觉看到"栈无法共享"

立刻想:同一地址空间 → 物理可访问 → 这句话是的。(Q32-IV)

警觉看到"进程时间片"

时间片属于线程。而且多线程不可能全处于执行态。(Q22-C)

警觉看到"用户级线程更公平"

快是真的,公平是假的。看不到全局怎么公平。(Q23-D)

警觉看到"不需要保存寄存器"

是"不需要保存全部",不是"不保存"。(Q25-D)

警觉看到"多对多不能并行"

看清主语。多对一才不能并行。(Q29-C 对 / D 错)

警觉看到"ULT 也有内核栈"

内核都看不见它,哪来的内核栈。(Q30-C)

时效性提醒

答题原则 408 统考以标准模型为准绳。只有当题目明确说"在 Linux 中",或要求"辨析下列说法是否正确"时,才把真实实现的知识拿出来用。先拿分,再较真。

共享一切的东西,也就没有什么真正属于自己。
除了那一小段栈,和寄存器里最后的状态。

408 · 操作系统 · 第二章 进程与线程 · 1000题 22–33