Javascript是单线程异步的.
但是:
- js编写的应用程序不一定是单线程的.
由上可得: - js的码农也需要考虑并发/并行控制.
首先, 我们需要明确的一点就是. Javascript是一门解释型脚本变成语言. 本身JS代码不能直接执行. 而是由JS引擎代为解释执行.
而V8是目前最流行的JS引擎. 如果你是一个资深老前端, 你肯定接触过火狐的Spider Monkey. 如果你是接触过ReactNative, 那你肯定接触过Javascript Core/Hermes.
长话短说, 关键点在于, 你所调用的方法, 很有可能并不是js代码, 而是js引擎在运行时注入的native code.
console.log(console.log.toString())
// function log() { [native code] }
所以, js程序很有可能存在并行执行的场景.
但是, js代码为什么可以按照单线程的方式来编写, 而且可以良好的执行呢?
这是由于JS引擎的事件循环+消息队列机制. 简单来说, 这些复杂的场景会在js引擎调度执行后, 将结果以消息的形式置于队列中. 然后通过事件循环的形式, 调用对应的方法回调.
回到开始的地方,
JS是单线程
异步的.
document
.querySelector('body')
.addEventListener('click', function() {
console.log('clicked');
});
上面的代码是异步的.
但是下面的代码是同步的.
function work(cb) {
cb();
}
work(function() {
console.log('work! work! work!');
});
下面的代码是上面的代码的异步版本.
function work(cb) {
setTimeout(cb, 0);
}
work(function() {
console.log('work! work! work!');
});
为什么加了setTimeout就是异步的, 不加就是同步的. 结合事件循环和消息队列才能深入的理解.
配合debugger我们可以很清晰的看到两种写法的Call Stack是不一样的.


提到Stack, 就想起面试字节的时候面试官闹得笑话, 暂且按下不表. [1]
所以说, 当我们真正的想编写高性能的js代码, 想充分利用事件循环和异步变成带来的好处. 首先, 我们要写出真正的异步代码.
本文真正的核心在于并发控制. 是的, js存在并发控制. 通过上面的举例/解释, js代码在运行时是存在多线程和并发控制的. 最普遍的场景莫过于网络请求的时序和优先级控制.
虽然并发场景多数情况下不需要程序员过多的控制和担心. 但真实的需求场景很多时候是有并行逻辑的. 强行使用async/await语法糖将并行逻辑捋成串行逻辑虽然可以简化场景, 却牺牲了性能. 在首屏加载或者其他重要场合, 过长的等待终究会带来无法承受的痛苦.
所以, 我们还是需要一些并发控制的能力, 来优化性能/提升体验.
当并行任务结束, 执行结果回馈到js代码里, 剩下的逻辑还是根据消息队列依次处理的.
所以这里引入了新的概念, 就是协程coroutine. 简单来说, 操作系统有1-N个进程[2]. 进程内部有1-N个线程. 线程内部可以有1-N个协程.
进程/线程的具体调度是用程序发起系统调用, OS最终完成. 而协程是由程序员直接调度, 暂时放弃当前函数的执行权. 由yield关键字将执行权让渡给迭代器.
为什么协程的执行效率要更高一点, 这就会牵扯到CPU上下文切换带来的额外开销. [3]
铺垫了这么久, 是时候下个总结了.
- js代码的调度是单线程的. 所以, 不需要锁来控制.
- js运行时是存在并发场景的.
- js的同步代码是具有原子性的. 所以, js的挂起只能是协程yield或者异步函数的入口.
强调了多线程的存在, 有这么几个好处. 比如说, 为什么Promise不可取消. Promise是js社区用于管理异步的方案, 而js中的异步, 在多线程的使用场景中, 基本可以对照为相等的场景.
- 从线程的角度来讲, 因为涉及到系统资源的申请和释放. 所以最好不要直接杀掉/中断一个线程, 而是通过标志位的改变, 让线程自己主动的结束执行.
- 资源的申请和释放应该由同一个角色负责. 而多线程一但启动, 那么作为一个独立执行并参与调度的单元, 其申请的资源应该由其自己释放. 如果我们直接去中断一个线程的执行, 那么代码内部的释放资源逻辑就得不到执行的机会. 这通常就是内存泄漏/死锁等等严重问题的原罪.
- 至于为什么平时编程不需要这么麻烦, 是因为多数应用级业务代码都是轻量级的组件. 当业务中使用了数量敏感的系统资源, 例如套接字socket和文件句柄, 不及时的释放, 会很容易导致当前进程被操作系统杀掉.
既然说到了多线程, 那么并发控制的基础操作, fork / join 以及cancel. 在社区主流声音在强调async的优雅时, 我还是推荐一下基于Generator的redux-saga库. 我们在js里假装有多线程, 并进行相关的并发编程
尤其是由于种种原因基于promise的async是不支持取消的. 但是, 实际业务中, 往往又会出现竞态解决后, 需要停止失败的业务线代码执行. 所以, 在这些需要取消的场景下, 我们需要严谨的/优雅的快速结束pending中的代码. 手写判断会导致业务代码掺杂了太多的冗余逻辑. 而Generator是需要由外部推动迭代器执行的这个设计, 却正好可以解决相关的痛点.
说到redux-saga这个库, 其实这是一个被名字拖累的宝藏库. 它本身完全可以脱离redux来运行.
初识saga是因为redux本身不擅长管理异步状态. 透过库本身的哲学理论, 进一步的了解CQRS[4]以及CSP[5].
其实早些年阿里系推出的dvajs的时候, 就内置了redux-saga的使用. 但是, 不得不遗憾的说, 其实dvajs对saga的使用方向违背了其设计原则.
快速说一下saga本身提供的能力.
- Generator的特性, 使得函数可以多次的重复进入退出, 但是语法上又可以保证连续性, 这样在一定的设计下, 可以良好的避免全局对象的污染.[6]
- saga中yield返回的effect, 其实只是对复杂/敏感操作的描述, 这样在UT中可以方便的去剥离外部系统的干扰.
- saga提供了一个高维度的视角, 让我们组织代码的时候, 不是以事件回调开始到结束, 而是以整体业务的范围去统筹规划.[7]
基于上述几个原因, 在实际业务代码中, 基于事件驱动的模型里. 通过channel[CSP提供的抽象能力]梳理后, 将数据特征和业务特征进行模式匹配. 剩下的事情就是自然而然的了.
当我们有了新的视角去分析业务问题, 那么我们所能提供的解决方案也就有了新的可能.
治大国如烹小鲜. 希望在面对复杂的系统设计的时候, 我们可以早日摆脱细枝末节的奇技淫巧.
PS. js栈空间内存探测代码及结果
function main() {
let code = '';
for (let i = 0; i < Math.pow(2, 18); i++) {
code += `const num${i} = 0;`;
}
new Function(code)();
}
main();
// 0;const num262118 = 0;const num262119 = 0;const
// 62128 = 0;const num262129 = 0;const num262130 =
// nst num262139 = 0;const num262140 = 0;const num
// ^
//
// RangeError: Maximum call stack size exceeded
-
面试官提问, js中栈内存和堆内存是如何管理的. 在我再三强调js没有栈内存的时候, 面试官仍然希望我能说出代码里面可以如何操纵栈内存. 面试结束后, 经过多方查阅资料及代码测试. 最终确认, 以NodeJS(v18.17.1 x64 win11)为例, 栈内存只受未被闭包捕获的变量数目和函数调用栈深度影响. ↩
-
一个进程这种情况可以参考docker镜像. ↩
-
正常的程序只能持续执行直到结束. 但是由于CPU复用的原因, 线程会出现切换. 这就会引申出线程池等等性能优化相关的话题. ↩
-
Command and Query Responsibility Segregation https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs ↩
-
Communicating sequential processes https://levelup.gitconnected.com/communicating-sequential-processes-csp-for-go-developer-in-a-nutshell-866795eb879d ↩
-
由于客观上的特性, 前端代码经常是以离散的形式, 夹杂重复的/无效的操作来完成最终业务. ↩
-
这样就避免了对全局状态的依赖. ↩