原文
使用像 JavaScript 这样的语言进行编程时,最重要但也经常被误解的部分之一是如何表达和操作一段需要某段时间才能完成执行的程序行为。
这不仅仅是从 for 循环开始到 for 循环结束发生的事情,这当然需要一些时间(微秒到毫秒)才能完成。它是关于当你的程序的一部分现在运行而你的程序的另一部分稍后运行时会发生什么。在程序的两部分分别得到执行的时间间隙,存在着一个 gap.
实际上,所有编写过的重要程序(尤其是用 JS 编写的)都必须以某种方式管理这个 gap,无论是等待用户输入、从数据库或文件系统请求数据、通过网络发送数据以及等待响应,或以固定的时间间隔执行重复的任务(如动画)。通过所有这些不同的方式,您的程序必须及时管理状态。
异步编程从 JS 开始就已经存在了,这是肯定的。但是大多数 JS 开发人员从来没有真正仔细考虑过它是如何以及为什么会出现在他们的程序中,或者探索各种其他方法来处理它。足够好的方法一直是不起眼的回调函数。直到今天,许多人仍坚持认为回调已绰绰有余。
但是随着 JS 的范围和复杂性不断增长,为了满足在浏览器和服务器以及介于两者之间的所有可能的设备中运行的一流编程语言不断扩大的需求,我们管理异步的痛苦正变得越来越严重,他们迫切需要更有能力和更合理的方法。
我们必须更深入地了解异步是什么以及它如何在 JS 中运行。
a program in chunks
你可以在一个 .js 文件中编写你的 JS 程序,但你的程序几乎肯定由几个块组成,其中只有一个现在要执行,其余的将稍后执行。 每个块最常见的单位是函数。
大多数刚接触 JS 的开发人员似乎都有的问题是,“later”不会严格地发生在“now”之后。 换句话说,根据定义,当前无法完成的任务将异步完成,因此我们不会像您直观地期望或想要的那样有阻塞行为。
考虑:
您可能知道标准 Ajax 请求不是同步完成的,这意味着 ajax(…) 函数还没有任何返回值以分配给 data 变量。 如果 ajax(…) 可以阻塞直到响应回来,那么 data = … 赋值会正常工作。
但这不是我们使用 Ajax 的方式。 我们现在发出一个异步的 Ajax 请求,直到稍后我们才会得到结果。
从现在到以后“等待”的最简单(但绝对不仅仅是,甚至最好!)方法是使用一个函数,通常称为回调函数:
看这段代码:
这个程序有两个部分:现在将运行的内容和稍后运行的内容。 这两个块是什么应该很明显。
现在立即执行的代码块:
稍后异步执行的代码块:
一旦您执行程序, now 块就会立即运行。 但是 setTimeout(…) 也会设置一个事件(超时)稍后发生,因此 later() 函数的内容将在稍后的时间(从现在起 1,000 毫秒)执行。
任何时候您将一部分代码包装到一个函数中并指定它应该响应某些事件(计时器、鼠标单击、Ajax 响应等)而执行时,您正在创建代码的“later”部分,从而引入异步到你的程序。
Async Console
console.log 到底是同步输出还是异步输出?
没有关于 console.* 方法如何工作的规范或一组要求——它们不是 JavaScript 的正式组成部分,而是由托管环境添加到 JS 中。
因此,不同的浏览器和 JS 环境有着各自的实现,这有时会导致混乱的行为。
特别是,有一些浏览器和一些条件,console.log(…) 实际上并没有立即输出它给出的内容。 这可能发生的主要原因是因为 I/O 是许多程序(不仅仅是 JS)的一个非常缓慢和阻塞的部分。 因此,浏览器在后台异步处理控制台 I/O 可能会表现得更好(从页面/UI 角度来看),而您甚至可能不知道发生了这种情况。
一个不太常见但可能的场景,可以观察到这种情况(不是从代码本身而是从外部):
我们通常希望在 console.log(…) 语句的确切时刻看到 a 对象被快照,打印类似 { index: 1 } 的内容,这样在 a.index++ 发生时的下一个语句中,它正在修改与 a. 的输出不同的东西,或者完全不同的东西。
大多数情况下,前面的代码可能会在您的开发人员工具的控制台中生成您所期望的对象表示。但同样的代码可能会在浏览器认为需要将控制台 I/O 推迟到后台的情况下运行,在这种情况下,当对象在浏览器控制台中表示时,a.index++已经发生了,它显示 { index: 2 }。
在什么条件下控制台 I/O 将被推迟,甚至是否可以观察到,这是一个不断变化的目标。请注意 I/O 中这种可能的异步性,以防您在调试中遇到问题,其中在 console.log(…) 语句之后修改了对象,但您看到意外的修改出现。
Event Loop
让我们做出一个(也许令人震惊的)声明:尽管您显然能够编写异步 JS 代码(例如我们刚刚看到的超时),但直到最近(ES6),JavaScript 本身实际上从未有任何内置的异步的直接概念.
什么!?这似乎是一个疯狂的主张,对吧?事实上,这是非常正确的。 JS 引擎本身从来没有做过任何事情,只是在任何给定的时刻,在被要求时执行你的程序的单个块。
被谁要求执行呢?这个问题很关键。
JS 引擎不是孤立运行的。它在托管环境中运行,对于大多数开发人员来说,这是典型的 Web 浏览器。在过去的几年里(但绝不是唯一的),JS 通过 Node.js 之类的东西从浏览器扩展到其他环境,例如服务器。事实上,如今 JavaScript 被嵌入到各种设备中,从机器人到灯泡。
但是所有这些环境的一个共同“线程”是它们中有一种机制来处理随着时间的推移来执行多个程序块,在每个时间点调用JS 引擎。这个线程称为事件循环。
换句话说,JS 引擎并没有与生俱来的时间感,而是一个任意 JS 片段的按需执行环境。总是安排“事件”(即 JS 代码执行)的是执行 JavaScript 代码的托管环境。
因此,例如,当您的 JS 程序发出 Ajax 请求以从服务器获取一些数据时,您在函数中设置响应代码(通常称为回调),JS 引擎告诉托管环境,“嘿,我现在将暂停执行,但是每当您完成该网络请求并且您有一些数据时,请回调此函数。”
然后浏览器被设置为监听来自网络的响应,当它有东西给你时,浏览器将回调函数插入到事件循环中,以此来调度回调函数的执行。
那么什么是事件循环呢?
让我们首先通过一些假代码来概念化它。
事件循环(event loop)的逻辑可以用下面的伪代码来表示:
当然,这是为了说明概念而大大简化的伪代码。但这应该足以帮助获得更好的理解。
如您所见,while 循环代表了一个持续运行的循环,该循环的每次迭代称为一个滴答。对于每个滴答声,如果一个事件在队列中等待,它就会被从队列里摘下并执行。这些事件是您的函数回调。
重要的是要注意 setTimeout(…) 不会将您的回调放在事件循环队列中。它的作用是设置一个计时器;当计时器到期时,环境会将您的回调放入事件循环中,以便将来某个滴答声将其拾取并执行。
如果此时事件循环中已经有 20 个项目怎么办?您的回调等待。它排在其他人后面——通常没有用于抢占队列和跳过队列的路径。这解释了为什么 setTimeout(…) 计时器可能无法以完美的时间精度触发。您可以保证(粗略地说)您的回调不会在您指定的时间间隔之前触发,但它可以在该时间或之后发生,具体取决于事件队列的状态。
因此,换句话说,您的程序通常被分解成许多小块,这些小块在事件循环队列中一个接一个地发生。从技术上讲,与您的程序不直接相关的其他事件也可以在队列中交错。
Parallel Threading
将术语“异步 async”和“并行 parallel”混为一谈是很常见的,但它们实际上是完全不同的。 请记住,异步是关于现在和以后之间的gap. 但并行是指事物能够同时(simultaneously)发生。
最常见的并行计算工具是进程和线程。 进程和线程独立执行,也可能同时执行:在不同的处理器上,甚至在不同的计算机上,但多个线程可以共享单个进程的内存。
相比之下,事件循环将其工作分解为任务并串行执行,不允许并行访问和更改共享内存。 并行和串行可以在不同线程中以协作事件循环的形式共存。
并行执行线程的交织和异步事件的交织发生在非常不同的粒度级别。
例如:
虽然 later() 的全部内容将被视为单个事件循环队列条目,但在考虑运行此代码的线程时,实际上可能有十几种不同的低级操作。 例如,answer = answer * 2 需要首先加载 answer 的当前值,然后将 2 放在某处,然后执行乘法,然后取结果并将其存储回 answer。
在单线程环境中,线程队列中的项是低级操作真的没有关系,因为没有什么可以中断线程。 但是如果你有一个并行系统,其中两个不同的线程在同一个程序中运行,你很可能会出现不可预测的行为。
考虑下面这段代码: