🐩

jsが非同期処理をシングルスレッドで実現する仕組み〜Web API、イベントループ、MicrotaskとしてのPromise〜

2023/01/10に公開

シングルスレッドのjsがどうやって非同期処理を実現しているのかという疑問から始まり、Web API、イベントループ、MicrotaskとしてのPromiseについてザッとみていく記事です👶
後半ではイベントループにおけるマイクロタスクの実行順序をJavaScript Visualizer 9000(こういうもの↓)を使って実際に目でみてみます👶
JavaScript Visualizer 9000でsetTimeout()やPromiseが混ざったスクリプトを実行している様子

疑問その1: 「シングルスレッドじゃ非同期処理できなくない?」

  • jsはシングルスレッドである。ざっくり言えば、複数の処理を並行して行うことはできないことが言語仕様から由来するさだめなのである
    • js誕生当時はシングルコアが当たり前で、コード量も少なかったから「シングルスレッド上等!」だった

      JavaScript is an inherently single-threaded language. It was designed in an era in which this was a positive choice; there were few multi-processor computers available to the general public, and the expected amount of code that would be handled by JavaScript was relatively low at that time.

    • Web Workerの話は考えない

  • いや、そしたら非同期処理ってなんやねん
    • ここでいう非同期処理とは「時間のかかる処理が始まった時、それが終わるのをただ待つのではなく、他の処理を進めながら待ち、終わり次第いい感じに合流するような進め方」を指す
    • こんなんシングルスレッドじゃ無理やん
      • 例えばFetch APIの非同期処理にしても、そもそもリクエストを「待つ」ってのもけっこう大変なこと(リクエストが返ってきているかを確認したり、タイムアウトの判定をしたり)のはず。jsがシングルスレッドなら、なぜ他の処理をしながらそんなことができるの?どういうこと????

疑問その1への答え: 「外部のAPIがいるんです」

  • 実は、jsは多くの処理を実行環境のAPIにまかせている
    • シングルスレッドである以上、非同期処理(=時間のかかる処理が始まった時、それが終わるのをただ待つのではなく、他の処理を進めながら待ち、終わり次第いい感じに合流するような進め方)を実現したいなら、もう誰かほかの人を連れてくるしかない

      • 時間のかかる処理は他の人に進めてもらい、終わったら知らせてもらうしかない
    • そのため実際、fetch(), setTimeout()といった非同期処理が実施されるメソッドはその処理を外部のAPIが肩代わりしている

      • ここでいう「外部のAPI」とは、正確には「実行環境が提供するAPI」と言うべき
      • 実際ブラウザがどんなAPIを提供しているのかは、MDNのWeb API(ブラウザAPI)一覧でみれる
    • これらAPIはjsではなく実行環境が担保するものなので、その仕様はjs側(=ECMAScript)で規定されているものではない。実行環境側で規定されている

      • 言うなれば、私たちがjsだと思っているものはjsの言語規格(ECMAScript)の守備範囲に、実行環境が提供するAPIを組み合わせたものだといえる(”JS = Browser APIs + JS” by https://dillionmegida.com/p/browser-apis-and-javascript/)
      • 例えば、setTimeout()やsetInterval()を担うTimersのspecはECMAScriptにはない
        • ブラウザのTimersのspecはwhatwgが出している(下記は実際のspecへのリンク)。chromeやfirefoxなどはこれをみながら実装してくれている
        • Node.jsとかDenoとか色々な実行環境がそれぞれがんばってTimersを作っている
          • TimersをNode.jsで再実装した話がここで読める
          • 補足: Timersが実行環境側に委ねられていることが分かっていると、JestでfakeTimerをぶっこめる理屈もなんとなくイメージできる。Timersの代わりにfakeTimerを外からプラグインとして与えるようにしているってわけよね
            • 補足: JestはJasmine、つまりNode.jsのうえに作られたフレームワークのうえになりたっている。要するにNode.jsで動いている
      • 例えば、Fetch API本体の実装だってjsにあるわけでもV8にあるわけでもない。chromiumが持っている。例えば下記リンク先の場所に実装があったりする(※ちょっと古いレポジトリにも見えるから違う気もする!!!)
        • 余談: fetch()の仕様策定の歴史は面白い。下記で読める
      • 例えば、DOMへのアクセスもブラウザが提供している。documentやwindowと書けばDOMにアクセスできたり、DOMになんらか処理を施せたりというのは、ブラウザが提供しているWeb APIが橋渡しをしてくれているから
        • だから、Node.jsはNode.jsでそういうものを持っておかないと、DOMの操作をするjsスクリプトのテストなんかをNode.js上で実行できないことになる。そこで、Node.jsのDOM操作APIの役割を果たすのが例えばjs-domである

          • したがって、Node.js上でDOMに関わるテストができるライブラリである「testing-library」もjs-domを必要とすることになる。公式ドキュメントにも、Jestなしで使うならjs-domの設定ちゃんとやってねって書いてある

            jsdom is a pure JavaScript implementation of the DOM and browser APIs that runs in Node. If you're not using Jest and you would like to run your tests in Node, then you must install jsdom yourself. There's also a package called global-jsdom which can be used to setup the global environment to simulate the browser APIs.

            • 補足: testing-libraryが「Jestと一緒に使うならjs-domのことは特に気にしなくていいよ」って言ってくれているのは、Jestはそれがインストールされたとき、js-domを上手く使えるように設定してくれもするから
              • 本来Jestはあくまでもテストフレームワークで、その言葉通りに受け止めるなら、Jestの責任はspec.tsxとかを集めてテストを走らせてくれるだけでしかないけど、ご丁寧にやってくれる
        • 補足: Denoはdeno-domとかLinkeDOMってのを持っていて、js-domよりそっちのがいいよと言っている。また、Denoは自身の機能としてテストフレームワーク機能も持ってる

          If you are interested in server side rendering, then both deno-dom and LinkeDOM are better choices. If you are trying to run code in a "virtual" browser that needs to be standards based, then it is possible that jsdom is suitable for you.

    • この前のJSConfでも取り上げられていたWinterCGという団体は、このように実行環境ごとにAPIの仕様が一様ではない状況をなんとかしようとしてくれている

      JavaScript は様々な理由から、ブラウザと直接の関係がない場所でも使われるようになってきました。Node.js や deno のみならず、Cloudflare の edge computing などにおいても JavaScript は広く使われています。そういった環境で標準化されていない API をサポートするときには各所それぞれが独自の実装をしていました(今回の記事の Node.js の setTimeout 実装がよい例になっていると思います)。この状態が続くと、例えば Node.js で書いたコードが deno や他の非ブラウザー環境で動かなくなる、というような事態が懸念されます。それを防ぐために Cloudflare などが中心となって WinterCG という団体を作って活動しています。

疑問その2: 「そのたくさんある外部のAPIを誰がまとめるのよ」

  • jsが外部APIを利用して非同期処理が実現されることはよくわかったけど……
  • 冷静に考えて、このたくさんの外部APIたち相手に誰がどう指揮を執るんだ??
    • 要するに、fetch()とかsetTimeout()とか色々なAPIが「おわったよ」とか「エラーでした」とか返事をしてくるわけで、それに応じて色々処理を切り替えてとか、そんな複雑なことをシングルスレッドでやりきれるのか!?

疑問その2への答え: 「イベントループというものがあるんです」

  • この点でもやはり、jsは実行環境のちからを借りている
    • jsはシングルスレッドなのに、外部の力を借りてマルチスレッド的状況を作っているといえる。そしたら、そのマルチスレッド的状況をjsだけで管理しようというのも無策なわけ
  • 具体的には、イベントループという仕組みがある。このイベントループというのは、めちゃくちゃざっくり言うと「その実行環境において処理されるべき色々なものごと(jsスクリプトに加えて、ブラウザならレンダリングなども含む)を整理して、何をどんな順番でやるのか整理してくれる頼れる人」って感じ
    • 死ぬほどざっくりいくと、jsの実行→レンダリングを繰り返すってこと

    • でもさすがにもう少し理解したほうが良い

      • JAVASCRIPT.INFOのこの章がめちゃくちゃ分かりやすいからまずは必読

        上記リンクから、エンジンの一般的なアルゴリズムを引用

        上記リンクから、エンジンの一般的なアルゴリズムに関する図を引用

    • 実際にイベントループを目で見て理解できるツールがあるのでやってみよう!!!

      • タスクをとってくる→コールスタックに積んでいって消化する→レンダリング→タスクをとってくる→……という流れになっていることがわかる!!!
    • setTimeoutとかsetIntervalというのは、要するに「XX秒後にキューにタスクを追加する」ってことで、厳密にXX秒後に実行されるわけではない

  • 補足: window, worklet, workerでイベントループは別々に管理されているとか、異なるタブのイベントループが場合によっては同じとか、そういう話も知りたかったらここにある
  • 補足: イベントループの細かな仕様も、Web API同様に、jsそのものではなくて実行環境側で定められている。つまり、細かい仕様はまたもやブラウザやNode.jsなど実行環境ごとに規定され、実行環境ごとに異なった実装がなされている
    • 補足: ブラウザ向け仕様はwhatwgさんが管理してる
    • 補足: Node.jsは非同期I/Oに強いlibuvというライブラリをイベントループに組み入れている。詳しくは下記リンク先参照
      • 余談: ブラウザとNode.jsのイベントループの違いがさくっとまとまってそうな記事(未読)
      • 余談: react nativeのjsランタイムであるHermesにも独自のやり方があったりするのかな?
    • 補足: いちおうECMAScriptにもイベントループの抽象的な定義はされている。ただ、細かい部分までは定義されてきっておらず、特にMicrotask(後述)とPromiseの関係が曖昧である
      • 余談: 興味ある人はこのあたりが該当するはずだから読んでみるとよい

      • 余談: このPromiseとMicrotaskの関係の曖昧さが2015年頃にブラウザ間で、Promiseの処理やレンダリング処理の実行タイミングの違いを引き起こしていたという記述もある(いまはブラウザ間で動作は揃っている模様)

        Firefox and Safari are correctly exhausting the microtask queue between click listeners, as shown by the mutation callbacks, but promises appear to be queued differently. This is sort-of excusable given that the link between jobs & microtasks is vague, but I'd still expect them to execute between listener callbacks.

      • setTimeoutにより追加されたタスク実行とイベントリスナー, レンダリングのタイミングの話をしている記事が他にもある。その中でも例えばこの記事は2015年で、こういうことが「Promiseの処理やレンダリングのタイミングの違い」に起因して起きていたのかも?

疑問その3: 「そのイベントループとやらは、どんなタスクをどんな順で繰り返すのよ」

  • 冷静に考えると、ブラウザでは色々なことが起きている
    • アニメーション、イベントによるコールバック発火、バブリング、イベントリスナーの登録、DOM変更によるコールバック発火……などなど
  • そういう細かいことの順序も知りたくなっちゃった

疑問その3への答え: 「分かった、イベントループの詳細をみてみよう(MutationObserverの細かい挙動なども含めて)」

  • 調べてみると、Blinkは詳細な仕様をドキュメントにはしていないことに勘付く

    • めちゃくちゃ掘ったけど分かりやすい図は見つからなかった……

    • コードをみてみたけど、さすがに難しかった

    • 先人の言葉をみて、やはりそうなのかとなる

      Chrome ブラウザ環境のイベントループの実装については実はほとんど情報がありません。Chrome ブラウザで利用されている JavaScript エンジンである V8 はデフォルトのイベントループを提供していますが、基本的に外部からプラグインして実装するようになっています。

      • なお、上記の記事は本当にわかりやすいので必読
  • というわけで、色々な動画や先人の実験結果をもとに推察してみるしかない……

  • と思いきや、まとめてくれた方がいる!!

    ブラウザ環境のイベントループの擬似コード(上記リンク先より引用)
    while (true) {
        queue = getNextQueue();
        task = queue.pop();
        execute(task);
    
        while (micortaskQueue.hasTasks()) {
            doMicrotask();
        }
    
        if(isReapintTime()) {
            animationTasks = animationQueue.copyTasks();
            for(task in animationTasks) {
                doAnimationTask(task);
                while (micortaskQueue.hasTasks()) {
                    doMicrotask();
                }
            }
            repaint();
        }
    }
    
    • 何がタスクなのかはwhatwgが決めている
      • イベント(Event)
      • パース(Parsing)
      • コールバック(Callbacks)
      • リソースの使用(Using a resource)
      • DOM 操作への反応(Reacting to DOM manipulation)
  • このコードをみつつ、この動画の再生ボタンを押す(見てほしい位置から再生されます)とめちゃよくわかる

https://www.youtube.com/watch?v=cCOL7MC4Pl0&t=1655

  • 上記の擬似コードから分かる「やってはいけないこと」として、Microtask(後述)と呼ばれるものは、もしMicrotaskがMicrotaskを作り続けるようなコードにしてしまうと永遠のトラップに陥ってしまうということがある
  • その他のめちゃ細かいルールで興味深いのは、このあたり
    • MutationObserverによりセットされたコールバックは、1つのjsスクリプトコンテクストにつき1回しか呼ばれない(=特定のコールバックが紐付けられた特定のDOM操作を、1つのjsスクリプトコンテクスト内でどれだけ繰り返し行っても、紐付けられたコールバックは一度しかキューに登録されない)
      • whatwgさんの仕様にもある

        To queue a mutation observer microtask, run these steps:

        1. If the surrounding agent’s mutation observer microtask queued is true, then return.
        2. Set the surrounding agent’s mutation observer microtask queued to true.
        3. Queue a microtask to notify mutation observers.
      • 下記サイトの3つ目の例をみると実際に体感できる

      • mutationObserverへのレコード登録は毎回される。リンク先のjsbinをためすといい

        • mutationObserverのこの独特の仕様は、mutationObserverを90年代に導入するときに課題になっていた「(ルートドキュメントに対する)1スクリプト内での大量のイベント発生」を念頭においていそう(だし、それは実際そうすべきだろう):
    • スクリプト内でイベントを発生させる(e.g. click())と、イベントを発生させたjsスクリプトコンテクストが継続して、そのコンテクストの中でイベントによって呼ばれたコールバックが処理される
      • 下記サイトの3つ目の例をみるとよい
    • 「イベントに反応してコールバックをタスクに入れる」という1つのタスクの中で、バブリングも一気に処理される(=バブリングは他タスクによって中断されず、ひとまとまりで行われる)
      • このルールはあくまでも下記リンクの実験結果から推察されることなので、仕様を見つけたわけではないです
  • イベントループの詳細を知るにあたりこれもわかりやすい

疑問その4: 「Microtaskって出てきたけどそれは何ですか」

  • イベントループに書いてあったMicrotaskってなんやねん

疑問その4への答え: 「Microtaskってのはタスクの合間にまとめて処理されるタイプのタスク群のことです。Promiseとかがそれなのよ」

  • Microtaskとは「タスク群の実行の合間に、もしくはアニメーション関連タスク群の実行の合間に、まとめて処理されるタスク」ってことが先の擬似コードから分かる
    • 優先的に処理されるタスクと言い換えることもできる

    • ある処理を優先的に実行したい、複数の処理を他のタスクに割り込まれずひとまとまりで実行したい。そういうときに使える!!!!

      マイクロタスクを使用する主な理由は次のとおりです。結果やデータが同期的に利用できる場合でも、タスクの一貫した順序付けを保証すると同時に、ユーザーが識別できる操作の遅れのリスクを低減するためです。

      • マイクロタスクの実際のユースケースがここにまとまっている
    • Microtaskの導入は90年代で、そのモチベーションはMutationObserverのハンドリングだったっぽい

      • これをみるとわかる
  • 何をMicrotaskとして扱うかはECMAScriptは定義しておらず、ブラウザ側で決められる
    • ただし、いまはもう大体揃っていて下記3つで呼ばれるコールバック関数はMicrotaskとしてキューに入るのだと思っておけばほとんど間違いない
      • MutationObserverで登録しておいたコールバック関数
      • Promiseの解決時に呼び出されるコールバック関数
      • queueMicrotask()で登録されたコールバック関数
    • addEventListener()で登録したコールバックが発動しても、それはMicrotaskではない!タスク。
      • 補足: addEventListenerの登録は同期的に行われる

疑問その5: 「やばい、Promiseってどんな感じだったっけ」

  • いい機会だしもう一度Promiseのことをじっくり考えてみよう……

疑問その5への答え: 「Promiseの使い方を復習してみよう」

  • 余談: PromiseはES2015(ES6)にて正式にECMAScriptに入れられたわけで、そのときにはすでにMicrotaskなる概念が存在したわけ。なので、ECMAScriptのスレッドにPromise導入時、それをMicrotaskにするか議論した形跡があったりする。下記でECMAScriptコミュニティでの実際の議論がみれる

    • Promiseはコールバック地獄をなくすために導入されたとされる(要出典)。じつは当時、すでにjQueryとかでPromiseっぽいものは使われていた
    • async / awaitの正式追加はES2016 / ES2017
    • Promiseは冷静に考えるとけっこうすごい代物で、つくってみると理解が深まる。下記の記事がおすすめ
    • 補足: promisificationという話がある
  • Promiseの要点としては、下記をおさえておけば問題なさそう

    • Promiseを新たに作るときは、引数として与えた関数は同期実行される
    • Promiseがresolved(settledではないはず……resolveでPromiseを返した時もここでは含めたいので。ちょっと自信ないけど)になった時点で、thenやcatchによって後続で設定されていたコールバックがMicrotaskとしてイベントループのキューに追加される
  • Promiseを例として、イベントループを実際にJavaScript Visualizer 9000で見ることができます。これは本当に理解が進むので色々なパターンをやってみるとよさそう

    • 実際にJavaScript Visualizer 9000でみれる!①通常の関数呼び出し、②setTimeout()で積まれたタスク、③Promiseで積まれたMicrotaskが①→③→②の順に消化されていくことがわかる: 下記を実際にJavaScript Visualizer 9000で試してみる
      JavaScript Visualizer 9000でsetTimeout()やPromiseが混ざったスクリプトを実行している
    • thenによるチェーンは、最初の処理が実行されるとそこから一気に実行される: 下記を実際にJavaScript Visualizer 9000で試してみる
      JavaScript Visualizer 9000でPromiseのthen()をチェーンしたスクリプトを実行している
      • 先述の通り、MicrotaskがMicrotaskを作り続けるようなコードにしてしまうと次のタスクにいけなくなってしまうことが分かる。その間はレンダリングもブロックされるため、長きにわたると、ブラウザの処理速度が目に見えて落ちる結果を招く
    • Promise起因のコールバック呼び出しはPromiseがresolvedになったタイミングで呼ばれる: 下記を実際にJavaScript Visualizer 9000で試してみる
      JavaScript Visualizer 9000でPromiseのthen()がPromiseのresolve()が呼ばれた時点でMicrotaskに追加されることを確認している
      • fetch()などの非同期処理を実現するAPIは、要するにその内部でPromiseの状態をsettled(fulfilledかrejected)にしていると思われる。ちなみにJavaScript Visualizer 9000でもfetch()を動かせるのでやってみると、こんな感じになる: 下記を実際にJavaScript Visualizer 9000で試してみる
        JavaScript Visualizer 9000でfetch()のPromiseの解決が、同じスクリプト内で同期的にresolvedされているPromiseの解決よりも遅れることを確認している
    • 若干の注意: Promiseオブジェクトを作る時に引数に入れる関数は同期的に処理される(下記参照)
  • PromiseがMicrotaskであるおかげで、非同期処理まわりの処理順序を確定できる。下記のような関数を実行した時、処理Bがどれだけ時間を必要としても/どれだけ一瞬で終わろうとも、Aは処理Bのあとに行われることが約束されている(もちろんマシンスペックにもよらない)

    const res = fetch('aaaaa')
    res.then(処理A)
    
    //処理B
    
    return
    
  • このMicrotaskとしてのPromiseと通常のタスクの実行のされ方の違いを用いて、処理を工夫することができる。実際の例をリンク先でみるといい。例えばめちゃ長い処理があった時、setTimeout()でぶつぎりにすればレンダリングが防がれないようにできる

  • ちなみにawaitは、『そのasync関数の残り全ての部分を、「awaitされるPromise」がresolvedになった時点でMicrotaskとしてキューに入れる仕組み』だと思っておけばよさそう

    function resolveAfter2Seconds() {
      return new Promise(resolve => {
        setTimeout(() => {
          resolve('resolved');
        }, 2000);
      });
    }
    
    async function asyncCall() {
      console.log('calling');
      const result = await resolveAfter2Seconds();
    	// ここから下がresolveAfter2Seconds()にthenでつながれたものとみなされる
      console.log(result);
      console.log("hi")
    }
    
    asyncCall();
    // calling
    // resolved
    // hi
    
    // これと同じ
    async function asyncCallrewrited() {
      console.log('calling');
      resolveAfter2Seconds().then((result) => {
        console.log(result);
    		console.log("hi")
      })
    }
    
    // これは違う
    async function asyncCall2() {
      console.log('calling');
      resolveAfter2Seconds().then((result) => {
        console.log(result);
      })
      console.log("hi")
    }
    
    asyncCall2()
    // calling
    // hi
    // resolved
    
    • このように考えると、あるPromiseに対してawaitするときは、そのPromiseの結果とは無関係に実行できる処理を巻き込まないように注意しないといけない!!

      You'll probably use async functions a lot where you might otherwise use promise chains, and they make working with promises much more intuitive.
      Keep in mind that just like a promise chain, await forces asynchronous operations to be completed in series. This is necessary if the result of the next operation depends on the result of the last one, but if that's not the case then something like Promise.all() will be more performant.

      • 例えばこういうこと

        // 例えば同時に実行できるfetchを待たせてしまうとか、
        function inefficientFetch() {
          const fetch1 = fetch('1')
          const fetch2 = fetch('2')
          const fetch3 = fetch('3')
          Promise.all(fetch1, fetch2, fetch3).then(console.log(fetch1, fetch2, fetch3))
        }
        
        async function efficientFetch() {
          const fetch1 = await fetch('1')
          const fetch2 = await fetch('2')
          const fetch3 = await fetch('3')
          console.log(fetch1, fetch2, fetch3)
        }
        
        // 例えば全然関係ない処理を待たせてしまうとか
        async function notRelatedAwait() {
        	const fetch1 = await fetch('1')
        	const notRelatedResult = notRelatedCalc()
        	return calc(fetch1, notRelatedResult)
        }
        // ↑
        // notRelatedCalc()はfetch1の結果に左右されない関数
        
        // これなら下記のようにして実行の順序を逆にしたほうが良い
        async function notRelatedAwait() {
        	const notRelatedResult = notRelatedCalc()
        	const fetch1 = await fetch('1')
        	return calc(fetch1, notRelatedResult)
        }
        

疑問その6: 「イベントループを体得したいんだけどどうしよう」

  • イベントループを文字通り体得したい……!

疑問その6への答え: 「こういう事例とかみてみるともっとわかるよ」

下記のクイズ群を何回かやってみるとイベントループやMicrotaskの実行のされ方がよくわかるようになる気がする。1つ目が特におすすめ

GitHubで編集を提案

Discussion