.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メソッドの操作も自動的に行わせる事が出来ます。

ユニバーサル Windows プラットフォーム向けアプリ開発オンライン講座が開催されます

uwpmvp来週2015/6/10に、次期Windows 10で対応される「ユニバーサル Windows プラットフォーム」のためのアプリ開発オンライン講座が開催されます。

概要

世界最大級の IT 技術カンファレンス //Build 2015 で発表された Windows 10 の開発技術をわかりやすく解説

配信日時 | 6 月 10 日 (水) 20:00 ‐

アプリ開発者、ウェブ開発者向けに //Build 2015 で発表された「ユニバーサル Windows プラットフォーム」アプリケーションの開発概要について、Microsoft MVP を受賞したアプリケーション開発のスペシャリストが日本語で解説します。

  • これまでの開発と何か違うところはあるの?
  • Universal App って、具体的に何なの?
  • Windows as a service で提供される OS を見据えたアプリケーション開発者が押さえておくべきキーワードはどのようなもの?

当日はこうした開発者の知りたい「なぜ」に生放送でお答えする、ライブ Q & A 形式のセッションにてお届けします。ご自身のアイデアやソフトウェアを世界中の幾億ものユーザーに向けて提供するのに最適な、「新しい」プラットフォームをご紹介するこの機会をどうぞお見逃しなく!

スピーカー:初音 玲 さんと 酒井 達明 さん

登録はこちら


WindowsプラットフォームのスペシャリストとAzureのスペシャリストということで、凄く期待出来そうです(と、ハードルを上げておいたりw)。