es6-promiseのコードを読んでみた

Node.jsでPromiseは欠かせない

Node.js(というかJavaScriptやTypeScript)で非同期処理を書くにあたって、Promiseasync/awaitは欠かせないものになっていると思っています。しかし、Promiseの使い方はわかっていたとしても、その裏側でどのように動作しているのかはあまりよくわかっていませんでした。正しい使い方さえわかっていればそれはそれでいいと思いますが、気になったら調べずにはいられないので実装を追ってみることにしました。

PromiseはNode.jsにも組み込まれていますし、QBluebirdといった実装が存在しますが、今回はES6のPromiseと互換性のあるes6-promiseの実装を調べてみました。

なお、Node.jsのイベントループとJavaScript Promiseの本の内容を理解している前提の記事となります。

サンプルとするPromiseを使った非同期処理

本記事では次のコードが動作させることを目標にes6-promiseの実装を追ってみます。

const timer = (sec) => {
    return new Promise((resolve, reject) => {
        if (sec >= 5) {
            return reject(`Can't wait for more than 5sec. [arg]: ${sec}`)
        }

        setTimeout(() => {
            return resolve(sec)
        }, sec * 1000)
    })
}

timer(3).then(time => console.log(`${time}sec elapsed.`), error => console.error(error))
timer(7).then(time => console.log(`${time}sec elapsed.`), error => console.error(error))
console.log('Timer setting completed.')

// [実行結果]
// Timer setting completed.
// Can't wait for more than 5sec. [arg]: 7
// 3sec elapsed.

シンプルなタイマー関数です。引数に指定した秒数だけ待機し、完了後に待機した秒数を返します。ただし、5秒以上を引数に指定した場合は即座に失敗します。この戻り値のPromiseオブジェクトに対し、thenで成功時とエラー時のコールバックをそれぞれ登録しています。このコードをNode.js(v12.16.1)で実行すると、コメントのような結果となります。

new Promise(fn)

Promiseを使うときにはコンストラクタ関数に非同期処理を実行する関数を渡します。Promsieのコンストラクタ関数のコードは次の通り。

class Promise {
    constructor(resolver) {
        // これってなんなんだろうね?
        this[PROMISE_ID] = nextId()

        // 状態とPromise内で実行され処理の結果を保持するプロパティを初期化する
        this._result = this._state = undefined
        this._subscribers = []

        if(resolver !== noop) {
            initializePromise(this, resolver)
        }
    }
}

function noop() {}

const PENDING = void 0
const FULFILLED = 1
const REJECTED = 2

Promiseはそのライフサイクルの中で3つの状態を持ち、PENDING(未定)から始まり、非同期処理の結果に応じてFULFILLED(成功)かREJECTED(失敗)のどちらかに状態が遷移します。Promiseのコンストラクタでは、その状態をPENDINGに初期化します。また、非同期処理の結果を保持するプロパティなども初期化します。_subscribersthenで渡された関数のキャッシュとして利用されます(thenの中身をみるときに詳しくみます)。

プロパティの初期化が完了したあと、intializePromiseをコールします。事前にnoopresolverを比較していますが、こちらはthenが返すPromiseオブジェクトを生成するときに関係するものなので、ここでは気にしなくて大丈夫です。

/**
 * @param promise Promiseオブジェクト
 * @param resolver Promiseコンストラクタ関数に渡された非同期処理
 */
function initializePromise(promise, resolver) {
    try {
        resolver(
          function resolvePromise(value) {
              resolve(promise, value)
          },
          function rejectPromise(reason) {
              reject(promise, reason)
          }
        )
    } catch (e) {
        reject(promise, e)
    }
}

function resolve(promise, value) {
    //...
}

function reject(promise, value) {
    //...
}

initializePromiseにはコンストラクタで生成されたPromiseオブジェクトとコンストラクタに渡された非同期処理が引数として渡されます。今回の例だと(resolve, reject) => ...の関数が渡されます。

const timer = (sec) => {
    return new Promise(
        // 以下の関数がinitalizePromiseに渡される
        (resolve, reject) => {
            if (sec >= 5) {
                return reject(`Can't wait for more than 5sec. [arg]: ${sec}`)
            }

            setTimeout(() => {
                return resolve(sec)
            }, sec * 1000)
        })
}

intializePromiseでは引数に渡された非同期処理が即実行されますが、実行時に二つの関数resolvePromiserejectPromiseが引数に渡されます。これらがPromiseを使うときの説明でよく言われる「処理結果が正常ならresolve(結果の値)を、失敗の場合はreject(エラー)と書きましょう」で出てくるresolverejectの実態となります。

このresolverejectの詳細を見る前にthenの実装がどうなっているか見てみます。

then

then(onFulfillment, onRejection) {
    const parent = this
    const child = new this.constructor(noop)

    // 呼び出し元のPromiseオブジェクトの状態を取得する
    const {_state} = parent

    if (_state) {
        // FULFILLEDかREJECTEDである場合
        // ...
    } else {
        // PENDINGである場合
        subscribe(parent, child, onFulfillment, onRejection)
    }

    return child
}

function noop() {}

thenの中で新しくPromiseオブジェクトを生成しています。thenの呼び出し元Promiseオブジェクトと、この新しく生成したPromiseオブジェクトを親子に見立ててPromiseチェーンを構成します。子Promiseオブジェクトを生成する時にはコンストラクタに空っぽの関数(noop)を渡します。Promiseのコンストラクタを見返してみると、コンストラクタの引数に渡された関数がnoopであった場合はinitializePromiseを実行しないようになっています。子Promiseオブジェクトも初期状態はPENDINGですが、親Promiseでの処理が完了したあとに呼ばれることとなるonFulfillmentonRejectionthenに渡すコールバック関数)にてその状態は変化します。なので子Promiseには親Promiseと違ってコンストラクタには空っぽの関数を渡しています。

子Promiseオブジェクトを生成したあとは、親のPromiseオブジェクトの状態によって処理が分岐します。今回の冒頭のサンプルの場合だと、非同期処理としてsetTimeoutを利用しておりthenをコールした時点ではPENDINGである(ことがほとんど)なので、まずはsubscribeがどうなっているのかを見てみます。

function subscribe(parent, child, onFulfillment, onRejection) {
    const {_subscribers} = parent
    let {length} = _subscribers

    parent._onerror = null

    _subscribers[length] = child
    _subscribers[length + FULFILLED] = onFulfillment
    _subscribers[length + REJECTED] = onRejection

    if(length === 0 && parent._state) {
        // 呼び出し元のPromiseオブジェクトの状態がFULFILLEDかREJECTEDならコールバックを実行する
        // asap(publish, parent)
    }
}

const PENDING = void 0
const FULFILLED = 1
const REJECTED = 2

まんまオブザーバーパターンですね。親Promiseオブジェクトが持つ配列に、子Promiseオブジェクトと、thenに渡す親Promiseが成功/失敗したときのコールバック関数をそれぞれキャッシュします。のちほど親Promiseの状態がFULFILLEDREJECTEDに遷移したときにキャッシュから関数が取り出され実行されます。

thenに渡した関数がPromiseコンストラクタに渡した非同期処理が完了したタイミングで実行される仕組みがなんとなく見えてきたので、initializePromiseに戻ってresolveの実態が何なのかをみてます。

resolve

initailizaPromiseを再掲。

/**
 * @param promise Promiseオブジェクト
 * @param resolver Promiseコンストラクタ関数に渡された非同期処理
 */
function initializePromise(promise, resolver) {
    try {
        resolver(
          function resolvePromise(value) {
              resolve(promise, value)
          },
          function rejectPromise(reason) {
              reject(promise, reason)
          }
        )
    } catch (e) {
        reject(promise, e)
    }
}

function resolve(promise, value) {
    //...
}

function reject(promise, value) {
    //...
}

タイマーのサンプルも並べてみる。

const timer = (sec) => {
    return new Promise(
        // 以下の関数がinitalizePromiseに渡される
        (resolve, reject) => {
            if (sec >= 5) {
                return reject(`Can't wait for more than 5sec. [arg]: ${sec}`)
            }

            setTimeout(() => {
                return resolve(sec)
            }, sec * 1000)
        })
}

コンストラクタにてinitializePromiseがコールされ、その中でコンストラクタの引数に渡した非同期処理、すなわちタイマー関数がすぐさま実行されます。タイマー関数ではsetTimeoutのコールバックがイベントループ内のキューに登録されます。そして指定した時間が経過するとコールバックが実行されresolve(sec)がコールされます。これはinitializePromiseresolverに渡しているresolvePromise(value)が実態で、その中でresolve(promise, value)がコールされます。この例だとvalueに入るのはresolve(sec)sec(待機した秒数)になります。ということで(Promiseで定義されている)resolveの内容を見てみます。

function resolve(promise, value) {
    if (promise === value) {
        // 処理結果がPromiseオブジェクトと同値の場合
        // 今回のサンプルではここにはこないので一旦スルー
    } else if (objectOrFunction(value)) {
        // 結果の値がオブジェクトか関数であった場合
        // 結果の値が別のPromiseオブジェクトの場合は、
        // そのPromiseオブジェクトの完了を待って結果を後続につなげていくので、
        // 別途仕組みが必要
    } else {
        // 処理の結果がプリミティブ型であった場合
        fulfill(promise, value)
    }
}

function fulfill(promise, value) {
    if (promise._state !== PENDING) return
    // Promiseオブジェクトのプロパティに処理の結果を登録し、状態をFULFILLEDに遷移させる
    promise._result = value
    promise._state = FULFILLED

    if (promise._subscribers.length !== 0) {
        // Node.jsではprocess.nextTickを使用するが、ブラウザやWeb Workerでは変わってくる。
        process.nextTick(() => publish(promise))
    }
}

resolveを通してfulfillがコールされ、その中でprocess.nextTickがコールされています。publishにてPromiseオブジェクトが保持しているコールバックのキャッシュ(_subscribersプロパティ)を使ってコールバックを実行するのですが、ここでprocess.nextTickを通してコールバックを呼び出しているので、コールバックも非同期で実行されることが保証されています。

ここはes6-promiseの実装からかなり端折っています。process.nextTickはNode.jsの機能であるためブラウザにそのようなAPIは存在しません。ライブラリでは実行環境を判定して、どのような仕組みで非同期的にコールバックを実行するか決めています。ブラウザでの動作がどうなるかはes6-promiseの実装を見てください。

続いてpublishです。

function publish(promise) {
    const subscribers = promise._subscribers
    const settled = promise._state

    // thenでなにも登録されていない
    if (subscribers.length === 0) return

    let child, callback, detail = promise._result

    // subscribersを走査
    for (let i = 0; i < subscribers.length; i += 3) {
        child = subscribers[i]
        // FULFILLED = 1, REJECTED = 2 なので、以下のようにして成功/失敗時のコールバックを取り出せる
        callback = subscribers[i + settled]
        if (child) {
            invokeCallback(settled, child, callback, detail)
        } else {
            callback(detail)
        }
    }

}

Promiseオブジェクト内のプロパティからコールバックと、そのコールバックを登録したときに生成した子Promiseを取り出してinvokeCallbackを実行しています。

function invokeCallback(settled, promise, callback, detail) {
    let hasCallback = typeof callback === 'function'
    let value, error, succeeded = true

    // thenに登録されたコールバックを実行する
    if (hasCallback) {
        try {
            value = callback(detail)
        } catch (e) {
            succeeded = false
            error = e
        }

        if(promise === value) {
            //
            return
        }
    } else {
        value = detail
    }

    // コールバックが成功すれば、子Promiseは成功とみなしてresolveする
    // コールバックが失敗ならば、子Promiseは失敗とみなしてrejectする
    if(promise._state !== PENDING) {

    } else if(hasCallback && succeeded) {
        resolve(promise, value)
    } else if(succeeded === false) {
        reject(promise, error)
    } else if(settled === FULFILLED) {
        fulfill(promise, value)
    } else if(settled === REJECTED) {
        reject(promise, value)
    }
}

コールバックを実行し、その結果に応じて子Promiseの状態を決定します。コールバックが成功していればresolveをコールして子Promiseの状態をFULFILLEDに、失敗していればrejectをコールして子Promiseの状態をREJECTEDに遷移させています。resolveをコールすると親Promiseのときと同じようなプロセスを経て子Promiseに対するthenで登録されたコールバックが実行されていくためPromiseチェーンが成立します。

ここまでで以下のコードは動くようになりました。

timer(3).then(time => console.log(`${time}sec elapsed.`), error => console.error(error))

しかしPromiseが失敗したときのコールバックはまだ動きません。そのために次はrejectを見ていきます。

reject

function reject(promise, reason) {
    if (promise._state !== PENDING) return

    promise._state = REJECTED
    promise._result = reason

    if (promise._subscribers.length !== 0) {
        process.nextTick(() => publish(promise))
    }
}

状態をREJECTEDに遷移させているだけで、やっていることはresolveと変わりありません。その後publishinvokeCallbackと続いていくのでPromiseチェーンが成立するのも変わりありません。

これでサンプル内の以下のコードも動作するはず!

timer(7).then(time => console.log(`${time}sec elapsed.`), error => console.error(error))

と思いきやエラーがコンソール上に出力されません。これは意図した結果と異なります。どうしてこうなるのか、もう一度タイマー関数の実装を見てみます。

const timer = (sec) => {
    return new Promise((resolve, reject) => {
        if (sec >= 5) {
            return reject(`Can't wait for more than 5sec. [arg]: ${sec}`)
        }

        setTimeout(() => {
            return resolve(sec)
        }, sec * 1000)
    })
}

Promiseに渡している関数ですが、引数がエラーに該当する場合は同期的にrejectをコールしています。また、これまで見てきたとおり、Promiseのコンストラクタに渡す関数は即実行されます。すなわち、thenでコールバックを登録する前にPromiseの状態がREJECTEDに遷移してしまっているため、Promiseオブジェクトが保持するコールバックのキャッシュがrejectコール時には空であるため状態が変化しても後続の処理が実行されません。これを解決するためには、Promiseオブジェクトの状態が変化した後にthenでコールバックを登録したとしてもそのコールバックが動作するようにしてあげる必要があります。

then、再び

その仕組みはthenの中にあります。

then(onFulfillment, onRejection) {
    const parent = this
    const child = new this.constructor(noop)

    const {_state} = parent

    if (_state) {
        // Promiseオブジェクトの状態がすでにFULFILLEDかREJECTEDに遷移している場合
        // thenの引数とPromiseオブジェクトの状態から、成功/失敗どちらのコールバックを実行するか決める。
        const callback = arguments[_state - 1]
        process.nextTick(() => invokeCallback(_state, child, callback, parent._result))
    } else {
        subscribe(parent, child, onFulfillment, onRejection)
    }

    return child
}

function noop() {}

最初はさらっとスルーしていましたが、thenでは呼び出し元のPromiseオブジェクトの状態によって処理が分岐します。状態がすでに確定している場合はコールバックをprocess.nextTickを介して実行しています(常に非同期で動作することを保証している)。

これで今回のサンプルのコードはすべて動作するようになりました!

まとめ

Promiseの仕組みってどうなっているんだろう?と気になったので、es6-promiseの実装を追ってみました。今回まとめたものは基本の基本だけなので、catchPromise.resolvePromise.allPromise.raceなどの実装がどうなっているのか見てみるのもいいんじゃないかなと思います。

参考

Profile
d_yama
元Microsoft MVP for Windows Development(2018-2020)
Sub-category : Windows Mixed Reality
Search