🗺️

JavaScriptでScalaのFutureを表現する

2023/10/06に公開2

はじめに

Scala.jsというプロジェクトがあります。

https://github.com/scala-js

Scalaで書いたプログラムをJavaScriptに変換する、とてもクールなツールです。

ただ、もちろん言語自体が違うため、完全なマッピングが可能な訳ではありません。ベストエフォートでセマンティクスを維持したままJavaScriptに変換しますが、いくつか対応付けが困難なケースがあります。

その中でも特にScalaの Future をJavaScript上でどのように表現するか? という点に関しては、JavaScriptの深みを知れるとても良い題材だと思ったので、まとめてみようと思います。

※ 一応Scala.jsをネタに出してますが、Scalaを知らなくても理解できるように書いたつもりです。

ScalaのFuture

Scalaには並列処理を行うためのデータ型として Future があります。

https://docs.scala-lang.org/ja/overviews/core/futures.html

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による実装も用意はされています。

https://github.com/scala-js/scala-js/blob/2f1fc326d0e10a576927fb96fb15979478f1c80a/library/src/main/scala/scala/scalajs/concurrent/QueueExecutionContext.scala#L31-L44

デフォルトで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です。

https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate#browser_compatibility

setImmediateは、macrotaskキューを用いつつ、かつsetTimeoutのタイマーにあったようなパフォーマンス問題が発生しないように設計されており、まさに今回のようなユースケースのために提案されたAPIです。

しかし、大変残念なことに、このsetImmediateはその有益性が目に見えて明らかなのにも関わらず、ほとんどのブラウザでサポートされていません。(Browser compatibility)

このsetImmediateの仕様は、元々Microsoftから提案されたものなのですが、Google、Apple、Firefoxがこぞってこの仕様に対して反対していたようです。現在、このsetImmediateをサポートしているメジャーなバックエンドとしてはNode.jsくらいのようです。

従って、ブラウザでこのsetImmediateを使いたいのであれば、何かしらの方法でポリフィルを用意する必要があります。幸いなことに、大体のメジャーなブラウザはsetImmediateを直接サポートしてはいないが、同等の機能を実現できる別のAPIを提供しています。

そのポリフィルを提供しているプロジェクトとしておそらく有名なものが下記になります。

https://github.com/YuzuJS/setImmediate

このプロジェクトでは、かなり多くのブラウザおよび(setImmediateが導入される前の古い)Node.js環境でのポリフィルを提供します。

READMEも大変詳しく書かれているので興味のある方はぜひ読んでいただきたいのですが、要点としてはブラウザに関してはpostMessageというAPIを使用することで、setImmediateのような挙動を擬似的に実現しています。[4][5]

色々紆余曲折がありましたが、このようにして、なんとか(ポリフィルを用いつつも)ブラウザ上でsetImmediateによる効率的な非同期処理の実現ができるわけです。

余談: Scala.jsでもsetImmediate使ったほうがよくね?

ちょっとだけScala.jsの話をします。ScalaのFutureはデフォルトではPromiseを使った実装が提供され、オプションでsetTimeoutを使った実装も使うことができると述べました。しかしこれまでの話で、これら2つの手法よりも、setImmediateが優れていることは明らかです。ではScala.jsもsetImmediateを使った実装を用意すべきじゃないでしょうか?

...心配しないでください。Scala.jsはこの問題を認識していて、ちゃんとsetImmediateによるFuture(ExecutionContext)の実装をライブラリとして提供しています。[6]

https://github.com/scala-js/scala-js-macrotask-executor

このライブラリは、上記でも紹介した YuzuJS/setImmediateの実装をベースとしたsetImmediateを提供してくれます[7]。2023年現状ではScala.jsのデフォルトのFutureの実装にはなっておらずライブラリでの提供になっていますが、READMEにも記載があるとおり、特に理由がなければScala.jsでFutureを使った処理を書く場合はこのライブラリが提供するExecutionContextを使用すべきでしょう。

まとめ

純粋にScalaのFutureってJavaScriptではどのように表現されてるのだろうと気になったのが事の発端でしたが、今回調べてみて思いの他色々な背景や工夫が詰まっているのだなあと感じて勉強になりました。個人的にsetImmediateがなぜ全てのブラウザで実装されてないのかが結構謎ですが...。

脚注
  1. ここの挙動は使用するExecutionContextによって色々と制御が可能ですが、一旦簡単のため単に別のスレッドにスケジュールされるとします。 ↩︎

  2. 実際にScala.jsが差し替えをおこなっているのはExecutionContextExecutorなので、出力するJavaScriptはこのようなPromiseとFutureの単純な置き換えにはならないのですが簡単のためこのようなコードにしています。 ↩︎

  3. https://html.spec.whatwg.org/multipage/webappapis.html#perform-a-microtask-checkpoint ↩︎

  4. postMessageの仕様を見た限り、明らかにsetImmediateをポリフィルするためのものではなさそうなので、若干ハック感が否めないですが...。 ↩︎

  5. https://dbaron.org/log/20100309-faster-timeouts
    https://qiita.com/hiruberuto/items/0b89c36556cadbd751e2 ↩︎

  6. ちなみに、この記事の内容はこのプロジェクトのREADMEをかなり参考に書いてますw ↩︎

  7. 正確にはsetImmediateの挙動をするExecutionContextExecutorを提供する。 ↩︎

Discussion

nanto_vinanto_vi

基本的にはmicrotaskキューを使いつつ、何回かに1回はmacrotaskキューを使うという実装も考えられそうです。Promiseの標準化以前に開発されていたJSDeferredライブラリでは、基本的にはUIレンダリングを挟まずに処理を続行するが、150ミリ秒ごとにUIレンダリングを挟む(setTimeoutを使う)という実装が存在しました。

mox692mox692

コメントありがとうございます!
なるほど、Promise以前のJSDeferredではmacroタスクだけでなくレンダリング処理(microタスク)も挟むような実装があったんですね