关于真正理解Node.js事件循环你需要了解的一切
xiaonei
7年前
<p>Node.js是一个基于事件的平台。这意味着Node中发生的任何事情都是对于事件的响应。传入Node的数据处理要经历一层层嵌套的回调。这一流程相对于开发者被抽象出来,由一个叫做libuv的库处理,就是libuv为我们提供了事件循环机制。</p> <p>事件循环也许是Node中最容易被误解的概念。</p> <p>我为<a href="/misc/goto?guid=4959756211606561800">Dynatrace</a>工作,这是一家性能监控服务商。在我们解决事件循环监控这一问题时,我们付出了很多努力去正确理解我们正在监测的部分。</p> <p>这篇文章将包含我们所学到的,事件循环是如何工作,以及如何去正确的监控它。</p> <h3>常见错误观念</h3> <p>libuv是为Node.js提供事件循环的库。libuv背后的关键人物之一,Bert Belder,在他令人惊叹的 <a href="/misc/goto?guid=4959756211693036523">Node Interactive的主题演讲</a>的一开始,他以一个Google图片搜索的结果展示了人们用来描绘事件循环的不同方法,并且他说其中大部分是错误的。</p> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/497797522461f5c543af97ac7eefcc50.png"></p> <p>我来概括一下(在我看来)最普遍的错误观念。</p> <p>错误观念 1: 事件循环在用户代码中运行于一个独立的线程。</p> <p>错误观念</p> <p>一个主线程来执行用户的JavaScript代码(用户代码 userland code),另一个线程来执行事件循环。每当有异步操作发生,主线程将会把异步操作移交给事件循环线程,当异步操作完成,事件循环线程将会通知主线程去执行回调。</p> <p>事实</p> <p>只有一个线程来执行JavaScript代码而且事件循环也运行在这个线程之中。回调(一个运行中的Node.js应用中的任何用户代码都是回调)的执行通过事件循环来完成。后面我们将深入了解这些。</p> <p>错误观念 2: 所有异步操作通过一个线程池来处理</p> <p>错误观念</p> <p>异步操作,比如操作文件系统,发起对外的HTTP请求或者数据库交互总是需要加载一个由libuv提供的线程池。</p> <p>事实</p> <p>libuv默认创建一个由四个线程组成的线程池来加载异步操作。如今的操作系统已经对很多I/O任务提供了异步接口 (<a href="/misc/goto?guid=4959639948323724697">如Linux中的AIO</a>)。只要有可能,libuv都会使用这些异步接口而避免使用线程池。这同样适用于第三方的子系统比如数据库。这些驱动的作者将更倾向于使用异步接口而不是使用线程池。简而言之: 只有不存在其他方式的时候,异步I/O才会使用线程池。</p> <p>错误观念 3: 事件循环类似于栈或队列</p> <p>错误观念</p> <p>事件循环轮询一个由异步任务组成的先进先出队列,当任务完成时执行回调。</p> <p>事实</p> <p>虽然需要类似于队列的结构,但是事件循环并没有使用栈。事件循环就像是一系列的阶段以循环的方式处理各自具体任务的过程。</p> <h3>理解事件循环中的不同阶段</h3> <p>为了真正了解事件循环我们必须去了解它在每个阶段做了哪些工作。希望可以得到Bert Belder的认同,以我的方式来展示事件循环是如何工作的将会是下面这样:</p> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/2d682c260b3bab0f59495e8a19feeef4.png"></p> <p>让我们来聊一聊这些阶段。全面的解释可以在<a href="/misc/goto?guid=4959756211805896645">Node.js 网站</a>上看到。</p> <p>Timers</p> <p>各种通过<code>setTimeout()</code>或者<code>setInterval()</code>设置的定时任务都将在这一阶段被处理。</p> <p>IO Callbacks</p> <p>这一阶段大多数回调会被处理。这里指的是用户代码,因为所有在Node.js中的用户代码本质上来说都在回调中(例如一个刚收到的http请求会触发一连串嵌套的回调)。</p> <p>IO Polling</p> <p>轮询将在下一轮事件循环中被处理的新事件。</p> <p>Set Immediate</p> <p>执行所有通过<code>setImmediate()</code>注册的回调。</p> <p>Close</p> <p>所有侦听<code>close</code>事件的回调将在这一阶段被处理。</p> <h3>监控事件循环</h3> <p>我们可以看出事实上一个Node应用里发生的任何事情都是通过事件循环来运行的。这意味着如果我们可以从事件循环中得到各种指标,这些指标可以在应用大体上的健康情况和性能方面,为我们提供有价值的信息。由于没有可以从事件循环中获取到运行时指标的API,各种监控工具提供了各自的指标。来看一下我们所提供的指标。</p> <p>Tick Frequency</p> <p>每段时间內完成的周期数量。</p> <p>Tick Duration</p> <p>一个周期需要花费的时间。</p> <p>由于我们的代理可以像原生模块那样运行,通过添加探针来为我们提供这些信息是相对容易的。</p> <p>Tick frequency 和 tick duration 指标在实际中的应用</p> <p>当我们第一次在不同的负载在进行测试的时候,结果是令人意想不到的----让我展示一个示例:</p> <p>在下面的场景中,我将调用一个<code>express.js</code>应用来向另外一台http服务器发送请求。</p> <p>这里有四个场景:</p> <ol> <li> <p>Idle 没有收到任何请求。</p> </li> <li> <p>ab -c 5 利用<code>apache bench</code>一次创建5个并发请求</p> </li> <li> <p>ab -c 10 10个并发请求</p> </li> <li> <p>ab -c 10 (slow backend) http服务器1s后再返回数据来模拟缓慢的后端。这会产生回调的压力因为请求在等待的后端返回在Node内部堆积。</p> </li> </ol> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/3bae95b52a277d7e748ada9a502791fe.png"></p> <p>如果我们观察结果图表,我们可以得出一个有趣的结论:</p> <p>事件循环的持续时间和频率是动态的,以适应负载的变化。</p> <p>如果应用是空闲的,意味着没有待处理的任务(计时器任务或是回调等等),因为没有理由去全速完成事件循环中的各个阶段,因此事件循环会调整以适应这一情况,并且会在轮询阶段阻塞一会儿来等待新的外部事件进来。</p> <p>这也意味着,没有负载下的指标(低频率高耗时)与在高负载下缓慢的后端的情况下的指标是相似的。</p> <p>我们也看到这个示例应用在5个并发请求的场景下运行的状态最好。</p> <p>因此周期频率和周期时间应以当前的每秒请求数为基准。</p> <p>尽管这些数据已经为我们提供了一些有价值的信息,但我们依旧不知道时间花在哪一个阶段,因此我们做了更加深入的研究,又提出了两个新指标。</p> <p>Work processed latency</p> <p>这个指标用了度量一个异步任务被线程池处理所花费的时间。</p> <p>高的工作处理时延表明了这是一个忙碌/被耗尽的线程池。</p> <p>为了测试这个指标,我创建了一个<code>express</code>路由,利用一个名叫<a href="/misc/goto?guid=4959756211892670231">Sharp</a>的图片来处理图片。因为图片处理是昂贵的,<code>Sharp</code>利用线程池来完成对图片的处理。</p> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/77f3c46a269c31a57aee064dd322d22c.png"></p> <p>运行<code>Apache bench</code>以5个并发连接请求有图片处理功能的路由的结果直接的反映在这个图表上,并且能够很明显的与中等负载而无图片处理的场景区分开。</p> <p>Event Loop Latency</p> <p>事件循环时延用来度量一个通过<code>setTimeout(X)</code>设置的定时任务被处理所花费的时间。</p> <p>高的时间循环时延意味着时间循环忙于处理回调。</p> <p>为了这次这个指标,我创建了一个<code>express</code>路由,通过一个很低效的算法来计算斐波那契数列。</p> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/043d5c698e83a38bd0905b87a982d390.png"></p> <p>运行<code>Apache bench</code>,以5个并发连接调用有斐波那契数列计算功能的路由,结果展示了当前回调队列是忙碌的。</p> <p>我们清楚地看到上面四个指标可以为我们提供有价值的信息来帮助我们更好的理解Node.js的内部是如何工作。</p> <p>所有这些指标都需要从一个更大的图景来观察以理解它。因此我们当前正在收集信息并将这些数据作为参考因素。</p> <p>调整事件循环</p> <p>事实上,仅有指标而不知道如何采取行动去修正这些问题对我们帮助不大。这里有一些关于事件循环看起来繁忙时应该如何去做的建议。</p> <p><img alt="关于真正理解Node.js事件循环你需要了解的一切 - 众成翻译" src="https://simg.open-open.com/show/b3c18c1b862759f79ab740e84c8a0787.png"></p> <p>利用所有的CPU</p> <p>一个Node.js应用运行在一个单一的线程中。这意味着在多核设备中,负载并没有被分发到所有的核心上。使用 <a href="/misc/goto?guid=4959646474498485566">cluster模块</a>,它使得Node.js可以轻松的在每个CPU上创建子进程。每个子进程维护着一个独立的事件循环,并且主进程将负载分发到所有的子进程中。</p> <p>调整线程池</p> <p>就像上面提到的,<code>libuv</code>将创建一个四个线程的线程池。这个线程池的默认大小可以通过设置环境变量<code>UV_THREADPOOL_SIZE</code>来重写。虽然这样可以解决I/O密集型应用的负载问题,但是过高的负载测试例如过大的线程池依旧会耗尽内存或CPU的资源。</p> <p>移除服务中的计算密集型工作</p> <p>如果Node.js花费太多时间在计算密集型操作上,为服务移除这些工作或是使用另一种更适合这个任务的语言将会是一个切实可行的选择。</p> <h3>总结</h3> <p>让我们总结一下在这篇文章中我们学到的:</p> <ul> <li> <p>事件循环维持着一个Node.js应用的运行</p> </li> <li> <p>它的功能经常被错误的理解----它是需要经历一系列的阶段,每个阶段处理不同的任务</p> </li> <li> <p>事件循环没有提供开箱可用的指标,因此不同的APM服务商收集的指标是不同的。</p> </li> <li> <p>虽然这些指标提供了关于性能瓶颈有价值的信息,但是深入理解事件循环机制和正在执行的代码才是关键。</p> </li> <li> <p>在未来,Dynatrace将增加一个事件循环远程监控技术到根本原因检测中以将事件循环的异常与问题相关联。</p> </li> </ul> <p>对我来说,毫无疑问的我们刚刚创建了当今市场上最全面的事件循环监控解决方案,并且我很开心这些令人激动的新特性将在接下来的几周内推向我们的用户。</p> <h3>感谢</h3> <p>Dynatrace中杰出的Node.js代理团队在事件循环监控上付出了很多努力。这篇博客文章中呈现的大部分发现是基于他们在Node.js内部工作机制方面深入的知识。我想感谢Bernhard Liedl、Dominik Gruber、Gerhard Stöbich和Gernot Reisinger,感谢他们付出的努力以及对我的支持。</p> <p>我希望这篇文章在这个主题上对读者确实有所启发。请关注我的推ter<a href="/misc/goto?guid=4959756211995840009">@dkhan</a>,在很高兴在那里或是在下面的评论区里解答你们的提问。</p> <p>如果你想继续了解更多事件循环的内部工作机制或是作为开发者如何使用事件循环,我推荐我朋友发表在RisingStack上的这篇<a href="/misc/goto?guid=4959736795363746447">文章</a> 。</p> <p>如果你想尝试一下我们的Node.js监控,<a href="/misc/goto?guid=4959756212106397201">下载我们的免费试用版</a>并在任何时间分享你的反馈给我——这是我们了解用户的方式。</p> <p>来自:</p> <p> </p>