抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

所有重要的程序都需要通过这样或那样的方法来管理持续一段时间的程序行为。这可能是用户输入、从数据库或文件系统中请求数据、通过网络发送数据并等待响应等。在诸如此类的场景中,程序都需要管理这段时间间隔执行重复任务。

事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。

分块的程序

可以将 JavaScript 程序写在单个.js文件中,但是这个程序几乎一定是由多个块组成。这些块中只有一个是现在执行,其余的则会在将来执行。最常见的块单位是函数。

通常,如果发送一个异步的 ajax 请求,像这样:

const data = ajax('some.url.1');

console.log(data); // 没有结果

经常与异步打交道可能就明白是由于这段 ajax 是异步进行的,程序在 ajax 请求还未完成时就就执行了下面的打印 data 的语句。所以没有结果。

通常,最常见的异步代码还是回调函数:

ajax('some.url.1', (data) => {
  console.log(data);
});

我们交给 ajax 函数一个回调函数,让其在准备完成后调用我们的回调函数就可以正常的获取到数据了。看上去很棒,至少现在是的。

ajax 请求也是可以同步的,但没人会那样用的。

这和分块的程序有什么关系呢?来研究下下列代码:

const now = () => {
  return 21;
};

const later = () => {
  answer = answer * 2;
  console.log('Meaning of life: 42');
};

let answer = now();

setTimeout(later, 999);

通常,分块的程序都是以函数为块来进行执行的。如上述代码,它有两个块(也有两个函数):一个是现在要执行的部分,另一个是将来要执行的部分。

很明显,now 函数是同步的被执行和赋值到的一个变量上的。但,我们还设置了一个定时器,将 later 函数于 999 毫秒后执行,也就是之后的某个时间执行。

任何时候,我们只要把一段代码包装成一个函数,并指定它在响应某个事件(定时器、鼠标点击、ajax 响应等)时执行,我们就是在代码中创建了一个将来执行的块, 也由此在这个程序中引入了异步机制。像 later 函数就是被异步执行的。

异步控制台

目前还没有规范定义了 console 对象下的方法应该如何工作的,它们并不是 JavaScript 正式的一部分,而是由宿主环境实现的。所有不同的宿主环境实现的方式可能不同。但,某些浏览器并不会把传入像console.log(...)这样的内容立即输出,而是异步处理。不过正常延迟输出的情况不多见。

事件循环

很早之前我们就能给编写上述的异步 JavaScript 代码,但直到最近(ES6),JavaScript 才真正内建有直接的异步概念。

JavaScript 语言本身被设计的是单线程语言,宿主环境的实现中都提供了一种机制来处理程序中多个块的执行,且执行每个块时调用 JavaScript 引擎,这种机制被称之为事件循环。

JavaScript 引擎本身没有时间概念,它只是按需求执行 JavaScript 任意代码片段的环境。“事件”(JavaScript 代码执行)调度总是由包含它的环境进行。

例如发送一个 ajax 请求,从服务器获取一些数据,并设置了一个回调函数,用于完成请求时回调。一旦完成了网络请求,JavaScript 引起就会通知宿主环境,执行对应的回调函数。

什么是事件循环?

来看一段伪代码:

// 事件循环队列,先进先出
const eventLoop = [];
let event;

// while 相当于执行代码
while (true) {
  // 每拿出一个事件进行执行,都是一次 tick
  if (eventLoop.length > 0) {
    // 拿到事件队列中的下个事件
    event = eventLoop.shift();
    // 执行下一个事件
    try {
      event();
    } catch (e) {
      reportError(e);
    }
  }
}

这是一段只能用来说明概念的简单的伪代码。这里用一个 while 代表持续运行的循环,循环的每一轮称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行。这些事件就是我们的回调函数。

常见的setTimeout()方法是设置异步的好方法,但它实际上并没有将我们的回调函数直接挂在事件队列上。而是设置一个定时器,当定时器到时后,环境会把回调函数放到事件队列中。这样,在未来某个时刻的 tick 就会摘下并执行这个回调,且没有办法将其直接排在队首。这也是setTimeout()方法精度不高的主要原因。

总的来说,程序通常分成了很多小块,在事件循环队列中一个接一个地执行。严格来说,和程序不直接相关的事件也可能会插入到队列中。

在 Node.js 中,setImmediate()方法会将回调函数准确的插入到所有事件循环队列最后。

并行线程

异步与并行虽然常常被混为一谈,但它们实际上意义完全不同。

计算机常见的工具是线程和进程,多个线程能够共享单个进程的内存。

事件循环机制将自身的工作分成一个个任务并顺序执行,不允许对共享内存进行并行访问和修改。通过分立线程中彼此合作的事件循环,并行和顺序执行可以共存。

多线程的交替执行与异步事件的交替调度,其颗粒度是完全不同的。

在单线程的 JavaScript 中,线程本身不会被中断。如果在多线程系统中,同一个程序中可能有两个不同的线程在运作,这时可能会得到很多不确定的结果。

let a = 20;

const foo = () => {
  a += 1;
};

const bar = () => {
  a *= 2;
};

ajax('url.1', foo);
ajax('url.2', bar);

根据 JavaScript 单线程与事件循环的特性,上述代码也会有不确定性。如果第一个请求先到达,那么 a 的结果就是 42,如果第二个请求先到达,a 的结果就是 41。

如果是多线程允许的话,事情就会变的更加微妙了。因为两个函数可能同时运行,并且共享内存中的 a,其结果的不确定性会更多。多线程编程是非常复杂的,如果不通过特殊的步骤来防止中断和交替运行,可能会得到出乎意料的不确定性行为。

上述 JavaScript 所表现的不确定性不全都是有害的。有时是无关紧要的,有时可能是我们刻意追求的结果。

完整运行

由于 JavaScript 的单线程特性,函数中的代码具有原子性。也就是说,函数foo()一旦开始执行,它的所有代码都会在bar()开始执行前完成,或者相反。这称之为完整运行(run-to-completion)特性。

虽然异步函数的先后执行顺序还是存在着不确定性。但是,这种不确定性是在函数(事件)顺序级别上,而不是多线程的语句顺序级别。

在 JavaScript 的特性中,这种函数顺序的不确定性就是通常所说的竞态条件(race condition)。

并发

通常想到并发,我们就可能与多线程联系上,因为它字面意思上理解就是多个任务同时运作(或者多个请求同时发送)。

在 JavaScript 中的并发通常是这样的情况:我们维护着一个状态更新列表(社交网页或新闻帖子)的网站,它有一个很常见的功能,下拉加载。当用户下拉到列表底部时,就会触发这个事件,由 JavaScript 发送网络请求来获取新的数据。如果在获取数据期间,用户频繁触发了下拉加载这个事件,就会导致更多的请求被发送(虽然实际情况中我们可能会避免这一状况)。

假如用户一瞬间触发了 6 个请求,而在接下来的时间里便会陆续的收到对应的 6 个响应:

// onscroll 事件
请求1;
请求2;
请求3;
请求4;
请求5;
请求6;

// ajax 响应事件
响应1;
响应2;
响应3;
响应4;
响应5;
响应6;

这是我们认为的情况,但根据 JavaScript 单线程事件循环的概念来看,实际上时间线可能是这样的:

onscroll, 请求1; // onscroll 事件
onscroll, 请求2;
响应1; // ajax 响应事件
onscroll, 请求3;
onscroll, 请求4;
响应2;
响应3;
onscroll, 请求5;
响应5;
onscroll, 请求6;
响应4;
响应6;

由于单线程的事件循环,JavaScript 一次只能处理一个事件,所以当在发送多个请求的间隙之间,有对应的响应到达时,JavaScript 便会去处理它。这就像在学校食堂排队一样,都得一个一个按顺序来。另外,由于不同的响应时间,响应的顺序可能也是乱序的。

非交互

两个或多个任务之间由异步交替进行任务时,如果这些任务不相干,则不一定需要交互。如果进程间没有相互影响的话,不确定性是完全可以接收的。

const res = {};

const foo = (result) => {
  res.foo = result;
};

const bar = (result) => {
  res.bar = result;
};

ajax('url.1', foo);
ajax('url.2', bar);

例如上述的伪代码,foo 和 bar 的两个回调按照什么顺序执行是不确定的,但它们是独立运行的,不会相互影响。

交互

现实中更常见的情况是多个任务之间需要相互进行交流,如果它们是交互的,则需要进行协调以避免竞态的出现。

再来看一段伪代码:

const res = [];

const foo = (result) => {
  res.push(result);
};

ajax('url.1', foo);
ajax('url.2', bar);

这里两个回调都是对数组进行追加结果,并且存在不确定性。这就导致了res[0]可能是任意一个回调的结果。这种不确定性很可能就是一个竞态 Bug。

如果需要解决这种不确定性带来的问题,可以尝试进行协调:

let a, b;

const foo = (x) => {
  a = x * 2;
  if (a && b) {
    baz();
  }
};

const bar = (y) => {
  b = y * 2;
  if (a && b) {
    baz();
  }
};

const baz = () => {
  console.log(a + b);
};

ajax('url.1', foo);
ajax('url.2', bar);

这是一种竞态(race),或者称之为门闩(latch)。

协作

还有一种合作方式,称为并发协作(cooperative concurrency)。这里的重点是利用异步的事件循环来将复杂繁重的任务插入到事件队列中,使得其他的并发任务有机会也在事件循环队列中被执行。

通俗的来说,就是分割耗时过长的同步任务到异步队列中,从而避免进程阻塞。

来看一段伪代码:

const res = [];

const response = (data) => {
  res = res.concat(data.map((item) => item * 3.14));
};

ajax('url.1', response);
ajax('url.2', response);

这里假设需要从某处发送 ajax 请求来取得某些数据,并将这些数据进行一系列操作后存入 res 数组中。如果数据只有几千几百条的话,那可能不是什么问题。但如果数据是千万级别甚至更多呢?这时可能就会阻塞进程好一会,效果类似于死循环几秒钟。

这种情况下,我们先处理一部分的数据,让后将剩下的数据处理添加到事件循环的队列中,使其它任务也拥有处理的时间。这样就有助于防止程序运行阻塞,进而提高性能。

const res = [];

const response = (data) => {
  const chunk = data.splice(0, 1000);

  res = res.concat(chunk.map((item) => item * 3.14));

  if (data.length > 0) {
    setTimeout(() => {
      response(data);
    }, 0);
  }
};

ajax('url.1', response);
ajax('url.2', response);

这里使用的是setTimeout(.., 0)的一个 hack 方式,它的大概意思就是尽早的将这个函数添加到事件循环队列,这不是一个推荐的添加到事件循环队列的方式。

任务

在 ES6 中,有一个新的概念建立在事件循环队列(宏任务)上,叫作任务队列(job queue)。现在流行的称呼也为微任务。微任务最大的影响就是 promise 的异步特性。

目前最好理解任务队列(微任务)的方法就是与事件循环队列(宏任务)一起理解。上述中,我们将宏任务描述为一个数组,每次从数组中取出一个任务(块)进行执行时,都称之为一次 tick。而微任务就是挂在每一次 tick 之后所进行的循环队列(常见的说法也有是挂在下次一 tick 开始之前,但这细微的差距不影响我们理解微任务)。

这里上一张上次看到的很好的一张图,图中的原文讲解 Process.nextTick 和 setImmediate 的区别? - 知乎 (zhihu.com) 的。但同时也很好的说明了微任务(剧透一下,process.nextTick()就是会添加到微任务队列中)。

Promise.resolve('B').then((val) => {
  console.log(val);
});

const a = () => {
  console.log('A');
};

const c = () => {
  console.log('C');
};

const d = () => {
  console.log('D');
};

setTimeout(() => {
  d();
}, 0);

a();
c();
// A C B D

img

这里的函数a()c()都是同步执行的(同步可以直接理解为第一次执行的宏任务队列),而函数d()是添加到宏任务的后续队列中的。Promise 的微任务则如图中一样,挂在同步(正在执行)队列后,等待队列(宏任务)之前。

小结

异步 JavaScript 的程序总是会至少分成两个块来运行:一个是现在运行的块;另一个是将来运行的块。尽管程序是一块一块执行的,但是所有这些块共享对程序的作用域和状态的访问,所以对状态的修改都是在之前累计的修改上进行的。

并发是指两个或多个事件链随着事件的发展交替执行,以至于从更高层次来看,就像是同时在运行(尽管在任意时刻只处理一个事件)。

评论