JavaScript中的异步编程概述

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.htmlhttp://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等都是解决该问题。

如果可以将异步执行从以上三个角度理解,那么各种语法解决的问题就显而易见啦。

请关注微信公众号:小韩说课,获取最新内容
小韩说课

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值