进程和线程:补基础第一课,从页面假死但浏览器没死这件怪事说起
“页面卡死了,但浏览器没死” 这个现象,本身就比“JS 是单线程的”那句口头禅更值得往下挖。单线程到底单的是哪一层,为什么一个标签页能卡成砖头,旁边的标签页却还能照常滚动和点击,答案并不在一句笼统定义里。
真正把这个问题想清楚,就会一路碰到进程和线程:浏览器为什么要拆多进程,渲染进程里线程怎么分工,JavaScript 单线程具体限制的是谁,Node 里又为什么能把并发跑起来。很多平时听熟了的词,只有挂回这些现象上才会变得有用。
下面不从教科书定义起手,而是从“一个标签页假死,另一个标签页却没事”这个具体现象开始,一层层往下拆。
第一问:卡死的边界为什么正好是一个标签页
先把概念立起来:进程是正在运行的程序实例,是操作系统分配资源的单位。打开一个浏览器、启动一个 Node 服务、跑一次 npm run build,操作系统都会为它分配独立的内存空间、文件句柄、环境变量。不同进程之间默认隔离,一个进程崩了,不该把另一个进程的内存改坏。
Chrome 的做法是把每个标签页(粗略说)放进独立的渲染进程,另外还有主进程(Browser 进程)、GPU 进程、插件进程各司其职。我那个报表页面的排序把它所在的渲染进程占满了,所以这个标签页里的一切——渲染、点击、滚动——全部停摆;但隔壁标签页住在另一个进程里,操作系统照常给它分时间片,它当然毫发无损。**卡死的边界正好是一个标签页,因为进程隔离的边界就画在那里。**这就是那个怪现象的第一层答案。
顺着这条线还能解释另一件我骂了一年的事:Chrome 吃内存。我的工作机开二三十个标签页是常态,活动监视器里 Chrome 相关的条目一大串,内存占用好几个 G。以前只会骂它是内存黑洞,这次认真查了才知道这不是 bug,是设计——每个进程都要一份独立的内存空间和运行环境。
换来的是稳定性:早年的单进程浏览器,一个网页崩了整个浏览器跟着完蛋,你开的几十个标签全没了;Chrome 里一个页面崩了,只是那个标签变成"哎呀崩溃了",别的标签照常用。Chrome 吃内存,是拿内存换稳定性和安全性的取舍,不是偷懒。想通这个取舍我才平了气——它不是浪费,是主动花内存买来的隔离性,而隔离性正是这些年浏览器最想要的东西。
这里有个我这次才学会的小工具:Chrome 自带任务管理器,Windows 上 Shift+Esc 就能调出来(Mac 在"窗口"菜单里),能看到每个标签页、每个扩展各自是哪个进程、吃了多少内存和 CPU。哪个页面把风扇吹起来了,一眼就能揪出来,比系统的活动监视器细得多。我拿它看了一眼自己那个报表页:一点排序按钮,对应进程的 CPU 直接顶到 100%,铁证如山。排查"页面为什么慢"的时候,先分清是哪个进程在烧 CPU,方向就对了一半。
顺着这个工具我还发现一件反直觉的事:标签页和进程并不是严格一一对应的。前面说"每个标签页一个渲染进程"其实是个粗略说法。
Chrome 有个进程模型的权衡——机器内存吃紧时,它会把同一个站点的多个标签页合并到一个渲染进程里省内存;反过来,同一个标签页如果内嵌了不同来源的 iframe,出于安全又可能把那个 iframe 拆到单独的进程去。所以任务管理器里有时候你看到进程数比标签页少,有时候一个标签页却对应好几个进程,都不是 bug。
理解这一点之后,"卡死边界正好是一个标签页"这个说法要打个补丁:**边界画在渲染进程上,而标签页和进程的对应关系是 Chrome 按内存和安全动态调的,不是死板的一对一。**这也是为什么同样开一堆标签,内存少的机器和内存多的机器,进程数会差挺多。
安全上也是同一套思路。渲染进程被沙箱关起来,权限很低,不能直接读硬盘、不能随便发系统调用,真要访问文件、网络,得通过 IPC 去求主进程。这样即使某个网页利用漏洞攻破了渲染进程,也被困在沙箱里,伤不到系统。
去年年初闹得很凶的 Spectre 那类 CPU 漏洞出来之后,Chrome 还在推更激进的 Site Isolation,把不同站点也拆到不同进程里隔离——隔离的粒度越来越细,本质都是"进程之间互相碰不到"这一条在兜底。Spectre 这类漏洞特别值得记一笔,因为它恰好击中了"共享"的软肋:它利用 CPU 的推测执行去偷读本不该读的内存,而如果攻击者的脚本和你的敏感数据(比如别的网站的登录态)恰好在同一个渲染进程里,理论上就有被读走的风险。Site Isolation 的对策简单粗暴——干脆让不同站点根本不共享进程,你连碰都碰不到我的内存,推测执行也无从偷起。从"每个标签一进程"到"每个站点一进程",隔离粒度的每一次收紧,背后都是一次真实的安全事件在推着走,不是工程师闲着没事拆着玩。
进程里面还有线程
进程再往里看一层,就是线程。线程是进程内的执行单元:一个进程可以有多个线程,它们共享这个进程的内存空间,但各自有自己的执行栈。
进程和线程的核心区别就在"共享"二字上。进程之间内存隔离,想通信得靠 IPC——管道、共享内存、消息队列,开销大但安全;同一进程内的线程共享内存,通信方便到改个共享变量就行,创建也比进程轻,代价是要自己处理数据竞争。
IPC 这几种方式我顺手也捋了一下,因为它们的取舍恰好能反过来印证"进程隔离"这件事的代价。
管道(pipe)是最朴素的,一端写一端读,像水管,Shell 里 a | b 那个竖线就是它,简单但只能单向、通常限于有亲缘关系的进程。共享内存是最快的一种——两个进程映射同一块物理内存,读写不用来回拷贝数据,但正因为"共享",它又把线程那套数据竞争问题带了回来,得自己加信号量之类的同步手段,等于用回了多线程的麻烦。消息队列(这里指操作系统层面的,不是前面那种中间件)走内核中转,进程之间发消息、内核帮你排队,安全清晰但每条消息都要经过内核、有拷贝开销。此外还有信号(signal)这种最轻量的,只能传个"事件发生了"的通知、带不了什么数据,kill 命令发的就是它。
把这三种放一起看就明白了:**IPC 的所有花样,本质都是在"进程内存彼此看不见"这个前提下,想办法搭一座桥。**桥越快(共享内存)就越危险,桥越安全(内核中转)就越慢。而同一进程内的线程根本不需要这座桥——内存本来就是共享的,改个变量对方立刻看得见。这一快一慢的对比,正是"线程共享、进程隔离"最实在的注脚,也解释了为什么"要通信频繁用多线程、要隔离性用多进程"会成为一条经验法则。
数据竞争这个词我以前觉得抽象,直到看到那个最经典的例子:两个线程同时对一个变量做 count++。这一行看着是原子的,其实是"读出 count、加一、写回"三步。两个线程交错执行,可能都读到 5、都加成 6、都写回 6——加了两次,结果只涨了 1。
更恶心的是这种 bug 依赖线程调度的时序,时有时无,本地复现不出来,线上偶尔抽风。所以多线程代码要用锁把临界区保护起来,但锁用多了又有死锁和性能问题,越想越头大。死锁那个经典画面我也记了下来:两个线程各拿着一把锁、又都在等对方手里那把,谁也不肯先放,就这么僵住。为了不死锁得约定"加锁顺序一致"这类纪律,为了性能又得让锁的粒度尽量细——细了容易出竞态、粗了并发上不去,全是在刀尖上找平衡。这一圈想下来,我算是真切体会到多线程编程为什么被称作"容易写、难写对"。
想到这里我反而理解了一件事:JS 把自己设计成单线程,是一种"用简单换安全"的选择。根本不让你共享内存,竞争问题从源头上就不存在。写了一年多 JS 没碰过锁,不是我幸运,是语言替我挡掉了。
渲染进程里也不止一条线程
进程—线程这套刚立起来,我就顺手把"渲染进程内部到底有几条线程"这个一直含糊的问题查清楚了,因为它直接关系到后面那个假死现象。
一个渲染进程里其实跑着好几条线程,只是它们分工不同。最核心的是主线程,JS 执行、样式计算、布局、绘制的调度都在它身上,也就是平时说"JS 单线程"里那条线程。但除了它,还有专门的合成线程(compositor thread)负责把已经画好的图层合成到屏幕上、处理滚动这类操作,还有光栅化线程池负责把图层转成实际像素,网络请求也另有线程在跑。
搞清楚这个分层,好几个平时觉得神奇的现象一下就通了。比如为什么一段纯 CSS 的 transform 动画,即使主线程被 JS 卡住了也照样流畅?因为 transform、opacity 这类变化能交给合成线程独立处理,不经过主线程那条拥堵的路。而 left、top、width 这类会触发重新布局的属性就不行,它们必须回到主线程算,主线程一卡,动画就跟着卡。同样是动画,走不走主线程,流畅度天差地别——这条后来成了我做动画优先用 transform 而不是改 left 的硬道理,根子就在渲染进程的线程分工上。
所以"JS 是单线程"这句话得说全:单的是渲染进程里那条 JS 主线程,不是整个渲染进程只有一条线程,更不是整个浏览器单线程。把这三层分清楚,前面那个"页面卡死但浏览器没死"的边界问题,和后面"哪些活能不占主线程"的优化方向,才都有了落脚点。
插播一个给实习生做的演示
概念读到一半,正好赶上给组里实习生讲"页面为什么会卡",我顺手做了个小演示,效果比讲十分钟定义都好。页面上放一个每帧自增的计数器和一个"卡住"按钮:
1<p id="counter">0</p> 2<button id="block">卡住主线程 3 秒</button>
1var count = 0 2var el = document.getElementById('counter') 3 4function tick() { 5 el.textContent = ++count 6 requestAnimationFrame(tick) // 每帧刷新一次数字 7} 8tick() 9 10document.getElementById('block').onclick = function () { 11 var start = Date.now() 12 while (Date.now() - start < 3000) {} // 同步死循环 3 秒 13}
不点按钮时数字哗哗涨,一点按钮,数字定格三秒、按钮按下的样式都弹不起来——requestAnimationFrame 的回调、样式更新、点击反馈全在排队,因为主线程被那个 while 占死了。三秒之后一切恢复。
实习生看完说了句"原来卡死是这么回事",我心想我也是上周才真正想明白的。这个演示之所以比讲定义有效,是因为它把"主线程是一份被多方争抢的稀缺资源"这个抽象说法变成了眼前能看见的现象:数字停了、按钮弹不起来、鼠标点了没反应,全都是在争同一条线程而争输了的表现。定义记不住,但这个"数字定格"的画面,实习生大概很难忘。
并发和并行:撸串店的两种老板
这两个词平时被混着用,但含义完全不同。我给自己编了个记法,就用跨年那顿撸串的店:
并发,是一个服务员同时招呼好几桌客人。他其实一次只服务一桌,靠快速来回切换,让每桌都觉得自己没被晾着。并行,是好几个服务员各管各的桌,真的同时在干活。
对应到 CPU:单核 CPU 靠时间片切换实现并发,看着像同时跑,说白了还是轮流;多核才能真并行。JavaScript 在浏览器主线程里一次只能执行一段 JS,但浏览器整体不是单线程——网络、定时器、渲染、事件背后都有不同的线程和模块在协作。
用撸串店这个比方还能顺手解释一件常被误解的事:为什么单核机器上开多线程有时反而更慢。单核就是一个服务员,你硬派他同时招呼十桌,他大部分精力都花在十桌之间来回跑(切换)上,真正端菜倒水的时间反被挤占——这正是前面上下文切换那节的直观版。多线程的收益要真正兑现,前提是"服务员本来就有好几个"(多核),或者"每桌大部分时间在等厨房出菜、服务员正好去招呼别桌"(I/O 密集)。搞不清自己是缺服务员还是缺厨房,加人只会加乱。
这个区分对判断性能瓶颈很关键。如果任务是 I/O 密集型——大量时间在等网络、等磁盘,CPU 其实闲着——那并发模型就够了,让 CPU 在等待的间隙去干别的活,效率很高,这正是 Node 擅长的场景。但如果是 CPU 密集型,比如大量计算、图片处理,单靠并发没用:CPU 一直满负荷,切来切去变不出更多算力,这时候才真正需要多核并行。
搞混这两者,就会陷入"我明明加了异步,怎么还是慢"的困惑——我那个万行排序就是 CPU 密集型,套多少层 Promise 都救不了它。这个坑我踩得很结实:一开始以为把排序包进 Promise 或者 setTimeout 就"异步"了,结果页面照卡不误。后来才想明白,Promise 只是把任务挪到了微任务队列稍后执行,它终究还是在主线程上跑,该占的 CPU 一点没少占——异步能解决的是"等待",解决不了"计算量"。分不清自己面对的是"在等"还是"在算",优化就永远使错劲。
上下文切换:切换本身不免费
线程不是开得越多越好,这一节说的就是为什么。
CPU 从一个线程切到另一个线程,要把当前线程的寄存器、程序计数器、栈指针这些"现场"保存起来,再加载下一个线程的现场,还可能伴随 CPU 缓存失效、TLB 刷新,这叫上下文切换。
单次切换是微秒级,听着不多,但线程开成几千个、每秒切换几万次,这部分开销就能吃掉可观的 CPU——真正干活的时间反而被切换挤占了。其中"缓存失效"这一项常被低估:CPU 有多级高速缓存,一个线程跑着跑着把常用数据都预热进了缓存,一切换,下一个线程的数据得重新从内存捞、把缓存刷一遍,这种看不见的间接成本往往比保存寄存器那点直接开销还大。所以上下文切换的代价不能只算"存取现场那几条指令",还得算上它把 CPU 辛苦攒的缓存热度给打散了。
所以服务端并发不是线程越多越好。一台 8 核的机器,开几千个都很忙的线程,纯属互相抢 CPU、疯狂切换;CPU 密集的场景下,线程数接近核数往往才是最优的。让干活的单元数量匹配核数,而不是无脑堆——这次读完书我在群里把这套理解复述了一遍,负责后端服务部署的同事回了句"对,加机器解决不了这种问题,得调进程数",算是印证了一遍。
不过"线程数接近核数"这条只对 CPU 密集型成立,得说清楚适用边界。如果是 I/O 密集型,线程大部分时间都在等 I/O、并没真占着 CPU,那开的线程数就可以远超核数——反正它们大多在睡觉,多开几个正好填满等待的空档。所以线程数怎么设,前提是先判断任务是 CPU 密集还是 I/O 密集,又绕回了前面那个区分。这也是为什么不存在一个"线程池开多大"的万能答案,得看你的活是在算还是在等。
这里有个之前没细想的分层:线程切换之所以贵,一大半贵在它要陷入内核。用户程序自己是没权限调度 CPU 的,得发起系统调用、从用户态切到内核态,由操作系统内核来保存现场、挑下一个线程、恢复现场,切完再回到用户态。这一进一出内核,本身就有固定开销,加上前面说的寄存器保存、缓存失效,一次线程切换的成本就上去了。
这也解释了为什么这两年协程这个词到处都是——Go 的 goroutine、JS 的 async/await 背后的事件循环,思路是一致的:切换由程序自己在用户态调度,不用陷入内核,比线程切换轻量得多,用很少的线程就能撑起海量并发的 I/O 任务。别让操作系统替你频繁切换重量级的线程,这是这一代并发模型共同的底层逻辑。
我特意把这条线跟平时天天打交道的事件循环接上了:JS 的 async/await、Promise,本质就是一种用户态的协作式调度。一个 await 挂起当前任务、去干别的,等 I/O 好了再回来接着跑,这个"挂起—切换—恢复"全发生在单条线程内、由 JS 引擎自己调度,压根没惊动操作系统去做线程切换。
所以一台机器上几万个并发连接(就是那个经典的 C10K 问题),用多线程模型每个连接一个线程早就被切换开销压垮了,而 Node 这种事件驱动模型靠一条线程加事件循环就能扛住——不是它算力更强,是它把"等 I/O"的时间全省下来干别的了,且完全避开了线程切换的成本。**并发的关键从来不是开更多执行单元,而是别让执行单元在无谓的等待和切换上空耗。**这个道理从操作系统的线程调度,一路贯穿到我每天写的 async/await,是同一件事。
不过协作式调度也有它的软肋,正好和抢占式对照:操作系统的线程调度是"抢占式"的,一个线程赖着不放 CPU,内核到时间片就强行把它换下来,所以单个线程死循环顶多卡自己;而协程、事件循环是"协作式"的,靠每个任务主动让出,一旦某个任务不肯让——比如一段同步死算不 await——整条线程上别的任务全得干等,谁也抢不走 CPU。这恰恰就是后面 Node 那个坑和浏览器主线程假死的共同根源,两种调度模型的取舍在这里第一次浮出来。
回到那次页面假死:JS 单线程到底卡在哪
概念齐了,回头看那个排序假死的页面,第二层答案也出来了。
通常说 JavaScript 是单线程,指的是渲染进程里的 JS 执行主线程。极端一点:
1while (true) {}
这一行能把页面彻底卡死。但关键不只是"JS 跑在一条线程上",而是主线程不只跑 JS——样式计算、布局、绘制这些渲染工作,还有响应用户点击,全都挤在这同一条线程上,和你的 JS 抢同一份时间。所以一段同步执行超过几十毫秒的 JS,期间浏览器就没法重绘、没法响应点击,表现就是页面假死。我的万行排序全在主线程同步跑,卡一两秒,鼠标转圈,一点不冤。
异步请求为什么不卡?因为请求本身在浏览器的网络线程里等着,不占主线程;只有回调回来执行那一下才占。判断一个任务该不该担心卡顿,就看它会不会长时间霸占主线程——网络请求不会,纯计算会。这条判断标准朴素但极其好用,它把五花八门的"页面卡不卡"问题一下收敛成一个问题:这段代码会不会长时间同步占着主线程。
顺手学到一个量化的尺子:Chrome 的 Performance 面板录一段操作,超过 50ms 的任务会被标成 Long Task,上面顶着红色小三角。Google 的 RAIL 模型给的建议也是这个数——想让用户的每次交互都在 100ms 内有响应,留给你的 JS 的预算大概就是 50ms。
为什么是 50ms 而不是 100ms,我一开始也纳闷,查了才懂这里有个"帧预算"的账:屏幕通常每秒刷新 60 次,一帧只有约 16ms,主线程要在这 16ms 内既跑完该跑的 JS、又完成渲染,才不掉帧。任何一个超过 50ms 的任务,都意味着中间有好几帧根本没机会渲染,交互也响应不了,用户就感到卡顿。所以 50ms 不是拍脑袋定的,是从"别让用户察觉到卡"倒推出来的一条硬线。
我把点排序按钮那一下录下来,火焰图上一根将近两秒的长条,底下的调用栈直接指到我的排序函数,比 console.time 直观多了。先录 Performance 再谈优化,不然连卡在哪都是猜的。
理解了这些,优化方向就有两条。第一条是把大计算切成小块,每做一块就把主线程让出来,给渲染和交互喘口气:
1function processInChunks(list, handler, done) { 2 var index = 0 3 var CHUNK = 500 4 5 function next() { 6 var end = Math.min(index + CHUNK, list.length) 7 for (; index < end; index++) { 8 handler(list[index]) 9 } 10 if (index < list.length) { 11 setTimeout(next, 0) // 让出主线程,下一轮宏任务再继续 12 } else { 13 done() 14 } 15 } 16 17 next() 18}
Chrome 里还有个更讲究的 requestIdleCallback,能在浏览器空闲时才执行回调,不过 IE 和 Safari 不支持,后台项目要兼容就还是 setTimeout 切片稳妥。这里其实是三个"让出主线程"的手段各有分工,我特意理清了:setTimeout(fn, 0) 是把任务扔到下一轮宏任务,最通用但时机最粗;requestIdleCallback 挑浏览器真正空闲的缝隙执行,最不打扰渲染但兼容性差、也不保证一定被调到;requestAnimationFrame 则是绑在每一帧重绘前。
别把切片和 requestAnimationFrame 搞混:rAF 是"下一次重绘前执行",适合做动画,每帧都跑;切计算用它反而会挤占每一帧的渲染预算。选哪个取决于你想让出后"什么时候回来接着算"——要尽快回来用 setTimeout,要不打扰渲染用 requestIdleCallback,别用管动画的 rAF 去干切计算的活。第二条路是把计算整个挪走——这就轮到 Web Worker 出场了。
Web Worker:把活挪到另一条线程
Web Worker 可以把一部分计算放到另一条线程上跑:
1const worker = new Worker('/worker.js') 2 3worker.postMessage({ numbers: [1, 2, 3] }) 4 5worker.onmessage = event => { 6 console.log(event.data) 7}
Worker 那一侧的代码住在单独的文件里,全局对象是 self 而不是 window,收消息、干活、把结果发回去:
1// worker.js 2self.onmessage = function (event) { 3 var rows = event.data.rows 4 // 上万行的排序和格式化都在这条线程里跑,主线程照常响应 5 rows.sort(function (a, b) { 6 return b.amount - a.amount 7 }) 8 var formatted = rows.map(formatRow) 9 self.postMessage(formatted) 10}
Worker 跑在独立线程上,它的计算不会卡住主线程的渲染和交互。那个排序假死的问题,把数据丢进 Worker 算完再 postMessage 回来更新视图,就解决了——我年前真这么改了,按钮点下去页面照常能滚动,只是结果晚零点几秒出来,体验天差地别。调试也不麻烦,DevTools 的 Sources 面板里 Worker 是一条独立的线程,能单独打断点,console.log 也照常打到控制台。
但 Worker 的限制要心里有数。它和主线程不共享内存,通信靠消息传递,数据是结构化克隆过去的——传一个超大对象本身就有序列化开销,别指望零成本(真是大块二进制数据的话,可以用 Transferable 把 ArrayBuffer 的所有权整个移交过去,避免拷贝,报表场景用不上,先记下)。
这个"不共享内存、只能传消息"的约束,回头看正是进程隔离那套逻辑在浏览器里的翻版:Worker 之于主线程,很像一个独立进程之于另一个进程,安全、互不干扰,但通信得走一遍"序列化—拷贝—反序列化"这座桥,跟前面 IPC 那节讲的完全是一个道理。理解了这层类比,我就不会再天真地以为"开个 Worker 把数据甩过去"是零成本的——数据一大,那座桥本身就成了瓶颈,得掂量传输量值不值得。
它也拿不到 document 和 window,不能操作 DOM,纯粹是干算的,算完把结果传回来由主线程更新视图;不过它能自己发 XHR、用 setTimeout,在 Worker 里拉数据再解析是完全可行的套路。所以它适合"算得久、又不碰 DOM"的活:大 JSON/CSV 解析、图像处理、加解密、复杂的数据聚合。反过来,几毫秒的小计算不值得开 Worker,创建和通信本身有成本。
顺带一个考古发现:其实规范里有过让 Worker 和主线程真正共享内存的 SharedArrayBuffer,但去年年初 Spectre 漏洞爆出来之后,各家浏览器把它紧急关掉了,到现在也没全面恢复。
共享内存一旦开了口子,数据竞争、安全问题全都跟着回来——JS 世界对"共享"的谨慎,和操作系统这几十年踩的坑是同一个坑。这件事我觉得特别有意思:JS 好不容易靠"不共享内存"躲开了多线程那一整套竞争和加锁的麻烦,SharedArrayBuffer 等于是想把这扇门重新打开一条缝,结果立刻就被 Spectre 这种利用共享内存的漏洞教育了,只好又把门关上。绕了一圈,反倒印证了当初"不共享"这个选择的分量——它挡掉的不只是编程复杂度,还有一整类安全风险。
Node 的"单线程"也坑过我们
Node 的 JS 执行同样是单线程,但说"Node 是单线程"容易产生误解——它底层有 libuv 的线程池在处理文件系统、DNS、加密这些任务,事件循环让大量 I/O 可以高效并发。真正的软肋和浏览器一样:CPU 密集型任务会阻塞唯一的 JS 主线程。
我们团队有个 Node 写的订单导出服务在这上面栽过。平时跑得好好的,直到某个接口图省事用了同步加密,只要这个接口被调到,整个服务的所有请求全卡住——那段 CPU 计算把 JS 主线程占满,事件循环根本没机会处理其他请求的回调。一个慢的同步操作,拖垮的不是一个请求,是整个进程上的所有请求,这是 Node 最经典的坑。
这个坑和浏览器主线程假死是同一个病,只是换了个舞台:浏览器里主线程既跑 JS 又管渲染交互,一段同步 JS 就卡住整个页面;Node 里那条唯一的 JS 主线程管着所有请求的回调,一段同步计算就卡住所有请求。两边都是"一条协作式调度的线程被某个任务霸占,别的任务全得干等",正是前面协程那节埋下的伏笔——协作式模型最怕不肯让出 CPU 的钉子户。想通这一点,浏览器和 Node 这两个看似不相干的场景,在我脑子里合并成了同一条规律。
应对办法正好把前面的概念全用上。想吃满多核,用 cluster 模块或者 PM2 起多个进程,每个进程一条事件循环,这是多进程并行:
1var cluster = require('cluster') 2var os = require('os') 3 4if (cluster.isMaster) { 5 // 主进程按 CPU 核数 fork 工作进程 6 os.cpus().forEach(function () { 7 cluster.fork() 8 }) 9 cluster.on('exit', function (worker) { 10 console.log('worker ' + worker.process.pid + ' 挂了,拉一个新的') 11 cluster.fork() // 进程隔离的好处:一个崩了,补一个就行 12 }) 13} else { 14 require('./server') // 每个工作进程各跑一份服务,共享监听同一个端口 15}
exit 那几行是我觉得最能体现"进程隔离"价值的地方:某个工作进程被一段烂代码搞崩了,主进程补一个新的,其他进程手上的请求一个不丢——和 Chrome 一个标签崩了别的照常用,完全是同一个道理。这两个场景我原来从没往一块想,现在看它们是同一条设计原则在浏览器和服务端各开了一朵花:用进程隔离把故障圈在局部,崩了就地补一个,别让一处崩溃扩散成全盘崩溃。
PM2 配置里那个 instances 填多少,答案前面已经写了:跟核数走,别无脑堆。单个进程里要做重计算,别在请求处理里同步跑,用 child_process 拆成子进程,或者干脆拆成独立的计算服务。还有一条最朴素的纪律:请求处理路径上禁用同步 API,fs.readFileSync 这类 Sync 结尾的函数会同步阻塞事件循环,脚本里随便用,服务里就是雷。这条纪律我们后来直接写进了 code review 的检查项,因为它太隐蔽——Sync 版本在开发本地单请求测试时一点问题没有,只有上了并发才暴露,靠人肉自觉根本防不住。
多进程还带出一个新问题:进程之间内存隔离,所以别把状态存在进程内存里。我们导出服务改多进程后出过一个小插曲——任务进度存在内存对象里,结果查询进度的请求被负载均衡打到另一个进程,永远查不到。最后把进度挪进了 Redis。
这个坑特别能说明"进程隔离"这把双刃剑:单进程时把状态往内存里一放又快又省事,一旦为了吃满多核改成多进程,同一份状态在不同进程里各存各的、互相看不见,之前理所当然的写法立刻失效。解法就是把这种需要跨进程共享的状态挪到进程外——Redis、数据库这类大家都能访问的地方,让进程本身回归"无状态",谁处理都一样。这又绕回了第一节那句话:进程隔离是保护,也是约束,享受了崩溃不扩散的隔离,就得接受状态不能随便共享的代价。
第一课记下来的东西
两周啃下来,我把这一课收成三句话,贴在了笔记最前面。
进程是资源分配的单位,线程是执行的单位;多进程更隔离,多线程共享内存但要面对竞争。并发是轮流、并行是同时,I/O 密集靠并发就够,CPU 密集才需要真并行。主线程是稀缺资源——浏览器里它兼管渲染和交互,Node 里它兼管所有请求,谁让它干等或者死算,谁就制造卡顿。
"页面假死但浏览器没死"这个怪现象,现在能完整讲下来了:进程隔离画出了卡死的边界,主线程被同步计算霸占造成了标签页内的假死。上周给实习生重新讲了一遍,这回没有在第二层问题上卡壳。比起记住结论,我更踏实的是:下次页面卡住,我知道先开 Chrome 任务管理器看哪个进程在烧 CPU,再录 Performance 找 Long Task,该切片切片、该挪 Worker 挪 Worker,而不是对着屏幕干着急。下一课打算啃内存和垃圾回收,到时候再记。