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

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

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

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

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

もう一つ、現在、「非同期処理の必要性」のような記事を書いているのですが、まだ完成していないので、もうちょっとライトなやつを書いてさっさと公開した方が良い気がしてきました。

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

そういうわけで:

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

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

# 補足: F#erの人には説明は不要と理解しております :)
# コードはやや冗長に書いています。


1. 同期処理と非同期処理を対比させてみる

以下のコードは、ウェブサイトから「同期的に」データをダウンロードします。要するに、普通のコードです。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.Wait()も、Task.Resultも出ませんでしたね? これらを使う事はまずありません(断言)。

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


2. 非同期操作のルーツ

非同期操作が考案された由来、とかそういう話ではなく、「どこからが非同期処理として扱われるか」あるいは「どこからを非同期処理として扱うのか」、という話です:

// これは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とした場合、呼び出し元は非同期処理の結果を追跡できず、処理結果を感知しません。
  • 逆に考えると、Loadedイベントの場合、そもそもイベントの呼び出し元は非同期処理の結果など気にしていません(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#の非同期処理(Taskとasync-await)を使ってコードを書くなら、これらの事を知っていれば9割がたの問題は回避できる!!
  • ここで示した方法から外れたことをしようとしている場合は、殆ど間違いなく誤った記述をしようとしているので、もう一度何をしようとしているのか整理しよう!!

これまでの経験で、WPF・UWPなどのGUIアプリケーションで、ここで紹介した方法「以外の」方法がどうしても必要となるパターンは、「一度もありません」でした。Task.Wait()やTask.Resultなどを必要としたケースは、MVVMフレームワークの設計時に、安全な非同期処理を実現するための、ごく内部的なコアロジックを設計したときだけです。

これを読んで新たに疑問が湧いてくるかもしれません(例えば、どうしてデッドロックするのか、voidの非同期メソッドの結果はどうなるのか、など)。それは良い事です。そのような場合は、より深く解説した記事を読んでみてください。

それでは!

投稿者:

kekyo

Microsoft platform developers. C#, LINQ, F#, IL, metaprogramming, Windows Phone or like. Microsoft MVP for VS and DevTech. CSM, CSPO. http://amzn.to/1SeuUwD