likes
comments
collection
share

Nodejs开发进阶L-异步执行和优化机制

作者站长头像
站长
· 阅读数 3

本章节笔者想要来讨论一下JavaScript异步执行相关的内容,这个内容本来是应该放在本系列中比较前面的部分的,但由于思考和规划的问题,到现在才有机会涉及和实现。

事件循环

在准备本文的时候,笔者参考了一些技术博客和文档,结合以前的一些应用的经验和体验,笔者已经了解到JS代码执行的基本原理,包括其中的一些核心概念。关于同步线程这部分一般没有太多的疑问。但很多材料都提到了异步任务这里面包括了微任务和宏任务及其执行策略等,并例举了一些方法并对其进行了归类。笔者原来也是大致这么理解的,但随着深入的了解和思考,特别是参考了nodejs官方的技术文档,笔者觉得原来的一些常规的理解,好像有一些不准确或者被误导的地方,当然也可能是版本或者认知演进的结果。这里笔者想来先探讨一下。

这个文档在这里: nodejs.org/en/guides/e…

笔者觉得这个材料里面的描述应该更加准确,本文中的阐述,就以此文作为基本的依据,并结合笔者的理解展开。

首先我们还是来复述和熟悉和复述一下JS执行代码的基本原理和方式。和传统程序顺序式的执行方式不同,JS程序的执行特点是虽然JS主要以单线程的模式运行,但通过事件循环的调度模式,它可以支持异步代码以非阻塞的方式执行,从而获得更充分的CPU运行资源的利用,和更高效的IO操作。整个执行模型,涉及以下核心的流程和概念:

  • 单线程模型 (Single Thread)

在JS程序执行时,在其主进程中,代码和程序默认是以单线程模型来进行执行的。它将要执行的代码和任务分成两个大类:同步线程和异步任务。程序会先执行同步线程中,此处所有任务完成后,会使用事件循环的调度机制来实现代码和程序的非阻塞的异步执行。

同步线程的执行容易理解,和常规的软件程序基本无异。但在事件循环机制中,异步化程序和任务可能是以交错的形式来进行的,但编写代码和调用程序只能使用顺序的方式,这通常是刚接触JS程序的开发者容易感到比较困惑的地方。

但笔者觉得不必过分担心,在对JS的执行机制有了基础的理解和认知,并且经过一段时间的实践和操作后,开发者一个个就可以比较熟练的掌握这个机制,并正确的编写和执行相关代码,来满足应用和业务的需求。

  • 同步线程(Synchronous Thread)

指的是在JS的主线程中,使用同步方式,按照顺序执行的普通代码和任务。这些代码会按照书写顺序从上到下执行,每行同步代码在执行时,JavaScript引擎其实会一直阻塞,直到此任务执行完毕,然后才会执行下一个任务。这就是所谓的阻塞式执行。

所有同步线程代码执行完成之后,JS引擎就会使用事件循环机制来执行异步代码。整个程序进入事件循环的处理阶段。从表面上看来,这个主线程已经执行完成了,分支任务会进入非阻塞的执行模式。就是所谓的非阻塞执行。

  • 事件循环

事件循环是nodejs进行异步代码执行的核心调度和控制机制。通过这个机制,nodejs可以尽量将操作卸载到操作系统内核来执行非阻塞I/O操作。由于大多数现代操作系统内核都是多线程的,因此它们可以处理在后台执行的多个操作。当这些操作之一完成时,内核会通知nodejs,以便将适当的回调添加到轮询队列中以完成最终执行。

下图简化显示了事件循环的操作顺序:


   ┌───────────────────────────┐
┌─>│           Timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

图中,每个框被称为事件循环的一个“阶段”。整个事件循环大体分为六个阶段,并且进行循环往复的运行。在每次运行事件循环之间,系统都会检查是否正在等待任何异步I/O或计时器,如果没有,则彻底关闭当前程序(程序自动退出)。

每个阶段都有一个要执行的回调的FIFO(先入先出)队列。虽然每个阶段都有其特殊之处,但通常,当事件循环进入给定阶段时,它将执行特定于该阶段的任何操作,然后执行该阶段队列中的回调,直到队列耗尽或达到最大回调数已执行。当队列耗尽或达到回调限制时,事件循环将进入下一阶段,依此类推。

由于这些操作中的任何一个都可能调度更多的操作,并且在轮询阶段处理的新事件由内核排队,因此轮询事件可以在处理轮询事件时排队。因此,长时间运行的回调可能会使轮询阶段的运行时间比计时器的阈值长得多。有关更多详细信息,请参阅计时器和轮询部分。

Windows和Unix/Linux实现之间存在轻微差异(因为涉及到操作系统底层的执行机制),但这对于基本原理而言并不重要。下面我们分别简单说明一下这些执行阶段。

  • 计时器 Timers

这个阶段,将会执行setTimeout和setInterval计划的回调。计时器指定了一个时间阈值,在该阈值之后可以执行所提供的回调,而非指定其希望执行的确切时间。这个回调方法将在指定时间过后“尽早”运行,显然操作系统调度或其他回调的运行可能会造成它们的延迟。从技术上讲,这个执行其实是在轮询阶段启动的。

  • 待处理回调 Pending Callbacks

此阶段,执行被推迟到下一个循环迭代的I/O回调方法。

此阶段执行一下系统操作的回调,如TCP错误。例如,如果 TCP 套接字ECONNREFUSED在尝试连接时接收,某些 *nix 系统希望等待报告错误。这将在待处理回调阶段排队执行。

  • 空闲/准备 Idle/Prepare

这个阶段仅在内部使用,和用户程序无关 。

  • 轮询 Poll

这个阶段,其实是事件循环处理的核心阶段。此阶段主要有两个功能,第一是计算应该阻塞和轮询I/O的时间,然后处理轮询队列中的事件。具体而言,当事件循环进入轮询阶段并且没有调度计时器时,将发生以下两种情况之一:

一、如果轮询队列不为空,则事件循环将迭代其回调队列,并以同步方式执行它们,直到队列耗尽或达到系统相关的硬限制

二、如果轮询队列为空,则会发生以下两种情况之一:如果脚本已被调度setImmediate(),事件循环将结束轮询阶段并继续到检查阶段以执行那些调度的脚本;如果脚本尚未被调度setImmediate(),事件循环将等待回调被添加到队列中,然后立即执行它们。

一旦轮询队列执行完毕,事件循环将检查是否已达到时间阈值的计时器。如果一个或多个计时器准备就绪,事件循环将返回到计时器阶段以执行这些计时器的回调。

  • 检查 Check

此阶段允许在轮询阶段完成后立即执行回调。如果轮询阶段变得空闲并且脚本已排队setImmediate(),则事件循环可能会继续进入检查阶段而不是等待。

setImmediate()实际上是一个特殊的计时器,在事件循环的单独阶段运行。它使用libuv API安排回调在轮询阶段完成后执行。

一般来说,随着代码的执行,事件循环最终将进入轮询阶段,它将等待传入的连接、请求等。但是,如果已安排回调并且setImmediate() 轮询阶段变得空闲,则它将结束并继续 检查阶段而不是等待轮询事件。

  • 关闭回调 Close Callbacks

如果系统关联的套接字或句柄突然关闭(例如socket.destroy()),该'close'事件将在此阶段发出。否则它将通过 发出process.nextTick()。

  • setTimeout和setImmediate

两者的主要差异是调用的时机不同。setImmediate设计为在当前轮询阶段完成后执行,setTimeout安排在最小时间阈值过去后运行。所以,在真正的程序中,计时器的执行顺序将根据调用它们的上下文而变化。如果两者都是从主模块内部调用的,那么计时将受到进程性能的约束,并可能受到其他程序的影响。

例如,如果我们运行以下不在I/O周期(即主模块)内的脚本,则两个计时器的执行顺序是不确定的,因为它受到进程性能的约束:

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => console.log('immediate'));

有趣的是,笔者在实验中,并没有找到一个特定场景能够来验证这一点。大部分实验的结果表明,似乎setImmediate有更高的优先级和立即执行的机会。简单的理论上也可以理解,就是它会在poll阶段,有机会被执行,而不需要等待到timer阶段来处理。

实际上,这些细微的差异,对于我们开发普通的应用程序,几乎没有什么影响。除非是我们需要开发系统级,或者对性能和执行次序需要非常精密的控制,才有机会需要深入的理解和应用。我们只需要大致理解,回调方法,作为异步调用,可能会改变代码执行的顺序(相对于编写),这时可能需要编写特别的代码组织方式,才可能可以控制异步代码可以按照预先的方式和顺序执行。

回调地狱 Callback Hell

按照JS异步调用函数默认的回调工作方式,当在逻辑上,对于比较复杂的有多个步骤的业务流程,可能需要将很多函数调用链接起来的时候,最简单的方式,就是将回调“嵌套起来”,就是在回调方法中,来调用其他的异步方法。这个时候,对于一个比较复杂的调用链,就会出现所谓“回调地狱”的情况(借用下图)。

Nodejs开发进阶L-异步执行和优化机制

JS程序和执行器,其实对于这个情况是无所谓的,只要逻辑不冲突,写成什么样子,都不影响它的执行。感到恐慌的其实只有开发者,特别是那些有代码洁癖的人。当然,从软件工程的角度,这样的代码也确实不好调试、移植和维护,比如要在中间加一个处理环节,就不能像普通顺序执行的代码那么轻松简单了。

所以我们还是希望,尽量以人类比较好理解的方式,来组织这些代码和逻辑。因此,JS语言就引入了Promise和Async/Await等模式,它们都是用来方便解决这类问题而产生的。

Promise

解决回调地狱的一个方式,是使用Promise(承诺)机制。关于这个机制的比较官方的解释和说明,应该在这里:

developer.mozilla.org/en-US/docs/…

笔者的简单解读和理解如下。

首先Promise是一个类和对象,它用于表示一个异步操作的事件性结束(无论成功或者失败)和其结果值。这个对象,有下列三种状态之一:

  • Pending(等待): 也是其初始状态
  • Fulfilled(实现): 操作已经成功完成
  • Rejected(拒绝): 操作失败

基于这个结构,我们可以进一步理解,Promise是未来某个值的代理,就是在Promise创建时该值可能还不确定,但是它允许你用它封装一个异步操作的处理程序,来返回最终成功值或者失败的信息。然后,可以像同步方法一样调用,并返回结果,不是立即返回最终值,而是返回Promise来在将来提供值(下图)。

Nodejs开发进阶L-异步执行和优化机制

为了便于读者理解这个问题,并对Promise的执行方式有直观的了解,笔者编写了一个简单的扔硬币游戏,用到了Promise对象,代码如下:

// promise define
const p = new Promise((f,r)=> Math.random()*10 > 5 ? f("win") : r("false"));

// promise call
p
.then(d1=>{ return d1; })
.then(d2=>console.log(d2))
.catch(console.log);

眼尖的读者应该可以看到,这个游戏的规则是必须两次都扔到正面才能算赢,否则都是输。这里的要点如下:

  • Promise的构建函数,参数是一个方法,回调参数就是fulfill和reject方法
  • 开发者应该可以根据业务需求,重写回调方法内容,在其中进行业务操作
  • 在业务操作代码中的合适的场景,调用fulfill或者reject,代表Promise的等待状态结束并返回结果(成功或者失败)
  • 可以使用then方法,来执行Promise,并捕获处理结果
  • 可以使用catch方法,来捕获失败的信息
  • then方法可以多次调用,上一次成功调用的返回值,会作为参数传递到下一个调用
  • 利用then的链接式调用,Promise可以处理逻辑前后关联的多个异步业务操作

Promise的正常调用方法,都是在其原型中定义的:

  • Promise.prototype.then()

用于承载和处理fulfilled的结果,这个结果将会作为then方法的参数注入,便于在then方法内部进行引用和处理。由于此方法的返回结果是Promise实例本身,所以then方法可以支持链式调用的形式,可以处理流程化执行的场景。

  • Promise.prototype.catch()

用于承载和处理reject或者错误throw的结果,这个结果将会作为catch方法的参数注入,便于在其内部进行引用和处理。

  • Promise.prototype.finally()

表示Promise调用的结束,在这里可以做一些收尾的工作。我们可以发现,这个结构其实很像JS标准的try-catch-finally结构。

Promise扩展方法

前面我们已经看到了Promise的典型用法,但实际上原生的Promise其实有更丰富的特性。它们体现为一系列相关的静态方法,我们可以查阅Promise的文档,获得更完整的信息。

快捷构造方法

首先是一类快速构造方法,它们可以用于快速的创建确定结果的Promise对象,方便日常开发和操作。

  • resolve

resolve方法,可以用于直接创建一个只能fulfilled的Promise对象。其参数是成功处理的结果。

  • reject

和resolve方法相对,它可以用于直接创建一个只能reject的Promise对象。

  • withResolvers()

这个方法用于快速的创建一个可结构的对象,包括Promise、Resolve和Reject方法,然后可以在后续定义处理方法。下面是一个简单的示例:

let { promise, resolve, reject } = Promise.withResolvers();

// 等效于
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });

组合Promise方法

其中有一类是可以将多个Promise进行组合处理的方法,包括了all、allSettled、any、race等,它们都是Promise的静态方法,输入参数是多个Promise对象,可以处理多个Promise对象和它们之间的业务逻辑关系。

  • all

all方法的输入是一个Promise实例数组,它可以迭代这个数组并生成一个新的Promise对象,如果数组中所有的Promise都fulfilled,则整体fulfilled,并返回完成结果的数组;否则返回第一个reject的值。all方法将一个Promise数组当成单一Promise对象看待,只有所有操作都成功,则返回成功结果(也是以数组方式),否则返回第一个失败的结果作为整体失败的结果。

  • allSettled

allSettled和all方法稍有差异,它也是返回一个结果Promise,但这个Promise的结果总是fulfilled一个和输入Promise数组对应的结果数组,里面同时包括成功或者失败结果。就如它的名字一样,它是能够成功返回所有结果已经设置好的这么一个Promise,只不过结果中有成功或者失败信息而已。这个结果是一个对象数组,对象属性包括status和value(详见示例)。

  • any

这个可以和all对应,类似于与和或的逻辑关系。就是只需要有一个fulfilled,这个结果就fulfilled;只有所有Promise都reject,结果Promise才reject。

  • race

就如方法名称那样,这个方法将同步执行输入Promise的数组,并将第一个有结果(成功或者失败)的Promise作为这个race Promise整体的结果。

可以看到,上面几个方法的基本逻辑都是可以将多个Promise转换并作为成为单一的Promise来处理,可以用于处理很多组合式的业务操作。下面笔者编写了一个简单的示例,让我们方便的对比和理解这些操作:


const p1 = Promise.resolve(3);
const p2 = Promise.reject("false2");
const p3 = 42;
const p4 = new Promise((f,r) => setTimeout(f, 500, 'bar'));
const p5 = new Promise((f,r) => setTimeout(f, 200, 'foo'));

// all 
const pAll = (plist)=> 
Promise.all(plist)
.then((values) => console.log("1 OK:", values))
.catch(err=>console.log("1 False:", err));

pAll([p1,p3,p4]);
pAll([p1,p4,p2]);

// all setteled
const pAllSet = (plist)=> 
Promise.allSettled(plist)
.then((values) => console.log("2 Result:", values));

pAllSet([p1,p3,p4]);

// all setteled
const pAny = (plist)=> 
Promise.any(plist)
.then((values) => console.log("3 Result:", values))
.catch(err=>console.log("3 False:", err));

pAny([p2,p3,p4]);

// all setteled
const pRace = (plist)=> 
Promise.race(plist)
.then((values) => console.log("4 Result:", values))
.catch(err=>console.log("4 False:", err));

pRace([p4,p5]);

// result 
3 Result: 42
1 False: false2
4 Result: foo
1 OK: [ 3, 42, 'bar' ]
2 Result: [
  { status: 'fulfilled', value: 3 },
  { status: 'fulfilled', value: 42 },
  { status: 'fulfilled', value: 'bar' }
]

async / await

我们前面已经看到,使用Promise的链接的then方法调用,可以将多个异步调用可以从先后的逻辑上组织起来,模拟顺序调用的形式。但很多人觉得,这可能离传统的代码书写方式,天然的前后逻辑关系和调用次序的组织,还是有一些差异的。因此,JS社区就在Promise的基础上,进一步提出的async/await的执行模式。

我们先研究一段简单的代码,方便后续讨论:



const pcall2 = async()=>{
    await pAll([p1,p4,p2]);
    await pAllSet([p1,p3,p4]);
    await pAny([p4,p5]);
    await pRace([p4,p5]);
}; pcall2();

如果有读者注意到前面Promise章节示例代码的执行顺序的话,就会发现,它们并不是按照代码编写的顺序来执行的,而是按照异步执行的逻辑来执行。如果我们想要强制的按照调用顺序来执行,就需要使用asnyc/await机制来进行控制。从示例中,我们可以看到这个相关代码编写的规则如下:

async是一个修饰符或者声明,它加在函数的定义或者声明之前。这样,这个函数就有了两个额外的特性。第一,作为被调用者,这个函数可以返回一个Promise对象,并可以被await语句;第二,作为调用者,它可以作为await方法的容器,在其中,使用await方式调用的异步方法,都会严格按照编写顺序执行。

await是个异步方法执行修饰符。它可以像同步方法一样用于执行一个被Promise化的异步方法,它的返回值是正常resolve值。如果需要处理reject值,则可能需要使用try-catch机制。

所以我们可以看到,本质而言,async/await就是一个“语法糖”,用于满足编写更简洁、优雅、直观的JS异步执行代码的需求。在很多情况下,如果弄清了调用的逻辑关系, Promise的Then方法,和await调用方式也是可以组合使用的,它们在逻辑上是等效的。

Async Npm

除了Nodejs官方提供的异步执行处理机制之外,开源社区在原来Nodejs异步机制尚不是特别完善的阶段,也提出了很多相关的技术方案,并以npm的形式交付。典型的如async,bluebird等等,但随着Nodejs本身的异步机制逐渐成熟,这些第三方库的必要性也在降低。

即便如此,除了处理Promise之外,以Async为代表的异步库,其实可以提供更多丰富而强大的异步执行控制的扩展功能。特别是Async库,笔者认为,对其充分的了解、掌握和使用,对于业务应用开发,还是有很大的帮助的。关于这一点,笔者有机会会另外专门写一个博客来讨论。async npm的官方页面在此处:

caolan.github.io/async/v3/

这里考虑到篇幅限制,只讨论一个简单的应用场景和实现,就是将多个并行的任务队列化。比如业务需求,需要在短时间内,通过请求外部 HTTP API接口方式,发送大量消息。我们了解到,最佳的策略并不是完全并发执行,那样会造成服务器短时间负载过大而宕机的风险,而是使用一个队列来处理发送,才能保证比较平稳而高效的完成所有的发送任务,从整体上达到最高的效率。这通常也被形象的称为“削峰填谷”。

我们下面,就以async为例,探讨一下它对于任务队列(queue)这种场景实现,它使用了queue模块。我们先讨论一下它的基本原理。

async/queue的大致工作原理是基于通过维护和执行一个任务队列对象来完成的。首先创建一个任务队列,这个步骤需要定义任务排队后执行的回调方法和并发数量;有任务到来时,默认被添加到队列尾部;任务处理从队列头部开始,队列会依次取出并且调用执行任务,它会将任务作为参数传入回调函数,并执行这个方法;执行完成后,会执行此回调预定的完成回调方法,来标识当前任务的完成; 看到任务完成之后,队列就将任务从队列中取出下一个任务来执行;如此往复,直到这个队列中所有的任务都完成,这里有一个名词就是Drain(耗尽),这时队列就是空的了,它会等待新的任务入队。

基于此原理,其相关示例代码如下:

import queue from 'async/queue';

// create a queue object with concurrency 2
const q = async.queue(function(task, callback) {
    console.log('hello ' + task.name);
    callback();
}, 2);

// assign an error callback
q.error(function(err, task) {
    console.error('task experienced an error');
});

// assign a callback
q.drain(function() {
    console.log('all items have been processed');
});

// add some items to the queue
q.push({name: 'foo'}, function(err) {
    console.log('finished processing foo');
});
// callback is optional
q.push({name: 'bar'});

// add some items to the queue (batch-wise)
q.push([{name: 'baz'},{name: 'bay'},{name: 'bax'}], function(err) {
    console.log('finished processing item');
});

// add some items to the front of the queue
q.unshift({name: 'bar'}, function (err) {
    console.log('finished processing bar');
});

这段代码来自async的官方示例,笔者进行了最简单的修改,这里的要点如下:

  • queue是async库的一个模块和类,用于处理队列,需要先引用一下
  • 在使用之前,需要先创建一个queue的实例
  • 构建方法包括两个参数,第一个参数定义任务处理回调,第二个参数是可选并行任务的数量
  • 任务处理回调方法中,定义具体的任务执行方式(业务需求),并且在任务执行完成后,回调执行任务完成方法
  • 可选定义error方法,用于处理队列级别的错误
  • 可选定义drain方法,用于处理队列耗尽时,需要进行的操作
  • 使用push方法,可以将任务加入到队列的尾部,排队等待处理,并且可选使用数组,同时加入多个任务
  • 使用unshift方法,可以将任务加入到队列的头部,它们会被优先执行(在当前任务接收后就会被调用)
  • 单个任务,也可以选择定义完成时的回调,作为一些任务的特别处理的机会
  • 这里的任务,可以只包括业务数据,相关的调用和回调方法,可以选择设置

小结

本文探讨了Nodejs应用中,异步执行相关的话题。包括Nodejs程序和代码执行的基本工作原理,对于异步执行的理解,回调地狱,Promise,Async/Await,以及Async NPM等相关的内容,希望能够帮助读者从简单的基础理论出发,结合Promise、Async等的实际应用,对JS的异步执行有更深入和细致的理解。