1 什么是异步?
异步(async)是对应同步(sync)来说的,想理解异步就要先说同步。同步执行直观的理解就是代码顺序就是执行顺序,例如:
console.log("A")
console.log("B")
console.log("C")
代码的执行结果,是 A,B,C。这样,就是同步代码,执行顺序与编写顺序保持一致。
对比下面的异步代码:
console.log("A")
// setTimeout 就是异步代码
setTimeout(()=>{
console.log("B")
}, 0)
console.log("C")
代码的执行结果,是 A,C,B。编写顺序上,我们先输出的B,但是执行结果却是先输出的C。本例中,setTimeout
函数就是异步执行,也就是当执行的到 setTimeout
时,内部的代码不会立即执行,而是将其放在异步执行队列中等待执行。同时 setTimeout
后边的代码就开始执行,也就是说输出C,没有等到输出B执行完,就开始执行了。
本例中,setTimeout
函数就是异步执行。
2 为什么需要异步执行?
异步执行要解决的问题是有些非CPU密集型程序会导致CPU闲置的问题。
再看上面的例子,我将 setTimeout
的间隔调大到1000ms,再思考:
console.log("A")
// setTimeout 就是异步代码
setTimeout(()=>{
console.log("B")
}, 1000)
console.log("C")
若输出B不是异步执行的而是同步执行的,那就意味着必须要等待1000ms,B输出完毕后,C才会输出。但是问题是,C的输出与输出B没有任何关系,同时在这1000ms内,我们除了等待,什么都没做,这就是所说的CPU被闲置。可见若没有异步执行,CPU的性能浪费是很严重的。
最常见的非CPU密集型操作,就是IO操作,无论是磁盘IO还是网络IO尤其是网络IO。在等待网络传输(或者磁盘读取)的过程中,CPU一直处于闲置状态,这个时候,就必须要让CPU运作起来,才可以充分发挥计算机性能。所以网络操作一般都是异步操作。
异步执行还有一个问题,就是若某些操作需要依赖于异步操作的结果。那如何保证这些操作的执行时机呢? JS提供了多种语法方案供我们使用,例如:事件驱动,Promise,Generator,async,await等。
3 JS的语法单线程
一个语法的问题需要注意,就是JS的语法层面是单线程的,就是不能同时执行。这样就带来了一个问题,就是既然是单线程的,那异步的代码是如何执行的呢?
一定要注意,单线程指的是JS的语法层面是单线程的,而不是说浏览器或Node在执行JS代码时是单线程的。
看下面的例子,说明语法层面的单线程:
console.log("A AT ", (new Date()).toLocaleTimeString())
// setTimeout 异步,1000ms(1s)后执行输出B和时间
setTimeout(()=>{
console.log("B ", (new Date()).toLocaleTimeString())
}, 1000)
// 大循环,循环很多次,为了保证循环时间大于1000ms
for (let i = 0, num=999999999; i <= num; ++ i) {
if (0 == i) {
console.log("Loop first Run at ", (new Date()).toLocaleTimeString())
}
if (num == i) {
console.log("Loop last Run at ", (new Date()).toLocaleTimeString())
}
}
结果:
A AT 21:57:58
Loop first Run at 21:57:58
Loop last Run at 21:58:01
B 21:58:01
上面的例子中,我们做了一个定时器,异步执行在1000ms后,之后执行一个for循环,很多次循环,执行时间大于了1000ms。从执行结果上看,当达到了1000ms时,并没有立即执行输出B,而是要等到for循环执行完毕后,才会执行已经到达时间的输出B。
这个语法现象,就是JS的语法单线程,就是执行着for的任务,不会中间停止,而是需要for任务执行完毕后,才会考虑接下来的任务!
由于上面的执行机制,出现了下面的循环的写法,目的是拆解多次的for循环,注意是使用 setTimeout(func, 0)
来实现的:
console.log("A AT ", (new Date()).toLocaleTimeString())
// setTimeout 就是异步代码
setTimeout(()=>{
console.log("B ", (new Date()).toLocaleTimeString())
}, 1000)
let i = 0
function userFor() {
let num = 999
if (i > num) {
return
}
if (0 == i) {
console.log("Loop first Run at ", (new Date()).toLocaleTimeString())
}
if (num == i) {
console.log("Loop last Run at ", (new Date()).toLocaleTimeString())
}
++i
setTimeout(()=>{
userFor()
}, 0)
}
userFor()
结果:
A AT 22:11:10
Loop first Run at 22:11:10
B 22:11:11
Loop last Run at 22:11:12
从结果上看,1000ms的计时器在时间到达时,被理立即执行,而没有被下面的循环阻塞到。因为下面的循环也是利用settimeout异步实现的。但是改代码的循环性能却大大降低,除非有非常明确的原因,否则不建议使用!
4 回调函数
异步执行的代码,最常见的一个问题就是当异步代码执行完毕后,才需要执行的某些代码如何处理?例如,当网络请求完成后,再执行渲染任务。这个网络请求就是异步执行的,而渲染任务就必须要是网络请求成功后才能执行。如何保证呢? 最经典的方式就是回调函数了。就是告诉异步代码,在执行完成后,应该去执行哪些代码,这些要被执行的代码,被定义成一个函数作为参数,称之为回调函数,callback。
以nodejs的异步文件操作为例,示例代码:
const fs = require('fs');
fs.readFile('./data', 'utf8', (err, content) => {
if (err) throw err;
console.log(content);
});
console.log('after readFile')
fs.readFile() 就是异步代码,其中 (err, content) => {} 就是异步的回调函数,在文件内容读取完毕后执行! 后边输出了一行 after readFile,先于文件内容输出吗?
回调函数是一个非常通用,好用,经典的异步解决方案。但也有典型问题,主要有两个:
非常容易出现callback hell回调地狱,指的是多层的毁掉嵌套,导致代码维护、重构成本增加。
回调函数中的异常,在外部不容易被捕获。因为异步回调函数的执行,是在其他的执行tick(执行周期)完成的。
回调函数还是非常好用的,即使有问题,个人觉着也就算有瑕疵而已,无伤大雅!
5 Promise
Promise 也是异步编程的一种解决方案,与回调函数相比,语法上合理些。最早由社区实现,ES6(ES2015)将其写进了语言标准,原生提供了Promise对象。因此从功能上说与回调函数方案解决的是同类问题,只是语法形式不同,从而带来了不同的语法体验!
基础语法结构如下,先得到Promise对象,再完成异步后的操作:
let promise = new Promise((resolve, reject) => {
// 异步执行代码
if (任务成功) {
resolve()
} else {
reject()
}
})
// 异步代码执行完毕,后执行的操作
promise
.then(() => {
// 任务成功执行的代码
})
.catch(() => {
// 任务失败执行的代码
})
Promise,承诺的意思,语义上指的是保证一个异步任务执行完后,告知你执行结果,成功或失败。我们使用Promise方案来实现nodejs中异步读取文件的操作,在NodeJS中提供了Promise的语法,示例如下:
const fs = require('fs');
const fsPromises = fs.promises;
fsPromises.readFile('./data', 'utf8')
.then((content) => {
console.log(content)
})
fs.promise 在当前版本(v11.10)中还是:Stability: 1 - Experimental 状态,不要用在生产环境
fsPromises.readFile 会返回一个Promise对象,之后使用.then()处理成功的状况,或者.catch()处理失败的状况。 上面的代码中,.then()中的函数,就相当于回调函数。
Promise 的语法会使嵌套异步代码变得扁平化,一般会说“使用同步的语法编写异步程序”,下面对比回调和Promise的语法,加以说明:
callback演示:
fs.readFile('./file-1', 'utf8', (err, content_1) => {
console.log(content_1)
fs.readFile('./file-2', 'utf8', (err, content_2) => {
console.log(content_2)
fs.readFile('./file-3', 'utf8', (err, content_3) => {
console.log(content_3)
})
})
})
Promise演示:
fsPromises.readFile('./file-1', 'utf8')
.then((content_1) => {
console.log(content_1)
return fsPromises.readFile('./file-2', 'utf8')
})
.then((content_2) => {
console.log(content_2)
return fsPromises.readFile('./file-3', 'utf8')
})
.then((content_3) => {
console.log(content_3)
})
以上功能一致的两段代码,callback需要多层嵌套,而promise需要连续的.then()即可。对比可见Promise使得异步代码更加扁平了,更加同步化了。
除了解决了大量的嵌套问题,Promise在处理异常错误时也更有语法优势,.catch(func) 结构就是在Promise异步执行失败(有错误)时,执行的代码,结构上与.then()是同级的。这样Promise解决方案就在实现异步的同时,解决了回调函数的典型问题,很好用的。
关于Promise的内容,可以移步 http://js.hellokang.net/promise.html 获取更多信息。
6 async-await
在 es7(ES2017) 中,提供了一种新的异步语法 async,该语法的目的就是定义一个异步执行的函数,内部实现是对Promise的封装,演示如下:
const fs = require('fs')
async function f() {
// 同步读取文件内容
return fs.readFileSync('./data', 'utf8')
}
f().then(v => console.log(v))
console.log('after run')
// 执行结果
after run
hello Kang!(./data文件内容)
分析以上代码,定义的函数 f,由于使用了 async 关键字,使得该函数变为了一个异步执行的函数。注意 f() 函数为异步函数与函数主体代码没有任何关系,本例中 fs.readFileSync
是一个同步方法。
在 async 异步函数被调用时,会返回一个Promise对象,而异步函数 f() 的返回值,就是Promise中resolve()的参数。因此后续可以使用 .then() 继续处理异步执行完毕后的代码。重要一点是f()函数为异步,是因为返回了Promise对象。而f()本身并没有异步!
再看执行结果,先输出的 after run,可见f()的调用是异步的。之后输出了文件内容,可见f()函数的返回值传到了then中。
既然是对Promise的封装,上面的代码可以改写为Promise的版本:
const fs = require('fs')
let promise = new Promise((resolve, reject)=>{
resolve(fs.readFileSync('./data', 'utf8'))
})
promise.then(v => console.log(v))
console.log('after run')
// 执行结果
after run
hello Kang!
对比这个Promise语法,使用async可以Promise的传参过程,而且async更加直观。
说完 async,再说 await,async wait,等待,用在async异步函数内。指的是await在等待异步调用的结果,可以用于在多个连续异步调用间传递数据。await通常后需要一个Promise对象,await可以获取该异步的结果。演示如下:
const fs = require('fs')
async function fr() {
c1 = await new Promise((resolve, reject) => {
// 异步执行代码
fs.readFile('./file-1', 'utf8', (e, content) => {
resolve(content)
})
})
console.log(c1)
return new Promise((resolve, reject) => {
// 异步执行代码
fs.readFile(c1, 'utf8', (e, content) => {
resolve(content)
})
})
}
fr().then((c)=>{
console.log(c)
})
console.log('after run')
// 输出结果
after run
./file-2
file 2 content
分析以上示例代码,fr() 有由两个异步请求实现,第一个异步请求的结果,是第二个请求的文件名。那就意味着需要在第一个异步请求成功后,再执行第二个异步请求。通常的做法,就是在异步请求1中的成功回调中,去调用异步请求2,这就面临的典型的回调地狱。使用 async+await 后,await用来等待异步请求1的结果,再作为参数给异步请求2来使用,而且不需要在异步1的成功回调内进行调用,两个异步请求在语法上是并行的,没有嵌套关系,比之前的Promise语法还要同步化!额外的,await 不就是把这个Promise变成阻塞执行?
关于 async+await 的更完整的语法,可移步:http://js.hellokang.net/async.html 和 http://js.hellokang.net/await.html。
7 Generator,生成器
Generator生成器之所以在异步执行中被讨论,是因为yield方法可以暂停(启动)代码的执行,可以通过.next()启动固定位置的代码,以达到多个异步顺序执行的目的。示例如下:
function* file_read() {
yield fs.readFile('./file-1', 'utf8', (err, content_1) => {
console.log(content_1)
fr.next()
})
yield fs.readFile('./file-2', 'utf8', (err, content_2) => {
console.log(content_2)
})
}
fr = file_read()
fr.next()
在异步代码前使用了yield,就可以暂停了异步的执行,需要的时候(第一个异步执行成功时),再启动。若没有这个语法,需要在第一个异步回调中嵌入第二个异步,又是回调地狱。
一个基于generator的库co.js,就是该类的实现。可以参考:https://www.npmjs.com/package/co
个人认为生成器不应该在异步中讨论,生成器主要是用来得到迭代器对象的,这个语法有点可以异步了。
8 EventLoop 事件循环
Js 的异步执行(并发执行)依赖于事件循环模式。JS是单线程,指的是其主线程指令为非并发执行的。一旦执行到异步代码(例如,setTimetout,AJAX,异步IO),异步任务不会阻塞主线程,而是先去注册处理函数再交由其他线程执行该异步代码。当其他线程执行完毕该异步任务后,会将该异步代码对应的处理函数加入到某个消息队列中(一般称之为:EventQueue)。当前的主线程代码执行完毕后,会从该消息队列中获取需要执行的任务,也就是异步代码注册的回到函数。
之所以叫事件循环,可以理解为一下的伪代码:
while (eventQueue.waitForMessage()) {
eventQueue.processNextMessage();
}
eventQueue.waitForMessage() 为同步状态,等待消息到达。
小结下就是:JS在执行时是的主线程是同步的,通过异步代码可以执行并发任务,但是并发任务由非主线程执行,因此不会干扰到主线程的执行,同时JS运行时(runtime)会不断监听事件消息队列,若存在可用的事件就去执行(但一定注意,不是事件一存在就执行,而是要等到主线程的当前任务执行结束,例如本文中的大的for)。
关于event事件,JS还有更细致的划分,通常分为macro-tast(宏任务)和micro-tast(微任务),使用的eventQueue和执行顺序也不尽相同,此处不予讨论。可以移步 http://js.hellokang.net/eventloop.html。
9 总结
异步执行,通常需要解决3个问题:
- 什么调用会异步执行? 例如:setTimeout,异步IO(AJAX,异步文件读写,fs.readFile)这些会异步执行。
- 异步执行后如何处理回调? 例如:传参callback,promise.then,async解决的是如何更好的执行回调函数的问题。
- 多个相关的异步如何保证顺序执行? 例如:回调地狱(回调中继续回调),promise.then.then…,await等都是解决该问题。
如果可以将异步执行从以上三个角度理解,那么各种语法解决的问题就显而易见啦。
请关注微信公众号:小韩说课,获取最新内容