Theory · OS · Process / Thread / Coroutine

进程、线程与协程

资源分配单位 vs 调度执行单位 vs 用户态执行流 —— 从 task_struct 到 goroutine 的三层抽象

进程

OS 资源分配的最小单位:独立地址空间 + PCB;fork + 写时复制让它又快又省

线程

CPU 调度的最小单位:共享地址空间的多条执行流;Linux 里就是带共享 flags 的 clone

协程

用户态调度的执行流:2KB 栈 + 纳秒级切换——Go runtime 把线程的阻塞变成调度事件

定位:OS 系列第二篇。三层抽象层层下沉——进程管隔离,线程管并行,协程管阻塞成本。Go 岗必考:goroutine 与线程的映射(M:N)贯穿全篇。

What Is a Process

进程 = 程序的一次运行实例 = 资源的总和

程序 vs 进程

程序是磁盘上的静态文件(代码 + 数据);进程是程序被加载执行后的动态实体——同一程序可同时对应多个进程。进程 = 地址空间 + 寄存器状态 + 打开文件表 + 信号处理等资源的总和(OSTEP 的"机器状态"定义)。

PCB:进程控制块(Linux = task_struct)

内核描述进程的元数据:PID/PPID、进程状态、调度信息(优先级、vruntime)、mm_struct(地址空间/页表)、files_struct(打开文件表)、信号处理表、CPU 现场(寄存器快照)。上下文切换的本质就是换一个 task_struct 的现场。

进程是 CPU 的抽象

配合调度器与虚拟内存,每个进程都"以为"自己独占一台机器——这是 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 的线程链)
进程树:Linux 以 PID 1(systemd/init)为根——所有进程都是它的后代;父进程先死,子进程被 PID 1 收养(孤儿进程,无害)。
面试口径:"进程是资源分配的最小单位,线程是 CPU 调度的最小单位"——这一句是所有追问的锚点。
PCB 用"总和"口径讲:地址空间+现场+文件表+信号。task_struct 字段表给足细节弹药;线程组字段为第 5 页"线程就是特殊进程"埋伏笔。

State Machine · R/S/D/T/Z

五态模型 vs Linux 的真实状态

进程五态模型状态机与 Linux 状态对照 状态机图:创建态进入就绪态,就绪态被调度后进入运行态,运行态时间片用完回到就绪态,运行态等待事件时进入阻塞态,事件完成后回到就绪态,运行态结束进入终止态。下方对照表列出 Linux 的 R、S、D、T、Z、X 六种状态与五态的对应关系。 创建 New fork 中 / 初始化 就绪 Ready 万事俱备,只差 CPU 运行 Running 正在 CPU 上执行 阻塞 Waiting 等 I/O / 锁 / 信号 终止 Terminated 退出,等父进程收尸 被调度 时间片用完 / 被抢占 主动等待事件 事件完成,重新排队 exit Linux 实际状态(ps S 列):R 运行/就绪 · S 可中断睡眠 · D 不可中断睡眠(通常等磁盘 I/O,kill -9 也没用)· T 停止(SIGSTOP)· Z 僵尸 · X 死亡 僵尸 Z:子进程已 exit,父进程还没 wait() 收尸——只剩 PCB 与退出码占着 PID;孤儿:父先死,被 PID 1 收养,正常退出即回收 高频追问:D 状态为什么杀不掉?——它在内核态等硬件应答,不检查信号;僵尸为什么有危害?——占 PID 与进程表项,堆积到 pid_max 耗尽
状态机的两个易错点:阻塞→就绪不能直接跳运行(要先排队);S 与 D 的区别(D 不响应信号,磁盘 I/O 或 NFS 常见)。僵尸/孤儿/D 状态是 ps 实战三连问。

fork / clone / COW

fork + 写时复制:创建进程为什么快

三个创建原语

fork():复制当前进程,一次调用、两次返回(父得子 PID,子得 0);clone(flags):更底层的原语,flags 决定共享什么——线程 = clone 带上 CLONE_VM|CLONE_FILES 等共享位;execve():把当前进程的地址空间整体替换为新程序(通常 fork 后跟 exec)。

写时复制 Copy-On-Write

fork 时只复制页表,不复制物理页,父子页都标只读;任一方写入时触发缺页异常,内核才复制那一页并改回可写。fork 因此接近 O(页表大小) 而非 O(整个内存)。

工程代价(高频实战题)

大内存进程 fork 仍要复制整张页表,耗时与内存成正比——Redis bgsave 主线程 fork 卡顿、以及 fork 后写内存带来的瞬时内存翻倍,都是这条原理的直接后果(Redis 持久化 deck 展开)。

写时复制:fork 前后父子进程与物理页的关系 示意图:fork 后父子进程各自持有页表,但页表项都指向同一组物理页且标记为只读;子进程写入页面时触发缺页异常,内核复制该物理页并更新子进程页表项为可写,其余页面继续共享。 FORK 之后 父进程页表 页0 → 只读 页1 → 只读 子进程页表 页0 → 只读 页1 → 只读 物理页0 物理页1 四条映射,两块物理页 子进程写页0 → 缺页 → 复制 物理页0' 物理页1 只复制 被写的那页 fork 便宜(只复制页表)· 代价延后到第一次写 Redis RDB 的 fork 卡顿与内存翻倍都源于此
COW 是虚拟内存缺页机制的第一个应用(第 7 篇会再遇到缺页)。工程落点:Redis fork。clone flags 讲清"线程=共享版进程",为下一页铺垫。

Threads in Linux

线程 = 共享地址空间的执行流 = 共享版的进程

定义

线程是进程内的一条执行流:拥有独立的栈、寄存器现场、程序计数器,但与同组线程共享地址空间、打开文件表、信号处理表

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 模型。

私有 vs 共享(面试必背)

私有:栈、寄存器/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);
}
为什么线程切换便宜:同进程线程共享页表——上下文切换不用换 CR3、TLB 不失效,只需换寄存器与栈指针。
为什么线程通信免费:共享地址空间 = 全局变量直接可读写。免费的同时引入竞态条件——这正是下下篇"同步与互斥"存在的理由。
Linux 无专门线程结构——线程=clone 共享版进程,这是 Linux 特色考点。私有/共享清单必须能脱口而出;右侧代码演示"共享引入竞态",为 thread-sync deck 立靶子。

Side by Side

进程 vs 线程:一张表答完所有追问

维度进程线程(同进程内)
定位资源分配的最小单位CPU 调度的最小单位
地址空间独立虚拟地址空间,天然隔离共享进程地址空间(堆/全局可互访)
创建 / 销毁fork + COW,要复制页表,较贵clone 共享资源,很便宜
切换成本换寄存器 + 换页表 CR3,TLB 失效,μs 级只换寄存器与栈,不换页表,更便宜
通信方式必须 IPC:管道 / 消息队列 / 共享内存 / socket / 信号直接读写共享内存(配锁),零拷贝
崩溃影响互不影响,天然故障隔离一个线程崩溃(SIGSEGV)默认拖垮整个进程
数据共享难(要 IPC 或共享内存段)天然共享(代价是需要同步)
怎么选(工程口径):需要隔离、权限边界、崩溃免疫——多进程(Nginx 的 master/worker、Chrome 多进程、数据库主进程);需要高并发协作、密集共享数据——多线程 / 协程(Go 服务的默认答案)。
面试金句:"进程用隔离换稳定,线程用共享换效率——共享省下了通信拷贝,却把正确性责任交给了锁。"
这张表是标准八股答案的上限版本:每一行都能往下追问一层(切换成本→CR3/TLB;通信→IPC 对比表;崩溃→信号机制)。选型例子给两个真实系统背书。

1:1 · N:1 · M:N

用户线程与内核线程的映射:Go 为什么选 M:N

模型映射优点缺点 / 代表
1:1每个用户线程配一个内核线程真并行;一个阻塞不拖累其他创建/切换贵,数量受内核限制
Linux NPTL/pthread
N:1所有用户线程复用一个内核线程切换在用户态,极快一个阻塞全员卡死;用不上多核
早期"绿色线程"
M:Nm 个用户线程复用 n 个内核线程用户态切换快 + 真并行 + 阻塞可转移runtime 复杂(调度、栈管理)
Go goroutine、Erlang
M:N 的核心难点:runtime 要自己处理调度、抢占、栈管理、阻塞系统调用的线程转移——Go 用 GMP 模型 + sysmon 监控 + netpoller 逐个解决(GMP deck 展开全过程)。

内核级线程 vs 用户级线程

内核级线程(KLT):内核可见、参与调度,能并行、能被抢占,但创建/切换要进内核。用户级线程(ULT):内核不可见,runtime 在用户态调度,切换快、规模大,但内核一无所知——一个 ULT 阻塞,内核照常把整个内核线程挂起。

为什么 Linux 固守 1:1 而 Go 在其上叠 M:N

内核不做"用户态调度"这种有争议的优化,只提供高效的 1:1(pthread);把调度策略下放到 runtime,让每个语言按自己的负载定制——Go 的 GMP、Erlang 的进程、JVM 虚拟线程(Java 21 Loom)都是这条思路。

一句总结

M:N = 在 1:1 的地基上,把"阻塞的成本"从内核搬回用户态处理。goroutine 数量与内核线程数量解耦,百万 goroutine 才成为可能。

三模型对比表 + KLT/ULT 概念。关键叙事:Linux 固守 1:1,把调度自由度留给 runtime——Go/Java Loom/Erlang 都是 M:N 的当代实践。为 GMP deck 挂链接。

Context Switch Internals

上下文切换切的是什么:从时钟中断到 switch_to

触发时机

① 时间片耗尽;② 被更高优先级任务抢占;③ 任务主动阻塞(等 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 / 优先级更新,放回队列
成本去向:直接成本(保存恢复现场)是小头;大头是间接成本——切走期间 cache/TLB 被别人的数据污染,切回来要重新预热(os-overview 第 7 页的展开)。
Go 对照:goroutine 切换只保存 PC/SP/g 寄存器(gobuf 结构),在用户态完成,几十纳秒;连 FPU/SIMD 都沿用"用到才存"的惰性思路。
机制级答案:switch_mm(页表)+ switch_to(寄存器/内核栈)。惰性 FPU 保存是细节加分项。Go 的 gobuf 对照把抽象概念落到 runtime 源码级别。

Coroutine · User-space Scheduling

协程:把"阻塞"从内核事件变成调度事件

定义

协程是由用户态 runtime 调度的执行流:切换不进内核、由程序显式让出(yield)或在运行时插入检查点。本质是把"线程阻塞(内核挂起 + μs 级切换)"替换为"协程让出(用户态换 PC/SP,几十 ns)"。

有栈 vs 无栈

有栈协程:每个协程有独立栈,可在任意调用深度挂起——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 兜底
面试金句:"协程不是更小的线程,而是阻塞语义的重新定价——线程阻塞要惊动内核,goroutine 阻塞只是调度队列里的一次搬家。"
边界意识:goroutine 里的阻塞式文件 I/O / cgo 调用仍会占住内核线程(M)——runtime 会唤醒新 M 接管 P,但线程数会涨;真正想"零线程占用"要走 netpoller 化的异步路径。
有栈/无栈的分歧点在"能不能在任意深度挂起"。goroutine vs pthread 数字表是简历项目的必背弹药。边界意识页防止"协程万能论":cgo 与阻塞 syscall 仍是线程池的天敌。

Process vs Thread vs Coroutine

一页总表:进程 / 线程 / 协程

维度进程线程协程(goroutine)
抽象层级OS 资源分配单位OS 调度单位runtime 调度单位
地址空间独立(页表隔离)共享进程空间共享线程所属进程空间
切换成本μs 级 + TLB 失效亚 μs ~ μs几十 ns(用户态)
独立地址空间默认 8MB 预留2KB 起步可增长
通信IPC(管道/共享内存/socket…)共享内存 + 锁channel + 共享内存 + 锁
崩溃域互不影响同进程连带崩溃panic 可 recover(runtime fatal 除外)
典型规模百级千级百万级
记忆主线:越往下"隔离"越弱、"共享与轻量"越强。进程买隔离,线程买并行,协程买吞吐——没有免费的午餐:隔离弱了,正确性责任(锁、panic 处理)就重了。
常见追问的落点:"为什么 Go 服务不开几千个线程也要开百万 goroutine?"——用这张表的行逐条回答:栈、切换、调度方、阻塞成本。每行都指回前面机制页的原理。
汇总页:七行三列。讲解时按"隔离→共享→轻量"的主线串行,把每行锚回前面的机制页(页表/CR3、clone、gobuf、netpoller)。

Thread Crash → Process Crash?

线程崩溃,进程为什么也崩:共享的代价

默认行为:整个进程终止

线程触发 SIGSEGV(非法内存访问)等致命信号时,内核的默认处置是终止整个进程——不是只杀出事的线程。

为什么不能"只杀那个线程"

崩溃发生时,共享的地址空间可能已被写坏:全局变量、堆、虚表都可能处于不一致状态。其他线程持有的锁可能永远不会释放(锁的主人死了)、数据结构可能已经损坏——内核无法证明"其余线程还能一致地跑下去",恢复成本无穷大,干脆整体终止。这是第 6 页"共享换效率"的账单。

Go 的例外

Go 把可恢复错误下沉到 goroutine 级:panic 逐层走 defer,未捕获才终止进程;但 runtime 级 fatal error(并发 map 读写、cgo 段错误、栈耗尽)依然进程级崩溃——能 recover 的是"语言定义的错误",不是"内存已损坏的错误"。

场景结果
进程 A 崩溃进程 B 不受影响(地址空间隔离)
线程 t1 崩溃整个进程终止(信号默认行为)
goroutine panic + recover该 goroutine 结束,进程存活
goroutine 未 recover panic进程终止(默认)
runtime fatal(并发 map 写等)进程终止,recover 也救不了
工程推论:把不稳定组件放进独立进程(Nginx worker、Chrome 渲染进程、插件进程)是唯一可靠的故障隔离——多进程的隔离成本买的就是这份保险。
面试金句:"崩溃粒度由地址空间边界决定:边界内的任何崩溃都可能污染共享状态,所以只能整体终止——这就是隔离的真正含义。"
答案内核:崩溃后共享状态无法保证一致,锁的主人已死。Go 的 recover 是语言级错误通道,不与内存损坏竞争。工程推论连回微服务/多进程架构的可靠性设计。

Go Runtime Mapping

Go 在 OS 这层借了什么:G ↔ M 的映射细则

GMP 对 OS 概念的映射

G(goroutine):用户态执行流,独立 2KB 栈;M:真正的内核线程(OS 调度对象);P:逻辑处理器(GOMAXPROCS 个),持有本地运行队列。G 是"任务",M 是"工人",P 是"工位"——M 必须持有 P 才能跑 G。

阻塞的分类处理(本 deck 概念的收口)

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 量级的内核线程
// 百万协程 ≠ 百万线程
容器陷阱(运维高频):Go 1.25 前 GOMAXPROCS 默认取宿主机核数而非 cgroup CPU limit——容器限 1 核、宿主 96 核时,runtime 开 96 个 P 抢 1 核的配额,调度抖动。Go 1.25 起默认尊重 cgroup limit,旧版本用 automaxprocs 库修复。
链接:调度细节(work stealing / 抢占 / netpoller)在 GMP 调度模型 deck;goroutine 栈的连续扩容在 内存分配 deck
三层阻塞处理是本篇与 GMP deck 的接口:channel/锁(用户态)、网络(netpoller)、syscall(M 挂起 P 解绑)。容器 GOMAXPROCS 陷阱是 Go 1.25 的版本敏感点,旧版 automaxprocs 是标准答案。

Interview QA

高频追问:进程 / 线程 / 协程

1 · 进程和线程的区别?(必考,答出层次感)

资源 vs 调度

进程是资源分配单位(独立地址空间 + PCB),线程是调度单位(共享地址空间的多条执行流)。展开四点:地址空间、切换成本(换不换页表)、通信(IPC vs 共享内存)、健壮性(隔离 vs 连坐)。

2 · fork 返回两次是怎么回事?

一次调用两次返回

fork 之后父子进程是两个独立执行流,从 fork 的返回点各自继续:父进程拿到子 PID(>0),子进程拿到 0,出错才返回 -1。物理上靠写时复制共享内存,逻辑上"复制了一份自己"。

3 · 僵尸进程怎么产生、怎么处理?

wait 未收尸

子进程 exit 后父进程没调 wait/waitpid,内核保留其 PCB 与退出码,状态变 Z。处理:父进程调 wait、注册 SIGCHLD 处理、或杀掉父进程让 PID 1 收养后收尸。预防:父进程设计好收尸路径。僵尸不占内存但占 PID 与进程表项。

4 · Linux 里线程到底是怎么实现的?

clone 共享 flags

没有专门的线程结构:pthread_create 底层是 clone() 带共享标志(CLONE_VM/CLONE_FILES/CLONE_SIGHAND),产出的 task_struct 与父线程同 tgid 不同 tid。NPTL(glibc + 2.6 内核起)确立 1:1 模型。

5 · 为什么线程崩溃进程就崩溃?

共享状态污染

致命信号(SIGSEGV)默认终止整个进程:崩溃时共享地址空间可能已损坏,其他线程依赖的锁可能永远无法释放,内核无法恢复一致性——只能整体终止。进程间有地址空间隔离,所以互不牵连。

6 · 协程为什么比线程轻?轻在哪几个数字上?

栈 + 切换 + 元数据

三个数量级差异:栈 2KB vs 8MB;切换几十 ns(用户态换 gobuf)vs μs(进内核);可调度规模百万 vs 千级。内核不感知协程,切换只动 PC/SP,FPU 惰性保存。

7 · 多线程和多进程怎么选?

隔离 vs 共享

要崩溃隔离、权限边界(插件、不可信代码、独立伸缩)→ 多进程;要高并发、密集共享数据、低延迟协作 → 线程/协程。真实系统常常混合:Nginx 多进程 + 每进程事件循环;数据库多进程多线程并存。

8 · 一个进程最多能开多少线程?

四道闸

四道闸从紧到松:① 虚拟内存 ÷ 栈大小(32 位 3G/10M ≈ 300 个;64 位几乎不受限);② threads-max;③ pid_max(旧默认 32768,4.15+ 64 位默认 4194304);④ max_map_count(每线程的栈 VMA)。64 位上一般先撞 threads-max 或内存。

八题覆盖本篇全部机制页。第 1 题给"层次感"答法;第 8 题的四道闸来自实测验证(threads-max 14553 / pid_max 32768 / max_map_count 65530 是老内核默认值,注明 4.15+ 的 pid_max 变化)。

Related & References

相关知识点与参考

OS 系列(本分类)

操作系统总览 →(内核态与系统调用是本文的地基)
CPU 调度 →(就绪队列里怎么挑下一个)
同步与互斥 →(共享内存的竞态怎么解)
进程间通信 →(隔离之后的通信补全)

跨领域联动

GMP 调度模型 →(M:N 的完整实现)
Redis 持久化 →(fork + COW 的真实代价)
内存分配与逃逸分析 →(goroutine 栈的连续扩容)

参考来源(本 deck 结论可溯源至下列一手材料)

OSTEP ch.4–7(进程抽象 / 进程 API / 机制)进程定义、fork/exec 语义、受限直接执行与上下文切换机制
man 2 fork / clone / execve / waitfork 两次返回、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 线程模型的历史确立
收尾:OSTEP ch4-7 是主理论源,clone/man 是线程实现的一手出处,Go 源码定位到 proc.go。总页数 14。下一篇:CPU 调度。