操作系统核心原理 — 进程与线程
用生活化的比喻,让你理解操作系统的核心抽象:进程是资源分配的最小单位,线程是 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-操作系统内存管理