前言
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执行,
- 创建JSPromise实例
- 创建PromiseResolvingFunctions,也就是resolve/reject方法对,它们是两个built-in方法,也是JSPromis实例状态变更的唯一触发点,可谓Promise核心机制之一
- 调用executor(resolve, reject),而且通过try-catch对executor执行过程进行错误捕获
- 最后返回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。
- 获取onFulfilled/onRejected handlers的执行上下文context
- 创建PromiseCapability,一个deferred的对象,既包含JSPromise实例,也包含对应的resolve/reject方法对,JSPromise实例总是和它的resolve/reject方法对搭配出现;同时PromiseCapability是promise chain的关键
- 针对刚刚创建的PromiseCapability,加上onFulfilled/onRejected handlers信息,创建PromiseReaction,PromiseReaction是后续handler被调用的载体,因为它是创建PromiseReactionJobTask的依据,然后配合当前JSPromise实例的reactions_or_result建立PromiseReactions链表
- 返回新创建的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链表。
注意:
- promise链式调用是基于新创建的JSPromise实例
- 如果JSPromise实例已经从pending状态变成fulfilled/rejected状态,意味着在调用then方法之前resolve/reject方法已经触发,这时在then方法里会直接创建PromiseReactionJobTask并进入microtask队列等待,对应PerformPromiseThenImpl里状态判断if-else的else分支,这里先不展开,后文分解,不过可以先简单了解下cxx源码中会根据不同状态创建不同的PromiseReactionJobTask,状态为fulfilled时NewPromiseFulfillReactionJobTask,状态为rejected时NewPromiseRejectReactionJobTask
- 由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方法入参类型进行分支判断:
- 非对象,直接FulfillPromise
- 对象但非thenable,直接FulfillPromise
- 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方法之前。
- 判断当前promise状态值是否处于pending,这点尤为重要,一旦promise状态发生变化意味着resolve/reject方法失去意义,即这个时候调用resolve/reject方法将不会有任何副作用
- 缓存当前JSPromise的reactions_or_result,这时的reactions_or_result为PromiseReactions链表,存放着promise chain信息
- 将reactions_or_result值设置为resolve入参,对应js层面就是设置promise.[[PromiseResult]];同时设置JSPromise状态值为fulfilled,对应js层面就是设置promise.[[PromiseState]]。这里也看出,先设置状态再触发PromiseReactions。
- TriggerPromiseReactions,遍历PromiseReactions分别创建PromiseReactionJobTask。
注意:
从PerformPromiseThenImpl可以看出,对已经不处于pending状态的promise多次调用then方法等价于往microtask队列里放入task,且handler接收到的入参值都是promise.reactions_or_result,因为这个值经由FulfillPromise逻辑处理已经锁定
再看TriggerPromiseReactions,将当前resolve入参透传到MorphAndEnqueuePromiseReaction,同时根据resolve/reject方法的调用设置reactionType值,分别传递fulfilled/rejected。
- 因为上文提到PromiseReactions链表是反序的,所以要先反转重新生成顺序正确的PromiseReactions
- 遍历顺序正确的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是否存在分成两个逻辑分支:
- handler存在,这是常规场景,将一路透传保存下来的resolve入参,传入handler执行
- handler执行成功,需对promise chain的下一跳PromiseCapacity进行“resolve”,也就是调用PromiseCapacity.resolve,而此时的入参就是handler的返回值,接着再次进入新一轮的ResolvePromise,只不过这次对应的JSPromise变成了PromiseCapacity.promise,就这样,promise chain向下传递。
- 执行失败,则直接调用PromiseCapacity.reject,进入rejected状态
- 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方法是相同
- 则针对thenable进行一次then方法调用,即PerformPromiseThenImpl,而promiseToResolve被作为这一次调用的临时PromiseCapability
- 而且此时的onFulfilled/onRejected handlers设置为空,handler为空是这次then方法调用的最鲜明特点
- 而等到这个中间产物resolve时,又是一次由ResolvePromise逻辑触发的PromiseReactionJob,此时由于handler不存在,直接进FuflfillPromiseReactionJob
- FuflfillPromiseReactionJob里,promise chain中下一跳的promiseToResolve就会进入ResolvePromise,然后又进入PromiseReactionJob,promise chain开始向下传递
如果handler返回的thenable只是一个带有then方法的普通对象
- 则通过promiseToResolve创建resolve/reject方法对
- 并以这个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遍历处理,每一次迭代器遍历时:
-
获取迭代器next value
-
根据当前迭代器所处index,创建PromiseResolvingFunctions,并绑定对应的index到resolve/reject方法上。此处的resolve/reject方法对是游离存在的,但是都和同一个resolveElementContext关联
-
根据next value值的类型进行逻辑分支,这里稍稍和ResolvePromise里碰到的值类型判断有些许区别,大致可以理解为一次ResolvePromise(promise, nextValue)
-
如果next value不为thenable,直接通过Promise.resolve(nextValue)方式创建一个fulfilled状态的JSPromise实例,此时针对该JSPromise调用其then方法,传入上面创建的一对游离resolve/reject方法分别作为onFulfilled/onRejected handlers。则由于JSPromise实例状态已为fulfilled,则作为onFulfilled handler的resolve方法会被触发
-
当next value是一个thenable时,将对其进行PerformPromiseThenImpl,也就是调用thenable.then,而且此时创建的这对游离resolve/reject方法也会被作为onFulfilled/onRejected handlers传入,意味着resolve/reject方法会在PromiseReactionJob中被触发
-
当这些临时根据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异步调用的认识,你有哪些想法呢?欢迎留言讨论。
参考资料
- ECMA Promise:tc39.es/ecma262/#se…
- V8源码:github.com/v8/v8
- Chromium Code Search:source.chromium.org/chromium/ch…