跨越时空的等待——async/await解密(一)

569 阅读10分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第3天,点击查看活动详情

这篇文章同样来自于我们团队的啸达同学,讲述了我们常说的“世界上最遥远的距离,是我在try里你在catch里,似乎一直相伴又永远分离”这个故事

观看指南

前言

第一接触异步这个词已经是10年前了,那会是个面试都会考考你异步的原理,要是能用XMLHTTPRequest手写个ajax基本上就能直接进第二轮了吧。到现在,异步的场景和解决方案越来越多了,跟异步相关的知识点也越来越多了。我自认为对异步还是有一定理解的,却也经常会陷入一些不能自圆其说的问题中去...

跨越时空的等待

paralle_1648886869374.png

我们都知道到ES6中新增了async...await...的异步解决方案,你可以这样使用它:

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

也可以这样:

async function f() {
  try {
    await Promise.reject('出错了');
  } catch(e) {
  }
  return await Promise.resolve('hello world');
}

f()
.then(v => console.log(v))

对我而言,新的解决方案有两个明显的特点:

  • async...await...异步解决方案支持我们同步地去执行异步的操作
  • async...await...异步解决方案支持通过try...catch...进行异常捕获

第一点我觉得还好,但是第2个特点确实有点颠覆了。一直以来,在我所认知的知识范围内,try...catch...都只能捕获同步异常。async...await...中的try...catch...,为什么可以捕获到异步的异常?

amazing_1648886567021.png

我立刻搬出了阮一峰老师的ES6教程复习了一把,结果阮老师就给我留下这么一句话:

image_1648890698199.png

时间和空间上的分离

paralle3_1648890786037.png

我读书少,阮老师你说的是平行时空么?

可以先不用理解Generator是什么,阮老师解释的很高级,但是有点虚。时间和空间的分离,好像觉得作者想说什么,但是又说不出的感觉。

按照我自己的方式理解一下时间和空间上的隔离:

node的异步操作都是借助libuv其它线程完成的。正常情况下,等eventloop通知调用栈处理异步回调函数的时候,原调用栈中的函数应该已经执行完了。所以说调用函数和异步逻辑是由完全不同的线程执行的,本质上是没有交集的,我们可以理解为空间上是隔离的。异步回调被触发执行时,调用函数早已执行完了。所以,回调函数和调用函数的执行时间上也是隔离的

怎么隔离的勉强可以解释通,但是async...await...是怎么打破这种隔离,让其中的try...catch...块捕获到异步操作中的异常?在我看来,async...await...好像可以强行拉长try...catch...作用域,让调用函数的生命周期可以无限延长,以至于可以等待直到异步函数执行完成;在此期间,如果异步过程出现异常,调用函数就可以捕捉其异常。但是延长函数的生命周期并等待异步执行结束,这不就相当于是在阻塞线程的执行?阻塞执行——这跟JS的非阻塞的特质又是背道而驰的。

所以,我总觉得调用函数和异步逻辑之间有某种诡异的Connection,说不清道不明。这种诡异实在难以语言形容清楚,好比你和ta(主函数和异步逻辑)就像是在两个时间和空间上隔离的生物,现在时空A中的你捕获到时空B中的ta的异常消息。这就像啥,我尝试用电影中一些桥段做个类比:

《彗星来的那一夜》版

好比你和ta处于两个不同的平行宇宙,只有当彗星擦过地球的时候才有可能使得平行宇宙之间出现交错混乱的状态,这也意味着你有可能见到平行宇宙中的那个ta,并跟ta取得联系。

《蜘蛛侠:平行宇宙》版

奇异博士掌握了空间宝石,才有了切割空间,打开时空之门的能力。你想见到平行宇宙中的ta,就需要有人帮你打开时空之门。

《星际穿越》版

虫洞可以连接不同的时空,甚至让你进入五维空间。在五维空间中,时间对你来说就不是个事了。你看到任意时间的四维空间,并尝试与四维空间中的ta进行信息交互。

所以综上所述,调用函数中的try...catch...块就像宇宙1中的你,异步就像宇宙2中的ta。你想感知到ta(捕获她的讯息),除非你 遇到彗星撞地球 | 你或她认识奇异博士 | 你或她穿越了

现在能理解这事多离谱了吧?

导航1

每天都在用的东西,并不能代表你真的掌握它。如果上述部分也勾起你的疑问,那么就跟我一起接着研究。

  • 为什么try...catch...不能捕获异步异常
  • 简介Promise解决方案
  • 进程、线程、协程,傻傻分不清楚
  • show me your code

为什么try...catch...不能捕获异步异常

上面所有的问题其实都是基于try...catch...不能捕获异步异常的这个前提,想要踏踏实实搞清楚研究问题本质,就要先捋清楚这个前提是怎么来的。

讲到异步就离不开Eventloop,Eventloop的相关知识到处都有。我自认为对这块理解的不够深刻,所以就不在这里班门弄斧了。况且Eventloop也不是本次讲解的重点,所以这里就在网上扒了一些gif。主要是想通过动图勾起大家的一点回忆即可。

下面的动图演示了foobarbaz三个函数的执行过程。同步函数的执行在调用栈中转瞬即逝,异步处理需要借助libuv。比如这个setTimeout是个Web API,它脱离于主线程是由libuv中别的线程负责执行的。执行结束后,会将对应回调函数放到等待队列中,当调用栈空闲后会从等待队列中取出回调函数执行。

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

eventloop_1649041719720.gif

这里,为了讲清楚异常不能被捕获的原因,我把示例代码改一下,模拟异步过程发生了异常。大家可以把执行逻辑再套回刚才的动图逻辑来看一下(这里没有动图了哈,自己脑补一下吧):当setTimeout的回调在Queue排队等待执行的时候,Call Stack中bar就已经执行完了,bar的销毁顺便也终止了try...catch...的捕获域。当主进程开始执行throw new Error()的时候,相当于外层是没有任何捕获机制的,该异常会直接抛出给V8进行处理。

const bar = () => {
    try {
      setTimeout(() => {
        throw new Error();
      }, 500);
    } catch (e) {
      // catch error.. doesn't work
    }
};

那么上述异常应该如果捕获?

哪里抛的哪里捕获。这就导致我们在写代码的时候处处小心,时刻留心以防哪里没有考虑到异常场景。

const bar = () => {
  setTimeout(() => {
    try {
      throw new Error();
    } catch (e) {
      // catch error in cb
    }
  }, 500);

导航2

基于回调的异常捕获太过烧脑,稍不留神就会让错误外逃。还好后来出现了Promise,大家逐渐形成一套统一的异步处理(异常处理)规范。

  • 为什么try...catch...不能捕获异步异常
  • 简介Promise解决方案
  • 进程、线程、协程,傻傻分不清楚
  • show me your code

简介Promise解决方案

Promise的原理这里也不过多解释,一句话,它本质就是一个状态管理机,同时又提供resolve和reject两个开关。resolve负责将状态机的状态调整成Fulfilled,reject将状态处理成Rejected。那Promise中是如何处理异步异常的?我们先把上面的代码示例拿来改改试试:

STEP 1

function bar() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       // 通过throw抛出异常 
       throw new Error('err');
    }, 500);
  });
}

function exec() {
  try {
    bar().then(res => {
      console.log('res', res);
    })
  } catch(err) {
     console.log('err has been caught in try-catch block');
  }
}

exec();  // Uncaught Error: err

第一次尝试抛出了全局异常(Uncaught Error),trycatch没有捕获到。造成该问题的原因还是因为异常抛出的时候,exec已经从执行栈中出栈了。况且,Promise规范中,在异步过程中通过throw抛出的异常是无法捕获的,异步异常必须通过reject进行抛出。

STEP 2

function bar() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       // 通过reject抛出异常
       reject('err');
    }, 500);
  });
}

function exec() {
  try {
    bar().then(res => {
      console.log('res', res);
    })
  } catch(err) {
     console.log('err has been caught in try-catch block');
  }
}

exec();  // Uncaught Error: err

这次通过reject抛出异常,但是try...catch...同样还是未捕获到异常。原因是因为reject应该配合Promise.prototype.catch一起使用。

STEP 3

function bar() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       // 通过reject抛出异常
       reject('err');
    }, 500);
  });
}

function exec() {
  try {
    bar().then(res => {
      console.log('res', res);
    }).catch(err => {
      // Promise.prototype.catch捕获异常
      console.log('err has been caught in promise catch');
    }); 
  } catch(err) {
     console.log('err has been caught in try-catch block');
  }
}

exec(); // "err has been caught in promise catch"

这次,异常成功地通过Promise.prototype.catch捕获到了。我们可以确定,Promise中的异常捕获跟try...catch...没有半毛钱关系。try...catch...为啥不能捕获上文也介绍好多了。但是当我们把上面的例子换成async...await...方式的时候呢?

STEP 4

function bar() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
       reject('err');
    }, 500);
  });
}

async function exec() {
  // trycatch捕获异常
  try {
    await bar()
  } catch(err) {
     console.log('err has been caught in try-catch block');
  }
}

exec(); // "err has been caught in try-catch block" 

这次try...catch...终于捕获到了异常,STEP3和STEP4的差异就在async...await...上,那它到底用什么魔力实现了这一点呢?

一段承上启下的陈述

首先,这里解释一下Promise处理异常的原理。Promise的本质也是基于回调的,基于回调它就同样无法摆脱try...catch...不能捕获异步异常的事实。然鹅,Promise规范中有一套自己的异常处理逻辑,它也不指望都能打破这种时空上的隔离。取而代之的是Promise将异步的异常逻辑封装在回调逻辑中(Promise.prototype.catchPromise.prototype.then的一种特殊形式,你可以把他们理解成一回事)。当Promise的状态发生改变时,将错误或异常以回调的形式呈现出来。

Promise的出现很大程度上改变了大家的编程方式,大家的编程习惯也越来越Promise化了。但是在我看来,Promise还是有它的弱点的,Promise运行严重依赖其内部状态的变迁。之前说了,Promise状态变迁的方式就只能依赖两个“开关” —— resolve和reject。这就需要我们必须明确异常会出现在哪里?然后在异常出现的地方通过reject方法将Promise的状态调整成Rejected的。换句话说,也就是我们必须明确在代码的什么位置手动执行reject(...)

但是,异常本无形,又经常大肠包小肠。Promise通过他的规范处理了我们比较明确的异常,但是那些不太确定的异常呢?还是会不经意间被放走。所以,前面一直强调,Promise更多是一种规范化的约束,至少在异步异常这块,他没有本质性地解决问题。


由于篇幅的原因,这一篇主要介绍了一下问题产生的原因和一些背景知识。后面的文章会陆续展示出async/await强大之处和强大的原因。其中会从原理、源码等给大家从不同的角度进行分析。我们下期见~