JavaScriptでScalaのFutureを表現する
はじめに
Scala.jsというプロジェクトがあります。
Scalaで書いたプログラムをJavaScriptに変換する、とてもクールなツールです。
ただ、もちろん言語自体が違うため、完全なマッピングが可能な訳ではありません。ベストエフォートでセマンティクスを維持したままJavaScriptに変換しますが、いくつか対応付けが困難なケースがあります。
その中でも特にScalaの Future
をJavaScript上でどのように表現するか? という点に関しては、JavaScriptの深みを知れるとても良い題材だと思ったので、まとめてみようと思います。
※ 一応Scala.jsをネタに出してますが、Scalaを知らなくても理解できるように書いたつもりです。
ScalaのFuture
Scalaには並列処理を行うためのデータ型として Future
があります。
Future は、ある時点において利用可能となる可能性のある値を保持するオブジェクトだ。
Java(Scala)には直接スレッドを生成するAPIも存在しますが、特に大きな理由がない限りはこのFuture
が使用される場合が多いと思います。
似たようなものとして、JavaのFuture
やJavaScriptのPromise
のようなものが挙げられると思います。
簡単な例を見て、ScalaのFuture
の雰囲気を掴んでみましょう。
import scala.concurrent.Future
import scala.concurrent.ExecutionContext.Implicits.global
object FutureSample {
def main(args: Array[String]): Unit = {
val s = "Hello"
val f: Future[String] = Future {
// このFutureのブロック内の処理は別のスレッドで実行される可能性がある
// タスクを実行しているスレッドを1000ms停止
Thread.sleep(1000)
s + " future!"
}
f.foreach { s =>
println(s)
}
// `isCompleted`でFutureが完了したかどうかを確認
println(f.isCompleted) // false
// mainを実行しているスレッドを5000ms停止
Thread.sleep(5000) // Hello future!
println(f.isCompleted) // true
}
}
// 出力
// false
// Hello future!
// true
https://scala-text.github.io より
ScalaではFuture
で囲まれた処理は別のスレッドで実行される可能性があります。[1]
上記は、mainのスレッドとは別で、1000ms停止してStringを返すようなFutureを生成するコード例になります。
このようなプリミティブのおかげで、Scalaにおいて複数のコンテキストで処理を行うといった並列処理のセマンティクスを実現することが可能になります。
しかし、当たり前ですが、JavaScritp上にはScalaのFutureはありません。ましてJavaScriptはシングルスレッドで動作する言語なので、このFutureのセマンティクスをJavaScriptで実現するのが可能なのかもよく分かりません。
というわけで、ここでScalaでのセマンティクスをいい感じに維持しながら、JavaScript上でどのようにFutureを表現するか、という問いにぶつかります。
ScalaのFutureをJavaScriptでどのように表現するか?
具体的な下記のScalaコードで考えてみます。
サンプルコード
// scala
def fetchData(): Future[String] = Future {
"some data!"
}
val f = fetchData()
f.onComplete { case Success(data) =>
// dataを使ってなにか処理
println(data)
}
fetchData()
は非同期でなにかしらのデータを取得してくる関数です。簡単のため、ここではすぐにsome data!
という文字列を返すようにしています。 そのfetchData()
が返すFutureをf
という変数に紐づけています。 そして最後、onComplete
でFutureが完了したタイミングで何かしらの処理を実施します。
さて、このScalaコードをどのようにJavaScriptで表現できるでしょうか?
ここでは、実際にScala.jsで採用されているものも含め考えられる実装3パターンについて、見ていこうと思います。
方法1: Promise
1つめの方法は、JavaScriptのPromiseを使ってFutureを実現する方法です。実は、これは本家のScala.jsがデフォルトで採用している方法です。
上で見たように、ScalaのFutureとJavaScriptのPromiseは近しいセマンティクスを持っているので、このマッピングは自然に見えます。
上記のFutureを使ったコードをJavaScriptにマッピングすると、下記のようなコードになるでしょう。[2]
サンプルコード
// javascript
function fetchData() {
return Promise.resolve()
.then(_ => "some data!")
}
const f = fetchData()
f.then(data =>
console.log(data)
)
しかし、通常使用では特に問題がないかもしれませんが、この方法には重大な落とし穴があります。
その落とし穴というのを、実際の例で見てみます。
Scala.jsで次のコードを書き、生成されたJavaScriptを実行すると、どのような処理になるでしょうか?
結論から言うと、このプログラムは終了することはなく、永遠とハングします。
// scala
var cancel = false
def loop(): Future[Unit] =
Future(cancel) flatMap { canceled =>
if (canceled)
Future.unit
else
loop()
}
setTimeout(100) {
cancel = true
}
loop()
同等のJavaScriptコードは次のようになるでしょう。
// javascript
let cancel = false
function loop() {
(new Promise((resolve) =>
resolve(cancel)
)).then(canceled => {
if(canceled) {
return
}
else
loop()
})
}
setTimeout(() => {
cancel = true
}, 100)
loop()
これらのJavaScriptコードをブラウザで実行するとブラウザはハングして、おそらく操作不能になると思います。
したがって、Future
(Scala) -> Promise
(JavaScript) と言うマッピングは、一見良さげには見えるのですが、Scalaのコードを書いた人からすると予想しない挙動を生む可能性があります。
JavaScriptにおけるmicrotaskとmacrotask
なぜ上記のコードはハングしてしまったのでしょうか? その理由はJavaScriptが使用している2種類のタスクキューの性質に由来します。
よく知られているように、JavaScriptはシングルスレッドで動作する言語です。一方で、JavaScriptはその1つのスレッドでCPUヘビーなタスク、I/O、UIレンダリングなどの様々なタスクをこなす必要があります。そのような条件をクリアするために、JavaScriptはいわゆるイベントループというアーキテクチャを取っています。
イベントループでは、いますぐ実行できないようなI/Oなどの処理は一旦タスクキューに詰めて処理を後回しにすることで、CPUを使用するような処理を優先的にこなしつつ、手が空いた際にI/Oの完了をpollingして(完了していれば)その処理を行う、といった方法を取ることでシングルスレッドでも効率的な処理を実現します。
さて、ここでタスクキューというものが登場しましたが、JavaScriptには2種のタスクキューがあります。microtaskキューとmacrotaskキューです。
macrotaskキューはsetTimeout、UIレンダリング、fetch等の処理に使われ、microtaskキューは主にPromiseで使用されます。 さらにこれらのタスクキューの重要な性質として、microtaskキューにタスクがある限りブラウザはこのキューのタスクを全て完了させようとします。つまり他のどのmacroタスクよりも優先してmicrotaskが実行される訳です。[3]
ここで、上記のコードがなぜハングするのかがはっきりしたと思います。
一度loop
関数に入ると、cancel = false
のうちは永遠とPromiseタスクを生成します。cancel = true
にする処理はsetTimeout
で駆動されますが、これはmacrotaskキューに積まれるのでmicrotaskキューにタスクがあるうちは実行されません。これによりsetTimeout
の処理が永遠とトリガされずに無限ループに陥ってしまうのです。
これはちょっと厄介です。JavaScriptに詳しい人がJavaScriptを書いているケースであれば、上記のようなコードはNGである、という事前知識を持ってして回避できる人もいると思います。ただScala.js側のようなケースだと意図しない挙動を誘発しやすいと思います。Scalaのコードではなんの問題のないコードでも、JavaScriptの上では違うセマンティクスで実行される可能性があります。
では、これを防ぐ方法はあるでしょうか。
先ほど見たPromiseを使った方法だと、microtaskにタスクがエンキューされることで、macrotaskよりも優先的にそのtaskが処理されてしまい、処理が永久に終了しないという問題がありました。
ということは、microtaskを使わずにFutureのようなセマンティクスを表現する方法があればよさそうです。JavaScriptにはsetTimeout
という関数があり、これはmacrotaskを使うので、これでFuture的なセマンティクスを表現できないか考えてみます。
方法2: setTimeout
setTimeout
は、指定した時間後にcallbackに指定した処理を実行します。また、Promiseで生成されるタスクと違ってmacrotaskキューを使用するので、他のタスクより優先的に実行され続けてしまうという事態も防げます。
Futureのセマンティクスを表現するコードを考えてみます。
// scala
def fetchData(): Future[String] = Future {
"some data!"
}
val f = fetchData()
f.onComplete { case Success(data) =>
// dataを使ってなにか処理
println(data)
}
// javascript
function fetchData(cb) {
setTimeout(() => {
cb("some data!")
}, 0)
}
fetchData(data => console.log(data))
この例では、JavaScript側はfetchData
に、処理が完了した後のcallbackを渡すような形になっています。そしてfetchData
の中身の実装ですが、まずsetTimeout(_, 0)
でcallback以降の処理をmacrotaskに積み、そしてすぐさま次のイベントループでsetTimeout
のcallbackが呼び出され、cb
の引数に継続する処理(ここでは data => console.log(data)
)に渡すデータを引数に渡します。
余談ですが、Scala.jsではデフォルトではFutureはPromiseベースの実装に対応づけられると先に述べましたが、一応このsetTimeout
による実装も用意はされています。
デフォルトでPromiseベースの実装を提供しているのは、setTimeout
が後述するようなデメリットがあるからかもしれませんが、詳しいことは分かりません。
別の例として、Scalaユーザーならお馴染み(?)、flatMap
でFutureの処理をチェーンさせる例も考えてみましょう。
// scala
fetchData()
.flatMap { _ =>
fetchData()
.flatMap { _ =>
fetchData()
}
}
上記のScalaコードに対応するJavaScriptコードは下記のようになります。
// javascript
function fetchData(cb) {
setTimeout(() => {
setTimeout(() => {
setTimeout(() => {
...
}, 0)
}, 0)
}, 0)
}
flatMapでチェーンさせただけ、setTimeout
の中でsetTimeout
を呼び出しているような形になっている点に注目してください。後述しますが、このことがsetTimeout
を使う上での大きな欠点に繋がってしまいます。
setTimeoutの弱点
さて、ここまでで「Futureの表現にはPromiseじゃなくてsetTimeoutを使えばいいじゃん!」と言いたくなりたくなりますが、setTimeoutにもデメリットがあります。端的に言うとsetTimeoutにはパフォーマンス上の問題があります。
まず、setTimtoutを5つ以上ネストさせると、最低でも4msの遅延を発生させるということが仕様上で明記されています。
If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.
HTML Standard#timers-and-user-prompts
残念ながら、これは上記のようにScalaでFuture
に対してflatMapで処理をチェーンさせるという、とても一般的なワークロードでいとも簡単に発生します。Futureのネストの度に4ms秒の遅延が付与されては、これだけで性能の高いアプリケーションを作るということがかなり困難になります。
さらに、setTimeoutで精度が高い(timeout時間が短い)タイマを複数追加すると、パフォーマンスが悪くなるということも知られています。これは基盤となるOSや、アプリケーションの稼働状況によって多少影響が異なるようですが、timeout時間が100msを切るようなタイマーを複数作成すると、特に性能が悪化します。(古い記事ですが)Googleはこれを回避するために、モバイル向けgmailで、高精度タイマーを複数作成する代わりに、1つのglobalな高精度タイマーを使うことでこの問題を回避したという経緯があったそうです。
では、setTimeoutのようなmacrotaskキューを使う仕組みで、かつパフォーマンスが良いやり方で非同期処理を実現する方法はないのでしょうか?
方法3: setImmediate
上記のsetTimeout
の欠点を補うために提案されたものが、setImmediate
です。
setImmediate
は、macrotaskキューを用いつつ、かつsetTimeout
のタイマーにあったようなパフォーマンス問題が発生しないように設計されており、まさに今回のようなユースケースのために提案されたAPIです。
しかし、大変残念なことに、このsetImmediate
はその有益性が目に見えて明らかなのにも関わらず、ほとんどのブラウザでサポートされていません。(Browser compatibility)
このsetImmediate
の仕様は、元々Microsoftから提案されたものなのですが、Google、Apple、Firefoxがこぞってこの仕様に対して反対していたようです。現在、このsetImmediate
をサポートしているメジャーなバックエンドとしてはNode.jsくらいのようです。
従って、ブラウザでこのsetImmediate
を使いたいのであれば、何かしらの方法でポリフィルを用意する必要があります。幸いなことに、大体のメジャーなブラウザはsetImmediate
を直接サポートしてはいないが、同等の機能を実現できる別のAPIを提供しています。
そのポリフィルを提供しているプロジェクトとしておそらく有名なものが下記になります。
このプロジェクトでは、かなり多くのブラウザおよび(setImmediate
が導入される前の古い)Node.js環境でのポリフィルを提供します。
READMEも大変詳しく書かれているので興味のある方はぜひ読んでいただきたいのですが、要点としてはブラウザに関してはpostMessageというAPIを使用することで、setImmediate
のような挙動を擬似的に実現しています。[4][5]
色々紆余曲折がありましたが、このようにして、なんとか(ポリフィルを用いつつも)ブラウザ上でsetImmediate
による効率的な非同期処理の実現ができるわけです。
setImmediate
使ったほうがよくね?
余談: Scala.jsでもちょっとだけScala.jsの話をします。ScalaのFuture
はデフォルトではPromise
を使った実装が提供され、オプションでsetTimeout
を使った実装も使うことができると述べました。しかしこれまでの話で、これら2つの手法よりも、setImmediate
が優れていることは明らかです。ではScala.jsもsetImmediate
を使った実装を用意すべきじゃないでしょうか?
...心配しないでください。Scala.jsはこの問題を認識していて、ちゃんとsetImmediate
によるFuture
(ExecutionContext
)の実装をライブラリとして提供しています。[6]
このライブラリは、上記でも紹介した YuzuJS/setImmediateの実装をベースとしたsetImmediate
を提供してくれます[7]。2023年現状ではScala.jsのデフォルトのFuture
の実装にはなっておらずライブラリでの提供になっていますが、READMEにも記載があるとおり、特に理由がなければScala.jsでFutureを使った処理を書く場合はこのライブラリが提供するExecutionContext
を使用すべきでしょう。
まとめ
純粋にScalaのFuture
ってJavaScriptではどのように表現されてるのだろうと気になったのが事の発端でしたが、今回調べてみて思いの他色々な背景や工夫が詰まっているのだなあと感じて勉強になりました。個人的にsetImmediate
がなぜ全てのブラウザで実装されてないのかが結構謎ですが...。
-
ここの挙動は使用する
ExecutionContext
によって色々と制御が可能ですが、一旦簡単のため単に別のスレッドにスケジュールされるとします。 ↩︎ -
実際にScala.jsが差し替えをおこなっているのは
ExecutionContextExecutor
なので、出力するJavaScriptはこのようなPromiseとFutureの単純な置き換えにはならないのですが簡単のためこのようなコードにしています。 ↩︎ -
https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint ↩︎
-
postMessage
の仕様を見た限り、明らかにsetImmediate
をポリフィルするためのものではなさそうなので、若干ハック感が否めないですが...。 ↩︎ -
https://dbaron.org/log/20100309-faster-timeouts
https://qiita.com/hiruberuto/items/0b89c36556cadbd751e2 ↩︎ -
ちなみに、この記事の内容はこのプロジェクトのREADMEをかなり参考に書いてますw ↩︎
-
正確には
setImmediate
の挙動をするExecutionContextExecutor
を提供する。 ↩︎
Discussion
基本的にはmicrotaskキューを使いつつ、何回かに1回はmacrotaskキューを使うという実装も考えられそうです。Promiseの標準化以前に開発されていたJSDeferredライブラリでは、基本的にはUIレンダリングを挟まずに処理を続行するが、150ミリ秒ごとにUIレンダリングを挟む(
setTimeout
を使う)という実装が存在しました。コメントありがとうございます!
なるほど、Promise以前のJSDeferredではmacroタスクだけでなくレンダリング処理(microタスク)も挟むような実装があったんですね