由于JavaScript的主要API为DOM
相关的操作,所以JavaScript设计为一门以单线程模式运行的语言,即JavaScript执行代码的线程只有一个。这样可以避免多线程复杂同步的问题,代码逻辑也更安全、简洁。
但是单线程的缺点也很明显,代码执行的任务都是队列执行的,当遇到耗时的操作时,后续任务不得不等待耗时任务的执行,对于web页面来说,常常造成假死的现象。
单线程模式下,为了避免耗时任务阻塞主线程的执行,JavaScript还支持同步模式和异步模式,对此,本文梳理了js
中异步模式的基本使用和常用异步方案。
线程和进程 & 同步和异步
进程和线程
在使用之前,理解其本质才能更好的运用自如。我们先来看一下看一下线程和进程的基本概念:
线程(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
进程(英语:process),是指计算机中已运行的程序。在面向线程设计的系统(如当代多数操作系统、Linux 2.6及更新的版本)中,进程本身不是基本运行单位,而是线程的容器。程序本身只是指令、数据及其组织形式的描述,进程才是程序(那些指令和数据)的真正运行实例。
可以看出,进程也就是计算机执行中的一项“任务”,而一条线程只是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
这就好比一个工厂(电脑)开启了一项新生产任务(程序|进程),按照任务的调度设计,需要10个工人(线程)来进行不同加工处理,最终完成产品的产出(程序执行结果)。
这10个人是不可分割的独立个体,好比线程是操作系统能够运算的最小单位,而10个人的任务组合起来完成了一项生产任务,这个任务我们就称之为进程,就像是一条生产线,我们需要开启、停止,以及安排多少工人进行加工只是程序设计的问题。
在多任务操作系统中,我们就可以并行执行多个程序,这就是为什么我们可以边打游戏边听歌等多程序同时执行,玩游戏和听歌,也就是我们说到应用程序,每个应用程序可以包含多个进程,而多个进程任务又可以各自划分为多个线程来执行。
但是CPU执行代码都是顺序执行的,如何做到多进程同时执行?实际上,早期的单核CPU只是以极快的速度在各个进程之间来回交替执行,速度之快以至于我们感受不到卡顿的感觉,除了当你打开太多应用而CPU处理不过来时你才会感到程序运行的卡顿。而后面出现的多核CPU才是真正的做到了并行执行。
当我们在进行多任务开发,或者比较大型且复杂的程序开发时,通常我们会设计Master-Worker模式,Master负责分配任务,Worker负责执行任务。
如果用多进程实现Master-Worker,主进程就是Master,其他进程就是Worker。
如果用多线程实现Master-Worker,主线程就是Master,其他线程就是Worker。
在开发复杂的应用程序时,我们可以选择多线程模式,也可以选择多进程模式,或者两种结合的模式,这需要根据实际的场景来考虑。
多进程的优点在于稳定性高,由于进程之间都是比较独立的,某个进程挂掉之后,并不会印象其他进程的的执行,这好比一个公司的多个部门,某个部门瘫痪一般不会造成其他部门的瘫痪。但是线程的创建和管理是比较消耗系统开销的,太多的进程会让系统难以调度运行。
而多线程只是单个进程中的一部分,开销只在内部运作,性能提升会好很多,但问题也十分明显,由于同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。这会让某个线程挂掉之后整个进程阻塞乃至结束。就好比一个部门虽说是分工合作的,但如果上一个人的任务没有完成,下一个人就会进入等待|阻塞状态,并可能造成链式阻塞,最终就可能被操作系统强制结束进程。
同步和异步
同步(英语:synchronization),是一种线性执行的过程,整个调用需要等待所有结果都返回才结束的过程。
异步(英语:asynchrony)是相对于**同步**(synchrony)的概念,异步指令的调用者无需等待执行结果而继续执行后续代码的过程,最终返回结果也无需等待异步执行结果。
举一个生活中的例子,比如你需要打电话办理业务,同步模式就是发起打电话的指令,然后告知业务员需要办理的业务,并等待业务员办理好业务,再挂掉电话。异步模式就是发起了打电话的指令,然后告知业务员需要办理的业务,然后把办理结果通过短信告知你,然后挂掉电话。
这其中的区别在于,办理业务这个过程,需要等待执行就是同步,而只需要把办理结果告知你(回调)就是异步。
我们上面讲到了线程和进程,他们都是互相独立的执行体,而要实现异步,我们就需要开启另外的线程或进程。
我们知道,JavaScript是单线程模式的,但是JavaScript的执行体,比如浏览器并不是单线程的,所以我们说js也支持异步,其实是浏览器支持异步,相当于js的异步操作交给浏览器来执行,后文会更详细地分析js异步执行的原理,这里不再赘述。
js中的同步模式
同步执行也就是js主线程线性执行的过程,也就是代码一句一句地顺序执行,比如下面的代码:
1 | console.log('global begin') |
执行结果是显而易见的。
js的主线程执行过程可以理解为进栈和出栈的过程。js是单线程,所以这有一个调用栈(Call Stack),我们逐行分析上面的代码:
入栈相当于加载需要执行的代码,出栈相当于执行代码。
运行js,执行上面的代码块,上面的代码块其实相当于在执行一个匿名函数,所以首先js使用匿名函数(anonymous)封装这个代码块,
第一步:匿名函数压如调用栈,并开始同步执行(执行函数体内部代码)。
第二步:console.log('global begin')
入栈,到这里可以看作为最小执行语句,出栈。
第三步:加载函数体,但函数体未调用,不需要执行,所以不入栈。
第四步:foo()
入栈。foo
函数不是最小执行语句,需要进行内部代码块入栈和出栈操作。相当于递归。
第五步:console.log('foo runing')
代码入栈,然后出栈。
第六步:bar()
入栈。
第七步:console.log('bar running')
入栈,然后出栈。
第八步:foo()
函数代码段已经全部出栈(执行)了,foo
函数出栈。
第九步:继续入栈,console.log('global end')
入栈,然后出栈。
第十步:匿名函数执行完毕,出栈。
最后,调用栈已经空了,执行结束,进入等待或结束状态。等待别的函数体或代码入栈,以此达到同步执行的效果。
可以发现,入栈再出栈的过程就保证了代码的执行顺序,先入栈最小执行代码的会先执行,后入栈的需要等待前面入栈的代码执行完毕再出栈执行,也就是同步模式的基本实现原理。
js中的异步模式
异步模式其实可以理解为同步模式过程发起的异步指令,而该指令发送完毕就会出栈,也就是后续代码能立即执行而不必等待异步执行的处理结果。
异步的计算过程会交由别的执行体(其他线程或其他进程)去执行,所以不会占用js的主线程,但执行结果是我们需要关心的,我们需要根据异步函数处理的结果进行后续处理,这就是回调函数。回调函数是调用者(js主线程)传给异步函数的一个参数,当异步调用结束之后,异步执行函数会把回调函数压入js调用栈中,所以js能在后续处理回调结果。
在js中,同步函数的执行是在js主线程的调用栈中执行,而异步函数相当于在js的调用者提供的api中执行,比如新的线程中执行,两个线程之间通过一个叫消息队列
的处理器进行通信,即当异步函数执行结束后会把回调函数交给消息队列,消息队列再把回调函数压入js主线程调用栈中执行。所以多个异步函数执行时,异步函数的回调函数的执行顺序是不确定的,取决于异步函数交给把回调函数交给消息队列的时间。
比如下面的js代码:
1 | console.log('global begin') |
首先,console.log('global begin')
入栈,出栈。
然后是异步函数1setTimeout
入栈,出栈。
接着是异步函数2setTimeout
入栈,出栈。
最后console.log('global end')
入栈,出栈,调用栈暂时为空,处于等待执行状态。
setTimeout
是浏览器提供的两个异步api,会在别的线程中执行,进过1s
后异步函数2执行完毕,回调函数2进入消息队列,消息队列把回调函数2入栈,再出栈,函数体console.log('async callback2....')
执行完毕。
再进过0.8s
后异步函数1执行结束,回调函数1进入消息队列,并接着被压入js调用栈,再出栈,onsole.log('async callback1....')
执行完毕。
调用栈再次回到空栈等待状态。
回调函数
回调函数就是异步函数执行结束之后重新入栈执行的函数,我们调用异步函数最终都需要处理异步之后的回调,否则这个异步就是无意义的。
回调函数是异步函数的根基,但由于回调函数执行顺序的不确定,过多的对调函数会让代码解构变得复杂。
还有一些状态需要使用到回调函数的执行结果,如果一味地往回调中补充调用逻辑,会让回调函数过于复杂,不容阅读且难以维护。
以上这些问题都可以使用js提供的统一的异步编程方案:Promise对象
来解决。
Promise
Promise对象用于表示一个异步操作的最终完成 (或失败), 及其结果值。Promise对象是一个代理对象(代理一个值),被代理的值在Promise对象创建时可能是未知的。它允许你为异步操作的成功和失败分别绑定相应的处理方法(handlers)。
为了解决异步回调函数嵌套多深的问题,CommonJS
社区提出了Promise
规范,并在ES2015
中被标准化。一个Promise
有三种状态:
- pending: 初始状态,既不是成功,也不是失败状态。 => 异步执行中。
- fulfilled: 意味着操作成功完成。 =>
onFulifilled
,执行成功回调。 - rejected: 意味着操作失败。=>
onRejected
,执行失败回调。
基本使用
1 | // 创建一个promise对象并发起异步请求 |
可以发现,Promise
对象需要接收一个带有resolve
和reject
两个参数的函数,用于分别接收异步执行fullfilled
和rejected
的状态结果。
而异步函数的then
函数同样以两个函数为参数,分别对应异步函数的成功回调和失败回调。
当执行then
之后,当回调函数状态由padding
变为fullfilled
或者rejected
,Promise
对象就会把对应的回调函数压入消息队列,然后入栈执行。(在上面的例子中,尽管异步函数执行体中没有异步代码,但执行状态结果任然会进入消息队列)。
Promise使用案例:Ajax
Asynchronous JavaScript + XML(异步JavaScript和XML), 其本身不是一种新技术,而是一个在 2005年被Jesse James Garrett提出的新术语,用来描述一种使用现有技术集合的‘新’方法,包括: HTML 或 XHTML, CSS, JavaScript, DOM, XML, XSLT, 以及最重要的
XMLHttpRequest
。当使用结合了这些技术的AJAX模型以后, 网页应用能够快速地将增量更新呈现在用户界面上,而不需要重载(刷新)整个页面。这使得程序能够更快地回应用户的操作。(尽管X在Ajax中代表XML, 但由于JSON的许多优势,比如更加轻量以及作为Javascript的一部分,目前JSON的使用比XML更加普遍。)
现在我们使用Promise
来实现一个简单的ajax
方案。
1 | /** |
链式调用
按照传统的回调执行,如果想要队列执行异步函数,我们可能会这样写:
1 | const url = '/user.json'; |
这样的嵌套写法是Promise
的一种用法误区,这无法体现Promise
的优越性,和原本的回调函数无异。而Promise
的then
方法其实会返回一个新的Promise对象,这样我们能就链式执行多个异步函数。就像这样:
1 | const url = '/user.json'; |
我们发现,只有第一个回调返回了ajax
异步请求的结果,后面的回调虽然执行,但结果都是undefined
,这是因为then
方法返回的并不是自身的promise
对象,而是一个新的Promise
对象。这是默认的返回一个新的Promise
,我们可以自行指定需要执行的Promise
对象,这样,就达到了链式调用的效果:
1 | ajax(url) |
Promise的异常处理
我们上面已经知道,then
函数的第二个参数就是用来处理异步失败结果的回调函数,但是在链式调用中,过多的书写异常函数过于繁杂,所以Promise
对象提供了一个catch
方法,这个方法会保留链式调用的异常结果给下一个Promise
对象,所以我们只需要在链式调用的最后使用catch
方法就可以捕获到链式调用中的异常。
1 | ajax(url) |
注意,如果某个链式中的某个异步函数失败了,后续的链式异步就不会再执行了,因为这里的链式都是写在成功回调中的,异步失败后将会打断链式执行的效果。
实际上,catch(reject)
函数本质上相当于then(undefined, reject)
,所以,在catch
中返回一个新的Peomise
对象同样能实现链式调用的效果。(而finally
函数总会返回一个新的Promise
对象,就算手动指定返回值也不能实现链式效果)。
Promise的静态方法
Promise.resolve()
Promise.resolve()。返回一个新的或者指定的
Promise
对象。
1 | const p1 = ajax('/user.json') |
光是这样这个函数没有什么效果,实际上我们还可以传入任意实现了then
方法的对象,然后就能返回一个新的Promise
对象:
1 | const p1 = { |
这种实现了then
方法的对象我们称之为实现了thenable
接口的对象,都可以转换为一个全新的Promise
对象,如果传入的是Promise
对象,则直接返回该对象。
Promise.resolve
的用途在于封装一个实现了thenable
接口的对象,早期在Promise
对象出现之前,可能有很多自己实现的异步方案,这样我们就能利用这个静态方法将那些异步对象转为Promise
对象。
Promise.reject
同
Promise.resolve
对象一样,reject
函数能让我们快速返回一个失败的Peomise
对象。
Promise.all
这个方法返回一个新的Promise
对象,该promise对象在iterable参数对象里所有的promise对象都成功的时候才会触发成功,一旦有任何一个iterable里面的promise对象失败则立即触发该promise对象的失败。
假设我们需要并行执行多个异步函数:
1 | ajax(url1).then(...) |
但是这样简单的调用我们无法知道所有异步任务是否都已经直接结束,传统的方法是,使用一个计数器来统计,而现在我们使用Promise.all
就能达到这个效果:
1 | // 需要并行执行的Promise函数 |
这样,当迭代器中的Promise
的状态都成功或者有一个失败就会结束整个异步执行状态。
当然,如果你希望某个异步函数失败后仍然能正常统计执行,可以改用Promise.allSettled
:
1 | Promise.allSettled(arr).then(results => { |
这样,返回的新Promise
对象会返回所有异步执行的回调结果,包含了成功和失败的结果。
此外,还有一个Promise.race
有类似的用途,区别在于race
方法会在iterable参数里的任意一个子promise被成功或失败后,父promise马上也会用子promise的成功返回值或失败详情作为参数调用父promise绑定的相应句柄,并返回该promise对象。即任意一个异步请求结束后就立即返回该对象的结果,而不会等待其余异步结果。(race
有比赛、竞赛的含义,意即几个回调函数同步执行,但该方法只接收第一个执行结束的异步函数)。
Generator异步解决方案
相比传统异步调用,Promise
最大的优势就是链式调用更扁平而解决了传统回调嵌套过深的问题。但链式调用过多相比同步模式来说还是不易控制和阅读。ES2015
提供了生成器函数,利用这个函数,我们能让异步函数拥有同步函数执行的效果。
首先看一个简单生成器函数的例子:
1 | function* getNumber() { |
生成器最大的特点就是能够利用yield
关键字“暂停”函数的执行,当主动调用生成器函数的next
方法后才会继续执行后续代码,直到下一个yield
函数然后再次暂停。
利用生成器函数的暂停代码特性,我们就可以暂停等待异步函数的回调结果,然后再继续后续代码的执行,以此达到异步代码同步执行的效果。
首先我们可以使用生成器函数来实现链式调用的效果:
1 | /** |
生成器函数还有一个特点就是,next
函数传入的值能返回给yield
语句。所以,上面的实例改造一下就变成:
1 | function *syncAjax() { |
而在syncAjax
函数中,p1
和p2
分别是两个异步函数的回调值,这就这个生成器函数内部变成了同步执行的代码。
我们可以封装为一个工具函数,让一个生成器作为参数传入,并等待生成器函数内部的异步执行结果:
1 | function *syncAjax() { |
我们观察生成器函数syncAjax
内部的代码,已经完全地把异步结果变成了同步结果来执行。而我们只需要使用一个工具函数syncG
即可实现,我们在利用生成器函数的throw
函数来捕获异常即可完善这个工具函数:
1 | function *syncAjax() { |
实际上,很多库已经封装了这个效果,类似co
这种库,包括生成器的出现,以及这种工具库其实我们都很少再使用,原因是官方在ES2017
中已有类似的实现,而且只需要使用async
和await
两个关键字即可达到这种效果。
这两个关键字只是官方提供的语法糖而已,内部任然是generator
和promise
配合yield
关键字实现的效果。
Asyn函数
上面的generator
异步编程方案,官方已经提供了更为简单便捷的语法糖关键字:aysnc
和await
,那么上面的案例只需要写成这样就可以:
1 | async function syncAjax() { |