Javascript的多线程和并发控制

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是不一样的.


setTimeout开启了新的调用栈

还是在当前调用栈中

提到Stack, 就想起面试字节的时候面试官闹得笑话, 暂且按下不表. [1]

所以说, 当我们真正的想编写高性能的js代码, 想充分利用事件循环和异步变成带来的好处. 首先, 我们要写出真正的异步代码.

本文真正的核心在于并发控制. 是的, js存在并发控制. 通过上面的举例/解释, js代码在运行时是存在多线程和并发控制的. 最普遍的场景莫过于网络请求的时序和优先级控制.
虽然并发场景多数情况下不需要程序员过多的控制和担心. 但真实的需求场景很多时候是有并行逻辑的. 强行使用async/await语法糖将并行逻辑捋成串行逻辑虽然可以简化场景, 却牺牲了性能. 在首屏加载或者其他重要场合, 过长的等待终究会带来无法承受的痛苦.
所以, 我们还是需要一些并发控制的能力, 来优化性能/提升体验.

当并行任务结束, 执行结果回馈到js代码里, 剩下的逻辑还是根据消息队列依次处理的.
所以这里引入了新的概念, 就是协程coroutine. 简单来说, 操作系统有1-N个进程[2]. 进程内部有1-N个线程. 线程内部可以有1-N个协程.
进程/线程的具体调度是用程序发起系统调用, OS最终完成. 而协程是由程序员直接调度, 暂时放弃当前函数的执行权. 由yield关键字将执行权让渡给迭代器.
为什么协程的执行效率要更高一点, 这就会牵扯到CPU上下文切换带来的额外开销. [3]

铺垫了这么久, 是时候下个总结了.

  1. js代码的调度是单线程的. 所以, 不需要锁来控制.
  2. js运行时是存在并发场景的.
  3. 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本身提供的能力.

  1. Generator的特性, 使得函数可以多次的重复进入退出, 但是语法上又可以保证连续性, 这样在一定的设计下, 可以良好的避免全局对象的污染.[6]
  2. saga中yield返回的effect, 其实只是对复杂/敏感操作的描述, 这样在UT中可以方便的去剥离外部系统的干扰.
  3. 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

  1. 面试官提问, js中栈内存和堆内存是如何管理的. 在我再三强调js没有栈内存的时候, 面试官仍然希望我能说出代码里面可以如何操纵栈内存. 面试结束后, 经过多方查阅资料及代码测试. 最终确认, 以NodeJS(v18.17.1 x64 win11)为例, 栈内存只受未被闭包捕获的变量数目和函数调用栈深度影响.

  2. 一个进程这种情况可以参考docker镜像.

  3. 正常的程序只能持续执行直到结束. 但是由于CPU复用的原因, 线程会出现切换. 这就会引申出线程池等等性能优化相关的话题.

  4. Command and Query Responsibility Segregation https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs

  5. Communicating sequential processes https://levelup.gitconnected.com/communicating-sequential-processes-csp-for-go-developer-in-a-nutshell-866795eb879d

  6. 由于客观上的特性, 前端代码经常是以离散的形式, 夹杂重复的/无效的操作来完成最终业务.

  7. 这样就避免了对全局状态的依赖.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容