深入理解异步事件驱动编程(一)

介绍

我们将更深入地探讨 Node 如何实现事件驱动编程。我们将首先解开事件驱动语言和环境,从中获得处理的想法和理论,以消除误解并加强掌握。在介绍事件之后,我们将重点介绍 Node.js 技术------事件循环。然后,我们将更详细地讨论 Node 如何实现定时器、回调和 I/O 事件,以及作为 Node 开发人员如何使用它们。我们还将讨论使用现代工具(如PromisesGeneratorsasync/await)管理并发的方法。在构建一些简单但典型的文件和数据驱动应用程序时,我们将实践这些理论。这些示例突出了 Node 的优势,并展示了 Node 如何成功地简化了网络应用程序设计。

Node 的独特设计

Node 旨在使 I/O 快速。它是为这个新的网络软件世界设计的,其中数据分布在许多地方,必须快速组装。许多传统的构建 Web 应用程序的框架是在一个单一用户使用桌面计算机,使用浏览器定期向运行关系数据库的单个服务器发出 HTTP 请求的时代设计的。现代软件必须预期成千上万个同时连接的客户端通过各种网络协议在任意数量的独特设备上同时更改庞大的共享数据池。Node 专门设计为帮助那些构建这种网络软件的人。

Node 设计所反映的思维突破一旦被认识到,就变得简单易懂,因为大多数工作线程都在等待------等待更多指令,等待子任务完成等。例如,被分配为服务命令"格式化我的硬盘"的进程将把所有资源用于管理工作流程,类似以下内容:

  • 向设备驱动程序通知已发出格式请求
  • 空闲,等待不可知的时间长度
  • 接收格式完成的信号
  • 通知客户端
  • 清理;关闭:

在前面的图中,我们看到一个昂贵的工人正在向客户收取固定的时间单位费用,无论是否正在做任何有用的工作(客户对活动和空闲一视同仁地付费)。换句话说,并不一定是真的,而且往往不是真的,组成总任务的子任务每个都需要相似的努力或专业知识。因此,为这种廉价劳动力支付高价是浪费的。

同情地说,我们还必须认识到,即使准备好并能够处理更多工作,这个工人也无法做得更好------即使是最有诚意的工人也无法解决 I/O 瓶颈的问题。这个工人是I/O 受限的。

相反,想象一种替代设计。如果多个客户端可以共享同一个工人,那么当一个工人因 I/O 瓶颈而宣布可用时,另一个客户端的工作可以开始吗?

Node 通过引入一个系统资源(理想情况下)永远不会空闲的环境,使 I/O 变得通用。Node 实现的事件驱动编程反映了降低整体系统成本的简单目标,主要通过减少 I/O 瓶颈的数量来鼓励共享昂贵的劳动力。我们不再拥有无能为力的僵化定价的劳动力块;我们可以将所有努力减少为精确界定形状的离散单位,因此可以实现更准确的定价。

一个协作调度了许多客户端工作的环境会是什么样子?这种事件之间的消息传递是如何处理的?此外,并发、并行、异步执行、回调和事件对 Node 开发人员意味着什么?

协作

与先前描述的阻塞系统相比,更可取的是一个协作工作环境,工人定期被分配新任务,而不是空闲。为了实现这样的目标,我们需要一个虚拟交换机,将服务请求分派给可用的工人,并让工人通知交换机他们的可用性。

实现这一目标的一种方法是拥有一个可用劳动力池,通过将任务委派给不同的工人来提高效率:

这种方法的一个缺点是需要进行大量的调度和工人监视。调度程序必须处理源源不断的请求,同时管理来自工人的关于他们可用性的消息,将请求整理成可管理的任务并高效地排序,以便最少数量的工人处于空闲状态。

也许最重要的是,当所有工人都被预订满了会发生什么?调度程序是否开始从客户那里丢弃请求?调度也是资源密集型的,调度程序的资源也是有限的。如果请求继续到达,而没有工人可用来为其提供服务,调度程序会怎么做?管理队列?我们现在有一个情况,调度程序不再做正确的工作(调度),而是负责簿记和保持列表,进一步延长每个任务完成所需的时间。每个任务需要一定的时间,并且必须按到达顺序进行处理。这个任务执行模型堆叠了固定的时间间隔------时间片 。这是同步执行。

排队

为了避免过载任何人,我们可以在客户和调度程序之间添加一个缓冲区。这个新的工人负责管理客户关系。客户不直接与调度程序交谈,而是与服务经理交谈,将请求传递给经理,并在将来的某个时候接到通知,说他们的任务已经完成。工作请求被添加到一个优先级工作队列(一个订单堆栈,最重要的订单在顶部),这个经理等待另一个客户走进门。

以下图表描述了情况:

调度程序试图通过从队列中提取任务,将工人完成的任务包传回,并通常维护一个理智的工作环境,以确保没有任何东西被丢弃或丢失,来使所有工人保持忙碌。与沿着单个时间线逐个进行任务不同,多个同时运行在其自己的时间线上的任务并行运行。如果所有工人都处于空闲状态且任务队列为空,那么办公室可以休息一会儿,直到下一个客户到来。

这是 Node 通过异步 工作而不是同步工作来获得速度的粗略示意图。现在,让我们深入了解 Node 的事件循环是如何工作的。

理解事件循环

在我们分解事件循环时,以下三点很重要:

  • 事件循环在与您的 JavaScript 代码运行的相同(单个)线程中运行。阻塞事件循环意味着阻塞整个线程。
  • 您不会启动和/或停止事件循环。事件循环在进程启动时开始,并在没有进一步的回调需要执行时结束。因此,事件循环可能永远运行。
  • 事件循环将许多 I/O 操作委托给libuv,后者管理这些操作(使用 OS 本身的能力,如线程池),并在结果可用时通知事件循环。易于理解的单线程编程模型通过多线程的效率得到了加强。

例如,以下while循环永远不会终止:

ini 复制代码
let stop = false;
setTimeout(() => {
  stop = true;
}, 1000);

while (stop === false) {};

即使有人可能期望,在大约一秒钟内,将布尔值true分配给变量stop,触发while条件并中断其循环;这永远不会发生。为什么?这个while循环通过无限运行来使事件循环饥饿,贪婪地检查和重新检查一个永远不会有机会改变的值,因为事件循环永远不会有机会安排我们的定时器回调进行执行。这证明了事件循环(管理定时器)并且在同一个线程上运行。

根据 Node 文档,"事件循环是 Node.js 执行非阻塞 I/O 操作的关键,尽管 JavaScript 是单线程的,但通过尽可能地将操作卸载到系统内核来实现。" Node 的设计者所做的关键设计选择是将事件循环实现为并发管理器。例如,通过libuv,OS 传递网络接口事件来通知基于 Node 的 HTTP 服务器与本地硬件的网络连接。

以下是事件驱动编程的描述(摘自:www.princeton.edu/~achaney/tmve/wiki100k/docs/Event-driven_programming.html),不仅清楚地描述了事件驱动范式,还向我们介绍了事件在 Node 中的处理方式,以及 JavaScript 是这种范式的理想语言。

在计算机编程中,事件驱动编程或基于事件的编程是一种编程范式,其中程序的流程由事件决定 - 即传感器输出或用户操作(鼠标点击,按键)或来自其他程序或线程的消息。事件驱动编程也可以被定义为一种应用架构技术,其中应用程序具有一个主循环,明确定义为两个部分:第一个是事件选择(或事件检测),第二个是事件处理[...]。事件驱动程序可以用任何语言编写,尽管在提供高级抽象的语言中更容易,比如闭包等。

Node 通过将许多阻塞操作委托给 OS 子系统来使单个线程更有效,只有在有数据可用时才会打扰主 V8 线程。主线程(执行中的 Node 程序)通过传递回调来表达对某些数据的兴趣(例如通过fs.readFile),并在数据可用时得到通知。在数据到达之前,不会对 V8 的主 JavaScript 线程施加进一步的负担。如何做到的?Node 将 I/O 工作委托给libuv,如引用所述:nikhilm.github.io/uvbook/basics.html#event-loops

在事件驱动编程中,应用程序表达对某些事件的兴趣,并在发生时做出响应。从操作系统收集事件或监视其他事件源的责任由libuv处理,用户可以注册回调以在事件发生时被调用。

  • Matteo Collina *创建了一个有趣的模块,用于对事件循环进行基准测试,可在以下网址找到:github.com/mcollina/loopbench

考虑以下代码:

javascript 复制代码
const fs = require('fs');
fs.readFile('foo.js', {encoding:'utf8'}, (err, fileContents) => {
  console.log('Then the contents are available', fileContents);
});
console.log('This happens first');

该程序的输出是:

markdown 复制代码
> This happens first
> Then the contents are available, [file contents shown]

执行此程序时,Node 的操作如下:

  1. 使用 V8 API 在 C++中创建了一个进程对象。然后将 Node.js 运行时导入到这个 V8 进程中。
  2. fs模块附加到 Node 运行时。V8 将 C++暴露给 JavaScript。这为您的 JavaScript 代码提供了对本机文件系统绑定的访问权限。
  3. fs.readFile方法传递了指令和 JavaScript 回调。通过fs.bindinglibuv被通知文件读取请求,并传递了原始程序发送的回调的特别准备版本。
  4. libuv调用了必要的操作系统级函数来读取文件。
  5. JavaScript 程序继续运行,打印This happens first。因为有一个未解决的回调,事件循环继续旋转,等待该回调解析。
  6. 当操作系统完全读取文件描述符时,通过内部机制通知libuv,并调用传递给libuv的回调,从而为原始 JavaScript 回调准备重新进入主(V8)线程。
  7. 原始的 JavaScript 回调被推送到事件循环,并在循环的近期刻度上被调用。
  8. 文件内容被打印到控制台。
  9. 由于没有进一步的回调在飞行中,进程退出。

在这里,我们看到了 Node 实现的关键思想,以实现快速、可管理和可扩展的 I/O。例如,如果在前面的程序中对foo.js进行了 10 次读取调用,执行时间仍然大致相同。每个调用都将由libuv尽可能高效地管理(例如,通过使用线程并行化调用)。尽管我们的代码是用 JavaScript 编写的,但实际上我们部署了一个非常高效的多线程执行引擎,同时避免了操作系统异步进程管理的困难。

现在我们知道了文件系统操作可能是如何工作的,让我们深入了解 Node 在事件循环中如何处理每种异步操作类型。

事件循环排序、阶段和优先级

事件循环通过阶段进行处理,每个阶段都有一个要处理的事件队列。来自 Node 文档:

对开发人员相关的阶段如下:

  • 定时器 :延迟到未来某个指定的毫秒数的回调,比如setTimeoutsetInterval
  • I/O 回调:在被委托给 Node 的管理线程池后返回到主线程的准备好的回调,比如文件系统调用和网络监听器
  • 轮询/检查 :主要是根据setImmediatenextTick的规则排列在堆栈上的函数

当套接字或其他流接口上有数据可用时,我们不能立即执行回调。JavaScript 是单线程的,所以结果必须同步。我们不能在事件循环的中间突然改变状态,这会导致一些经典的多线程应用程序问题,比如竞争条件、内存访问冲突等。

要了解更多关于 Node 如何绑定到libuv和其他核心库的信息,请查看fs模块的代码:github.com/nodejs/node/blob/master/lib/fs.js。比较fs.readfs.readSync方法,观察同步和异步操作的实现方式的不同;注意在fs.read中传递给原生binding.read方法的包装回调。要深入了解 Node 设计的核心部分,包括队列实现,请阅读 Node 源代码:github.com/joyent/node/tree/master/src。查看fs_event_wrap.cc中的FSEventWrap。调查req_wrap类,这是 V8 引擎的包装器,在node_file.cc和其他地方部署,并在req_wrap.h中定义。

进入事件循环时,Node 实际上会复制当前指令队列(也称为堆栈 ),清空原始队列,并执行其副本。处理这个指令队列被称为tick 。如果libuv在单个主线程(V8)上处理此 tick 开始时复制的指令链时异步接收到结果(包装为回调),这些结果将被排队。一旦当前队列被清空并且其最后一条指令完成,队列将再次被检查以执行下一个 tick 上的指令。这种检查和执行队列的模式将重复(循环),直到队列被清空,并且不再期望有更多的数据事件,此时 Node 进程退出。

接下来,让我们看看 Node 的事件接口。

监听事件

现代网络软件因为各种原因变得越来越复杂,并且在很多方面改变了我们对应用程序开发的看法。大多数新平台和语言都试图解决这些变化。Node 也不例外,JavaScript 也不例外。

学习 Node 意味着学习事件驱动编程,将软件组合成模块,创建和链接数据流,生成和消耗事件及其相关数据。基于 Node 的架构通常由许多小进程和/或服务组成,这些进程和/或服务通过事件进行通信 - 内部通过扩展EventEmitter接口并使用回调,外部通过几种常见的传输层之一(例如 HTTP,TCP),或通过覆盖这些传输层之一的消息传输层(例如 0MQ,Redis PUBSUB 和 Kafka)。

这些进程很可能由几个免费、开源和高质量的 npm 模块组成,每个模块都配备了单元测试和/或示例和/或文档。这是我们在逐章阅读时将遇到的主要事件接口,因为它为许多暴露事件接口的 Node 对象提供了原型类,例如文件和网络流。不同模块 API 暴露的各种closeexitdata和其他事件都表示了EventEmitter接口的存在,随着我们的学习,我们将了解这些模块和用例。

在本节中,我们的目标是讨论一些较少为人知的事件源:信号、子进程通信、文件系统更改事件和延迟执行。

信号

事件驱动编程就像硬件中断编程。中断正是其名称所暗示的。它们利用中断控制器、CPU 或任何其他设备正在执行的任务,要求立即为它们的特定需求提供服务。

事实上,Node 进程对象公开了标准可移植操作系统接口(POSIX) 信号名称,因此 Node 进程可以订阅这些系统事件。

正如en.wikipedia.org/wiki/POSIX_signal 所定义的,"信号是 Unix、类 Unix 和其他符合 POSIX 标准的操作系统中使用的一种有限的进程间通信形式。它是异步通知,发送给进程或同一进程中的特定线程,以通知其发生的事件。"

这是将 Node 进程暴露给操作系统信号事件的一种非常优雅和自然的方式。可以配置监听器来捕获指示 Node 进程重新启动或更新某些配置文件,或者简单地进行清理和关闭的信号。

例如,当控制终端检测到Ctrl + C (或等效)按键时,SIGINT信号将发送到进程。此信号告诉进程已请求中断。如果 Node 进程已将回调绑定到此事件,则该函数可能在终止之前记录请求,执行其他清理工作,甚至忽略请求:

arduino 复制代码
// sigint.js
console.log("Running...");

// After 16 minutes, do nothing
setInterval(() => {}, 1e6); // Keeps Node running the process

// Subscribe to SIGINT, so some of our code runs when Node gets that signal
process.on("SIGINT", () => {
    console.log("We received the SIGINT signal!");
    process.exit(1);
});

以下是sigint.js的输出:

arduino 复制代码
$ node sigint.js
Running...
(then press Ctrl+C)
We received the SIGINT signal!

此示例启动了一个长时间间隔,因此 Node 不会因无其他任务而退出。当您通过控制进程的终端从键盘发送Ctrl + C时,Node 会从操作系统接收信号。您的代码已订阅了该事件,Node 会运行您的函数。

现在,考虑这样一种情况,即 Node 进程正在进行一些持续的工作,例如解析日志。能够向该进程发送信号,例如更新配置文件或重新启动扫描,可能是有用的。您可能希望从命令行发送这些信号。您可能更喜欢由另一个进程执行此操作 - 这种做法称为进程间通信(IPC)。

创建一个名为ipc.js的文件,并键入以下代码:

javascript 复制代码
// ipc.js
setInterval(() => {}, 1e6);
process.on("SIGUSR1", () => {
    console.log("Got a signal!");
});

运行以下命令:

ruby 复制代码
$ node ipc.js

与以前一样,Node 将在运行空函数之前等待大约 16 分钟,保持进程开放,因此您将不得不使用*Ctrl *+ C来恢复提示符。请注意,即使在这里,我们没有订阅 SIGINT 信号,这也可以正常工作。

SIGUSR1(和SIGUSR2)是用户定义的信号,由操作系统不知道的特定操作触发。它们用于自定义功能。

要向进程发送命令,必须确定其进程 ID 。有了 PID,您就可以寻址进程并与其通信。如果ipc.js在通过 Node 运行后分配的 PID 是123,那么我们可以使用kill命令向该进程发送SIGUSR1信号:

shell 复制代码
$ kill --s SIGUSR1 123

在 UNIX 中查找给定 Node 进程的 PID 的一个简单方法是在系统进程列表中搜索正在运行的程序名称。如果ipc.js当前正在执行,可以通过在控制台/终端中输入以下命令行来找到其 PID:

使用ps aux | grep ipc.js命令。试试看。

子进程

Node 设计的一个基本部分是在并行执行或扩展系统时创建或分叉进程,而不是创建线程池。我们将在本书中以各种方式使用这些子进程。现在,重点将放在理解如何处理子进程之间的通信事件上。

要创建一个子进程,需要引入 Node 的child_process模块,并调用fork方法。传递新进程应执行的程序文件的名称:

ini 复制代码
let cp = require("child_process");
let child = cp.fork(__dirname + "/lovechild.js");

您可以使用这种方法保持任意数量的子进程运行。在多核机器上,操作系统将分配分叉出的进程到可用的硬件核心上。将 Node 进程分布到核心上,甚至分布到其他机器上,并管理 IPC 是一种稳定、可理解和可预测的方式来扩展 Node 应用程序。

扩展前面的示例,现在分叉进程(parent)可以发送消息,并监听来自分叉进程(child)的消息。以下是parent.js的代码:

javascript 复制代码
// parent.js
const cp = require("child_process");
let child = cp.fork(__dirname + "/lovechild.js");

child.on("message", (m) => {
  console.log("Child said: ", m); // Parent got a message up from our child
});
child.send("I love you"); // Send a message down to our child

以下是parent.js的输出:

less 复制代码
$ node parent.js
Parent said:  I love you
Child said:  I love you too
(then Ctrl+C to terminate both processes)

在那个文件旁边,再创建一个文件,命名为lovechild.js。这里的子代码可以监听消息并将其发送回去:

arduino 复制代码
// lovechild.js
process.on("message", (m) => {
  console.log("Parent said: ", m); // Child got a message down from the parent
  process.send("I love you too"); // Send a message up to our parent
});

不要自己运行lovechild.js--parent.js会为您进行分叉!

运行parent.js应该会分叉出一个子进程并向该子进程发送消息。子进程应该以同样的方式回应:

less 复制代码
Parent said:  I love you
Child said:  I love you too

运行parent.js时,请检查您的操作系统任务管理器。与之前的示例不同,这里将有两个 Node 进程,而不是一个。

另一个非常强大的想法是将网络服务器的对象传递给子进程。这种技术允许多个进程,包括父进程,共享服务连接请求的责任,将负载分布到核心上。

例如,以下程序将启动一个网络服务器,分叉一个子进程,并将父进程的服务器引用传递给子进程:

ini 复制代码
// net-parent.js
const path = require('path');
let child = require("child_process").fork(path.join(__dirname, "net-child.js"));
let server = require("net").createServer();

server.on("connection", (socket) => {
  socket.end("Parent handled connection");
});

server.listen(8080, () => {
  child.send("Parent passing down server", server);
});

除了将消息作为第一个参数发送给子进程之外,前面的代码还将服务器句柄作为第二个参数发送给自己。我们的子服务器现在可以帮助家族的服务业务:

vbscript 复制代码
// net-child.js
process.on("message", function(message, server) {
  console.log(message);
  server.on("connection", function(socket) {
    socket.end("Child handled connection");
  });
});

这个子进程应该会在您的控制台上打印出发送的消息,并开始监听连接,共享发送的服务器句柄。

重复连接到localhost:8080的服务器将显示由子进程处理的连接或由父进程处理的连接;两个独立的进程正在平衡服务器负载。当与之前讨论的简单进程间通信协议相结合时,这种技术展示了Ryan Dahl的创作如何成功地提供了构建可扩展网络程序的简单方法。

我们只用了几行代码就连接了两个节点。

我们将讨论 Node 的新集群模块,它扩展并简化了之前在第七章中讨论的技术,使用多个进程 。如果您对服务器处理共享感兴趣,请访问集群文档:nodejs.org/dist/latest-v9.x/docs/api/cluster.html

文件事件

大多数应用程序都会对文件系统进行一些操作,特别是那些作为 Web 服务的应用程序。此外,专业的应用程序可能会记录有关使用情况的信息,缓存预渲染的数据视图,或者对文件和目录结构进行其他更改。Node 允许开发人员通过fs.watch方法注册文件事件的通知。watch方法会在文件和目录上广播更改事件。

watch方法按顺序接受三个参数:

  • 正在被监视的文件或目录路径。如果文件不存在,将抛出ENOENT(没有实体) 错误,因此建议在某个有用的先前点使用fs.exists
  • 一个可选的选项对象,包括:
  • 持久(默认为 true 的布尔值):Node 会保持进程活动,只要还有事情要做 。将此选项设置为false,即使你的代码仍然有一个文件监视器在监视,也会让 Node 关闭进程。
  • 递归(默认为 false 的布尔值):是否自动进入子目录。注意:这在不同平台上的实现不一致。因此,出于性能考虑,你应该明确控制你要监视的文件列表,而不是随意监视目录。
  • 编码(默认为utf8的字符串):传递文件名的字符编码。你可能不需要更改这个。
  • listener函数,接收两个参数:
  • 更改事件的名称(renamechange之一)
  • 已更改的文件名(在监视目录时很重要)

这个例子将在自身上设置一个观察者,更改自己的文件名,然后退出:

javascript 复制代码
const fs = require('fs');
fs.watch(__filename, { persistent: false }, (event, filename) => {
  console.log(event);
  console.log(filename);
})

setImmediate(function() {
  fs.rename(__filename, __filename + '.new', () => {});
});

两行,rename和原始文件的名称,应该已经打印到控制台上。

在任何时候关闭你的观察者通道,你想使用这样的代码:

ini 复制代码
let w = fs.watch('file', () => {});
w.close();

应该注意,fs.watch在很大程度上取决于主机操作系统如何处理文件事件,Node 文档中也提到了这一点:

"fs.watch API 在各个平台上并不完全一致,并且在某些情况下不可用。"

作者在许多不同的系统上对该模块有非常好的体验,只是在 OS X 实现中回调函数的文件名参数为空。不同的系统也可能强制执行大小写敏感性,无论哪种方式。然而,一定要在你特定的架构上运行测试 ------ 信任,但要验证。

或者,使用第三方包!如果你在使用 Node 模块时遇到困难,请检查 npm 是否有替代方案。在这里,作为fs.watch的问题修复包装器,考虑Paul Millerchokidar 。它被用作构建系统(如 gulp)的文件监视工具,以及许多其他项目。参考:www.npmjs.com/package/chokidar

总结

还没写完,这章省略....

相关推荐
橘右溪11 小时前
Node.js种os模块详解
node.js
HelloRevit11 小时前
npm install 版本过高引发错误,请添加 --legacy-peer-deps
前端·npm·node.js
Bl_a_ck13 小时前
npm、nvm、nrm
前端·vue.js·npm·node.js·vue
zhu_zhu_xia13 小时前
npm包管理工具理解
前端·npm·node.js
eason_fan14 小时前
解决nvm安装指定版本node失败的方法
前端·node.js
全栈派森15 小时前
Web认证宇宙漫游指南
python·node.js
夏虫不与冰语15 小时前
nvm切换node版本后,解决npm找不到的问题
node.js
冰墩墩115 小时前
使用nvm install XXX 下载node版本时网络不好导致npm下载失败解决方案
前端·npm·node.js
techdashen16 小时前
性能比拼: Node.js vs Go
开发语言·golang·node.js
Mintopia17 小时前
Node.js 对前端技术有利的知识点
前端·javascript·node.js