Theory · OS · Process / Thread / Coroutine
资源分配单位 vs 调度执行单位 vs 用户态执行流 —— 从 task_struct 到 goroutine 的三层抽象
OS 资源分配的最小单位:独立地址空间 + PCB;fork + 写时复制让它又快又省
CPU 调度的最小单位:共享地址空间的多条执行流;Linux 里就是带共享 flags 的 clone
用户态调度的执行流:2KB 栈 + 纳秒级切换——Go runtime 把线程的阻塞变成调度事件
What Is a Process
程序是磁盘上的静态文件(代码 + 数据);进程是程序被加载执行后的动态实体——同一程序可同时对应多个进程。进程 = 地址空间 + 寄存器状态 + 打开文件表 + 信号处理等资源的总和(OSTEP 的"机器状态"定义)。
内核描述进程的元数据:PID/PPID、进程状态、调度信息(优先级、vruntime)、mm_struct(地址空间/页表)、files_struct(打开文件表)、信号处理表、CPU 现场(寄存器快照)。上下文切换的本质就是换一个 task_struct 的现场。
配合调度器与虚拟内存,每个进程都"以为"自己独占一台机器——这是 OS 给应用的核心假象(对应 os-overview 的"抽象服务者"角色)。
| task_struct 里有什么 | 内容 / 对应内核字段 |
|---|---|
| 标识 | pid、tgid、ppid(进程树) |
| 状态 | state:R / S / D / T / Z(下一页) |
| 调度信息 | prio、policy、vruntime(CFS) |
| 地址空间 | mm(页表、VMA 列表) |
| 打开文件 | files(fd 数组 → file 结构) |
| 信号 | signal 处理表 + pending 集合 |
| 线程组 | thread_group(同 tgid 的线程链) |
State Machine · R/S/D/T/Z
fork / clone / COW
fork():复制当前进程,一次调用、两次返回(父得子 PID,子得 0);clone(flags):更底层的原语,flags 决定共享什么——线程 = clone 带上 CLONE_VM|CLONE_FILES 等共享位;execve():把当前进程的地址空间整体替换为新程序(通常 fork 后跟 exec)。
fork 时只复制页表,不复制物理页,父子页都标只读;任一方写入时触发缺页异常,内核才复制那一页并改回可写。fork 因此接近 O(页表大小) 而非 O(整个内存)。
大内存进程 fork 仍要复制整张页表,耗时与内存成正比——Redis bgsave 主线程 fork 卡顿、以及 fork 后写内存带来的瞬时内存翻倍,都是这条原理的直接后果(Redis 持久化 deck 展开)。
Threads in Linux
线程是进程内的一条执行流:拥有独立的栈、寄存器现场、程序计数器,但与同组线程共享地址空间、打开文件表、信号处理表。
内核里线程和进程都是 task_struct,区别只在创建方式:pthread_create 底层是 clone() 带 CLONE_VM | CLONE_FILES | CLONE_SIGHAND … 共享标志。同组线程共享 tgid(进程 ID),各有各的 tid(getpid vs gettid)。NPTL(glibc 2.5+,内核 2.6+)确立 1:1 模型。
私有:栈、寄存器/PC、线程 ID、信号掩码、errno(C 语言)、调度参数。共享:堆与全局变量、代码段、打开文件表、信号处理函数、当前工作目录、子进程。
// 同一地址空间里跑两个执行流 #include <pthread.h> int counter = 0; // 共享:全局变量 void* worker(void* arg) { int local = 0; // 私有:线程栈上 for (int i = 0; i < 1e5; i++) counter++; // 竞态!下篇展开 return NULL; } int main(void) { pthread_t t1, t2; pthread_create(&t1, NULL, worker, NULL); pthread_create(&t2, NULL, worker, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); }
Side by Side
| 维度 | 进程 | 线程(同进程内) |
|---|---|---|
| 定位 | 资源分配的最小单位 | CPU 调度的最小单位 |
| 地址空间 | 独立虚拟地址空间,天然隔离 | 共享进程地址空间(堆/全局可互访) |
| 创建 / 销毁 | fork + COW,要复制页表,较贵 | clone 共享资源,很便宜 |
| 切换成本 | 换寄存器 + 换页表 CR3,TLB 失效,μs 级 | 只换寄存器与栈,不换页表,更便宜 |
| 通信方式 | 必须 IPC:管道 / 消息队列 / 共享内存 / socket / 信号 | 直接读写共享内存(配锁),零拷贝 |
| 崩溃影响 | 互不影响,天然故障隔离 | 一个线程崩溃(SIGSEGV)默认拖垮整个进程 |
| 数据共享 | 难(要 IPC 或共享内存段) | 天然共享(代价是需要同步) |
1:1 · N:1 · M:N
| 模型 | 映射 | 优点 | 缺点 / 代表 |
|---|---|---|---|
| 1:1 | 每个用户线程配一个内核线程 | 真并行;一个阻塞不拖累其他 | 创建/切换贵,数量受内核限制 Linux NPTL/pthread |
| N:1 | 所有用户线程复用一个内核线程 | 切换在用户态,极快 | 一个阻塞全员卡死;用不上多核 早期"绿色线程" |
| M:N | m 个用户线程复用 n 个内核线程 | 用户态切换快 + 真并行 + 阻塞可转移 | runtime 复杂(调度、栈管理) Go goroutine、Erlang |
内核级线程(KLT):内核可见、参与调度,能并行、能被抢占,但创建/切换要进内核。用户级线程(ULT):内核不可见,runtime 在用户态调度,切换快、规模大,但内核一无所知——一个 ULT 阻塞,内核照常把整个内核线程挂起。
内核不做"用户态调度"这种有争议的优化,只提供高效的 1:1(pthread);把调度策略下放到 runtime,让每个语言按自己的负载定制——Go 的 GMP、Erlang 的进程、JVM 虚拟线程(Java 21 Loom)都是这条思路。
M:N = 在 1:1 的地基上,把"阻塞的成本"从内核搬回用户态处理。goroutine 数量与内核线程数量解耦,百万 goroutine 才成为可能。
Context Switch Internals
① 时间片耗尽;② 被更高优先级任务抢占;③ 任务主动阻塞(等 I/O、锁、sleep);④ yield。全程由内核完成——切换本身发生在内核态。
时钟中断/陷入内核 → 调度器挑选下一个任务(schedule)→ context_switch:先 switch_mm(进程级:换页表基址 CR3,TLB 失效),再 switch_to(线程级:保存/恢复寄存器、切换内核栈指针)→ 新任务从上次挂起点继续。
同一进程的线程切换跳过 switch_mm;跨进程切换多付 CR3 重装 + TLB 失效(PCID/ASID 给 TLB 打 tag 可缓解)——这就是"线程切换更便宜"的机制级解释。
| 要保存/恢复什么 | 说明 |
|---|---|
| 通用寄存器 + PC | 存入 task_struct / 内核栈,恢复时弹回 |
| 栈指针 SP | 内核栈随之切换(每个 task 一张内核栈) |
| 浮点 / SIMD 寄存器 | 惰性保存(用到才存,省切换时间) |
| 页表基址 CR3(仅进程) | 换地址空间,TLB 大面积失效 |
| 调度器元数据 | vruntime / 优先级更新,放回队列 |
Coroutine · User-space Scheduling
协程是由用户态 runtime 调度的执行流:切换不进内核、由程序显式让出(yield)或在运行时插入检查点。本质是把"线程阻塞(内核挂起 + μs 级切换)"替换为"协程让出(用户态换 PC/SP,几十 ns)"。
有栈协程:每个协程有独立栈,可在任意调用深度挂起——goroutine(2KB 起步,可增长)、Lua coroutine。无栈协程:编译器把 async 函数变成状态机,不需要独立栈,只能在 await 点挂起——Rust async、C++20 coroutine、JS async/await。Go 属于有栈阵营,代价是栈管理,收益是"哪里都能阻塞"。
栈 2KB 起(线程默认预留 8MB)+ 切换不进内核 + 元数据由 runtime 精打细算——上一页的线程上限四道闸(threads-max / pid_max / max_map_count / 栈内存)对 goroutine 几乎不构成约束,瓶颈变成真正的内存与 CPU。
| 维度 | 线程(pthread) | goroutine |
|---|---|---|
| 调度方 | 内核调度器 | Go runtime(用户态) |
| 初始栈 | 默认 8MB(预留) | 2KB,按需增长 |
| 切换成本 | ~μs(进内核) | ~几十 ns(用户态) |
| 并发规模 | 千级 | 百万级常见 |
| 阻塞 syscall | 直接挂起内核线程 | netpoller 化解;长 syscall 占 M 由 runtime 兜底 |
Process vs Thread vs Coroutine
| 维度 | 进程 | 线程 | 协程(goroutine) |
|---|---|---|---|
| 抽象层级 | OS 资源分配单位 | OS 调度单位 | runtime 调度单位 |
| 地址空间 | 独立(页表隔离) | 共享进程空间 | 共享线程所属进程空间 |
| 切换成本 | μs 级 + TLB 失效 | 亚 μs ~ μs | 几十 ns(用户态) |
| 栈 | 独立地址空间 | 默认 8MB 预留 | 2KB 起步可增长 |
| 通信 | IPC(管道/共享内存/socket…) | 共享内存 + 锁 | channel + 共享内存 + 锁 |
| 崩溃域 | 互不影响 | 同进程连带崩溃 | panic 可 recover(runtime fatal 除外) |
| 典型规模 | 百级 | 千级 | 百万级 |
Thread Crash → Process Crash?
线程触发 SIGSEGV(非法内存访问)等致命信号时,内核的默认处置是终止整个进程——不是只杀出事的线程。
崩溃发生时,共享的地址空间可能已被写坏:全局变量、堆、虚表都可能处于不一致状态。其他线程持有的锁可能永远不会释放(锁的主人死了)、数据结构可能已经损坏——内核无法证明"其余线程还能一致地跑下去",恢复成本无穷大,干脆整体终止。这是第 6 页"共享换效率"的账单。
Go 把可恢复错误下沉到 goroutine 级:panic 逐层走 defer,未捕获才终止进程;但 runtime 级 fatal error(并发 map 读写、cgo 段错误、栈耗尽)依然进程级崩溃——能 recover 的是"语言定义的错误",不是"内存已损坏的错误"。
| 场景 | 结果 |
|---|---|
| 进程 A 崩溃 | 进程 B 不受影响(地址空间隔离) |
| 线程 t1 崩溃 | 整个进程终止(信号默认行为) |
| goroutine panic + recover | 该 goroutine 结束,进程存活 |
| goroutine 未 recover panic | 进程终止(默认) |
| runtime fatal(并发 map 写等) | 进程终止,recover 也救不了 |
Go Runtime Mapping
G(goroutine):用户态执行流,独立 2KB 栈;M:真正的内核线程(OS 调度对象);P:逻辑处理器(GOMAXPROCS 个),持有本地运行队列。G 是"任务",M 是"工人",P 是"工位"——M 必须持有 P 才能跑 G。
① channel/锁阻塞:G 进等待队列,M 换下一个 G——纯用户态切换;② 网络 I/O:G 注册到 netpoller(epoll),M 继续跑别的 G;③ 阻塞 syscall/文件 I/O:M 被内核挂起,P 与之解绑交给别的 M——阻塞发生在哪一层,决定了谁来买单。
内核负责 M 之间的真并行与 CPU 时间片;runtime 负责 G 之间的快速切换与公平。GOMAXPROCS(P 数)决定并行度,goroutine 数量与线程数彻底解耦。
// 验证:goroutine 与线程解耦 func main() { for i := 0; i < 1000000; i++ { go func() { time.Sleep(time.Hour) // 挂起,不占线程 }() } time.Sleep(time.Minute) } // top 看:只有 GOMAXPROCS 量级的内核线程 // 百万协程 ≠ 百万线程
automaxprocs 库修复。
Interview QA
进程是资源分配单位(独立地址空间 + PCB),线程是调度单位(共享地址空间的多条执行流)。展开四点:地址空间、切换成本(换不换页表)、通信(IPC vs 共享内存)、健壮性(隔离 vs 连坐)。
fork 之后父子进程是两个独立执行流,从 fork 的返回点各自继续:父进程拿到子 PID(>0),子进程拿到 0,出错才返回 -1。物理上靠写时复制共享内存,逻辑上"复制了一份自己"。
子进程 exit 后父进程没调 wait/waitpid,内核保留其 PCB 与退出码,状态变 Z。处理:父进程调 wait、注册 SIGCHLD 处理、或杀掉父进程让 PID 1 收养后收尸。预防:父进程设计好收尸路径。僵尸不占内存但占 PID 与进程表项。
没有专门的线程结构:pthread_create 底层是 clone() 带共享标志(CLONE_VM/CLONE_FILES/CLONE_SIGHAND),产出的 task_struct 与父线程同 tgid 不同 tid。NPTL(glibc + 2.6 内核起)确立 1:1 模型。
致命信号(SIGSEGV)默认终止整个进程:崩溃时共享地址空间可能已损坏,其他线程依赖的锁可能永远无法释放,内核无法恢复一致性——只能整体终止。进程间有地址空间隔离,所以互不牵连。
三个数量级差异:栈 2KB vs 8MB;切换几十 ns(用户态换 gobuf)vs μs(进内核);可调度规模百万 vs 千级。内核不感知协程,切换只动 PC/SP,FPU 惰性保存。
要崩溃隔离、权限边界(插件、不可信代码、独立伸缩)→ 多进程;要高并发、密集共享数据、低延迟协作 → 线程/协程。真实系统常常混合:Nginx 多进程 + 每进程事件循环;数据库多进程多线程并存。
四道闸从紧到松:① 虚拟内存 ÷ 栈大小(32 位 3G/10M ≈ 300 个;64 位几乎不受限);② threads-max;③ pid_max(旧默认 32768,4.15+ 64 位默认 4194304);④ max_map_count(每线程的栈 VMA)。64 位上一般先撞 threads-max 或内存。
Related & References
GMP 调度模型 →(M:N 的完整实现)
Redis 持久化 →(fork + COW 的真实代价)
内存分配与逃逸分析 →(goroutine 栈的连续扩容)
参考来源(本 deck 结论可溯源至下列一手材料)
| OSTEP ch.4–7(进程抽象 / 进程 API / 机制) | 进程定义、fork/exec 语义、受限直接执行与上下文切换机制 |
| man 2 fork / clone / execve / wait | fork 两次返回、clone 共享标志、收尸语义 |
| CSAPP ch.8 异常控制流 | 信号(SIGSEGV/SIGCHLD)与进程终止语义 |
| Go src/runtime/proc.go(本机 Go 版本) | GMP 结构、M 与内核线程绑定、P 解绑 handoff 逻辑 |
| xiaolincoding.com《图解系统》process_base / thread_crash / create_thread_max | 状态模型、线程崩溃语义、线程数四道闸(threads-max/pid_max/max_map_count 实测) |
| Linux kernel docs:PID namespace / NPTL(7) | tgid vs tid、1:1 线程模型的历史确立 |