跳转到内容

进程、线程与上下文切换

操作系统对 CPU 的第一个抽象:进程是资源与保护的单位,线程是调度的单位。本章讲进程的地址空间与状态、fork/exec 模型、线程的实现、上下文切换的成本,以及协程为何再度流行。

进程是被操作系统虚拟出来的一台机器:独立的虚拟地址空间、文件描述符表、信号处理、一组寄存器状态。隔离由此而来——一个进程不能直接读写另一个进程的内存(→ 虚拟内存、分页与 TLB)。

进程状态机:运行 → 就绪 → 等待(阻塞)。等待 I/O 或锁的进程让出 CPU,就绪队列里的进程等待被调度(→ CPU 调度)。

Unix 创建进程的两步:

  • fork:复制当前进程(地址空间、fd 表全部复制)——写时复制(copy-on-write)让创建进程便宜:父子共享物理页,标记只读,谁写谁触发缺页、才真正复制。fork 本身只是复制页表;
  • exec:替换地址空间为新程序,保留 fd(可配置)与 pid。

fork/exec 分离的设计回报:fork 后、exec 前可以配置新进程(重定向 fd、设置环境)——shell 的管道与重定向就是在这两步之间搭的(ls | grep x:fork 两次,中间把 fd 1 重定向到管道)。Windows 的单步 CreateProcess 模型少这层灵活性,多这类参数。

fork 的写时复制让创建进程便宜,但多线程程序 fork 是著名的坑:只有调用线程存活,锁的状态可能死锁——现代做法是 posix_spawn。

线程共享进程的地址空间(代码、堆、打开的文件),私有栈与寄存器:

  • 内核级线程:内核知道并调度每个线程;创建与切换走系统调用,但能用上多核;
  • 用户级线程(绿色线程):用户态库管理,切换快、创建海量线程,但阻塞系统调用会卡住整个进程(内核看不到);
  • M:N 模型:用户线程多路复用到内核线程——现代语言运行时(Go goroutine、Erlang 进程、Java 虚拟线程)的路线。

切换进程/线程:保存寄存器 → 切换内核栈 → 切换地址空间 → 恢复。直接成本微秒级,隐性成本是缓存与 TLB 失效:

  • 切换地址空间使 TLB(可能还有缓存)失效——新进程的访存全部未命中,真实成本是数十微秒到毫秒级的"热身";
  • 这就是"上下文切换的隐性成本是缓存与 TLB 失效",也是调度器避免频繁切换(时间片不能太小)的原因(→ CPU 调度)。

协程把调度交给用户态以避免内核开销:协作式让出(yield)代替抢占式切换,切换只保存少量寄存器与栈指针,不进内核、不换地址空间——纳秒到微秒级。海量并发 I/O(10 万连接)由此可行:goroutine、async/await、虚拟线程都是协程(→ I/O 模型与事件驱动)。

代价是协作式调度:不让出的协程饿死其他协程(阻塞系统调用、CPU 密集循环)。运行时的应对:Go 在系统调用与分配点插入抢占检查。

  • 进程是资源与保护的单位,线程是调度的单位;隔离来自独立地址空间
  • fork 的写时复制让创建进程便宜;fork/exec 分离让"创建后配置"成为可能
  • 上下文切换的隐性成本是缓存与 TLB 失效,直接成本反而是小头
  • 内核级线程用得上多核,用户级线程切换快;M:N 是现代运行时的折中
  • 协程把调度交给用户态以避免内核开销,代价是协作式的饿死风险