Cats Effect IO.fromFutureの2.0.0での変更について

cats effect IOの2.0.0でのfromFutureのインタフェース変更によってFutureの実行後にContextShiftすることが強制されるようになりました。
2020.12.28

はじめに

今更ですがメインのプロジェクトでCats Effectのバージョンを2.x.xに上げたのでAPIの変更点の対応を行っていました。今回はIO#fromFutureが変更されたことについて詳細を調べてみました。

IO#fromFutureの変更点

以下のようにfromFutureがContextShiftを要求するようになって、これまでのインターフェースはdeprecatedになっています。

IO.scala

def fromFuture[A](iof: IO[Future[A]])(implicit cs: ContextShift[IO]): IO[A] =
	iof.flatMap(IOFromFuture.apply).guarantee(cs.shift)

@deprecated("Use the variant that takes an implicit ContextShift.", "2.0.0")
private[IO] def fromFuture[A](iof: IO[Future[A]]): IO[A] =
	iof.flatMap(IOFromFuture.apply)

何が変わったかというとIO#gurantee(ContextShift)を呼ぶようになったのですが、これにはどういった意味があるのでしょう?

IO#guranteeとは何か

guranteeを見ると IO.unit.bracket(_ => io)(_ => f) と同等だと説明しています。guranteeはIOの実行が成功、失敗、キャンセルのいずれの場合でもfinalizerを実行するIOを返します。(Java でいうtry-catch-finallyのようなものですが、finalize処理を含めて合成可能なのです。最高だな。)

/**
   * Executes the given `finalizer` when the source is finished,
   * either in success or in error, or if canceled.
   *
   * This variant of [[guaranteeCase]] evaluates the given `finalizer`
   * regardless of how the source gets terminated:
   *
   *  - normal completion
   *  - completion in error
   *  - cancelation
   *
   * This equivalence always holds:
   *
   * {{{
   *   io.guarantee(f) <-> IO.unit.bracket(_ => io)(_ => f)
   * }}}
   *
   * As best practice, it's not a good idea to release resources
   * via `guaranteeCase` in polymorphic code. Prefer [[bracket]]
   * for the acquisition and release of resources.
   *
   * @see [[guaranteeCase]] for the version that can discriminate
   *      between termination conditions
   *
   * @see [[bracket]] for the more general operation
   */
  def guarantee(finalizer: IO[Unit]): IO[A] =
    guaranteeCase(_ => finalizer)

再びfromFutureへ

fromFutureはFutureから生成したIOのfinalizerでコンテキストシフトをしています。

ということはgurannteeなしの場合はFutureのあとのIOはFutureが実行されたExecutionContextで実行されていたのか?ということで確かめてみます。

package example

import cats.effect.{ExitCode, IO, IOApp, Resource}

import java.util.concurrent.Executors
import scala.concurrent.{ExecutionContext, Future}

object FromFutureExample extends IOApp {
  override def run(args: List[String]): IO[ExitCode] =
		//区別しやすくするためにFutureはスレッドプール で実行
    Resource
      .make(IO(Executors.newFixedThreadPool(1)))(es => IO(es.shutdownNow()))
      .map(es => ExecutionContext.fromExecutor(es))
      .use { implicit ec =>
        for {
          _ <- IO(println(
            s"threadName of before_future: ${Thread.currentThread().getName}"))
          _ <- IO.fromFuture(
            IO(Future(println(
              s"threadName of future: ${Thread.currentThread().getName}"))))
          _ <- IO(
            println(
              s"threadName of after_future: ${Thread.currentThread().getName}"))
        } yield ExitCode.Success
      }
}

実行結果は以下のようになります(cats effect 1.4.0で実行)

threadName of before_future: ioapp-compute-0
threadName of future: pool-1-thread-1
threadName of after_future: ioapp-compute-1

次に2.x系と同様にguranteeを追加して実行します。

for式だけ抜粋するとこうなります。

for {
    _ <- IO(println(
      s"threadName of before_future: ${Thread.currentThread().getName}"))
    _ <- IO
      .fromFuture(IO(Future(println(
        s"threadName of future: ${Thread.currentThread().getName}"))))
      .guarantee(contextShift.shift)
    _ <- IO(
      println(
        s"threadName of after_future: ${Thread.currentThread().getName}"))
    } yield ExitCode.Success

この実行結果は以下の通り(2.x相当)

threadName of before_future: scala-execution-context-global-11
threadName of future: pool-1-thread-1
threadName of after_future: scala-execution-context-global-11

やはりFutureの処理の前後でExecutionContextの切り替えが強制されるかどうかが違いのようです。

変更の経緯

この変更の経緯はcats-effect #546 で確認できます。破壊的な変更だと分かりながらもExecutionContextが切り替わったことに気付きづらいそれまでのインターフェースよりはいいということで変更されたようです。

まとめ

今更2.x系の変更点でしたが、今回移行してみてインタフェースが変わったこと以上に、これまでExecutionContextが変わったことに気づかず使っていた点にショックを受けました。