🗒️品品Javascript并发
2025-11-27
| 2025-11-28
Words 7310Read Time 19 min
URL
type
status
date
slug
summary
tags
category
icon
password
网上都说node适合IO密集型业务,性能不如java。这篇文章试图从一个前端开发的视角,从JavaScript波折的并发历史开始,一步步探索这个问题的本质。

一、疑问

刚学前端接触到node的时候,就听到node做后端性能不如java的说法。当时只把js当作一门解释型的动态类型语言,而java是静态类型,且还能通过jvm编译成更底层的字节码,比js强是天经地义的。
过一阵子了解了更多v8引擎的原理,发现现代的js解释器其实也是解释+即时编译,底层做了超级多优化,完全称得上js虚拟机了,性能也是紧追java。又看了看网上说node性能不如java的说法,他们说nodejs是单线程的,不适合cpu密集型业务,性能不比java。但是又说node很适合IO密集型业务,这方面甚至比java还强。
由于当时对js和java都没那么了解,这勾起了我许多疑惑: 1. 一台服务器的硬件是固定的,为什么不同的语言能发挥出的能力不一样呢?
  1. js明明有worker线程,为什么还说它是单线程的呢?
  1. 为什么node是单线程的,却很适合IO密集型的业务呢?
  1. node性能这么差,为啥国外云服务很多都是node构建的?
  1. ……
诸多疑惑盘踞心头,我想只有深入到更加底层,才能真正解答这个问题。这几天在重温操作系统,正看到并发部分,有了些开发经验后便有了新的感悟,遂决定系统研究下这个问题。

二、线程和并发

要回答这个问题,最重要的就是理解为什么node或者说js是单线程的,而java是多线程的。这得先了解下线程是什么,这里简单介绍。

线程

什么是线程:当我打开一个软件,操做系统就会创建一个或多个进程,操作系统会为每个进程分配独立的内存空间,将运行程序所需的代码、文件加载进内存中,一个进程崩了也不会影响其它进程。代码指令就在进程里跑,最开始是一段代码一段代码排着队跑,但有时候我们想让一个进程同时干多件事情,因为有些程序可能卡住,导致整个队伍堵住。由此有了线程,进程为每个线程分配独立的栈空间,让每个线程可以执行自己的代码,并且可以共享进程的内存空间,齐心协力办大事。不过坏也坏在共享内存空间,这样一个线程崩了整个进程就崩了。
上面说的是操作系统层面的线程,JavaScript中的线程还有些许区别,这也是为什么JavaScript老被说成是单线程语言的原因,且听后面分析。
notion image

并发和竟态条件

多个线程都可以操作同一块内存,就会产生各种并发问题。其中最经典的就是银行取款问题:A和B从各自的ATM机上,同时取同一个账户的钱,相当于两个线程改同一个数据。账户原本有100,两人各取50,理论上账户会剩0元,但实际上却还剩50元。这里的关键点在于,对余额的读和写是两个独立的操作。ATM A读取了数据100,但还没写入取款后数据,ATM B又来读取了数据还是100,最终两人都写入取款后数据50,就导致100元取了100还剩50。这种多个线程操作同一个资源的情况就叫竟态条件。
因此要把读写变成一个完整的操作,让一个人完整读写后才允许下一个人完整读写。可以让ATM A读取余额前,给金库上个锁,其他人都不允许操作了,等A取完钱将数据写入后,再解锁,B再来上锁、读写、解锁,这样就能保证读写是一个完整的操作。
这种协调不同线程的操作叫线程同步,人们为此做了不少工作:硬件层面有原子化指令,代码层面有互斥锁、信号量、读写锁等等,可以自行了解。你只需要知道,并发问题非常麻烦,这个银行取款只是最简单的一种,实际业务逻辑星罗密布,线程更多,复杂度只会几何上升。
notion image

三、Javascript的并发之路

事件循环

相信每一个有经验的前端开发都对事件循环不陌生,主线程同步的执行,遇到异步任务就加入任务队列里面,在事件循环中不停的清空任务队列。
其实感觉人的模式和JavaScript挺像的,当单线程的我们遇到麻烦事我们会怎么办?
  1. 硬刚,放下其它事情
  1. 放弃,直接不干了
  1. 拖延,记下来以后做
  1. 外包,找别人做,等结果就好
当然,人设计编程语言可不是让它放弃的,除非出错了,而其他三个做法JavaScript都会。
  • 硬刚:就是同步执行,挨着一行代码一行代码干完。
  • 拖延:可以看成promise或者setTimeout等api,可以让代码在当前循环后执行,一般用来等待一些结果,调整代码顺序,或者单纯想拖延。
  • 外包:比如网络请求、文件读取这样的IO操作,JavaScript会外包给操作系统,操作系统拿到数据后再通知JavaScript。
推荐一个讲解事件循环的视频,生动有趣,我逢人便推荐:
这里重点说说“外包”。浏览器的一个网页,通常只有一个渲染主线程。它是一个超级繁忙的线程,什么html的解析、样式计算、布局、js脚本执行、渲染、用户交互都被它大包大揽。(现代浏览器架构下,会有很多辅助线程提高性能,但是主线程的主要任务依然有这些,故此处忽略那些辅助线程)如此繁忙,还得保证每秒60次的刷新,不然用户会感觉卡顿。
人生不如意十有八九,总有些耗时耗力的杂活累活需要干,比如网络请求,网不好可能半天等不来一个数据,再比如一些媒体编解码,要算老半天才能算完。现代网页可以说百分百要干这些活,但网页就一个线程,难道在这里堵着等数据返回?我们知道这些功能往往被封装成了异步api,主线程执行到这里,就把任务交给了操作系统或者worker线程,然后自己若无其事的继续往下执行,等着别人处理好通知它,将回调函数加入任务队列。这种外包的思想,正是让Node处理IO密集型业务的秘籍。
notion image

为什么JavaScript是单线程?

前端er应该都不陌生JavaScript的起源故事,它作为浏览器的脚本语言被开发,最初被用来做一些表单验证、简单的交互。没什么雄心壮志,就一心为网页服务,连名字都是蹭java热度起的。
早期浏览器就是一个单进程应用,网页都是通过一个UI线程进行渲染,html文件来了,就一步步的解析、渲染,就和我们背的老八股一样。因此网页本身就没考虑过什么多线程操作,里面所有的操作都是没有上锁的。如果出现多个线程同时改变同一个dom的情况,就会发生上述并发问题,导致混乱,我们可以称之为线程不安全。运行环境就是单线程的,而且还线程不安全,那js设计成单线程的语言也十分合理。
当然,JavaScript最初没有加入多线程,其父Brendan Eich功不可没,他2007年的一篇文章中阐述了许多深层原因,文章名字也是直言不讳——Threads suck。我总结了他拒绝搞多线程的两大原因:
  1. 多线程编程对开发者要求高,大大增加了开发的门槛。多线程编程会破坏掉代码的抽象性,什么意思呢,单线程下,封装了一个函数就是一个函数,它的功能很明确,行为也可以预测,固定的输入得到固定的输出。但是多线程下,开发者不得不考虑全局,代码行为无法预测,开发者无法专注业务,被并发问题本身所牵制。由于要加各种锁,锁本身的开销大,线程切换开销也大,还有各种死锁陷阱,这样开发者很容开发出性能奇差的垃圾程序。(特别是DOM这样的树结构,在多线程环境下可能有灾难性问题)
  1. 竟态条件可以通过静态分析找出来,因为它取决于代码结构。比如Rust就能通过类型系统和编译器规避掉数据竞争问题。但是Eich认为,静态分析对性能损耗极大,而且线程安全并无必要,只需要保证单线程就没有线程安全问题了。
notion image
对于js引入传统多线程架构的建议,Eich只有一个回答——over your dead body!(除非你想死)。文章中我们可以看出,除了减轻开发者的心智负担,性能是一个非常重要的指标。在网页渲染中,哪怕卡了一秒都会导致大量客户的流失。为了保持足够的帧率,计算都是争分夺秒的,因此在浏览器环境下,性能显得弥足珍贵。这其实算一种权衡之术,牺牲掉理论性能上限和共享数据的便利,换取低门槛、安全性和页面渲染性能。
此处还有一个背景,人们意识到摩尔定律随着物理极限会逐渐失效!因此以后肯定是多核时代,cpu通过堆砌核心数量提高性能,那如何让JavaScript这个单线程语言跟上时代步伐,也吃上多核cpu的福利呢?上面我说的传统多线程架构,是指共享内存的多线程模式,Eich本质上并不反对多线程,他认为真正的洪水猛兽是“可变的共享数据”,产生竟态条件的直接原因。他引用scheme设计者Will Clinger的观点——the key idea is to separate the mutable unshared data from the immutable shared data(关键是分离可变非共享数据和不可变的共享数据)。在这种观点里,可变数据不应该共享,而共享数据就一定不可变,很容易理解,这种设计能完全杜绝各种数据竞争,还能享用多核计算。
Eich在文中提出了自己所推崇的不那么传统的多线程设计。他受Erlang等语言的启发,希望将线程做完全的内存隔离,通过消息传递数据,类似进程间通信一样,消息是复制过去的,因此不存在竟态条件。这样相当于把繁重的计算“外包”给其它线程,其它线程处理完毕后,再把结果通过消息传递复制一份回主线程。并且Eich希望语言层面保持简洁,对于底层多线程的分配和调度,都交给一个虚拟机处理,开发者只需要专注业务就好。
拓展:Erlang由爱立信开发,最初用于开发电话交换机软件。由于电话用户很多,且需要24小时连轴转,因此需要处理高并发,且不能停机。Erlang使用Actor模型,它会创建许多Erlang进程,Erlang进程不是OS进程也不是OS线程,它比OS线程更轻。Erlang跑在BEAM虚拟机上,Erlang进程就是虚拟机内部自己实现的,这种进程的创建在微妙级别,切换速度也相当快,内存完全隔离通过消息传递数据。Erlang最有特点的就是容错机制,一个进程错误,直接重启,由于内存隔离,也不会影响其它进程,因此永远不会停机。单机中的Erlang能轻松调度百万级别的Erlang进程,像Whatsapp、Discord都用到了Erlang。

另类的多线程系统

后面推出的worker线程以及各种js引擎,恰恰就遵循了Eich的思路。现在最出名的js引擎莫过于谷歌的v8了,它通过各种优化手段,将JavaScript的性能提到了一个新高度,这正是Eich所说的虚拟机。每创建一个worker线程,v8引擎都会为其分配一个实例,包括独立的堆栈空间,宿主环境(浏览器或node)也会为其分配独立的事件循环,因为v8引擎不维护事件循环。总之,每个线程都可以看成一套完整的js运行环境,相互隔离,保证了可变数据只在每个线程内部。线程间通过postMeassage API来传递数据,这里的数据传递实际上用到了内部的结构化克隆算法,算是一种深拷贝,可见我的另一篇文章JS数据拷贝简史,因此不存在竞态。开发者只需要调用几个API,JavaScript虚拟机就将一切都准备好了,由于在原生OS线程上构建,也能享受多核CPU的性能加成。
2016年,浏览器厂商们推出了SharedArrayBuffer,一块所有线程共享的内存空间,妥妥的共享了可变数据,这不是打了Eich老爷子的脸吗?
notion image
Eich确实颇有遇见性,SharedArrayBuffer一来便捅了篓子,准确的说不是它捅了篓子,而是CPU厂商的优化策略,让基于时间的侧信道攻击成为了可能,造成了幽灵和熔断漏洞,具体可看相关科普。而SharedArrayBuffer是给这个漏洞撕开了一个口子,不过这也侧面说明了共享可变状态处处是陷阱。捅娄子后SharedArrayBuffer被停业整顿,整顿了将近两年又重新上线了。为啥这么多坑的东西,浏览器厂商们始终念念不忘?
这说到底,还是时代变了,有了新的需求了。这已经是2016~2020时期,我们对网页的要求不再是能看清字就行,而是需要展示更复杂的动画、3d场景甚至游戏引擎,这其中的计算量多大可想而知。再加上之前说的,cpu单核能力受限,早已进入堆核心数时代,因此单核的计算完全无法和原生应用竞争。另一方面是IO问题,这时候网页中经常会有些超大的资源,比如高清贴图、模型、视频图片等等,而线程间通信还是靠深拷贝,极大拖慢了速度。要是能共享内存空间,那效率可以大大增加,实现所谓的零拷贝。再三权衡之下,工程师们认为,冒险加一块共享空间,让网页体验再上一层楼,是值得的!
当然这块空间也是很讲究的,它提供Atomics对象来支持一些原子操作,并且必须显式共享内存,就是说开发者必须手动去声明共享的数据,这强迫开发者去思考并发问题。而其它很多语言默认变量都是共享的,开发者经常不知道哪些数据陷入了竞态条件。没有任何高级抽象,只能操作二进制数据,也就是说,在这块空间中没有各种基础类型引用类型的变量,没有什么数据结构。这是一块纯纯的蛮荒之地,如果想要得自己用二进制数据和原语从头构建。

奇怪的内存共享机制

那为什么SharedArrayBuffer要设计得如此底层,而不是像其它语言一样有高级抽象呢?我查阅资料后,认为主要有两个原因:web规范设计哲学的改变、生态和架构兼容成本太高。
2013年,web标准领域的核心大佬们联合发起了一个宣言——《可扩展Web宣言》(Extensible Web Manifesto)。我们知道web技术非常依赖于标准,正是因为有标准,各家浏览器才能依据标准实现相应技术,让我们的代码能够跨平台丝滑运行。而新标准又来自于社区需求,在这之前,标准委员会根据社区需求,提出一份草案,经过冗长且反复的审批、修订,试图一次提出一个完整的解决方案。这种方式的反馈周期非常长,因为要等委员会设计出“完美方案”后,开发者才能用上,但只有开发者用上才知道是否真的“完美”,往往花费数年才能落实一个草案。而《宣言》中主张浏览器尽可能暴露底层能力,社区的开发者可以用这些底层api开发创新的工具,如果社区反馈好,就可以直接把这些工具纳入标准。并且由于是底层api实现的,因此在标准正式实现前,开发者还可以使用polyfill的方式抢先体验新规范,标准委员会也能及时的获得社区反馈。比如 Intersection Observer API最初就是谷歌通过getBoundingClientRect()和scroll事件封装的,并在2019年成为了事实标准。
在这种思想的指导下,SharedArrayBuffer被设计的非常底层,只能操作二进制数组,只能提供必要的底层原语。而整个并发工具的生态,就交给社区去构建了。比较典型的就是Atomics.waitAsync原语,原本的Atomics.wait()会阻塞主线程,因此在主线程中无法使用,因此社区及时反馈需要一个异步等待机制。现在开发者需要手动实现锁相关的功能,因此Atomics.Mutex 和 Atomics.Condition也被提上日程,现已在提案阶段。实际上官方并不鼓励应用开发者直接使用SharedArrayBuffer,毕竟前端开发人员对并发问题还是缺乏经验。官方鼓励直接使用建立在SharedArrayBuffer基础上的成熟库,或者结合WebAssembly,使用Rust等更加安全的语言来构建程序。
notion image

曲折的探索

实际上让JavaScript并发化的努力一直没有停止过。随着GPU算力的普及,人们意识到这种强大的并行算力如果挪到web端一定能掀起一场革命。果不其然,基于openGL的webGL大获成功,复杂的图形计算在浏览器成为可能,一举将web带入3d时代。OpenCL是一个非常流行的开放并行编程规范,也有成熟的C++编写的OpenCL库,它是更加通用的并行计算规范,因此当时一个尝试方向就是将OpenCL融入浏览器,在上层开发JavaScript API。
比如早在2011年,Intel实验室就发布了River Trail,一开始这是个支持并发js引擎,作为firefox插件使用,后面直接整合进了SpiderMonkey(Firefox的js引擎),因此也叫PJS(Parallel JavaScript)。River Trail正是基于OpenCL构建的,它并没将JavaScript全盘并行化,而是提供了一些支持并行的特殊数据结构,主要就是ParallelArray,一种支持并行运算的数组结构。这隐藏了底层细节,减轻了开发者的心智负担。不久后webGL的标准组准备复刻webGL的成功,开发webCL标准,希望将通用并行计算带入浏览器。不过,这些努力通通失败了,事实证明通用的并发能力在JavaScript中是极难落地的。他们的失败其实可以看做OpenCL在浏览器平台的失败,OpenCL允许指针算术和任意内存访问,在浏览器中的安全性不可控。且在大多数场景下,OpenCL的性能优势并不明显,并且有优势的部分多半WebGL也能干。再者OpenCL在移动端普及度不高,因此给浏览器造成了跨平台困难,所有各家浏览器厂商对这个标准都不大感冒。此外,River Trail还犯了之前说的错误,它想一口气构建一个完美的高级API,但是又缺乏社区的反馈,因此进度缓慢。吸取他们的教训后,webGPU取得了最后的成功,提供安全的底层原语,基于现代API(Vulkan/Metal/DX12),且移动平台支持良好。
最后再提一个非常大胆的尝试,来自苹果的webkit团队。在SharedArrayBuffer发布之后, Filip Pizlo团队认为可以将JavaScript全面的并发化,也就是说所有js线程都共享一个堆,变成传统的多线程模型。而改造之后的语法十分类似java,复杂对象也可以在线程间共享。对于棘手的DOM操作,和现在的解决方案差不多,只允许主线程操作。由于JavaScript的对象是动态大小的,这对多线程而言非常棘手,给每个对象加锁又非常影响性能。JSCore(webkit的js引擎)的对象模型是cell+butterfly,Pizlo在这个模型上提出了一种分段butterfly用于解决并发问题。这是一个非常有趣的思想实验,具体内容可以阅读原文:并发式 JavaScript:它确实可行!比较硬核,我合着ai也读了个云里雾里。不过最终它也只停留在了思想实验,有人认为它的API设计还是老java那一套,太out了,开发者心智负担大,风险大于收益。最重要的是,它只是基于webkit生态的假设,和当时主流浏览器架构(v8)不兼容,如果要改造,GC、JIT、调度等大量底层代码都需要重写,成本难以接受。
Javascript的并发之旅还没结束,这些被历史抛弃的方案并非过眼云烟,他们成为了JS生态茁壮生长不可或缺的养料,为后人提供了宝贵的经验和灵感。随着WebAssembly、WebGPU、SharedArrayBuffer等底层能力的日趋完善,我相信社区一定能创新出更优秀的并发范式,正如《可扩展Web宣言》所期待的那样。

浏览器的诅咒

JavaScript的多线程模型最终被塑造成了一个非常奇怪的样子:它能够创建许多线程,但是线程间又完全的内存隔离,必须通过消息这种低效的方式传递数据。开发者可以手动开辟一片所有线程共享的空间,但这片空间只能操作二进制数据,没有任何高级抽象,只提供一些基本的原子操作。
我个人认为这是浏览器这个特殊的环境造就的,浏览器最核心的功能就是渲染网页,最最重要的就是那个渲染主线程。很多时候服务端每个请求可以单独返回响应,因此每个请求都可以单独一个线程处理。和服务端不同,浏览器中几乎所有数据最终都是为网页渲染服务的,所以大多数数据最终都会流向渲染主线程,被页面渲染所消费。而由于历史原因,主线程被赋予了太多的任务,是绝对绝对不能阻塞的存在,并且汇聚到主线程这一操作有串行的特点,这大大限制了并发操作的发挥。并且浏览器会打开不同来源的网页,内存安全问题也不容小觑。
浏览器的历史包袱也无比繁重,堪比一个村都是钉子户。JavaScript以及配套基建一直都是按照单线程模式开发的,web标准基本上也都要考虑向前兼容,因此我们可以看到这些标准像积木一样堆砌,很少完全推倒重建,就算底层重构了也要兼容旧有标准。从单线程、到内存隔离的worker线程、到SharedArrayBuffer,每一步都像是外挂上去了一个插件,你把它们挨个拿掉也可以正常跑。
前端开发者群体整体对并发知识比较陌生,似乎是一个重要考量,我查阅资料的过程中,看到过很多反对多线程的理由,都是并发模式的心智负负担太重。
当然这些只是我个人事后诸葛的拙见,浏览器标准的确定有太多市场、性能、易用性、安全的博弈,每一步都是艰难的试探,但每一步都无比精彩。
notion image

四、Java——天生的多线程语言

传统的多线程

正如前面所讲,传统的多线程是共享进程内存空间的多线程。而Java天生就支持这种多线程模式,并且还在其基础上构建了高层级的抽象。在每个线程中,可以创建各种类型的数据和对象,并且是
notion image

多线程真的能提高性能吗?

参考资料

 
  • JavaScript
  • Java
  • 多线程
  • 并发
  • nodejs
  • Git常用命令JS数据拷贝简史
    Loading...