fs2.Pull#compile 勉強会 その1
今回やりたいこと
cats-effect の IO の定義
fs2.Stream から cats.effect.IO への変換と cats.effect.IO から scala.concurrent.Future への変換がどのような抽象化レイヤで動作しているのかの確認
cats-effect の IO の定義
IO などは皆さん使ってるとのことなので、細かい使い方については省略します
hsjoihs.icon さん向けに言うと、Haskell の Prelude.IO から unsafeInterleaveIO を取っ払って、幾らかの便利機能 (後述) をプリミティブな操作として足したものだと思ってもらえれば良いです
IO[A]の値 ioa をひとつ持ってきたとき、これは何なのか?
「正常終了すれば A の値をひとつ吐き出してくるはずの実行可能なプログラム」だと思ってもらってよい
プログラムの Abstract Syntax Tree (AST; 抽象構文木) みたいなものを想像してほしい
(Scala の型システムで縛られており、未定義変数に出くわしたりすることが無い、という意味で) 普通のプログラムの AST よりも構造化されている
windymelt.icon Scalaの世界からしか値や式を持ってくることができないので、謎の未定義変数を定義することはできない
ある意味、プログラムの低級な表現と言ってよい
どんな機能がある?
おなじみ IO.pure: [A] => A => IO[A]、IO[A]#flatMap: (A => IO[B]) => IO[B] がある
便利機能が色々入っている
並行実行 (IO.Start(ioa): IO[FiberIO[A]] や IO.RacePair(ioa, iob): IO[(OutcomeIO[A], FiberIO[B]) Either (FiberIO[A], OutcomeIO[B])])
FiberIOってなんや
めちゃくちゃ雑に言うとPIDみたいなやつ
なにができるんや
待ったり (.join) キャンセルしたり (.cancel)
OutcomeIOってなんや
FiberIOが実行された結果か、失敗したよ〜という情報が入っている
自分自身 (IO.cancel link)、もしくは実行中の他の軽量プロセス (fiber) を cancel すること cancelation は停止要求のようなもので、例えば OS の interrupt などよりも遥かに緩い要請
軽量プロセスが cancelation status をちょくちょくチェックして、「あ、止まってって言われてたっぽいので止まります」という感じで止まる
cancelation からクリティカルセクションを保護するために「キャンセル不能領域」を規定すること
Prelude.IO に対しては MonadMask で似たようなことができるらしいですね
windymelt.icon 交差点の途中で停まらないで〜みたいなことができる
「cancel してもいいんだけど、その時には特定の処理が走らなければならない」という一種の trap を設置すること (IO.OnCancel(ioa, finalizer))
IO.OnCancel がネストしている時には、unwinding のようなことが起こる
e.g. IO.OnCancel(IO.OnCancel(longRunningProgram, ioa), iob) を cancel すると ioa が走り終わった後に iob が走り終わってから軽量プロセスが終了する
軽量プロセス固有の局所変数を作ること (IO.Local; Java の ThreadLocal みたいなやつ)
windymelt.icon これあんまり使ったことがないな
関数を跨いで破壊的なゴニョゴニョができるのか
あまりに普段からimmutableな暮らしをしているので理解が及ばなかった
Fiberをまたぐとコピーされるから世界線が破壊されずにすむ
chen.icon「これ用途ってなんなの」
→トレーシング・・・
hsjoihs.icon C の仕様書にあったな~
windymelt.icon Ref系いろいろあるよね
Refは別Fiberでいじると同期されてくる
非同期 FFI (IO.IOCont; 非同期コールバックを受け取るようになった unsafePerformIO みたいなやつ)
非同期コールバックの具体例としては、「HTTP GET を行って body を String として読みだす」ようなライブラリ関数など
ブロックするかわりにコールバックを受け取るタイプの外部ライブラリのAPIをうまいことCats EffectのIO世界観に引きずり込むためのお道具
Cats.Effect の IO 世界観に載っていないものは全て Foreign と見なす視点での命名
kory.icon 最初聞いたときは「思想強いな~」と思った
windymelt.icon レゴブロックみたいで愉快だなぁ
IO は GADT (generalized algebraic datatype) になっている 各 case は、その case を IO の最後 (一番トップレベル) の命令としたときに、プログラムの結果としてどんな型の値が得られうるのか?というのを extends IO[x] の x のところに書いている
tag と TracingEvent については一旦無視してください (時間あったら話します)
case 達を抜粋すると次のような感じ
class Pure[+A](value: A) extends IO[A] (link) class Error(t: Throwable) extends IO[Nothing] (link) class Delay[+A](thunk: () => A, ...) extends IO[A] (link) object RealTime extends IO[FiniteDuration] (link) object Monotonic extends IO[FiniteDuration] (link) object ReadEC extends IO[ExecutionContext] (link) class Map[E, +A](ioe: IO[E], f: E => A, ...) extends IO[A] (link) class FlatMap[E, +A](ioe: IO[E], f: E => IO[A], ...) extends IO[A] (link) class Attempt[+A](ioa: IO[A]) extends IO[Either[Throwable, A]] (link) class HandleErrorWith[+A](ioa: IO[A], f: Throwable => IO[A], ...) extends IO[A] (link) object Canceled extends IO[Unit] (link) class OnCancel[+A](ioa: IO[A], fin: IO[Unit]) extends IO[A] (link) class Uncancelable[+A](body: Poll[IO] => IO[A], ...) extends IO[A] (link) /*INTERNAL*/ class Uncancelable.UnmaskRunLoop[+A](ioa: IO[A], id: Int, self: IOFiber[_]) extends IO[A] (link) class IOCont[K, R](body: Cont[IO, K, R], ...) extends IO[R] (link) /*INTERNAL*/ class IOCont.Get[A](state: ContState) extends IO[A] (link) object Cede extends IO[Unit] (link) class Start[A](ioa: IO[A]) extends IO[FiberIO[A]] (link) class RacePair[A, B](ioa: IO[A], iob: IO[B]) extends IO[Either[(OutcomeIO[A], FiberIO[B]), (FiberIO[A], OutcomeIO[B])]] (link) class Sleep(delay: FiniteDuration) extends IO[Unit] (link) class EvalOn[+A](ioa: IO[A], ec: ExecutionContext) extends IO[A] (link) class Blocking[+A](..., thunk: () => A, ...) extends IO[A] (link) class Local[+A](f: IOLocalState => (IOLocalState, A)) extends IO[A] (link) object IOTrace extends IO[Trace] (link) /*INTERNAL*/ object EndFiber extends IO[Nothing] (link) こいつらの大半は「サイズ1」 -- 単発の命令のように振る舞う
internalな構造はユーザが作ることはできない。IOが処理されていく過程で内部的に使われるだけ
windymelt.icon 薬とかの中間産生物みたいな感じだ
hsjoihs.icon 最初に肝臓にしばいてもらうことを前提とするタイプの薬
例を考えてみよう
IOLocal のページ に落ちていた次のプログラムを考えてみる code:Scala
for {
local <- IOLocal(42)
_ <- inc(1, local)
_ <- inc(2, local)
current <- local.get
_ <- IO.println(s"fiber A: $current")
} yield ()
for式は.flatMapと.mapメソッドに展開される
で、IOに対して.flatMapや.mapはそれぞれFlatMapやMapを作るように実装されているので・・・
code:Scala
FlatMap(
Delay(() => new IOLocalImpl(42)),
local => FlatMap(
inc(1, local),
_ => FlatMap(
inc(2, local),
_ => FlatMap(
local.get,
current => Map(
IO.println(s"fiber A: $current"),
_ => ()
)
)
)
)
)
windymelt.icon レゴブロックやな〜
余談: CEK機械ってのがあるらしい
windymelt.icon 素朴な疑問: 直接命令を実行するかわりに、いったんレゴブロックポチポチしてから構造を挟んでそれを実行するという形になるけどパフォーマンスとかどうなるんだろ
ブロックの組み立て自体は一瞬だし無料みたいなもんか
Kory.icon 毎回メモリを舐めていくんで、普通の命令プログラミングよりは遅そう。分岐が無い素直な手続き型プログラミングを切り刻んで配置すると速度は落ちそう(測定したことないけど)。一方で、並列性が本質的に必要な場合は、全てが Cats.Effect 上に留まって協調的に動けることによる速度メリットがある(少なくとも Cats.Effect はそう主張している)
windymelt.icon スケールするから勝つンゴってことか
次回に向けて:プログラムモデルたちとランタイムたちの関係
プログラムって…実行しないと意味が無いんですよ…という話をする
↓ この部分
fs2.Stream から cats.effect.IO への変換と cats.effect.IO から scala.concurrent.Future への変換がどのような抽象化レイヤで動作しているのかの確認
cats-effect 3 においては、次のような機構がある
IOFiber:IO を実行する機械
「古典的なOOP的な意味で」iof: IOFiber[A] は機械である。
run() link を呼び出すと、「自分が飽きる、非同期境界にぶち当たる、もしくは cancelation を観測するまで自律して」動作する 自分が飽きる → 例えば、長さ 1000万 くらいの .flatMap チェインを複数同時並行で実行しようとしたときにも、各々のプロセスがしばらく (デフォルトでは 1024 ステップ) 実行されたら、それぞれが勝手に停止する、という挙動を我々は期待する。このために、それぞれの iof は一定回数処理を行ったら「飽きる」必要がある。
「ファイバーが占有していたスレッドをいったん手放す」ように見える
windymelt.icon Cats Effect 2にはshift()、CE3にcede()がありましたね
非同期境界にぶち当たる → FFI のコールバックだけ登録して、「もう今やることなーい」って言って止まる
状態を持っている
IORuntime: IOFiber が動作するための「環境」や、IOFiber の動作を設定する IORuntimeConfig などを持っている
「環境」→ compute / block するときにどの ExecutionContext を使えばよいか、という情報や、時計情報など
windymelt.icon ランダムプールとかもここに入る??
IO[A]#unsafeRunFiber メソッド (link) は、自身を (given された) IORuntime を使って実行してくれるような IOFiber を新たに作って、即時実行する (runtime.compute に、作った IOFiber を放り込む) fs2.Pull の (例えば) cats.effect.IO[Vector[A]] への変換もだいたい同じような仕組みになっている。
Pull -> IO への変換は、Scala (という、一番「外側」にある言語体系) から見ると、transpile / compile になっている (Pull も IO も Scala の中に構築されたプログラミング言語なので)。一方で、IO のレイヤから見ると、Pull -> IO への変換は、compilation ではなく interpretation になっている。
「IO から見た Pull」と「JVM からみた IO」が相似関係にある
windymelt.icon 一貫した意味は外側から与えられているけれど、内側からすると言われた通りやるだけ
windymelt.icon「マクロ展開しているように見える」
これは一番外側の階層から見た視点
違う点として、
fs2.Pull は IO とは違って「環境」のような概念には触らない。
意味論が IO で完結するから、追加で持ってこないといけないものが無い
IOの言葉だけでfs2.Pullの動作を語ることができるから
「基底」が IO にある。compile: Pull ~> IO は (JVM 内の) 副作用を生じない。
雑な図
IOFiber (赤) は IO の言うとおりに JVM 内で副作用を起こす機構である。
compile は、一番外側の世界 (JVM) から見れば、Pull という言語を IO という言語で書き直しているだけで、得られるものは IO というプログラムなのだから、compile をしている
持っておくと良い気持ち: IO(うんちゃらら)はJVMの世界から見るとPureやんという感じだが、IOの言葉の中ではちゃんと副作用が「起こっている」
しかし、IO の中に入って fs2.Pull を見つめなおしてみると、compile というのは、fs2.Pull の言うとおりに IO というプログラミング言語の中で副作用を起こす機構になる。そういう意味では、compile は IOFiber と同じように、インタプリットする機構として機能している。
https://scrapbox.io/files/6717a95b96db977af2acd442.png
おまけ
windymelt.icon 異常なチューニングが行なわれている様子だ
次回
10/29(火) 20:00~