并发调度
Haploid.js 从一开始设计就专门在并发调度上进行优化,以取得尽可能高的用户响应效率。
何为调度呢?我们知道,在微前端架构中,子应用的装载和卸载常常是受控于用户的操作,比如点击链接和按钮,或者点击浏览器前进后退。我们可以称这种操作为指令。
微前端系统需要处理这种指令,来决定装载哪个子应用,进而卸载所有其它子应用。然而,子应用的装载和卸载往往是异步的,它往往不能立即响应指令。
因此,系统需要有一种机制能够缓存所有指令,并根据一些原则来决定指令的处理时机,比如必须等到前一个子应用卸载完成后,方可装载下一个子应用,以免发生冲突。此外,系统可能还需要甄别哪些指令是过时的,应该抛弃它。比如“装载 A”的指令尚未开始处理,又收到了“装载 B”的指令,那么显然“装载 A”就是一个过时指令。
上述的这种微前端机制称之为调度。我们来举另一个形象的例子。
地球🌍操控远在火星👽上的火星车,由于距离的因数,中间会有很长一段延时。发出去的指令,往往前一个还在路途中,地球就又发出了一个新指令。
火星车该如何处理这些指令呢?
- 首先,要看执行中指令是否过期
- 新的指令如何与当前正在执行中的指令有互斥,那么当前指令就过期了,可能需要中断它,也可能需要等待它执行完成,这个过程可能需要一段时间;
- 如果没有互斥,那么就可以并发执行
- 其次,要看下一个指令是否过期
- 在执行下一个新指令前,检查最新收到的指令是否与该指令互斥,如果互斥,那么立即抛弃该指令
显然,和我们的微前端一样,都可以理解为一个经典的共享资源的异步任务处理系统。微前端需要系统尽快地响应用户的最新操作指令,而无需关心用户在这个指令之前做了什么。
那么我们就必须深挖指令之间的关系,让过期指令尽快退出,才能加速响应。
如何判断指令是互斥的?
一般来说,不同指令具有操作同一种资源(比如内存)的可能性,那么它们就是互斥的。两条尝试向数据库中同一行写入数据的指令,显然就是互斥的,否则就会引起不一致的结果。
互斥的本质是竞争,解决这种竞争至少有两种办法:
- 在资源上加锁;
- 在指令上加锁
前一种适合用在数据库上,因为数据库作为一种高度聚合的资源,在单一节点上实现即可应对各种各样的指令。
微前端领域的竞争资源主要是 DOM —— 两个子应用不可能在同一个挂载点创建自己的 DOM 结构,也可能包括 URL,两个子应用都要求浏览器的地址栏和自己相匹配也是不可能的。
你还能想到其它资源,比如 localStorage、WebSocket 等等,这些资源的特点是分散,没有办法统一加锁来应对不同子应用的竞态访问,因此简单和行之有效的办法就是不允许两个子应用同时被激活,即在指令上加锁 —— 装载 A 就必须卸载 B。
但是指令的执行是异步的,“卸载 B” 发出时,可能 B 正在装载中,也可能在执行其它指令,要不要等待它完成呢?
等与不等,又涉及到同一个应用内部对资源的竞争了,具体结果比较复杂,比如卸载一个正在装载的应用会发生什么呢?可能装载过程就停止了,但也有可能它会重新创建 DOM,后者是我们不希望发生的。因此最保守的做法是等待装载完成再卸载,这也是常规的指令调度策略:一个指令等待另一个指令,队列模型。
但这样可能需要等待太久,比如在 single-spa 的模型中,bootstrap 是下载远程资源的阶段,耗时更久,它会严重影响后续指令。
如果我们能确定甚至缩小不同指令之间发生竞争的时间区间,那么显然它们就不必在整个时间线上互斥和等待了。
可中断的指令
试看下图,Haploid.js 使用这样一种副作用区间模型,即认为指令之间并非完全互斥,而是存在一个副作用区间,只要在这个区间内只有一个指令在运行,那么两个指令在时间线上是有一定重合度的。
来看上半部分的方案 A,同时观察横轴时间线,指令 start 正在执行中,假设在 0.5s 时发出 stop 指令,等待 start 执行完毕,再执行 stop,共耗时 10.5s。
现在我们在指令执行过程中设计多个“中断点”,一旦执行到此,便检查是否有必要退出。来看方案 B。start 在执行到第一个中断点时,发现有新的 stop 指令到来,那么 start 应该立即退出,stop 开始执行,整个过程耗时 6.75s。
如果 start 在第二个“中断点”退出,那么 stop 也应该相应地延后,但总是要比方案 A 更快。
Haploid.js 要求指令都是异步的,这也符合 single-spa 的规范。因此中断点便容易设计。
具体来说,Haploid.js 具有以下效果:
- 一个子应用的 bootstrap 生命周期过程不会对任何其它子应用的任何生命周期造成阻塞,Haploid.js 假设 bootstrap 不会产生副作用;
- 连续发出对同一个应用的装载和卸载指令,并不会执行装载过程,反之亦然;
- 装载和更新过程中,应用可能被中断,以尽快响应卸载指令,见安全模式
总结
Haploid.js 使用副作用区间模型来描述指令之间的互斥状态,尽可能缩短互斥的时间,从而提前指令的执行,加快系统对用户操作的反应速度。
以上所说的指令,在子应用层是指各个生命周期函数:bootstrap、mount、unmount 和 update,这和 single-spa 是完全一致的。Haploid.js 进一步抽象成了具备调度能力的 start、stop、update、unload 四个指令。大致对应关系是:
start => bootstrap+mount
stop => unmount
update => update
unload => unmount
因此,如果 start 指令正在执行 bootstrap,stop 和 unmount 根本无需等待其结束。
提示
以上就是实现并发调度的原理,本质上就是降低指令的粒度,以尽可能找到更多可以并行执行的时间区间。
这种实现的弊端就是让指令之间变得更耦合,如果未来新增指令,那么就需要遍历与现有指令之间的关系。