这就是编程:你知道在cxx语言层面Promise如何实现吗

1,289 阅读15分钟

前言

2022年,文章写少了,源码也看少了,进入2023年,就希望能坚持写作,把每一次源码阅读的过程都记录下来,最后收到《这就是编程》系列中。

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

今天带来关于V8 Promise在cxx语言层面的源码分析,一文带你全面了解V8 Promise的实现原理及使用细节,让你对js异步编程有新的认识。

Let's start!

初识Promise

JSPromise在cxx里结构是什么: 

state:分别为pending、fulfilled、rejected

reactions_or_result的可能值:

  • PromiseReactions,简单理解为promise chain链表
  • 结果值,即resolve/reject函数调用时传入的参数值 

这些属性在js对象上的表达是以下internal slot: 

  • [[PromiseState]] 
  • [[PromiseResult]]

实例创建

接下来看在cxx层面promise原理,首从Promise构建器开始。

一旦js层面new Promise(executor)执行,对应cxx源码里PromiseConstructor执行,

  1. 创建JSPromise实例
  2. 创建PromiseResolvingFunctions,也就是resolve/reject方法对,它们是两个built-in方法,也是JSPromis实例状态变更的唯一触发点,可谓Promise核心机制之一
  3. 调用executor(resolve, reject),而且通过try-catch对executor执行过程进行错误捕获
  4. 最后返回JSPromise实例 

注意:

在Promise构建过程中executor是同步执行,同时由于cxx内部会有try-catch对执行过程错误进行捕获,所以executor里一旦抛错同样能被onRejected handler处理,js层面不必对executor添加额外的try-catch

这里抛一个题,代号叫“看似简单的promise”,想一想下面这段代码输出会是什么:

Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4);
}).then((res) => {
    console.log(res)
})

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
})

直观看来,then方法调用先绑定的handler先执行,那不就输出:

0
4
1
2
3

你认为答案对吗?你对handler的执行时机和方式真的了解了吗?

绑定回调

then

得到JSPromise实例后,就一定会调用其then方法,不然JSPromise实例就失去了意义。

js层面Promise.prototype.then是一个built-in方法,对应cxx层面的PromisePrototypeThen

  1. 获取onFulfilled/onRejected handlers的执行上下文context
  2. 创建PromiseCapability,一个deferred的对象,既包含JSPromise实例,也包含对应的resolve/reject方法对,JSPromise实例总是和它的resolve/reject方法对搭配出现;同时PromiseCapability是promise chain的关键
  3. 针对刚刚创建的PromiseCapability,加上onFulfilled/onRejected handlers信息,创建PromiseReaction,PromiseReaction是后续handler被调用的载体,因为它是创建PromiseReactionJobTask的依据,然后配合当前JSPromise实例的reactions_or_result建立PromiseReactions链表
  4. 返回新创建的JSPromise实例 

注意:

考虑以下这样的场景,对同一个JSPromise实例多次进行then方法调用,这时PromiseReactions链表里会保存每一次调用传入的onFulfilled/onRejected handlers信息。

举个例子:

const myPromise4 = new Promise((resolve, reject) => {
  setTimeout(_ => {
    resolve('my code delay 5000') 
  }, 5e3)
})

myPromise4.then(result => {
  console.log('第 1 个 then')
})

myPromise4.then(result => {
  console.log('第 2 个 then')
})

此时myPromise4的reactions_or_result所存放的PromiseReactions链表如下图所示:

为了更加直观的表示,这里以handler代替PromiseReaction实例

then方法调用后,需要达到先绑定的handler先执行的特点,那么上图反映的PromiseReactions链表其实顺序刚好相反,笔者疑惑cxx层面为什么不在建立PromiseReactions链表时就按正确顺序呢?先埋个伏笔,看看后续cxx对这个顺序如何处理。

OK,接着往下,其实then方法核心逻辑在PerformPromiseThenImpl里。

当promise的状态处于pending时,then方法才会建立PromiseReactions链表。

注意:

  1. promise链式调用是基于新创建的JSPromise实例
  2. 如果JSPromise实例已经从pending状态变成fulfilled/rejected状态,意味着在调用then方法之前resolve/reject方法已经触发,这时在then方法里会直接创建PromiseReactionJobTask并进入microtask队列等待,对应PerformPromiseThenImpl里状态判断if-else的else分支,这里先不展开,后文分解,不过可以先简单了解下cxx源码中会根据不同状态创建不同的PromiseReactionJobTask,状态为fulfilled时NewPromiseFulfillReactionJobTask,状态为rejected时NewPromiseRejectReactionJobTask
  3. 由2得到,resolve/reject方法对都是可以单独同步调用,这从下文也能得到佐证,这也是deferred的实现基础

这里暂且先对PromiseReactionJobTask保持印象,这是microtask队列中一种关于Promise的JobTask,后面还会提到另一种。某种意义上说JobTask也是Promise核心机制之一。

OK,接着往下,在promise状态为pending且经过then方法调用后,PromiseReactions已经创建并建立,这时resolve方法触发,表现是状态从pending进入fulfilled。

cxx层面的ResolvePromise方法是resolve方法的built-in实现,前面提到在Promise构造方法调用时会创建PromiseResolvingFunctions,得到的resolve方法就是ResolvePromise这个built-in方法。

ResolvePromise接收两个入参:

  • resolve方法的上下文context所对应的JSPromise实例
  • 作为promise.reactions_or_result值的resolution入参

在ResolvePromise里,主要是针对resolve方法入参类型进行分支判断:

  1. 非对象,直接FulfillPromise
  2. 对象但非thenable,直接FulfillPromise
  3. thenable,进入Enqueue,创建PromiseResolveThenableJobTask放入microtask队列等待,这里先不重点分析这种情况,下文详细说明 

这里出现的PromiseResolveThenableJobTask,和上面提到的PromiseReactionJobTask一样,

都是在microtask队列中关于Promise的JobTask。

注意:

目前onFulfilled handler都还没有执行,那么什么时候执行,什么方式执行,关键就在FulfillPromise里,它也是JSPromise实例进入fulfilled状态的唯一触发点。

catch

明白了then方法执行逻辑之后,再来看看catch方法。catch方法指向cxx内部builtin方法PromisePrototypeCatch。

对照源码发现:

Promise.prototype.catch本质上就是Promise.prototype.then,唯一的区别就是catch方法里onFulfilled被设置为undefined。

队列等待

现在假设resolve入参为非thenable类型,即直接进入FulfillPromise的调用逻辑。

在FulfillPromise中,会将遍历PromiseReactions链表生成的一个个PromiseReactionJobTask放入microtask队列等待执行,这种PromiseReactionJobTask创建场景是最常见的,也就是说then方法调用在resolve方法之前。

  1. 判断当前promise状态值是否处于pending,这点尤为重要,一旦promise状态发生变化意味着resolve/reject方法失去意义,即这个时候调用resolve/reject方法将不会有任何副作用
  2. 缓存当前JSPromise的reactions_or_result,这时的reactions_or_result为PromiseReactions链表,存放着promise chain信息
  3. 将reactions_or_result值设置为resolve入参,对应js层面就是设置promise.[[PromiseResult]];同时设置JSPromise状态值为fulfilled,对应js层面就是设置promise.[[PromiseState]]。这里也看出,先设置状态再触发PromiseReactions。
  4. TriggerPromiseReactions,遍历PromiseReactions分别创建PromiseReactionJobTask。

注意:

从PerformPromiseThenImpl可以看出,对已经不处于pending状态的promise多次调用then方法等价于往microtask队列里放入task,且handler接收到的入参值都是promise.reactions_or_result,因为这个值经由FulfillPromise逻辑处理已经锁定

再看TriggerPromiseReactions,将当前resolve入参透传到MorphAndEnqueuePromiseReaction,同时根据resolve/reject方法的调用设置reactionType值,分别传递fulfilled/rejected。

  1. 因为上文提到PromiseReactions链表是反序的,所以要先反转重新生成顺序正确的PromiseReactions
  2. 遍历顺序正确的PromiseReactions,分别对每一个PromiseReaction进行MorphAndEnqueuePromiseReaction逻辑处理,就是根据当前promise状态,分别对其创建对应的PromiseReactionJobTask并放入microtask队列等待,由于microtask队列是先进先出,所以前面才要反转PromiseReactions链表,这样先绑定的handler才能先出栈执行。笔者认为cxx层面在建立PromiseReactions链表时完全可以一步到位直接建立一个顺序正确的链表,不知道cxx层面为什么没这么做?🙅🏻‍♀️

注意:

此时PromiseReactionJobTask所存的argument属性值就是resolve方法入参,它经过FulfillPromise,再经过TriggerPromiseReactions,一路透传到MorphAndEnqueuePromiseReaction里,最后放入PromiseReactionJobTask实例里。

如下图,PromiseReactions经过反转得到了最后handler正确顺序执行的结果。

经过FulfillPromise的分析,现在onFulfilled handler执行方式以及执行时机就非常清晰了:

  • 执行时机是在每一轮microtask队列执行时伴随其中每一个PromiseReactionJob出栈执行
  • 执行方式是在resolve方法调用后创建PromiseReactionJobTask被放入microtask队列等待执行

回头再看“看似简单的promise”这道题,现在应该有不同答案了:

0
1
2
4
3

但是这答案对吗?🤔在handler中可是return Promise.resolve(4)呢?上文假设resolve入参是非对象,可现在却是一个thenable的promise实例。对于这样一个thenable,resolve会如何处理呢?不急,接着往下看。

注意:

到这里,都在分析resolve方法,那对于promise的reject方法逻辑链路,其实和resolve方法逻辑链路基本一致,由RejectPromise触发,将promise状态设置为rejected,然后此时TriggerPromiseReactions传递的reactionType是rejected。

特别注意的是RejectPromise也同样会创建PromiseReactionJob,但是PromiseReactionJob中onRejected handler执行完会进入下一跳promise chain的FuflfillPromiseReactionJob,这就是为什么reject触发之后,下一跳的onFulfilled handler会触发的原因所在。

异步执行

Microtask

从上面的分析知道,只有在resolve/reject被执行时,才会创建microtask放入队列等待执行,所以要先简单看一下microtask队列是如何运作的,也就是说这些不同类型的microtask是怎么被处理的。

关于microtask队列执行逻辑的代码在builtins-microtask-queue-gen.cc

在MicrotaskQueueBuiltinsAssembler::RunSingleMicrotask里面,根据当前microtask类型作相应的处理。关于promise的microtask类型上面也已经提到了,对照源码里分别是:

  • is_promise_fulfill_reaction_job,由NewPromiseFulfillReactionJobTask所创建
  • is_promise_reject_reaction_job,由NewPromiseRejectReactionJobTask所创建
  • is_promise_resolve_thenable_job,当ResolvePromise接收一个thenable对象时由NewPromiseResolveThenableJobTask所创建

OK,接下来要着重看下这几个类型的microtask的具体执行逻辑。

PromiseReactionJob

随着事件循环Event-Loop,每一次microtask队列都会先进先出的执行完当前所有的JobTask。上面提到对于Promise,有两种JobTask:

  • PromiseReactionJobTask,创建PromiseReactionJob
  • PromiseResolveThenableJobTask,创建PromiseResolveThenableJob

注意:

上文提到在promise不处于pending状态时,调用then方法会根据promise当前状态分别创建JobTask放入microtask队列:

  • fulfilled对应NewPromiseFulfillReactionJobTask,会创建PromiseFulfillReactionJob

  • rejected对应NewPromiseRejectReactionJobTask,会创建PromiseRejectReactionJob

底层调用的也还是PromiseReactionJob:

归根结底,最后所有的handler处理逻辑都是在PromiseReactionJob中被触发:

针对handler是否存在分成两个逻辑分支:

  1. handler存在,这是常规场景,将一路透传保存下来的resolve入参,传入handler执行
  2. handler执行成功,需对promise chain的下一跳PromiseCapacity进行“resolve”,也就是调用PromiseCapacity.resolve,而此时的入参就是handler的返回值,接着再次进入新一轮的ResolvePromise,只不过这次对应的JSPromise变成了PromiseCapacity.promise,就这样,promise chain向下传递。
  3. 执行失败,则直接调用PromiseCapacity.reject,进入rejected状态
  4. handler不存在,先按下不表,这种情况只有在PromiseResolveThenableJob里存在,下面说

PromiseResolveThenableJob

上文假设resolve入参为非thenable类型,即直接进入FulfillPromise的调用逻辑。

但是假设handler的返回值是一个thenable对象会怎么样,意味着PromiseCapacity.resolve(thenable),意味着这时ResolvePromise接收到的resolution参数是一个thenable,此时内部逻辑分支会进入Enqueue:

着重看一下NewPromiseResolveThenableJobTask的参数:

  • promise,当前resolve方法归属的promise,针对PromiseCapacity,这里promise就是PromiseCapacity.promise,命名为promiseToResolve,代表将要处理的promise实例

  • resolution,handler返回的thenable对象,带then方法的普通对象或者是一个JSPromise实例,称呼它为promise中间产物

  • then,这个thenable的then方法,callable的普通函数或者Promise.prototype.then

最后得到PromiseResolveThenableJobTask实例并同样被放入microtask队列等待:

那这个临时生成的promise中间产物如何和当前的PromiseCapacity.promise产生关联,为什么thenable resolve之后PromiseCapacity.promise也会同样resolve?接着看,下面解答。

现在在microtask队里,轮到PromiseResolveThenableJob执行了:

如果handler返回的thenable是一个JSPromise实例,则其then方法就是Promise.prototype.then,和context的then方法是相同

  1. 则针对thenable进行一次then方法调用,即PerformPromiseThenImpl,而promiseToResolve被作为这一次调用的临时PromiseCapability
  2. 而且此时的onFulfilled/onRejected handlers设置为空,handler为空是这次then方法调用的最鲜明特点
  3. 而等到这个中间产物resolve时,又是一次由ResolvePromise逻辑触发的PromiseReactionJob,此时由于handler不存在,直接进FuflfillPromiseReactionJob
  4. FuflfillPromiseReactionJob里,promise chain中下一跳的promiseToResolve就会进入ResolvePromise,然后又进入PromiseReactionJob,promise chain开始向下传递

如果handler返回的thenable只是一个带有then方法的普通对象

  1. 则通过promiseToResolve创建resolve/reject方法对
  2. 并以这个thenable为上下文context调用普通对象的then方法,传入resolve/reject方法对,最终达到promiseToResolve.resolve的效果,然后又进入PromiseReactionJob,promise chain开始向下传递

正如下面这个例子

Promise.resolve().then(() => {
    return {
        then(resolve, reject) {
            resolve(1)
        }
    }
}).then((res) => console.log(res));

// 1

注意:

现在弄明白当handler返回一个thenable对象时,会先在microtask队列里放入一个PromiseResolveThenableJobTask,然后经由PromiseResolveThenableJob又在microtask队列里放入PromiseReactionJobTask。那么现在回头看“看似简单的promise”这道题,正确答案应该是

0
1
2
3
4

因为return Promise.resolve(4)导致在microtask队列里多放入了一个PromiseResolveThenableJobTask,使得console.log(3)这个handler所在的PromiseReactionJobTask先于console.log(res),而microtask队列先进先出,所以先输出了3,后输出4

其他方法

Promise.resolve

Promise.resolve返回的是一个创建完成就进入fulfilled状态的JSPromise实例。

如果入参不是thenable,直接进入NeedToAllocate代码逻辑块处理,

  • 如果当前上下文中的Promise构造器为cxx原生Promise构造器,NewJSPromise生成promise实例并对其ResolvePromise,此时该该promise已经处于fulfilled状态,且值锁定为value
  • 通过当前上下文中的Promise构造器创建PromiseCapability并直接调用PromiseCapability.resolve(value),最后返回PromiseCapability.promise,此时该PromiseCapability.promise已经处于fulfilled状态,且值锁定为value

如果入参为thenable

  • 当该thenable为JSPromise实例,即它的构造器和cxx原生Promise构造器相同,直接返回这个JSPromise
  • 如果该thenable不是JSPromise实例,且不为cxx原生Promise的子类,进入NeedToAllocate代码逻辑块处理

后续所有处理就是针对这个返回的JSPromise实例,但是由于它的状态已经变成fulfilled,所以then方法调用时会直接创建PromiseReactionJobTask。PromiseReactionJobTask的执行上文已经分解过了。

比如下面这段代码:

Promise.resolve(1).then((res) => { console.log(res); })

.then((res) => { console.log(res); })这个then调用时直接将(res) => { console.log(res); }这个onFulfilled handler包装成PromiseReactionJobTask

Promise.reject

Promise.reject返回一个创建完成并进入rejected状态的JSPromise实例。

  • 对reject静态方法调用的上下文context如果不是cxx原生Promise构造器,NewPromiseCapability创建PromiseCapability并直接调用PromiseCapability.reject(reason),返回PromiseCapability.promise
  • 如果是Promise.reject的方式去调用,则由NewJSPromise(PromiseState::kRejected, reason)生成一个状态为rejected的JSPromise,并交由runtime::PromiseRejectEventFromStack(promise, reason)去处理,因为一个状态为rejected的JSPromise如果没有添加任何onRejected handler时,需要由runtime去帮助捕获错误

Promise.all

Promise.all可以接收一个iterable类型入参,该iterable迭代器的每一项可以是JSPromise/thenable/普通值。

当通过Promise.all方式调用,当前all方法里的上下文receiver就是cxx原生Promise构造器,通过它进行NewPromiseCapability生成PromiseCapability,此时promiseResolveFunction即为built-in的resolve方法。

进入PerformPromiseAll方法后,对接收到的迭代器iterator遍历处理,每一次迭代器遍历时:

  1. 获取迭代器next value

  2. 根据当前迭代器所处index,创建PromiseResolvingFunctions,并绑定对应的index到resolve/reject方法上。此处的resolve/reject方法对是游离存在的,但是都和同一个resolveElementContext关联

  3. 根据next value值的类型进行逻辑分支,这里稍稍和ResolvePromise里碰到的值类型判断有些许区别,大致可以理解为一次ResolvePromise(promise, nextValue)

  4. 如果next value不为thenable,直接通过Promise.resolve(nextValue)方式创建一个fulfilled状态的JSPromise实例,此时针对该JSPromise调用其then方法,传入上面创建的一对游离resolve/reject方法分别作为onFulfilled/onRejected handlers。则由于JSPromise实例状态已为fulfilled,则作为onFulfilled handler的resolve方法会被触发

  5. 当next value是一个thenable时,将对其进行PerformPromiseThenImpl,也就是调用thenable.then,而且此时创建的这对游离resolve/reject方法也会被作为onFulfilled/onRejected handlers传入,意味着resolve/reject方法会在PromiseReactionJob中被触发

  6. 当这些临时根据index创建的游离resolve/reject方法对被触发后,会将对应的结果值存入*NativeContextSlot(nativeContext, ContextSlot::JS_ARRAY_PACKED_ELEMENTS_MAP_INDEX)所对应的arrayMap,在js层面,简单理解就是存放入一个堆内存上。最后通过PromiseCapability.resolve来触发

注意:

在任意一次迭代器结果获取中出错,对应的PromiseCapability.promise都会进入rejected,所以针对于Promise.all,只有迭代器中所有结果值都resolve才能进入fulfilled状态。

结尾

经过以上分析,现在彻底明白Promise实现原理:

  • 基于microtask队列实现异步执行
  • 通过PromiseCapability的deferred模式,完美实现promise chain

在此基础上,Promise实现逻辑中关于把handler转化为PromiseReactionJobTask并放入microtask队列的巧思,既是对Event-Loop机制的保护,也是对js引擎单线程特点的充分利用。

那么,关于js异步调用的认识,你有哪些想法呢?欢迎留言讨论。

参考资料