通用异步编程概念

在这篇文章里,我们会快速介绍和异步编程相关的一些重要概念,以及它们在浏览器和JavaScript里的体现。在学习本系列的其他文章之前,你应该理解这些概念。

预备条件: 拥有基本的计算机知识,对JavaScript原理有一定了解。
目标: 理解异步编程的基本概念,以及异步编程在浏览器和JavaScript里面的表现。

异步?

通常来说,程序都是顺序执行,同一时刻只会发生一件事。如果一个函数依赖于另一个函数的结果,它只能等待那个函数结束才能继续执行,从用户的角度来说,整个程序才算运行完毕.

Mac 用户有时会经历过这种旋转的彩虹光标(常称为沙滩球),操作系统通过这个光标告诉用户:“现在运行的程序正在等待其他的某一件事情完成,才能继续运行,都这么长的时间了,你一定在担心到底发生了什么事情”。

multi-colored macos beachball busy spinner

这是令人沮丧的体验,没有充分利用电脑的计算能力 — 尤其是在一个计算机普遍都有多核CPU的时代. 坐在那里傻等毫无意义,你完全可以在另一个处理器内核上干其他的工作,同时电脑完成耗时任务的时候通知你. 这样你可以同时完成其他工作。这就是异步编程的出发点。你正在使用的编程环境提供APIs,这样你就可以异步的运行这些任务(就web开发而言,编程环境就是web浏览器) 

产生阻塞的代码

异步技术非常有用,特别是在web编程。当浏览器里面的一个web应用进行密集运算还没有把控制权返回给浏览器的时候,整个浏览器就像冻僵了一样,这叫做阻塞;这时候浏览器无法处理用户的输入,进行其他的任务,直到web应用结束,交回处理器的控制。

我们来看一些阻塞的例子。

例子: simple-sync.html  (see it running live), 在按钮上添加了一个事件监听器,当按钮被点击,它就开始运行一个非常耗时的任务(计算1千万遍时间,然后把最后一个在console里面显示出来),然后在DOM里面添加一个段落:

const btn = document.querySelector('button');
btn.addEventListener('click', () => {
  let myDate;
  for(let i = 0; i < 10000000; i++) {
    let date = new Date();
    myDate = date
  }

  console.log(myDate);

  let pElem = document.createElement('p');
  pElem.textContent = 'This is a newly-added paragraph.';
  document.body.appendChild(pElem);
});

运行这个例子的时候,打开JS console,然后点击按钮 — 你会注意到,直到时间的运算结束,最后一个date在console上显示出来,段落才会出现在网页上。代码按照源代码的顺序执行,只有前面的代码结束运行,后面的代码才会执行。

Note: 这个例子不现实:在实际情况中一般不会发生,没有谁会计算1千万次时间,它仅仅提供一个非常直观的体验.

第二个例子, simple-sync-ui-blocking.html (see it live), 我们模拟一个在现实的网页可能遇到的情况:因为渲染UI而阻塞用户的互动,这个例子有2个按钮:

  • "Fill canvas" : 点击的时候用1百万个蓝色的圆填满整个<canvas> .
  • "Click me for alert" :点击显示alert 消息.
function expensiveOperation() {
  for(let i = 0; i < 1000000; i++) {
    ctx.fillStyle = 'rgba(0,0,255, 0.2)';
    ctx.beginPath();
    ctx.arc(random(0, canvas.width), random(0, canvas.height), 10, degToRad(0), degToRad(360), false);
    ctx.fill()
  }
}

fillBtn.addEventListener('click', expensiveOperation);

alertBtn.addEventListener('click', () =>
  alert('You clicked me!')
);

如果你点击第一个按钮,然后快速点击第二个,会注意到aler消息并没有出现,只有等到圆圈都画完以后,才会出现:因为第一个操作没有完成之前阻塞了第二个操作的运行.

Note: 当然,这个例子也很丑陋,因为我们只是在模拟阻塞效果。但在现实中,这是一个很常见的问题。开发人员们一直在努力缓解它。

为什么这样? 回答是:JS一般来说是单线程的(single threaded接着我们来介绍线程的概念.

线程Threads

一个线程是一个基本的处理过程,程序用它来完成任务。任何时候每一个线程只能做一件事情:

Task A --> Task B --> Task C

每一个任务顺序执行,只有前面的结束了,后面的才能开始。

早先说过,现在的计算机大都有多个内核(core),所以可以同时做多个任务。支持多线程的编程语言可以使用计算机的多个内核,同时完成多个任务:

Thread 1: Task A --> Task B
Thread 2: Task C --> Task D

JavaScript 是单线程的

JavaScript 传统上是单线程的。即使有多核,你只能让它在一个线程是运行任务,称为主线程(main thread). 上面的例子就像下面这样运行:

Main thread: Render circles to canvas --> Display alert()

经过一段时间,JS获得了一些工具来解决这种问题. Web workers 可以把一些任务交给一个分离的线程,叫做worker, 所以你就可以同时运行多个JS代码块,一般来说,用一个worker来运行一个耗时的任务,main Thread就可以处理用户的交互(避免了阻塞)

  Main thread: Task A --> Task C
Worker thread: Expensive task B

记住这些,看看 simple-sync-worker.html (观察它的运行), 记住打开浏览器的JavaScript 终端. 这个例子重写了前例:用一个分离的worker计算一千万次时间,你再点击按钮,现在浏览器可以在计算完成之前显示段落,阻塞消失了。

异步代码

workers相当有用,但是他们的确也有局限。主要的一个就是不能访问 DOM — 它不能直接更新UI. 我们不能在worker里面渲染1百万个蓝色圆圈,它基本上只在做算数的苦活。.

其次,虽然在worker里面运行的代码不会产生阻塞,但是基本上还是同步的。当一个函数依赖于 几个在它之前运行的过程的结果,这就会成为问题。考虑下面的情况:

Main thread: Task A --> Task B

在这种情况下,比如说Task A 正在从服务器上获取一个图片之类的资源,Task B 准备在图片上加一个滤镜。如果开始运行Task A 后立马尝试运行Task B, 你就会得到一个错误,因为图像还没有获取到.

  Main thread: Task A --> Task B --> |Task D|
Worker thread: Task C -------------> |      |

这种情况下,比如说Task D 要同时使用 Task B 和Task C的结果. 如果能保证这两个结果同时提供,程序可能正常运行,但是这不太可能。如果Task D 尝试在任何一个结果还没有就绪的情况下就运行,程序就会抛出一个错误。

为了解决这个问题,浏览器允许我们异步运行某一些操作. Promises 特性就允许让一些操作运行 (比如:从服务器上获取图片), 然后等到结果返回再运行其他的操作:

Main thread: Task A                   Task B
    Promise:      |__async operation__|

既然操作在其他地方发生,当操作正在异步处理的时候,主线程就不会被阻塞.

下一篇文章将要介绍,如何写异步代码。令人兴奋,不是吗?!接着读下去!

结论

围绕异步编程领域,现代软件设计正在加速旋转,就为了让程序在一个时间内做更多的事情。当你使用更新的,更强大的APIs,你会发现更多的情况下,使用异步编程是唯一的途径.。在以前,写异步代码的确很难,现在也需要你来适应,但是已经变容易了很多。在余下的部分,我们将探索为什么异步如此重要,如何设计代码来防止前面已经提到过的问题。

模块大纲

文档标签和贡献者

此页面的贡献者: HermitSun, oceanMIH
最后编辑者: HermitSun,