JS中的计算量子力学

您好,我叫德米特里·卡洛夫斯基,我……待业。 因此,我有很多空闲时间来播放音乐,运动,创造力,语言,JS会议和计算机科学。 我将向您介绍半自动将长计算拆分为几毫秒的小量域的最新研究,这产生了一个微型库$mol_fiber 。 但首先,让我们概述我们将要解决的问题..


广达!


这是HolyJS 2018 Piter中同名表演的文本版本。 您可以将其作为文章阅读 ,也可以在演示界面中将其打开 ,或者观看视频


问题:响应速度慢


如果我们希望每秒稳定60帧,那么只有16微秒的毫秒可以完成所有工作,包括浏览器在屏幕上显示结果的工作。


但是,如果我们花更长的时间怎么办? 然后,用户将观察到滞后的界面,从而抑制了UX降级的动画等。


低响应


问题:无法逃脱


碰巧在执行计算时,结果对我们而言不再有意义。 例如,我们有一个虚拟滚动,用户主动拉动它,但是我们无法跟上它,也无法渲染实际区域,直到上一个渲染返回控件来处理用户事件。


无法撤消


理想情况下,无论我们做多长时间,我们都应继续处理事件,并能够随时取消已经开始但尚未完成的工作。


我很快,我知道


但是,如果我们的工作不是一个,而是几个,而是一个流,该怎么办? 想象一下,您驾驶着刚购买的黄莲花开车前往铁路道口。 当它是免费的时,您可以瞬间将其滑动。 但是..


酷车


问题:无并发


当过境点被一公里的火车所占据时,您必须站起来等待十分钟,直到它过去为止。 不是因为您买了跑车,对吗?


快速等待慢


如果将这列火车分成10列每列100米的火车,它们之间会有几分钟的路程,那将有多酷! 那时您不会太迟。


那么,现在在JS世界中解决这些问题的解决方案是什么?


解决方案:工人


想到的第一件事:让我们将所有复杂的计算放到单独的线程中吗? 为此,我们为WebWorkers提供了一种机制。


工人逻辑


UI流中的事件将传递给工作程序。 在那里进行处理,并且关于页面上的内容和更改方式的说明已经传回。 因此,我们从大量的计算层中保存了UI流,但是并非所有问题都以这种方式解决,此外还添加了新的问题。


工人:问题:(De)序列化


流之间的通信是通过发送序列化为字节流,传输到另一个流并在其中被解析为对象的消息而发生的。 这比在单个线程中直接调用方法要慢得多。


(De)序列化


工人:问题:仅异步


消息严格异步传输。 这意味着我要求您使用某些功能。 例如,您无法停止来自工作程序的ui事件的上升,因为到处理程序启动时,UI线程中的事件将已经完成其生命周期。


消息浓度


工作者:问题:API受限


以下API对我们的工作人员不可用。


  • DOM,CSSOM
  • 帆布
  • 地理位置
  • 历史和位置
  • 同步http请求
  • XMLHttpRequest.responseXML

工人:问题:无法取消


再说一次,我们无法停止计算。


别说了


是的,我们可以停止整个工作人员,但是这将停止其中的所有任务。
是的,您可以在单独的工作程序中运行每个任务,但这会占用大量资源。


解决方案:反应纤维


当然,很多人听说过FaceBook英勇地重写了React,将其中的所有计算分解为由特殊调度程序启动的一堆小功能。


棘手的反应光纤逻辑


由于这是一个单独的大主题,因此我不会详细介绍其实现。 我将仅介绍一些功能,因此可能不适合您。


反应纤维:需要反应


显然,如果您使用Angular,Vue或React以外的其他框架,那么React Fiber对您毫无用处。


反应大家!


React Fiber:仅渲染


React-仅覆盖渲染层。 该应用程序的所有其他层均未进行任何量化。


没那么快!


当您需要例如通过棘手的条件过滤大量数据时,React Fiber不会为您省钱。


反应光纤:禁用量化


尽管声称支持量化,但默认情况下它仍处于关闭状态,因为它破坏了向后兼容性。


营销陷阱


React中的量化仍然是实验性的事情。 小心点!


React Fiber:调试很痛苦


启用量化功能后,调用堆栈将不再与您的代码匹配,从而使调试变得非常复杂。 但是,我们将回到这个问题。


所有调试的痛苦


解决方案:量化


让我们尝试概括化React Fiber方法,以摆脱上述缺点。 我们希望停留在一个流的框架内,但将长时间的计算分解为较小的量,在此之间,浏览器可以呈现已对页面进行的更改,我们将响应事件。


火焰图


在上方,您看到了很长的计算,使整个世界停滞了100毫秒以上。 从下面开始-进行相同的计算,但细分为约16毫秒的时间片,平均每秒可产生60帧。 由于我们通常不知道计算会花费多少时间,因此我们无法手动将其提前分成16ms。 因此,我们需要某种运行时机制来衡量完成任务和超出量子范围所需的时间,这将暂停执行直到下一个动画帧。 让我们考虑一下我们在这里有什么机制可以实施这些暂停的任务。


并发:纤维-Stackfull协程


在像Go和D这样的语言中,有一个成语是“带有堆栈的协程”,它也是“纤维”或“纤维”。


 import { Future } from 'node-fibers' const one = ()=> Future.wait( future => setTimeout( future.return ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 Future.task( four ).detach() 

在代码示例中,您将看到one功能,该功能可以暂停当前光纤,但它本身具有完全同步的接口。 twothreefour功能是常规同步功能,它们对光纤一无所知。 在其中,您可以充分使用javascript的所有功能。 最后,在最后一行,我们仅在单独的光纤中运行这four功能。


使用光纤非常方便,但是要支持它们,您需要运行时支持,而大多数JS解释器都没有。 但是,对于NodeJS,有一个本机node-fibers扩展添加了此支持。 不幸的是,任何浏览器都没有可用的浏览器。


并发:FSM-无堆栈协程


在像C#和现在的JS这样的语言中,都支持“无堆栈协程”或“异步函数”。 这些功能是一个状态机,对堆栈一无所知,因此您必须用特殊关键字“ async”标记它们,并在其中可以暂停它们的位置-“ await”。


 const one = ()=> new Promise( done => setTimeout( done ) ) const two = async ()=> ( await one() ) + 1 const three = async ()=> ( await two() ) + 1 const four = async ()=> ( await three() ) + 1 four() 

由于我们可能需要随时推迟计算,因此事实证明,几乎必须将应用程序中的所有功能都设为异步。 这不仅是代码的复杂性,而且会极大地影响性能。 此外,许多接受回调的API仍不支持异步回调。 一个引人注目的示例是任何数组的reduce方法。


并发:半光纤-重新启动


让我们尝试使用类似于光纤的功能,仅使用任何现代浏览器中可用的功能。


 import { $mol_fiber_async , $mol_fiber_start } from 'mol_fiber/web' const one = ()=> $mol_fiber_async( back => setTimeout( back ) ) const two = ()=> one() + 1 const three = ()=> two() + 1 const four = ()=> three() + 1 $mol_fiber_start( four ) 

如您所见,中间函数对中断一无所知-这是常规JS。 只有one功能知道暂停的可能性。 要中止计算,她只是将Promise抛出为异常。 在最后一行,我们在单独的伪光纤中运行这four功能,该函数监视内部抛出的异常,如果Promise到达,则预订其resolve ,然后重新启动光纤。


人物


为了展示伪光纤的工作原理,我们将编写一个棘手的代码。


典型执行图


假设这里的step函数将一些内容写入控制台,并在20ms内完成了一些其他工作。 walk功能调用两次,记录整个过程。 在中间,它将显示控制台中现在显示的内容。 右边是伪纤维树的状态。


$ mol_fiber:没有量化


让我们运行这段代码,看看会发生什么..


无需量化即可执行


到目前为止,一切都很简单明了。 当然,不包含伪光纤树。 一切都会好起来的,但是这段代码执行的时间超过40毫秒,这毫无价值。


$ mol_fiber:首先缓存


让我们将两个函数包装在一个特殊的包装器中,该包装器在伪光纤中运行它,然后看看会发生什么。


填充缓存


这里值得关注的事实是,对于在walk光纤内部调用one功能的每个位置,都创建了单独的光纤。 第一个调用的结果被缓存,但是由于我们用尽了时间片,所以抛出了Promise而不是第二个。


$ mol_fiber:缓存第二个


丢在第一帧中, Promise将在下一帧中自动解决,这将导致walk光纤重新启动。


缓存重用


如您所见,由于重新启动,我们再次向控制台返回了“开始”和“首先完成”,但是“第一次开始”不再存在,因为它位于光纤中,缓存已较早填充,因此其处理程序更多不叫。 当walk光纤的缓存被填满时,所有嵌入的光纤都将被破坏,因为执行将永远无法到达它们。


那么,为什么first begin打印一次,然后first done打印两次呢? 都是关于幂等的。 console.log非幂等操作,您调用它的次数,如此多次,它将向控制台添加一个条目。 但是在另一根光纤中执行的光纤是幂等的,它仅在第一次调用时执行该句柄,并在随后的返回中立即从缓存中返回结果,而不会导致任何其他副作用。


$ mol_fiber:等幂优先


让我们将console.log包裹在光纤中,使其成为幂等,然后查看程序的行为..


填充指数等缓存


如您所见,现在在光纤树中,每个调用log函数的条目都有条目。


$ mol_fiber:幂等第二


walk光纤的下一次重新启动时,对log函数的重复调用不再导致对真实console.log调用,但是一旦我们执行了具有空缓存的光纤的执行,就立即恢复对console.log的调用。


重用幂等缓存


请注意,在控制台中,我们现在不会显示任何多余的内容-完全是同步代码中显示的内容,而无需任何光纤和量化。


$ mol_fiber:中断


计算如何中断? 在数量开始时,设定了最后期限。 在启动每根光纤之前,请检查是否已经到达。 如果到达,则Promise赶到,这将在下一帧中解决,并开始一个新的量子..


 if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } 

$ mol_fiber:截止日期


量子的截止日期很容易设定。 当前时间增加了8毫秒。 为什么正好是8个,因为最多要准备16个镜头? 事实是我们不预先知道浏览器将需要渲染多长时间,因此我们需要留出一些时间才能正常工作。 但是有时会发生浏览器不需要渲染任何东西的情况,然后使用8ms量子,我们可以在同一帧中插入另一个量子,这将使量子紧密压缩,同时将处理器停机时间降至最低。


 const now = Date.now() const quant = 8 const elapsed = Math.max( 0 , now - $mol_fiber.deadline ) const resistance = Math.min( elapsed , 1000 ) / 10 // 0 .. 100 ms $mol_fiber.deadline = now + quant + resistence 

但是,如果我们每8ms抛出一个异常,那么在异常停止打开的情况下进行调试将变成一团糟。 我们需要某种机制来检测此调试器模式。 不幸的是,这只能间接地理解:一个人花了大约一秒钟的时间来了解是否继续执行。 这意味着,如果控件很长时间没有返回脚本,则调试器将停止运行,或者计算量很大。 要坐在两把椅子上,我们将经过的时间增加10%,但不超过100毫秒。 这不会严重影响FPS,但是由于量化,它会将调试器的停止频率降低了一个数量级。


调试:尝试/捕获


既然我们在谈论调试,那么您认为调试器在代码的什么位置停止?


 function foo() { throw new Error( 'Something wrong' ) // [1] } try { foo() } catch( error ) { handle( error ) throw error // [2] } 

通常,他需要在第一次引发异常的位置停止,但现实情况是,他仅在上次引发异常的位置停止,通常离异常发生​​的位置很远。 因此,为了不使调试复杂化,永远不要通过try-catch捕获异常。 但是即使没有异常处理,这也是不可能的。


调试:未处理的事件


通常,运行时会提供针对每个未捕获的异常发生的全局事件。


 function foo() { throw new Error( 'Something wrong' ) } window.addEventListener( 'error' , event => handle( event.error ) ) foo() 

除了麻烦之外,该解决方案还具有以下缺点:所有异常都落在这里,并且很难从哪个光纤和光纤中了解该事件是否发生。


调试:承诺


承诺是处理异常的最佳方法。


 function foo() { throw new Error( 'Something wrong' ) } new Promise( ()=> { foo() } ).catch( error => handle( error ) ) 

传递给Promise的函数将立即被同步调用,但是不会捕获到异常,并且可以安全地在发生调试器的位置停止调试器。 再过一会儿,它已经异步地调用了错误处理程序,在错误处理程序中,我们确切地知道是哪根光纤导致了故障,而哪根故障了。 这正是$ mol_fiber中使用的机制。


堆叠痕迹:反应纤维


让我们看一下在React Fiber中获得的堆栈跟踪。


空的数值


如您所见,我们得到了很多直觉。 从这里的有用处来看,层次结构中只有异常的发生点和组件的名称更高。 不是很多


堆栈跟踪:$ mol_fiber


在$ mol_fiber中,我们获得了更为有用的堆栈跟踪:没有胆量,只有应用程序代码中的特定点才导致异常。


内容跟踪


这可以通过使用本机堆栈,promise和自动删除肠来实现。 如果您愿意,可以在控制台中扩展错误,如屏幕截图所示,并查看其内容,但是没有什么有趣的。


$ mol_fiber:处理


因此,为了打断量子,将抛出Promise。


 limit() { if( Date.now() > $mol_fiber.deadline ) { throw new Promise( $mol_fiber.schedule ) } // ... } 

但是,正如您可能猜到的那样,Promise绝对可以是任何东西-对Fibre而言,一般而言,期望的内容并不重要:下一帧,数据加载完成或其他。


 fail( error : Error ) { if( error instanceof Promise ) { const listener = ()=> self.start() return error.then( listener , listener ) } // ... } 

光纤只订阅解决承诺并重新启动。 但是不需要手动抛出和捕获承诺,因为该程序包包含几个有用的包装器。


$ mol_fiber:函数


要将任何同步函数转换为幂等光纤,只需将其包装在$mol_fiber_func


 import { $mol_fiber_func as fiberize } from 'mol_fiber/web' const log = fiberize( console.log ) export const main = fiberize( ()=> { log( getData( 'goo.gl' ).data ) } ) 

在这里,我们使console.log幂等,并且main教导我们在等待下载时进行中断。


$ mol_fiber:错误处理


但是,如果我们不想使用try-catch该如何应对异常? 然后我们可以使用$mol_fiber_catch注册错误处理程序。


 import { $mol_fiber_func as fiberize , $mol_fiber_catch as onError } from 'mol_fiber' const getConfig = fiberize( ()=> { onError( error => ({ user : 'Anonymous' }) ) return getData( '/config' ).data } ) 

如果我们返回的错误与错误有所不同,那将是当前光纤的结果。 在此示例中,如果无法从服务器下载配置,则默认情况下, getConfig函数将返回配置。


$ mol_fiber:方法


当然,您不仅可以包装函数,还可以使用装饰器包装方法。


 import { $mol_fiber_method as action } from 'mol_fiber/web' export class Mover { @action move() { sendData( 'ya.ru' , getData( 'goo.gl' ) ) } } 

例如,在这里,我们从Google上传数据并将其上传到Yandex。


$ mol_fiber:承诺


要从服务器下载数据,只需采取异步功能即可,只要轻按一下就可以将其转换为同步功能。


 import { $mol_fiber_sync as sync } from 'mol_fiber/web' export const getData = sync( fetch ) 

此实现对每个人都有好处,但是它不支持在破坏一棵纤维树时取消请求,因此我们需要使用更加混乱的API


$ mol_fiber:取消请求


 import { $mol_fiber_async as async } from 'mol_fiber/web' function getData( uri : string ) : Response { return async( back => { var controller = new AbortController(); fetch( uri , { signal : controller.signal } ).then( back( res => res ) , back( error => { throw error } ) , ) return ()=> controller.abort() } ) } 

传递给async包装器的函数仅被调用一次, async包装器被传递给它,您需要在其中包装回调。 因此,在这些回调中,您必须返回值或引发异常。 无论回调的结果如何,它也将是光纤的结果。 请注意,最后我们返回一个函数,以防光纤过早损坏。


$ mol_fiber:取消响应


在服务器端,当客户端中断时取消计算也很有用。 让我们在midleware上实现一个包装器,以创建一个将在其中运行原始midleware的光纤。 , , , .


 import { $mol_fiber_make as Fiber } from 'mol_fiber' const middle_fiber = middleware => ( req , res ) => { const fiber = Fiber( ()=> middleware( req , res ) ) req.on( 'close' , ()=> fiber.destructor() ) fiber.start() } app.get( '/foo' , middle_fiber( ( req , res ) => { // do something } ) ) 

$mol_fiber: concurrency


, . , 3 : , , - ..


快速和慢速请求


: , . . , , .


$mol_fiber: properties


, ..


Pros:
  • Runtime support isn't required
  • Can be cancelled at any time
  • High FPS
  • Concurrent execution
  • Debug friendly
  • ~ 3KB gzipped


Cons:
  • Instrumentation is required
  • All code should be idempotent
  • Longer total execution

$mol_fiber — , . — , . , , . , , , , . , . .


Links



Call back


意见反馈


: , , )


: , .


: . , .


: . , . , .


: , . , )


: , .


: - . , , .


: . , , .


: , . 16ms, ? 16 8 , 8, . , . , «».


: — . 谢谢你


: . , . !


: , . .


: , , , , , / , .


: , .


: .


: , . mol.


: , , . , , , .


: .


: , . , $mol, , .


: , , . — . .


: - , .


: $mol , . (pdf, ) , .


: , . , .


: , ) .


: . .


: In some places I missed what the reporter was saying. The conversation was about how to use the "Mola" library and "why?". But how it works remains a mystery for me.To smoke an source code is for the overhead.


: , .


: . , . . .


: : . - (, ). , : 16?


: . . , mol_fiber … , 30fps 60fps — . — .

Source: https://habr.com/ru/post/zh-CN413791/


All Articles