糸
いと・スレッド
线程的本质
进程给了它一个可以居住的世界, 却没有给它一个可以独处的房间。
它共享一切——地址、文件、信号、名字, 唯独寄存器与那一小段栈,是它自己的。
这份笔记,把教材说的话,和机器真正在做的事,分开来讲。
408 · 第二章
1000题 22–33
应试口径 × 真实实现
Linux / NT / Go / Loom
SCROLL
做这一章的题,最容易踩的坑不是"不会",而是"会得太多"或"会得太少"。
教材给你一套干净的二分法:进程是资源分配的基本单位,线程是 CPU 调度的基本单位 。这套说法必须背,考场按它作答。
但真实的 Linux 里,根本没有"线程"这种内核对象 ——只有 task_struct,进程和线程只是共享程度不同的同一种东西。
这份笔记全程双轨:粉色 = 应试口径 ,青色 = 真实工程 。冲突处会明确告诉你考场该写哪个。
应试 教材的世界
进程 = 资源容器 + PCB;线程 = 执行流 + TCB。一个 PCB 下挂若干 TCB。用户级线程在用户态被线程库调度,内核不可见;内核级线程被内核调度,内核可见。
模型干净、边界清晰、便于命题。这就是你要背的。
真实 机器的世界
Linux 只有 task_struct。fork()、vfork()、pthread_create() 走的是同一个 clone() 系统调用,只是标志位不同。
"进程"是把一组 task 用 tgid 绑成一束,再让它们指向同一份 mm_struct / files_struct / signal_struct——语义是拼 出来的。
两套模型的对照
教材模型
进程 P(资源分配单位)
PCB
地址空间 / 文件表
TCB1 PC/寄存器 栈指针
TCB2 PC/寄存器 栈指针
TCB3 PC/寄存器 栈指针
Linux 真实模型
thread group(tgid = 1024)
task_struct pid 1024 tgid 1024
task_struct pid 1025 tgid 1024
task_struct pid 1026 tgid 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 页表 A mm_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 线程 ID task_struct.pidgettid()每个线程各不相同
POSIX 进程 ID task_struct.tgidgetpid()全组相同 = 主线程的 pid
线程组组长 group_leader— 指向主线程的 task_struct
进程级信号信息 signal_struct— 整组共享一份
信号处理函数表 sighand_structsigaction()整组共享一份
信号屏蔽字 blockedpthread_sigmask()每线程独立
单线程进程里 pid == tgid;多线程进程里主线程 pid == tgid,其余线程各有独立 pid 但 tgid 相同。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 下打断点可见):PspAllocateThread → PspCreateThread(设起始 RIP)→ PspInsertThread(链入进程线程链表)→ KeInitThread → KeStartThread(标记 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_struct、files_struct、fs_struct、signal_struct、sighand_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
内核线程 kthread task->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 布置 TCB struct 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() 和 main 的 return 触发的都是后者——这就是"进程退出时所有线程一起死"的机制。
5 · 线程栈:不是常数,是 RLIMIT_STACK
常见误记 "glibc 线程栈默认 8MB"——这句话只在 RLIMIT_STACK 取常见默认值 8MB 时成立。真相是:非主线程的栈大小由主线程的 RLIMIT_STACK 决定 ,实际落在 2–10 MB 区间。可用 pthread_attr_setstacksize() 修改。
主线程栈由内核动态增长;非主线程栈由 glibc 用 mmap 预先分配固定大小 的匿名映射。
栈底带一个 guard page (PROT_NONE),触碰即 SIGSEGV,用于检测栈溢出。
8 MB 只是虚拟地址空间 被预留,物理页惰性分配(缺页时才映射)。
线程数上限 来源 影响
/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_struct(CLONE_FILES)
cwd / 根目录 / umask 共享 fs_struct(CLONE_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 读
ARM64 TPIDR_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 竞态: 同进程线程共享同一个 open file description,也就共享 f_pos。man 手册明确指出:Linux 3.14 之前 ,共享同一 open file description 的两方并发 write(),更新文件偏移与写数据不是原子的 ,两方输出的数据块可能错误地重叠。3.14 修复。
pread() / pwrite(): 用调用者提供的 offset 原子完成"定位 + 读写",且不改变 f_pos 。man 原话大意:它们在多线程程序里特别有用,允许多个线程在同一 fd 上做 I/O 而不受其他线程改动偏移的影响。
O_APPEND: 每次 write() 前把偏移定位到文件末尾,定位与写作为单一原子步骤完成 。(NFS 上不保证。)
操作 产生什么 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 · 三种模型
多对一 M:1
一对一 1:1
多对多 M:N
用户态:线程库自己调度
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 的溃败:为什么两层调度器活不下去
Linux NGPT 输给 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) 调度器合进内核,"争论基本就结束了"。
FreeBSD KSE 被放弃
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
既保留用户级线程的低开销,又能正确处理阻塞——理论上完美
为什么它还是没被工业界采纳
实现复杂:内核与用户态双向大改 。
upcall 本身有开销:论文实测 upcall 延迟约为内核线程的 5 倍 。
临界区恢复机制拖累常见路径:为了处理"线程在临界区被抢占",论文用了复制临界区代码的办法,但即使临界区没有 被抢占,每次自旋锁操作也要检查线程描述符。
最致命的:随着内核 1:1 线程被优化到足够快(futex、O(1) 调度器),性价比不再成立 。
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_noctxt → newstack)检查抢占标志 stackguard0 == stackPreempt紧密循环(无函数调用)永不让出,for{} 会永久霸占一个 P
Go 1.14 起 基于信号的异步抢占 :sysmon 发现某 G 跑超 10 ms(源码 forcePreemptNS = 10 * 1000 * 1000)→ preemptone() → 用 tgkill 给该 M 发 SIGURG 信号处理器 → doSigPreempt() → 查 PCDATA 表判断是否异步安全点 → 注入 runtime.asyncPreempt → mcall 切到 g0 栈 → gopreempt_m() → schedule()
为什么选 SIGURG 不干扰调试器、不被 libc 使用、可以被无害地虚假触发、且能覆盖没有实时信号的平台。一个信号的选择也能讲出这么多讲究——这是运行时设计的典型质感。
Java 虚拟线程(Project Loom, JEP 444)
JDK 19/20 预览(JEP 425 / 436),JDK 21(2023 LTS)正式(JEP 444) 。
continuation + carrier thread: 虚拟线程的调用栈存在 Java 堆 上(作为 continuation 对象),起始仅几 KB 并按需增长;执行时挂载(mount) 到一个 carrier thread(来自 ForkJoinPool,默认大小 = 可用处理器数)。
阻塞即卸载(unmount): 阻塞 I/O 时 JVM 捕获 continuation(栈状态 + 局部变量)、把它从 carrier 卸载、栈存回堆,carrier 立刻去跑别的虚拟线程;I/O 完成后重新挂载到任一 可用 carrier 恢复。
JDK 的 java.net / java.nio / JDBC 等阻塞 I/O 已被改写 为 park 虚拟线程而非阻塞 carrier——这是关键前提。
pinning(钉住): JDK 21–23 中持有 synchronized 监视器锁时无法卸载,Object.wait()、JNI/FFI 也会钉住。JDK 24 的 JEP 491 修复了 synchronized 导致的钉住 。诊断参数:-Djdk.tracePinnedThreads=full。
别记反 虚拟线程对 I/O 密集型 (大量并发阻塞)吞吐提升巨大;对 CPU 密集型 任务不适合 ——那受限于物理核心数。对照:平台线程默认约 1 MB 栈(-Xss),虚拟线程栈小得多。
Erlang BEAM
默认每核一个调度器线程,用 reduction 计数 实现抢占(执行一定数量归约后强制让出)。进程极轻量、消息传递无共享——M:N 的另一条成功路径。
Rust async / tokio
async 编译成状态机(无栈 协程),tokio 是多线程 work-stealing 运行时,靠 mio 封装 epoll/kqueue。与 Go/Loom 的区别:无栈——编译期生成状态机,而非运行时保存栈。
Windows:Fiber 与 UMS
Fiber(纤程) UMS(用户态调度)
调度方式 协作式、非抢占 ,必须手动 SwitchToFiber 让出用户态调度器可在用户态切换,无需系统调度器介入
线程上下文 共享所在线程的上下文 每个 UMS 线程有自己的线程上下文
内核阻塞时 整个宿主线程阻塞 能把控制权交回用户态调度器 ——这正是 M:N 的精髓
API ConvertThreadToFiber → CreateFiber → SwitchToFiberUMS 系列 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
PCID(x86-64)/ ASID(ARM): 给 TLB 条目打上上下文标签,换 CR3 时不必全量刷 TLB。
Linux 4.7 就引入了 PCID,但当时几乎没用武之地。Meltdown 改变了一切。
KPTI(CVE-2017-5754 缓解): 用户态与内核态改用两套页表 ,每次进出内核都要写 CR3。若无 PCID,每次都全刷 TLB,开销灾难性。
PCID 拯救了 KPTI: 内核页表与用户页表各用不同 PCID 标签,两者可同时留在 TLB 里。代价是 KPTI 下每个 mm 需要两个 PCID,有效 ASID 空间减半到 2048。
KPTI 开销的量化(Brendan Gregg 实测 / USENIX ;login: 2018)
开销
syscall 速率 →
50k/s 2%
跑 Apache 4.5%
工作集>10MB 7%
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 μs Li/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可达数十 μs L1/L2/LLC 冷启动 + TLB miss + 分支预测器状态失效
6 · 时间片到底给谁:本章最大的教材陷阱
教材口径 时间片给进程
"进程时间片用完,其包含的所有内核级线程的状态均由执行态变为就绪态。"
这就是第 22 题 C 项。它是错误 选项。
真实 时间片给线程
Linux 调度的实体是 sched_entity,对应一个 task(线程) 。时间片 / 虚拟运行时属于单个 task。
某线程时间片用完,只有那一个线程 从执行态变就绪态。
从状态角度看也说不通:多线程进程的线程可能同时分布在执行态、就绪态、阻塞态 ,不可能"所有内核级线程都处于执行态"。
版本时效性: Linux 长期用 CFS(2.6.23 引入),但自 Linux 6.6(2023)起默认调度器已换成 EEVDF 。内核文档的措辞是"给每个 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 项考的就是左图
点击任意一题展开完整解析。青底 = 正确表述,粉底 = 错误表述。
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 ✓ 同进程线程切换不换页表、不刷 TLB (context_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 个。
推理链
能并行的用户线程数,上限是可用内核级线程数 与物理核数 的较小者。本题 KLT 只有 3 个,核心有 4 个,瓶颈在 KLT。
用户级线程执行阻塞系统调用 时会陷入内核,把它当时所在的那个内核级线程一并阻塞 。
关键一步:同一时刻,一个内核级线程上只能运行一个用户级线程 。既然这 2 个用户线程是同时 处于系统调用中的,它们必然各自占据一个不同的 KLT 。
所以 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)
时效性提醒
EEVDF 取代 CFS: 自 Linux 6.6(2023)起默认调度器已是 EEVDF。早于此的教材都写 CFS。两者调度实体都是 task,不影响结论。
UMS 移除: Windows 11 起才真正移除 OS 层 UMS API;更早弃用的是 ConcRT 对它的使用,两件事别混。
数据的版本依赖: 内核栈 16 KB、glibc 栈 2–10 MB、goroutine 栈 2 KB、Go 的 M 上限 10000——都是特定版本 / 架构的默认值,会随 ulimit、GOMAXPROCS、-Xss 变化。
Li/Ding/Shen 的 3.4 μs: 2007 年 Linux 2.6.17 + 当年硬件的数据。现代硬件绝对值更低,但"直接成本 μs 级、间接成本受 cache 主导"的结论依然成立。
Scheduler Activations 的年份: 会议版 SOSP 1991,期刊版 ACM TOCS 1992——同一工作,两个年份都对。
答题原则 408 统考以标准模型为准绳。只有当题目明确说"在 Linux 中",或要求"辨析下列说法是否正确"时,才把真实实现的知识拿出来用。先拿分,再较真。
糸
共享一切的东西,也就没有什么真正属于自己。 除了那一小段栈,和寄存器里最后的状态。
408 · 操作系统 · 第二章 进程与线程 · 1000题 22–33