你知道伊万·图卢普吗? 很有可能,您只是不知道这是什么样的人,因此您需要特别注意他的心血管系统的状态。
关于这一点以及异步在JS中的工作原理,事件循环在浏览器和Node.js中的工作方式,是否存在任何差异,
Mikhail Bashurov (
SaitoNakamura )在其关于RIT的报告中讲述了类似的事情。 ++。 我们很高兴与您分享这份内容丰富的演讲的笔录。
关于演讲者: Mikhail Bashurov是Luxoft的JS和.NET上的全栈Web开发人员。 他喜欢漂亮的UI,绿色测试,编译,编译,允许技术的编译器和改善开发人员的经验。
编者注: Mikhail的报告不仅随附幻灯片,还包含一个演示项目,在该项目中,您可以单击按钮并独立观看随机播放的执行情况。 最好的选择是在相邻的选项卡中打开
演示文稿并定期引用它,但是文本还将提供指向特定页面的链接。 现在我们将地板交给演讲者,享受阅读。
祖父伊万·图卢普
我曾竞选伊万·图卢普(Ivan Tulup)。

但是我决定走一条更加顺从的道路,所以见一面-祖父Ivan Tulup!

实际上,关于他只需要知道两件事:
- 他喜欢玩纸牌。
- 像所有人一样,他有一颗心,它跳动着。
心脏病发作事实
您可能已经听说,心脏病和心脏病致死病例最近变得更加频繁。 可能最常见的心脏病是心脏病发作,即心脏病发作。
心脏病发作有什么有趣之处?
- 通常,它发生在星期一早上。
- 在单身人士中,心脏病发作的风险要高两倍。 在这里,也许这一点仅是相关,而不是因果关系。 不幸的是(或者幸运的是)事实是这样。
- 十名指挥家在进行过程中死于心脏病发作(显然是非常紧张的工作!)。
- 心脏病发作是由于血液缺乏引起的心肌坏死。
我们有一个冠状动脉,将血液带到肌肉(心肌)。 如果血液开始流动不畅,肌肉就会逐渐死亡。 自然,这会对心脏及其工作产生极其不利的影响。
祖父伊万·图卢普(Ivan Tulup)也有一颗心,它跳动着。 但是我们的心在抽血,伊万·图卢普的心在抽我们的代码和悲伤。
Tasky:血液循环大圈
什么是任务? 浏览器通常可以偷懒什么? 为什么根本需要它们?
例如,我们从脚本执行代码。 这是一个心跳,现在我们有血液流动。 我们单击按钮并订阅事件-该事件的事件处理程序吐出-我们发送的回调。 他们设置了超时,回调工作了-另一个任务。 因此,从某种程度上来说,心跳是一项任务。

白菜有许多不同的来源,根据规格,它们很多。 我们的心在继续跳动,尽管跳动着,但一切对我们都很好。
浏览器中的事件循环:简化版本
这可以用非常简单的图表表示。

- 有一个任务,我们已经完成了。
- 然后我们执行浏览器渲染。
但是实际上,这不是必需的,因为在某些情况下,浏览器可能无法在两个任务之间进行渲染。
例如,如果浏览器可以决定对多个超时或多个滚动事件进行分组,则会发生这种情况。 或在某个时候出了点问题,浏览器决定以30 fps的速率显示而不是60 fps(通常的帧速率,以便一切变得流畅和流畅)。 因此,他将有更多时间执行您的代码和其他有用的工作,他将能够执行几次冲击。
因此,渲染不是在每个任务之后真正执行的。
Tasky:分类
有两种潜在的操作:
- I / O绑定;
- CPU限制。
CPU限制是我们所做的有用的工作(相信,显示等)
I / O约束是我们可以共享任务的关键点。 可能是:
我们将setTimeout设置为5000毫秒,我们只是等待这5000毫秒,但是我们可以做其他有用的工作。 只有在这段时间过去之后,我们才会收到Callback,并在其中进行一些工作。
我们上网了。 在等待网络响应时,我们只是在等待,但我们也可以做一些有用的事情。
或者,例如,我们转到Network BD。 我们还讨论了Node.js,包括,如果我们想从Node.js进入网络,这是相同的潜在I / O绑定任务(输入/输出)。
读取文件-可能根本不是与CPU绑定的任务。 老实说,在Node.js中,由于Linux API的扭曲,它在线程池中运行。
然后,CPUbound是:
- 例如,当我们进行for / for(;;)循环时,或者使用其他方法(过滤器,映射等)以某种方式通过数组。
- JSON.parse或JSON.stringify,即消息序列化/反序列化。 这些都是在CPU上完成的,我们不能只等它在某个地方神奇地执行。
- 计算哈希,即加密挖矿。
当然,也可以在GPU上开采加密货币,但是我认为-GPU,CPU-您可以理解这种类比。
Tasky:心律失常和血栓
结果,我们的心脏跳动了:它完成了一项任务,第二项,第三项-直到我们做错了事。 例如,我们遍历一百万个元素的数组并计算总和。 看来这并不是那么困难,但是可能要花一些时间。 如果我们在不释放任务的情况下花费大量时间,则无法执行渲染。 他在这种渴望中盘旋,所有心律不齐开始。
我认为每个人都知道心律不齐是一种相当不愉快的心脏病。 但是你仍然可以和他住在一起。 如果您将一个仅将整个事件循环挂起的任务置于无限循环中,该怎么办? 您在冠状动脉或其他动脉中放置了血块,一切都会变得完全难过。 不幸的是,我们的祖父伊万·图卢普(Ivan Tulup)将死。
所以爷爷伊万死了...

对我们来说,这意味着整个选项卡完全冻结-您无法单击任何内容,然后Chrome会说:“糟糕!”
这比发生问题时的网站错误要严重得多。 但是,如果所有东西都挂了,甚至可能CPU加载了并且用户通常都挂了,那么他很可能永远不会再去您的站点。
因此,我们的想法是这样的:我们有一个任务,我们不需要长时间挂在这个任务上。 我们需要快速释放它,以便浏览器(如果有)可以呈现(如果需要)。 如果您不想-很棒,那就跳舞吧!
Philip Roberts演示:Philip Roberts的放大镜
考虑
一个例子 :
$.on('button', 'click', function onClick(){ console.log('click'); }); setTimeout(function timeout() { console log("timeout"); }. 5000); console.log(“Hello world");
本质是:我们有一个按钮,我们订阅了它(addEventListener),Timeout调用了5秒钟,然后立即在console.log中写入“ Hello,world!”,在setTimeout中写入Timeout,在onClick中写入Click。
如果我们运行它并且多次单击按钮,将会发生什么情况-何时实际执行超时? 让我们看一下演示:
代码开始执行,进入堆栈,超时发生。 同时,我们单击了按钮。 在队列的底部,添加了几个事件。 在运行Click的过程中,尽管经过了5秒钟,但超时仍在等待。
在这里,onClick很快,但是,如果您放置了更长的任务,则所有内容都会冻结,如前所述。 这是一个非常简化的示例。 这是一回合,但实际上,在浏览器中,并非所有事情都如此。
事件以什么顺序执行-HTML规范怎么说?
她说:我们有2个概念:
- 任务来源;
- 任务队列。
任务源是一种任务。 这可能是用户交互,即onClick,onChange-用户与之交互的东西; 或计时器,即setTimeout和setInterval或PostMessages; 甚至是完全野生的类型,例如Canvas Blob序列化任务源-也是一个单独的类型。
该规范说,对于相同的任务,将确保源任务按添加的顺序执行。 对于其他所有内容,都无法保证,因为可以有无限数量的任务队列。 浏览器决定会有多少个。 借助任务队列及其创建,浏览器可以确定某些任务的优先级。
浏览器优先级和任务队列

想象一下,我们有3行:
- 用户互动;
- 超时时间
- 发布消息。
浏览器开始从以下队列获取任务:
- 首先,他进行了焦点用户交互-这非常重要-心跳消失了。
- 然后他接受了postMessages-好吧,postMessages的优先级很高,太酷了!
- 下一个onChange也是来自用户交互的优先级。
- 接下来发送onClick 。 用户交互队列已经结束,我们已经向用户显示了所需的一切。
- 然后我们使用setInterval ,添加postMessages。
- setTimeout将仅执行最新的 。 他在那条线的尽头。
这又是一个非常简化的示例,不幸的是,
没有人可以保证它在浏览器中的工作方式 ,因为它们是由他们自己决定的。 如果要了解它是什么,则需要自己进行测试。
例如,postMessages优先于setTimeout。 您可能听说过setImmediate这样的东西,例如在IE浏览器中,它只是本机的。 但是有些多文件主要不是基于setTimeout,而是基于创建postMessages通道并订阅它。 通常这会更快,因为浏览器会对其进行优先级排序。
好了,这些任务已经执行了。 我们在什么时候完成任务并了解可以采取下一个任务还是可以进行渲染?
叠放
堆栈是一个简单的数据结构,它遵循“后进先出”的原则,即 “我放了最后一个-您得到了第一个
。 ” 最接近的,可能是真实的副本是一副纸牌。 因此,我们的祖父Ivan Tulup喜欢玩纸牌。

上面的示例中有一些代码,可以在
演示文稿中找到相同的示例。 在某些地方,我们调用handleClick,输入console.log,然后调用showPopup和window。 确认。 让我们形成一个堆栈。
- 因此,首先我们使用handleClick并将对该函数的调用推入堆栈-太好了!
- 然后我们进入他的身体并执行它。
- 我们将console.log放在堆栈上并立即执行它,因为在那里所有的东西都可以执行它。
- 接下来,我们将showConfirm-这是一个函数调用-很好。
- 我们将函数放在堆栈上-将其主体放在window.confirm中。
我们仅此而已-我们正在这样做。 将会弹出一个窗口:“确定吗?”,单击“是”,所有内容都将离开堆栈。 现在,我们完成了showConfirm主体和handleClick主体。 我们的堆栈已清除,我们可以继续执行下一个任务。 问题:好的,我现在知道您需要将所有内容分解成小块。 例如,在最基本的情况下,我该怎么做?
将数组划分为块并异步处理
让我们看一下最“正面”的例子。 我立即警告您:请不要尝试在家里重复此操作-它不会编译。

我们有一个很大的数组,我们想基于它来计算一些东西,例如,解析一些二进制数据。 我们可以简单地将其分解成小块:处理这块,这个和这个。 我们选择块的大小,例如1万个元素,然后考虑要拥有多少块。 我们有一个parseData函数,该函数进入CPU限制,并且确实可以完成一些繁重的工作。 然后我们将数组分成多个块,执行setTimeout(()=> parseData(slice),0)。
在这种情况下,浏览器将再次能够确定用户交互的优先级并在两者之间进行渲染。 也就是说,您至少释放了事件循环,并且该循环继续工作。 你的心继续跳动,那很好。
但这确实是一个非常“前置”的例子。 浏览器中有许多API可以帮助您以更专业的方式执行此操作。
除了setTimeout和setInterval之外,还有一些超出限制的API,例如requestAnimationFrame和requestIdleCallback。
可能很多人都熟悉
requestAnimationFrame ,甚至已经使用过它。 它在渲染之前执行。 它的魅力在于,首先,它尝试每60 fps(或30 fps)执行一次,其次,所有这些都在创建CSS Object Model等之前立即完成。

因此,即使您有几个requestAnimationFrame,它们也将实际上对所有更改进行分组,并且框架将完整显示。 对于setTimeout,您当然无法获得这样的保证。 一个setTimeout会改变一件事,而另一件事会改变,在这之间渲染可能会滑移-您将在屏幕或其他物体上出现抖动。 RequestAnimationFrame对此非常有用。
除此之外,还有
requestIdleCallback。 也许您听说过它在React v16.0(Fiber)中使用。 RequestIdleCallback的工作方式是,如果浏览器了解到帧之间有时间间隔(60 fps)可以做一些有用的事情,并且同时他们已经完成了所有工作-他们完成了任务,requestAnimationFrame完成了-看起来很酷,那么它就很酷可以产生小的量子,例如每个50毫秒,因此您可以执行某些操作(“空闲”模式)。
由于它不在任何特定位置,因此它不在上图中。 浏览器可以决定将其放置在requestAnimationFrame和渲染之间,框架之后,任务之后,任务之前,框架之后。 没有人能保证这一点。
向您保证,如果您的工作与更改DOM无关(因为requestAnimationFrame是动画等),尽管它不是超优先级而是有形的,那么requestIdleCallback是您的出路。
因此,如果我们有很长的CPU限制操作,那么我们可以尝试将其分解成碎片。
- 如果这是DOM更改,则使用requestAnimationFrame。
- 如果这是一项非优先级,短暂且不困难的任务,不会使 CPU 过载 ,则请requestIdleCallback。
- 如果我们有一项需要不断执行的强大任务,那么我们将超越事件循环并使用WebWorkers。 没有别的办法了。
浏览器中的任务:- 将所有内容粉碎成小任务。
- 有许多类型的任务。
- 这些类型通过规范队列对任务进行优先级排序。
- 许多事情由浏览器决定,了解它如何工作的唯一方法是简单地检查一个或另一个代码是否正在运行。
- 但是并不总是遵守规范!
问题在于我们的Ivan Tulup是一位老祖父,因为浏览器中的Event Loop实现实际上也很老。 它们是在编写规范之前创建的,因此,不幸的是,该规范一直受到尊重。 即使您阅读了该规范的内容,也无法保证所有浏览器都支持该规范。 因此,请务必在浏览器中检查它的实际工作方式。
浏览器中的祖父Ivan Tulup是一个很难预测的人,具有一些有趣的功能,您需要记住这一点。
终结者圣诞老人:Node.js上的吉祥物循环
Node.js更像是这样的人。

因为一方面,他是同一个留着胡须的祖父,但与此同时,所有事物都分阶段分配,并且清楚地描绘了完成的位置。
Node.js中事件循环的阶段:- 计时器
- 待处理的回调;
- 闲着,准备;
- 民意测验
- 检查
- 关闭回调。
除了最后一个,其他所有内容都不十分清楚。 这些阶段具有如此奇怪的名称,因为众所周知,在内部,我们拥有Libuv以便统治每个人:
- Linux- epoll / POSIX AIO;
- BSD- kqueue;
- Windows- IOCP;
- Solaris-事件端口。
数千个!
此外,Libuv还提供了相同的事件循环。 它没有Node.js的细节,但是有一些阶段,Node.js仅使用它们。 但是由于某种原因,她从那里取了名字。
让我们看看每个阶段的实际含义。
计时器阶段执行:
- 回调就绪计时器;
- setTimeout和setInterval;
- 但是NOT setImmediate是一个不同的阶段。
阶段待处理的回调
在此之前,文档编制阶段称为I / O回调。 最近,该文档已得到更正,并且不再矛盾。 在此之前,有一个地方写道,I / O回调在此阶段执行,而在另一个地方-在轮询阶段。 但是,现在所有内容都毫无疑问地写在那里了,所以请阅读文档-某些内容将变得更加容易理解。
在挂起的回调阶段,将执行某些系统操作(TCP错误)的回调。 也就是说,如果在Unix中TCP套接字中有错误,则在这种情况下,他不想立即将其丢弃,而是在回调中将其丢弃,该回调将在此阶段执行。 这就是我们需要了解的所有信息。 我们实际上对此不感兴趣。
阶段空闲,准备
在这个阶段,我们什么也做不了,因此原则上我们会忘记它。

轮询阶段
这是Node.js中最有趣的阶段,因为它完成了主要的有用工作:
- 执行I / O回调(不等待回调阶段!)。
- 等待来自I / O的事件;
- 做setImmediate很酷;
- 没有计时器;
展望未来,setImmediate将在下一个检查阶段执行,即在计时器之前保证。
轮询阶段还控制事件循环流。 例如,如果我们没有计时器,则没有setImmediate,即没有人计时器,setImmediate没有调用,我们只是在此阶段阻塞,等待I / O中的事件,如果有事情发生,是否有回调如果我们签了东西。
如何实现非阻塞模型? 例如,在同一个Epoll,我们可以订阅一个事件-打开一个套接字,然后等待向其写入内容。 另外,第二个参数是超时,即 我们将等待Epoll,但是如果超时结束,并且I / O中的事件没有到来,则它将退出超时。 如果某个事件从网络传来(有人写套接字),那么它就会来。
因此,轮询阶段将最早的回调的堆(堆是允许良好地进行传递和传递的数据结构)取出,使其超时,写入此超时并释放所有内容。 因此,即使没有人在套接字中写信给我们,超时也可以工作,返回到轮询阶段,工作将继续进行。
重要的是要注意,在轮询阶段,一次回调的数量是有限制的。
令人遗憾的是,在其余阶段还没有。 如果添加100亿个超时,则添加100亿个超时。 因此,下一个阶段是检查阶段。
检查阶段
这是setImmediate的执行位置。 该阶段很漂亮,因为在轮询阶段称为setImmediate,可以确保比计时器更早执行。 因为计时器仅在开始时和轮询阶段的更早时才在下一个刻度上。 因此,我们不必担心与其他计时器的竞争,并将此阶段用于由于某些原因我们不希望在回调中执行的那些事情。
阶段结束回调
这个阶段不会执行我们所有的套接字关闭回调和其他类型:
socket.on('close', …).
仅当此事件意外发生时,她才执行它们,例如,另一端的某个人发送了消息:“一切-关闭插座-从这里走,Vasya!” 然后,此阶段将起作用,因为该事件是意外的。 但这并没有特别影响我们。
Node.js中块的异步处理不正确
如果将与使用setTimeout在浏览器中使用的模式相同的模式放在Node.js上,那将会发生什么-也就是说,我们将数组分为多个块,对于每个块,我们将setTimeout-0。
const bigArray = [1..1_000_000] const chunks = getChunks(bigArray) const parseData = (slice) =>
您认为这有什么问题吗?
我说如果您增加1万个超时(或100亿个!),我已经跑了一点,队列中将有1万个计时器,他将获取并执行它们-对此没有任何保护:获取-执行,获取-完成广告等等。
只有轮询阶段(如果我们不断从I / O接收事件),总是有人在套接字中写一些东西,以便我们至少可以执行计时器和setImmediate,它具有限制保护,并且与系统有关。 也就是说,在不同的操作系统上会有所不同。
不幸的是,其他阶段,包括计时器和setImmediate,
都没有这种保护。 因此,如果按照示例中的方法进行操作,所有内容将冻结,并且很长时间不会进入轮询阶段。
但是您是否认为如果将setTimeout(()=> parseData(slice),0)替换为setImmediate(()=> parseData(slice)),会不会发生某些变化? -当然,不,那里的检查阶段也没有任何保护。
要解决此问题,您可以调用
递归处理 。
const parseData = (slice) =>
最重要的是,我们采用了parseData函数并编写了它的递归调用,但不仅是我们自己的,而是通过setImmediate编写的。 在setImmediate阶段调用此函数时,它将到达下一个刻度,而不是当前的刻度。 因此,这将释放“事件循环”,它会进一步走一圈。 也就是说,我们具有recursiveAsyncParseData,在其中传递特定索引,通过该索引获取块,然后对其进行解析-然后将队列setImmediate与下一个索引一起放入。 它将到达下一步,我们可以递归地处理整个过程。
确实,问题在于这仍然是某种CPU限制的任务。 也许她仍然会以某种方式在Event Loop中称重并花些时间。 您最有可能希望您的Node.js完全受I / O约束。
因此,最好使用其他一些东西,例如
进程派生/线程池。现在我们知道有关Node.js的信息:
- 一切都是分阶段分配的-好吧,我们很清楚这一点;
- 有防止过长轮询阶段的保护措施,但其余的则没有;
- 可以应用递归处理模式,以免阻塞事件循环;
- 但是最好使用进程派生,线程池,子进程
您还应该注意线程池,因为Node.js从那里开始,特别是DNS解析,因为对于Linux,由于某种原因,DNS解析功能不是异步的。 因此,它必须在ThreadPool中执行。 幸运的是,在Windows上并非如此。 但是您可以在那里异步读取文件。 不幸的是,在Linux中,这是不可能的。
我认为,ThreadPool中的标准限制为4个进程。 因此,如果您在那里积极地做某事,它将与其他人竞争-与fs和其他人竞争。 您可以考虑增加ThreadPool,但也要非常小心。 因此,请阅读有关此主题的内容。
微任务:肺循环
我们在Node.js中有任务,在浏览器中有任务。 您可能已经听说过微任务。 让我们看看它是什么以及它们如何工作,并从浏览器开始。
浏览器中的微任务
为了了解微任务的工作原理,我们根据whatwg标准转向事件循环算法,即让我们看一下规范,看看其外观如何。

翻译成人类语言,看起来像这样:
- 从我们的生产线接下免费任务
- 我们执行
- 我们执行微任务检查点-好的,我们仍然不知道它是什么,但是我们记住了。
- 我们更新渲染(如有必要),然后返回到第一平方。

它们在图上指示的位置以及其他几个位置进行,我们将很快学习。 即,任务结束,执行微任务。
微型动物的来源
重要-不是Promise本身,即Promise.then。 当时放置的回调是一个微任务。 如果您叫10,那么-您有10辆微型汽车,则有1万辆-1万辆微型汽车。
- 变异观察者。
- Object.observe ,已弃用,没有人需要。
有多少人使用突变观察者?
我认为很少使用Mutation观察器。 最有可能使用Promise.then,这就是我们在示例中考虑它的原因。
微任务检查点的功能:- 我们会做所有事情 -这意味着我们将执行队列中所有的微任务,直到最后。 我们什么都不放手-我们只是拿走做所有的事,就是它们应该是微小的,对吧?
- 您仍然可以在该过程中生成新的微任务,并且它们将在同一微任务检查点中执行。
- 同样重要的是-它们不仅在任务执行后执行,而且在清除堆栈后执行。
这是一个有趣的观点。 事实证明,有可能生成新的微任务,我们将全部完成它们。 这可以导致我们做什么?

我们有两颗心。 我用JS动画为第一颗心脏动画,然后用CSS动画为第二颗心脏动画。 还有一个很棒的功能叫做starveMicrotasks。 我们调用Promise.resolve,然后将相同的函数放入其中。
在
演示文稿中查看如果调用此函数会发生什么。
是的,JS的心脏将停止,因为我们添加了一个微任务,然后在其中添加了一个微任务,然后又在其中添加了一个微任务...如此无止境。
也就是说,递归调用microtucks将使所有内容挂起。 但似乎我一切都异步了! 应该放手,我在那里叫setTimeout。 不行 不幸的是,您需要谨慎处理微任务,因此,如果以某种方式使用递归调用,请小心-您可以阻止所有操作。
此外,我们记得,微任务在堆栈清理结束时执行。 我们记得什么是堆栈。 事实证明,一旦我们退出代码,就会执行setTimeout回调-就是这样-微任务就在那里了。 这可能导致有趣的副作用。
考虑
一个例子 。

有一个按钮和一个位于其中的灰色容器。 我们同意单击按钮和容器。 , , , .
2 :
- Promise.resolve;
- .then, console.log('RO')
«FUS», – «DAH!» ( ).
, ? , , «FUS RO DAH!» 太好了! , .

, , . – . , - ?

! .

, .
, , , . ,
.
- — buttonHandleClick, .
- Promise.resolve. . , console.log('RO') . .
- console.log('FUS').
- buttonHandleClick . .
- , (divHandleClick) , «DAH!».
- HandleClick .
, . ?
:
- button.click(). .
- button HandleClick.
- Promise.resolve then. , Promise.resolve .
- console.log «FUS».
- buttonHandleClick , .
(click) , , . divHandleClick , , console.log('DAH!') . , .
, , button.click .
. , , . , , .
: () ( ). - , , stopPropagation. , , , , - , .
, - ( junior-) — «», promise, , then , - . ,
, : , , . . , - .
( 4) , . , , , , - . .
, :, . — , , .
Node.js
Node.js Promise.then process.nextTick. , — . , , , , .
process.nextTick
, process.nextTick, setImmediate? Node.js ?
. createServer, EventEmitter, , listen ( ), .
const createServer = () => { const evEmitter = new EventEmitter() return { listen: port => { evEmitter.emit('listening', port) return evEmitter } } } const server = createServer().listen(8080) server.on('listening', () => console.log('listening'))
, , 8080, listening console.log - .
, , - .
createServer, . listen, , . .
, , . ? process.nextTick: evEmitter.emit('listening', port) process.nextTick(() => evEmitter.emit('listening', port)).
,
process.nextTick , . EventEmitter, . , , API, . process.nextTick, emit , userland . createServer, , listen, listening. — process.nextTick — ! , , .
process.nextTick . , .
, process.nextTick , Promise.then . process.nextTick , — , Event Loop, Node.js. , , .
process.nextTick , ghbvtybnm setImmediate , C++ .. process.nextTick .
Async/await
API — async/await, - . . , async/await Promise, Event Loop . , .
有用的链接
, !Frontend Conf — 4 5 , . , :
来吧,这将很有趣!