【面试题】对async/await 的了解?

简介: 【面试题】对async/await 的了解?

前言

大厂面试题分享 面试题库

后端面试题库 (面试必备) 推荐:★★★★★

地址:前端面试题库


“编程,就像一场开卷考试,题目的答案取决于对书本内容的熟悉程度;而一份源代码就好比一本书,正所谓读书百遍其义自见,读懂源码就读懂了编程!”

Hello,《这就是编程》每周六晚如期更新,带大家走进编程世界,一起读懂源码,读懂编程!

Error-First Callback风格的异步代码容易出现Callback Hell,Promise的出现某种意义上解决了这个问题,但是期待一种同步风格来编写异步代码。

随着JavaScript语言特性开始支持async function,async/await可以达到同步代码风格,这种“同步”方式是如何实现的呢?今天第二期,带来关于在cxx层面async function实现原理的源码分析,让你进一步提升对JavaScript异步编程的认识,以及掌握async/await使用细节。

假如读者想对Promise实现原理做进一步了解,可以查看上一期内容👆🏻,因为Promise是async/await的基础。

执行过程


JavaScript是解释型语言,如上图,V8经过AST parser和Bytecode-Generator得到JavaScript与机器码的中间产物bytecode,bytecode展示了JavaScript执行过程。

全文以下面这段代码为例,分析在V8中async function如何执行。

asyncfunctionfoo(v) {
    const w = await v;
    return w;
}
foo(42)
    .then((res) => { console.log(res) })
    .catch((err) => { console.log(err) })
复制代码

首先看一下foo函数声明时生成的bytecode

[generated bytecode for function:(0x0efd0025a71d<SharedFunctionInfo>)]
Bytecode length:56Parametercount1Registercount5Framesize40Bytecode age:00xefd0025a85e@0 :1300LdaConstant [0]
         0xefd0025a860@2 :c3Star10xefd0025a861@3 :19fef8Mov<closure>,r20xefd0025a864@6 :656401f902CallRuntime [DeclareGlobals],r1-r20xefd0025a869@11 :210100LdaGlobal [1], [0]
         0xefd0025a86c@14 :c1Star30xefd0025a86d@15 :0d2aLdaSmi [42]
         0xefd0025a86f@17 :c0Star40xefd0025a870@18 :62f7f602CallUndefinedReceiver1r3,r4, [2]
         0xefd0025a874@22 :c1Star30xefd0025a875@23 :2df70204GetNamedPropertyr3, [2], [4]
         0xefd0025a879@27 :c2Star20xefd0025a87a@28 :80030000CreateClosure [3], [0],#00xefd0025a87e@32 :c0Star40xefd0025a87f@33 :5ef8f7f606CallProperty1r2,r3,r4, [6]
         0xefd0025a884@38 :c2Star20xefd0025a885@39 :2df80408GetNamedPropertyr2, [4], [8]
         0xefd0025a889@43 :c3Star10xefd0025a88a@44 :80050100CreateClosure [5], [1],#00xefd0025a88e@48 :c1Star30xefd0025a88f@49 :5ef9f8f70aCallProperty1r1,r2,r3, [10]
         0xefd0025a894@54 :c4Star00xefd0025a895@55 :a9ReturnConstantpool(size=6)0xefd0025a81d: [FixedArray] inOldSpace-map:0x0efd00000089<Map(FIXED_ARRAY_TYPE)>-length:60:0x0efd0025a765<FixedArray[2]>1:0x0efd0025a6d5<String[3]:#foo>2:0x0efd0000605d<String[4]:#then>3:0x0efd0025a7ad<SharedFunctionInfo>4:0x0efd0020378d<String[5]:#catch>5:0x0efd0025a7e5<SharedFunctionInfo>HandlerTable(size=0)SourcePositionTable(size=0)复制代码

从bytecode中发现,async function的声明过程和普通函数一样,毕竟async function是以Function.prototype为原型。

await实现原理

重点分析一下对应foo函数执行的bytecode,如下:

[generated bytecode for function:foo(0x0efd0025a775<SharedFunctionInfofoo>)]
Bytecode length:87Parametercount2Registercount6Framesize48Bytecode age:00xefd0025a9fe@0 :aefa0001SwitchOnGeneratorStater0, [0], [1] { 0:@33 }
         0xefd0025aa02@4 :19fef8Mov<closure>,r20xefd0025aa05@7 :1902f7Mov<this>,r30xefd0025aa08@10 :6802f802InvokeIntrinsic [_AsyncFunctionEnter],r2-r30xefd0025aa0c@14 :c4Star00xefd0025aa0d@15 :19fff8Mov<context>,r20xefd0025aa10@18 :19faf7Movr0,r30xefd0025aa13@21 :1903f6Mova0,r40xefd0025aa16@24 :6801f702InvokeIntrinsic [_AsyncFunctionAwaitUncaught],r3-r40xefd0025aa1a@28 :affafa0300SuspendGeneratorr0,r0-r2, [0]
         0xefd0025aa1f@33 :b0fafa03ResumeGeneratorr0,r0-r20xefd0025aa23@37 :c1Star30xefd0025aa24@38 :680bfa01InvokeIntrinsic [_GeneratorGetResumeMode],r0-r00xefd0025aa28@42 :c0Star40xefd0025aa29@43 :0cLdaZero0xefd0025aa2a@44 :1cf6TestReferenceEqualr40xefd0025aa2c@46 :9805JumpIfTrue [5] (0xefd0025aa31@51)0xefd0025aa2e@48 :0bf7Ldarr30xefd0025aa30@50 :a8ReThrow0xefd0025aa31@51 :19f7f9Movr3,r10xefd0025aa34@54 :19faf7Movr0,r30xefd0025aa37@57 :19f9f6Movr1,r40xefd0025aa3a@60 :6804f702InvokeIntrinsic [_AsyncFunctionResolve],r3-r40xefd0025aa3e@64 :a9Return0xefd0025aa3f@65 :c1Star30xefd0025aa40@66 :82f701CreateCatchContextr3, [1]
         0xefd0025aa43@69 :c2Star20xefd0025aa44@70 :10LdaTheHole0xefd0025aa45@71 :a6SetPendingMessage0xefd0025aa46@72 :0bf8Ldarr20xefd0025aa48@74 :1af7PushContextr30xefd0025aa4a@76 :1702LdaImmutableCurrentContextSlot [2]
         0xefd0025aa4c@78 :bfStar50xefd0025aa4d@79 :19faf6Movr0,r40xefd0025aa50@82 :6803f602InvokeIntrinsic [_AsyncFunctionReject],r4-r50xefd0025aa54@86 :a9ReturnConstantpool(size=2)0xefd0025a9cd: [FixedArray] inOldSpace-map:0x0efd00000089<Map(FIXED_ARRAY_TYPE)>-length:20:331:0x0efd0025a99d<ScopeInfoCATCH_SCOPE>HandlerTable(size=16)fromtohdlr(prediction,data)(18,65)->65(prediction=3,data=2)SourcePositionTable(size=0)复制代码

对照如下执行示意图:

  • 首先async function编译后被标记成resumable,也就是byecode中SwitchOnGeneratorState,因为async/await底层是以Generator为基础封装,也是借由Generator的协程机制实现可挂起、可恢复
  • 接着InvokeIntrinsic [_AsyncFunctionEnter],async function开始执行,在[AsyncFunctionEnter](https://github.com/v8/v8/blob/main/src/builtins/builtins-async-function-gen.cc#L79)里,核心逻辑包括计算寄存器及参数数量、创建寄存器、创建需返回调用者的promise实例以及创建代表async function自身的async_function_object对象
  • 然后InvokeIntrinsic [_AsyncFunctionAwaitUncaught],即是进入await语法糖处理逻辑,以Promise实例包装每一个await point,并在handlers里封装resume/throw处理逻辑,以实现在特定执行时机(比如promise fulfilled)恢复且返回结果值

说到这里,先穿插分析下await语法糖的实现原理,V8在生成bytecode的过程中会经过很多的visitor,对于await关键字会进入BytecodeGenerator::VisitAwait处理

在VisitAwait里,对当前await point保存现场,然后对其进行BuildAwait,使得每一个await point成为resumable,即可挂起和可恢复,具体看看BuildAwait是如何处理:

  1. 首先为register寄存器设置正确的作用域,因为这关系到await point在LoadAccumulatorWithRegister时能否从正确的register里获取结果值,即在JavaScript层面await能否获取到预期结果值
  2. 确定await关键字的FunctionId,这个FunctionId直接决定了await在cxx层面的逻辑处理,即怎么处理跟随的“数据”。上面bytecode里调用的InvokeIntrinsic [_AsyncFunctionAwaitUncaught]正是FunctionId所指向的cxx内部AsyncFunctionBuiltinsAssembler的built-in方法AsyncFunctionAwaitUncaught
 0xefd0025aa16 @   24 : 68 01 f7 02       InvokeIntrinsic [_AsyncFunctionAwaitUncaught], r3-r4
复制代码

  1. 绑定FunctionId作为await的逻辑处理函数,然后获取register列表,并作为闭包参数传给await使用

接着BuildSuspendPoint

为await point标记suspend_id构建suspend point,使得await point可被挂起。suspend_id是suspend point的唯一索引,缓存在generator_jump_table_中。等await触发时,在suspend_id位置挂起async_function_object;await处理完成时从suspend_id对应await point恢复继续执行。

0xefd0025aa1a@28 :affafa0300SuspendGeneratorr0,r0-r2, [0]
 0xefd0025aa1f@33 :b0fafa03ResumeGeneratorr0,r0-r2复制代码
  1. 最后判断当前async_function_object是否需要resume,resume_mode为true,表明async function需要从suspend_id位置ResumeGenerator恢复继续执行,则将async_function_object的当前value返回给当前await point,进入InvokeIntrinsic [[_AsyncFunctionResolve](https://github.com/v8/v8/blob/main/src/builtins/builtins-async-function-gen.cc#L179)];否则ReThrow抛错退出进入InvokeIntrinsic [[_AsyncFunctionReject](https://github.com/v8/v8/blob/main/src/builtins/builtins-async-function-gen.cc#L156)]
0xefd0025aa1f@33 :b0fafa03ResumeGeneratorr0,r0-r20xefd0025aa23@37 :c1Star30xefd0025aa24@38 :680bfa01InvokeIntrinsic [_GeneratorGetResumeMode],r0-r00xefd0025aa28@42 :c0Star40xefd0025aa29@43 :0cLdaZero0xefd0025aa2a@44 :1cf6TestReferenceEqualr40xefd0025aa2c@46 :9805JumpIfTrue [5] (0xefd0025aa31@51)复制代码

以上bytecode非常清晰展示了async function整个执行过程。

再以直观的示意图来加深印象:

注意:V8 v7.2之前,无论v值类型都会为其创建临时promise实例

由图中分析:

  1. 创建implicit_promise作为async function返回值,同时createPromise创建临时promise实例用于对v值(命名为promise_42)进行resolvePromise
  2. 针对每一个await point,首先通过performPromiseThen将async function的resume/throw注册到promise_42。然后suspend挂起,等待promise_42处理。一旦fulfilled执行resume并将数据返回await point,进入下一个await point;而rejected就执行throw退出async function。
  3. 最后async function并将implicit_promise返回给调用者,同时归还执行权,调用者继续执行后续script代码,如对implicit_promise进行then方法调用等。
  4. foo函数等待ResolvePromise(promise, promise_42),此时由于ResolvePromise入参promise_42是一个promise实例,会创建

PromiseResolveThenableJob

放入microtask队列等待,而PromiseResolveThenableJob又会创建PromiseReactionJob

V8 v7.2之后,根据v值类型判断是否创建临时Promise实例

图左红底代码块逻辑事实上等价于PromiseResolve

如图右绿底代码,利用PromiseResolvebuilt-in实现,当v值是promise实例时直接复用,即不再创建临时promise实例,这样避免ResolvePromise入参为promise实例而额外创建PromiseResolveThenableJob,而是直接创建PromiseReactionJob。

在最后一个await point接收到从async function resume的返回值42时,由所有await point构建的promise chain进入最后一跳,也就是implicit_promise。async function成功退出,对应implicit_promise进入fulfilled;而如果因任一await point抛错导致的失败退出,对应implicit_promise就进入了rejected,调用者通过then注册的对应状态handler也会执行。

await如何执行

上面分析了await关键字的实现原理,接下来要分析一下await关键字如何执行,也就是cxx层面的built-in方法AsyncFunctionBuiltinsAssembler::AsyncFunctionAwait

首先着重看看注释部分,表达的信息很重要:

  • AsyncFunctionAwait是await关键字的核心逻辑,V8将await value语法糖反解成yield AsyncFunctionAwait(.generator_object, value),表明async function在cxx层面确实是以Generator为基础而封装实现的

OK,接着看一下AsyncFunctionAwait的具体处理逻辑:

  • 首先Return(outer_promise)表明AsyncFunctionAwait返回promise实例,就像foo函数例子里的implicit_promise一样。本质上,从下面这段代码可以发现outer_promise是贯穿于async function的整个执行过程的,简而言之,每一个await point都与这个outer_promise关联。
TNode<JSPromise> outer_promise = LoadObjectField<JSPromise>(
       async_function_object, JSAsyncFunctionObject::kPromiseOffset)
  • 然后重点要提到AsyncFunctionAwaitResolveSharedFunConstant和AsyncFunctionAwaitRejectSharedFunConstant,由它们生成Promise层面的resolve/reject方法对

在所有准备工作完成之后,await就进入真正的处理逻辑AsyncBuiltinsAssembler::Await

首先针对await接收的value值进行PromiseResolve处理,最后得到一个wrapper promise实例。

  • 接着初始化await的上下文,这里的上下文不是async function对应的JSAsyncFunction实例async_function_object,可以理解是接下来马上要创建的onFulfilled/onRejected handlers的闭包上下文
  • 同时因为在闭包上下文中无法获取到async_function_object,所以要将它存入闭包上下文的扩展字段,这样onFulfilled/onRejected handlers执行时就能获取到正确的async_function_object,也就能对其进行resume/throw操作

接下来就是创建onFulfilled/onRejected handlers,在async function里,它们包含了resume/throw执行逻辑,通过它们就能从suspend point恢复或者抛错退出。

resume/throw对应bytecode中ResumeGenerator/Rethrow

其实整个await执行过程,除了在BuildAwait时BuildSuspendPoint引入了suspend point,其他就是纯粹的Promise处理逻辑。接下来就是对wrapper promise实例调用一次PerformPromiseThenImpl,如下代码所示,而此时也会传递上面创建的onFulfilled/onRejected handlers。最后一旦onFulfilled,async function就从await point恢复继续执行,然后继续后面的await point的执行;而一旦onRejected,async function就会在suspend point抛错退出

performPromiseThen(
    promise,
    (res) => resume(<<foo>>, res),
    (err) => throw(<<foo>>, err),
    throwaway
)
复制代码
<<foo>>就是async_function_object,作为resume/throw的执行上下文

注意:

事实上,resume/throw在cxx层面是由generator的next/throw来实现,且async function里的return对应了Generator.prototype.return,本质上也是一次await point处理,只不过在return时,generator实例的next方法返回值中done属性为true,表示generator迭代完结,从而停止触发next方法。

小结

OK,目前已经分析了async function在V8中整个执行过程,总结起来它的实现原理是:

  1. 以Generator协程机制为基础,由Promise实现异步执行
  2. 通过promise chain搭配Generator的resume/rethrow达到await“等待”执行的同步效果

相信大家对async/await也有了新的认识,接下来继续来看一些和JavaScript async function有关的优秀源码,比如regenerator-runtime。

regenerator-runtime


在JavaScript业界,提到async/await,就不得不提regenerator-runtime,它是facebook/regenerator中关于async function的runtime实现。

鉴于当前兼容性问题,async function有时不能以原生的方式使用,因此会使用regenerator-runtime来模拟async function的runtime。

facebook/regenerator是一个monorepo,不仅包括regenerator-runtime,也提供了regenerator命令,通过regenerator --include-runtime可以生成自带regenerator-runtime的async function转译代码,如转译后的foo函数,这里省略了regenerator-runtime代码:

functionfoo() {  
    var v;  
    return regeneratorRuntime.async(functionfoo$(_context) {    
        while (1) switch (_context.prev = _context.next) {
            case0:       
                _context.next = 2;        
                return regeneratorRuntime.awrap(42);      
            case2:        
                v = _context.sent;        
                return _context.abrupt("return", v);      
            case4:      
            case"end":        
                return _context.stop();    
        }  
    }, null, null, null, Promise);
}
复制代码

发现async function被转译成regeneratorRuntime.async,同时async function的函数体变成了while(1)无限循环。

往往regenerator转译后的代码给人的印象,甚至说误解:

  • 调用栈紊乱,难以调试,尤其是while(1)无限循环的出现
  • 增加代码大小影响性能

但希望在明白它的实现原理之后能缓解以上这些误解

Generator runtime

之所把它放在async function在V8的实现原理分析之后,是因为regenerator-runtime完美复刻了V8的async function实现原理:

  1. 以Generator协程机制为基础,由Promise实现异步执行
  2. 通过promise chain搭配Generator的resume/rethrow达到await“等待”执行的同步效果

  1. 首先获取AsyncIterator实例iter
  2. 通过promise chain配合iter.next来实现await“等待”效果,因为当AsyncIterator迭代器未完结时就会由promise onFulfilled触发下一次iter.next继续迭代下去
  3. 最后将promise chain返回给调用者

wrap函数的目的就如其字面意思一样,包装generator实例,同时定义一个_invoke作为Generator原生方法next、return、throw的泛化调用。

在AsyncIterator构造器里:

  • 将包装的generator实例封装进AsyncIterator实例作为协程机制的基础触发点。
  • 定义一个_invoke属性,以enqueue函数作为属性值,enqueue的作用是始终返回一个promise实例,并同时触发generator持续迭代,以promise chain来响应generator的迭代值或抛错。

Suspend point

经过以上分析明白了regenerator-runtime内部如何通过Generator配合Promise来模拟await,但是对于调用者,是怎么在所谓的await point位置获取正确的结果值呢?

这一切都归功于Context实例,它巧妙的模拟了suspend point

var context = new Context(tryLocsList || []);复制代码

在V8执行async function时,对每一个await point都会构建一个suspend point并设置对应的suspend_id;而在regenerator-runtime中,所有的suspend point都在while(1)中的switch里,每一个case就是一个suspend point,准确的说,这些suspend point是在regenerator的ast解析过程中生成的,而所有的suspend_id对应await point的ast node的index索引值。

对于await point,当generator实例正确执行next/return时,suspend point处恢复的执行权按顺序从上往下通过context.next来传递,而当generator实例throw抛错时,则在context里throw context.arg退出并进入promise chain的onRejected handler。

现在理解了regenerator-runtime的实现原理,后续调试肯定会得心应手了。

结尾


经过上一期《这就是编程:你知道在cxx语言层面Promise如何实现吗》和本期对async function在cxx层面的实现原理分析,相信对JavaScript异步编程肯定有了更深的理解:

  • 了解了await语法糖如何实现
  • 了解了async function在V8内部以Generator协程机制为基础
  • 了解了async function如何通过promise chain巧妙实现await“等待”效果

OK,本期先分享到这里,如果读者有任何疑惑,欢迎评论区留言讨论👏🏻,笔者很乐意与大家分享源码阅读的心得,也是一次很好的自我学习机会🤔。

大厂面试题分享 面试题库

后端面试题库 (面试必备) 推荐:★★★★★

地址:前端面试题库

相关文章
|
2月前
|
前端开发 JavaScript 开发者
No102.精选前端面试题,享受每天的挑战和学习(async/await)
No102.精选前端面试题,享受每天的挑战和学习(async/await)
|
2月前
|
前端开发 UED
【面试题】async/await 函数到底要不要加 try catch ?
【面试题】async/await 函数到底要不要加 try catch ?
|
2月前
|
自然语言处理 前端开发
阿里面试官:如何给所有的async函数添加try/catch?
阿里面试官:如何给所有的async函数添加try/catch?
|
2月前
|
前端开发 JavaScript
【面试题】async/await、promise和setTimeout的执行顺序
【面试题】async/await、promise和setTimeout的执行顺序
|
2月前
|
前端开发 开发者
【面试题】手写async await核心原理,再也不怕面试官问我async await原理
【面试题】手写async await核心原理,再也不怕面试官问我async await原理
|
2月前
|
前端开发
【面试题】说说你对 async和await 理解
【面试题】说说你对 async和await 理解
|
存储 JavaScript 前端开发
web前端面试高频考点——Vue3.x升级的重要功能(emits属性、生命周期、多事件、Fragment、移出.async、异步组件写法、移出 filter、Teleport、Suspense...)
web前端面试高频考点——Vue3.x升级的重要功能(emits属性、生命周期、多事件、Fragment、移出.async、异步组件写法、移出 filter、Teleport、Suspense...)
194 0
|
前端开发 JavaScript
web前端面试高频考点——JavaScript 篇(二)【JS 异步进阶】Event Loop、then 和 catch、async/await、宏任务微任务、手撕 Promise 源码
web前端面试高频考点——JavaScript 篇(二)【JS 异步进阶】Event Loop、then 和 catch、async/await、宏任务微任务、手撕 Promise 源码
153 0
|
前端开发 JavaScript
手写async await的 20 行最简实现,阿里面试热门题
如果让你手写async函数的实现,你是不是会觉得很复杂?这篇文章带你用20行搞定它的核心。