Node.js的事件循环介绍

85 阅读5分钟

绪论

事件循环是了解Node的最重要的方面之一。

为什么它如此重要?因为它解释了Node如何实现异步和非阻塞I/O,所以它基本上解释了Node的 "杀手级应用",也就是使它如此成功的东西。

Node.js的JavaScript代码在一个单线程上运行。每次只有一件事在发生。

这是一个实际上非常有帮助的限制,因为它简化了很多你如何编程而不用担心并发问题。

你只需要注意如何写你的代码,避免任何可能阻塞线程的东西,比如同步网络调用或无限循环

一般来说,在大多数浏览器中,每个浏览器标签都有一个事件循环,以使每个进程都被隔离,避免一个有无限循环的网页或繁重的处理过程阻塞你整个浏览器。

环境管理多个并发的事件循环,以处理API调用为例。Web工作者也在他们自己的事件循环中运行。

你主要需要关注的是,你的代码会在一个事件循环中运行,写代码时要考虑到这件事,以避免阻塞它。

阻断事件循环

任何JavaScript代码如果花费太长的时间将控制权返回给事件循环,就会阻塞页面中任何JavaScript代码的执行,甚至阻塞UI线程,用户就无法点击周围,滚动页面,等等。

JavaScript中几乎所有的I/O原语都是无阻塞的。网络请求、文件系统操作,等等。被阻塞是一个例外,这就是为什么JavaScript基于回调,最近又基于承诺async/await

调用堆栈

调用堆栈是一个LIFO队列(Last In, First Out)。

事件循环不断地检查调用栈,看是否有任何需要运行的函数。

在这样做的时候,它将发现的任何函数调用添加到调用栈中,并按顺序执行每一个函数。

你知道你可能熟悉的错误堆栈跟踪,在调试器或浏览器控制台中?浏览器查找调用堆栈中的函数名称,以告知你当前调用是由哪个函数发起的。

Exception call stack

一个简单的事件循环解释

让我们选一个例子。

我使用foo,barbaz 作为随机名称。输入任何种类的名字来代替它们。

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()

这段代码的打印结果

正如预期的那样。

当这段代码运行时,首先调用foo() 。在foo() ,我们首先调用bar() ,然后调用baz()

在这一点上,调用栈看起来像这样。

Call stack first example

事件循环在每次迭代时都会查看调用栈中是否有东西,并执行它。

Execution order first example

直到调用栈为空。

排队执行函数

上面的例子看起来很正常,没有什么特别的地方。JavaScript找到要执行的东西,按顺序运行它们。

我们来看看如何推迟一个函数,直到堆栈清空。

setTimeout(() => {}), 0) 的用例是调用一个函数,但要在代码中的其他函数都执行完后再执行它。

以此为例。

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()

这段代码的打印,也许是令人惊讶的。

当这段代码运行时,首先调用foo()。在foo()里面,我们首先调用setTimeout,传递bar 作为参数,我们指示它立即以最快的速度运行,传递0作为定时器。然后我们调用 baz()。

在这一点上,调用栈看起来像这样。

Call stack second example

这里是我们程序中所有函数的执行顺序。

Execution order second example

为什么会出现这种情况?

消息队列

当setTimeout()被调用时,浏览器或Node.js会启动定时器。一旦计时器过期,在这种情况下,由于我们把0作为超时时间,回调函数就会被放到消息队列中。

消息队列也是用户发起的事件的地方,如点击或键盘事件,或获取响应,在你的代码有机会对其作出反应之前被排队。或者还有DOM事件,如onLoad

循环给了调用栈优先权,它首先处理它在调用栈中发现的所有东西,一旦里面没有东西了,它就去捡起消息队列中的东西。

我们不必等待像setTimeout, fetch或其他东西的函数来做自己的工作,因为它们是由浏览器提供的,而且它们生活在自己的线程中。例如,如果你把setTimeout 的超时设置为2秒,你就不必等待2秒--等待发生在其他地方。

ES6作业队列

ECMAScript 2015引入了Job Queue的概念,它被Promises(也在ES6/ES2015中引入)使用。这是一种尽快执行异步函数结果的方法,而不是放在调用栈的最后。

在当前函数结束前解决的承诺将在当前函数之后被执行。

我觉得用游乐园的过山车来比喻很好:消息队列把你放在队列的后面,在所有其他人的后面,你将不得不等待轮到你,而工作队列是快速通行票,可以让你在完成前一项工作后立即乘坐另一项工作。

例子。

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}

foo()

这张打印的

foo
baz
should be right after baz, before bar
bar

这是承诺(以及建立在承诺之上的Async/await)和通过setTimeout() 或其他平台API的普通异步函数之间的巨大区别。

总结

这篇文章向你介绍了Node.js事件循环的基本构建模块。

这是用Node.js编写的任何程序的一个重要部分,我希望这里解释的一些概念对你将来会有帮助。