异步以及异步编程解决方案

301 阅读12分钟

什么是异步

所谓”异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。

相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务。

例如:

setTimeout(function(){
	XXX;
},1000)
console.log("complete!");

setTimeout就是一个异步任务,任务分为两个部分,一个部分是延迟1000ms,另一个部分是执行XXX。setTimeout是先执行1000ms的延迟,然后再延迟期间将执行权交给了console.log,输出了“complete!”之后,再去执行setTimeout任务的第二部分,执行XXX。

如果是同步的话,它会等到1000ms过去,执行XXX之后,再输出”complete!”,显然JavaScript不想这样浪费时间。

异步编程方案

异步编程也有多种解决方案,其演变过程是:回调函数 —> Promise —> Generator —> async/await,每个新的演变都解决了之前的一些痛点。

回调函数

所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。callback的意思就是“重新调用”。

读取文件进行处理,是这样写的:

fs.readFile('/etc/passwd', 'utf-8', function (err, data) {
  if (err) throw err;
  console.log(data);
});

上面代码中,readFile函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd这个文件以后,回调函数才会执行。

也就是说在执行readFile的时候,执行权交给了主线程执行栈的同步任务,同步任务执行完毕后才会执行这个readFile的回调。

回调函数的缺点

回调函数会导致回调地狱。

比如现在有多个异步任务,且任务有依赖关系(一个任务需要拿到另一个任务成功后的结果才能开始执行)的时候,回调的方式写出来的代码就会像这样:

getData1(data1 => {
  getData2(data1, data2 => {
    getData3(data2, data3 => {
      getData4(data3, data4 => {
        getData5(data4, data5 => {
          // 终于取到data5了
        })
      })
    })
  })
})

这种多层嵌套的结构就是回调地狱,这种情况下代码的可读性很差。

Promise

因此就出现了PromisePromise的关键点就是将回调函数的嵌套改为了链式调用。

我们使用new Promise去创建一个Promise实例,这个Promise实例会传入一个函数作为参数,函数又有两个函数作为参数,分别是:resolvereject

执行resolve函数,Promise实例的状态会变为fulfilled,后续就会去执行.then回调函数

执行reject函数,Promise实例的状态会变为rejected,后续就会去执行.catch回调函数,或者.then的第二个回调函数。

3种状态

Promise实例有三种状态:

  • pending(进行中)
  • fulfilled(已成功)
  • rejected(已失败) fulfilledrejected这两种状态又归为已完成状态。

resolve和reject

调用resolvereject能将分别将promise实例的状态变成fulfilledrejected,只有状态变成已完成(即fulfilledrejected之一),才能触发状态的回调。

resolvereject两种函数只会执行一种,执行了其中一个之后就不会执行另一个了

Promise至多只能有一个决议值(完成或拒绝)。如果你没有用任何值显式决议,那么这个值就是undefined,这是js常见的处理方式。

如果使用多个参数调用resolve(..)或者reject(..),第一个参数之后的所有参数都会被默默忽略。

因此,如果要传递多个值,你就必须要把它们封装在单个值中传递,比如通过一个数组或对象。

基本结构

let p = new Promise((resolve,reject)=>{
    //做一些事情
    //然后在某些条件下resolve,或者reject(以下代码)
    if(/* 条件随便写 */){
       	 resolve()
     } else{
         reject()
     }
})

p.then(()=>{
    //如果p的状态被resolve了,就进入这里
},()=>{
    //如果p的状态被reject
})

简单来说,这种.then.then.then的编码方式,就是Promise

它的异步体现在,new Promise部分是立即执行的,那执行完毕后,就会将执行权交给主线程执行栈的同步任务,同步任务执行完毕后才会去执行.then回调。这里涉及JavaScript事件执行机制Eventloop

吞掉错误或异常

var p = new Promise(function(resolve, reject) {
    foo.bar(); // foo未定义,这里会出错
    resolve(42); // 永远不会到达这里
});
p.then(function fulfilled() {
    // 永远不会到这里
}, function rejected() {
    // err将会是一个来自foo.bar()这一行的TypeError异常对象
})

foo.bar()中发生js错误导致了Promise拒绝,你可以捕捉并对其作出响应。

但如果是在then(..)注册的回调中出现js异常错误呢?以上这种写法是没有办法捕捉到错误的:

var p = new Promise(function(resolve, reject) {
    resolve(42);
});
p.then(function fulfilled(msg) {
    foo.bar(); // 出错
    console.log(msg); // 永远不会到达这里
}, function rejected(err) {
    // 永远不会到达这里
});

因为.then的第二个function是侦听new Promise中是否有出错的,而不是侦听.then回调这个Promise是否有出错的。所以如果想要侦听这个回调里是否有出错,需要后面再加一个.then用于去侦听p.then这个Promise是否有出错。

而且根据Promise最基本的特征:Promise一旦决议就不可再变。p已经完成为值42,所以之后查看p的决议时,并不能因为出错就把p变为一个拒绝。

Promise实现多个异步任务顺序执行

如果Promise.then回调内的函数执行的是同步任务,那么在函数内直接用return将结果返回,下一个.then回调能够正常获取return的值:

var p = Promise.resolve(21);
p.then(function(v) {
    console.log(v); // 21
    return v*2;
})
// 这里是链接的promise
.then(function(v) {
    console.log(v); // 42
});

但如果第一个.then回调的函数内执行的是异步任务,想让第二个.then回调等待第一个.then回调执行完后再执行则不能直接使用return,需要返回一个new Promise,包裹住异步任务来实现等待:

var result=new Promise(function(resolve,reject){
	setTimeout(function(){
		resolve("one");
	},3000)
}).then(function(data){
	console.log(data);
	return new Promise(function(resolve,reject){
		setTimeout(function(){
			resolve("two");
		},3000)
	})
}).then(function(data){
	console.log(data);
})

Promise实现多任务并行(即A、B任务都执行完毕了才能执行C任务):

var p1=new Promise((resolve,reject)=>{
    resolve('hello');
})
p1.then(result=>result);

var p2=new Promise((resolve,reject)=>{
    resolve('hi');
})
p1.then(result=>result);

var p=Promise.all([p1,p2]);
p.then(result=>console.log(result));

这里主要用到Promise.all这种结构。Promise.all([p1,p2])接受一个数组作为参数,数组的元素都是Promise实例,只有当数组中所有Promise实例的状态都变为fulfilled的时候,这个Promise.all的实例才会变为fulfilled,才能执行后续的.then操作。

Promise.all相关的还有Promise.race,那Promise.race实例的状态是等于其参数中第一个执行完毕的Promise实例的状态,它有可能是rejected,也有可能是fulfilled

建立可信任的Promise

下面这种情况下,promise p1promise p2的行为是完全一样的:

var p1 = new Promise(function(resolve, reject) {
    resolve(42);
});
var p2 = Promise.resolve(42);

而如果向Promise.resolve(..)传递一个真正的Promise,就只会返回同一个promise

var p1 = Promise.resolve(42);
var p2 = Promise.resolve(p1);
p1 === p2; // true

Promise的缺点

Promise的最大问题是代码冗余:原来的任务被Promise包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。

Generator

Generator函数是ES6提供的一种异步编程解决方案,Generator就能够解决上述Promise代码冗余的问题。它能够以一种类似同步的写法来执行一些异步操作。

  1. 形式上Generator函数是一个普通函数,有两个特征:1)function关键字与函数名之间有个星号; 2)函数内部使用yield表达式,定义不同的内部状态
  2. 执行Generator函数会返回一个遍历器对象
  3. 调用Generator函数后,该函数并不执行,返回的不是函数运行结果,而是一个指向内部状态的指针对象
  4. 必须调用遍历器对象的next方法,使得指针移向下一个状态,输出返回的结果

Generator函数的写法

function* helloWorldGenerator() {
  yield 'hello';
  yield 'world';
  return 'ending';
}

var hw = helloWorldGenerator();
hw.next()
// { value: 'hello', done: false }

hw.next()
// { value: 'world', done: false }

hw.next()
// { value: 'ending', done: true }

hw.next()
// { value: undefined, done: true }

输入和输出

生成器函数虽然是特殊的函数,有新的执行模式,但是它仍然有一些基本的特性没有改变。比如,它仍然可以接受参数(即输入),也能够返回值(即输出)。

生成器提供更强大的内建消息输入输出能力,通过yield和next(..)实现:

function *foo(x) {
    var y = x * (yield);
    return y;
}
var it = foo(6);
// 启动foo(..)
it.next();
var res = it.next(7);
res.value; // 42

第一个it.next()的作用是启动foo生成器,遇到一个yield表达式时暂停,并在本质上要求调用代码为yield表达式提供一个结果值。

接下来调用it.next(7),这一句把值7传回作为被暂停的yield表达式的结果。此时y=6*7=42。所以返回42。

消息是双向传递的——yield..作为一个表达式可以发出消息响应next(..)调用,next(..)也可以向暂停的yield表达式发送值。

function *foo(x) {
    var y = x * (yield "Hello"); // yield一个值
    return y;
}
var it = foo(6);
var res = it.next();
res.value; // "Hello"
res = it.next(7); // 将7传递给暂停的yield表达式
res.value; // 42

yield..next(..)这一对组合起来,在生成器执行过程中构成了一个双向消息传递系统。

我们并没有向第一个next()调用发送值,这是有意为之。只有暂停的yield才能接受这样一个通过next(..)传递的值,而在生成器的起始处我们调用第一个next()时,还没有暂停的yield来接受这样一个值。第一个next()一般是用于启动生成器,获得第一个yield出来的值(如果有的话)。

异步应用

因为yield能够中断执行代码的特性,可以帮助我们来控制异步代码的执行顺序。

例如有两个异步的函数 A 和 B, 并且 B 的参数是 A 的返回值,也就是说,如果 A 没有执行结束,我们不能执行 B。

那这时候我们写一段伪代码:

function* effect() {
  const { param } = yield A();
  const { result } = yield B(param);
  console.table(result);
}
const iterator = effect()
iterator.next()
iterator.next()

co库可以用来每次执行A()/b()的请求结束之后,都会自动执行next()方法。

使用Generator去实现Promise的任务顺序执行

我们再回顾一下Promise版本:

var result=new Promise(function(resolve,reject){
	setTimeout(function(){
		resolve("one");
	},3000)
}).then(function(data){
	console.log(data);
	return new Promise(function(resolve,reject){
		setTimeout(function(){
			resolve("two");
		},3000)
	})
}).then(function(data){
	console.log(data);
})

Generator版本:

function f1() {
  setTimeout(function() {
    g.next("one");  //将参数传给data1
  }, 3000);
}

function f2(data1) {
  console.log("接收到了" + data1);
  setTimeout(function(){
      g.next("two");  //将参数传给data2
  },3000)
}

function* mygenerator() {
  var data1 = yield f1();
  var data2=yield f2(data1);
  console.log(data2);
}

var g = mygenerator();
g.next();

在全局作用域下的g.next()是用于开始迭代器的遍历,执行f1()。当f1()执行到g.next("one")时,第一个yield表达式的值就会被赋值为"one",然后执行f2(data1)。当f2()执行到g.next("two")时,第二个yield表达式的值就会被赋值为"two"

捕获错误

使用try...catch

function* gen(x){
  try {
    var y = yield x + 2;
  } catch (e){
    console.log(e);
  }
  return y;
}

var g = gen(1);
g.next();
g.throw('出错了');
// 出错了

try...catch是无法捕获promise抛出的异常的,promise抛出的异常只能由其.catch回调或者.then的第二个回调捕获。

按理来说try...catch是没有办法捕获异步错误的,而yield暂停使得Generator能够捕获错误。

async和await

async函数就是Generator的语法糖。

形式上的不同:

  1. async函数将Generator函数的星号(*)替换成async
  2. yield替换成await

内置执行器

也就是说async函数的执行,和普通函数一样,只需要一行就可以。不用像Generator函数需要调用next方法才能真正执行。

例如对于一个async函数来说:

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

调用时只需要:

asyncReadFile();

更好的语义

asyncawait比起星号和yield,语义更加清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

返回值是Promise

async函数的返回值是Promise对象,这比Generator函数的返回值是Iterator对象方便多了。你可以用then方法指定下一步的操作。

async函数基本用法

async函数返回一个Promise对象,可以使用then方法添加回调函数。当函数执行的时候,一旦遇到await就会先返回,等到异步操作完成,再接着执行函数体内后面的语句。

async function getStockPriceByName(name) {
  const symbol = await getStockSymbol(name);
  const stockPrice = await getStockPrice(symbol);
  return stockPrice;
}

getStockPriceByName('goog').then(function (result) {
  console.log(result);
});

先执行了第一个await后的getStockSymbol(name)函数;得到了股票的名称symbol后,将symbol传给第二个await后面的getStockPrice(symbol)作为参数;最后返回股票价格stockPrice

async/await错误处理

使用tryawait语句包含起来,如果await后的语句执行错误,则错误会被catch捕获:

run();

async function run() {
    try {
        await Promise.reject(new Error("Oops!"));
    } catch (error) {
        error.message; // "Oops!"
    }
}

image.png

run();

async function run() {
    const v = null;
    try {
        await Promise.resolve("foo");
        v.thisWillThrow;
    } catch (error) {
        // "TypeError: Cannot read property 'thisWillThrow' of null"
        error.message;
    }
}

执行顺序问题

async function async1(){
   console.log('async1 start');
    await async2();
    console.log('async1 end')
}
async function async2(){
    console.log('async2')
}

console.log('script start');
async1();
console.log('script end')

// 输出顺序:script start->async1 start->async2->script end->async1 end

async函数执行的时候,一旦遇到await就会先返回,等到触发的异步操作完成后,再执行函数体后面的语句。可以理解为,是让出了线程,跳出了async函数体。

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}
console.log('script start');
setTimeout(function() {
    console.log('setTimeout');
}, 0)
async1();
new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');

执行顺序为:

image.png

执行async1的时候遇到await先去执行async2,然后跳出了函数体,去执行后续的代码,然后再回到当前async1函数当中执行await后续语句。