进程与线程
在前端面试中,"进程和线程有什么区别?""浏览器有哪些进程?""为什么 JavaScript 是单线程的?"几乎是必考题。理解进程与线程的本质,不仅能帮助我们回答这些问题,更能让我们深入理解浏览器的工作原理、JavaScript 的执行机制,以及 Web Worker 等多线程方案的设计思路。
基本概念
什么是进程(Process)
进程是操作系统分配资源的最小单位,可以理解为一个正在运行的程序实例。每个进程拥有独立的内存空间、代码、数据和系统资源。
用一个生活中的例子来类比:进程就像一家工厂。每家工厂有自己独立的厂房(内存空间)、设备(系统资源)和原材料(数据),不同工厂之间互不干扰。
进程的核心特点:
| 特点 | 说明 |
|---|---|
| 独立性 | 每个进程有自己独立的内存空间,一个进程崩溃不会影响其他进程 |
| 隔离性 | 进程之间不能直接访问对方的内存,需要通过 IPC(进程间通信)来交换数据 |
| 资源开销大 | 创建和销毁进程的成本较高,需要分配独立的内存和系统资源 |
什么是线程(Thread)
线程是 CPU 调度的最小单位,是进程中的一个执行单元。一个进程可以包含多个线程,这些线程共享进程的内存空间和资源,但每个线程有自己独立的执行栈和程序计数器。
继续用工厂的类比:线程就像工厂里的工人。工人们共享同一间厂房和设备,可以协作完成工作,但每个工人有自己的工位(执行栈)和任务清单(程序计数器)。
线程的核心特点:
| 特点 | 说明 |
|---|---|
| 共享性 | 同一进程内的线程共享内存空间,可以直接访问共享数据 |
| 轻量级 | 创建和销毁线程的开销远小于进程 |
| 协作性 | 多个线程可以并发执行,提高程序效率 |
| 风险性 | 一个线程崩溃可能导致整个进程崩溃(因为共享内存) |
进程与线程的关系和区别
| 对比项 | 进程 | 线程 |
|---|---|---|
| 定义 | 资源分配的最小单位 | CPU 调度的最小单位 |
| 内存 | 拥有独立内存空间 | 共享所属进程的内存空间 |
| 通信方式 | IPC(管道、消息队列、共享内存等) | 直接读写共享变量(需注意同步) |
| 创建开销 | 大(需要分配独立资源) | 小(共享进程资源) |
| 崩溃影响 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
| 切换开销 | 大(需要切换内存空间等上下文) | 小(只需切换执行栈和寄存器) |
| 数量关系 | 一个进程至少包含一个线程 | 线程不能独立于进程存在 |
进程是资源的容器,线程是执行的单元。 一个进程是一家工厂,线程是工厂里的工人。工人共享工厂的设备和资源,但各自有独立的工作任务。
浏览器的多进程架构
现代浏览器(以 Chrome 为代表)采用多进程架构。早期浏览器是单进程的,所有功能(页面渲染、JS 执行、插件运行、网络请求等)都挤在一个进程里,任何一部分出问题整个浏览器就会崩溃。
早期单进程浏览器的问题
单进程架构存在三大问题:
| 问题 | 说明 |
|---|---|
| 不稳定 | 一个插件崩溃或一段恶意 JS 就能让整个浏览器挂掉 |
| 不安全 | 插件和页面脚本可以直接访问操作系统资源,存在安全隐患 |
| 不流畅 | 一个页面的 JS 死循环会导致所有页面无响应 |
Chrome 的多进程架构
Chrome 将不同功能拆分到独立的进程中,每个进程各司其职:
各进程的职责详解:
| 进程 | 职责 | 数量 |
|---|---|---|
| 浏览器主进程 | 管理地址栏、书签、前进/后退按钮;协调其他进程;处理文件访问、网络等特权操作 | 1 个 |
| 渲染进程 | 解析 HTML/CSS、执行 JavaScript、计算布局、绘制页面。这是前端开发最核心的进程 | 通常每个标签页一个 |
| 网络进程 | 处理所有网络请求(HTTP、WebSocket 等) | 1 个 |
| GPU 进程 | 处理 GPU 任务,如页面合成、CSS 动画、WebGL 等 | 1 个 |
| 插件进程 | 运行浏览器插件(如 Flash,现已淘汰) | 每个插件一个 |
| 实用程序进程 | 处理音视频解码等辅助任务 | 按需创建 |
在 Chrome 中按 Shift + Esc(Mac 上在菜单栏选择"窗口" → "任务管理器")打开 Chrome 的任务管理器,可以看到每个进程的名称、内存占用和 CPU 使用率。
多进程架构的优势
| 优势 | 说明 |
|---|---|
| 稳定性 | 一个标签页崩溃不会影响其他标签页,关掉崩溃的标签页即可恢复 |
| 安全性 | 渲染进程运行在沙箱(Sandbox) 中,即使被恶意代码攻击也无法直接访问操作系统资源 |
| 流畅性 | 每个标签页有独立的渲染进程,一个页面卡顿不会拖慢其他页面 |
多进程架构也有缺点:内存消耗更大。每个进程都需要独立的内存空间,所以 Chrome 被称为"内存大户"。Chrome 为此做了优化——当内存不足时,会将同一站点的多个标签页合并到同一个渲染进程中(站点隔离策略下会有所不同)。
站点隔离(Site Isolation)
Chrome 67 之后引入了站点隔离策略,确保不同站点(Origin)的页面运行在不同的渲染进程中。即使是同一个标签页中的 <iframe>,如果它来自不同的站点,也会在独立的渲染进程中运行。
站点隔离的意义在于安全防护——防止恶意页面通过 Spectre 等 CPU 漏洞读取其他站点的数据。
渲染进程中的线程
渲染进程是前端开发者最需要关注的进程,它内部包含多个重要线程:
| 线程 | 职责 | 与前端开发的关系 |
|---|---|---|
| 主线程 | 解析 HTML/CSS → 构建 DOM/CSSOM → 样式计算 → 布局 → 绘制 → 执行 JS | 前端开发最核心的线程,JS 代码就在这里运行 |
| 合成线程 | 接收绘制指令,将页面分层、分块,调度光栅化,生成合成帧 | transform/opacity 动画由它处理,不受主线程阻塞影响 |
| 光栅化线程池 | 将绘制指令转换为实际的像素位图 | GPU 加速在这里发挥作用 |
| IO 线程 | 处理进程间通信(IPC)消息 | 接收来自浏览器主进程和其他进程的消息 |
JavaScript 执行和页面渲染共享同一个主线程。这意味着:
- JS 执行时,渲染会被阻塞(页面无法更新)
- 渲染进行时,JS 无法执行
这就是为什么长时间运行的 JS 代码会导致页面卡顿、无响应。
JavaScript 为什么是单线程的
这是面试中的经典问题。JavaScript 从诞生之初就被设计为单线程语言,原因如下:
设计初衷
JavaScript 最初是为了在浏览器中操作 DOM 而设计的。如果 JS 是多线程的,会出现什么问题?
假设两个线程同时操作同一个 DOM 元素:一个线程要删除它,另一个线程要修改它的样式——浏览器将无法决定以谁为准。要解决这个问题就需要引入锁机制,这会让浏览器端的 JS 开发变得异常复杂。
为了避免这种复杂性,JavaScript 被设计为单线程:同一时间只能做一件事。
单线程不意味着低效
虽然 JavaScript 是单线程的,但浏览器本身是多线程、多进程的。JavaScript 通过事件循环(Event Loop) 和异步编程来实现高效运行:
| 异步任务 | 实际处理者 | 完成后 |
|---|---|---|
setTimeout / setInterval | 定时器线程 | 回调加入宏任务队列 |
网络请求(fetch、XHR) | 网络进程 | 回调加入微/宏任务队列 |
| DOM 事件监听 | 事件触发线程 | 回调加入宏任务队列 |
Promise.then | 主线程自身 | 回调加入微任务队列 |
MutationObserver | 主线程自身 | 回调加入微任务队列 |
JavaScript 是单线程的,但浏览器不是单线程的。JS 主线程只负责执行代码和处理回调,耗时的 IO 操作(网络、定时器、用户事件等)都由浏览器的其他线程或进程去处理,完成后将回调放入任务队列,等主线程空闲时再执行。这就是 JavaScript 虽然单线程却能处理并发任务的秘密。
Web Worker:突破单线程限制
虽然 JS 主线程是单线程的,但 HTML5 引入了 Web Worker,允许我们在后台创建独立的线程来执行 JavaScript 代码,从而避免阻塞主线程。
Worker 的基本使用
// main.js —— 主线程
const worker = new Worker('worker.js');
// 向 Worker 发送消息
worker.postMessage({ type: 'calculate', data: [1, 2, 3, 4, 5] });
// 接收 Worker 返回的结果
worker.onmessage = (event) => {
console.log('计算结果:', event.data); // 15
};
// 错误处理
worker.onerror = (error) => {
console.error('Worker 出错:', error.message);
};
// worker.js —— Worker 线程
self.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'calculate') {
// 在后台线程执行耗时计算,不会阻塞主线程
const result = data.reduce((sum, num) => sum + num, 0);
self.postMessage(result);
}
};
Worker 的通信机制
主线程和 Worker 线程之间通过 postMessage 进行通信,数据通过结构化克隆算法(Structured Clone) 进行深拷贝传递,而不是共享内存。
数据通过深拷贝传递意味着大量数据的传输本身也有性能开销。对于大型数据(如图像、音频等),可以使用 Transferable Objects(可转移对象)来实现零拷贝传输:
// 使用 Transferable Objects 传递 ArrayBuffer(零拷贝)
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
worker.postMessage(buffer, [buffer]); // 第二个参数指定可转移对象
// 注意:传输后主线程就不能再访问 buffer 了,所有权转移给了 Worker
Worker 的三种类型
| 类型 | 特点 | 使用场景 |
|---|---|---|
| Dedicated Worker | 只能被创建它的页面使用,一对一关系 | 大量计算、数据处理、图像处理 |
| Shared Worker | 可以被同源的多个页面共享,多对一关系 | 多标签页共享数据、WebSocket 连接共享 |
| Service Worker | 独立于页面运行,可以拦截网络请求,具有生命周期 | 离线缓存(PWA)、推送通知、后台同步 |
Dedicated Worker 示例:图像处理
// main.js
const worker = new Worker('image-worker.js');
// 获取图片像素数据
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 将像素数据传给 Worker 处理(使用 Transferable 零拷贝)
worker.postMessage(
{ imageData: imageData.data.buffer, width: canvas.width, height: canvas.height },
[imageData.data.buffer]
);
worker.onmessage = (event) => {
// 接收处理后的像素数据,渲染回画布
const processedData = new ImageData(
new Uint8ClampedArray(event.data),
canvas.width,
canvas.height
);
ctx.putImageData(processedData, 0, 0);
};
// image-worker.js
self.onmessage = (event) => {
const { imageData, width, height } = event.data;
const pixels = new Uint8ClampedArray(imageData);
// 灰度化处理(耗时操作,在 Worker 中执行不会卡顿页面)
for (let i = 0; i < pixels.length; i += 4) {
const gray = pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114;
pixels[i] = gray; // R
pixels[i + 1] = gray; // G
pixels[i + 2] = gray; // B
// pixels[i + 3] 是 Alpha,保持不变
}
self.postMessage(pixels.buffer, [pixels.buffer]);
};
Service Worker 示例:离线缓存
// 注册 Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then((registration) => {
console.log('Service Worker 注册成功,作用域:', registration.scope);
});
}
// sw.js —— Service Worker
const CACHE_NAME = 'my-app-v1';
const CACHE_URLS = ['/', '/index.html', '/styles.css', '/app.js'];
// 安装阶段:预缓存资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(CACHE_URLS))
);
});
// 拦截网络请求:缓存优先策略
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});
Worker 的限制
Worker 虽然强大,但也有诸多限制:
| 限制 | 原因 |
|---|---|
| 不能操作 DOM | DOM 操作只能在主线程进行,避免多线程同时修改 DOM 的冲突 |
不能使用 window 对象 | Worker 有自己的全局对象 self(DedicatedWorkerGlobalScope) |
不能使用 document、alert | 这些是主线程专属的 API |
| 同源限制 | Worker 脚本必须与主页面同源 |
| 通信有开销 | 数据需要序列化/反序列化(除非使用 Transferable Objects) |
- 大量数据计算(排序、加密、数学运算等)
- 图像 / 音视频处理
- 复杂的数据解析(CSV、大型 JSON 等)
- 任何可能超过 16ms(一帧的时间)的计算任务
如果一个操作不到 16ms 就能完成,通常不需要使用 Worker,因为通信本身也有开销。
进程间通信(IPC)
浏览器的多个进程之间需要相互协作,这就涉及到进程间通信(Inter-Process Communication,IPC)。
浏览器中的 IPC 流程
以用户输入 URL 到页面展示为例,看看多个进程是如何协作的:
常见的操作系统 IPC 方式
了解操作系统层面的 IPC 方式,有助于理解浏览器通信的底层机制:
| IPC 方式 | 原理 | 特点 |
|---|---|---|
| 管道(Pipe) | 半双工通信,数据单向流动 | 简单、适合父子进程通信 |
| 消息队列 | 内核维护的消息链表 | 有格式、可按类型读取 |
| 共享内存 | 多个进程映射同一块内存 | 速度最快、但需要同步机制 |
| Socket | 网络通信接口 | 可跨机器通信 |
Chrome 内部主要使用 Mojo 框架实现 IPC,它提供了一套类型安全的消息传递机制。
并发与并行
在理解多线程和多进程时,还需要区分两个容易混淆的概念:
| 概念 | 含义 | 类比 |
|---|---|---|
| 并发 | 多个任务在同一时间段内交替执行,看起来像同时进行 | 一个人交替地写作业和玩手机 |
| 并行 | 多个任务在同一时刻真正同时执行,需要多核 CPU | 两个人分别写作业和玩手机 |
JavaScript 的事件循环机制实现的是并发——单线程交替处理不同的任务。而 Web Worker 在多核 CPU 上可以实现真正的并行。
实战:主线程阻塞 vs Worker
下面的例子直观演示了耗时任务在主线程执行和在 Worker 中执行的区别:
// ❌ 在主线程执行耗时计算 —— 页面会卡死
function heavyCalculation() {
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += Math.sqrt(i);
}
return result;
}
// 点击按钮后页面会卡住几秒,按钮、滚动等交互全部无响应
document.getElementById('btn').addEventListener('click', () => {
const result = heavyCalculation(); // 阻塞主线程!
console.log(result);
});
// ✅ 在 Worker 中执行耗时计算 —— 页面保持流畅
document.getElementById('btn').addEventListener('click', () => {
const worker = new Worker('calc-worker.js');
worker.postMessage('start');
worker.onmessage = (event) => {
console.log('计算完成:', event.data);
worker.terminate(); // 用完关闭
};
});
// calc-worker.js
self.onmessage = () => {
let result = 0;
for (let i = 0; i < 1e9; i++) {
result += Math.sqrt(i);
}
self.postMessage(result); // 将结果发回主线程
};
主线程上的任何代码执行超过 50ms,用户就可能感知到卡顿。这个 50ms 的标准来自 Google 的 RAIL 性能模型。对于复杂计算,一定要考虑使用 Web Worker 或将计算拆分为小块通过 requestIdleCallback 分帧处理。
面试高频问答
Q1:进程和线程有什么区别?
答: 进程是操作系统分配资源的最小单位,线程是 CPU 调度的最小单位。主要区别:
- 内存:进程拥有独立内存空间,线程共享所属进程的内存空间
- 开销:创建/销毁/切换进程的开销远大于线程
- 通信:进程间通过 IPC 通信(管道、消息队列等),线程间可以直接读写共享变量
- 稳定性:一个进程崩溃不影响其他进程,但一个线程崩溃可能导致整个进程崩溃
- 关系:一个进程至少包含一个线程(主线程),线程不能独立于进程存在
Q2:浏览器有哪些主要进程?各自的作用是什么?
答: 以 Chrome 为例,主要有以下进程:
- 浏览器主进程:管理 UI(地址栏、书签等)、协调其他进程、处理文件/网络等特权操作
- 渲染进程:每个标签页通常有一个,负责解析 HTML/CSS、执行 JS、页面渲染。运行在沙箱中以保证安全性
- 网络进程:处理所有网络请求
- GPU 进程:处理 GPU 相关任务,如页面合成、CSS 3D 变换、WebGL 等
- 插件进程:每个插件有独立进程
多进程架构的好处是稳定(进程隔离、互不影响)、安全(沙箱机制)、流畅(独立渲染)。
Q3:为什么 JavaScript 是单线程的?单线程如何处理异步任务?
答: JS 设计为单线程的根本原因是为了避免 DOM 操作的竞态问题。如果多个线程同时操作同一个 DOM 节点(一个要删除、一个要修改),浏览器无法判断以谁为准,需要引入复杂的锁机制。
虽然 JS 是单线程的,但浏览器是多线程的。JS 通过事件循环(Event Loop) 实现异步:
- 遇到异步任务(定时器、网络请求、DOM 事件等)时,交给浏览器对应的线程/进程处理
- 异步操作完成后,回调函数被放入任务队列(宏任务队列或微任务队列)
- 主线程执行完当前同步代码后,从任务队列中取出回调执行
这样 JS 虽然是单线程的,但可以通过异步+事件循环实现"非阻塞"的并发效果。
Q4:Web Worker 和主线程有什么区别?Worker 有哪些限制?
答: Web Worker 是运行在后台的独立 JS 线程,与主线程的区别和限制:
- 不能操作 DOM:Worker 无法访问
document对象,所有 DOM 操作必须在主线程进行 - 独立全局对象:Worker 使用
self而非window,无法使用alert、confirm等 - 通信方式:主线程和 Worker 通过
postMessage通信,数据通过结构化克隆深拷贝(或 Transferable 零拷贝) - 同源限制:Worker 脚本文件必须与页面同源
- 适用场景:大量数据计算、图像处理、数据解析等可能阻塞主线程超过 16ms 的任务
Q5:Dedicated Worker、Shared Worker、Service Worker 有什么区别?
答:
- Dedicated Worker(专用 Worker):最常用的类型,只能被创建它的页面使用。用于后台计算。
- Shared Worker(共享 Worker):可以被同源的多个页面/标签页共享。适合多标签页间的数据共享或共用一个 WebSocket 连接。
- Service Worker(服务 Worker):特殊的 Worker,独立于页面生命周期运行。核心能力是拦截和处理网络请求、管理缓存。是 PWA(渐进式 Web 应用)的核心技术,支持离线访问、推送通知和后台同步。
Q6:什么是站点隔离(Site Isolation)?它解决了什么问题?
答: 站点隔离是 Chrome 67 引入的安全策略,确保来自不同站点的页面运行在不同的渲染进程中,即使它们在同一个标签页(如主页面嵌入了第三方 iframe)。
它主要解决的问题是防止 Spectre/Meltdown 等 CPU 侧信道攻击。在没有站点隔离的情况下,恶意页面可能利用 CPU 漏洞读取同一进程中其他站点的敏感数据(如 Cookie、密码)。站点隔离确保不同站点的数据在不同进程中,从根本上杜绝了跨站点的内存读取攻击。
Q7:为什么说 JS 阻塞会导致页面卡顿?如何避免?
答: 因为 JS 执行和页面渲染共享同一个主线程。浏览器通常以 60fps(每帧约 16.6ms)刷新页面,如果 JS 的执行时间超过了这个预算,渲染就会被延迟,用户就会感知到卡顿。
避免方法:
- 拆分长任务:将大任务拆分为多个小任务,使用
requestIdleCallback或setTimeout(fn, 0)在每帧空闲时执行 - 使用 Web Worker:将耗时计算放到 Worker 线程
- 虚拟列表:大列表使用虚拟滚动,只渲染可见区域
- 节流 / 防抖:限制高频事件(scroll、resize)的处理频率
- 使用
scheduler.yield()(新 API):主动让出主线程,让浏览器有机会处理渲染和用户输入
// 拆分长任务示例
async function processLargeArray(items) {
const CHUNK_SIZE = 100;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
processChunk(chunk); // 处理一小批
// 让出主线程,让浏览器有机会渲染和响应用户操作
await new Promise((resolve) => setTimeout(resolve, 0));
}
}