浅谈Generator函数的异步应用之async函数

1.异步编程的终极解决方案

    前文结尾时提到,async/await是异步编程的'终极'解决方案,而终极二字就体现在,使用async/await来操作异步无论是逻辑上还是语义上都与同步操作无限接近(当然只是形式上像,没有改变异步的本质,后面会解释)。
    先来看一下之前使用Generator函数控制异步流程的代码

function* gen() {
  const res1 = yield promisify_readFile("./text1.txt");
  console.log(res1.toString());
  const res2 = yield promisify_readFile("./text2.txt");
  console.log(res2.toString());
}
co(gen);

    下面使用async/await实现

async function asyncReadFile() {
  const res1 = await promisify_readFile("./text1.txt");
  console.log(res1.toString());
  const res2 = await promisify_readFile("./text2.txt");
  console.log(res2.toString());
}
asyncReadFile()

    可以看到,从形式上看使用async/await进行异步流程处理无需执行器,函数可以像普通函数一样执行,这意味着async函数内置了Generator函数的执行器。从语义上看,async关键字表示函数内部有异步操作,await关键字表示要等待异步操作执行完毕,相比于Generator函数用*声明以及yield表达式划分状态要更加友好。
    下面具体介绍async函数和await关键字的特点。

2.async函数和await关键字的特点
2.1 async函数返回值

    async函数返回的是Promise对象,因此可以为async函数指定then,catch等方法。

asyncReadFile().then(() => {
  console.log("end");
});

    既然async函数返回的是Promise对象,那其结果和状态由什么决定呢

  • 当async函数内部的return有返回值时,该参数会成为then方法成功回调的参数(即Promise的结果值),状态变为成功。
const promisifyTimeOut = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve('timeOut')
    }, 500);
  })
}
const asyncTimeOut = async () => {
  const res = await promisifyTimeOut()
  return res
};
asyncTimeOut().then(
  (res) => {
    console.log('success' + res);
  },
  (r) => {
    console.log('err' + r);
  }
);
//success timeOut
  • 当async函数内部抛出错误时,状态会立即变为失败,并执行then方法的失败回调或catch方法。
const asyncTimeOut = async () => {
  const res = await promisifyTimeOut()
  throw res
};
asyncTimeOut().then(
  (res) => {
    console.log('success' + res);
  },
  (r) => {
    console.log('err' + r);
  }
);
// err timeOut

    利用这一点可以,我们可以进行对async函数的错误处理,后面会介绍。

2.2 await关键字的特点
  • await命令只能用在async函数之中,用在普通函数中会报错。
  • await命令后面如果是一个 Promise 对象,返回该Promise 对象的结果值,如果不是 Promise 对象,就直接返回对应的值 。
(async function(){
    const res1 = await Promise.resolve('foo')
    console.log(res1)
    const res2 = await 'bar'
    console.log(res2)
})()
// foo
// bar
3.async函数的错误处理

    前面提到,async函数内部抛出错误时,其状态会立即变为失败并执行失败回调(假设指定了失败回调)。因此任何一个await关键字后面的Promise状态变为rejected都会导致async函数立即中断执行。

const promisifyTimeOut = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject("some err");
    }, 500);
  });
};
const asyncTimeOut = async () => {
  await promisifyTimeOut();
  console.log("foo");
};
asyncTimeOut().catch((r) => console.log(r));
// some err

    上面代码,await后的异步抛出错误,async函数中断执行导致foo没有被打印。如果不想让async函数内部一抛出错误就终止执行,可以将可能抛出错误的Promise包在try...catch代码块中 ,或者为可能抛出错误的Promise指定失败回调(指定then方法或catch方法),下面以try...catch为例演示。

const asyncTimeOut = async () => {
  try {
    await promisifyTimeOut()
  } catch (error) {
    console.log(error)
  }
  console.log("foo");
};
asyncTimeOut().catch((r) => console.log(r))
// some err
// foo

    如果使用上述两种方法进行错误处理,则async函数指定的失败回调将不生效(假设不在catch语句或Promise失败回调中将错误抛出)。另外,多个await语句可以一起包在try...catch中进行统一错误处理。

4.async函数的实现原理

    其实,经过前面对co模块的讨论,以及上面对async函数特点的介绍,我们可以知道,async/await就是Generator函数的语法糖,我们只需根据其特点进行封装,具体如下。

  • async函数内置Generator函数执行器。
  • async函数返回Promise,要等内部所有Promise执行完后再改变状态,函数内部抛出错误,状态立即变为rejected。

    我们假设async的内置执行器叫做spawn函数,那么async函数的结构就是这样的

const async = (gen) => {
  return () => {
    return spawn(gen);
  };
};

    接下来实现执行器,其原理与前面讨论的co模块基本一致

function spawn(genF) {
  return new Promise(function (resolve, reject) {
    const gen = genF();
    function step(data) {
      let res;
      try {
        res = gen.next(data);
      } catch (e) {
        // 内部抛出错误 状态变为rejet
        return reject(e);
      }
      if (res.done) {
        return resolve(res.value);
      }
      // 为异步指定成功/失败回调 成功则继续执行 失败则立即rejected
      Promise.resolve(res.value).then(step, (r) => reject(r));
    }
    step();
  });
}

下面简单测试一下

const promisify = (data) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(data);
    }, 300);
  });
};

function* testGen() {
  const res1 = yield promisify(1);
  console.log(res1);
  const res2 = yield promisify(2);
  console.log(res2);
  return res2;
}

const async = (gen) => {
  return () => {
    return spawn(gen);
  };
};
// 得到async函数
const asyncFoo = async(testGen);
// 得到async函数的执行结果
const res = asyncFoo();
setTimeout(() => {
  console.log(res);
}, 1000);
// 1
// 2
// Promise { 2 }
5.async函数与执行环境栈

    在前面对JavaScript执行上下文的讨论时我们知道,JavaScript引擎在执行代码之前, 会创建一个执行环境栈,之后创建全局执行上下文并将它压入栈中作为栈底。每遇到一个函数执行时,都会为该函数创建执行上下文,并将其推入执行环境栈中,形成一个由执行上下文构成的堆栈(context stack)。每个上下文都有一个与之相关联的变量对象,包含了当前上下文的变量,函数,形参等。栈是“后进先出”的数据结构,因此最后产生的上下文环境首先执行完成并出栈,然后再执行它下层的上下文,栈底永远是全局上下文,当浏览器窗口关闭,全局上下文才会出栈。
    Generator函数不是这样,执行Generator函数产生的上下文,遇到yield命令时,会暂时退出堆栈,但是并不消失,变量对象里面的所有变量和对象会冻结在当前状态。等到执行next命令时,执行上下文会重新加入执行环境栈,冻结的变量和对象恢复执行。而async函数是Generator函数的语法糖,因此他也有一样的特性,即async 函数可以保留运行堆栈。
    下面用一个例子进行对比说明

const timeOut = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, 500);
  });
};
(function () {
  for (let i = 0; i < 3; i++) {
    timeOut().then(() => {
      console.log(i);
    });
  }
  console.log("end");
})();
// end
// 0 1 2

    上面代码会先打印end 之后012几乎同时打印,原因不难分析,由于promise.then方法不会将当前上下文冻结,因此循环的进行不受影响,而因为then方法中的回调会异步执行,因此三个log语句会几乎同时被加入任务队列,最终造成上述的执行结果。
    下面用async/await重写上面代码

(async function () {
  for (let i = 0; i < 3; i++) {
    await timeOut();
    console.log(i);
  }
  console.log("end");
})();
// 0 1 2 end

    上面代码会依次打印0 1 2 end。
    分析原因,由于async 函数可以保留当前上下文环境,当遇到await命令,当前上下文的所有状态都被冻结,包括for循环在内的所有代码都会暂停执行,因此造成上述执行结果。
    其实,这条特性可以理解为, await命令后面的所有代码都会进入异步任务队列。 await相当于then的语法糖,其后面的代码都进入了promise.then的回调函数中,会进入任务队列异步执行。
    利用这一点,我们可以实现休眠器。

function sleep(interval) {
  return new Promise((resolve) => {
    setTimeout(resolve, interval);
  });
}
// 用法
async function Async(timeOut) {
  await sleep(timeOut);
  console.log("foo!");
}
Async(1000);
// 一秒后打印foo!

    关于上述特性,有两点需要说明

    1.await语句冻结的只是async函数的上下文,即async函数后面的代码执行不会被阻塞。这也就说明,async/await只是写起来像同步代码,异步的本质没有改变。

async function Async(timeOut) {
  await sleep(timeOut);
  console.log("foo!");
}
Async(0)
console.log('end!')
// end!
// foo!

    2. 上面说到,遇到await关键字,其后所有代码都将被冻结,因此await语句下面的异步任务也会等到await语句的异步结束后再执行。这点对于具有依赖关系的异步(继发关系)的处理是非常友好的。但同样的,如果两个异步没有继发关系,则尽量不要这么写,因为会造成阻塞。可以使用Promise.all()等方式让他们并发执行,而不是继发执行。

function sleep(interval) {
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("foo!");
      resolve();
    }, interval);
  });
}
async function Async() {
  await Promise.all([sleep(500), sleep(500)]);
  console.log("end");
}
Async();
// 两个异步并发执行 几乎同时打印foo!

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):