【一】理解Node.js事件循环


你已经使用 Node.js一段时间了。你已经构建了一些应用程序,尝试了不同的模块,甚至对异步编程感到很舒适。但有件事一直在困扰着你 —— 事件循环

如果你像我一样,你已经花了无数个小时阅读文档和观看视频,试图理解事件循环。但即使作为一名经验丰富的开发者,要完全理解它的工作原理也很困难。

JavaScript 中的异步编程

我们将从 JavaScript 中的异步编程开始。尽管 JavaScriptweb、移动和桌面应用程序中被广泛使用,但重要的是要记住,JavaScript 在其最基本的形式中是一种同步、阻塞、单线程的语言。让我们用一小段代码来理解这句话。

// index.js

function A() {
  console.log("A");
}

function B() {
  console.log("B");
}

A()
B()

// Logs A and then B
JavaScript 是同步的

如果我们有两个函数将消息记录到控制台,代码将自上而下执行,任何时候只有一行在执行。在代码片段中,我们看到AB之前被执行。

JavaScript 是阻塞的

JavaScript是阻塞的,因为它是同步的。无论前一个过程花费多长时间,后续的过程都不会启动,直到前一个过程完成。在代码片段中,如果函数A 必须执行一大块密集的代码,JavaScript 必须在执行完毕之前完成它,而不会转到函数B。即使那段代码需要花费 10 秒或 1 分钟的时间。

你可能在浏览器中经历过这种情况。当 web应用程序在浏览器中运行并且执行了一大块密集的代码而没有将控制返回给浏览器时,浏览器可能会出现冻结的情况。这就是所谓的阻塞。浏览器被阻止继续处理用户输入和执行其他任务,直到 web应用程序将处理器的控制权返回。

JavaScript 是单线程的

线程就是你的 JavaScript程序可以用来运行任务的过程。每个线程一次只能做一件事情。不像一些其他支持多线程的语言可以同时运行多个任务,JavaScript 只有一个线程,称为主线程,用于执行任何代码。

等待 JavaScript

正如你可能猜到的那样,这种JavaScript模型会产生问题,因为我们必须等待数据被获取,然后才能继续执行代码。这个等待可能需要几秒钟,在此期间我们无法运行任何进一步的代码。如果 JavaScript 在等待过程中继续执行,我们将会遇到错误。我们需要一种方法来在JavaScript中实现异步行为。这时候就引入了 Node.js

Node.js 运行时

Node.js运行时是一个环境,您可以在其中在浏览器之外使用和运行JavaScript程序。在其核心,Node运行时由三个主要组件组成。

    1. 外部依赖项 —— 例如 V8libuvcrypto —— Node.js所需的用于其运行的外部依赖项。
    1. 提供文件系统访问和网络功能等功能的 C++ 特性。
    1. 一个 JavaScript 库,提供函数和实用程序,以便从您的 JavaScript 代码中利用 C++ 特性。

虽然所有部分都很重要,但 Node.js 中异步编程的关键组件是外部依赖项libuv

Libuv

Libuv 是一个跨平台的开源库,用C 语言编写。在Node.js 运行时中,其角色是提供支持处理异步操作。让我们来看看它是如何工作的。

Node.js 运行时中的代码执行

图片显示左侧代表 V8引擎的矩形块,右侧代表libuv的矩形块
让我们来概括一下代码在 Node运行时中的典型执行方式。当我们执行代码时,位于图像左侧的 V8引擎负责执行 JavaScript代码。该引擎包括内存堆和调用堆栈。

无论我们声明变量还是函数,都会在堆上分配内存,无论何时执行代码,函数都会被推送到调用堆栈中。当函数返回时,它就会从调用堆栈中弹出。这是堆栈数据结构的一个简单实现,其中最后添加的项目是第一个被移除的。在图像右侧,我们有libuv,它负责处理异步方法。

每当我们执行一个异步方法时,libuv接管任务的执行。然后,libuv使用操作系统的本地异步机制来运行任务。如果本地机制不可用或不足,它会利用线程池来运行任务,以确保主线程不被阻塞。

同步代码执行

首先,让我们来看看同步代码执行。以下代码包含三个控制台日志语句,分别记录 "First"、"Second" 和 "Third"。让我们像运行时正在执行一样来看代码。

// index.js
console.log("First");
console.log("Second");
console.log("Third");

以下是使用 Node 运行时可视化同步代码执行的方式。


执行的主线程总是从全局范围开始。全局函数,如果我们可以这样称呼它,被推送到堆栈上。然后,在第 1 行,我们有一个控制台日志语句。函数被推送到堆栈上。假设这发生在 1 毫秒时,"First"被记录到控制台。然后,函数从堆栈中弹出。

执行到达第 3 行。假设在 2 毫秒时,日志函数再次被推送到堆栈上。"Second" 被记录到控制台,然后函数从堆栈中弹出。

最后,执行到达第 5 行。在 3 毫秒时,函数被推送到堆栈上,"Third"被记录到控制台,然后函数从堆栈中弹出。没有更多的代码要执行,全局范围也被弹出。

异步代码执行

接下来,让我们看一下异步代码的执行。考虑下面的代码片段。有三个日志语句,但这次第二个日志语句位于传递给 fs.readFile()的回调函数中。

执行的主线程总是从全局范围开始。全局函数被推送到堆栈上。然后,执行来到第 1 行。在 1 毫秒时,"First"在控制台中被记录,然后函数从堆栈中弹出。然后,执行继续到第 3 行。在 2 毫秒时,readFile 方法被推送到堆栈上。由于readFile是一个异步操作,它被转移到 libuv。

JavaScript 从调用堆栈中弹出 readFile 方法,因为就第 3 行的执行而言,它的任务已经完成。在后台,libuv 开始在单独的线程上读取文件内容。在 3 毫秒时,JavaScript 继续到第 7 行,将log函数推送到堆栈上,"Third" 被记录到控制台,然后函数从堆栈中弹出。

大约在 4 毫秒时,假设文件读取任务在线程池中完成。现在,相关的回调函数在调用堆栈上执行。在回调函数中,遇到了log语句。

它被推送到调用堆栈上,"Second" 被记录到控制台,然后log 函数被弹出。由于在回调函数中没有更多的语句要执行,回调函数也被弹出。没有更多的代码可运行,所以全局函数也被从堆栈中弹出。

控制台输出将会是"First""Third",然后是 "Second"

Libuv 和异步操作

很明显,libuvNode.js 中帮助处理异步操作。对于像处理网络请求这样的异步操作,libuv 依赖于操作系统的原语。对于像读取没有本地操作系统支持的文件这样的异步操作,libuv依赖于其线程池,以确保主线程不被阻塞。然而,这确实引发了一些问题。

  • libuv 中的异步任务完成时,Node 在什么时候决定在调用堆栈上运行相关的回调函数?
  • Node 是否等待调用堆栈为空才运行回调函数,还是打断正常的执行流程来运行回调函数?
  • 其他异步方法如 setTimeoutsetInterval,也会延迟执行回调函数,那它们呢?
  • 如果两个异步任务,比如 setTimeoutreadFile,同时完成,Node 如何决定在调用堆栈上首先运行哪个回调函数?是否有一个比另一个更优先?
    所有这些问题都可以通过理解 libuv 的核心部分 —— 事件循环来回答。

什么是事件循环?

从技术上讲,事件循环只是一个 C程序。但是,您可以将其视为一种设计模式,用于协调 Node.js 中同步和异步代码的执行。

事件循环视图展示

事件循环是一个循环,只要您的 Node.js应用程序正在运行,它就会持续运行。每个循环中有六个不同的队列,每个队列最终都会保存一个或多个需要在调用堆栈上执行的回调函数。

事件循环由 6 个不同队列组成。

  • 首先,有定时器队列(技术上是一个最小堆),其中保存与 setTimeoutsetInterval相关的回调。
  • 其次,有 I/O 队列,其中包含与所有异步方法相关的回调,比如与 fshttp 模块相关的方法。
  • 第三,有检查队列,其中保存与 setImmediate 函数相关的回调,这是特定于 Node 的函数。
  • 第四,有关闭队列,其中保存与异步任务的关闭事件相关的回调。

最后,有微任务队列,其中包含两个单独的队列。

  • nextTick 队列,其中保存与 process.nextTick 函数相关的回调。
  • Promise 队列,其中保存与JavaScript 中的原生Promise相关的回调。

需要注意的是,定时器、I/O、检查和关闭队列都是 libuv 的一部分。然而,两个微任务队列不是libuv 的一部分。尽管如此,它们仍然是 Node 运行时的一部分,并且在回调执行顺序中起着重要作用。说到这一点,让我们继续理解下一步。

事件循环的工作原理

箭头已经给了提示,但很容易让人感到困惑。让我解释一下队列的优先顺序。首先,要知道所有用户编写的同步JavaScript代码优先于运行时希望执行的异步代码。这意味着只有在调用堆栈为空时,事件循环才会发挥作用。

  • 在事件循环中,执行顺序遵循某些规则。有很多规则需要理解,所以让我们逐一来看看:
  • 微任务队列中的任何回调都会被执行。首先是 nextTick 队列中的任务,然后是 promise队列中的任务。
    所有定时器队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会在定时器队列中的每个回调后被执行。首先是 nextTick 队列中的任务,然后是 promise 队列中的任务。
  • 所有I/O队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会被执行,从 nextTick 队列开始,然后是 Promise队列。
  • 所有检查队列中的回调都会被执行。
  • 微任务队列中的回调(如果存在)会在检查队列中的每个回调后被执行。首先是 nextTick 队列中的任务,然后是 promise队列中的任务。
  • 所有关闭队列中的回调都会被执行。
  • 在同一循环中,最后一次执行微任务队列。首先是 nextTick队列中的任务,然后是promise队列中的任务。

如果在此时还有更多的回调需要处理,循环将被保持活动状态进行另一次运行,并重复相同的步骤。另一方面,如果所有回调都已执行,并且没有更多的代码需要处理,则事件循环退出。

这就是libuv的事件循环在 Node.js中执行异步代码中所扮演的角色。有了这些规则,我们可以重新审视之前的问题。

  • 当 libuv 中的异步任务完成时,Node 何时决定在调用堆栈上运行相关的回调函数?
    只有在调用堆栈为空时才会执行回调函数。

  • Node 是否等待调用堆栈为空才运行回调函数,还是打断正常的执行流程来运行回调函数?
    不会打断正常的执行流程来运行回调函数。

  • 其他异步方法如 setTimeoutsetInterval 如何处理,它们也会延迟执行回调函数?
    setTimeoutsetInterval的回调函数被优先考虑。

  • 如果两个异步任务,例如 setTimeoutreadFile,在同一时间完成,Node 如何决定在调用堆栈上先运行哪个回调函数?是否有一个比另一个更优先?
    定时器回调函数在I/O 回调函数之前执行,即使两者在完全相同的时间准备就绪。

我们学到了更多,但下面这个可视化表示(与上面相同)是我希望你将其牢记在心的,因为它展示了 Node.js 在幕后执行异步代码的方式。


“但等等,验证这个可视化的代码在哪里?”你可能会问。好吧,事件循环中的每个队列在执行上都有细微差别,因此最好一次处理一个。

结论

事件循环是 Node.js 的基本部分,通过确保主线程不被阻塞来实现异步编程。理解事件循环的工作原理可能会有挑战,但对于构建性能优异的应用程序至关重要。

文章涵盖了 JavaScript 中异步编程的基础知识、Node.js运行时以及负责处理异步操作的libuv。有了这些知识,您可以构建事件循环的强大思维模型,这将帮助您编写利用 Node.js 异步特性的代码。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,504评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,434评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,089评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,378评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,472评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,506评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,519评论 3 413
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,292评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,738评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,022评论 2 329
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,194评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,873评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,536评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,162评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,413评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,075评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,080评论 2 352

推荐阅读更多精彩内容