「continuatioN Linking」という題目で、NL名古屋で登壇してきました。
NとLの字さえ使っていれば何でも良いというカオスなショートセッション会なのですが、内容は非常に濃くて面白いものばかりでした。とても満足度が高かったです。Togetterのまとめはこちら
今回写真撮り忘れてしまったのでありませんが、50名超えと大入りな感じでした。
継続ネタ
継続渡しスタイル(Continuation Passing Style)の話に絡めて、.NET TaskとF# Asyncのシームレスな相互運用を行うネタを発表してきました。
何しろここは名古屋 (;´Д`) なので、このネタで行くにはまだ「ひよっこ」で恐怖しかない感じでしたが、掴みもスベる事なく発表できたのでうれしかったです。一部の方には触れるものがあったようで、「おおーー!!」という声はやって良かった感ありました。
発表はショートセッションですが参加者が多いこともあり、最終的に時間制約が10分となって苦しいところでしたが、予定通り進行を無視して「継続」させていただきましたありがとうございます。
補足
さて、内容は駆け足だったので少し補足しておきます。
GitHub: kekyo/FSharp.Control.FusionTasks
NuGet (F# 2.0): FSharp.Control.FusionTasks.FS20
NuGet (F# 3.0): FSharp.Control.FusionTasks.FS30
NuGet (F# 3.1): FSharp.Control.FusionTasks.FS31
NuGet (F# 4.0): FSharp.Control.FusionTasks.FS40
検討
アイデアはすぐに思いついたものの、実現可能かどうかが良くわかりませんでした。そもそも素人の浅はかで、.NET TaskクラスとF# Asyncクラスは似ているな、もしかしたら簡単に相互運用できるかも?と思ったのがきっかけです。
- わざわざ似ているものが併存している事に何か特別な理由があるのかと思っていましたが、実はF# AsyncのほうがTaskよりも歴史が古く、CLR 4.0の形も無いころから既に非同期ワークフローがサポートされていました(.NET 2.0から実現している)。だからこの疑問は不適切で、.NET TaskはF#に依存しないように改めて設計しなおしたのだと思われます。
- F#非同期ワークフローは、AsyncBuilderクラスを使用する、一種の構文糖(computation式)だと理解しています。F#の場合、任意の型に(インスタンスであろうとスタティックであろうと)拡張メンバー(C#で言う所の拡張メソッド)を生やす事ができるため、AsyncBuilderに.NET Taskを扱うメンバーを増やすことで、非同期ワークフローでシームレスに.NET Taskを扱えるのではないかと考えました。
- 一番大きな動機は、HttpClientなどのネイティブ非同期対応ライブラリを、そのままF# Asyncで使うことが面倒であったということがあります。F#にも標準で.NET Taskのためのサポートはあります。「Async.AwaitTask関数」がそれですが、この関数は見ての通りT型が特定されなければならず、非ジェネリックTaskをAsync<unit>に変換できません。また、逆の変換(AsyncからTask)への変換方法もありません。逆の変換ができると、F#で書いた非同期ワークフローのインスタンスを、C#側でawait出来るようになるため、さらに応用性が広がります。
※ computation式については、「詳説コンピュテーション式」が詳しいです。
非同期コンテキストの結合
.NET非同期処理(async-await)と例外の制御 で持ち出した「タスクコンテキスト」という用語なのですが、もはや統合されたこの世界では「非同期コンテキスト」としか言いようがないですね。
「非同期コンテキスト」をシームレスに結合した場合、維持されなければならない重要なポイントがあります。それは、「同期コンテキスト(SynchronizationContext)」と「キャンセルトークン(CancellationToken)」です。うーん、コンテキスト紛らわしい (*´Д`)
.NET Task側の事はある程度わかっているのですが、F#非同期ワークフローではこれがどのように維持されるのかがわからず、シームレスな相互運用が実現するにはココの担保が不可欠だと考えていました。
fsugjpの方々とやりとりしたり(あまりまとまりは無いです)、MSDNを調べたりして、以下の事がわかりました (Thx fsugjp!!)
- F#にはAsync.FromContinuations<T>関数があり、これを使うとコールバック関数を経由してAsyncクラスの結果を制御できます。これは丁度TaskCompletionSource<T>クラスを使ってTaskを間接的に操作することに相当するので、Taskの結果を反映させることに使えます。
余談ですが、初見ではあの引き数で何ができるのか、さっぱりわかりませんでした(つまりビギナー認定w) - 「Continuations」という単語の響きに導かれ、逆のパターンにはAsync.StartWithContinuations<T>関数を使えばよいことがわかりました。こっちはもっと単純で、正常・異常・キャンセルの継続処理をCPS形式で渡すだけです。
同期コンテキスト自体は、Asyncクラス内で非同期待期に使用しているので、F#非同期ワークフローで使う限り特に問題なさそうです。Task.ConfigureAwaitメソッドに相当する同期コンテキストのキャプチャ制御についてはむしろF#の方が柔軟性があり、Async.SwitchToContext・Async.SwitchToThreadPool・Async.SwitchToNewThread関数を使用して、いかようにでも同期コンテキストを操ることができます。
そしてキャンセルトークンの方ですが、F#側はAsyncクラスのインスタンスを何らかの方法で実行するときにトークンを保持し、それがAsyncクラスの非同期コンテキストの情報として保持されて使われます。簡単に言うと:
// 非同期ワークフロー内で非同期スリープ(Task.Delayと概念は同じ) let asyncBody = async { // (トークンはこの非同期ワークフローのコンテキストに暗黙に伝搬している) do! Async.Sleep(10000) } // キャンセルトークンを準備 let cts = new CancellationTokenSource() // 非同期ワークフローをキャンセルトークンありで同期実行する Async.RunSynchronously(asyncBody, Timeout.Infinite, cts.Token)
※ 注意: RunSynchronouslyは説明のために使用しています。本当は使わないように書くべきです。Task.Waitに相当します。
上記のような状態でSleepしているときにトークンがシグナル状態となると、正しくSleepが中断されます。Task.Delayの場合はキャンセルトークンを受け取るオーバーロードを使用していないと中断できませんが、F#非同期ワークフローの場合は、現在の非同期コンテキストに伝搬するトークンが管理され、Async.Sleepはそれを監視しているので正しく中断されるのです。
伝搬の手法は魔術的な何かでもなんでもなく、RunSynchronouslyで渡されたトークンがAsync.CancellationToken プロパティで管理されていて、Async.Sleepはこれを参照しているからです。実際、Task.Delayと異なり、Async.Sleepにはトークンを明示的に指定するオーバーロードはありません。
作ってみた感想
この部分、私的にはとても良くできていると思いました。というのも、F#非同期ワークフローの「非同期処理」に関する範囲と制約は、すべてワークフロー(computation式)内に閉じているからです。キャンセルトークンの管理もここで閉じているので、操作する側はAsyncクラスのインスタンス=非同期コンテキストと言うように、はっきりとイメージ出来ます。
対照的に.NET TaskとC# async-awaitではこれが曖昧で、ビギナーにasync-awaitの事を説明するためには、どこからが非同期コンテキストなのかをしつこいほどに解説する必要がありました。また、デフォルトではキャンセルに対する操作は完全に使用者任せとなり、Task.Delayのようにトークンを引き渡すシグネチャを明示的に設計に盛り込む必要があります。私はこのことに気が付くのが遅かったために、フレームワークインターフェイスの変更という大手術を行う羽目になったことがあります。そして、たとえ周到に準備しても、使用者がトークンを渡すのを忘れると台無しとなり、トークンをどこで管理するのかということも(使用者が)考える必要があります。
物事にはトレードオフがあるはずで、.NET Taskとasync-awaitが後発でありながらこういう選択をした理由も恐らくあるのでしょう。想像出来ることと言えば、async-await方式は、メソッド内のコードに「await」という単語が入るだけで、それ以上の大幅な変化がないと言うことです。しかし、実用的な非同期コードをasync-awaitで書いたことがある方ならわかると思いますが、現実には非同期であることを意識したコードを書かないと、pitfallに落とされます(一例を例外の記事でも書きました)。
F#非同期ワークフローでは、「async」に囲まれたcomputation式内でしか使えず、computation式では「let!」「do!」などの普通には使わない予約語を使う必要があります。ただ、上で述べたような問題とのトレードオフとしては悪くない選択だなと思います。説明も簡単なのが大きいですね。
そして、一番最初の「もしかしたら簡単に相互運用できるかも?」というのは、「それなりにイケた」という感じですが、やってみた後の感想としては、.NET TaskとF# Asyncクラスはそれぞれ役割もインターフェイスも似ているにも関わらず、現実には設計思想からして全く違うものだという印象が強くなりました。
後は、現在進行中のもう一つのプロジェクトと合わせて、何とかF#のコードが書けるようになったと言うことかな。人に説明するにはまだいろいろ足りてない感じですが、F#や関数型言語の面白さの一端は見えてきました。
それではまた。