Taskを極めろ!async/await完全攻略

  • 195
    いいね
  • 1
    コメント

この記事は、

  • Task.Runを書けばとりあえず非同期で動くのはわかる
  • 時々なんかうまく動かなかったりするけどどうして動かないのかはよくわからない
  • よくわからないまま書いてよくわからないまま動いてるけどこれで大丈夫なのかわからなくてこわい

みたいな人を対象にしています。

Taskクラスとasync/await

皆さん、非同期してますか?当然してますね。同期処理が許されるのはC#2.0までだよねーじゃなくて、async/awaitはC# 5.0から導入された、Taskクラスと紐付いた言語構文の一つです。登場はもう数年前なはずなんですが、未だに新しい言語仕様な感じがしてフシギです。それでもさすがに今は馴染んでいて、どこにでも遠慮なく飛び出てくるようになっています。

Taskの難しさ

Taskは、難しいです。
Taskがというよりは、非同期処理自体が持つ複雑さが根本に存在するため、いくらシンプルに書ける構文があったとしても、その難しさは変わりません。
非同期処理の一般的な問題として、よくわかっていないまま使うと、目に見えにくい不具合や再現性が著しく低い不具合などを埋め込んでしまい大変危険なことになるんですが、その危険性も、使い慣れている人じゃないと気づけないというところがあります。
下手な使い方をすると、

  • 例外がthrowされてたけど気づかない
  • 見つけづらいデッドロックの発生
  • 並列アクセスによるスレッドセーフ問題
  • そもそもちゃんと非同期処理になってないクソコード

などの問題を引き起こします。やばいですね。

Taskを理解しよう

まずはasync/awaitのことは置いておいて、Taskの事を理解することが大切です。
細かい動きがわからなくても、理屈さえ理解さえしていれば、間違ってもasync voidなんて書いたりしなくなりますからね。

オブジェクト指向

さすがに今更オブジェクト指向のことについて言及する必要もないとは思いますが、これ以降の話の内容にちょっと関わるので、一言だけ。
オブジェクト指向というのは「有象無象の『値』の集まりに対して、クラスという枠組みを作り、その『値』に特別な性質と名前を与え、それを分類して管理していく」プログラミングスタイルのことです。つまり、オブジェクト指向におけるクラスの「名前」というのは非常に重要で、例えば中身が全く同じであっても、その名前が違うだけで使い方が自然に変わっていくような、そんなハイコンテクストなプログラミング手法なのです。

そもそもTaskとは何なのか

Taskの解説を読むとスレッドプールだとかIO待ちだとか、並列だ並行だと話が出てきますが、そもそもスレッドプールとIO待ちは全くの別物だったりしますね。これは一体どういうことなのか?

まず一番の前提をぶち壊して行きます。
「Taskとは、『非同期処理』のことではない。」

だって、「Task」はただの「タスク」なのだから。

Taskという「名前」

ここで、非同期処理に「Task」という名前が与えられている意味を考えてみましょう。Taskクラスはその名の通り、一つの「作業単位」「仕事」「タスク」そのものを表しているといえます。一般的なTaskファクトリであるTask.Runを例とすると、

var task = Task.Run(() =>
{
    MethodA();
    MethodB();
});

この変数taskは、「MethodAを実行後、MethodBを実行する、という『タスク』を作成し、それを開始したもの」を表します。つまり、変数taskの中身は、文字通り『タスク』そのものなのです。

「1000ミリ秒待機するという『仕事』を表す」

var task = Task.Delay(1000); // ただのDelayなんだけど、ちょっと違って見えてきません?

「HttpClientでGETを行うという『仕事』」

var task = client.GetAsync("https://......"); // <- awaitを付けていない

ここで記述しているのは「開始するところ」だけなのがポイント。だから、「実際のGET処理は裏で誰かが勝手にやってくれる」。

async/awaitキーワード、そして「非同期メソッド」とは

  • シグネチャにasyncを付けたメソッドのことを「非同期メソッド」と呼びます。
  • 非同期メソッドの特徴はただ一つ、文中でawaitキーワードを使えるようになることです。
  • そして、awaitキーワードの効果は、「指定したTaskの完了を待つ」「そして、その結果を取り出す」ことです。
  • 最後に、非同期メソッドの戻り値は必ずTask/Task<T>になります。

なにか見えてきませんか?

非同期メソッドとは、複数の「タスク」の実行順序などを記述した「一つのタスク」である
いわゆる、作業手順書のようなものである。

「1000ミリ秒待機する『タスク』の完了を待ち、その後"Done!"を出力する、という『タスク』」

async Task AsyncMethod()
{
    await Task.Delay(1000); // 1000ミリ秒待機するという仕事の完了を待ち、
    WriteLine("Done!"); // "Done!"をコンソールに出力する
} // という、「一つのTask」を表す。

「HttpClientでGETした内容を文字列で手に入れる『タスク』」

async Task<string> AsyncMethod2(Uri uri)
{
    using (var client = new HttpClient()) // <- 本当はHttpClientをusingで使っちゃダメ
    {
        var response = await client.GetAsync(uri); // <- 「GETせよ」のタスクを開始し、その完了を待機する
        var text = await response.Content.ReadAsStringAsync(); // 「レスポンスからその本文をstringとして読み出す」タスクを開始し、その完了を待機する
        return text; // 読み出したtextを返す
    }
} // という「一つのタスク(Task<string>)」を表す。

「タスク」であるという意味

非同期メソッドはタスクであり、作業手順書です。手順書であるということは、それが「書かれた通りの順序で実行していけばいい」ということであり、言い換えるとそれは「記述されたタスクの実行順序さえ間違えなければ、誰が(どのスレッドが)どのタスクを実行したって構わない」と表明しているような事になります。つまり、Taskとは「スレッドの存在を意識する必要がない、単なる『処理』のまとまり」だということです。非同期処理を「タスク」の組み合わせとみなすことで、処理を分割しやすく、そして容易に合成できるようにしたもの。それが、Taskクラスなのです。

// ちゃんと順番通り実行してくれるなら、
var response = await client.GetAsync(uri); // このタスクを実行する人(スレッド)と
var text = await response.Content.ReadAsStringAsync(); // このタスクを実行する人が違ったとしても
return text; // 全く問題はない!

スレッドプール、IO待ち

Taskは非同期のことではありません。あくまで処理を「手順書」として記述したものであり、それの中身がなんであるかを気にする必要はないのです。例えば、同期処理をTask.Runでラップした場合、それは「スレッドプール上で動作する一連の処理」となります。Task.Delayは「指定したミリ秒後にタイマーを設定し、スレッドを解放」します。HttpClientなどのIO待ちは、イベント処理をTaskでラップしたものになります。このように、中身は全く異なる枠組みで動作していても、それらを全て「単なるタスク」として取り扱えること。これが、Taskの本質なのです。

見て覚えるasync/await

似たようだけどぜんぜん違うコードを並べてみましたので、見て、そして、理解して下さい。
わかりやすさのため、for文を使用しています。
この項では便宜的に、voidなメソッドのことを「アクション」と表現しています。その意図については後で説明します。

重い処理をエミュレートする同期メソッド

private void HeavyMethod(int x)
{
    Thread.Sleep(10 * (100 - x)); // てきとーに時間を潰す
    Console.WriteLine(x);
}

「HeavyMethodを同期的に10回実行する」普通のメソッド

public void RunHeavyMethodSync() // 比較のため、ただの同期メソッド
{
    for (var i = 0; i < 10; i++)
    {
        var x = i;
        HeavyMethod(x);
    }
}

「HeavyMethodを順次実行する」という一つのタスク

public async Task RunHeavyMethodAsync1()
{
    for (var i = 0; i < 10; i++)
    {
        var x = i;
        await Task.Run(() => HeavyMethod(x)); // 「HeavyMethodを実行する」というタスクを開始し、完了するまで待機
    } // を、10回繰り返す
} // というタスクを表す
// ので、これは順次動作であり、並列ではない。

「HeavyMethodを順次実行する」というアクション

public async void RunHeavyMethodAsync2() // RunHeavyMethodAsync1の戻り値がvoidになっただけ
{
    for (var i = 0; i < 10; i++)
    {
        var x = i;
        await Task.Run(() => HeavyMethod(x));
    }
} // 動作はRunHeavyMethodAsync1と同じだけど、HeavyMethodの実行がいつ完了するのか知ることができない。つらい。

「HeavyMethodを実行するタスクを10個開始する」というアクション

public void RunHeavyMethodParallel1() // asyncじゃない
{
    for (var i = 0; i < 10; i++)
    {
        var x = i;
        Task.Run(() => HeavyMethod(x)); // HeavyMethodを開始せよという命令
    } // を、10回繰り返すだけ
} // なので、これは並列動作になる。Task.Runが投げっぱなしなので、HeavyMethodの状態がわからなくてつらい。

「HeavyMethodを実行するタスクを10個開始し、その全てが完了した時に完了する」という一つのタスク

public Task RunHeavyMethodParallel2() // asyncじゃないけど、戻り値がTask
{
    var tasks = new List<Task>(); // TaskをまとめるListを作成
    for (var i = 0; i < 10; i++)
    {
        var x = i;
        var task = Task.Run(() => HeavyMethod(x)); // HeavyMethodを開始するというTask
        tasks.Add(task); // を、Listにまとめる
    }
    return Task.WhenAll(tasks); // 全てのTaskが完了した時に完了扱いになるたった一つのTaskを作成
} // 非同期メソッドではないが、戻り値がTaskなので、このメソッドは一つのタスクを表しているといえる。

以上です。違いが分かるでしょうか?

まず最初に、Taskをawaitした場合とawaitしない場合の違いがあります。
awaitするということは、「そのTaskが完了するまで待つ」ということなので、いわゆる同期実行的なフローになります。awaitしない場合は、その行で「(誰か)この仕事を開始して!」という命令を投げるだけに留まります。なので、そのタスクの実行中に自分は本来の仕事の続きをこなすことが出来るのです。並行ですね。そして、戻り値としてTaskを握ることで、その仕事の進行状況を把握することができるようになっています。

次に、戻り値がTaskの場合とvoidの場合です。
Taskを返す場合は、基本的にその手順書に書かれた仕事が全て完了したことを報告することになります。非同期メソッドで戻り値をTaskにした場合は、自動的に、そのメソッドがreturnしたときに完了するTaskになります。
そして、voidは、何も返しません。どういうことか?上司は「この仕事を開始して!」と命令したあと、命令したことを忘れます。そしてTaskを割り当てられたスレッドくんは、その仕事が完了しても報告する相手がいないわけです。マジヤバイ。

voidなメソッドを「アクション」と表現しましたが、これの意図は一つ。「それは『タスク』ではないから」。命令を投げるだけ投げっぱなしなのは、責任の所在を不明にする行為で、おおよそ推奨できるものではありません。その実害としては、voidで実行した非同期メソッド中で万が一例外が発生した場合など。その例外はコード上で見つけることが出来なくなり、それは時にアプリケーションを殺します。

async void なんてなかった

async void は 使用禁止!使用禁止!使用禁止!
上の話で書いたとおりですが、せっかく非同期メソッドがその仕事の進行状況を把握できるような設計になっているのに、わざわざそれを捨てるなんてとんでもない!
同期メソッドで言うvoidは、非同期メソッドでは戻り値Taskのことであり、同期メソッドにおける戻り値は、非同期メソッドにおける戻り値Task<T>のTになります。それ以外はないです。

では、何故async voidなんていうものが存在するのか?その理由はただ一つ。UIイベントハンドラーに非同期メソッドを登録するには、voidじゃないと無理だったから。
UIから直接実行されるイベントの受け皿メソッドにのみ、async voidを使うことが許されていると思って下さい。

Taskってむずかしい

ちょっとごちゃごちゃしてますが、ここまで実装とはなるべく無関係に内容を説明してきたつもりです。ここからはもう少し深掘りしていくので、読めなかったら適当に読み飛ばしてもいいですよーなのです。

Task.Runとは何なのか

Task.Runはお手軽にTaskを作れるファクトリメソッドですが、お手軽すぎるせいで、使う必要が全くない場面でも使われていることがとても多くてもにょもにょします。
Task.Runの概念は、一言で言えばこうです。

「同期的な一連の処理を、一つのタスクとみなす」

非同期タスクを組み合わせ、合成して、一つのタスクを組み上げるのが非同期メソッドだとしたら、その中に同期処理もタスクの一つとして組み込みたいこともあるわけです。その時に使えるのがTask.Runなのです。

もう一つ別の使い方に、Task.Runの中に非同期ラムダ式を書くパターンがあります。これは、単純にその非同期メソッドを呼び出しているようなものです。直接呼び出した場合と唯一異なるのが、「非同期メソッドを明確に別コンテキストで実行する」というものです。

これ以外の使い方をしているなら、それはTask.Runの濫用だと思っていいです。

Taskはスレッドの呪縛から解放された! ……?

上で書いたとおり、「Taskは単なる手順書であり、それぞれの処理をどのスレッドが実行したって結果は変わらない」というものです。よって、Taskはレガシープログラミング的なスレッド管理という概念から解放された非同期機構であり、特定のスレッドに依存する処理(UI操作など)は、本来非同期メソッド上で扱ってはいけないのです!……Taskの思想では、そうなるはずだったのです。

async/awaitはスレッドと切り離せなかった……

async/awaitの有名な使用例として、まず最初にUI処理が出てきます。UIはシングルスレッドなので、その貴重な資源を内部処理などで長時間専有するなんてとんでもない!というのはその通りで、まさにここが非同期処理が活躍する最大の見せ場でもありました。なので、非同期メソッドには「まるで同期処理のような書き方で非同期処理を行えて、自然にUI処理に組み込める」というものが期待されました。
よって、async/awaitは、「非同期メソッド内で何らかの処理をawaitした後、その続きはawaitする前と『同じスレッド上で』実行される」という設計になりました。
例えば、UIからボタン操作などで非同期メソッドを開始(UIスレッド上で動作)し、その途中で何かしらの内部処理を呼び出してawait(内部処理は別スレッドで動作し、UIスレッドは自由に)、内部処理が完了した後の続きは再びUIスレッドに戻って動作するので、そこでUIの更新などのスレッド依存処理を書いても問題なく動作させることができるのです。
これで、期待された「まるで同期処理のような書き方でUI処理」を実現することができたのです。これでみんなハッピー!

……本当に?

TaskをWaitしてはいけない

TaskをWaitしてはいけないと聞いたことはありますね?
どこの解説を読んでも、TaskをWaitすることについては一言二言書いてあったはずです。
理由はもちろんデッドロック。ちょっとWaitを呼んでみるだけで、予想よりもずっと簡単にデッドロックを発生させることができてしまいます。
例えば、

public void KusoMethod()
{
    KusoAsyncMethod().Wait(); // エターナル不応答しぬ
}

private async Task KusoAsyncMethod()
{
    await Task.Delay(1000);
}

はい。たったこれだけで、完全にデッドロックで死にます。ギルティです。

何故デッドロックが発生するのか

非同期メソッドは、awaitする内部タスクを開始した後、自分のスレッドを一旦解放します。そして、その内部タスクが完了したとき、処理の続きを「前と同じスレッドで」実行します。その間に、「前のスレッドを既に誰かが使っていたら?」「そして、そのスレッドが解放されるためには、Taskの実行が完了しないといけないとしたら?」はい。デッドロックです。
Waitじゃなくてもいいです。例えば、Taskが実行完了するまで廻り続けるwhileループとかでも余裕で発生します。Task<T>.Resultプロパティの参照もWaitと同様なので同じように死にます。

本来、Taskは好きなときに好きなようにWaitしても全く問題ないものだったはずなのです。しかし、async/awaitがスレッドのコンテキストを掴む関係で、Waitした際にデッドロックが発生する可能性が出てしまいました。非同期メソッドが本当の意味での「ただのタスク」であったならば、それがコンテキストを保存する必要は全くなかったのです。全ては、「UIスレッドのイベントハンドラをナチュラルに書く」というただ一つの用途のため。そのために、Waitは全面使用禁止となり、使おうとすれば我々は一目では見つけづらいデッドロックに怯えなければならない状況になってしまったのです。とてもつらい。

しかし!対策はあります!

ConfigureAwait(false)のススメ

特定のスレッドに戻りたくても戻れないために発生するデッドロックならば、「特定のスレッドに戻らなくても良い」ようにすればいいのではないでしょうか。そのためのメソッドが、ConfigureAwait(false)です。
引数のbool値は、「awaitする際にコンテキストを保存するか?」を指定します。つまり、既定値はtrueで、これをfalseにすることで、await後に戻る先のスレッドを指定しないようにできます。そうすれば、続きの実行は適当な空いているスレッドに割り当てられ、上のようなデッドロックが発生することはなくなります。

つまりこういうことです、

  • まずは、awaitする時は必ずConfigureAwait(false)を書け!
  • その上で、UI処理など、一連の処理を特定のスレッド上で走らせたい場合に限り、ConfigureAwait(false)を外せ!
  • なるべく多くのawaitにConfigureAwait(false)を付けるために、UI処理などはなるべく浅い層のメソッド上にまとめて書け!

という設計が、Taskを扱う場合のベストプラクティスになります。
超ぉめんどくさいですね。めんどくさいです。でも、やってください。これについては、async/awaitの最初の設計が悪かったとしか言えないです。
このコーディングを徹底することで、初めてデッドロック問題を回避することができます。

public void KusoMethod()
{
    NiceAsyncMethod().Wait(); // Waitしてもデッドロックしない!
}

private async Task NiceAsyncMethod()
{
    await Task.Delay(1000).ConfigureAwait(false);
}

しかし、ここでさらに問題が。上のコード例、NiceAsyncMeshodとKusoAsyncMethodの違いは、中のawaitにConfigureAwait(false)がついているかどうかだけです。ということは、この2つのメソッドのシグネチャを見比べても、その違いはわからないわけです。もし、このメソッドが外部ライブラリの中に書かれていたものだとしたら?そのメソッドは果たしてちゃんとConfigureAwait(false)されているのか?これは、もう、ソースを読まないとわからない、のですね。
そういうわけなので、ユーザーとしては、TaskのWaitは絶対使用禁止、必ず非同期メソッドでawaitを使うこと。となってしまうわけです。とても、残念です。

Taskのこれから

C# 7が絶賛開発中なのです!たのしいのです!
Visual Studio 2017のリリースに合わせて、C#のバージョン7がリリースされようとしています。
その目玉新機能の一つとして、「値型のTask」と、それに付随して「ユーザー定義のTask型」が搭載されます!
……これについては、ほぼパフォーマンス要件によるものなので、ユーザー目線で見ると、今までのTaskと全然変わらないし、逆に、変わってはいけないものです。完全にライブラリ制作者向けのものなので、まあ気にしなくていいです!!この記事を丸ごとすらすら読めるような人なら、調べてみたら面白いかもですよって感じです!簡単に言うと、Taskがインスタンス作りまくるからちょっとがべこれキツいんでスタック領域でなんとかならないですかね?っていう話と、値型のTask作るから自前でTask作れる枠組みをくれ!っていう話です。

まとめ

割とクソ長だこれ!

  • Taskとは、「非同期処理」のことではない
  • Taskはただの「タスク」であり、そのタスクを誰かが処理していくという構造なので、勝手に非同期になるだけ
  • 非同期メソッドは、Taskを組み合わせて作ったTask(作業手順書)である
  • Taskの意味さえわかってれば、仕組みがわからなくてもちゃんと使えるんだよ!
  • async void絶対使用禁止 (除く:UIイベントハンドラ)
  • そのTask.Run、本当に必要ですか?
  • TaskのWaitは本当にヤバイ
  • ConfigureAwait(false)つけような!
  • C# 7でValueTaskとかTask-likeとか出てくるけど、枠組みは変わらないから、まずは普通のTaskをマスターしよう!

async/await、Taskは既に完成した代物なので、これからも変わらず使われていくことになるでしょう。
ちゃんと理解してしまえば、つまづきどころも見えやすい仕組みなので、これを機に、非同期完全攻略、してみましょう?
いくらか問題点はあるとしても、それでもasync/awaitはとてもよくできた構文なので、これを使わない手はないです。そんなわけで、みんな非同期でハッピーになりましょう!


アドベントなんとかってやつに勢いで申し込んでみたんだけど、これ間に合った!?間に合ってないな!!ちょっと乱文具合がひどい気がする!ごめんなさい!

この投稿は C# Advent Calendar 20169日目の記事です。