Chienomi

JavaScriptの非同期があんまり非同期じゃない話

プログラミング::lang

本記事はJavaScriptの話だが、基本的にはNode.jsの話だと思って欲しい。 ただし、本内容は(少なくともChromiumの)クライアントサイドJavaScriptでも同じ挙動を示す。 また、Node.jsだがES Moduleを前提にしている。

まず、次のようなモジュールを書くとする。

const globalConfig = {}

export {globalConfig}

ファイルAで次のようにセットする:

import {globalConfig} from './config.js'

globalConfig["foo"] = "A"

ファイルBで次のように読む:

import {globalConfig} from './config.js'

console.log(globalConfig)

さて、これでHTTPサーバーを起動し、AとBをそれぞれ異なるAPIにおいて呼び出すとする。 Bが呼び出されたとき、どんな値が返るだろうか。

結論から言うと、ロードされたモジュールはメモリ上に置かれ、何度ロードしようがメモリ上にある以上すべきことがないため、モジュールはメモリ上で単一のインスタンスである。 このため、ファイルAで代入したあとファイルBで参照すると

{"foo": "A"}

が返る。

では、このオブジェクトを各APIが非同期で好きに更新して問題は起きないのだろうか?

モジュール名前空間の理解をもう少し深める

クライアントサイドスクリプトで考えてみよう。 まず、土台のなるページ。

<html>
  <head>
    <script src="1.js"></script>
  </head>
  <body><p>TEST</p></body>
  <div id="SC"></div>
</html>

1.js2.jsをロードするscript要素を追加するスクリプト

var load2 = async () => {
  var sc = document.getElementById("SC")
  var scr = document.createElement("script")
  scr.src = "./2.js"
  sc.appendChild(scr)
}

これでコンソールからload2()を呼べば2.jsがロードされる。 2.jsglglというクロージャ関数を返す。

var glglobj = {}
var glgl = () => glglobj

これはscriptでロードするたびにロードし直されてしまう。

load2()
glgl()
// {}
glgl().a = 100
// {"a": 100}
load2()
glgl()
// {}

だが、importは重複したロードをしない。 3.js4.jsからglglobjを参照するクロージャをエクスポートする。

import {glglobj} from './4.js'

const glgl = () => glglobj

export {glgl}

4.jsは単にglglobjをエクスポートするだけだ。

const glglobj = {}

export {glglobj}

3.jsをモジュールとしてロードしてしまうとglglにアクセスできないので、import()でロードする。

let js3 = await import("./3.js")
js3.glgl()
// {}
js3.glgl().a = 100
js3.glgl()
// {a: 100}
js3 = await import("./3.js")
js3.glgl()
// {a: 100}

単一のモジュールは単一の名前空間に属し、再ロードはされないことが分かる。 そしてこれは、Chromium/FirefoxでもNode.jsでも同じ挙動だ。

共有オブジェクトへの非同期アクセス

疑問

この方法を使えば、windowglobalオブジェクトを使ったり、Express.jsのreqへの埋め込みを行ったりしなくてもサーバープロセスで共有する設定オブジェクトなどを保持できる。

だが、ここで疑問が生じるかもしれない。 JavaScriptではasyncを使うことで非同期処理を行うことができる。 多くのブログ等ではasyncすると並列処理され、他の処理の間で割り込んで処理されるという説明がなされる。

一方で、いくつかの記事ではJavaScriptはシングルスレッドであり、リソースの競合を気にする必要はないということを説明される。

しかし、並列処理されるのであれば他の処理によって状態は変更される可能性があるし、シングルスレッドで割り込まれる余地がないのであればそれは同期処理なので、これらの説明は不十分であると分かる。

このため、これらは共有されたオブジェクトの非同期の更新が安全かどうかの答にならない。

そこで、実際にJavaScriptのasyncはどのように処理されるのかを見ていこう。

非同期から非同期を呼んで更新

「共有オブジェクトを更新する非同期関数を待ち合わせせずに10個呼び、共有オブジェクトが自身の更新がなされているか確認し、合計100回まで再帰呼び呼び出しをする非同期関数を4つ待ち合わせせずに呼び出すことを1000回繰り返す」という処理を試す。

これは、待ち合わせなしに非同期関数を待ち合わせなしに4000000個呼び出すものであり、「並列処理」という観点から言えば普通は整合性が取れない。

const test = {}

const setTest = async (sym, val) => {
  test[sym] = val
}

const called = {
  a: 0,
  b: 0,
  c: 0,
  d: 0
}

const a = async () => {
  setTest("a", "A")
  setTest("b", "A")
  setTest("c", "A")
  setTest("d", "A")
  setTest("e", "A")
  setTest("f", "A")
  setTest("g", "A")
  setTest("h", "A")
  setTest("i", "A")
  setTest("j", "A")
  for (const x in test) {
    if (test[x] !== "A") {
      console.log(test)
    }
  }
  if (called.a < 100) {
    called.a++
    a()
  } else {
    console.log("A is end")
  }
}
const b = async () => {
  setTest("a", "B")
  setTest("b", "B")
  setTest("c", "B")
  setTest("d", "B")
  setTest("e", "B")
  setTest("f", "B")
  setTest("g", "B")
  setTest("h", "B")
  setTest("i", "B")
  setTest("j", "B")
  for (const x in test) {
    if (test[x] !== "B") {
      console.log(test)
    }
  }
  if (called.b < 100) {
    called.b++
    b()
  } else {
    console.log("B is end")
  }
}
const c = async () => {
  setTest("a", "C")
  setTest("b", "C")
  setTest("c", "C")
  setTest("d", "C")
  setTest("e", "C")
  setTest("f", "C")
  setTest("g", "C")
  setTest("h", "C")
  setTest("i", "C")
  setTest("j", "C")
  for (const x in test) {
    if (test[x] !== "C") {
      console.log(test)
    }
  }
  if (called.c < 100) {
    called.c++
    c()
  } else {
    console.log("C is end")
  }
}
const d = async () => {
  setTest("a", "D")
  setTest("b", "D")
  setTest("c", "D")
  setTest("d", "D")
  setTest("e", "D")
  setTest("f", "D")
  setTest("g", "D")
  setTest("h", "D")
  setTest("i", "D")
  setTest("j", "D")
  for (const x in test) {
    if (test[x] !== "D") {
      console.log(test)
    }
  }
  if (called.d < 100) {
    called.d++
    d()
  } else {
    console.log("D is end")
  }
}

for (let i = 0; i < 1000; i++) {
  a()
  b()
  c()
  d()
}

だが、これはうまくいくのだ。 このコードは、すべて同期処理である場合と同じように処理される。

これにより、非同期関数を呼び出すタイミングで実行実行が切り替わらないことが分かる。

asyncは一体いつ処理が切り替わるのか

ひたすらループするaと終了するb

1000000回console.log()する非同期関数a()と、プロセスを終了する非同期関数b()を呼び出す。 これを使えば、a()が何回コールされたあとでb()がコールされるかが判別できる。

const a = async () => {
  for (let i=0; i < 1000000; i++) {
    console.log(i)
  }
}

const b = async () => {
  process.exit(1)
}

a()
b()

a()はそれなりに時間がかかるが、何回やったとしてもちちゃんと999999までカウントされてからプロセスは終了する。 つまり、待ち合わせのない非同期呼び出しにもかかわらず、a()が終わるまでb()の呼び出しは実行されないのだ。

これにより、非同期関数はタイムスライスで切り替わらないことが分かる。 既に非同期関数呼び出しが切り替わりを発生させないことは確認されているが、改めてconsole.log()を非同期化しても結果は同じだ。

const out = async(i) => {
  console.log(i)
}

const a = async () => {
  for (let i=0; i < 1000000; i++) {
    out(i)
  }
}

const b = async () => {
  process.exit(1)
}

a()
b()

ブロッキングとasync

では、a()をHTTPアクセスを100回繰り返すものに変えてみよう。

const a = async () => {
  for (let i=0; i < 100; i++) {
    console.log(i)
    await fetch("https://chienomi.org")
  }
}

const b = async () => {
  process.exit(1)
}

a()
b()

今度はa()は1回しか呼び出されない。 a()が走り切る前にb()が呼び出されているということだ。

これにより、非同期関数でI/O待ちが発生すると実行が切り替わっていることが分かる。

CPUを手放すと切り替わる

つまり、JavaScriptのasyncはCPUを使い続けている限り直列で実行され、CPUを手放すと呼び出し元の次のステップへ進行する。

これにより、たとえawaitしなくても、ほとんど直列実行のコードと同じような書き心地になっている。 それでいて、I/Oなどブロッキングを発生させるような場合は実行を明け渡すことで、処理待ちを発生させないような動きだ。

もし少し専門的な言い方をすると、JavaScriptのasyncはコンテキストスイッチ型のスケジューラで、タイムスライスではないということだ。

RubyにはFiberというコンテキストスイッチ型の並列実行実装があり、Ruby3.0ではFiber schedulerというものが追加され、これを使えばJavaScriptのasync/awaitと似たようなものを実装することができる。 別の見方をすれば、コンテキストスイッチ並列実行はI/Oなどをフックして切り替えるというのが実際の使われ方であるということだろう。 そして、JavaScriptのasync/awaitがそのように動作するのは、わかりやすく効率的な直列実行をしつつ、モダンJavaScriptのノンブロッキング志向を実現する筋良い手段に思える。

以上のことから、I/OやsetTimeout()のようなCPUを手放す処理をはさまない限り、非同期関数の中であってもオブジェクトへのアクセス(変更を含む)はアトミックに行われるため、冒頭のような共有オブジェクトの利用は安全である。

awaitの作用

asyncの仕組みに対してawaitの仕組みはやや筋が悪い面がある。

await直接awaitした非同期関数のPromiseが解決されるまで待つ。 そのため、次のようなケースでは、待ち合わせされている非同期関数a()が呼び出す待ち合わせされていない非同期関数fetch()の戻り値はPromise(<pending>)である。 (fetch()が間に合ってしまわないよう、socatで待ち受けて応答しないようにしている)。

それだけの話であれば非同期関数の待ち合わせをしたいところにawaitを入れればいいということになりそうだが、実際は待ち合わせされていない非同期関数はUnhandledPromiseRejectionを発生させるため、非同期関数は全部awaitをつけることになりやすい。

次のコードはUnhandledPromiseRejectionを発生させる。

const a = async () => {
  b()
}

const b = async () => {
  throw "TEST"
}

try {
  await a()
} catch(e) {
  console.log("CATCHED")
}

async/awaitですべて解決しようとするとこの問題に直面し、結局同期処理と同じコードになる。 それなら非同期関数にしなければ良さそうなものだが、ライブラリは非同期関数になっていたりする上に、仕様として同期実行されている関数からawaitできないため、「本当は非同期にしたいが、エラー処理の問題から同期処理を強制するために全部非同期で書いた上で待ち合わせをする」という意味不明な事態に陥る。

待ち合わせされない非同期関数であっても、Promiseそのものに付属しているコールバック関数は呼ばれる。 勘違いされやすいが、awaitを使ったところでthen()error()が不要になるわけではない。awaitは、待ち合わせしたいケースにおいて無理やりthen()error()を使わざるを得ない状況を解消しているものだ。

次のようにすると、適切に捕捉される

const a = async () => {
  b().catch(e => {console.log("CATCHED CALLBACK")})
}

const b = async () => {
  throw "TEST"
}

a()

つまり、

  • 非同期関数の待ち合わせをしたい場合
    • awaitする
    • 例外処理が必要ならtry-catchを使う
  • 非同期関数を投げっぱなしにしたい場合
    • 結果をもって何かをしたいならthen()を使う
    • エラー時にやりたいことがなくても常にerror()を使う

が正しい。

待ち合わせされていない非同期関数は呼び出し元を遡ることができないため、error()で例外を投げることはできない。 場合によっては、error()のコールバックをtry-catchで囲む必要がある。

この変な仕様は、「非現実的なノンブロッキングへの執着」から発生している。

JavaScriptはもともとノンブロッキング志向だったわけではないが、初期はブラウザそのものがシングルスレッドだったため、「JavaScriptが動いている間はブラウザを動かせない」仕様だった。 これにより、ブロッキングは結構致命的な問題を生じやすかった。

また、JavaScriptはGUIのインタラクティブアクションをメインに考えられていたため、プログラミングしやすさという意味でもイベントドリブンプログラミングがよかった。 イベントドリブン志向にすればブロッキングが発生しづらいため、よりそれを色濃くすることは「安い」解決策になる。

この時点ではイベントドリブンは手段でしかなかったのだが、ある時期にノンブロッキングプログラミングが注目されだし、イベントドリブンは素晴らしいと持て囃されるようになる。 これはブラウザベンダーがウェブテクノロジーに対して再び支配的に振る舞うようになった時期と重なり、JavaScriptがその本質から離れ始めた時期でもある。

それ以降、「ブロックは悪」思想でECMAは突き進む。 ブロックを生じさせる要素は徹底的に排除すべしとしたわけだ。

だが、それは現実を反映しない話であり、プログラミングに苦痛をもたらした。 しばらく後に、それが非現実的な思想であることを受け入れ、待ち合わせを可能にしたわけだ。

だが、ここでさらに抵抗を見せてしまう。 非同期関数の中で待ち合わせされる分にはグローバルにブロックされることはないが、トップレベルで同期実行されている関数の中で待ち合わせされてしまうと、処理全体がブロックされてしまう。 ブロックされているとイベントで割り込めないため、ページ全体の処理が止まったように見える。そこで、待ち合わせができるのは非同期関数の中だけとすることでその問題を発生させないようにした。

だが、ここに一貫した思想を持って設計されていない問題が出てしまう。 同期関数で待ち合わせできないようにしたことにより、非同期関数の中で呼ばれた同期関数の中で待ち合わせができない問題が発生する。

実はこれには解が存在する。 awaitは値となる非同期関数を待ち合わせるほかに、値の関数が返すPromiseも待ち合わせる。 なので、同期関数は自身が呼び出す非同期関数のPromiseを返すようにすれば、上流で待ち合わせが可能。 次のコードはちゃんと捕捉できる。

const a = async () => {
  return b()
}

const b = async () => {
  throw "TEST"
}

try {
  await a()
} catch(e) {
  console.log("CATCHED")
}

同期関数a()が非同期関数b()Promiseを返すようにすれば、b()の待ち合わせが可能。

const a = () => {
  return b()
}

const b = async () => {
  return "ABC"
}

const result = await a()
console.log(result)

つまり、次のようなことをしたいとすると:

async function a() {
  b()
}

function b() {
  b1()
  const result = await fetch() // ERROR
  return b2(result)
}

非同期呼び出しをするところで同期関数が終了するように書けば、上流で待ち合わせできる。

async function a() {
  let result = await b()
  result = b2(result)
}

function b() {
  b1()
  return fetch() 
}

しかしこういう形なってきたのは、Promiseを直接扱うプログラミングの書き心地の悪さ故のはずなのだが、結局Promiseを強く意識することを強いており、書き心地が悪い。

この仕様の段階ではimport()を待てないなど致命的に使い勝手が悪い部分があっため、トップレベルでawaitできるようになった。 だが、絶対にブロックを許さないという思想から同期関数で待ち合わせさせないようにしたのに、トップレベルで待ち合わせさせるというのは既に破綻している。であれば、同期関数でも待ち合わせさせたほうが使い勝手は絶対に良いのだ。結局は書き手が責任を負うべき部分なのだから。

いずれにせよ、現時点での現実はPromiseを強く意識してthen(), error()をつけ、各関数は待ち合わせるべきPromiseを返すようにプログラミングせざるをえない。 この点についてあまり知られてもいないし語られてもいないが、問題を生じさせないためには本来そうさせたいのであろう書き方をするべきだろう。