浏览器给每个标签页一个独立进程,是操作系统唯一能提供的故障隔离手段;算清线程的资源账本,才能解释主线程为什么会掉帧。
十个标签页里有一个页面死循环卡住了,浏览器还能滚动、还能单独杀掉它,这不是 Chrome 写得好,是进程边界在兜底。同一段计算放在主线程让动画停住、丢进 Web Worker 就不卡,差别同样来自进程与线程的资源划分。
| 维度 | 进程 Process | 线程 Thread | 协程 Coroutine |
|---|---|---|---|
| 内存空间 | 独立虚拟地址空间,互相不可见 | 共享进程的堆、代码段、fd,独享栈 | 用户态栈或状态机,不经过内核 |
| 切换开销 | 大:换页表、刷 TLB,1~10 μs | 小:同进程内换栈与寄存器,约 1 μs | 最小:用户态跳转,百纳秒级 |
| 通信方式 | IPC:管道、消息队列、共享内存、socket | 直接读写共享变量,需锁或原子操作 | 共享变量加调度器,无内核介入 |
| 谁调度 | 内核,抢占式 | 内核,抢占式 | 用户态运行时 |
| 崩溃影响 | 只死自己 | 拖垮整个进程 | 拖垮所在线程 |
进程是资源分配单位,线程是 CPU 调度单位;同进程内一个线程写坏指针,整个进程一起死。
CPU 有特权级(x86 上是 ring 0 到 ring 3)。用户态代码不能碰页表、不能关中断、不能访问硬件端口,读文件、发包、申请内存都要用 syscall 指令陷入内核。
一次调用要保存寄存器现场、切内核栈、把用户指针指向的参数拷进来校验(copy_from_user),执行完再恢复。单次 100 ns ~ 1 μs 不算贵,但它不可抢占,内核里做的事越多,就越像一次微型阻塞。所以高频路径都在摊薄固定成本:readv/writev 一次读写多段、epoll_wait 一次拿一批事件。
task_struct,选下一个线程,恢复它的寄存器组CR3(页表基址),TLB 大部分失效;PCID 能缓解,但 L1/L2 cache 仍是冷的实测一次 35 μs。60 fps 每帧预算只有 16.6 ms,一秒切 1000 次就吃掉 35 ms,本该留给样式计算和绘制。
| 进程 | 职责 | 崩了会怎样 |
|---|---|---|
| 主进程 Browser | 窗口、标签页、地址栏、进程调度 | 整个浏览器退出 |
| 渲染进程 Renderer | 解析、执行 JS、布局、绘制 | 单个标签页白屏,可恢复 |
| GPU 进程 | 图层合成、光栅化、视频解码 | 滚动与动画异常 |
| 网络进程 Network | DNS、TCP/TLS、HTTP 缓存 | 请求失败,页面还在 |
渲染进程内部也不是单线程:主线程负责解析、JS、样式、布局,和 JS 抢同一个线程,这是掉帧的根源;合成线程做图层合成,滚动和 transform 动画可以不依赖主线程;光栅线程把绘制指令转成位图。改 transform 不卡、改 width 卡,就是因为前者能留在合成线程。
JS 只有一根主线程,但等网络时页面不冻住,因为等待发生在别处:fetch 的 socket 读写发生在网络进程;定时器由浏览器的 timer 队列托管。主线程没有任务时,内核把这个线程挂起(Linux 上通常阻塞在 epoll_wait 或 futex),不占 CPU;数据到达后内核唤醒它,回调入队、恢复执行。
所以「单线程」指执行 JS 的线程唯一,不是整个运行时只有一个线程。挂起与恢复由内核完成,代码只负责把后续逻辑写成回调。
new Worker() 创建一个真正的 OS 线程,内部是独立 V8 isolate:独立堆、独立栈、独立 GC。因此不能直接共享 JS 对象,postMessage 是结构化克隆(深拷贝);ArrayBuffer 可以用 Transferable 转移所有权,零拷贝但原线程失效;SharedArrayBuffer + Atomics 是唯一能真共享内存的例外,需要 COOP/COEP 响应头开启跨源隔离。
主线程只有一个,负责执行 JS 和驱动事件循环。libuv 维护一个线程池(默认 4 个,UV_THREADPOOL_SIZE 可调),文件读写、getaddrinfo、crypto.pbkdf2、zlib 都扔进池子里阻塞执行;网络 IO 不走线程池,走 epoll/kqueue/IOCP。所以 Node 适合 IO 密集,bcrypt、图像处理直接放主线程会占满池子,把请求排到后面。
// 主线程同步计算,1e7 次开方约 30~60 ms
document.querySelector('#start').addEventListener('click', () => {
const t0 = performance.now()
let sum = 0
for (let i = 0; i < 1e7; i++) sum += Math.sqrt(i)
console.log('blocked', (performance.now() - t0).toFixed(1), 'ms', sum)
})
60 fps 要求每帧 16.6 ms,这段相当于丢掉 2~4 帧,点击后动画一顿。把计算搬进 Worker:
// worker.js:独立线程,不碰 DOM
self.onmessage = ({ data: { chunk } }) => {
let sum = 0
for (let i = 0; i < chunk; i++) sum += Math.sqrt(i)
self.postMessage(sum)
}
// main.js
const worker = new Worker('./worker.js')
const compute = (chunk) =>
new Promise((resolve) => {
worker.onmessage = ({ data }) => resolve(data)
worker.postMessage({ chunk })
})
不想开线程、只想让出主线程,用 MessageChannel 切片:
const channel = new MessageChannel()
const yieldToMain = () =>
new Promise((resolve) => {
channel.port1.onmessage = () => resolve()
channel.port2.postMessage(null) // 宏任务,插在渲染之前,没有 setTimeout 的 4ms 下限
})
async function chunked(total, step = 5e4) {
for (let i = 0; i < total; i += step) {
heavyPart(i, Math.min(i + step, total))
await yieldToMain() // 每片之后把控制权还给渲染
}
}
很多人以为 Web Worker 之间能共享对象。 实际上 postMessage 是结构化克隆,传 10 MB 普通对象就实打实拷 10 MB;只有 ArrayBuffer 能转移,SharedArrayBuffer 能共享且必须配 Atomics 才有内存序保证。
很多人以为 async/await 会开新线程。 实际上它只是把函数编译成状态机,await 时挂起当前上下文、把后续注册为微任务,全程在同一根主线程上,await 一个同步死循环照样卡死。
进程和线程的区别? 进程有独立地址空间,是资源分配单位;线程共享进程内存,是调度单位。进程切换要换页表刷 TLB,线程切换只换栈和寄存器,快一个数量级。进程间通信走 IPC,线程间共享变量要加锁。
浏览器为什么每个标签页一个渲染进程? 为了故障隔离与安全隔离:一个页面的 JS 崩溃或内存爆炸不会拖垮其他页面,同源策略在进程边界上也更硬。代价是内存占用高,内存紧张时浏览器会合并或丢弃后台标签页。
Web Worker 能共享内存吗?
默认不能,独立 V8 isolate,通信靠结构化克隆。SharedArrayBuffer 可以共享同一块内存,但需要 COOP/COEP 开启跨源隔离,并发读写必须用 Atomics 保证原子性与可见性。