前端人都该懂的浏览器多进程、多线程、事件循环全解析(含彩蛋:JS 为什么和渲染是互斥的!)

139 阅读6分钟

作者:一只努力在面试里不结巴的前端小白
适用人群:前端实习、面试、自我夸夸、装 X


前言

要问前端最容易被面试官随口一问问懵的八股文是啥?
那肯定是:

  • 浏览器到底是多进程还是多线程?
  • JS 为什么是单线程?
  • Event Loop 是怎么调度的?
  • setTimeout 为什么不准?
  • JS 和页面渲染为啥互斥?

别急,看完这篇,从多进程到微任务,一条龙全部安排明白,把面试官问得沉默是我的 KPI


CPU 轮循:计算机的时间片马戏团

先别急着扔词儿,咱从操作系统讲起:

CPU 其实就是个特别讲秩序的大管家——谁都想上去跑,但 CPU 核心(core)一个时刻只能跑一个任务,咋办?

时间片轮转调度

  • 操作系统把 CPU 时间切成无数个小时间片,像切蛋糕一样分给不同的进程和线程轮流跑,切换得贼快,你肉眼看着像在同时跑,其实是排队+轮流干活。

进程:程序界的独立小隔间

进程(Process) ,就是一个正在操作系统里跑的程序单元。

特点:

  • 分配资源(内存、文件句柄)的最小单元
  • 拥有独立的 PID(进程 ID)
  • 有自己的私有内存空间
  • 创建和销毁比线程开销大

就像公司办公室里的独立工位,一个人一个座位,互不打扰。


线程:CPU 调度的最小单元

线程(Thread) ,是 CPU 真正调度时干活的家伙。

  • 进程是办公室,线程是坐在办公室里敲键盘的员工。
  • 一个进程里可以有多个线程——多线程并发干活更高效。
  • 同一个进程里的线程共享内存,沟通方便,切换也快。

主进程 & 子进程:有爹娘就是香

  • 一个程序(主进程)可以拉起子进程(比如 Node.jschild_process)。
  • 父子进程间能用管道、共享内存传话,效率比两个陌生进程直接沟通高多了。

Chrome:多进程浏览器,稳到离谱

现代浏览器(以 Chrome 为代表)就是把「多进程」用到了极致。

为什么多进程?

  • 安全:每个标签页/iframe/插件都单独开一个渲染进程,互相隔离,防止恶意脚本搞大新闻。
  • 稳定:一个页面崩了只炸自己的小隔间,不会带崩整个浏览器。
  • 多核利用:多进程 + 多线程,把多核 CPU 用爽。

Chrome 都有哪些进程?

进程作用
浏览器主进程管整个浏览器 UI、标签管理、进程调度。
渲染进程每个页面一个,干 HTML/CSS/JS 渲染的活儿。
GPU 进程干图形加速、合成、绘制(显卡亲自下场)。
插件进程第三方插件单开(没落的 Flash 泪目)。

渲染进程里:多线程齐上阵

一个渲染进程里,线程们各司其职,互相搭配:

线程干啥的
渲染主线程(主角)执行 JS、解析 HTML/CSS、构建 DOM 树、CSSOM、Render Tree,布局,安排绘制,统统跑在这。
定时器线程专门负责 setTimeoutsetInterval 计时,到点就把 callback 丢到宏任务队列。
网络线程fetch、XHR,请求发出去、下载回来都走它,完了把回调丢到队列里。
独立绘制线程真正把合成好的图层丢给 GPU,画到屏幕上。

JS 单线程:为啥单线程不香吗?

为啥 JS 单线程?难道不能多线程吗?

不能。或者说——不敢。

因为 JS 可以随时读写 DOM,如果多线程同时读写:

  • 线程 A 改一半,线程 B 改另一半,那 DOM 树炸了,谁的版本对?

所以 W3C 一拍脑门:干脆单线程,谁都别抢。
安全!省心!可控!
代价就是单线程只能一个一个跑,卡主线程就卡 UI。


setTimeout 背后的定时器线程 & 不准的真相

很多人以为:

setTimeout(() => console.log('Hi'), 0);

就会立刻执行?别傻了!

真相:setTimeout(fn, delay) 只能保证「至少延迟 delay 毫秒」。

它的流程:

  1. JS 把定时器扔给浏览器的定时器模块(底层线程)。
  2. 到点后,这个模块把 callback 丢到宏任务队列。
  3. 事件循环轮到它了,主线程空闲了,才执行。

为什么会不准?

  • JS 是单线程,如果你有同步大任务卡住主线程,那 setTimeout 就得排队等。
  • 浏览器有最小时间片限制(比如 Node.js 有个最小 4ms)。
  • 结果:只能保证 最短等待,不能保证准时。

经典案例:

setTimeout(() => console.log('A: 0ms'), 0);
setTimeout(() => console.log('B: 10ms'), 10);

while (Date.now() < Date.now() + 100) {} // 阻塞 100ms

结果:

A: 0ms
B: 10ms

因为两者都到期了,但主线程 100ms 都被 while 占了,谁先注册谁先执行。即如果两个定时器顺序反一下,则先输入10ms,再输出0ms的。


addEventListener 背后没有独立线程?

对!addEventListener 的本质:

  • 浏览器底层(输入线程)感知点击/输入事件。
  • 事件触发后,把回调放宏任务队列。
  • 真正执行还是跑在 JS 主线程。

所以:

  • addEventListener 没有像定时器那样的专属线程。
  • 主线程要是被阻塞(如死循环),你点破鼠标也没用,事件永远排不上队执行。

fetch/xhr 的「专属线程」咋理解?

  • 网络请求是浏览器/Node 的网络模块单独跑的。
  • 下载和 TCP 连接不会占 JS 主线程。
  • 下载完才把回调扔到队列里。
  • 真正处理返回结果(解析 JSON、改 DOM)还是要回到主线程。

Event Loop:宏任务 & 微任务

队列代表
宏任务setTimeoutsetIntervalfetch 回调、DOM 事件
微任务Promise.thenqueueMicrotaskMutationObserver

顺序:

  1. 执行同步任务
  2. 执行完立刻清空微任务队列
  3. 执行一个宏任务
  4. 有空就渲染
  5. 重复 1

JS 和渲染:为什么互斥?

来了来了,今天最核心的一个点!

JavaScript 执行时,页面渲染会被挂起,互斥运行。

为啥?

  • JS 可以改 DOM、改 CSS。

  • 渲染也要用 DOM、CSS。

  • 如果两边同时跑:一边算一半 DOM 树,JS 改了一半,渲染挂了,页面就花了。

  • 所以浏览器的原则是:

    • 同一时刻,要么跑 JS,要么跑渲染。
    • 保证一致性,别把页面搞崩。

这就是为什么:

  • JS 执行特别久(死循环、大循环),页面就会卡住,不会重绘。
  • 高效的做法是:把耗时任务拆小块,让渲染线程有机会插空渲染,比如用 requestAnimationFrame 分帧更新。

结尾:这套面试必杀句

浏览器是多进程的,Chrome 每个 tab/iframe 是独立渲染进程,稳定安全。

渲染进程是多线程的,V8 执行 JS 的主线程、定时器线程、网络线程、绘制线程各司其职。

JS 是单线程的,保证了 DOM 操作的原子性,不用加锁,简单安全。

Event Loop 负责把异步回调从宏任务/微任务队列调度给主线程执行。

JS 和渲染互斥,防止状态错乱,所以大任务要拆分,别卡主线程。

背完这套,面试官听了默默点头:

这小子,稳了!


如果觉得有用,点个赞,码字不易,让更多小伙伴少踩坑!

我是一个在面试路上磕磕绊绊但越磕越勇的前端小白,后面我会持续分享 JS 原理、浏览器底层、性能优化、面试经验,别走开,我们下篇掘金见!