操作系统核心原理 — 进程与线程

用生活化的比喻,让你理解操作系统的核心抽象:进程是资源分配的最小单位,线程是 CPU 调度的最小单位

前置知识:第01章 编译原理 + 第02章 链接器(理解程序是怎么被编译和链接的)


阅读指南(初学者必看)

为什么你需要学习进程与线程?

你每天都在和进程、线程打交道——Node.js 是单线程事件循环,Java 游戏服务器用线程池,Chrome 每个标签页是一个进程。但你不理解底层机制:

  • 为什么 Node.js 选择单线程而不是多线程?
  • 线程切换的开销有多大?为什么说进程切换比线程切换慢?
  • 游戏服务器进程间怎么通信?

学完本章,你能回答:

  • 进程和线程的区别是什么?为什么需要两种抽象?
  • 上下文切换的开销来自哪里?
  • 常见的调度算法有哪些?CFS 是怎么工作的?
  • 进程间通信有哪些方式?共享内存为什么最快?

本文结构

第一部分:进程是什么(理解进程模型)
第二部分:线程是什么(理解线程模型)
第三部分:上下文切换(理解切换开销)
第四部分:线程调度算法(理解 CPU 如何分配时间)
第五部分:进程间通信 IPC(理解进程如何协作)
第六部分:JavaScript 中的进程与线程

3.1 进程是什么?

生活类比:进程就像一个工厂。每个工厂有自己的车间(内存)、工人(线程)、仓库(文件描述符)。工厂之间互不干扰(进程隔离),但可以通过管道(IPC)传递货物。

进程 = 资源分配的最小单位
线程 = CPU 调度的最小单位

一个进程包含:
├── 虚拟地址空间(独立的内存)
├── 文件描述符表(打开的文件、Socket)
├── 信号处理
├── 线程组
│   ├── 主线程
│   ├── 工作线程 1
│   └── 工作线程 2
└── 其他资源(共享内存、信号量等)

进程的状态

创建(New)→ 就绪(Ready)→ 运行(Running)→ 阻塞(Blocked)→ 终止(Terminated)

状态转换:
- 就绪 → 运行:被调度器选中
- 运行 → 就绪:时间片用完
- 运行 → 阻塞:等待I/O
- 阻塞 → 就绪:I/O完成

Node.js 中的进程

// 获取当前进程信息
console.log('进程ID:', process.pid);
console.log('进程标题:', process.title);
console.log('进程平台:', process.platform);
console.log('进程内存使用:', process.memoryUsage());

// 创建子进程
const { fork } = require('child_process');
const child = fork('./child.js');

// 发送消息给子进程
child.send({ hello: 'world' });

// 接收子进程消息
child.on('message', (msg) => {
  console.log('收到子进程消息:', msg);
});

3.2 线程是什么?

生活类比:线程就像进程中的一条执行线索。进程 = 工厂,线程 = 工厂里的工人。

线程 = 线程ID + 寄存器 + 栈 + 程序计数器

线程共享:
- 代码段
- 数据段
- 打开的文件

线程私有:
- 栈
- 寄存器
- 程序计数器

JavaScript 中的线程

// JavaScript 是单线程的
console.log('1');
setTimeout(() => {
  console.log('2');
}, 0);
console.log('3');
// 输出:1, 3, 2

// Web Worker(浏览器多线程)
const worker = new Worker('worker.js');
worker.postMessage({ hello: 'world' });
worker.onmessage = (e) => {
  console.log('收到Worker消息:', e.data);
};

// Node.js Worker Threads
const { Worker, isMainThread, parentPort } = require('worker_threads');
if (isMainThread) {
  const worker = new Worker(__filename);
  worker.on('message', (msg) => console.log('收到Worker消息:', msg));
  worker.postMessage({ hello: 'world' });
} else {
  parentPort.on('message', (msg) => {
    parentPort.postMessage({ reply: 'hello' });
  });
}

3.3 进程与线程的区别

特性 进程 线程
定义 资源分配的单位 CPU调度的单位
开销 大(需要分配资源) 小(共享资源)
通信 复杂(IPC) 简单(共享内存)
切换
安全 高(相互隔离) 低(共享内存)
崩溃影响 不影响其他进程 可能影响整个进程

生活比喻

进程 = 火车
线程 = 车厢

- 一列火车可以有多个车厢
- 不同火车之间相互独立
- 同一列火车的车厢共享资源
- 一节车厢出问题可能影响整列火车

3.4 上下文切换

生活类比:上下文切换就像你同时在看两本书。看了一页书 A,要去查书 B,你得先记住书 A 读到哪了(保存上下文),然后翻开书 B 的那一页(恢复上下文),看完再回来。

上下文切换的开销:
1. 保存当前线程的寄存器状态(几十个寄存器)
2. 切换虚拟地址空间(切换页表,刷新 TLB)← 如果是进程切换
3. 恢复目标线程的寄存器状态
4. 恢复执行

直接开销:
- 保存/恢复寄存器
- 更新PCB/TCB
- 切换页表

间接开销:
- 缓存失效
- TLB失效
- 分支预测失效

典型耗时:
- 线程切换:1~5 微秒
- 进程切换:5~20 微秒(含 TLB flush)

游戏中的影响:
- 60fps 游戏每帧 16.7ms
- 一次进程切换 ≈ 消耗 0.1%~0.3% 的帧时间
- 频繁切换会累积成可观的开销

为什么 Node.js / Netty 用单线程事件循环?

  • 避免线程切换开销
  • 避免锁竞争
  • 事件驱动:I/O 等待时不消耗 CPU,由操作系统通知

减少上下文切换

// 使用线程池减少线程创建销毁
const { Worker } = require('worker_threads');

class ThreadPool {
  constructor(size, workerFile) {
    this.workers = [];
    this.taskQueue = [];
    for (let i = 0; i < size; i++) {
      const worker = new Worker(workerFile);
      worker.on('message', (result) => {
        if (this.taskQueue.length > 0) {
          const task = this.taskQueue.shift();
          worker.postMessage(task);
        }
      });
      this.workers.push(worker);
    }
  }

  execute(task) {
    const idleWorker = this.workers.find(w => !w.busy);
    if (idleWorker) {
      idleWorker.busy = true;
      idleWorker.postMessage(task);
    } else {
      this.taskQueue.push(task);
    }
  }
}

3.5 线程调度算法

常见调度算法:

1. FIFO(先进先出)/ FCFS(先来先服务)
   简单但不公平,短任务可能等很久
   可能导致"护航效应"

2. SJF(最短作业优先)
   平均等待时间最短,但无法预知任务长度

3. 时间片轮转(Round Robin)⭐ 操作系统最常用
   每个线程分配一个时间片(如 10ms)
   时间片用完 → 让出 CPU → 排到队尾
   所有线程公平获得 CPU 时间

4. 优先级调度
   高优先级先执行
   可能导致"饥饿"(低优先级永远等不到)
   解决:优先级老化(等太久提升优先级)

5. CFS(Completely Fair Scheduler)── Linux 默认
   用红黑树维护所有可运行线程
   按虚拟运行时间排序,选最小的运行
   近似"理想公平调度"

6. 多级反馈队列
   多个队列,不同优先级
   时间片大小不同
   动态调整优先级

JavaScript 事件循环

// JavaScript 的"调度算法":事件循环
console.log('1');  // 同步任务
setTimeout(() => {
  console.log('2');  // 宏任务
}, 0);
Promise.resolve().then(() => {
  console.log('3');  // 微任务
});
console.log('4');  // 同步任务

// 输出:1, 4, 3, 2
// 执行顺序:同步任务 → 微任务 → 宏任务

// 事件循环伪代码
while (true) {
  executeSyncTasks();           // 1. 执行同步任务
  while (microtaskQueue.length > 0) {
    microtaskQueue.shift()();   // 2. 执行微任务
  }
  if (macrotaskQueue.length > 0) {
    macrotaskQueue.shift()();   // 3. 执行一个宏任务
  }
  if (needsRender) {
    render();                    // 4. 渲染(如果需要)
  }
}

3.6 进程间通信(IPC)

方式 原理 特点 游戏场景
管道(Pipe) 半双工字节流 父子进程间 简单数据传递
命名管道(FIFO) 文件系统中的管道 无亲缘关系进程间 进程间命令传递
消息队列 内核中的消息链表 有格式、有类型 游戏服务间通信
共享内存 多个进程映射同一块内存 最快(零拷贝) 游戏服务间大数据共享
信号量 计数器 配合共享内存使用 同步
Socket 网络通信 跨机器 游戏服务器集群
// 共享内存示例(C语言)
#include <sys/mman.h>
#include <fcntl.h>

// 进程 A:创建共享内存
int fd = shm_open("/game_data", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);  // 设置大小
void* ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 写入数据
sprintf(ptr, "Player HP: 100");

// 进程 B:打开共享内存
int fd = shm_open("/game_data", O_RDWR, 0666);
void* ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

// 读取数据
printf("%s\n", (char*)ptr);  // "Player HP: 100"

和游戏服务器的关系

  • 游戏网关和房间服务器之间可以用共享内存通信(同机器)
  • 跨机器用 Socket/RPC
  • Skynet 框架的 Actor 模型底层就是进程间通信

自问自答

Q:进程和线程的根本区别是什么? A:进程是资源分配的单位,每个进程有独立的虚拟地址空间;线程是 CPU 调度的单位,同一进程的线程共享地址空间。根本区别在于:进程间内存隔离,线程间内存共享。这也解释了为什么线程切换比进程切换快——不需要切换页表和刷新 TLB。

Q:为什么 Node.js 选择单线程事件循环? A:Node.js 的场景是 I/O 密集型(网络请求、文件读写),不是 CPU 密集型。单线程事件循环 + I/O 多路复用可以高效处理大量并发连接,避免了多线程的锁竞争和上下文切换开销。CPU 密集型任务用 Worker Threads 或子进程处理。

Q:CFS 是怎么实现"公平"的? A:CFS 给每个线程维护一个"虚拟运行时间"(vruntime)。每次选择 vruntime 最小的线程运行,运行时间越长的线程 vruntime 越大,越不容易被选中。这就像排队——谁等得最久谁先服务。红黑树保证了查找最小 vruntime 的效率是 O(log n)。

Q:游戏服务器为什么用多进程而不是多线程? A:1)故障隔离:一个进程崩溃不影响其他进程;2)方便水平扩展:每个进程可以部署在不同机器上;3)避免锁竞争:进程间通过消息传递通信,不需要锁。Skynet 框架就是多进程架构,每个 Actor 是一个独立的执行单元。

Q:共享内存为什么是最快的 IPC 方式? A:因为共享内存是"零拷贝"——数据不需要在内核空间和用户空间之间复制。管道、消息队列、Socket 都需要把数据从用户空间拷贝到内核空间,再从内核空间拷贝到另一个进程的用户空间(两次拷贝)。共享内存直接映射同一块物理内存,读写就是内存操作。

Q:JavaScript是单线程还是多线程? A:JavaScript 执行是单线程的,但浏览器/Node.js 运行时是多线程的。Web Worker/Worker Threads 可以创建多线程执行环境。

Q:为什么Chrome每个标签页一个进程? A:隔离(一个标签页崩溃不影响其他)、安全(不同网站不能互相访问内存)、稳定(内存泄漏不会影响其他标签页)。

Q:多线程一定比单线程快吗? A:不一定。多线程有开销:线程创建销毁开销、上下文切换开销、锁竞争开销。对于 I/O 密集型任务,单线程事件循环往往更高效。


实践任务

  • 任务1:编写一个简单的多线程程序(如并行计算数组求和),观察线程数和性能的关系
  • 任务2:实现生产者-消费者模型,用互斥锁 + 条件变量保证线程安全
  • 任务3:用 top -H -p <pid> 观察 Java/Node.js 进程的线程数量和 CPU 使用
  • 任务4:编写两个进程通过共享内存通信的程序,测量通信延迟
  • 任务5:对比线程锁和进程间信号量的性能差异

与其他章节的关联

本章内容 关联章节 关联点
上下文切换 第04章 内存管理 进程切换需要切换页表和刷新 TLB
I/O 多路复用 第05章 I/O与文件系统 事件循环基于 I/O 多路复用
进程间通信 第06章 TCP/IP Socket 是跨机器的 IPC 方式
线程调度 第10章 实战篇 GC 停顿和线程调度的关系
事件循环 2_1_v8Learn V8 事件循环是操作系统 I/O 模型的封装

上一章:02-链接器与加载器 | 下一章:04-操作系统内存管理