Closed17

await の書き換えとマイクロタスクの話

nikogolinikogoli

以下の記述の正確性は一切保証できないので、間違っていても許してほしい

モチベ

もともとは、「await の直列処理は reduce がベスト」っていう保証が欲しいという思い

  • メモレベルのブログが量産されていて嫌になったので、もうちょっと根本的な部分から非同期関連を理解しておきたいと調べてみた
    • この点自体についてはまあ、これ↓とかにもあるように、reduce で良かったみたい
    • MDN に記載しておいてほしい

https://azu.github.io/promises-book/#chapter2-how-to-write-promise

nikogolinikogoli

その後

この辺を調べるために色々読んでいて

  • uyho さんの マイクロタスクの話を読み返して、「わかりやすいけど、(コードを見てわかるかどうかという点で) graphical にはわかりやすくないな」と感じた
  • await 1Promise.resolve(1).then(...)とだいたい同じで、async functionfunction() { return Promise.resolve().then() .... と同じだとみなせる』みたいな記述をいくつか見た
      

思ったこと
await の変換を使って async 関数を全部 Promise 型の関数に書き換えてやれば、マイクロタスクの話は (then の数とかで) graphical にわかりやすくなるのでは?

nikogolinikogoli

結果

(すくなくとも自分の知識の範囲からの結論としては) そんなことはなかった
  

  • 上の考えは、「await xPromise.resolve(x).then(...)の2つがマイクロタスク面において等価として書き換えできる」ことを暗黙の前提としている
  • しかし、これはどうも成り立たない (成り立つことは重要視されていない) ようだった
     

より正確には、2つ以上の await がある async 関数に対して、Promise を使った「適切な表現」かつ「マイクロタスク面で同じ挙動」の 非 async 関数は作れない、というのが自分の結論

nikogolinikogoli

次のような async 関数を作って、uyho さんのマイクロタスク時計で挙動を調べてみる

async function poo() {
    const n = await 1
    console.log(n)
    const res = await Promise.resolve(2)
    console.log(res)
    return "poo"
}

clock()
poo().then((v) => console.log(v))

以下を得る (マイクロタスクの変わり目がわかりやすいように表示は調整した)

_______
   0
_______
   1
1
_______
   2
2
_______
   3
poo
_______
   4
表示調整版マイクロタスク時計

この休日の唯一の成果物と言って良いもの (uyho さん作)

 function clock() {
     let time = 0
     rec()
     function rec() {
-        console.log(time);
+        console.log(`_______\n   ${time}`)
         Promise.resolve().then(() => {
             time++;
             if (time <= 10) rec()
         })
     }
 }
nikogolinikogoli

この async 関数は、(おそらく) 以下のような非 async 関数と機能的には等価である(と思う)[1]

function alt_poo(){
  return Promise.resolve(11)
  .then( (v) => {
    const n = v    // ここは冗長。 ".then(n) => {... " でも良い
    console.log(n)
    return Promise.resolve(22)
  })
  .then((v) => {
    const res = v    // ここも冗長。".then(res) => {... " でも良い
    console.log(res)
    return "xx poo xx"
  })
}

clock()
alt_poo().then((v) => console.log(v))

コードの見た目的には、thenで +1 して 1mt でconsole.log(11)、さらにthenで +1 して 2mt でconsole.log(22)、最後のthenで +1 して 3mt でconsole.log("xx poo xx")になるように見える

脚注
  1. 細かい部分はともかく全体として大きな間違いはないと思う。自信はない ↩︎

nikogolinikogoli

しかし、そうはならない。1回目の then のあと、大きな遅れが生じる

_______
   0
_______
   1
11
_______
   2
_______
   3
_______
   4
22
_______
   5
xx poo xx
_______
   6
_______
nikogolinikogoli

理由は↓これらで説明されている
https://zenn.dev/uhyo/articles/return-await-promise

https://zenn.dev/qnighy/articles/0aa6ec47248d80#チェイニング

ざっくり雰囲気でまとめると

  • (async 関数で return Promise.resolve() したときや).then() のなかで Promise が返されたとき、それらは new Promise((resolve, reject))resolveで処理される
  • このとき、返り値の promise の then メソッドを経由して excuter を登録する?処理が挟み込まれる
  • 結果、この処理の分だけマイクロタスク面で遅くなる

要するに、.then()を使ったコードに変えると、見た目はネストによってマイクロタスク+1 の挙動が表現されているように見えるが、実際はマイクロタスク+3 になってしまう

何よりも厄介なのは、1つ目の then のネストの中では遅れはない、つまり async 関数に await が2つ以上あってthen(...)の中にreturn Promise.resolve()が出た後にも処理が続く場合になって初めて、マイクロタスクのずれが表面化することである

nikogolinikogoli

もうちょっとフォーマルに

以下、MDN から引用


async function foo() {
   await 1
}

これは次のものと等価です。

function foo() {
   return Promise.resolve(1).then(() => undefined)
}

(引用ここまで)
これは正しい

では awaitを1つ増やしたの等価な書き換えとは?

async function foofoo() {
   await 1
   await 1
}
nikogolinikogoli

await 1 = Promise.resolve(1).then に準拠して書き換えてみる

function foofoo_alt() {
    return Promise.resolve(1)
    .then( () => Promise.resolve(1) )
    .then( () => undefined )
}

上の alt_poo 関数で見たしたように、これはマイクロタスク的に等価ではない

clock()
foofoo().then((v) => console.log("foofoo"))             // 3mt
foofoo_alt().then((v) => console.log("foofoo_alt"))     // 5mt
nikogolinikogoli

こっちが適切な書き換えなのかもしれない。が、それでも等価ではない[1]

function foofoo_alt() {
    return Promise.resolve(1)
    .then( () => Promise.resolve(1)
        .then( () => undefined )
    )
}

clock()
foofoo().then((v) => console.log("foofoo"))            // 3mt
foofoo_alt().then((v) => console.log("foofoo_alt"))     // 4mt
脚注
  1. ただし処理そのもののタイミングは等価というか、マイクロタスクの順序は「一気に最下層まで降りていく → 内側から外側のthen()へと fulfill していく」となる(っぽい)。なので、foofoo_alt().then(...)としない、つまり最終的な return を受け取るタイミングで評価せずに関数内部でconsole.log()するタイミングのみに注目していると、この2つはマイクロタスク面で等価に見える ↩︎

nikogolinikogoli

このように書き換えると、マイクロタスク面では等価になる

 function foofoo_alt() {
     return Promise.resolve(1)
-    .then( () => Promise.resolve(1) )
+    .then( () => 1 )
     .then( () => undefined )
 }

 clock()
 foofoo().then((v) => console.log("foofoo"))            // 3mt
 foofoo_alt().then((v) => console.log("foofoo_alt"))     // 3mt

しかし、これはawait 1 = Promise.resolve(1)という関係を完全に放棄している

したがって、「await 1Promise.resolve(1).then()と(マイクロタスク面で)等価である」という表現は、await が1つのときのみしか成立しない[1]

脚注
  1. 他にも「それぞれの await 式の後のコードは、.then コールバックの中に存在すると考えることができます。」という MDN の記述と整合的な書き換えはあって、それならうまくいくのかもしれない。が、疲れた ↩︎

nikogolinikogoli

まとめ

以下の2つは確かにマイクロタスク面で等価である

async function foo() {
   await 1
}

function alt_foo() {
   return Promise.resolve(1).then(() => undefined)
}
  • しかしこの関係は、await が1つのときのみの限定的な関係
  • await が2つ以上になったとき、機能面およびマイクロタスク面の両方において等価となる async 関数と Promise を使った非 async 関数の書き換えは、おそらく存在しない
      

理由みたいなもの

  • await 1Promise.then(1)に書き換えることは、複数の await は Promise チェーンに書き換えられることを意味し、retrun Pormise.resolve(1)があることを意味する
  • Promise の return では 呼び出し元が値を受けとるまでにかかる最小microtick数が 3 になる。つまりretrun Pormise.resolve(1)においてマイクロタスクは +3 される
  • 一方、await 1においてマイクロタスクは +1 される
  • したがって、複数の await を Promise チェーンに書き換えることは、必ずマイクロタスク面での処理タイミングのずれを生み出す

逆に言えば javascipt は、絶対的なタイミング?という点でのマイクロタスク面の挙動において、記述ごとに差異が出ることを気にしていないと思われる
  

なので、記述によるマイクロタスクのタイミングの一致・差異を話しても、あんまり意味がないように思う

nikogolinikogoli

感想:MDN の嘘つき[1]。休日を返せ

脚注
  1. と思ったけど、MDN はマイクロタスク面で等価とは言ってないので、別に嘘はついていない。でも休日は返してほしい ↩︎

nikogolinikogoli

また別の話

uyho さんのとこの話 を使うと、async 関数における return 1return Promise.resolve(1)の違いは、以下のように一般化っぽく表現できる

function BASE(X) {
    return new Promise((resolve) => {
      resolve(X)
    })
}


async function just_return() {
    return "JUST return"
}


async function resolve_return() {
    return Promise.resolve( "RESOLVE return" )
}


function alt_just_return(){
    const x = "xx JUST return xx"
    return BASE(x)
}


function alt_resolve_return(){
    const x = Promise.resolve("xx RESOLVE_return xx")
    return BASE(x)
}
_______
   0
_______
   1
JUST return
xx JUST return xx
_______
   2
_______
   3
RESOLVE return
xx RESOLVE_return xx
_______
   4
nikogolinikogoli

問題というか疑問というか困った事

  1. await があるとき、Xは何に対応するのか
  2. await が2つ以上あるときはどうするのか
  3. というか今までの話はどうなるのか
     

もしこのBASEの適用を必須とした場合、MDN の書き換えのPromise.resolve(...をまとめて BASE()に渡す必要があるのだが、当然その分だけマイクロタスクが嵩むことになる

このとき、「マイクロタスク面で一致している async 関数の書き換え」と「原理的に正しい(と思われる) async 関数の書き換え」のうちどちらを正しいとするべきなのか?

nikogolinikogoli

このスクラップの結論:どっちでもいい[1]

脚注
  1. 『機能とマイクロタスクの両方で一致する書き換え』が存在しない場合、スクリプトを動かしても「原理に厳密に正しい書き換え」の正しさはわからないわけで、だったら利用者レベルではその正しさを議論する必要性はないかなと思う ↩︎

nikogolinikogoli

https://tc39.es/ecma262/#sec-runtime-semantics-evaluateasyncfunctionbody

https://zenn.dev/qnighy/articles/3a999fdecc3e81#async関数脱出時のタイミング仕様

↑にも書いてあるが、仕様を眺めた感じからすると、async 関数はその返り値が何であれ、Promise でラップしてその resolve に 結果を突っ込んでいるように見える。

ということは、await を Promise.resolve().then() に書き換えた結果を、さらに BASE() にわたすのが『正しい書き換え』に思われる

しかしその場合、明らかにそのマイクロタスクのタイミングは元の async 関数とは一致しないわけで、「await の書き換え」をマイクロタスクのタイミングと結びつけること自体が無理だったのかもしれない

このスクラップは2022/04/11にクローズされました