浅谈Javascript异步

0x00 先从js运行机制说起

Javascript是一门事件驱动,非阻塞式I/O模型的语言。虽然在一般的实现上,它是单线程的,但是由于它本身的一些设计机制,导致了它实际上是一门在面向高并发场景下执行效率非常高的语言。
在js中,一旦有事件产生(例如:网络连接建立,网络数据包到达,磁盘I/O操作完成,定时器到时触发),js就会将其加入到事件队列里,再进行处理。实际上,js在执行的时候,其主要的流程可以看做是一个叫Event Loop的东西

while(true)
{
    if(EventQueueIsNotEmpty)
    {
        Pop the front item of the event queue.
        Execute it.
    }
}

可以从这段伪代码可以看出,只要某个事件的处理代码开始执行了,除非它本身出让它的执行权,对于js运行环境来说,所有的cpu时间都由它独占,其他的事件必须等待它处理完成才会开始处理。

这里举一个例子

console.log(1)
let i = 0
setTimeout(function(){
	console.log(2)
},1000)
console.log(3)
while(true)	i++
console.log(4)

代码中设定了一个定时器,规定在1000ms后输出2。在执行过程中,控制台里只会出现1 32永远都不会出现。因为当前执行的事件一直处于执行状态。虽然在1000ms后定时器触发,往控制台输出2的事件加到了事件队列里,但将永远都没有开始执行的机会。

0x01 为什么需要异步

在计算机中,相对CPU的执行速度而言,I/O操作(如磁盘读写、网络传输、键盘输入等)是非常非常慢的。这里以磁盘读写为例,要从机械硬盘上读数据,至少需要大概10ms的时间,而在这10ms的时间内,cpu已经可以执行10^7次加法运算了。所以,如果在执行I/O操作时,让cpu一直处于等待状态,无疑是低效的。那么,这个问题如何解决呢?首先明确我们的目的,要在I/O操作完成前不要白白浪费CPU。其中一种解决方法是使用多线程。例如我们编写了一个简单的HTTP服务器,负责将磁盘上的文件发送给请求的用户。那么我们就可以对于每个用户的请求启用一个新的线程来处理,这样就可以大大增加了软件的吞吐量。但是,如果使用多线程的话,就要另外花费精力去处理一些附加的问题,如锁的问题。
假如每个线程都执行下面这一个操作,其中i是一个全局访问的变量,初始为0

int a = i;
a = a+1;
i = a;

假如我们执行了100个这样的线程,在所有线程执行完后,i的结果往往不是100。因为在多线程的执行的时候,由于实际cpu数量并没有这么多,并不是真正意义上的并发执行,是每个线程执行一段时间,暂停,再让另一个线程执行一段时间,这个过程一般是由操作系统来控制的。所以很有可能在执行完a=a+1时,这个线程就立刻被暂停了,而在其它线程里面i的值被更新,当这个线程恢复执行的时候,它就将旧的a更新到i上。最终100个线程执行完后得到的并不是我们本意要的结果。
那么,要如何解决了?我们可以通过加锁来处理,来保证同时只有一个线程会执行这三行代码,当有线程在执行这三行代码时,其他线程不得进入这个代码区域,来确保对i的值的更新是原子的。
显然,在多线程下,因为有很多事情是无法预知的,这就需要程序员花费额外的精力去处理一些棘手的事情,同时也容易因为考虑不周全产生其他bug。
而javascript的事件驱动模型,就很好的解决了上面这些问题。首先,在js的代码中,并没有并发执行这种概念,意味着同一时间内只会有一段代码执行,并且在执行的过程中不会被其他的事情打断,这样就从执行机制上很好的避免了多线程中需要保证原子操作的要求。同时,对于I/O操作,可以将其交由外部组件处理,要求在I/O操作完成时触发一个事件。这样,代码在执行的过程中,如果遇到I/O操作,就可以将I/O操作完成后要执行的代码封装好,然后将I/O交给外部组件处理,之后即可交出代码的执行权,让执行器执行下一个事件,等到I/O操作完成后再“唤醒”自己,将未完成的操作完成。这样,就能让CPU一直处于工作状态,不会因为等待I/O而空转。
下面,将用一段常用的代码来辅助说明一下

var xhr = new XMLHttpRequest()
xhr.onreadystatechange = function() {
    if(xhr.readyState==4 && xhr.status==200){
        console.log(xhr.responseText)
    }
}
xhr.open("GET","test.txt",true)
xhr.send()
console.log(1)

在执行xhr.send()之后,就会将该http请求交由外部组件处理,同时该函数将会立即返回,执行下面的console.log(1)。注意此时http请求尚未完成。待到该http请求完成时,将会在事件队列中加入这一事件。在事件队列中轮到该事件时将会执行预先定义好的处理代码:

if(xhr.readyState==4 && xhr.status==200){
        console.log(xhr.responseText)
    }

很多初学者会认为在xhr.send()执行完后意味着http请求已经完成,这是一种错误的想法。
也有人可能会想,那我能不能在xhr.send()下通过死循环来检测请求是否完成呢?

while(xhr.readyState!=4) continue;
console.log(xhr.responseText)

然而,很遗憾,代码将永远都不会跳出那个循环。回想下前面提到的Event loop,在http请求完成时,将会将事件放到事件队列等待执行。而这段代码一直在执行,没有机会给请求完成后进行处理,从而xhr.readyState永远都不会得到修改。

0x02 Promise

在ES6还未定制之前,对于这种需要异步操作的场景,都需要用到回调函数(callback)这种东西,即指定任务完成时该进行什么行为。如上面的

xhr.onreadystatechange = function() {
    if(xhr.readyState==4 && xhr.status==200){
        console.log(xhr.responseText)
    }
}

但是,设想一下,如果我们要依次发出多个请求,并且要求每个请求都要在上一个请求完成后再发送,那么就只能再回调函数里面再创建一个请求,再在他的回调函数里面再创建一个请求....这样写下去,可想而知,你的代码的最后面是长这样的:

        })
       })
      })
     })
    })
   })
  })
 })
})

这样的代码很丑就不说了,关键是非常难以维护。假如要往这一堆代码中加另外一个http请求,就必须要小心翼翼地去修改。这就是常说的回调地狱。
在ES6中,有一种好用的东西叫Promise,通过它就能很优雅的处理要用到异步操作的代码了。
相比而言,Promise更像是一个包装器,它可以将异步操作的代码包装起来,使其调用起来更加优雅,同时使以往使用回调函数难以实现的操作以一种十分简便的方法就能得以实现。
正如Promise字面意思说讲,Promise是一种承诺,承诺交给它的东西一定会完成,无论是成功还是失败。一个Promise对象有三种状态,分别是pendingfulfilledrejected,分别对应事件正在/还未执行,事件执行完成,事件执行过程抛出了错误。一旦状态发生了改变就意味着状态将不会再发生改变。
在ES6中,Promise对象是一个构造函数,用来生成Promise实例,它接受一个函数,并将会往其传递俩函数,以让函数内部改变该Promise实例的状态。
首先,我们从创建一个Promise实例说起。

let promise = new Promise((resolve, reject) => {
    console.log(1)
    let xhr = new XMLHttpRequest()
    xhr.onreadystatechange = function() {
        if(xhr.readyState==4){
            if(xhr.status==200)
                resolve(xhr.responseText)
            else
                reject(new Error(xhr.statusText))
        }
    }
    xhr.open("GET","test.txt",true)
    xhr.send()
})
console.log(2)
promise.then(response => {
    console.log(response)
}).catch(error => {
    console.log(error)
})

在上面的代码中,创建Promise实例时提供的函数会立即执行,而在对于这个函数,Promise提供了俩个参数,分别是resolvereject,用于给内部代码改变当前Promise实例的状态。
如果代码正常执行,只需调用传进来的resolve函数。如果有需要传出去的内容,作为resolve函数的参数即可,如上文代码中response即为传出去的xhr.responseText
而如果发生了错误,就该调用reject
PS:在调用完resolvereject之后,该promise的实例的状态并不会立即改变,而是会将其放入事件队列中,轮到该事件时状态才会改变。
在创建完promise实例后,如上文所述的行为一样,http请求尚未完成。接下来我们就该指定该异步事件完成后该执行的代码(即上文的回调函数)
Promise提供两个api用于处理结果,thencatch,分别用于处理fulfilled状态与rejected状态的。在指定了处理函数后,同样的,在promise状态发生改变后会将其放入到事件队列中,轮到其时再执行相应的代码。
另外一点需要提到的是,Promise支持链式调用。调用thencatch后会返回一个Promise实例。但注意thencatch的行为是不同的
对于then而言,传递到参数里面的是上一个then里面所return的东西。如果它是一个Promise实例,那么下一个then会在该Promise变为fulfilled状态后执行,而函数里面的得到的就相对应的变成了resolve里面的参数。利用这一个特性我们可以以另一种更加优雅的方式去处理上文提到的那个问题。

function getUrl(url){
    let promise = new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest()
    xhr.onreadystatechange = function() {
        if(xhr.readyState==4){
            if(xhr.status==200)
                resolve(xhr.responseText)
            else
                reject(new Error(xhr.statusText))
        }
    }
    xhr.open("GET",url,true)
    xhr.send()
    })
    return promise
}
getUrl(first_url).then(e => {
    console.log(e)
    return getUrl(second_url)
}).then(e => {
    console.log(e)
    return getUrl(third_url)
}).then(e => {
    console.log(e)
    console.log("finish")
}).catch(error => {
    console.log(error)
})

对于catch而言,它的行为与then不同。当某个地方的promise的状态改为rejected时,将会从那个promise实例开始往链后面找,直到找到第一个catch,再将其作为rejected的处理函数。如上面的代码,前三个promise如果出现了rejected,将立即停止后面的then的执行,并交给其后第一个catch进行处理。

此外,Promise还提供了两个非常有用的静态方法,分别是Promise.all()Promise.race()
他们都接受一个包含promise实例的数组作为参数,并在调用后返回一个promise实例。而他们的行为有所不同,Promise.all()仅在传给他的所有的promise实例状态都改为fullfilled才会将状态改为fullfilled,而Promise.race()只要有一个promise实例状态改为fullfilled就会变为fullfilled
例如如果我们要拉取100个url,就可以创建100个promise,达到了100并发请求的效果,然后通过Promise.all()就可以方便的等待他们都执行完后统一进行处理。

0x03 async/await

这是ES2017才引入的新的语法糖,他让异步操作变得更为方便,使得异步操作写起来就像同步代码一样,同时又保留其本身的特性。
使用起来也非常简单。对于一个函数,如果在其声明前添加async标识,就意味着这个函数内部含有异步操作,并且执行器将会对其进行一定的改造:
1、允许其内部使用await
2、对于其return的东西,将会自动对其包装成一个Promise实例,当然对于return的东西的类型不同会有不同的行为,例如如果是非promise实例就直接封装,如果是promise实例或者是有then方法的对象,就将其变为等价的promise返回,与上文链式调用中提到的一致。说白了,实际上是将return的东西加一层Promise.resolve(value_to_return),具体可看这个api的作用,便于理解。
3、对于函数内部抛出的未捕获的异常或者await后面的promise对象状态变为rejected状态且其链上没有catch函数,该函数将会终止运行,并且其返回的promise对象的状态将会改为rejected

await

await这个语法糖也很有趣。await后面跟的是一个promise实例(或者说,同上文一样,非promise实例会调用Promise.resolve())。实际上await的作用是"等待"其后的promise实例状态改变为fullfilled,再将其内部的resolve里面的参数提取出来,并作为整体的一个返回值。
下面举一个例子:

function getUrlPromise(url){
    let promise = new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest()
    xhr.onreadystatechange = function() {
        if(xhr.readyState==4){
            if(xhr.status==200)
                resolve(xhr.responseText)
            else
                reject(new Error(xhr.statusText))
        }
    }
    xhr.open("GET",url,true)
    xhr.send()
    })
    return promise
}
async function getUrlsConcurrent(urls) {
	let promises = urls.map(e => {
		return getUrlPromise(e)
	}
	return Promise.all(promises)
}
async function getUrlsOneByOne(urls){
	let contents = []
	for(let i = 0;i < urls.length; i++)
	{
		contents.push(await getUrlPromise(urls[i])
	}
	return contents;

}
(async () => {
	let contents = await getUrlsConcurrent(["https://www.baidu.com/", "https://google.com/"])
	console.log(contents)

})()

在这个例子中,我们定义了三个函数,其中getUrlPromise并没有使用async,而是返回了一个promise实例,而getUrlsConcurrentgetUrlsOneByOneasync函数,内部使用了await,但是其后跟的是返回promise的一个函数。如getUrlsOneByOne中,每个await的地方都会等待后面的promise执行完成后才会继续执行下去,对于程序员而言这段代码是阻塞式运行的,但实际上他还是异步执行的。多亏了这些语法糖使得代码更为直观。这个函数的行为正如名字所言,将一系列请求依次执行。
而对于getUrlsConcurrent,函数中先是生成了所有的promise实例(这里并没有await所以并不会"阻塞"),再来个Promise.all(),行为就是一次性发出所有请求。
再在最后一个函数里(由于不能直接使用await所以套了一层闭包),这个await是对一个async函数使用的,实际上和await对promise实例使用时一样的。
在具体执行机制中,对于async函数,在执行时还是会立即执行,但是一旦遇到await操作就会将之后的代码封装成一个事件,扔进事件队列里,等待事件完成时"唤醒"继续执行。当然实际处理可能不是这样,但可以直接这样理解。

总结

由此可以看出,随着语言的发展,一些以前实现起来不方便的东西现在就变得非常方便了。这个异步问题的解决是一步一步慢慢来的,从一开始的回调事件再到promise再到async/await(其实在这之前还有一个generator函数,本人认为直接将async/await理解为对promise的封装更易于理解,所以这里就省略了。具体可看阮一峰老师的es6入门教程)。而于此同时javascript由于语言本身的特性导致他在I/O密集型的应用中(如web服务器)会有极高的效率,所以其在后端领域也有了一席之地(nodejs)。可以说,ES6是js语言的一个分水岭,在此之后js是一门非常强大的编程语言。
还有一个问题,就是js是单线程执行的,可能会导致无法在多核机器上发挥全部的性能。实际上这个问题正在被解决(或者说已经),在浏览器端有web worker,可以充分利用多核资源,在后端有node的cluster,都对这个问题有了一定的解决。

参考资料

JavaScript 运行机制详解:再谈Event Loop http://www.ruanyifeng.com/blog/2014/10/event-loop.html
Concurrency model and Event Loop - MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
ECMAScript 6 入门 http://es6.ruanyifeng.com/
art of node https://github.com/maxogden/art-of-node

浅谈Javascript异步
Share this