JavaScript 异步 01 - 初识 JavaScript 异步

78 阅读7分钟

名词解释

  • 异步:异步指两个或两个以上的对象或事件不同时存在或发生

  • 同步:各方都实时地收取信息的即时沟通方式,即为同步。

  • 运行时:指运行时系统,即某门语言的宿主环境,在本文中一般指浏览器和 Node.js [1]

  • 阻塞:进程一直在等待某个事件的完成,期间无法执行其他操作。

关于 JavaScript 同步异步的第一印象

我们总是听说 JavaScript 是一个单线程 [2] 的语言,所以代码都是同步的,按照书写的顺序去执行;不用考虑异步开发时容易造成的死锁和冲突问题。但是又经常在一些文档中看到 JavaScript 的异步操作等相关的内容,需要保证异步操作的顺序和结果的正确。想来一些朋友可能会有困扰,这两者到底是一个什么样的关系。为了介绍的简单一些,这篇文档先暂时不提 Promise、async/await 和相应的影响。

为什么需要异步 JavaScript?同步操作虽然可以降低学习门槛,并且在 ES5 中也并没有提出对异步的要求,但是为什么各种 JavaScript 运行时(浏览器或者 Node.js)都提供了异步的能力?主要原因还是同步造成的缺陷:耗时较长的函数会导致整个页面的卡死。

因为同步代码是一行一行执行的,而当前端页面在处理数据或者等待网络请求的结果时,整个线程在进行等待,就会导致没办法去对页面进行操作无法对我们的点击事件等其他行为做出响应,这就是驱使大家给 JavaScript 添加异步能力的一个原因。

大家的解决方法,就是通过使用事件和回调函数来解决这个问题。浏览器提供了相应的异步 API 比如 XHR [3](XMLHttpRequest 属于 Web API 的范畴,并不是 ECMA-262 中对语法的要求,所以是由运行时提供的),通过给 XHR 对象设置监听器和回调函数,函数调用这个 API 的时候,浏览器就根据提供的参数去进行网络请求。当请求成功的事件触发监听器后,就将回调函数添加到 JavaScript 引擎中去执行回调函数,实现异步操作的效果。通过让浏览器去额外执行那些比较花费时间的操作,保持页面的线程不被长期占用,然后设置事件监听和回调函数来实现获取异步函数的返回结果,这样主线程就可以保持是同步的,异步的操作由运行时在其他线程上执行。

并发模型与事件循环

其实关于 JavaScript 的异步的原理和实现,有一个必读的文档,就是 mdn 撰写的关于并发模型与事件循环的文档 [4],描述的就是 JavaScript 运行时是如何实现并发,只有可以同时并发的执行多个线程,同时这几个线程之间可以通信,那么才会实现异步效果,使得代码可以进行异步编程。

为了理解事件监听和回调函数是如何实现异步,和其中涉及到运行时的引擎的设计,首先需要解释一下何为事件监听和异步。简单的例子可以说,你点击页面按钮的时候,触发了按钮上设置的点击监听器,而这个监听器再去触发设置在上面的回调函数。


function foo(someValue, callback) {
	const value = doSometing(someValue);
	callback(value);
}

function bar() {
	foo("abcd", res => {
		console.log(res);
	}
}

如上,按照函数式编程的思想,函数是可以作为参数使用的。函数 foo 除了接受正常的进行计算用的参数以外,还接受了一个名为 callback 的参数,而这个参数是一个函数。这个 callback 就是回调函数。当在其他函数,比如 bar 函数中使用 foo 函数的时候,就可以将一个匿名函数作为参数传递给 foo。这样 foo 会在执行完计算操作后,将计算结果传递给回调函数。

当讲到这一步时就可以看出来,JavaScript 的异步实现依赖于运行时提供的 API 来实现。当你调用这些 API 的时候,浏览器会添加一个监听器,并去把你要执行的另一个操作的参数去放到另一个线程中执行,当执行结束后,就会触发监听器,而浏览器就会把回调函数添加到发起异步请求的页面的 JavaScript 引擎中。这一套操作,就依靠并发模型与事件循环来实现。

并发模型与事件循环文档解读

模型结构:

模型结构

图片来源:developer.mozilla.org/en-US/docs/…

在这个模型中,执行栈(Stack)中存储的是当前函数调用,每个帧是一个函数的参数和变量等具体内容,执行完一个函数就会出栈一个函数。堆(Heap)中是 JavaScript 程序中的各种对象以及的数据,程序执行的时候,通过指针来堆中取得具体的数据。队列(Queue)是待处理的消息队列,每一个消息代表一个事件监听器完成后被添加到队列中,而运行时可以通过这个消息来找到当时绑定的回调函数。

当 JavaScript 引擎执行函数的时候,根据代码的执行顺序为函数创建帧并压入执行栈中,如果这个函数调用了其他函数,就为这个被调用的函数创建新的帧,然后压入执行栈中执行。每个函数执行完毕返回后,就弹出这个函数对应的帧,直到栈中的帧被执行完。

当执行栈中的函数执行完后,就进入事件循环期间,运行时就会逐个处理队列中的消息,根据回调函数,创建新的帧压入执行栈让 JavaScript 引擎去执行代码。等栈空后,事件循环机制会再处理队列中的下一个消息的回调函数。

队列中的每一个消息完整执行后,其他消息才执行,而当执行栈中的一个函数执行时,JavaScript 引擎不会被其他函数抢占,只有在执行栈中的帧都运行完毕之后才会去再次处理消息队列中的消息。这样的设计就在线程同步执行的基础上,实现了异步编程的效果,但是永远不会因为等待网络请求等其他操作而阻塞。

当然,这样的设计也不是没有缺点的,当一个回调函数需要较长的处理时间时,由于点击和滚动页面等行为也是利用事件实现的,这时候 Web 应用程序就无法处理与用户的交互。只有被绑定监听器的事件发生的时候,回调函数才会添加到消息队列。

还有一个典型的例子就是 setTimeout 这个 Web API,虽然函数有一个参数是设置计时器时间,但是因为事件循环机制的存在,实际执行的情况是。运行时在等待设置的延迟时间后,把回调函数添加到消息队列中。因为队列的先进先出的特性,如果此时队列中没有其它消息并且执行栈为空,设置的回调函数会被马上添加到执行栈中去处理。但是,如果有其它消息,setTimeout 的回调函数必须等待其它消息处理完后才能执行。因此第二个参数仅仅表示了最少延迟时间,而非确切的等待时间,等待的时间取决于队列里待处理的消息数量。

针对这些问题,还有 Promise、async/await,会在之后的文档中进行更进一步的解释。

参考链接

  1. 运行时(runtime)是什么意思?应该怎样深入且直观地理解? - doodlewind 的回答 - 知乎

  2. JavaScript

  3. XMLHttpRequest

  4. 并发模型与事件循环

  5. 异步 JavaScript 简介

  6. 异步

  7. 同步