$shibayu36->blog;

株式会社はてなでエンジニアをしています。プログラミングや読書のことなどについて書いています。

Nodeのイベントループを理解するために遊んだ & Apolloのテストでawait wait(0)するとなぜデータがロードされるか

Apolloを触っていて、テストをするために https://www.apollographql.com/docs/react/development-testing/testing/#testing-final-state を読んでいた。その文章の中で、

  • MockedProviderをrenderした時はloading状態になる
  • データがロードされた最終状態にするにはwaaitみたいなnpm packageを使って、await wait(0)とかしてね
    • It delays until the next "tick" of the event loop, and allows time for that Promise returned from MockedProvider to be fulfilled.

と書かれていて、この文章の意味がわからなかったので、Nodeのイベントループを知るためにちょっとだけ遊んだ。あまり詳しくないので内容が正しいか不明だし、むしろ間違ってる可能性の方が高そうなのでもし間違っていたら教えて下さい...

参考資料

以下2つが非常に参考になった。

Promiseを使って実行順がどうなるか見る

参考資料を前提に以下のサンプルコードを実行して、logがどの順に出力されるか観察する。

// 初めからresolveしているPromiseの実行順
async function func1 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });

  console.log('bottom');
}

// 途中でsetTimeoutでresolveするPromiseをawaitする
async function func2 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });
  let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
  promise2.then(function(value) {
    console.log(value);
  });
  await promise2;

  console.log('bottom');
}

// timeoutが先
async function func3 () {
  let promise1 = new Promise(resolve => setTimeout(() => resolve('promise1')));
  promise1.then(function(value) {
    console.log(value);
  });

  let promise2 = Promise.resolve('promise2');
  promise2.then(function(value) {
    console.log(value);
  });
  await promise2;

  console.log('bottom');
}

// func2のpromise2のthenをawaitの後に書く
async function func4 () {
  let promise1 = Promise.resolve('promise1');
  promise1.then(function(value) {
    console.log(value);
  });
  let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
  await promise2;
  promise2.then(function(value) {
    console.log(value);
  });

  console.log('bottom');
}


(async function main () {
  console.log('======= func1 =======');
  await func1();
  // bottom -> promise1

  console.log('======= func2 =======');
  await func2();
  // promise1 -> promise2 -> bottom

  console.log('======= func3 =======');
  await func3();
  // promise2 -> bottom -> promise1

  console.log('======= func4 =======');
  await func4();
  // promise1 -> bottom -> promise2
})();

まずfunc1。これはbottom -> promise1の順でログが出力される。初めからPromise.resolveしていたとしてもthenの処理が即座に実行されるわけではなく、microTaskQueueというところに入れられているため、現在の処理が終わった後(bottomが出力された後)にpromise1が出力されると考えられる。

次にfunc2。これはpromise1 -> promise2 -> bottomの順でログが出力される。promise2がsetTimeoutでPromiseをresolveしているため、Timers Phaseまで待たないと次に進めない。つまり次のように処理が実行されていると考えられる(勘で言っていて自信がまったくない、Queueの状態を出力できれば正しくわかりそうだけど...)。

  • promise1のthenがmicroTaskQueueに入れられる
  • promise2の作成処理で、Expired timers / intervals queue に、resolveする関数が入れられる
  • promise2のthenがmicroTaskQueueに入れられる
  • promise2でawaitするのでTimers Phaseでresolveされるまでそこでブロック
  • promise1は既にresolveされているので、Timers Phaseを待つことなく、現在のPhase(microTaskQueueの処理Phase)で処理される
    • ※promise1がログ出力
  • Timers Phaseに来るとsetTimeoutの処理が実行されて、promise2がresolveされ、microTaskQueueのpromise2.thenが実行可能になる
  • Timers Phase終了後、microTaskQueueを見てpromise2.thenのコールバックが実行可能なので、処理される
    • ※promise2がログ出力
  • awaitを過ぎたのでfunc2の処理が再開
    • ※bottomがログ出力


次にfunc3。これはpromise2 -> bottom -> promise1の順でログが出力される。これはおそらく以下のようになっている?

  • promise1のthenがmicroTaskQueueに入れられる(が、setTimeoutでresolveされるので、Timers Phaseの処理が終わらないとこのタスクは実行できない)
  • promise2のthenがmicroTaskQueueに入れられる
  • await promise2では、既にpromise2はresolveされているのでthenコールバックが実行される
    • ※promise2がログ出力
  • func3のメイン処理が再開
    • ※bottomがログ出力
  • Timers Phaseに到達し、promise1がresolveされる
  • Timers Phase終了後、microTaskQueueを見てpromise1.thenのコールバックが処理される
    • ※promise1がログ出力


面白いのはfunc4。func2の処理をちょっと変えて、awaitの後にthenを書いただけだが、これはpromise1 -> bottom -> promise2と出力される(func2ではpromise1 -> promise2 -> bottom)。これはasync/awaitをPromiseの構文に直してみると分かるかも?

(これであってるのか...?)

function func4 () {
  return new Promise(resolve => {
    let promise1 = Promise.resolve('promise1');
    promise1.then(function(value) {
      console.log(value);
    });
    let promise2 = new Promise(resolve => setTimeout(() => resolve('promise2')));
    promise2.then(function(value) {
      promise2.then(function(value) {
        console.log(value);
      });

      console.log('bottom');

      resolve()
    });
  });
}

こうすると、console.log('bottom')が出力されるthenが先にmicroTaskQueueに積まれ、その後にpromise2が出力されるthenがmicroTaskQueueに積まれるので、bottom -> promise2になっていそうに見える。

なぜテスト時にawait wait(0)とするとApolloのデータがロードされるか

上記で色々実験したが、Timers Phaseのことが理解できていれば、なぜテスト時にawait wait(0)とするとApolloのデータがロードされるかが分かる。

まず、MockedProviderに渡されるMockLinkのrequestメソッドが、setTimeout(..., 0)でcompleteさせるObservableを返している(この辺)。このため、requestメソッドが呼ばれた瞬間はデータがロードされず、次のTimers Phaseが訪れたときにデータがロードされるようになる。

また、waaitパッケージのwait関数は単純にsetTimeoutでresolveするPromiseを返しているだけである(参考)。つまりawait wait(0)のやっていることはawait new Promise(resolve => setTimeout(resolve, 0));というだけ。

これらから

  • MockedProviderを使うと、Timers PhaseのキューにApolloのクエリのデータをロードするコールバックが登録される
  • await wait(0)で、Timers Phaseにresolveするだけのコールバックが登録され、awaitでresolveを待つ
  • キューはFIFOなので、先にApolloのクエリのデータをロードするコールバックが実行される
  • await wait(0)のためのresolve()が呼ばれ、awaitを抜ける

となり、await wait(0)が終わった頃にはデータがロードされているということだろう。


このような仕組みなので、setTimeoutを使わずに、単純にresolveされたPromiseを待ったり、setImmediateでresolveされるPromiseを待ったりするだけではロードされている保証がなさそう。

単純にresolveされたPromiseを待つ

const promise = Promise.resolve('resolved');
await promise;

setImmediateでresolveされるPromiseを待つ

await new Promise(resolve => setImmediate(resolve));

まとめ

今回はApolloのテストの挙動を理解するために、Nodeのイベントループの仕組みについて調べて、少し遊んでみた。実証するレベルまでは今回は出来ず、内容が正しいかは保証できないので、もし間違っているところがあったら教えて下さい...

参考