FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

ZIO 2.1.x で詰まってしまったコード

こんにちは。河内です。 今回の投稿はFLINTERSブログ祭りの記事です。テーマは #Scala #ZIO です。

2024年5月にリリースされた ZIO 2.1.0 では runtime にいくつかの変更があります。

一つは autoblocking のデフォルト無効化です。 2022年6月にリリースされた ZIO 2.0.0 では autoblocking という機能が導入されました。 これは blocking な操作を自動的に blocking 専用の thread pool で実行するようにランタイムが調整してくれるという機能です。 main 処理用の thread pool は CPU のコア数分作成されます。 blocking な操作(I/O待ちなど)は CPU を使用しないため、main 処理用の thread pool で実行すると、CPUを十分に使いきれません。 そこで ZIO では blocking 処理用の thread pool を別に持っており、 ZIO.blocking() で指定した操作や autoblocking で判定された操作が blocking 処理用の thread pool で実行されます。 ところが ZIO 2.1.0 から、デフォルトで autoblocking が無効になりました。どうやら autoblocking が性能の妨げになるケースがあったようです。

また Project Loom ベースの executor が追加されています。 ただしベンチマークによると性能は既存の executor のほうが優れているとのことです。

そんな 2.1.0 ですが、2.0.x では動いていたプログラムが 2.1.x では動かなくなったケースに遭遇したので共有します。 かなりのコーナーケースかもしれませんが、同様の事象でお困りの方がいらしたらご参照下さい。

該当コードは effect 内で unsafe.run しており、ポイントを簡略化すると以下のようなコードになります(Unsafe を気軽につかわないほうが良いとの指摘をいただきそうですが、既存コードのマイグレーション過程にて結果的にこのような構造になることはありえるかと思います。ご容赦下さい)。

import zio.{Scope, Unsafe, ZIO, ZIOAppArgs, ZIOAppDefault}

import scala.concurrent.Future

object Main210 extends ZIOAppDefault {

  override def run: ZIO[ZIOAppArgs with Scope, Any, Any] =
    for {
      _ <- ZIO.succeed(printThread("main"))
      // ここが詰まる
      _ <- ZIO.attempt(Unsafe.unsafe(implicit u => runtime.unsafe.run(effectFuture).getOrThrow()))
    } yield 0

  def effectFuture = ZIO.fromFuture(implicit ec => Future(printThread("effectFuture")))

  def printThread(prefix: String) = println(s"$prefix: ${Thread.currentThread()}")
}

ZIO は main 処理用の thread pool を CPU コア数分作りますが、問題の再現のために -XX:ActiveProcessorCount=1 オプションを付け、JVM から見えるコア数を 1 に設定します。

まず ZIO 2.0.0 で実行してみると

main: Thread[#14,ZScheduler-Worker-0,5,main]
effectFuture: Thread[#17,ZScheduler-0,5,main]

と出力され完走します。

ところが ZIO 2.1.2 で実行すると

main: Thread[#14,ZScheduler-Worker-0,5,main]

と出力されたところでプログラムから応答がなくなります。

debugger でみてみると main 処理用である ZScheduler-Worker-0 thread では runtime.unsafe.run() が呼び出す jdk.internal.misc.Unsafe.park() でブロッキングしています。 effectFuture が完了すると先に進めるのですが、Future() に ZIO の main 処理用の executor を ExecutionContext (ec) として渡しているため、Future が完了せず、ブロックしっぱなしという状況のようです。

Future() に別の ExecutionContext を渡してみると、ひとつ出力が先に進みますが依然としてプログラムが終了しません。

main: Thread[#16,ZScheduler-Worker-0,5,main]
effectFuture: Thread[#19,scala-execution-context-global-19,5,main]

park している thread を unpark するのにも main 処理用の thread が必要なのでしょうか。

2.1.0 でAutoblocking がデフォルトで無効になったことが挙動の違いを生み出しているのかと推測しましたが、下記のコードを追加して autoblocking を有効にしても 2.1.2 では挙動が変わりませんでした。

  override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.enableAutoBlockingExecutor

なお下記のコードを追加して Loom ベースの executor に変更すると完走しました。

  override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] = Runtime.enableLoomBasedExecutor
main: VirtualThread[#17]/runnable@ForkJoinPool-1-worker-1
effectFuture: Thread[#14,ZScheduler-Worker-0,5,main]

最初の出力が VirtualThread からになっていることから、Loom が効いていることがわかります。

また unsafe.runToFuture に変えることでも完走しました。 ZIO.fromFuture() では 1つしかない main 処理用の thread を block しないのだと思います。

    for {
      _ <- ZIO.succeed(printThread("main"))
      _ <- ZIO.fromFuture(_ => Unsafe.unsafe(implicit u => runtime.unsafe.runToFuture(effectFuture)))
    } yield 0
main: Thread[#14,ZScheduler-Worker-0,5,main]
effectFuture: Thread[#14,ZScheduler-Worker-0,5,main]

まとめ

まとまりのない記事になってしまいましたが、まとめると

  • ZIO 2.1.x では 2.0.x で動いていたプログラムが詰まってしまうことがある
  • 今回遭遇したケースでの原因は、 main 処理用の thread pool を全て block してしまい、処理を進めるのに必要な thead を確保できないようだった
  • Runtime.enableAutoBlockingExecutor は効果がなかった。Autoblocking 無効化とは関係がないかもしれない。
  • サンプルコード上では ZIO.fromFuture() や Loom ベースの executor を使うことで問題が解消できた