进程与线程 入门
我们的 curl 命令以一个 进程 形式跑 —— OS 抽象包着一个地址空间、一张打开文件表、和一小撮内核状态。 进程里面可以跑 1 个或多个 线程。5 个 section 把画面搭出来:进程到底装了啥(PCB、地址空间、fd 表);线程作为共享地址空间的可调度上下文;用户态 / 内核态边界和各自能看到什么;上下文切换 + 可交互成本分解;最后是速查表。
进程到底是什么
一个跑起来的程序不是磁盘上的那个文件。它是 OS 的簿记 —— 地址空间、打开的文件、凭据、可调度状态 —— 捆成一个 unit,内核能给它命名、挂起、和保护它不被别人碰。
你输 curl ... 时,shell 调 fork() + execve(): 内核分配一个新的 task_struct(Linux 给进程控制块 PCB 的名字)、 一个新的 mm_struct(地址空间)、一张新的文件描述符表、给个新 PID。 ELF 二进制的代码被映射、入口点装进程序计数器、执行开始。 这个跑起来的程序的一切 —— 每个寄存器、栈和堆的每个字节、每个打开的 socket —— 都住在那些内核结构里,不在二进制文件里。
地址空间
每个进程都有一个私有的 64 位虚拟地址空间(x86-64 上 256 TB,其中用户半区 128 TB)。 布局,从低到高:
- text —— 程序的机器码,只读,从 ELF 文件映射。
- data / bss —— 全局变量,已初始化的和未初始化的。
- heap —— 用
brk()或mmap向上增长。malloc住在这。 - mmap 区 —— 共享库、大分配、文件映射。
- stack —— 从高地址向下增长;每线程一个。
- 内核空间 —— 上半区(x86-64 Linux 上 0xffff_8000_0000_0000 以上), 映射进每个进程,但只在内核态可访问。
地址空间是虚拟的:每次 load 和 store 都过页表,找哪一个物理页(如果有)支撑那个虚拟地址。 两个进程都能在虚拟地址 0x7fff1234 持有整数 42, 但在不同的物理页里。virtual-memory primer 讲机制。
PCB 的其余部分
除了地址空间,一个进程拥有:
- 打开文件表 —— fd 0(stdin)、1(stdout)、2(stderr),加上任何
open()出来的 socket 或文件。fd 号是每进程struct file指针数组的索引。 - 凭据 —— uid、gid、capabilities。每个内核权限检查都用。
- 信号处置 —— 哪些信号被阻塞,哪些有自定义 handler。
- 工作目录、root、umask、rlimits —— 一小撮其他一切都依赖的每进程状态。
- 父 / 子指针 —— 进程树。
wait()和 zombie 怎么被收割,都用它。 - 调度状态 —— 可运行、睡眠、僵尸;优先级;累计 CPU 时间。 cpu-scheduling primer 解释调度器怎么用这些。
这一捆 —— 地址空间 + 打开文件 + 凭据 + 调度状态 —— 就是隔离的 unit。 内核保证没有进程能读另一个的内存、截取它的文件描述符、或冒充它的凭据, 除非通过它显式 mediate 的 IPC 机制(共享内存、管道、socket、信号、ptrace)。
要点。「一个进程是内核为了隔离捆起来的一包: 地址空间 + 打开文件表 + 凭据 + 调度状态。磁盘上的二进制是种子; 让程序跑起来的一切,都住在内核在 fork() + execve()时分配的 task_struct 和 mm_struct 里。」
线程 —— 共享地址空间的可调度上下文
线程是调度器把 CPU 交给的单位。一个进程可以装 1 个线程(你最早写的「单线程程序」) 或几千个。它们全都共享同一份内存,这既是线程吸引力的来源、也是它大多数 bug 的来源。
Linux 上,pthread_create 实际上调的是 clone() syscall, 带着「请求一个新 task_struct,它共享父进程的 mm_struct、fd 表、信号 handler 和其他状态」的 flag。 新 task 得到自己的栈、自己的 TLS、自己的寄存器状态、自己的内核调度槽 —— 但它对全局内存的读写,打到的是这个进程里其他每个线程都看得到的同样的物理页。
这种共享既是吸引力也是危险。吸引力:线程之间传数据免费 —— 它们看同样的指针、同样的堆。 危险:对共享状态的无同步访问悄无声息地腐蚀内存;concurrency primer 讲怎么办。
内核线程 vs 用户线程
现代 Linux 上,每个 pthread 都是内核线程 —— 内核知道它、调度它、能挂起恢复它、能在 CPU 之间搬它。这有时叫「1:1 模型」: 一个应用级线程对一个内核线程。简单,让进程真的能用上多核并行,但内核线程很重 —— 每个要一个 task_struct、一个内核栈(Linux 上 16 KB)、~1 KB 的额外簿记。 服务器上实际上限:几千。
用户态线程(green thread、fiber、协程)把多个应用级并发任务 复用到更少的内核线程上。Go 的 goroutine、Java 虚拟线程(Project Loom、JDK 21+)、 Rust async task、Python asyncio task —— 全部是用户态。 runtime 维护自己的调度器,在 M:N 或 1:N 模型上 park/wake 任务,跑在内核线程之上。 好处是能扩到几百万个并发任务(一个 goroutine ~2 KB 栈); 代价是任何底层内核线程上的阻塞 syscall 会阻塞它上面调度的所有用户任务 (靠 runtime 把它们搬到别的内核线程、或者用非阻塞 I/O 缓解 —— I/O primer 讲 epoll 和 io_uring)。
线程本地存储(TLS)
每个线程都共享地址空间,所以 C 里的全局变量默认共享。要让每个线程有自己的值, 声明为 thread_local(C++11)或 __thread(GCC), 或者用 pthread_key_create 的运行时按 key 的存储。 编译器吐出通过 FS 段寄存器(x86-64)或 TPIDR_EL0(ARM64)的访问, 所以每个线程从不同的物理位置读。
TLS 是避免共享状态争用的标准做法:cache-coherence 那篇里的 per-thread 计数器模式、 tcmalloc/jemalloc 的 per-thread allocator cache、tracing 里的 per-thread 当前请求上下文 —— 都住在 TLS 里。
线程的代价
- 内存。每个线程默认预留 8 MB 栈(虚拟;物理页按用按需分配)。 1 000 个线程 = ~8 GB 虚拟地址空间预留。用
pthread_attr_setstacksize能缩小,但大多数服务器懒得动。 - 调度器负载。CFS 调度器在可运行线程的 O(log N) 里挑下一个跑谁。 几千个没问题;几百万个不行(所以那种场景需要用户态线程 runtime)。
- 缓存压力。每个线程有自己的工作集;在它们之间切换会把彼此的数据 从 L1/L2 里赶出去。高并发低吞吐的服务器可能在缓存 thrashing 上花的时间比真实工作还多。
要点。「线程共享父进程的地址空间、fd、凭据,但有自己的栈和寄存器。 内核线程(Linux 上每个 pthread)和内核调度单位 1:1 —— 几千个是实际上限。 用户态线程(goroutine、虚拟线程、async task)把多任务复用到更少的内核线程上, 能扩到几百万,代价是底下需要非阻塞 I/O。」
用户态 vs 内核态 —— 特权边界
CPU 在硬件层强制两个特权级。你程序执行的每一个字节代码,要么在一个里、要么在另一个里跑, 两边能干什么的规则,差距巨大。
现代 CPU 实现多个特权环(x86:4 环,实际只用 0 和 3;ARM:4 个异常级 EL0–EL3)。 对我们来说有两个:用户态(ring 3 / EL0),应用程序跑的地方, 和 内核态(ring 0 / EL1),OS 跑的地方。硬件强制这个边界;软件逃不掉。
用户态不能做什么
- 碰没给它映射的内存。每个进程地址空间的内核半区是映射的, 但从 ring 3 不可访问 —— 尝试读 / 写会触发一个 fault,内核把它变成 SIGSEGV。
- 用特权指令。禁用中断(
cli)、加载新页表 (mov cr3, ...)、读 model-specific 寄存器、执行 I/O 端口指令 (in/out)、配置 IDT —— 全部 ring 0 专属。 - 直接跟硬件说话。不能读网卡的 buffer、不能编程定时器、不能启动 DMA。 用户代码通过 syscall 求内核;内核做真实的硬件工作。
内核态能做什么(几乎一切)
一旦跑在 ring 0,内核能做硬件能做的任何事。内核仍然尊重自己的策略 —— capability 检查、 namespace 限制、cgroup 限制 —— 但这些是软件约定,不是硬件强制。 内核里的一个 bug 能腐蚀任何内存、搞瘫机器、或者给恶意用户授予 root。 这就是为啥内核代码的评审偏执程度,超过任何用户态代码。
控制怎么穿过边界
3 个机制把 CPU 在环之间移动,每个都有自己的 primer 详细讲:
- Syscall(x86-64 上是
syscall、ARM64 上是svc #0): 用户代码求内核做某件事。主动、受控,参数在寄存器里。见 assembly-isa primer。 - 中断(interrupt):硬件要求内核注意 —— 网卡来了个包、定时器响了、键盘有输入。从用户代码的视角看是被动的。 见 syscalls-interrupts primer。
- 异常(exception):用户代码做了非法的事 —— 除 0、解引用未映射地址、执行未定义指令。CPU 跳到一个固定的内核 handler; 内核通常给进程送一个信号(SIGSEGV、SIGFPE、SIGILL)回来。
三种情况下硬件都原子地保存用户的指令指针和栈指针、切到每 CPU 的内核栈、抬高特权环、 跳到记在 model-specific 寄存器里的内核入口点。内核做该做的,然后安排回到 ring 3、 在保存的(或修改过的)用户指令指针处继续。
为啥要有两个模式
没有特权分离,每个程序都能腐蚀内核、每个程序都能读别的程序的内存、 每个程序都能读每一个磁盘文件。实用的多用户计算需要隔离。 内核是强制它的可信中介;特权边界是硬件给内核做的支撑。
代价是真实的 —— 每次 syscall、每次缺页、每次中断都是一次边界穿越。 后 Meltdown 的 KPTI 让每次穿越都刷 TLB,把每次穿越的成本从 ~50 ns 推到 ~150 ns。 高性能服务器因此下大力气避免穿越(I/O primer 讲 io_uring;syscalls primer 讲 vDSO)。
要点。「用户态(ring 3)不能碰未映射的内存、执行特权指令、 或跟硬件说话。内核态(ring 0)能做硬件能做的任何事。控制经 syscall(主动)、中断(硬件发起)、或异常(用户代码出错)穿过边界。 每次穿越是 ~150 ns 的纯开销,所以高吞吐服务器尽量减少。」
上下文切换 —— 真实代价
内核每秒在跑的线程之间切换数千次。直接代价是几百纳秒;间接代价 (cache 冷、TLB 刷、调度器簿记)经常远大于它。
每当内核需要停下一个线程、开始跑另一个线程,就发生一次上下文切换。 触发条件包括:定时器中断耗光当前线程时间片、当前线程主动在 I/O 上阻塞、 更高优先级的线程变可运行、或者当前线程调 sched_yield。
存什么、恢复什么
内核把离开的线程的 CPU 状态存进它的 task_struct: 通用寄存器、指令指针、栈指针、标志、段寄存器、FPU 和向量状态 (现代 CPU 上大多数都是惰性的 —— 实际用到时才恢复)。 x86-64 上是 ~200 字节状态;保存和恢复花几十个周期。
然后内核决定接下来跑啥(调度决定 —— 见下一篇 primer)、更新 per-CPU 指针 (Linux 上的 current)、开始加载进来线程的状态。 如果进来的线程在不同进程里,内核还要换 CR3(页表基址寄存器), 触发 TLB 刷新 —— 所有缓存的虚拟到物理的翻译被丢掉,下次访问必须从页表重走。
代价的不对称
进程 → 进程切换是贵的那个。除了直接的寄存器存 / 恢复(~100 ns), TLB 刷新意味着接下来 ~50–500 次内存访问每次都要走一次页表, 而不是用缓存的翻译。L1 指令和数据缓存也丢掉离开线程的内容, 要为进来的线程从 L2/L3/DRAM 重新预热 —— 在切换后的头 ~10 μs 里测得 IPC 显著下降。
同一进程内的线程 → 线程切换完全跳过 CR3 换和 TLB 刷。 直接代价 ~100 ns;cache 效应最小,因为进来线程的工作集很可能还热(它刚跑过)。 这就是为啥「几百个线程共享一个进程」在 I/O 受限服务器上,同样负载下能比 「每请求一个进程」快 5–10 倍。
现代缓解措施
- ASID / PCID 标记的 TLB。较新的 x86(从 Westmere 起,2010 年) 和所有现代 ARM 都给 TLB 项打进程 ID 标记, 所以切到不同进程能保留离开进程的 TLB 项而不刷掉。 降低重走代价但消除不了 cache 效应。
- CPU affinity。把线程钉到特定核 (
sched_setaffinity、taskset)能让它的 cache 状态 跨切换保持热。低延迟系统(高频交易、像 ScyllaDB 这种数据库)用。 - 用户态调度。Go 的 goroutine 调度器几乎一切都在用户态做, 在 goroutine 之间切换不卷入内核。每次切换 ~50 ns,vs 内核的 ~1 μs。 Java 虚拟线程(Loom)和 Rust 的 tokio 同样工作。
怎么量
Linux 上:perf stat -e context-switches,cs,migrations 显示速率;perf sched 显示从 wakeup 到 run 的墙钟延迟。vmstat 1 的 cs 列是最简单的一行检查。 典型 web 服务器每秒 50K req,显示每秒 10 万–50 万次上下文切换; 繁忙数据库可能每秒 1 万–5 万次。每秒百万级提示线程池太大或者锁争用过多。
要点。「上下文切换存离开线程的寄存器、挑下一个线程、装它的寄存器。 同进程内线程→线程:~100 ns 直接代价、cache 保持热。 进程→进程:~100 ns 直接 + TLB 刷 + cache 后悔 —— I/O 受限代码上墙钟影响达毫秒级。 用户态调度(goroutine、虚拟线程)对进程内场景完全避开内核,跑快 ~10 倍。」
速查表
6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。
进程和线程的区别?
进程是 OS 隔离的捆包(地址空间 + 打开文件 + 凭据 + 调度状态)。 线程是进程里的一个可调度上下文。同进程内的线程共享地址空间、fd 表、凭据 —— 只有栈和寄存器是每线程的。Linux 上两者都通过同一个 clone() syscall 创建; 差别在 flag。
为啥线程切换比进程切换便宜?
两个都存恢复寄存器(~100 ns)。进程切换额外要换 CR3(页表基址)、 在没有 ASID 标记 TLB 的 CPU 上要刷 TLB —— 让缓存的虚拟→物理翻译失效、 强制下面很多次内存访问走页表。再加上 cache 冷的后悔:对内存受限代码,墙钟影响达毫秒级。
内核线程和用户态线程的区别?
内核线程(Linux 上每个 pthread)和内核调度单位 1:1 —— 内核知道每一个。 重:每台机器实际上限 ~几千。用户态线程(goroutine、虚拟线程、async task)由 runtime 管理, 复用到更少的内核线程上。轻:每进程上百万,但底下需要非阻塞 I/O 来避免阻塞内核线程、 拖死它上面所有用户任务。
fork() 干什么、COW 怎么让它便宜?
fork() 创建一个父进程的同一份拷贝 —— 同样的代码、同样的数据、同样的 fd、同样的寄存器。COW 让它便宜: 内核拷贝页表但把每一页标成只读、加一个引用计数。 两个进程看到同样的物理页,直到有人写,触发 fault,只拷贝那一页。 典型的 fork+exec 大部分页从来不写,所以大部分页一直共享。fork 不 exec(用于并行)只为实际改的页付钱。
CPU 在用户态 vs 内核态是什么意思?
用户态(x86 ring 3、ARM EL0):不能访问内核内存、不能用特权指令、不能直接碰硬件。 应用代码住这。内核态(ring 0 / EL1):能做硬件能做的任何事。 穿越靠 syscall(主动)、中断(硬件发起)、或异常(用户代码出错)。 后 KPTI 每次穿越是 ~150 ns 的纯开销,高吞吐服务器为啥尽量少。
怎么估算上下文切换设定的最大吞吐上限?
典型内核线程切换:~1 μs CPU 时间。单核上是 100 万次/秒的天花板, 但那个速率下 CPU 只在切换、零有用工作。实际上限:每核每秒 ~10 万–50 万次切换, 超过这个开销就占主导。如果你服务器跑 50K req/s、每请求两次切换,~100K/s,没事; 如果跑 500K req/s、每请求十次切换,在浪费周期。 用 perf stat -e context-switches 量。
Code review 红旗
- 每请求 fork 一个进程。Apache prefork 风格。 低并发能跑,但 fork 的开销和每进程内存占用,过了几百并发就崩。用线程或 async。
- 没有上限的线程池。「每个进来的请求开一个新线程」无上限, 会撞上 ~几千线程的天花板,然后要么 OOM(栈预留)、要么 thrash 调度器。 用有界池 + 队列。
- async / goroutine 上下文里的阻塞调用。一次阻塞 syscall 钉住下面的内核线程,饿死它上面调度的其他每个任务。 用 async 变体,或者交给 worker 线程。
- 没有同步的共享可变状态。线程共享内存;语言里没什么会让你记得。
std::atomic、Arc<Mutex<...>>、channel, 或者消息传递 —— 总得有谁拥有同步。 - 不 yield 的紧凑 CPU 受限循环。一个 goroutine 或线程几毫秒不碰调度点(函数调用、channel 操作、syscall), 会抓着 CPU 不放、延迟同一 runqueue 上的其他一切。