非同期処理、なにもわからない

最近のお仕事で、C#の非同期処理の書き方が分からずに、溶岩地帯に自爆していくコードを沢山みるようになったので、ケースとして日常風の記事にしてみました。

どんな風にハマってしまい、どうやって解決するのかが分かると思います。

結論

先に書いておきます。

  1. Task.Run()を使ってはいけません
  2. Task.Result, Task.Wait()を使ってはいけません
  3. Threadクラスを使ってはいけません
  4. async-await構文だけを使って書きます
  5. async voidにするのは特殊な場合だけです

がんばれー、わかってしまえば難しくない!

補足: もしあなたがJavaScriptで非同期処理を書いた事があるなら、その知識をそのまま生かせます。
JavaScriptではTaskの代わりにPromiseを使いますが、Promiseには上記1,2,3は存在しません。しかし、それで困ることはありませんよね? (これはとても重要な示唆です)

平和の終焉

以下のような実装を考えます:

// Web APIからデータを取得する
public string FetchData()
{
    // TODO: ここにWeb APIからのデータ取得コードを書く
}

ここからコードを書こうとしている人は既に罠にはまっているわけですが、気が付けないので、そのままここに肉付けをしていくわけです。

Web APIにアクセスするには、今はHttpClientを使いますが、彼らは使い慣れているWebClientを使います:

// Web APIからデータを取得する
public string FetchData()
{
    WebClient wc = new WebClient();
    string body = wc.DownloadString("https://example.com/api/data");
    return body;
}

出来ました。ただ、WebClientのドキュメントを見ると、今から使用するのは推奨しない、代わりにHttpClientを使うように、という事が書かれています。また、レビュアーに指摘されたり、ググったりして見ても、やはりもうWebClientは使うな、と言うような情報が得られます。そこでこれを書き換えようとするわけです。

(なお、今回の題材はWeb APIですが、例えばRedisだとかAzureのサービスとかで提供されるSDKとか、もう大半が非同期処理対応のAPIしか用意されていなかったりするので、Web API固有の話ではないという事に注意して下さい)

Day 1

Sync氏は、WebClientをHttpClientに書き換えていきます。しかし、GetStringがありません。代わりに、GetStringAsyncという似たようなのがあるので、これを使おうとします:

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // エラー: 型が合わない
    string body = hc.GetStringAsync("https://example.com/api/data");

    return body;
}

GetStringAsyncというのを使ったら、型が合わないというエラーが出ます(幸いなことに? Sync氏はvarを使わないので、ここでエラーを検出)。よくよく見ると、Task<string>という型です。

ここで彼らの運命は分岐します。どちらも溶岩地帯です。

Day 2 – ピュアなResult

MSDN公式のTaskクラスのドキュメントを読むとサンプルコードがあり、どうやらResultプロパティで結果を取得できるらしい。

(すいません、この記事書いててちょっと頭クラクラしてます… 上のドキュメントは初心者をサイレントに殺しに掛かっているので、見ない方が良いです。リンクは消しました)

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskは良く分からないけど、Resultプロパティを見れば結果が得られそうだ
    // 型もばっちり一致して、コンパイルが通ったぞ!
    string body = hc.GetStringAsync("https://example.com/api/data").Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない

Day 3 – 分身するResult

GUIアプリケーションを苦労してデバッグしていくと、どうもResultが返ってこないことがあることが分かった。ASP.NETの方は、皆目見当がつかない。そこそこ良いVMを割り当てているのに、.NETって性能悪いの?それともWindows VMのせいなの? Linuxにすれば良いの?

そうは言っても納期が迫っているので、何とかしなければならない…

そうだ、ワーカースレッドでResult取得するところを分離すれば固まらなく出来るのでは? 性能上げられるのでは?? さっきのドキュメント読んだら、Task.Runを使えば簡単にワーカースレッド使えるみたいだし、これで行こう!

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskって便利だね!ワーカースレッド簡単に使えるんだ
    string body = Task.Run(() =>
        hc.GetStringAsync("https://example.com/api/data").Result).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2より悪化

Day 4 – async-awaitの存在を見出す

もうわかんない、HttpClientとかTaskとか何もわからない… Taskでググるとasync-awaitとか言うやつが出てくるから、これを使えばいいのかな…

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // async-awaitの書き方本当にわからない...
    // 1日試行錯誤してやっとエラー出なくなった...
    string body = Task.Run(async () =>
        await hc.GetStringAsync("https://example.com/api/data")).Result;

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 何かの拍子にアプリケーションが固まる。原因不明
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2ぐらい。多分この人はその違いを測定してない…

Day 5 – スレッド手動操作

わかったわかった、Taskなんて使うからダメなんだ。初めからThreadクラスを使えば良かったんだよ。

// Web APIからデータを取得する
public string FetchData()
{
    HttpClient hc = new HttpClient();

    // Taskなんて意味わかんないし、これでいいや。
    // でも何でこんなの使うの? WebClientでよくね?
    string body;
    Thread thread = new Thread(() =>
        body = hc.GetStringAsync("https://example.com/api/data").Result);
    thread.Start();
    thread.Join();

    return body;
}

起きうる結果について:

  • GUIアプリケーション(WinForms, WPF, Xamarinなど): 一応動作はする。但し、Web APIアクセス中は固まる
  • サーバーサイド(ASP.NETなど): 同時接続によるパフォーマンスが悪い、全然伸びない。Day 2と同じ

わけがわからないよ。どうしてみんなはそんなにHttpClientにこだわるんだい?

Day 6 – 真実

Sync氏「HttpClientだと固まったり性能が上がりません。HttpClientは動作が怪しいので多分バグってます。WebClientを使う方が安全です」

レビュアー「駄目です、HttpClientを使ってください。こう書きます」

// Web APIからデータを取得する
public async Task<string> FetchDataAsync()
{
    var hc = new HttpClient();
    return await hc.GetStringAsync("https://example.com/api/data");
}

レビュアー「非同期処理を行うメソッドは、必ずTaskクラスを返すようにします。また、実際にはHttpClientはstaticで保持る必要があります。とりあえずこんな感じで他のも全部直してください」

Sync氏「え、でもこれだと、メソッド名も戻り値も変わっちゃってるので、呼び出し元にも修正が入ってしまいます」

レビュアー「呼び出し元も直してください。他にうまくやる方法はありません。実際には、メソッド名は直さなくても良いですが、直しておいた方が後々困らないです」

Sync氏「(絶望…)」

(補足: 文字通りです。大げさな話ではなく、他に方法はありません。試行錯誤は時間の無駄になります。警告しましたよ?)

Day 7 – GUIの真実

Sync氏は、必死にGUIのコードを直していた。

// コンストラクタ
public FoolishForm()
{
    // エラー: 型が合わない
    button.Click += this.Button_ClickAsync;
}

// ボタンがクリックされたら、データを取得して反映
public async Task Button_ClickAsync(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

良い所まで来ていたが、Button_ClickAsyncメソッドをイベントハンドラとして登録するところで、型のエラーが発生して悩んでいた。

数時間後…

Sync氏「イベントハンドラの部分はどう書けばいいんですか?型が合わないと言われてしまいます」

レビュアー「こう書きます」

// コンストラクタ
public FoolishForm()
{
    // 問題ない
    button.Click += this.Button_Click;
}

// voidでいい / Task返さないので~Asyncも不要
public async void Button_Click(object sender, EventArgs e)
{
    // データを得る
    var data = await this.FetchDataAsync();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「え、でも非同期処理ではTaskを返さなければならないのでは?」

レビュアー「この場合だけ、つまりイベントハンドラだけ、async voidを使えます。この場合だけと考えてほぼOKです。他では使わないでください」

数分後…

Sync氏「試してみたら固まらなくなりました!ただ、動いているときにボタンを連打すると、何度もサーバーにアクセスしてしまうのですが」

レビュアー「古典的なJavaScriptの問題と同じで、アクセス中にボタンをクリックできないように仕様変更して下さい。実装はこんな感じにすれば良いと思います」

// ボタンがクリックされたら、データを取得して反映
public async void Button_Click(object sender, EventArgs e)
{
    // まずボタンをクリックできなくする
    this.button.IsEnabled = false;

    try
    {
        // データを得る
        var data = await this.FetchDataAsync();
        // テキストボックスに反映
        this.textBox.Text = data;
    }
    finally
    {
        // ボタンをクリック可能にする
        this.button.IsEnabled = true;
    }
}

Sync氏「ああなるほど! そうか、ロード中くるくる回るやつを追加で表示したほうが良いかもしれないですね」

レビュアー「そうですね。あと、async voidとする場合は、必ず例外をcatchして下さい」

Sync氏「何故ですか? 今はApplicationクラスの共通例外ハンドラで処理しているので問題ないと思いますが…」

レビュアー「その例外は、共通例外ハンドラでは処理できません。見たことの無い例外がデバッガのログに出てませんでしたか?」

Sync氏「ああ… UnobservedTaskExceptionというのが出てました。それも変なタイミングで表示されていた気がします」

レビュアー「私はこれから出張があるので詳しくは省きますが、メソッド内でcatchして処理するのがベストです」

(補足: この事については、別に記事を書きましたので、興味があれば参照してください

Day 8 – サーバーサイドの真実

さらに数日後…

Sync氏「サーバー側の実装もわかりました、前は呼び出し元はこうしていたのですが」

[HttpPost]
public string GetData()
{
    // データを得る
    var data = this.FetchData();
    // テキストボックスに反映
    this.textBox.Text = data;
}

Sync氏「こうすれば良いという事ですね?」

// APIエンドポイント名にAsyncとついていても、自動的に無視される
[HttpPost]
public async Task<string> GetDataAsync()
{
    // データを得る
    var data = await this.FetchDataAsync();
    // 返却する
    return data;
}

レビュアー「はい、それで良いと思います」

Sync氏「GUI側と違って、サーバー側の実装は特に変わったことは無いんですね… もう一つだけ、わからない事があるんですが」

レビュアー「なんですか?」

Sync氏「今回の非同期処理対応で、結局Task.Runメソッドを使わなかったのですが、あれはいつ使うものなのですか? C#の非同期処理についてググってみると、大体Task.Runを使う例が載っているので使おうとしてああいうコード書いてしまったんですが…」

レビュアー「大量の計算だけを行う処理を並行して同時に処理させたい時だけに使います。我々が普段書いているコードで、そういう処理は殆ど無いですよね? なので、知らなくても良いレベルで使わない筈です」

(注意: 初稿ではHttpPost属性でAsyncを付けていない名前を明示していましたが、不要でした。Ovisさん、ありがとう)

輝かしい未来への第一歩

Sync氏に横たわっていた暗い霧は、今晴れわたった。そこにはまだ何も存在しないが、やっとスタート地点に立った。そう感じたのである…


お約束の…

ここで挙げた例は、どうしてTask.RunやTask.Resultを使いたがってしまうのかという所に焦点を当てているため、実際にはもっと効率の良い実装テクニックが色々ありますが、最低限このように書けば、Taskをいじり倒して溶岩地帯に突っ込んで死亡する、ということは無くなると思います。なお、ASP.NET (Core), WinForms, WPF, Xamarinについて取り上げましたが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。

もう少しかっちりした解説が必要なら、こちらを参照してください

では、いつものやつ、行ってみましょう:

Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。

今、記事冒頭の「結論」の項を読み返せば、納得できると思います。「C# 非同期処理」とか「C# async await」などでググってみると、Task.Run()やTask.Resultを最初に説明したり引き合いに出す記事の何と多い事か… これらは本当に、知らなくて良いんですよ

もし、自信が無くなってきたら、JavaScriptにもPromiseとasync-await構文があるのに、これらに該当するものは無くても実用出来ているということを思い出すと良いと思います。

(更に補足すると、.NET/C#の場合は、async-awaitよりも先にTaskが生まれてしまった事が、不幸の始まりだったのです)

この記事で少しでも救われる人が増えますように。

それではまた。

“パターンでわかる! .NET Coreの非同期処理” と楽屋ネタ

先日、“.NET Conf 2019 meetup in AICHI”を開催・登壇したので、その話をまとめます。

2つ目のセッションの話を先に

私は2つのセッションで喋りました:

1. “.NET Core 3の更新内容・紹介”
2. “パターンでわかる! .NET Coreの非同期処理”

最初の方はキーノートのまとめ的なものにしようと思っていたのですが、その話はまた後で。

非同期処理のセッションですが、今回はあんまり内部の技術的な話ではなくて、.NET Core 3.0とC# 8.0で.NETの非同期処理が一応の完成を見たと思っているので、改めて全体を網羅するような内容にしようと思ったものです。その上で、

* 度々非同期処理の誤用が話題に登ったり、辛しみのあるコードを目撃することから「こんな」記事を書いたりしていたのですが、もっと網羅的にまとめたいなーと思っていたので良い機会として共有しよう

というところを目標にしました。

スライド: パターンでわかる! .NET Coreの非同期処理 (SlideShare)

セッション50分でこの分量は無理ゲーなのはわかっていたので、スライドは後で読み返すことが出来るような構成にして、如何に喋りで補えるかに注意しました。が、セッションを聞きながら見ていない場合は、少し足りない(わかりにくい)部分があるような気はしています。セッションビデオと両方で補完してもらえればと思います。

なお、最近twitter上で非同期の事について話ししたものをいくつか置いておきます。この辺の話が今回のセッションをやりたいと思う動機につながっています:

上記の内容や過去の自分が書いた記事、構文的な書き方はわかるんだけど、具体にどう書いていいのかわからない… みたいなところから、ちょっとずつネタを拝借。

で、最初のセッション

最初のセッションの方は、参加した方には普通に楽しんでもらえたと思っているのですが、懇親会で舞台裏の話をしていたら、それをブログにまとめろとのお達しが… なので、普段、この方面の話は書かないのですが、楽屋ネタを書きます。

Scott Hanselmanこのセッションは、当初は私が全部.NET Core 3.0のキーポイントをまとめて1セッション構成するつもりだったのですが、水面下ではMSHQのScott Hanselmanが、日本のコミュニティ向けに何か喋ってくれるかもしれない、という話がもちあがり、それならその方向から構成を考えるために、決定するまで待つかと思っていました。

みなさんご存知の通り、大変に忙しいキーパーソンなので、この試みがキャンセルされるという事態も考えられましたが、関係者が頑張って調整に入っているようなので、ギリギリまで待つつもりでした。

ところで、コミュニティイベントを開催された方なら同意してもらえると思うのですが、イベント開催のためには以下のようなハードルをこなす必要があります:

* 会場の地理的なアクセスのしやすさの検討
* 会場収容人数
* 会場は有償か無償か。有償なら費用をどうするか
* プロジェクターや音声設備の有無・レンタル費用
* WiFiや有線LANなどの通信経路の確保
* 運営メンバーの募集と決定
* 登壇者の募集・登壇内容の調整
* ビデオ撮りあるいはストリーミングライブ配信するのかどうか(合意形成も含めて)
* チェックイン作業
* スポンサーを募集するのかどうか
* 集金や支払いなどの金回り
* ノベルティーなどのグッズ・抽選の有無
* 提供するおかしなど
* 終了後のハッピーアワーや懇親会の検討・場所の確保と誘導・支払い徴収

運営は本当に頭の痛い問題なのですが(これで折れてしまう方もいるので、私は自分が出来る範囲で出来ることしかやらない事を守るスタンスです)、登壇する側としても、セッションネタを考える場合に、上記の制限条件からネタ構成を考える必要も出てきます。例えば:

* 収容人数: 10名・30名・50名・100名かそれ以上、によって、セッションの構成(しゃべる内容や構成、進行方法)を変えたほうが良い。人数が多くなると、後ろの方の席の人は、スライドが読みにくいだとか、声が聞こえないとか、没入感が得られない、といった潜在的な問題が発生しそう。
* プロジェクターや音声設備: スライドの色校正(たいてい黒地だとハマる会場は多いが、パリッとした映像を出せる会場なら、逆に黒地が映える。動画を流す場合は音量に注意しないと、うるさいとか音が小さいとかの問題につながる。しかし、実際に会場で調整しないと思ったとおりには行かない(マイクとかミキサーなどの設備にも影響される)ので、セッションのネタにするのは結構なリスクとなる)
* WiFiや有線LAN: 参加人数が多いと、特別に設計されたWiFiアンテナなどを備えた会場でないと、話にならない事態になります。私はあまりオンライン向きのネタを扱わないので良いのですが、最近だとクラウドアクセスをデモの前提にする必要があったりして、非常にやりにくいでしょうね。逆に、ネットワークの信頼性が高い事が前提にできるなら、クラウドだろうがYouTubeだろうが、セッションの子道具としてバリバリ使えます。

まあ、細かいところを挙げるとキリがないのでこのぐらいで。つまり(人にもよりますが)、セッションネタ一つ決めるのにも、こういう要素が絡んできます。なので、登壇する側は事前にこれらの要素が早く確定していれば、それだけ登壇者や参加者にとって、実りのある内容に詰めていける可能性があるわけです。

で、冒頭の話につながるのですが、今回は会場を日立ソリューションズさん(HISOLさん)にお借り出来たのですが、その前にもいくつか候補があって調整していました。全ての会場が完全に同一条件なら、「まあ確保できるところでやればいいよね」と、問題の一つが片付くのですが、現実はそうじゃないのでヤキモキします。

結局、検討していた会場は確保できなかったのでHISOLさんに打診したところ、快諾得られたので良かったのですが、確定までに2週間前までかかったこともあって、なかなか会場を公表できませんでした。登壇者のみなさんごめんなさい。

こういう点で、名古屋は非常に苦労するというのはなかなか理解してもらえないところでツライです。東京に近いところは楽そうだなと思うし、もっと地方ではそもそも会場になる場所さえ無く、勉強会というものを想像するのも難しいと言うのが現実でしょう。

で、問題は上記の会場環境の話に繋がります。HISOLさんの会場では、残念ながらパブリックなWiFi設備を借りる事が出来ません(もちろん有線LANも)。すると例えば、クラウドベースのデモとかかなり厳しい(デモの悪魔に取り憑かれる可能性が高い)わけです。

言わずもがな、YouTubeを参照したりなど出来ず、オンラインで遠隔会場と連携しながらビデオチャットでセッションを進行するという、海の向こうでは割とポピュラーな手法も取れず… これはイベントの一体感もあるので、ある程度の規模のイベントではやりたくなることの一つです。

実際、もしかしたらHanselmanさんとTeamsで完全オンラインでの相互コミュニケーション、というのも検討の一つとしてありましたが、まあ会場的に無理なので、実現するとなったら有償で会場を確保するかということも考えなければなりません。

(そうすると参加費500円とかでは無理ですし、そもそもそんなギリギリで会場を予約できるか怪しいし、堅苦しい契約書をかわさなければならないですよ、個人で。請求や領収、今だと税金をどうするかみたいな話も出てくるかも)

なので、主催が本当に小規模な勉強会を小規模な会場でやる分には良いんですが、ちょっとでも規模が大きくなり始めると、非常に辛くなります。そこを踏ん張ってやるからには、それを超える動機づけが必要になるでしょう。私の場合は自分の技術的な知見をシェアする、まあ淡良くば技術ポートフォリオとして誰かの目に止まるかどうか、ぐらいで、これで食っていく気は全く無い(食える気がしない)ので、必然的に掛けられる労力もそのぐらいになります。

さて、そういうわけで、様々な軸での考えがあるため、これらをどこにバランスさせて、セッションのネタにするのか、というのは、真面目に考えるととんでもなく難しいです。今回のネタとして、実は以下のような移り変わりがありました:

ごく初期

.NET Confの開催より後に開くことを前提に、.NET ConfでMSHQで公開されるビデオをネタに同じような感じでローカライズバージョンをやればいいかな(鼻ホジ)

環境リッチな会場で開催できるか

.NET Confを拾い上げるにしても、様々なセッションから何をネタにするのかは、上で挙げたような要素が、特に会場確保によって大きく変わるので、まずはどこの会場が使えるのかが最大の課題。

第一としていた候補はダメ。あと2~3考えていたが、それらはどれも環境的に厳し目なため(WiFiが無いとか、ちょっと会場の母体の趣旨と合わないとか)、この時点でセッション内容についてはかなり幅が狭くなる。

Hanselmanさんがビデオメッセージを送ってくれる(あるいはリアルタイムで!)かも??という打診

すごい話だけど、運営側としては、完全に確定だからあとはやれるように組む、というのと違い、不確定だとどう動けば良いのかわからずツライ(それもギョムの合間を縫って行動する必要があるので)。早く決まって欲しい… 早く… 早く…

別の会場を打診

アテの一つは早々に辞退(上記の後者側)、もう一つを打診。

自分の登壇ネタを検討する

.NET Confのセッションを拾い上げるという案は、会場の自由度があるから考えられる手法であって、もうそれは無理なので、結局自分でネタをひねり出すしか無い。非同期の話をしたいから、まあ2枠って事になるかな。

で、問題はもうほかの登壇者さんの募集とか打診とかして枠が埋まりつつ合ったので、もしHanselmanさんの話が現実化する場合、どこの枠(時間帯)をその話にするのかを考える必要がある。内容がキーノート的なものなら、時間的にはやっぱり最初に喋って欲しいけど、そうでないならべつの時間帯でも良いわけだけど、果たしてオンラインで喋るとすれば、本当にJST(日本時間)に合わせてくれるの?(いや、そもそも無理ちゃうの?)

WiFiすら使えない環境でどうやって実現するの? 安定性は? Hanselmanの生の話が聞けるなら、みたいな期待で参加者が押し寄せて、出来ませんでしたテヘッ、みたいな事態を想定しなきゃいかんのか、とか…

非常にツライ…

正直なところは、予定が決まらなかったら、こちらから辞退することも視野に入れていました。自分のネタを考えているはずが、全体の進行まで気になり始めて、それどころじゃない感じ。全然集中できない…

気を取り直してネタ考案

最悪、キャンセルになって自分で全部仕切る(つまり2枠考えて喋る)ということを想定して準備。なんか色々頑張っても無駄に終わりそうで、それもつらい。 最初の方は、.NET Confのキーノートを読み込んで概要にまとめて、面白そうなトピックを実演を交えて喋る、みたいに構成するしかないかな(もはやキャパオーバーだけど、主催自分だから仕方ない)。

3日前の夜に、Hanselmanさんのビデオが送られてきた!!

もうこの時点では、8割方断る方向で心中決まっていて(でないと間に合わない)、手間が空いたときに断りのメッセージを送るしか無いか、と、散々たる気持ちでいたのですが、こっちからメッセージを送る前に向こうから来た。しかも、いきなりビデオが来た… マジか… もうこれで構成するしか無い(非同期のセッションをほぼ書き終えていたので、首の皮一枚つながった感じ)

ということで、20分のメッセージをまずはAzure Video
Indexerで字幕データを作り
、それを補正(わりと全自動では駄目な感じの字幕なので、手修正する)して、ffmpegで合成して英語版字幕付きビデオを作る(成果物はここ、但しビデオそのものは含まれていません。時間の関係で、訳を直したりタイムラインを調整したりと言ったところはまだ細部が荒いです。関係者はエンハンスして欲しいです、是非に)。

2日前

ビデオの内容を読み込み、字幕がアレだったことから日本語字幕化する事を考え始めた。でも、SRTファイルをそのままGoogle Translatorにかけるとフォーマットが壊れてしまうのと、センテンスがつながっていないのが原因でダメな翻訳になってしまうこともあって考えあぐねていた(Azure Video Indexerで日本語訳出せばいいじゃん、と思うかもしれません。機能的にはありますが、まあ、察してください…)

それと同時に、セッションをどう進行するかを考えていたけど、ビデオ流しながら補足を入れつつ、(50分枠なので)後半は小さいコードをライブで何かデモするか、そうだねぇ、あんまりどこにも話題にかかっていないgRPCとか良いかもしれない、と考えて、概要を調べる。

その後、字幕の調整のために、ぼーっとSRTファイルの中身を見てたら、これ、フォーマット簡単だよね? もしや自分でパース出来るのでは?と思いつく。そこで、あえてビデオを流さず、いきなりライブコーディングでパーサーを書き、Google Translator APIを使って日本語に翻訳(4G経由の通信なので、ここにリスクはあるが)して日本語字幕ファイルを出力するツールを書き、ffmpegで合成して、日本語字幕のついたビデオをその場でみんなに見てもらう、というのは、かなりエキサイティングじゃないか?

というのを思いついたので、パーサーを書き始める。パーサーは簡単だった。しかもここに非同期イテレーターを使っているので、.NET Core 3としてもふさわしい。

そして、Google Translator APIのメチャクチャ簡単で使いやすいこと!! しかも.NET向けのNuGetパッケージもあって、面倒くさそうなアカウントキーもどこからサインアップしてGetすればいいのか、わかりやすいドキュメント… なんてことだ!15分ぐらいで実際に翻訳出来た!! ええ、なんでGoogle API使っているのかって、自問してましたよ…

しかしここで字幕特有の難しさにブチ当たる。それは、1センテンス(日本語なら読点まで)が必ず1画面に収まるわけではなく、分割されたセンテンスに推測不能(一定時間ではない)時間タグがついて分割されている、ということなのです。

“My name is (ここで分割されている) Kouji Matsui.”

“私の名前は松井幸治です。”

果たしてどこで分割する? System.Stringで英文の割合で発生する分割点と同じ割合の文字で切るか? 例えば、46/54なら、「は」の後ろで丁度切れるかもしれない。しかし、こんなにうまくフィットするわけがなくて、普通は非常に読みにくい位置で分割されてしまう…

では、形態素解析するか?形態素解析すれば、助詞とか句読点位置を検出してそこで区切って、自然な感じの字幕に出来るのでは? えーやったこと無いぞ、今からは無理では… .NET実装はあるのか?と考えて検索したら、あった: .NET形態素解析エンジンNMeCab (SourceForge.jp)

NMeCabで検索すると、割と紹介記事があるのだが… ライブラリ更新されてない… net20とか古すぎる。そうか、じゃあNMeCabをベースにチャッチャと移植して、これはこれで別の機会にネタとしてまとめよう、と思いついて移植を始める。

1日前

NMeCabの.NET Coreへの移植が出来た… アンセーフブロック使ってたり古いリソース(Settingsとか)して、ちょっとハマる点があったけど、そのあたりを軽く手を入れ、net40, net45, netstandard1.3, netstandard2.0, netstandard2.1対応のNuGetパッケージ、しかも標準のIPADIC同梱でありながら、自分の辞書を使う事も出来るように構成可能にして公開しました。

まあ、気がつくともう翌日が本番なのに何やってるんだろう、みたいな…

で、日本語字幕を付けるコードの続きを書き始めたのですが、日本語字幕センテンスを割合で再分割してSRTのフレームにする部分が思いの外面倒で、疲れ切った頭では正しいコードをひねり出せず… もう寝る時間が無くなって本番の運営に支障をきたしそうなので、無念だけどここでピボットする事に。

MeCab.DotNetのことと日本語字幕ツールの話は、また後日のネタにする事にして、当日はHanselmanの英語字幕付きビデオを流してコメントを入れつつ、その後にHanselmanのデモと同じことを私が再現して、多分言い足りない部分を補う感じにすれば、丁度50分に収まるだろうと考えました。

(今回のセッションではなく)不特定の人に英語技術ビデオを見た後に感想を聞くと、なんか腑に落ちない顔をしていることがあります。英語や英語の字幕のせいでピンときていないのかも知れないし、ビデオでは簡単にやって見せているけど、周到に準備してるからじゃないの?というような疑念があるんじゃないかと思っているので、同じことを目の前でやって見せることで払拭したかったのです。

(Hanselmanさんのデモ自体は、上記のような不安要素は殆どなくて、押さえるべきところが押さえられていて流石だなと思っています。)

ポイントはそんなに多くなくて、実際やって見せればシンプルな話だと思っていて、目の前で動いているプロセスの状態を見たりとか、今まさにWindows側でビルドしたコードが、WSLのubuntu上でそのまま動きましたよね(間違いなく)、みたいなことじゃないかなと。

そういうわけで、これで50分のセッションを構成することにしました。この内容の利点は、資料が一切不要なこと。自分の喋りと進行をその場で工夫して乗り切ればOKだ。この時点でもう23時なので、他の手段は取れそうにない…

妄想

ということで、今回のイベントの裏がどうなっていたのかが何となくわかっていただけたかと思います。半分は運営全体の話、半分は個人的な事情によるものですが、こうしてみるとやっぱり運営に割ける時間が足りない、運営に手間を取られすぎる、ということが大きかったと思います。

コミュニティへの貢献は色んな方法が考えられます。私が一番して欲しいと思っていることは、ぜひ、自分の時事ネタでも興味のあることや知見でもなんでも良いので、発表・シェアしてほしいです。元々そういう場なので。

そして、コミュニティに物理的な(生々しい話をするなら金銭的な)貢献をして頂ける場合は、運営の裏舞台、特に発表とは関係のない諸々の作業(これは私の場合ですが、それぞれのコミュニティの事情を読み取って)の負担を軽減してもらえて、結果的に時間を削減出来ると、本当に助かります。時間を確保出来るようになれば、発表する内容をより吟味したり、面白く楽しい内容に出来るようになり、より良いコミュニティイベントを開催・継続できることにつながると思います。

(あえて挙げるとすれば、名古屋の場合はやっぱり会場ですかね。リッチな環境が整備された会場が提供されると、悩みの半分は解決します。場合によっては7割ぐらい楽になる感じ。今や募集とかはconnpassのようなサービスで出来るし、あとは当日そこに行って発表する事を考えるだけなので。いつも会場提供して頂ける団体や組織には、本当に感謝しています :)

C#でわかる こわくないモナド – F# 勉強会 岐阜

今年久々の投稿。「C#でわかる こわくないM」というお題で、F# 勉強会 岐阜で登壇してきました。

「M」とは「モナド」のことです。内容について誰にも推敲依頼しなかったので、実際に発表するまで内容の正確性に自信がなかったのですが、大きな問題はなさそうでホッとしました。
最初に喋りましたが、これについては解説の筋道を2年ぐらい考えていました。なんとか形になってよかったのと、自分の口で説明することでより理解が深まったのが大きな収穫でした。

帰りにはもみあげさんとコレクションへのモナドの適用(要するにLINQのSelectMany)がモナドであるのか否かの話も出来たので、これも大きかったです。次はモノイドの理解とモナド・モノイドの(頭のなかでの)統合が課題かな。このネタは、Fsharp Bootcamp Tokyo 2016 with Tomas Petricekのときに出た、モナド(又はモノイド)に対するコンピュテーション式の適用での矛盾が元ネタで、この時にはまだ消化しきれなかったので、いつか噛み砕いて理解しようと思っていたことです。このあたりが理解できると、抽象化の道具がまた一つモノになったと言える気がします。今日の発表で、一歩近づいたかな。

* 内容はbleisさんのGistに書いてありますが、今見てもまだ怯んでしまうな…

それではまた。

できる!C#で非同期処理(Taskとasync-await)

asyncexception21ヤヴァいタイトル付けてしまった…. ええと、これはAdvent Calendarではありませんが、勢いで書いています(C# ADは既に埋まっていた…)

それには、こんな事情があったのです:

  • 「.NET非同期処理(async-await)を制御する、様々な方法」の記事がコンスタントにPVを稼いでいる
    (その割に、他の非同期関連の記事は読まれない)
  • asyncやawaitキーワードの使い方を度々聞かれる。
  • Task.Wait()とか、Task.Resultとか使ってるコードを頻繁に見る(不要なのに! しかも、この記事を参照しながら全部Wait()とかResultしているブログ記事… そしてそこから流入するPV… 死んじゃう、死んじゃうよ…)。
  • Task.Run()とか、Task.Factory.StartNew()とか、Task.Start()とか、Task.ContinueWith()を使ってるコードを頻繁に見る(不要なのに)。
  • いまだにThreadクラス直接使ってるしかもその中でTask.Wait()とか(?!?!)

私のセッションや記事は、「なぜなのか」を深掘りして問題の根本を理解してもらう事を念頭に置いているのですが、さすがにこれだけ頻出してるのは、いろいろ問題があるんじゃないだろうかと思いました。特にXamarinや.NET Coreが注目されると、今までC#でコード書いた事が無い、と言う人が、見よう見まねで書き始める時期じゃないかと思います(それで何となく書けてしまうのが、C#の間口の広いところではあるのですが)。

「できる(なんちゃら)」じゃないけど、そういう資料も切り口を丁寧に詰めればありかも知れない…

そういうわけで:

  • C#の非同期処理(Taskとasync-await)を使ってコードを書くなら、以下の事を知っていれば9割がたの問題は回避できる!!
  • ここで示した方法から外れたことをしようとしている場合は、殆ど間違いなく誤った記述をしようとしているので、もう一度何をしようとしているのか整理しよう!!

2021.2: これとは別の切り口、「非同期処理、何もわからない」という、開発の日常風に書いたものもあります。こちらの方が分かりやすい人もいるかも知れません。

章立てを二章だけにして、極限まで絞り込みました。ではどうぞ。

# 補足: コードはやや冗長に書いています。


第一章. 同期処理と非同期処理を対比させて、書き方を把握する

以下のコードは、ウェブサイトから「同期的に」データをダウンロードします。要するに、普通のコードです。WebClientクラスを使い、OpenReadメソッドでStreamを取得して、StreamReaderで文字列として読み取ります(クラス定義などは省略):

using System.IO;
using System.Net;

public static string ReadFromUrl(Uri url)
{
	using (WebClient webClient = new WebClient())
	{
		using (Stream stream = webClient.OpenRead(url))
		{
			TextReader tr = new StreamReader(stream, Encoding.UTF8, true);
			string body = tr.ReadToEnd();
			return body;
		}
	}
}

public static void Download()
{
	Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md");
	string body = ReadFromUrl(url);
	Console.WriteLine(body);
}

これを非同期処理として変形します。変形した箇所にはコメントを入れておきました。番号の順にやればスムーズに行くと思います:

using System.IO;
using System.Net;
using System.Threading.Tasks;

// Step5: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。
// Step6: メソッド内でawaitキーワードを使ったら、戻り値の型はTask<T>型で返さなければならない。
//        T型は本来返すべき戻り値の型。
// Step7: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する(慣例なので必須ではない)。
public static async Task<string> ReadFromUrlAsync(Uri url)
{
	using (WebClient webClient = new WebClient())
	{
		// Step1: OpenReadから非同期対応のOpenReadTaskAsyncに変更する。
		// Step2: OpenReadTaskAsyncがTask<Stream>を返すので、awaitする。
		//        awaitすると、Streamが得られる。
		using (Stream stream = await webClient.OpenReadTaskAsync(url))
		{
			TextReader tr = new StreamReader(stream, Encoding.UTF8, true);
			// Step3: ReadToEndから非同期対応のReadToEndAsyncに変更する。
			// Step4: ReadToEndAsyncがTask<string>を返すので、awaitする。
			//        awaitすると、stringが得られる。
			string body = await tr.ReadToEndAsync();
			return body;
		}
	}
}

// Step10: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。
// Step11: メソッド内でawaitキーワードを使ったら、戻り値の型はTask型で返さなければならない。
//         ここでは元々返す値が無かった(void)なので、非ジェネリックなTask型を返す。
// Step12: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する(慣例なので必須ではない)。
public static async Task DownloadAsync()
{
	Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md");
	// Step8: ReadFromUrlから非同期対応のReadFromUrlAsyncに変更する。
	// Step9: ReadFromUrlAsyncがTask<string>を返すので、awaitする。
	//        awaitすると、stringが得られる。
	string body = await ReadFromUrlAsync(url);
	Console.WriteLine(body);
}

# 補足: HttpClientではなくWebClientを使ったのは、対比させやすいためです。非同期前提で書くなら、始めからHttpClientを使うと良いでしょう。WebClientの代わりにHttpClientで同じ処理を書いてみるのは、良い練習になると思います。

asyncpractice1出来るだけわかりやすくなるようにコメントを入れたので、「ひたすらめんどくさそう」に見えますが、よく見れば大した作業ではない筈です。ReadFromUrlとDownloadのそれぞれのメソッドが、どのように変形されるのかを見てください。そうすると何となく法則が見えてくると思います。

また、この作業で、2つのメソッド「ReadFromUrl」と「Download」が、両方とも非同期メソッド化された事にも注意してください。

asyncpractice2つまり、特別な理由がない限り、非同期処理は呼び出し元から末端まで、「全て非同期メソッドで実装する必要がある」、と言う事です。ここで示した変形を行うと、連鎖的に、すべてのメソッドが非同期化されます。

これが核心です。以下にまとめます:

  • 非同期対応メソッドが存在する場合は、そのメソッドを呼び出すように切り替えます。上記では、「OpenRead」「OpenReadTaskAsync」に変えました。目的の非同期メソッドは、「TaskやTask<T>を返すメソッドがあるかどうか」や「~Async」と命名されているメソッドがあるかどうか、を目印に探すと良いと思います。また、この理由のために、メソッド名を「~Async」と命名する慣例が生きてきます。
  • TaskやTask<T>は、そのままでは戻り値T型になりません。これらの値は「await」キーワードを使う事で、戻り値を得る事が出来ます。awaitを実行している時、つまりそれが「非同期的に結果を待機している」と言う事です。
  • 自分で定義したメソッド内で「await」キーワードを使った場合は、メソッド先頭に「async」キーワードを追加する必要があります。「async」キーワードの効能はそれだけです!!! 以下のTask型云々は関係ありません。
  • 非同期メソッドの戻り値は、Task型、Task<T>型、または「void」とする必要がありますが、voidは殆ど使用しません(次章)。
  • 同期メソッドの戻り値の型がstringなら、Task<string>のように、ジェネリックなTask<T>型を使います。同期メソッドの戻り値がない(void)場合は、Task型(非ジェネリック)を返します。

これだけです。これだけですよ奥さん!!! 英語の動詞変形みたいな話です。この法則だけ覚えていれば、一旦同期的にコードを書いてみて、それから非同期処理に置き換えるという「練習」が出来ます。しばらくやっていれば、最初から非同期で全部書けるようになります。

ここまで、Task.Run()も、Task.Factory.StartNew()も、Task.Start()も、Task.ContinueWith()も、Task.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。

それでも、という方、では第二章へどうぞ。


第二章. どこからが非同期処理となるのか

非同期処理を記述した時に、どこからが非同期処理として扱われるか、あるいは、どこからを非同期処理として扱うのか、という話です:

// これはWPFのウインドウ実装です(但し、WPFであることはあまり重要ではありません):
public partial class MainWindow : Window
{
    // 参考:
    //   Window.Loadedイベントの定義:
    //     public event RoutedEventHandler Loaded;
    //   RoutedEventHandlerデリゲートの定義:
    //     public delegate void RoutedEventHandler(object sender, RoutedEventArgs e);

    // コンストラクタ
    public MainWindow()
    {
        InitializeComponent();

        // ウインドウ表示開始時のイベントをフックする
        this.Loaded += MainWindow_Loaded; 
    }

    // ウインドウが表示されると呼び出される
    private void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        // 第1章で実装したReadFromUrlを呼び出して、テキストボックスに表示する
        Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md");
        string body = ReadFromUrl(url);
        textBox.Text = body;
    }
}

# XAMLは省略します。TextBoxコントロールに”textBox”という名前を付けて定義してある前提です(また、本当はデータバインディングでやるべきです)。

このコードだと、ダウンロードが完了するまで、UIが固まってしまいますね。では、これを非同期処理に対応させます:

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        this.Loaded += MainWindow_Loaded; 
    }

    // Step3: メソッド内でawaitキーワードを使ったら、メソッドの先頭でasyncを加えなければならない。
    // Step4: メソッド内でawaitキーワードを使ったら、戻り値の型はTask型で返さなければならない。
    //        しかし、Loadedイベントのシグネチャは変更できない。従って「void」のままとする。
    // Step5: 非同期処理対応メソッドは、「慣例」で「~Async」と命名する。
    //        しかし、値を返さないためawait忘れの可能性はない事と、あくまで慣例であるため
    //        ここでは「~Async」と命名しない。
    private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
    {
        Uri url = new Uri("https://github.com/Microsoft/dotnet/blob/master/README.md");
        // Step1: ReadFromUrlから非同期対応のReadFromUrlAsyncに変更する。
        // Step2: ReadFromUrlAsyncがTask<string>を返すので、awaitする。
        //        awaitすると、stringが得られる。
        string body = await ReadFromUrlAsync(url);
        textBox.Text = body;
    }
}

asyncpractice31MainWindow_Loadedメソッドと、第1章のDownloadAsyncメソッドとの違いを確かめてください。Loadedイベントのシグネチャは「RoutedEventHandler」デリゲートであり、「delegate void RoutedEventHandler(object, RoutedEventArgs)」です。つまり、戻り値を返さない「void」であることが決められています。

非同期メソッドはTask型かTask<T>型を返すべきですが、ここでは型が合いません。値を返さない場合に限り、このような非同期メソッドを「void」で定義します。

ポイント:

  • 戻り値を返さず「void」と定義するメソッドは、イベントをフックしたハンドラメソッドのような場合しかありません。戻り値をvoidとした場合、呼び出し元は非同期処理の完了を追跡できず、処理結果がどうなっても感知しません(例え、非同期処理中に例外がスローされた場合も、です!)
  • 逆に考えると、そもそもイベントの呼び出し元は非同期処理の結果など気にしていません(voidなので)。だから、voidとしても良い、と解釈することもできます。
  • 「Task」を返そうが「void」を返そうが、「await」キーワードを使ったので「async」キーワードを追加する必要があります。

設計上の観点で見た場合、処理の開始起点が、このハンドラメソッドにある事にも注目してください。このように、呼び出し元(WPFのLoadedイベント処理)が、呼び出し先(MainWindow_Loadedメソッド)の事について感知していないような場合にのみ、呼び出し先の非同期メソッドの実装方法を(voidとすべきかどうか)考える必要があります。

コンソールアプリケーションの非同期処理ではどうでしょうか:

// コンソールアプリケーションのメインエントリポイント
// awaitを使っているのでasyncを追加する(?)
public static async void Main(string[] args)
{
    // Task型が返されるので、この処理を待機するためawaitする(?)
    await DownloadAsync();
}

asyncpractice4第1章のDownloadAsyncメソッドを呼び出してみました。もし、この呼び出しをawaitで待機する場合、Mainメソッドの先頭にasyncと付ける必要があります。このコードは問題なくコンパイルできますが、実行すると(殆どの場合)ダウンロードが完了することなくプログラムが終了します。

Mainメソッドは、DownloadAsyncメソッドの非同期処理をawaitで待機します。しかしこれは「非同期的に待機」しているので、「Mainメソッドの処理(メインスレッド)は継続的に実行されている」のです。すると、メインスレッドはそのままMainメソッドを抜けてしまい、一瞬でプログラムが終了してしまいます。

したがって:

// コンソールアプリケーションのメインエントリポイント
public static void Main(string[] args)
{
    // Step1: Task.Wait()を使わざるを得ない。
    Task task = DownloadAsync();
    task.Wait();
}

asyncpractice5このような場合にのみ、Task.Wait() 又は Task.Resultで待機する必要があります。GUIアプリケーション(WPF/UWP/Xamarinなど)を実装している皆さんには、まったく縁のない話なので、このことは忘れても構いません。GUIアプリケーションでTask.Wait() や Task.Resultを使うとデッドロックを起こします。

大事な事なのでもう一度: 「デッドロックします。使ってはいけません」

今Task.Wait() や Task.Resultを呼び出してうまく動いていますか? そうですか、いつかTaskに「後ろから刺される」覚悟はしておいた方が良いでしょう…
(ここでは詳しく解説しませんが、タイミングによって発生したりしなかったりします)

awaitで待機するように記述する事を「大前提」として、そうするにはどうやって書いたらいいかを考えると良いと思います。

なお、C# 7.1以上を使うのであれば、以下のようにもっと簡単に書けます:

// コンソールアプリケーションのメインエントリポイント
// C# 7.1以上を使うなら、MainがTaskを返す事が出来る
public static async Task Main(string[] args)
{
    // 普通にawait出来る。Task.Wait()の呼び出しは不要
    await DownloadAsync();
}

この場合でも、内部的にはTask.Wait()が呼び出されたのと等価になります。従って、GUIアプリケーションでこの方法は使えません。

でも、これで晴れて完全にTask.Wait()やTask.Resultから手を切る事が出来ましたね!


まとめ

これだけです。とりあえずこれだけで、「マトモ」な非同期処理コードを書くことが出来るはずです。繰り返しますが:

  • C#の非同期処理(Taskとasync-await)を使ってコードを書くなら、これらの事を知っていれば9割がたの問題は回避できる!!
  • ここで示した方法から外れたことをしようとしている場合は、殆ど間違いなく誤った記述をしようとしているので、もう一度何をしようとしているのか整理しよう!!

これまでの経験で、WPF・UWPなどのGUIアプリケーションで、ここで紹介した方法「以外の」方法がどうしても必要となるパターンは、「一度もありません」でした(多分現在に至るまでにC#で100万行近いコードを書いています)。

Task.Wait()やTask.Resultなどを必要としたケースは、MVVMフレームワークの設計時に、安全な非同期処理を実現するための、ごく内部的なコアロジックを設計したときだけです(普通の人がそんなコードを書く機会はない)。

なお、.NETの代表的なプラットフォームとして、ASP.NET (Core), WinForms, WPF, Xamarinが挙げられますが、これらだけに適用される手法ではなく、コマンドラインプログラムやUnity、独自の.NET環境など、すべての.NETの非同期処理で適用できる手法です。

次は何をすればいい?

これを読んで新たに疑問が湧いてくるかもしれません。例えば:

そう言った疑問にすべて答えられるかどうか分かりませんが、様々な切り口で非同期処理を解説しているので、参照してみて下さい。

それでは!

continuatioN Linking – NL名古屋

「continuatioN Linking」という題目で、NL名古屋で登壇してきました。

NとLの字さえ使っていれば何でも良いというカオスなショートセッション会なのですが、内容は非常に濃くて面白いものばかりでした。とても満足度が高かったです。Togetterのまとめはこちら

今回写真撮り忘れてしまったのでありませんが、50名超えと大入りな感じでした。


継続ネタ

継続渡しスタイル(Continuation Passing Style)の話に絡めて、.NET TaskとF# Asyncのシームレスな相互運用を行うネタを発表してきました。

何しろここは名古屋 (;´Д`) なので、このネタで行くにはまだ「ひよっこ」で恐怖しかない感じでしたが、掴みもスベる事なく発表できたのでうれしかったです。一部の方には触れるものがあったようで、「おおーー!!」という声はやって良かった感ありました。

発表はショートセッションですが参加者が多いこともあり、最終的に時間制約が10分となって苦しいところでしたが、予定通り進行を無視して「継続」させていただきましたありがとうございます。


補足

さて、内容は駆け足だったので少し補足しておきます。

FSharp.Control.FusionTasks.128GitHub: 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.SwitchToContextAsync.SwitchToThreadPoolAsync.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#や関数型言語の面白さの一端は見えてきました。

それではまた。

継続飛行 (1) – 原点

数回に分けて、継続についてのもろもろを、時系列にそって書きます。

そもそも「継続」という学問があるようなのですが、私はここ最近に至るまでそんな学問があるとも知らずに継続を扱ってきたこともあって、きっと厳密性は全く無いと思います。ただ、経験から得られた知識であれど、実際に動作させたという知見があるので、もしそういった方からのアプローチが役に立つのであればそれはそれでいいんじゃないかという妄想によりますので、控えめによろしくお願いします (;´Д`)

※そこんとこは「継続」で言うところの何某だよ、みたいなコメントがあると嬉しいです

ちなみに、近日「継続勉強会」があるようです。うぅ、東京うらやましい…


原点

2000年よりも昔、まだインターネットが大学でしか使えなかったころ、巷ではシリアル通信によるパソコン通信なるものがコミュニケーションの主流でした。そのころに私は某BBSシステムを改良したシステムをフリーソフトウェア(この頃は定義さえ怪しい)として公開して配布していました。

驚いたことに、これはいまだにベクターで公開されており、ソースコードも公開していたので、今回のネタの原点から振り返ってみることが可能でした(肝心のコードはあまりに古く、色々アレなので、ポインタについては勘弁してくださいw)。

コードはTurbo PASCALで書かれており、一部がアセンブラ(x86)です。プラットフォームはMS-DOSであり、RS-232C(シリアル)のドライバも一から書かなければならない頃です。そのBBSシステムは、多チャンネルに対応していたため、複数の電話回線から複数のモデムを通じてのセッションを同時に裁く必要があります。UNIXでいえば、物理的なシリアル何回線かに対して、シリアルコンソールでgettyに接続する感じでしょうか。

現代的なコードであれば、スレッドを使って個々の接続を個別のスレッドで処理するような感じです。しかし相手はMS-DOS、もちろんスレッドなんてありませんし、プロセスは分離されず子プロセスのみでコンカレント動作はありません。MS-DOSを知らない方には想像つかないかもしれませんが…

となると、多チャンネルの操作は、ステートマシンの手動実装、つまり「ステート値」とその値に依存する「巨大なswitch-case」のような実装、そして細切れにされた各ステートの処理の乱立、という、酷すぎて保守したくないコードのようなものにならざるを得ません。

しかし、このシステムには画期的な、ある小さなコードの断片がありました。

code	segment byte public
	assume cs:code, ds:data

public	transfer

trans_stack	struc	; arguments of transfer.
tr_bp	dw	?
return	dd	?
proc2	dd	?
proc1	dd	?
trans_stack	ends

transfer proc far
	push	bp
	mov	bp, sp
	les	di, [bp].proc1
	mov	es:[di], sp	;現在のスタックポインタをproc1が示すtrans_stackに保存
	mov	es:[di+2],ss	
	cli
	mov	sp, word ptr [bp].proc2	; proc2が示すtrans_stackのポインタをスタックポインタに設定
	mov	ss, word ptr [bp].proc2+2
	sti
	pop	bp
	ret	8
transfer endp

code	ends
	end

このコードはx86のアセンブラで、「transfer」と呼ばれるメソッドが定義されています。引数に指定されている「trans_stack」構造体へのポインタを使って、現在のスタックポインタを「無理やり別のスタック位置に入れ替える」操作を行います。

初めてこのコードを見たときは「衝撃」でした(今でもよく覚えている。いまだにこの方面のことに興味があるのは、これのせいかもしれない)。

何しろ動作中にスタックポインタを書き換えるのです。しかもこのコードでは、proc2がどこからやってくるのかがよくわからないため、スタックポインタがどのような値になるのか想像もつきません。更にスタックポインタを書き換えた状態で、「pop」とか「ret」を実行しているのです。一体なにがpopされるのか、その後retでどこに戻るというのか… 頭がパニックになります。

「機械語」 – 先日のILのキホンでも言ったんですが、このような低レベルのコードは、「書いたとおりに動作する」のです。ILの場合は明らかに不正なコード片はCLRが止めてしまいますが、機械語の場合はほぼ書いたとおりに実行されます。

スタックポインタがどこを指しているのかわかりませんが、その「どこか」から値をpopし、更に値を取得してそこに処理が遷移する(ret)のです。特にretは、その動作から、以下のようなジャンプ命令のように振る舞います。

	; @tempというレジスタがあったと仮定して:
	pop	@temp
	jmp	@temp

つまり、あらかじめ「新しいスタックポインタ」が示すメモリ領域に、bpに復元すべき値とジャンプ先のアドレスを書き込んでおき、そこを指すようにtrans_stack構造体を初期化しておいてtransferを呼び出せば、見事ジャンプ先に遷移することになります。

で、こんなめんどくさくてわかりにくい事を何故行うのかというと、一度遷移に成功すれば、再びtransferを呼び出すことで、「以前使っていたスタックポインタの位置から処理を継続できる」からです。

transferは「call命令」で呼び出されることを前提にしています。つまり、transferに遷移してきたとき、すでに戻るべき処理位置へのポインタはスタックに積まれており、これが次回のtransferからretするときに復元されます。元コードはTurbo PASCALで書かれていましたが、Cで書くと以下のような疑似的なコードとなります。

// 疑似コードです。動きません
extern void transfer(trans_stack* fromtr, trans_stack* totr);

static trans_stack main_tr;
static trans_stack sub_tr;

// subのtransfer-processエントリポイント
static void sub()
{
  auto i = 1000;
  while (1)
  {
    printf("sub(): %d\n", i++);

    // subからmainに転送
    transfer(&sub_tr, &main_tr);
  }
}

// main(のtransfer-process)
void main()
{
  // bpの初期値は0で良い
  sub_tr.tr_bp = 0;
  // transfer-processの初期位置はsub関数の先頭
  sub_tr.return = &sub;
  // subのためのスタックを確保
  sub_tr.proc2 = malloc(0x1000);

  for (auto i = 0; i < 10000; i++)
  {
    printf("main(): %d\n", i);

    // mainからsubに転送
    transfer(&main_tr, &sub_tr);
  }
}

よーく見てください。このコードを実行すると、mainのprintfとsubのprintfを交互に実行します。そして、mainで10000回処理を行った後はプログラムが終了します。subは無限ループなので、終了しても放置されていますね。いずれにしても、このプログラムに「スレッド」はありません。環境はMS-DOSです。しかしながら、「transfer」を呼び出す必要があることを除けば、まるでスレッドが存在するかのようです。

こうすれば、各「transfer-process」の暗黙のステートは、それぞれのスタック(mainのスタックと、mallocで確保されたメモリ)に隠されており、独立性を保ち、不要なステートマシンを作る必要がなくなります。当然、各シリアル制御のコードは大幅に簡略化されるでしょう。実際、transferがなければまともなコードにはならなかったはずです。

例えば、上記のsub関数内の自動変数「i」は、mallocで確保されたスタックメモリ領域内にその値が格納されています。コードのどこにも、明示的にmallocのメモリを読み書きしている個所がありませんが、これはスタックなのです。そして自動変数はスタック内に割り当てられます。つまり、subを実行するtransfer-processは、subの実行に必要な暗黙のステートを、スタック内で管理しているのです。そして、transferを呼び出すたびに、このスタックは切り替えられます。

このような構造を「コルーチン(coroutine)」と呼びます。

もちろん、この時にはこれが「継続」と呼ばれる何かだとは、全く気が付いていませんでした。

.NET非同期処理(async-await)と例外の制御

Taskクラスとasync-awaitを使用して非同期処理を記述する場合の、例外にまつわるあれこれをまとめました。

概要:

  • 表面上は殆ど変らない
  • 現実の動作
  • タスクコンテキストとスレッドコンテキスト
  • スタックウォーク
  • 処理されない例外

この記事は、非同期処理と例外処理について、多少難易度の高い話題を含みます。もし、もっと基本的な記述方法や、安全に例外を処理する方法を知りたい場合は、この記事をお勧めします: 「.NET非同期処理で例外を安全に対処する」

この記事の前に、非同期処理の基本を扱った記事もあります: 「.NET非同期処理(async-await)を制御する、様々な方法」


非同期処理中に発生する例外の捕捉

非同期処理中に発生する例外を捕捉する方法は、一般的な例外の捕捉とほとんど変わりません。

// 指定されたURLからHTMLコンテンツをダウンロードする
public static async Task<XElement> StartFetchContentAsync(Uri url)
{
	using (var httpClient = new HttpClient())
	{
		try
		{
			// HTTP GETでURLに非同期的に要求する
			using (var stream = await httpClient.GetStreamAsync(url))
			{
				// (パース)
				return SgmlReader.ParseXElement(stream);
			}
		}
		// 非同期処理中(又は同期処理中)に発生した例外を捕捉
		catch (Exception ex)
		{
			// 何かエラー処理
			logger_.WriteLog(ex.Message);
			// 再スロー
			throw;
		}
	}
}

上記のコードにより:

  • GetStreamAsync非同期メソッドの完了待機中(await)に発生した例外の捕捉
  • それ以外の例外(例:SgmlReaderのパース)の捕捉

の両方とも、catch構文で例外を捕捉出来ます。
上記の例では示しませんが、C#5.0では、catchブロック中(又はfinally)で更に非同期メソッドを呼び出して待機(await)することは出来ません。C#6.0では可能です。

Task.WhenAllを使って非同期処理を集約している場合、一度に複数の例外が発生するかもしれないため、発生した例外は「AggregateException」クラスに内包されてスローされます。

// 指定されたURL群からHTMLコンテンツをダウンロードする
public static async Task<XElement[]> StartFetchContentsAsync(IEnumerable<Uri> urls)
{
	try
	{
		return await Task.WhenAll(
			urls.Select(async url =>
			{
				using (var httpClient = new HttpClient())
				{
					// HTTP GETでURLに非同期的に要求する
					using (var stream = await httpClient.GetStreamAsync(url))
					{
						// (パース)
						return SgmlReader.ParseXElement(stream);
					}
				}
			}));
	}
	// Task.WhenAllで発生した例外を捕捉
	catch (AggregateException ex)
	{
		// 何かエラー処理
		ex.InnerExceptions.ForEach(iex => logger_.WriteLog(iex.Message));
		// 再スロー
		throw;
	}
}

非同期処理の遷移

上記のように、非同期メソッドを呼び出して例外を処理するコードを書いても、表面上は同期メソッド呼び出しと殆ど変りません。また、実際に考慮すべき事も殆ど変りません。しかし、内部で発生している例外がどのように処理されているのかという点では、かなり異なります。

以下の図を見て下さい:

(なお、まるでこれがHttpClientの実装であるかのように書いていますが、実際のHttpClientは別の手法で実現している可能性があります)

asyncexception11これは、非同期メソッドではなく、同期メソッドでの例外のフローです(疑似的に、HttpClientに「GetStream」という同期メソッドがあると仮定して書いています。実際にはありません)。

GetStreamを呼び出したものの、指定されたURLに接続出来なかったり、HTTPサーバーがエラーを返すなどした場合、メソッド内で例外がスローされます。それはそのまま呼び出し元のcatchブロックでキャッチされます。当たり前ですが、この時の呼び出し元スレッドの実行パスは、赤い矢印で図示した通りの経路をたどります。

では、非同期メソッドを呼び出した場合はどうなるでしょうか?

asyncexception21まず、GetStreamAsyncは非同期メソッドなので、異なる実行コンテキスト上で接続処理が行われます。「実行コンテキスト」とは、今はまだ「ワーカースレッド」と言うように読み替えて構いません。そして、実行コンテキストが異なるという事は、呼び出し元スレッドの実行パスは、接続処理を行っているコンテキストとは別に、平行して処理が可能である事を意味します。

この例では、呼び出し元のスレッドがGetStreamAsyncから抜けた(Task<Stream>の戻り値を得る)際に、すぐに「await」を実行 – つまり非同期的に待機します。その間、まだ接続処理は別のコンテキスト(ワーカースレッド)で実行中です。ここで、接続に失敗するなどして例外が発生したとします。

asyncexception3先ほど、実行コンテキストはワーカースレッドと読み替えて良いという話をしました。そのため、接続処理で発生した例外は、「そのスレッドコンテキスト内のcatchブロック」で捕捉されます。図ではそれを疑似的に示しましたが、これでは、ワーカースレッド上では例外を捕捉出来ても、呼び出し元では捕捉出来ない事になります。

従来のThreadクラスによるワーカースレッド処理では、ワーカースレッドで発生した例外を、どうやって元のスレッドに通知するのかという事も、設計者が考慮する必要がありました。しかし、Taskベースの非同期処理では、例外を通知するのはTaskクラスの役割となっていて、一貫した操作が可能となっています。

asyncexception4「.NET非同期処理(async-await)を制御する、様々な方法」でも書きましたが、能動的な方法で通知するのであれば、「TaskCompletionSourceクラス」を使えば、SetExceptionメソッドを使用して、呼び出し元のスレッドに通知する事が出来ます。

BookJournal余談ですが、仮に接続処理が「Task.Runメソッド」によって実行されていた場合、スローされた例外は自動的にRunメソッドによって捕捉され、呼び出し元のスレッドに通知されます。


実行コンテキストとは

非同期メソッド内の非同期的な処理を行う主体を、「ワーカースレッド」ではなく「実行コンテキスト」という抽象的な呼び方をしました。上記の説明の通り、非同期的な処理が実際にワーカースレッドで実現されているのか、又はそれ以外のなんらかの方法で実現されているのかは、メソッドの呼び出し元からは知る由もなく、知る必要のない、実装の詳細です(現実には把握したい場合もあるかもしれませんが)。

asyncexception5ワーカースレッドでなければ、どうやって非同期処理を実現するのでしょうか? 一例を挙げるなら、Win32 APIでは一般的な「コールバック」です。Win32では伝統的に、処理の完了や失敗をコールバックで通知する事が多いのです。これは、Windowsのカーネルモード内部奥深く、デバイスドライバーが発する完了のタイミングの処理から、鮭が川を上ってくるように「逆方向」にメソッドが呼び出され、手元のコールバックハンドラメソッドが呼び出される、というように通知されます(「Async訪ねて3000里」を参照)。

ここで、TaskCompletionSourceを使い、SetResultやSetExceptionを呼び出すことで、非同期処理の完了を通知します。

従って、非同期処理とは、必ずしも特定のスレッドに紐づいた処理では無い事を意味します。そのため、「実行コンテキスト」という呼び方をしました。更に、呼び出し元のスレッドにとっては、非同期メソッドである事の証は「Taskクラス」だけです。GetStreamAsyncメソッドの戻り値は「Task<Stream>」で、このインスタンスを「await」する事によって待機します。ここでも、スレッド基準ではなくタスク基準です。

タスク基準の非同期処理の管理を行う場合に、タスクを認識して処理を行う主体に名前が欲しいと思う事があるため、特に「タスクコンテキスト」と呼んでいます(公式ではありません)。

さて、ここまでで、「非同期メソッド内の処理の主体」が、必ずしもスレッドに紐づかないという展開をしましたが、これがそっくりそのまま、呼び出し元にも当てはまります。何故なら、呼び出し元は「await」した時点でスレッドによるハードブロックではなく、疑似的に非同期処理を待機しているかのように見せているからです。

asyncexception6スレッドをブロックしない、という事は、この図のように、awaitの直前までのスレッドコンテキストは解放されることを意味します。そのスレッドがどこに行ってしまうのか?はともかくとして、ここでも実行コンテキストはスレッド基準ではなくタスク基準である事が分かります。

GetStreamAsyncがSetExceptionで例外を通知する時、awaitしているタスクコンテキストは起こされ、catchブロックの処理を継続します。処理を継続するにはコードを実行しなければならないため、物理的なスレッドコンテキストが必要です。それは図のように「メインスレッド」なのでしょうか? 元のメインスレッドはawait時にどこかに行ってしまったかも知れませんね。新たに割り当てられたワーカースレッドが、代わりに処理を継続するかもしれません。処理を継続するスレッドが何になるのかは、「SynchronizationContextクラス」がカギを握っていて、WPFやWindows Formsでは結局メインスレッドが再び処理を継続します。

SynchronizationContextクラスの動作に踏み込むと本題と外れてしまうため、ここでは呼び出し元の処理もまた「非同期メソッド」となり、実行コンテキストはスレッド基準ではなくタスク基準となる、という事が分かれば良いと思います。


タスクベース処理のデバッグ

参考までに、タスク基準でのデバッグ手法について触れておきます。

今まで、スレッドベースでのプログラミングに慣れてきたため、デバッグ時にはどのメソッドがどのメソッドを呼び出して来て今の状態に至るのかを「呼び出し履歴(スタックトレース)」を観察するのが基本でした。しかし、タスクベースの非同期処理をデバッグする場合は、スタックトレースを眺めても何も得られない可能性があります。タスクコンテキストはスレッドに紐づく場合もあればそうでない場合もあり、更にたまたま割り当てられたワーカースレッドによって実行コンテキストが得られている場合もあります。

そのため、Visual Studioのデバッガには「タスク」ウインドウが追加されました。asyncexception7

このウインドウで、待機中のタスクコンテキストの状態と、待機が発生した位置を確認する事が出来ます。残念ながら、その時点の待機に至った履歴(疑似的な、非同期待機のスタックトレースのような)は見る事が出来ません。そのため、各タスク間にどのような依存性があるのかないのかは、このウインドウを見てもはっきりとは分かりません。将来的に改善されると良いなと思います。


例外のスタックトレース(古代CLR)

C# 1.0がリリースされた時、言語仕様がJavaに似ている事から様々な点が比較されました。例外についてもかなり似通っているのですが、決定的に異なる点が少なくとも一つ存在します。それは、スタックトレースの扱いです。C#でやってはいけないと言われている事の一つに、例外の再スローの方法があります。

public static void RethrowExceptionSample()
{
	try
	{
		// 何らかの処理
		throw new Exception();
	}
	catch (Exception ex)
	{
		// 例外の処理
		logger_.WriteLog(ex);

		// 再スロー(ここからスタックトレースが再構築されてしまう)
		throw ex;
	}
}

Javaからの移行組の人はついついやってしまいますが、C#ではこのコードはエラーになりませんがやってはいけません。何故なら、「throw ex」すると、ex内に記録されているスタックトレースの情報が失われ(リセット)、新たにスタックトレースが再構築されるからです。正しい再スローは以下の通りです。

public static void RethrowExceptionSample()
{
	try
	{
		// 何らかの処理
		throw new Exception();
	}
	catch (Exception ex)
	{
		// 例外の処理
		logger_.WriteLog(ex);

		// 再スロー(スタックトレースは維持される)
		throw;
	}
}

throw句に何も指定しなければ、(例外処理ブロック中であれば)その例外が再スローされます。これは、IL命令でのrethrowに変換されます。つまり、特別な処理としてCLRに認識されているのです。

この事は、前述の「Threadクラスで生成したワーカースレッド内で発生した例外を、元のスレッドにどうやって通知するか」と言う事に大きな制約を生じさせます。非同期処理の外側では、内部実装でワーカースレッドを使っているかどうかは関係なく、関心の無い事です。従って、ワーカースレッドを使っていたとしても、メソッド内で発生した例外は、あたかも連続したスレッドコンテキストで発生したかのように、スタックトレースが観察されて欲しい、と思う筈です。

例えば、以下のようなコードで、ワーカースレッドで発生した例外を別のスレッドで再スローしたらどうでしょうか?

public static void RethrowAnotherThreadBoundExceptionSample()
{
	// ワーカースレッドで発生した例外を保持する変数
	Exception caughtException= null;

	// ワーカースレッドの生成
	var thread = new Thread(() =>
		{
			try
			{
				// 何らかの処理
				throw new Exception();
			}
			catch (Exception ex)
			{
				// 例外を記録
				caughtException = ex;
			}
		});

	// ワーカースレッドの実行と完了の待機
	thread.Start();
	thread.Join();

	// ワーカースレッドで例外が発生していたら
	if (caughtException != null)
	{
		// error CS0156: 引数なしの throw ステートメントは catch 句以外では使えません。
		throw;
		// スタックトレースを失う
		// throw caughtException;
	}
}

このコードはコンパイル出来ません。何故なら、再スロー構文「throw」は、catchブロック内でのみ使用出来るからです。かといって、「throw caughtException」と書いてしまうと、スタックトレースを失ってしまいます。

ファッ○ン CLR!!

と思いましたか? 私は思いましたよ、.NET Remotingを知るまでは。

asyncexception17CLR設計者に聞いたわけではありませんが、この仕様は恐らく意図的なものです。.NETは最初のバージョンから「アプリケーションドメイン」という考え方を導入しています。これは、「マイクロプロセス」と呼べるもので、同一のプロセス内に、サンドボックス的な分離構造を持たせる事が出来る機能です。同一プロセスであっても、異なるアプリケーションドメイン間の通信には大きな制約が生じます。これにより、アプリケーションドメイン間の安全性を高め、かつ、プロセス起動・終了による非常に大きなコストを排除するのが狙いです。

.NET Remotingは、このアプリケーションドメイン間の通信や、プロセスワイド、又はマシンワイド間での「リモート参照」機能を提供して、通信の実装負担を軽減します。リモート参照は一見してクラスやインターフェイスそのものに見えます。そこにはメソッドが定義されていて、メソッドを呼び出すとリモートのメソッドが呼び出されるという、非常に透過的で便利な機構です。


BookJournal補足:現代においては、CLRのリモート参照機能はややレガシーとして扱われています。一般的にXMLやJSONによるHTTP REST APIが良く使われていますが、そこに皮を被せる形でリモート参照を使うケースはまだまだあるでしょう。また、アプリケーションドメインとは切っても切り離せない関係にあるため、当分の間、CLRリモート参照の機能が廃止されることはないと思われます。


この「リモートメソッドの呼び出し」は、当然、引数や戻り値もハンドリングします。引数や戻り値をリモートメソッド間でやり取りするためには、インスタンスのシリアル化と逆シリアル化という非常に大きなトピックが含まれます。更に、リモートメソッドで発生した例外は、そのまま呼び出し元に例外として通知されます。

例外の通知を実現するためには、例外もまたシリアル化・逆シリアル化可能でなければなりません。シリアル化可能であるためには、例外クラスに含まれる情報が全てシリアル化可能である必要があります。そこには、例外のメッセージ文字列のような単純なものもありますが、「スタックトレース」も含まれているのです。

スタックトレース情報は文字列ではありません(ToStringする事により文字列化されます)。スタックトレースは「StackTraceクラス」や「StackFrameクラス」を使って、実行時に動的にトラバースする事が出来ます。仮に、シリアル化が完全に機能するためには、これらのクラスの中身もシリアル化されなければなりません。StackFrameクラスは、リフレクションのMethodInfoクラスを保持しています。MethodInfoはこれが定義されているTypeクラスを参照し、Typeはそれが定義されたModuleやAssemblyクラスを参照しているでしょう。完全にシリアル化するためには、それらの型情報が保持された全てのDLLが特定されなければならず、逆シリアル化の際には呼び出し元でもすべての参照が解決されなければなりません。

これは明らかに大げさすぎます。リモートメソッド呼び出し側は単に例外の種類(例外クラス)と、メッセージぐらいが判別できれば良いのかもしれません。あるいは例外がスローされることによって、リモート側のスタックの全貌が観察出来てしまうのは、セキュリティリスクです。

このような理由かどうかは分かりませんが、CLRが再スロー可能なタイミングを強制しているのは:

  • 「いつでもスタックトレースが維持される」という状況は望ましくない場合がある。
  • そうであるなら再スローは限られた状況下でのみ機能するようにデザインすれば良い。

と判断したように見えます。

さて、このようなCLRのデザインの為、残念ながらExceptionクラスは、スタックトレースを維持したまま、別のスレッドで再スローする事が出来ない事になります。.NETのスタンダードなソリューションとして、「TargetInvocationException」というクラスが、元の例外のインスタンスをInnerExceptionに保持してスローする事になっています。こうすれば、元の例外のスタックトレースを失うことなく、新たなスタックトレースを構築できます。但し、元の例外クラスを指定してcatch出来ないため、「何このうっとおしい例外は!」と思うかもしれません。


BookJournal補足:TargetInvocationException.InnerExceptionに保持したからと言って、スタックトレースの物理的な情報をシリアル化するわけではありません。アプリケーションドメインを超える時、.NET Remotingのインフラが、スタックトレースを文字列のような安全なデータに固定化します。そのため、リモート例外を受信したスレッドは、リモートのスタックトレースの「動的な」トラバースを行う事は出来ません。また、セキュリティ要件として、この機能を構成で無効化する事も出来ます。つまり、例外は捕捉可能でも、スタックトレースは全く参照出来ないようにする事も出来ます(むしろ、プロセス間以上のRemotingは、デフォルトが逆だったかもしれません。忘れてしまいました)。


例外のスタックトレース(CLR 4.0・4.5以降)

昔の話はこれぐらいにしておきましょう。CLR 4.0にてTaskクラスが導入され、タスクベースの非同期処理が可能になりました。しかし、これまで述べてきたスタックトレースの問題は依然として残っています。従って、awaitで待機中のタスクコンテキストに例外を通知する場合でも、迂闊にそのまま通知することは出来ない事になります。

// CLR 4.0以降
public static Task RethrowAnotherThreadBoundExceptionSampleAsync()
{
	// タスクコンテキストの拠り所を生成
	var tcs = new TaskCompletionSource<object>();

	// ワーカースレッドの生成
	var thread = new Thread(() =>
	{
		try
		{
			// 何らかの処理
			throw new Exception();
		}
		catch (Exception ex)
		{
			// 例外を通知
			tcs.SetException(ex);
		}
	});

	// ワーカースレッドの実行
	thread.Start();

	// Taskを返す
	return tcs.Task;
}

static void Main(string[] args)
{
	try
	{
		// タスクの完了をハードウェイトする
		RethrowAnotherThreadBoundExceptionSampleAsync().Wait();
	}
	catch (Exception ex)
	{
		Debug.Assert(ex.GetType() == typeof(AggregateException));
		Console.WriteLine(ex.ToString());
	}
}

asyncexception9もはや忘れているかもしれません(自分でも書いてて思い出した)、async-awaitはCLR 4.5からのサポートです。従って、上記のようなコードを書いても、await時にどうなるかは分かり難いですね。Task.Waitメソッドを呼び出してハードウェイトした場合、発生した例外は「AggregateExceptionクラス」という、新たなクラスに保持されてスローされます。使われ方はTargetInvocationExceptionと同じですが、AggregateExceptionは複数の例外を内包出来るところが異なります。つまるところ、それまでのスタックトレースは維持されるものの、結局スタックトレースは結合されず分断される、と言う訳です。

スクリーンショットは、処理中に「throw new Exception()」を発行した様子です。先頭はcatchしたAggregateExceptionで、下の方に辿っていくと「(内部例外 #0)」という行が見つかります。この行が、AggregateExceptionが内包しているExceptionクラスの例外のスタックトレースです。シームレスに繋がっているように見えるのは、ToStringがうまく文字列フォーマットしているだけで、本質的にスタックトレースが結合している訳ではありません。

これが、CLR 4.5以上でasync-awaitを使い、非同期的に待機した結果、どうなるかと言うと:

// 非同期待機(await)出来るようにするため、非同期メソッドを定義
public static async Task CallerAsync()
{
	try
	{
		// 非同期メソッドを呼び出して非同期的に待機
		await RethrowAnotherThreadBoundExceptionSampleAsync();
	}
	catch (Exception ex)
	{
		Debug.Assert(ex.GetType() == typeof(Exception));
		Console.WriteLine(ex.ToString());
	}
}

static void Main(string[] args)
{
	CallerAsync().Wait();
}

asyncexception8スクリーンショットを見て分かりますか? AggregateExceptionがありません。catch句はまるでExceptionクラスの例外を直接受信しているかのようです。実際に、catch句のexは、Exceptionクラスのインスタンスです。これでは、まるでスタックトレースがシームレスに結合しているようではありませんか!!

重要な事です:awaitで非同期待機中に受信した例外は、スタックトレースが結合され、同期メソッドの例外と同じように振る舞います。

今までは不可能な事だったので、これには仕掛けがあります。CLR 4.5で新たに追加された「ExceptionDispatcherInfoクラス」を使います。一般の開発者がこのクラスを直接操作するのは、あまり良い事ではありません。フレームワーク設計者はこのクラスを使って、スタックトレースを厳密に操作したくなるかもしれませんね。

もし、await待機中に受信した例外が全てAggregateExceptionにラップされてしまうと、async-awaitを使用したプログラミングはかなり非効率的になってしまいます。すべての例外はAggregateExceptionをキャッチし、改めて内包例外を判定する必要があります。これでは、細部を知らない開発者は「なんでこんな変な事になっているんだ」と文句を垂れる事になるでしょう。

しかし、スタックトレースが結合され、AggregateExceptionを使わない事で、今まで掘り下げてきたことは何も知らなくても、とりあえずasync-await構文を使って普通にコードが書ける、という、最初の説明に繋がるわけです。

CLRの世代を経て、結局スタックトレースが維持可能になったのは少し残念ですね。これではTargetInvocationExceptionやAggregateExceptionは何だったのかと思えてきます。しかも、いまさら無理な話ですが、Task.Waitの挙動もExceptionDispatcherInfoを使えばスタックトレースを結合出来てウハウハではありませんか (;´Д`)

しかし、再度この問題をひっくり返すネタが、次の本題です。


放置された非同期処理の行方

さて、ようやく本題です。この記事を書こうと思った要因となるネタですが、今までの説明はこの話の導入に必要でした。以下のように、2つのI/O操作を効率よく実行するため、それぞれを非同期メソッド呼び出しで開始させ、その後awaitで完了を待ちます。

public async Task ExecuteMultipleIOAsync(Stream stream1, byte[] data1, Stream stream2, byte[] data2)
{
	try
	{
		// 非同期処理を開始
		var task1 = stream1.WriteAsync(data1, 0, data1.Length);
		var task2 = stream2.WriteAsync(data2, 0, data2.Length);

		// それぞれの完了を待機
		await task1;
		await task2;
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.ToString());
	}
}

このような実装はデザイン的に避ける必要があります。とりあえず、一例を図示してみましょう。

asyncexception10メソッド内部で呼び出した非同期メソッドは、それぞれtask1, task2のバーで示しました。これらの非同期メソッドの内部で何が行われているのかは不明です。ワーカースレッドを使っているのかもしれませんし、ネイティブなWin32 APIによって駆動されているのかもしれません。単にそれらを緑枠で「タスクコンテキスト」として扱っています。

二つのサブタスクはほぼ同時に開始されます。メインタスクとは関係なく非同期で動作するため、サブタスクのどちらが先に終了するのかは、良く分かりません。図中では、task1→task2の順で完了した場合を示しています。

仮に逆だった場合:

asyncexception13メインタスクは「await task1」として、task1の完了を先に待機しているため、通知もtask1→task2の順になります。つまり、非同期処理の順序がどのように処理されたとしても、両方の完了が正しく処理されるのです。

本当でしょうか?

asyncexception14この例は、task1は処理が成功し、task2は失敗して例外をスローするパターンです。「await task1」は完了し、「await task2」にて例外が再スローされ、catchブロックでキャッチされます。何も問題なさそうですね?

逆のパターンはどうでしょうか:

asyncexception15task1の処理が失敗して例外がスローされた場合、メインタスクは例外を受け取ってcatchブロックで処理します。すると、非同期で動作中のtask2の結果(正常終了)を受け取る機会が失われます。何故ならもう「await task2」という文は実行されないからです。

正常終了の通知を受け取らなかった場合、その通知はどうなってしまうのか?

とても重要:どうにもなりません

この通知は、メインタスクだけでなく、あらゆるタスクが受け取る機会を失います。従って、この結果通知は「無視」されます。このコード例では、WriteAsyncは結果を返しません(非ジェネリックなTaskを返します)。そのため、task2が成功しようが失敗しようが、結果を確認できなかったとしても、恐らく問題にはなりません。メインタスクの「関心」は、task1が例外をスローした時点で例外処理に遷移しているのです。task2がどうなろうが、知ったことではない… と解釈されます。

さあ、問題の核心です:

asyncexception16もう、何となくわかったかもしれません。この図では、task1もtask2も処理に失敗して例外を通知しようとします。task1はawaitしているので、その時点で例外が通知され、メインタスクはcatch句に遷移し、例外が処理されます。task2の例外は通知しようにも、受け取るタスクが無いのです。前の例と同じように、「await task2」は実行されないので、

きわめて重要:task2の例外(あるいは成功)は、メインタスクで処理「されません」

最初に解説した通り、async-awaitの構文は、殆ど同期メソッドを使用したコードと同じように、非同期メソッドの実装を可能にします。しかし、本質的には全く動作が異なるのです。この問題は、同期メソッドの実装の感覚で理解していると、ハマってかつ理解不能、というパターンかも知れません。

task1とtask2を順番にawaitするというコードは、意図して書いたのであれば問題ありません。意図してという事は、「task2がどうなろうと知ったことではないが、task1が正常に終了するときには面倒を見たい」のような場合です。こういう要求は、現実には殆ど無いと言って良いと思います。従って、このようなコードを書かないように注意する必要があります。


この問題に対処する方法ですが、まず、成功はともかく、例外が完全に無視されてしまうのは問題と思われます。「無視されず、例外が発生すればいいんでね?」と思うかもしれません。その通りではあるのですが、受け取るタスクが存在しないのです。実は例外を発生させる方法はあります。それは、App.configに次のような指定を入れる事です。

<configuration> 
	<runtime> 
		<ThrowUnobservedTaskExceptions enabled="true"/> 
	</runtime> 
</configuration>

この指定は、CLR 4.5移行で有効です。CLR 4ではこの指定に関わらず、「UnobservedTaskException例外」がスローされます(CLR 4ではasync-awaitが使えないので、これはあまり問題にならないかもしれません)。しかし、例外をスローしても、受け取るタスクが居ないんでしたよね。誰がどこからスローするんだ?という話になるのですが、実は「TaskExceptionHolder」という内部クラスが、「ファイナライザースレッドコンテキスト」でスローします。

ファイナライザースレッドなので、これが分かったとしても何も出来ません。何かしても、もう手遅れで、この後プロセスは死亡します。そもそもこの例外を受け取れる可能性があるのは、「AppDomain.UnhandledExceptionイベント」しか無い、という事になります。

そのようなわけで、もう少し使いやすいイベントハンドラがあります。「TaskScheduler.UnobservedTaskExceptionイベント」です。このイベントをフックすれば、発生した非同期例外と、その例外を「処理済み」としてマークするかどうかの選択肢が得られます。例えば、ログに記録してから無視する、等の方法が取れます。

起きてしまった非同期例外のフォローはこのような対策ですが、そもそも問題を起こさないようにするには、どうすれば良いでしょうか?

public async Task ExecuteMultipleIOAsync(Stream stream1, byte[] data1, Stream stream2, byte[] data2)
{
	// 非同期処理を開始
	var task1 = stream1.WriteAsync(data1, 0, data1.Length);
	var task2 = stream2.WriteAsync(data2, 0, data2.Length);

	try
	{
		// task1の完了を待機
		await task1;
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.ToString());
	}

	try
	{
		// task2の完了を待機
		await task2;
	}
	catch (Exception ex)
	{
		Console.WriteLine(ex.ToString());
	}
}

しかし、これは面倒です。awaitの度に、例外が発生したか否かをいちいちcatchで確認しなければなりません。根本的には、Taskインスタンスを持ってまわるようなコードを書いた時点で、不測の事態が起きうることを覚悟する必要があります。どうしてもこのようなコードを書かなければならないのか、は考える必要があります。

もっと良い例を挙げましょう:

public async Task ExecuteMultipleIOAsync(Stream stream1, byte[] data1, Stream stream2, byte[] data2)
{
	// 非同期処理を開始
	var task1 = stream1.WriteAsync(data1, 0, data1.Length);
	var task2 = stream2.WriteAsync(data2, 0, data2.Length);

	try
	{
		// task1とtask2のの完了を同時に待機
		await Task.WhenAll(task1, task2);
	}
	catch (Exception ex)
	{
		// (非同期例外であれば、AggregateExceptionに内包されている)
		Console.WriteLine(ex.ToString());
	}
}

原点回帰、というか、Task.WhenAllを使って複数のタスクを同時に待機します。今回の例はtask1とtask2が完了するのを待つ、というのが主目的なので、WhenAllを使えば目的は達成します。しかも、片方又は両方で例外が(どのような順序で)発生した場合でも、確実に例外を補足できます。

そして、ここで、AggregateExceptionの必要性が生まれます。前節でスタックトレースの結合が出来た事で、AggregateExceptionのような例外内包クラスは不要ではないかという例を示しましたが、非同期で発生する複数の例外を安全に呼び出し元のタスクに通知するために、この例外が必要になるのです。


長くなりましたが、非同期例外にまつわる技術的な背景を書いてみました。より安全な非同期処理が書けると良いですね。

.NET非同期処理(async-await)を制御する、様々な方法

async-awaitベースの非同期処理を制御する方法をまとめました。コードはわざと冗長に書いています。

概要:

  • Taskベースのワーカースレッド生成
  • 他の非同期I/Oと連携してタスクを制御する方法
  • TaskとLINQを応用して、多量の計算を安全に並列実行させる方法
  • Taskを使っていない非同期処理をTask化する方法
  • 非同期処理のキャンセルの実現方法
  • WinRT・ユニバーサルWindowsアプリケーション(UWP)での非同期処理とTaskの連携方法

読む前に補足

C#でTaskやasync-awaitを使った非同期処理の書き方を探しているのであれば、ポイントに絞って書いた、こちらの記事をお勧めします: 「できる!C#で非同期処理(Taskとasync-await)」

Taskクラスの使用例として、ワーカースレッドを起動するという例が良く挙げられます。本記事も最初にTask.Runによるワーカースレッドの例を紹介していますが、本来のTaskクラスの役割はワーカースレッドの管理ではなく、非同期処理の統一された操作にあります(非同期I/Oもワーカースレッドも同じように「非同期処理」として管理可能にする)。

また、async-awaitの使い方を調べていてこの記事にたどり着いた場合は、「基本的にTask.Runは使わない」と言う事を頭の片隅に置いておいて下さい。特殊な場合を除いて、明示的にTask.Runでワーカースレッドを操作する必要はありません。非同期メソッドが返すTaskクラスをawaitする事に集中すれば、問題なくコードを記述できる筈です。

違いが分からなくなったり混乱した場合は、この前提を思い出してみて下さい。

この記事の続きとして、非同期処理における例外を扱った記事もあります: 「.NET非同期処理(async-await)と例外の制御」


Task.Runの基礎

TaskクラスのRunメソッドを使用すると、ワーカースレッドを使用して独自の処理を非同期化(タスク化)出来ます。

public static async Task SampleTaskRun1Async()
{
	// ワーカースレッドが生成される
	Task task = Task.Run(new Action(() =>
		{
			// 任意のCPU依存性処理...
		}));

	// 処理が完了するのを非同期的に待機
	await task;

	Console.WriteLine("Done!");
}

Task.Runメソッドは、.NET4.5未満ではTask.Factory.StartNewと同じです。

Task.Runを使用した場合、戻り値としてTaskクラスのインスタンスが取得出来ます。
従来は、Threadクラスを使用してワーカースレッドを直接生成して管理していましたが、Task.Runで返却されたTaskを使用すれば、非同期処理と同じようにワーカースレッドの操作を待機する事が出来ます。

また、Threadクラスを使った場合、ワーカースレッドの待機は、Thread.Joinメソッドを使いますが、このメソッドはハードブロックしてしまいます。非同期処理の世界で行儀よく振る舞うためには、待機すべきあらゆる箇所でTaskクラスが必要となります。

# 厳密にはGetAwaiterメソッドの実装が担保しますが、細部なので省略。また、WinRTでは、IAsyncActionやIAsyncOperationでも待機出来ます(後述)

Taskを使用する優れた点は、他のタスクとの合成が簡単であることです。

public static async Task SampleTaskRun2Async(Stream dataStream, byte[] data)
{
	Task task1 = Task.Run(new Action(() =>
		{
			// 任意のCPU依存性処理...
		}));

	Task task2 = Task.Run(new Action(() =>
		{
			// 他の任意のCPU依存性処理...
		}));

	// ネイティブな非同期I/O処理
	Task task3 = dataStream.WriteAsync(data, 0, data.Length);

	// 全ての処理が完了するのを非同期的に待機
	Task combinedTask = Task.WhenAll(task1, task2, task3);
	await combinedTask;

	Console.WriteLine("All done!");
}

Task.WhenAllメソッドは、指定されたタスク群が全て完了するのを待機する、新しいTaskを返します。
上記のように、CPU依存性の(つまり、ワーカースレッドで実行する)非同期処理と、ネイティブなI/O操作の非同期処理を、全く同じように扱う事が出来ます。

Task.Runを使うと簡単にワーカースレッドを制御出来るので、従来のThreadクラスは、

  • スレッド名を割り当てる必要がある
  • COMアパートメントを設定する必要がある
  • スレッドローカルストレージに残骸が残るような状況を完全に破棄したい

と言うような場合にだけ使用すれば良いでしょう。


Task.Runの戻り値

Task.Runは戻り値を返すことが出来ます。

public static async Task SampleTaskRun3Async(int count)
{
	// Func<T>デリゲートを使用することで、処理の戻り値を返す事が出来る
	Task<double> task = Task.Run(new Func<double>(() =>
		{
			// 任意のCPU依存性処理...
			var r = new Random();
			return Enumerable.Range(0, count).Select(index => r.Next()).Average();
		}));

	// 処理が完了するのを非同期的に待機
	double result = await task;

	Console.WriteLine("Done: {0}", result);
}

Task.Run引数のデリゲートが戻り値を返す「Func<T>」である場合、返されるTaskも「Task<T>」となり、awaitすると戻り値が返されます。


Task.WhenAllで、結果の集約

戻り値を返すタスクと、Task.WhenAllとLINQを使って応用すると、大量のデータを効率よくワーカースレッドに流し込んで並列実行させ、かつ全て完了するのを待機するという、今までなら出血しそうなコードが、非常に簡単に安全に記述出来ます。

public static async Task SampleTaskRun4Async(int count)
{
	// 計算を非同期に実行するタスクを、count個列挙する
	// 1, 1+2, 1+2+3, 1+2+3+4, ...
	IEnumerable<Task<long>> tasks =
		Enumerable.Range(1, count).
		Select(index => Task.Run(() => Enumerable.Range(1, index).Sum(v => (long)v)));

	// 全ての処理が完了するのを非同期的に待機
	long[] results = await Task.WhenAll(tasks);

	Console.WriteLine("Done: [{0}]", string.Join(",", results));
}

慣れていないと分かり難いかも知れません。この処理は、以下のように動作します。

taskrun2この方法は、タスク(中身はワーカースレッド)を指定された個数分起動し、全てが完了するのを待ちます。直感的には、大量のワーカースレッドが生成され、コンテキストスイッチングで飽和してまともに動作しないように見えますが、実際はそうなりません。Task.Runは、スレッドプールからワーカースレッドを取得しますが、スレッドプールの総スレッド数は、効率よく実行できる程度に調整されています(図の例では、最大で4個のスレッドが次々と計算を処理します)。

taskrun11このスクリーンショットは、Process Explorerでスレッドに割り当てられているCPUサイクルを見たものです。このテストを実施したマシンは、4コア2論理スレッドなので、システム上は8スレッド使えます。実際にほぼ8スレッドだけがアクティブに動作し、大量のワーカースレッドで飽和する事が無い事が分かります。

PLINQ(並列LINQ)と比べると、タスクを集約可能な式として実装する必要があるため、PLINQのように自然に拡張する事は出来ませんが、パフォーマンスの予測がしやすい事が利点です。


Taskの存在しない世界に、Taskを導入する

このように、処理に紐づいたTaskがあれば、非常に応用範囲が広くなります。しかし、そもそも処理の完了をTaskで担保しない場合はどうでしょうか。例えば、.NET CLRのイベントは一種のコールバックなので、対応するTaskが存在しません。

より具体的な例で考えます。WPFのボタンは、クリックされるとClickイベントが発火します。普通はこれをフックして、ハンドラとなるラムダブロックやメソッドで処理を実装します。この時、擬似的なTaskで発火状態を置き換える事が出来れば、様々な応用が可能になります。

処理の完了を疑似的なTaskで表現するのに、「TaskCompletionSource<T>」クラスが使えます。

// (コードビハインドで書いていますが、推奨はしません。また、エラーチェックを省略しています)
private async void OnInitialized(object sender, EventArgs e)
{
	// 文字列を返すことが出来るTaskCompletionSourceを準備
	var tcs = new TaskCompletionSource<string>();

	// ボタンがクリックされたら、テキストボックスの文字列をTaskCompletionSourceを介して間接的に返却する
	button.Click += (s, e) => tcs.SetResult(textBox.Text);

	// 非同期で待機する
	string inputText = await tcs.Task;

	// 結果をテキストブロックに表示
	textBlock.Text = inputText;
}

SetResultメソッドを呼び出すと、待機しているタスクが戻り値を伴って継続します。

上記の例は、ボタンのハンドラから直接テキストブロックに表示すれば良いので、何の為に複雑にするのかわからないかも知れません。イベントをタスク化する利点は、先ほど示したように合成が簡単だからです。

// (コードビハインドで書いていますが、推奨はしません。また、エラーチェックを省略しています)
protected override async void OnInitialized(EventArgs e)
{
	// ボタン1がクリックされたら、テキストボックス1の文字列をTaskCompletionSourceを介して間接的に返却する
	var tcs1 = new TaskCompletionSource<string>();
	button1.Click += (s, e) => tcs1.SetResult(textBox1.Text);

	// ボタン2がクリックされたら、テキストボックス2の文字列をTaskCompletionSourceを介して間接的に返却する
	var tcs2 = new TaskCompletionSource<string>();
	button2.Click += (s, e) => tcs2.SetResult(textBox2.Text);

	// 両方のボタンがクリックされるまで待機
	string[] inputTexts = await Task.WhenAll(tcs1.Task, tcs.Task2);

	// 結果をテキストブロックに表示
	textBlock.Text = string.Join(",", inputTexts);
}

上記の例では冗長に書きましたが、例えば動的に任意の個数で生成したテキストボックスやボタンの列に対して簡単に拡張・記述量の大幅削減が出来そうです。

// (コードビハインドで書いていますが、推奨はしません。また、エラーチェックを省略しています)
protected override async void OnInitialized(EventArgs e)
{
	// itemCount_個だけUI部品を生成し、関連付けられたTask群を返す
	IEnumerable<Task<string>> tasks =
		Enumerable.Range(0, itemCount_).
		Select(index =>
		{
			var textBox = new TextBox();
			var button = new Button();
			stackPanel.Children.Add(textBox);
			stackPanel.Children.Add(button);
			
			var tcs = new TaskCompletionSource<string>();
			button.Click += (s, e) => tcs.SetResult(textBox.Text);

			return tcs.Task;
		});

	// 全てのボタンがクリックされるまで待機
	string[] inputTexts = await Task.WhenAll(tasks);

	// 結果をテキストブロックに表示
	textBlock.Text = string.Join(",", inputTexts);
}

もう一つ例を示します。ワーカースレッドにThreadクラスを使わなければならない状況でも、TaskCompletionSource<T>を使ってタスク化する事が出来ます。

// COMアパートメントを指定して、独立したワーカースレッドを実行する
public static Task<TResult> RunEx<TResult>(
	Func<TResult> action,
	ApartmentState state = ApartmentState.STA)
{
	// スレッド終了を通知するTaskCompletionSource
	var tcs = new TaskCompletionSource<TResult>();

	// ワーカースレッドを生成する
	var thread = new Thread(new ThreadStart(() =>
		{
			try
			{
				// 処理本体を実行して、戻り値を得る
				var result = action();

				// Taskに終了を通知
				tcs.SetResult(result);
			}
			catch (Exception ex)
			{
				// 例外をTaskに通知
				tcs.SetException(ex);
			}
		}));
	thread.SetApartmentState(state);
	thread.Start();

	// Task<TResult>を返す
	return tcs.Task;
}

このRunEx<T>は、Task.Run<T>のように使え、かつCOMのアパートメントを指定可能にする例です。

TaskCompletionSource<T>は、例外を通知する事も出来ます。SetExceptionメソッドを呼び出すと、非同期待機しているタスクで指定された例外がスローされます。

なお、何故かTaskCompletionSource<T>のジェネリック引数を指定しないバージョン(つまり、非同期待機後に戻り値を受け取らないバージョン)は存在しません。但し、Task<T>は、Taskクラスを継承しているので、適当な引数型を与えて、完了もSetResultに適当な値を渡して代用する事が出来ます。


非同期処理のキャンセル

非同期処理をキャンセルするためのインフラも標準で用意されています。

// 指定されたデータをストリームに指定回数出力する
public static async Task SampleTaskRun5Async(
	Stream stream,
	byte[] data,
	int count,
	CancellationToken token)
{
	for (var index = 0; index < count; index++)
	{
		// キャンセル要求があれば、例外をスローして中断する
		token.ThrowIfCancellationRequested();

		// 非同期I/O処理も、中断を可能にする
		await stream.WriteAsync(data, 0, data.Length, token);
	}
}

CancellationToken構造体は、関連する非同期処理全体にわたってキャンセル要求を通知するための構造体です。キャンセルが発生したかどうかを保持する、一種のフラグのようなものです。呼び出し側が何らかの事情でキャンセル要求を行うと、このトークン経由で通知されるので、非同期処理を中断させる事が出来ます。

ThrowIfCancellationRequestedメソッドは、キャンセルが要求されているかどうかを検出し、キャンセルしていればその場でOperationCanceledExceptionをスローします。上の例ではトークンをWriteAsyncにも渡しているので、あまり大きな意味はありません(キャンセルされればWriteAsync内で例外がスローされる)。出来るだけ早くキャンセル要求に反応したい場合に、ThrowIfCancellationRequestedを使う事が出来ます。

ところで、CancellationTokenはどこから来るのでしょうか? CalcellationToken自体は、キャンセル要求を通知するための抽象的な構造体に過ぎません。自分で呼び出し側のコードを書いている場合は、CancellationTokenSourceクラスを使う事で、制御可能なトークンを用意する事が出来ます。

// (コードビハインドで書いていますが、推奨はしません。また、エラーチェックを省略しています)
protected override async void OnInitialized(EventArgs e)
{
	// CancellationTokenSourceクラスを準備(これでキャンセルを通知出来る)
	var cts = new CancellationTokenSource();

	// ボタンがクリックされたら、キャンセルを通知する
	button.Click += (s, e) => cts.Cancel();

	try
	{
		// 時間のかかる非同期処理にキャンセルトークンを渡す
		Task task = SampleTaskRun5Async(stream_, data_, 100000, cts.Token);

		// 非同期で待機する
		await task;

		// 結果をテキストブロックに表示
		textBlock.Text = "Output completed!";
	}
	catch (OperationCancelledException)
	{
		// 結果をテキストブロックに表示
		textBlock.Text = "Output canceled...";
	}
}

仮に、キャンセルさせたい場合の実行コンテキストが存在しない場合は、Registerメソッドを使って、コールバック化する事が出来ます。以下の例は、手元に実行コンテキストがある(awaitで待機している)ため、あまり適切な例ではありませんが、例えばネイティブAPIで処理開始後、処理がスレッドから完全に切り離されるような状況下で使用する事が出来ます。

// 指定されたデータをストリームに指定回数出力する
public static async Task SampleTaskRun6Async(
	Stream stream,
	byte[] data,
	int count,
	CancellationToken token)
{
	// キャンセル要求に対して、コールバックで反応する
	// (下記の実処理とは無関係に、キャンセル時にコールバックが発生する)
	var index = 0;
	token.Register(() =>
		// (実際にはここでキャンセル処理を行う)
		Console.WriteLine("Operation canceled: Written count={0}", index));

	// 実処理
	for (; index < count; index++)
	{
		token.ThrowIfCancellationRequested();
		await stream.WriteAsync(data, 0, data.Length, token);
	}
}

この他にも、WaitHandleプロパティから、待機可能なWin32カーネルオブジェクトが取得出来ます。Win32 APIと連携して同時にキャンセルを待機させたい場合などに使う事が出来ます。


WinRT・ユニバーサルWindowsアプリ(UWP)での非同期処理

WinRTの世界では、WinRT APIの呼び出しに対する非同期処理が「IAsyncAction」「IAsyncOperation」インターフェイスで表現されています。これらは「IAsyncInfo」インターフェイスを継承していて、Taskクラスによく似ていますが、追加の情報(進行状況など)を通知する能力を持たせることが出来るようになっています。

IAsyncActionとIAsyncOperationは、非同期処理の結果として戻り値を返すかどうかが異なります。Taskクラスの非ジェネリックバージョンとジェネリックバージョンに対応します。そして、これらのインスタンスは「await」で待機可能です。

.NET Task WinRT IAsyncInfo
戻り値無し Task IAsyncAction
戻り値あり Task<TResult> IAsyncOperation<TResult>

そのようなわけで、大体Taskクラスと同じように扱えますが、Task.WhenAllなどを使ったタスクの合成は出来ません。Task.WhenAllの引数は、Taskクラスしか受け付けないからです。そこで、IAsyncActionやIAsyncOperationをTaskクラスに変換する、ヘルパーメソッドが用意されています。

// WinRT非同期処理群を待機して、結果をテキストブロックに反映する
public static async Task SampleTaskRun7Async(IEnumerable<IAsyncOperation<string>> asyncOperations)
{
	// IAsyncOperation<string>をTask<string>に変換
	// (WindowsRuntimeSystemExtensions.AsTask拡張メソッド)
	IEnumerable<Task<string>> tasks =
		asyncOperations.Select(asyncOperation => asyncOperation.AsTask());

	// すべてが完了するのを待機出来るタスクに変換
	Task<string[]> combinedTask = Task.WhenAll(tasks);

	// 完了を待つ
	string[] results = await combinedTask;

	// 結果を反映
	textBlock.Text = string.Join(",", results);
}

AsTask拡張メソッドを使用すると、Taskに変換できます。一旦Taskに変換できれば、これまでの例と同じように応用が可能です。また、TaskをIAsyncActionやIAsyncOperationに逆変換する拡張メソッド(AsAsyncActionAsAsyncOperation)も用意されています。

WinRTの世界では、CancellationTokenに相当する、キャンセル管理用のクラスやインターフェイスがありません。その代わり、IAsyncInfo.Cancelメソッドがあり、直接キャンセルを要求する事が出来ます。TaskとCancellationTokenの世界をシームレスに結合する場合は、AsTaskのオーバーロードにCancellationTokenを渡すことによって、Cancelメソッドの操作も自動的に行わせる事が出来ます。

データバインディングと非同期 – C# Advent Calendar 2014

この記事は、「C# Advent Calendar 2014」の21日目の記事です。

WPFやWindows Phoneやストアアプリでは、Windows Formsのような「コードビハインド」による実装の他に、「データバインディング」を積極的に使う実装を行う事が出来ます。というよりも、データバインディングという手法を使わないと、複雑なUIを表現するのが困難です。この記事では、その辺りと非同期がどう関係するのかを書いてみたいと思います。

(なお、例はWPFですが、ストアアプリでもほぼ同様です)


データバインディングって何?

そもそもデータバインディングとは、どういう手法でしょうか?

asyncbinding1

Windows Formsの時代、データバインディングと言うと「DataSet」クラスの内容を、「DataGridView」コントロールを使って表示させるという用途が一般的でした。

詳しい解説は「Windowsフォームにおける「データ・バインディング」」を参考にして下さい。

この手法の問題点として:

  • DataSetやその周りのクラス(DataTableなど)と、List等の限定されたクラスがターゲット。
    実際には、IListインターフェイスやIBindingSourceインターフェイスを実装したクラスであれば受け入れる事が出来ますが、殆どの場合はこれらのインターフェイスを独自実装しないで、単にDataSetクラスを使用していたと思います。
  • DataGridViewのセル内に、更に別の(コレクション)要素を配置するようなリッチな表現を、統一された方法で実現するのが困難。
    「DataGridViewのセル内に「コンボボックス」を配置するのはどうしたら良いのか?」と言うのは、定期的に発生するFAQであったと思いますが、このような拡張がスムーズに行えないのは、グリッドの表現をDataGridViewが独自に制御していたため、応用性が無く、学習効果が得られないからと思われます。
  • バインディング対象のインスタンスがコンポーネントとしてフォームに設定される必要があり、柔軟性に欠ける。
    DataGridView以外のコントロールでも、バインディング機能を使う事は出来ました。但し、バインディングしたいインスタンス(モデルクラスやコレクションなど)は、コンポーネントとしてフォームに配置される必要があったため、フォームとデータが密結合する事になり、データバインディングする事の意義が見出しにくい状態でした。また、コントロールへの直接アクセスの手法があまりに一般に広まったため、そもそもこの手法が利用可能である事も周知されませんでした。

「DataSetを用意して、単純にDataGridViewに表形式で表示」させるのであれば十分なのですが、それ以上のUI表現に踏み込むと途端に難易度が高くなり、インターフェイスが複雑化してしまいます。

また、DataSet自体がデータベースのテーブルを模倣したものであったため、階層化されたデータを容易に扱えないという問題もあります。Visual Studioのデザイナー機能によって「型付きDataSet」を生成出来るようになりましたが、結局(生の)DataSetをラップしたものに過ぎず、本来コード上でモデル型として表現されている構造から乖離してしまう事は避けられませんでした。

asyncbinding2

.NET 3.0でWPFが発表され、ここの部分に大きなメスが入りました。DataGridViewのような、特殊なクラスの特殊な機能によってバインディングを実現するのではなく、そもそもWPFの枠組みでこれを実現し、どのようなコントロールでも統一されたバインディングを使えるようにし、UIとデータを完全に分離可能にした… それが今のデータバインディングです。

図のように、「DataGridコントロール(WPF)」「TextBoxコントロール(WPF)」は、両方ともモデルクラスとのデータの送受を「バインディング式(Binding expression)」で実現しています。この手法には全く違いは無く、更にDataGridコントロールの場合は、階層化されたモデルクラスのインスタンス群をどのようにDataGridで表現するのかまでを、全てバインディング式で記述する事が出来ます。

DataGridコントロールだけではなく、リスト形式で要素を表示する「ListBoxコントロール」や、コントロール要素の配置を制御する「Panelクラス」を継承したコントロール群「DockPanelStackPanelGrid等」も、ほぼ同様の手法でデータとの関連付けが可能です。

そして、これらのコントロールを階層化して組み合わせた複雑なUIは、データバインディングの規則に従っている限り、技術的な制限なく実装する事が出来ます。

WPFでも、従来のWindows Formsのようにコントロールに直接アクセスして、ほぼ全てを制御する事も出来ますが、データバインディングを使えるようになると、かなり面倒に感じられるはずです。また、ちょっとUIが複雑化するだけで実現難易度が高くなるため、単調なUIで実現する事しか選択の余地が無くなってしまいます。

(Windows Formsが楽と言う意味ではなく、むしろ複雑なUIを実現する場合、Windows Formsでは更に難易度が高い)


データバインディングのキモ

データバインディングのキモは、コントロールの公開するプロパティと、モデルクラスの公開するプロパティの値を自動転送するところです。以下にTextBoxの例を示します。

これはXAMLの定義です:

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<TextBox x:Name="sampleTextBox" />
</Window>

対応するコードビハインド:

public partial class MainWindow : Window
{
	public MainWindow()
	{
		InitializeComponent();

		// TextBoxに初期文字列を設定
		sampleTextBox.Text = "ABC";
	}
}

asyncbinding3

TextBoxのTextプロパティは、テキストボックスに入力された文字列をやり取りするプロパティです。あらかじめこのプロパティに値を設定すれば、テキストボックスの初期表示文字列となり、ユーザーが文字列を入力したり編集したりすれば、それがこのプロパティから読み取れます。ここまではWindows Formsと同様です。

ここで、Textプロパティにバインディング式を記述します。バインディングするためには、相方のプロパティが必要です。とりあえず、MainWindowクラスに直接実装しましょう。

public partial class MainWindow : Window
{
	// バインディングされる値を保持するフィールド
	private string sampleText_ = "Bound!";

	public MainWindow()
	{
		InitializeComponent();

		// バインディング対象のインスタンスを、このクラス自身に設定
		this.DataContext = this;
	}

	// バインディングされるプロパティ
	public string SampleText
	{
		get
		{
			return sampleText_;
		}
		set
		{
			sampleText_ = value;
		}
	}
}

XAMLにはこのようにバインディング式を書きます:

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<!-- TextプロパティとSampleTextプロパティをバインディングする式 -->
	<TextBox Text="{Binding SampleText}" />
</Window>

XAMLのコードについて、重要な部分が二カ所あります。一つはバインディング式を記述した事です。TextBoxにはTextプロパティがあるのですが、そのプロパティに対して「{Binding SampleText}」と記述します。この中括弧+Bindingで始まる指定がバインディング式です。「SampleText」と言うのは、バインディングさせたい対象のプロパティ名です。ここではMainWindowクラスに定義した「SampleText」プロパティを指定しています。

もう一つの重要な点は、もはや「x:Name」による、コントロールへの命名が不要になっている点です。この点についてはまた後で説明します。

MainWindowクラスへの変更も説明をしておきます。MainWindowクラスにも重要な二カ所の変更点があります。一つはバインディング対象のプロパティ「SampleText」を追加した事です。これに合わせて、値を保存しておくためのフィールドも追加してあります(熱心なC#信者なら、自動実装プロパティを使う事が出来ると思うかもしれません。デバッガで動きを追えるようにするためにわざと手動で実装しています)。

もう一つはコンストラクタに追加した、「DataContext」プロパティにthisを代入するという式です。このプロパティは、XAML上でバインディング式を実行する際に、デフォルトのバインディング対象のインスタンスを特定するために使われます。ここでthisを代入しているという事は、デフォルトのバインディング対象はMainWindowクラスのインスタンスとなる、と言う事です。そして、XAML上に記述したバインディング式が「{Binding SampleText}」となっているので、結局「MainWidnowクラスのインスタンス」の「SampleTextプロパティ」が、TextBoxのTextプロパティにバインディングされる、と言う事になります。


asyncbinding4

さて、このコードを実行すると、望みの通り、バインディングが実現し、初期状態としてフィールドの値が表示されます。しかし、テキストボックスの文字列をタイプして変更しても、SampleTextプロパティのsetterに遷移しません(ブレークポイントを設定して確かめて下さい)。

これは、バインディング粒度のデフォルトが、コントロールからフォーカスが外れた時、となっているためで、サンプルではTextBoxが一つしかないためにそのタイミングが無い事によります。ここでは単純に、「一文字でも変化があれば」、と変えておきます。この指定もバインディング式だけで実現出来ます。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="100">
	<TextBox Text="{Binding SampleText, UpdateSourceTrigger=PropertyChanged}" />
</Window>

asyncbinding5

XAMLのバインディング式に「UpdateSourceTrigger=PropertyChanged」と付け加えます。すると、バインディング処理の実行が、Textプロパティに変化があった時という条件となります。これで、setterにブレークポイントを張っておいて、テキストボックスで「A」をタイプするとこのようになり、バインディング式によって自動的に値が転送されて来る事が分かります。

asyncbinding6

上記のデータバインディング例を図示すると、このようになります。従来のWindows Formsのように、イベントハンドラのフックやTextBoxのTextプロパティへの直接アクセスをする事無く、値の受け渡しが出来ている事になります。おっと、忘れる所でした。これで「x:Name」によるコントロールの命名が不要になっている理由も分かりますね。プロパティ同士がバインディング式で直接転送されるので、「どのコントロールに対する操作なのか」の明示は不要となっているのです。

(ここで解説しませんが、能動的にSampleTextの値が変化する場合は、MainWindowクラスにINotifyPropertyChangedインターフェイスを実装して、プロパティの値が変化したことをWPFに通知する必要があります)

ここまで、Windows Formsっぽい手法からスタートして説明するため、バインディング対象のインスタンスをDataContextプロパティにthisを代入する事で「MainWindow」そのものにしましたが、更にコードの責務を分離するために、バインディングするクラスを独自のクラスに完全に分離することも出来ます。例えば「MainWindowViewModel」クラスを作り、DataContextにこのクラスのインスタンスを代入する事で、バインディング対象をMainWindowから切り離す事が出来ます。

// MainWindowクラスから完全に独立したクラス(ビューモデル)
public sealed class MainWindowViewModel
{
	// バインディングされる値を保持するフィールド
	private string sampleText_ = "Bound!";

	// バインディング対象のプロパティ
	public string SampleText
	{
		get
		{
			return sampleText_;
		}
		set
		{
			sampleText_ = value;
		}
	}
}

// コードビハインドが殆ど存在しないMainWindowクラス(ビュー)
public partial class MainWindow : Window
{
	public MainWindow()
	{
		InitializeComponent();

		// バインディング対象のインスタンスを、MainWindowViewModelに設定
		this.DataContext = new MainWindowViewModel();
	}
}

これが「MVVM(Model-View-ViewModel)」パターンの入り口です。


データバインディングと非同期

長い前フリでしたが (;´Д`) 本題に入ります。ストアアプリに導入された新しいWinRT APIのセットは、原則として外部リソースアクセスは全て非同期で実現しなければならないようになっています。ブログの記事や勉強会でも説明した通り、これらはC#で言う所の「async/await」を使って実現します(より深くはTaskクラスやIAsyncInfoインターフェイスを使う事ですが、本題と関係ないので省略)。

「非同期処理」と「ユーザーインターフェイス」とはどう絡むのかが本題です。この問題は古典的でもあって、一般的なウェブブラウザのフォームでも同じような問題を抱えていました。例えば、「購入」ボタンを何度もクリックされると困るので、クリック後はボタンを無効化して押せなくする等の処置は、(ブラウザとは無関係に)ウェブサーバー側で購入処理が非同期で実行されるから発生する問題でした。

あるいはもっと良い方法もあります。何度「購入」ボタンが押されても問題ないように、同じセッションが維持されていると分かる場合は、単一の要求と見なすなどです。

どちらの手法を取るにしても、「非同期処理は何も対策しないで実現することは無理」と言う事です。async/awaitのサポートによって、非同期処理の実装難易度は格段に改善されたのですが、この部分をどうするのかは、仕様や実装を行う者に任されています。

二つのパターンの背景を説明しましたが、非同期処理をユーザーインターフェイスを工夫する事によって担保する方法について、データバインディングと絡めて考えてみます。


非同期処理中にユーザーインターフェイスをロックダウンする、と言うのは、もっとも単純で効果的な方法です。真っ先に思いつくのは、Windowsクラス全体のIsEnabledをfalseに設定する事でしょう。これをいつ行えばよいでしょうか?というよりも、非同期処理をどこで開始するのか?と言う事です。

例えば、UIにボタンが配置されており、ボタンのクリックと同時に非同期処理が開始されるとします。データバインディングを使うのであれば、「ICommandインターフェイス」を実装したクラスを使って、ボタンクリックのイベントを「バインディング」するでしょう。移譲されたイベントハンドラは、以下のような実装となります。

// (これは擬似コードのため動きません。INotifyPropertyChangedの実装やCommandクラスの実装が必要です)
public sealed class MainWindowViewModel
{
	public MainWindowViewModel()
	{
		// ICommandインターフェイスを実装したクラスを使って、バインディング可能なイベントハンドラを公開する
		this.FireStart = new Command(this.OnFireStart);
	}

	// バインディング可能なイベントハンドラ(ボタンのCommandプロパティにバインディングする)
	public ICommand FireStart
	{
		get;
		private set;
	}

	// ウインドウ全体の有効・無効を制御する(MainWindowのIsEnabledプロパティにバインディングする)
	public bool IsEnabled
	{
		get;
		private set;
	}

	// Commandクラスによって、転送されるイベントの実処理
	private async void OnFireStart(object parameter)
	{
		// ユーザーインターフェイスを無効化する
		this.IsEnabled = false;
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("..."))
			{ ... }
		}
		finally 
		{
			// ユーザーインターフェイスを有効化する
			this.IsEnabled = true;
		}
	}
}

この例では、ボタンのクリックと言う「アクション」を引き金に動作するので、比較的分かりやすいと言えます。非同期プログラミングのベストプラクティスによれば、シグネチャとして「async void」と定義するのはイベントハンドラの場合だけだと述べられています。この例は本物(CLR)のイベントハンドラではありませんが、ICommandによって間接的に呼び出されるコールバックとして、広義において同じと見なしても良いと思います。

asyncbinding7

では、値を転送するデータバインディングそのものが引き金になる場合はどうでしょうか?前フリのような、テキストボックスに対する編集操作で、非同期的に処理を実行し、結果を反映する場合です。例えば、Visual Studioのインテリセンスがシンボル名の絞り込みを行う過程や、ウェブで言うなら「googleサジェスト」のような機能です。

この場合、バインディングされたプロパティに、一文字変化がある度に値が転送されてきます。転送はsetterのコールによって実行されるため、非同期処理はsetterから始める必要があります。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="200"
		IsEnabled="{Binding IsEnabled}">
	<DockPanel>
		<!-- Textプロパティ同士をバインディングし、テキストボックスへの文字の入力を検知可能にする -->
		<TextBox DockPanel.Dock="Top" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />

		<!-- 結果を表示するリストボックス -->
		<ListBox ItemsSource="{Binding ResultItems}">
			<ListBox.ItemTemplate>
				<DataTemplate>
					<!-- 結果を表示するテンプレート -->
				</DataTemplate>
			</ListBox.ItemTemplate>
		</ListBox>
	</DockPanel>
</Window>
// (これも同様に擬似コードです)
public sealed class MainWindowViewModel
{
	private string text_;

	public MainWindowViewModel()
	{
		this.ResultItems = new ObservableCollection<ResultModel>();
	}

	// TextBox.Textにバインディング(UpdateSourceTrigger=PropertyChangedとする)
	public string Text
	{
		get
		{
			return text_;
		}
		set
		{
			// ユーザーインターフェイスを無効化する
			this.IsEnabled = false;
			try
			{
				// 非同期処理...
				using (var stream = await httpClient.GetStreamAsync("...?q=" + text_))
				{ ... }
			}
			finally 
			{
				// ユーザーインターフェイスを有効化する
				this.IsEnabled = true;
			}
		}
	}

	public bool IsEnabled
	{
		get;
		private set;
	}

	public ObservableCollection<ResultModel> ResultItems
	{
		get;
		private set;
	}
}

このコードには仕様的にも実装的にも問題があります。仕様面では、一文字入力する度にWindowが無効化される(しかも非同期処理が完了するまで解除されない)ため、恐らくユーザー体験は最悪の物となる事です。実装的な問題としては、setter内ではawait出来ない事です。これはsetterにはasyncを指定出来ない事から来ています。

まず、実装的な問題は、実処理をメソッド抽出すれば解決します。

public sealed class MainWindowViewModel
{
	private string text_;

	public MainWindowViewModel()
	{
	}

	// TextBox.Textにバインディング(UpdateSourceTrigger=PropertyChangedとする)
	public string Text
	{
		get
		{
			return text_;
		}
		set
		{
			// 非同期メソッドを呼び出すが、待機は出来ない。
			// →Taskを返し、メソッド名に「Async」と付ける事で、setter呼び出し後は、非同期処理が継続する事がはっきりする
			var task = this.ProceedTextAsync(value);
		}
	}

	// 一般的な非同期処理のシグネチャを順守
	private async Task ProceedTextAsync(string value)
	{
		// ユーザーインターフェイスを無効化する
		this.IsEnabled = false;
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("...?q=" + value))
			{ ... }
		}
		finally
		{
			// ユーザーインターフェイスを有効化する
			this.IsEnabled = true;
		}
	}

	public bool IsEnabled
	{
		get;
		private set;
	}
}

実処理を「ProceedText」としてasync voidとする事も出来ますが、ここではより汎用的になるように(そしてベストプラクティスに従うように)、Taskクラスを返却するようにしました。setterで戻り値のTaskを無視すると、C#コンパイラが警告を発します。そのため、ダミーの変数で受けるように書いています。わざわざTaskクラスを返す事で、別の仕様要件でProceedTextAsyncを呼び出さなければならない場合にも、非同期処理の待機が可能になる余地を残す事が出来ます。

残る仕様的な問題ですが、ウインドウ全体を無効化する必要が無いのであれば、特定のコントロールに絞って無効化を実施するという方法が考えられます。例えば、検索結果に応じてリストボックスの内容を変化させる場合、テキストボックスは有効としたまま、リストボックスだけを無効化するようにすれば良いのです。その場合、ロジックの変更は必要ありません。XAMLのバインディング式をListBoxに適用します。

<Window x:Class="CenterCLR.AsyncBindings.MainWindow"
		xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
		xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
		Title="MainWindow"
		Width="525"
		Height="200">
	<DockPanel>
		<TextBox DockPanel.Dock="Top" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
		
		<!-- IsEnabledをListBoxにバインディング -->
		<ListBox ItemsSource="{Binding ResultItems}" IsEnabled="{Binding IsEnabled}">
			<ListBox.ItemTemplate>
				<DataTemplate>
					<!-- 結果を表示するテンプレート -->
				</DataTemplate>
			</ListBox.ItemTemplate>
		</ListBox>
	</DockPanel>
</Window>

非同期処理のエラーの対処

ここまでで、データバインディングで値が転送された場合の非同期処理は対処可能になりました。では、非同期処理中に発生するエラーはどのように対処すれば良いでしょうか?

そもそも、仕様的な面で非同期処理中のエラーにどう対処するかを考える必要があります。最後の例では、ユーザーがテキストボックスに何かタイプすると、非同期で検索処理を行い、結果がリストボックスに(非同期的に)表示されます。一文字タイプし、その事が原因で何らかのエラーが発生したとしても、ユーザーはもう次の文字をタイプしているかも知れませんし、ウインドウ上の別の機能を実行しているかも知れませんし、ウインドウを閉じているかも知れません。

  • エラーが発生したら、その場でメッセージボックスを表示する。
    単純なユーザーインターフェイスであれば、これでも良いと思われます。但し、複数の非同期処理を実行している可能性があり、それらが一斉に失敗すると、メッセージボックスが大量にスクリーンに表示される可能性があります。また、忘れた頃にいきなり表示される可能性もあります。
  • ログ出力のような表示領域に表示させる。
    過去に発生した内容を表示できるので、より望ましいと言えます。但し、どのメッセージが何の操作に紐づいていたのかは分かりにくくなるかも知れません。

どのような方法でも、握りつぶすのでなければ、対処方法は同じでしょう。非同期処理の実質的なコードが例外をスローする場合は、単にtry-catchで例外を補足し、エラー処理を行います。

private async Task ProceedTextAsync(string value)
{
	// ユーザーインターフェイスを無効化する
	this.IsEnabled = false;
	try
	{
		try
		{
			// 非同期処理...
			using (var stream = await httpClient.GetStreamAsync("...?q=" + value))
			{ ... }
		}
		catch (Exception ex)
		{
			// メッセージボックスを表示する
			MessageBox.Show(ex.Message, "Error occurred!", MessageBoxButton.OK, MessageBoxImage.Error);
		}
	}
	finally
	{
		// ユーザーインターフェイスを有効化する
		this.IsEnabled = true;
	}
}

注意点としては、C# 5.0ではcatch-finallyブロック内でawait出来ない事です。WPFの場合はMessageBoxの内部でメッセージポンプが実行される(Win32メッセージループが実行される)ので良いのですが、ストアアプリではawaitで待機する必要があります。そのため、多少泥臭いコードを書く必要があります(エラーを保存して一旦catchブロックから抜けてから表示するなど)。

エラー処理のコード例をお見せしましたが、結局のところ、一般的なコードと何も変わらないことが分かると思います。


ビジネスロジックの分離

仕様がより高度になるにつれ、データバインディングを実装しているクラスを分割したくなります。特にユーザーインターフェイスに対応するための固有のコードと、いわゆるビジネスロジックの分離です。リファクタリングの作法に従って、責務によってクラスを分割して行くと、MVVMで言う所のビューモデルとモデルに分割されます(うまくやればね :)。この時、前述の例で示したHttpClientによる検索処理などのビジネスロジックは、ビューモデル(元のクラス)からモデルに移動する事になります。

実装を移動する際、HttpClient.GetStreamAsyncは非同期メソッドなので、await可能でなければなりません。と言う事は、移動先のメソッドにはasyncが適用されなければならず、ベストプラクティスに従うならTaskクラスを返却するメソッドでなければなりません。結局のところ、「ProceedTextAsync」メソッドはほぼそのままの形でモデルクラスに移動する事になります。

ProceedTextAsyncがビューモデルに依存している箇所は、それぞれを以下のように対処します。

  • 結果をどう反映するか : モデルはthis.ResultItemsにはアクセス出来ない。したがって、ProceedTextAsyncは結果をまとめて(コレクションに入れるなどして)返却する。
  • IsEnabledへのアクセス : モデルは直接UIとの関係を持たないと考えると、IsEnabledの操作はビューモデルに残しておく。
  • メッセージボックスへのアクセス : モデルは直接UIとの関係を持たないと考えると、メッセージボックスの操作(例外の処理)はビューモデルに残しておく。

以下の例では、モデルクラス自身をコレクション(List)とし、UpdateAsyncメソッドでここに結果を保持するようにします。モデルの存在意義を考えてこのようにしましたが、実際の所はメソッドを単なる移譲として分離(戻り値にコレクションを返すスタティックなメソッド)した方が良かったかも知れません。

// ビジネスロジックを含むモデル(コレクション)
public sealed class BusinessLogicModel : List<ResultItem>
{
	// コレクションを更新する
	public async Task UpdateAsync(string value)
	{
		// 非同期処理...
		using (var stream = await httpClient.GetStreamAsync("...?q=" + value).
			ConfigureAwait(false))  // 以降の処理をワーカースレッドで実行
		{
			var document = SgmlReader.Parse(stream);

			// ここではUIを直接操作出来ない(ワーカースレッドなので)
			this.Clear();
			this.AddRange(
				from html in document.Elements("html")
				from body in html.Elements("body")
				// ...
				select new ResultItem { ... });
		}
	}
}

このように分離すれば、あとはビューモデルから呼び出すだけです。

private async Task ProceedTextAsync(string value)
{
	// ユーザーインターフェイスを無効化する
	this.IsEnabled = false;
	try
	{
		try
		{
			// モデルを用意して結果をフィルする
			var model = new BusinessLogicModel();
			await model.UpdateAsync(value);

			// バインディングする
			// (awaitでメインスレッドにマーシャリングされているので、UIを操作しても問題ない)
			this.ResultItems = model;
		}
		catch (Exception ex)
		{
			MessageBox.Show(ex.Message, "Error occurred!",
				MessageBoxButton.OK, MessageBoxImage.Error);
		}
	}
	finally
	{
		// ユーザーインターフェイスを有効化する
		this.IsEnabled = true;
	}
}

更なる発展

コレクションは段階的に反映したいですね。結果が認識されるたびに、1件ずつリストボックスに反映されたりすると、ユーザーインターフェイス的にとても良いと思います。

このような実装にするには、結果が1件取得される度に、バインディングしているコレクションに反映して、それがUIにも反映される必要があります。コレクションへの反映はINotifyCollectionChangedインターフェイスで通知することが出来ます。このインターフェイスはObservableCollectionクラスが実装しているので、Listの代わりにこのクラスを使うと良いでしょう。

注意点として、コレクションへの追加処理は、UIスレッド(WPFでは恐らくメインスレッド・ストアアプリではビュー毎に割り当てられているスレッド)で実行する必要があるという事です。ここにトレードオフがあります。

CPU依存性処理をワーカースレッドで効率よく処理させるために、「ConfigureAwait」メソッドを使用したくなるかもしれません。これにより、awaitの度にUIスレッドにマーシャリングされなくなるため、パフォーマンスが向上します。しかし、当然の事ながらUIスレッドにマーシャリング出来ないので、(メソッドを抜けるまでは)UIに絡む処理は実行出来ません。

ObservableCollectionを使用した場合、要素の変更時に「CollectionChanged」イベントを発火します。このイベントの発火はUIスレッドで実行しなければならないため、ObservableCollectionの操作は暗黙にUIスレッドで実行する必要があります。

前述のコード例でConfigureAwaitを使用しているのは、結果を一旦Listに保存しているので、直接的にも間接的にもUIに依存していない事が分かっているためです。このように、呼び出し元のスレッドがどこまで有効に作用するのかを考えて、ロジックを分離すると良いでしょう。

この辺りを掘り下げた資料は、以前の勉強会で発表した資料があるので、参考にどうぞ。


今年もあと少し、今日は寒くて堪りませんでした。明日はmoririringさんです。よろしく!

コマンドラインプログラムで非同期処理ってどう書くの?

awaitableconsoleprogram

… ネタが降ってきた (;´Д`)

.NETのコンソールプログラムは、Visual Studioで普通に作ると、以下のようなテンプレコードが生成されます。

namespace ConsoleApplication2
{
	internal static class Program
	{
		public static int Main(string[] args)
		{
			return 0;
		}
	}
}

勉強会のasync/awaitシリーズで説明したシチュエーションは、WPFやWinFormsという環境での話でした。そこでは、「UIキュー」が重要で、このキューに非同期処理の完了が放り込まれ、メインスレッドがこれを拾い上げて処理する事で、await以降の処理が実行されることを説明しました。

コンソールで実行されるコードの場合、この「UIキュー」が存在しません(WPFやWinFormsのインフラを使った場合は生成されますが、ここでは扱いません)。そのため、await時に「ハードウェイト」しても困らない事になります。

UIキューが存在する環境でハードウェイトすると、UIキュー内のエントリが実行されなくなります。目に見える問題として、ユーザーの操作(ウインドウを移動したりサイズ変更したり、ユーザーインターフェイス要素の操作)が無視され、アプリケーションが固まっているように見えます。

しかし、コンソールアプリケーション実行時のウインドウ「コマンドプロンプト」は、アプリケーションのスレッドの状態に関わらず、常に操作可能です。以下のProcessExplorerのスクリーンショットは、コマンドプロンプトウインドウを激しく動かしている時に撮ったものです。

awaitableconsoleprogram3

コンソールアプリケーション(ここではcmd.exe)を起動すると、同時に子プロセスとして「conhost.exe」が起動します。はっきりしたことは分かりませんが、上記のようにウインドウの操作を行うと、このconhost.exeの負荷が上昇する事から、ウインドウメッセージの処理はこのプロセスが受け持っている事が推測できます。そうであれば、実際のコンソールアプリケーションは、UIキューの処理に影響される事無く(元々持っていない)、そしてユーザーインターフェイスがブロックされる事が無く、動作するのです。

話を戻して、コンソールアプリケーションではUIキューを持っていないので、スレッドがハードブロックしても問題ありません。Taskクラスを返却する「非同期メソッド」は、必要な個所でハードブロック出来ます。この事を利用して、以下のようなスケルトンコードを考える事が出来ます。

namespace ConsoleApplication2
{
	internal static class Program
	{
		private static async Task<int> InternalMainAsync(string[] args)
		{
			// あんな非同期やこんな非同期処理 ...

			// Console.WriteLineへのアクセスはスレッドコンテキスト違反にはならない
			Console.WriteLine("async/await on Console Application.");
		}

		public static int Main(string[] args)
		{
			try
			{
				return InternalMainAsync(args).Result;
			}
			catch (Exception ex)
			{
				Console.Error.WriteLine(ex.ToString());
				return Marshal.GetHRForException(ex);
			}
		}
	}
}

Mainの最初の処理として、InternalMainAsyncメソッドを呼び出します。このメソッドはTaskクラスを返却する非同期メソッドです。そのため、このまま放置すると次の処理に行ってしまいます(Mainを抜けて終了してしまう)。そのため、Waitを呼び出してハードブロックする必要があります。上記の例ではResultプロパティを呼ぶことで、間接的にハードブロックしています(戻り値intを取得するため)。

一方、InternalMainAsyncの中の処理は全て非同期で記述できます。ここでの知識は、WPFやWinFormsで非同期処理を記述する時と全く同じ知識を活用できます。言い換えると、普段から非同期処理を書いているなら、同じ流れで書いて問題ないという事です。

スレッドはどうなるのか? ですが、メインスレッドとUIキューの関係は無く、メインスレッドはResultでハードブロックしているので、全ての非同期処理(非同期処理の継続操作)はワーカースレッドのコンテキストで駆動されます。

今話題にしているのはコンソールアプリケーションでしたね? 例えば、ワーカースレッドからConsole.WriteLineを呼び出しても問題なく出力出来ますね? 勿論、マルチスレッドアクセスが認められないクラスやメソッド、特定のスレッドコンテキストに紐づくクラス等を扱う場合は、スレッド同期やマーシャリング等の操作が必要になりますが、この辺の知識はTask.RunやThreadクラスを使って手動でワーカースレッドを作った時と同じように考えればOKです。

追記: 現代の話 (2020年)

ネタとしてはもうかなり前から出来るようになっていますが、C# 7.1以上を使用している場合は、以下のように直接MainメソッドがTaskを返すように書く事が出来ます:

namespace ConsoleApplication3
{
	internal static class Program
	{
		public static async Task<int> Main(string[] args)
		{
			// あんな非同期やこんな非同期処理 ...
		}
	}
}

ポイントは、

  • メソッド名はMainであること
  • 引数は従来通り、無しかstring[]であること
  • 戻り値の型がvoidの場合は従来通りの扱い、Task又はTask<int>の場合のみ、Mainを非同期メソッドとして認識

.NET Core/Frameworkのバージョンではなく、C#のバージョンによって使用可能になります。つまり、この機能は、コンパイラが判断して適切なコードを自動生成する事で実現しています。生成されるコードは、私たちが手動で行っていたのと同じように、Wait()やResultを呼び出してハードウェイトしている事になるため、UIキューを使うようなシチュエーションでは使えません。