.NET タスクを WinRT 非同期処理として公開する
「WinRT と await を掘り下げる」というブログ記事では、C# と Visual Basic の新しい async キーワードと await キーワードについてや、それらのキーワードを使って Windows ランタイム (WinRT) 非同期処理を利用する方法について説明しました。
.NET 基本クラス ライブラリ (BCL) の助けを借りると、それらのキーワードを使って非同期処理を開発し、別の言語で構築された他のコンポーネントに WinRT をとおしてその処理を公開して利用することができます。この記事では、その方法について考えます (C# や Visual Basic を使って WinRT コンポーネントを実装する詳しい方法については、「C# と Visual Basic で Windows ランタイム コンポーネントを作成する」(英語) をご覧ください)。
まず、WinRT における非同期 API の形を見てみましょう。
WinRT 非同期インターフェイス
WinRT には、非同期処理に関連するいくつかのインターフェイスがあります。1 つ目は IAsyncInfo で、これはすべての有効な WinRT 非同期処理が実装します。このインターフェイスによって、処理の現在のステータス、失敗した場合は処理のエラー、処理の ID、キャンセルを要求する機能など、非同期処理の一般的な機能を利用することができます。
public interface IAsyncInfo { AsyncStatus Status { get; } Exception ErrorCode { get; } uint Id { get; } void Cancel(); void Close(); }
IAsyncInfo の実装に加えて、すべての有効な WinRT 非同期処理は、追加インターフェイスである IAsyncAction、IAsyncActionWithProgress<TProgress> 、IAsyncOperation<TResult> 、または IAsyncOperationWithProgress<TResult,TProgress> のいずれかを実装します。これらの追加インターフェイスを使うと、コンシューマーは非同期処理が完了したときに呼び出されるコールバックを設定することができ、オプションとして結果を取得したり、進捗状況の報告を受け取ることができるようになります。
// No results, no progress public interface IAsyncAction : IAsyncInfo { AsyncActionCompletedHandler Completed { get; set; } void GetResults(); } // No results, with progress public interface IAsyncActionWithProgress<TProgress> : IAsyncInfo { AsyncActionWithProgressCompletedHandler<TProgress> Completed { get; set; } AsyncActionProgressHandler<TProgress> Progress { get; set; } void GetResults(); } // With results, no progress public interface IAsyncOperation<TResult> : IAsyncInfo { AsyncOperationCompletedHandler<TResult> Completed { get; set; } TResult GetResults(); } // With results, with progress public interface IAsyncOperationWithProgress<TResult,TProgress> : IAsyncInfo { AsyncOperationWithProgressCompletedHandler<TResult,TProgress> Completed { get; set; } AsyncOperationProgressHandler<TResult,TProgress> Progress { get; set; } TResult GetResults(); }
(IAsyncAction は、結果が返されない場合でも GetResults メソッドを生成します。これによって、すべてのコンシューマーに IAsyncInfo の ErrorCode プロパティへのアクセスが強制されるのではなく、例外をスローするメソッドが失敗した非同期処理で生成されます。これは、GetResult メソッドが void を返すように型指定されている場合でも、C# と Visual Basic の awaiter 型が GetResult メソッドを公開するのと似ています)。
WinRT ライブラリを構築すると、そのライブラリの公開された非同期処理はすべて、これらの 4 つのインターフェイスのいずれかを返すように型指定されます。一方、.NET ライブラリから公開される新しい非同期処理はタスク ベースの非同期パターン (TAP (英語)) に従い、Task または Task<TResult> を返します。前者の処理では結果が返されず、後者の処理では結果が返されます。
Task と Task<TResult> はこれらの WinRT インターフェイスを実装せず、共通言語ランタイム (CLR) が違いを暗黙的に隠すこともありません (WinRT の Windows.Foundation.Uri 型や BCL の System.Uri 型など、一部の型に対しては行います)。代わりに、ある世界から別の世界へと明示的に変換する必要があります。「WinRT と await を掘り下げる」では、BCL の AsTask 拡張メソッドに用意された、WinRT 非同期インターフェイスから .NET タスクに変換するキャストに似た明示的なメカニズムについて説明しました。BCL では、.NET タスクから WinRT 非同期インターフェイスに変換するメソッドによって、もう一方の方向もサポートされます。
AsAsyncAction と AsAsyncOperation を使って変換する
このブログ記事の説明のため、.NET 非同期メソッド DownloadStringAsyncInternal があるとします。これを Web ページの URL に渡すと、メソッドによりそのページのコンテンツが非同期的にダウンロードされ、文字列として返されます。
internal static Task<string> DownloadStringAsyncInternal(string url);
このメソッドの実装方法は問題となりません。むしろ、目標はこれを WinRT 非同期処理としてラップし、前に述べた 4 つのインターフェイスのいずれかを返すメソッドとすることです。この処理では結果 (文字列) が返され、進捗状況の報告はサポートされないため、この WinRT 非同期処理では IAsyncOperation<string> が返されます。
public static IAsyncOperation<string> DownloadStringAsync(string url);
このメソッドを実装するには、DownloadStringAsyncInternal メソッドを呼び出し、結果として生成される Task<string> を取得します。次に、そのタスクを必要な IAsyncOperation<string> に変換する必要がありますが、今行うのでしょうか。
public static IAsyncOperation<string> DownloadStringAsync(string url) { Task<string> from = DownloadStringAsyncInternal(url); IAsyncOperation<string> to = ...; // TODO: how do we convert 'from'? return to; }
このすきまを埋めるため、.NET 4.5 の System.Runtime.WindowsRuntime.dll アセンブリには、Task と Task<TResult> 用の必要な変換を行う拡張メソッドが含まれています。
// in System.Runtime.WindowsRuntime.dll public static class WindowsRuntimeSystemExtensions { public static IAsyncAction AsAsyncAction( this Task source); public static IAsyncOperation<TResult> AsAsyncOperation<TResult>( this Task<TResult> source); ... }
これらのメソッドは、新しい IAsyncAction インスタンスまたは IAsyncOperation<TResult> インスタンスを返し、これらのインスタンスはそれぞれ、指定された Task または Task<TResult> をラップします (Task<TResult> は Task から派生したため、これらのメソッドはどちらも Task<TResult> に利用できますが、結果が返される非同期メソッドで AsAsyncAction を使うことは比較的まれです)。論理的には、これらの処理を明示的と同期キャストと考えることができます。または、デザイン パターンの観点からはアダプターと考えることができます。また、これらのメソッドは、基になるタスクを表すが WinRT の必要な表面を公開するインスタンスを返します。そのような拡張メソッドを使って、DownloadStringAsync 実装を終えることができます。
public static IAsyncOperation<string> DownloadStringAsync(string url) { Task<string> from = DownloadStringAsyncInternal(url); IAsyncOperation<string> to = from.AsAsyncOperation(); return to; }
これは、処理がキャストにかなり似ていることを強調しながらもっと簡潔に記述することもできます。
public static IAsyncOperation<string> DownloadStringAsync(string url) { return DownloadStringAsyncInternal(url).AsAsyncOperation(); }
DownloadStringAsyncInternal は、AsAsyncOperation を呼び出す前に呼び出されます。これは、DownloadStringAsync ラッパー メソッドが確実に応答するためには、DownloadStringAsyncInternal への非同期呼び出しを行ってすばやく返す必要があることを意味します。何らかの理由で実行する同期処理に時間がかかりすぎるのが心配な場合や、他の理由でスレッド プールへの呼び出しを明示的にオフロードする場合は、Task.Run を使ってこれを行った後、返されたタスクで AsAsyncOperation を呼び出すことができます。
public static IAsyncOperation<string> DownloadStringAsync(string url) { return Task.Run(()=>DownloadStringAsyncInternal(url)).AsAsyncOperation(); }
AsyncInfo.Run によって柔軟性を高める
これらの組み込み AsAsyncAction および AsAsyncOperation 拡張メソッドは、Task から IAsyncAction と、Task<TResult> から IAsyncOperation<TResult> への単純な変換に適しています。しかし、もっと高度な変換の場合はどうでしょうか。
System.Runtime.WindowsRuntime.dll には、柔軟性が高いもう 1 つの型である AsyncInfo が System.Runtime.InteropServices.WindowsRuntime 名前空間に用意されています。AsyncInfo は、静的な Run メソッドに 4 つのオーバーロード (4 つの各 WinRT 非同期インターフェイスに 1 つずつ) を公開します。
// in System.Runtime.WindowsRuntime.dll public static class AsyncInfo { // No results, no progress public static IAsyncAction Run( Func<CancellationToken, Task> taskProvider); // No results, with progress public static IAsyncActionWithProgress<TProgress> Run<TProgress>( Func<CancellationToken, IProgress<TProgress>, Task> taskProvider); // With results, no progress public static IAsyncOperation<TResult> Run<TResult>( Func<CancellationToken, Task<TResult>> taskProvider); // With results, with progress public static IAsyncOperationWithProgress<TResult, TProgress> Run<TResult, TProgress>( Func<CancellationToken, IProgress<TProgress>, Task<TResult>> taskProvider); }
ここまでで調べた AsAsyncAction メソッドと AsAsyncOperation メソッドは、Task を引数として受け入れます。一方、これらの Run メソッドは Task を返す関数デリゲートを受け入れます。Task と Func<…,Task> の間のこの違いによって、高度な処理を行うのに必要な柔軟性が実現します。
論理的には、AsAsyncAction と AsAsyncOperation を、高度な AsyncInfo.Run の上にある単純なヘルパーと考えることができます。
public static IAsyncAction AsAsyncAction( this Task source) { return AsyncInfo.Run(_ => source); } public static IAsyncOperation<TResult> AsAsyncOperation<TResult>( this Task<TResult> source) { return AsyncInfo.Run(_ => source); }
これは、.NET 4.5 で実装される正確な方法ではありませんが、機能的にはこのように動作するため、そのように考えると基本的なサポートと高度なサポートを対比しやすくなります。単純なケースの場合は AsAsyncAction と AsAsyncOperation を使いますが、AsyncInfo.Run が適した高度なケースもいくつかあります。
キャンセル
AsyncInfo.Run を使うと、WinRT 非同期メソッドによるキャンセルをサポートできます。
引き続きダウンロードの例を使って、CancellationToken を受け入れる別の DownloadStringAsyncInternal オーバーロードがあるとします。
internal static Task<string> DownloadStringAsyncInternal( string url, CancellationToken cancellationToken);
CancellationToken は、構成可能な方法で連携によるキャンセルをサポートする .NET Framework の型です。1 つのトークンを任意の数のメソッド呼び出しに渡すことができ、(トークンを作成した CancellationTokenSource をとおして) そのトークンがキャンセルを要求すると、利用する側のすべての処理からキャンセル要求が見えるようになります。このアプローチは、WinRT 非同期により使用されるアプローチ (個々の IAsyncInfo がそれぞれ Cancel メソッドを公開します) とはわずかに異なります。この点を踏まえると、IAsyncOperation<string> での Cancel への呼び出しが、DownloadStringAsyncInternal に渡された CancellationToken でキャンセルを要求するにはどうすればよいでしょうか。この場合、AsAsyncOperation は機能しません。
public static IAsyncOperation<string> DownloadStringAsync(string uri) { return DownloadStringAsyncInternal(uri, … /* what goes here?? */) .AsAsyncOperation(); }
IAsyncOperation<string> のキャンセルがいつ要求されるかを知るには、Cancel メソッドがキャンセルされたことをそのインスタンスが何らかの方法でリスナーに知らせる必要があります。たとえば、DownloadStringAsyncInternal に渡した CancellationToken のキャンセルを要求することによってです。ただし、これにはよく知られたジレンマがあります。既に DownloadStringAsyncInternal を呼び出していなければ AsAsyncOperation を呼び出す Task<string> を取得しません。しかし、その時点で、AsAsyncOperation によって生成されるまさに CancellationToken を指定する必要が生じます。
このジレンマを解決する方法は、AsyncInfo.Run によって採用されている解決方法など、いくつかあります。Run メソッドは IAsyncOperation<string> の作成に関与し、そのインスタンスを作成して CancellationToken のキャンセルを要求しますが、このトークンも非同期処理の Cancel メソッドが呼び出されたときに作成されます。次に、Run に渡されたユーザー指定のデリゲートを呼び出すと、このトークンに渡され、前に説明したサイクルが回避されます。
public static IAsyncOperation<string> DownloadStringAsync(string uri) { return AsyncInfo.Run(cancellationToken => DownloadStringAsyncInternal(uri, cancellationToken)); }
ラムダと匿名メソッド
AsyncInfo.Run は、ラムダ関数と匿名メソッドの使用を簡略化して WinRT 非同期メソッドを実装します。
たとえば、まだ DownloadStringAsyncInternal メソッドがない場合、実装すると DownloadStringAsync は次のようになります。
public static IAsyncOperation<string> DownloadStringAsync(string uri) { return AsyncInfo.Run(delegate(CancellationToken cancellationToken) { return DownloadStringAsyncInternal(uri, cancellationToken)); }); } private static async Task<string> DownloadStringAsyncInternal( string uri, CancellationToken cancellationToken) { var response = await new HttpClient().GetAsync( uri, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }
C# と Visual Basic による非同期匿名メソッドの記述のサポートを活用して、これらの 2 つのメソッドを 1 つに結合して実装を簡略化することができます。
public static IAsyncOperation<string> DownloadStringAsync(string uri) { return AsyncInfo.Run(async delegate(CancellationToken cancellationToken) { var response = await new HttpClient().GetAsync( uri, cancellationToken); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); }); }
進捗状況
AsyncInfo.Run では、WinRT 非同期メソッドによる進捗状況の報告もサポートされます。
IAsyncOperation<string> を返す DownloadStringAsync メソッドの代わりに、IAsyncOperationWithProgress<string,int> を返すように要求したとします。
public static IAsyncOperationWithProgress<string,int> DownloadStringAsync(string uri);
DownloadStringAsync は、積分データを含む進捗状況の最新情報を生成できるようになるため、コンシューマーはデリゲートをインターフェイスの Progress プロパティとして設定し、進捗状況の変化に関する通知を受け取ることができます。
AsyncInfo.Run には、Func<CancellationToken,IProgress<TProgress>,Task<TResult>> を受け入れるオーバーロードが用意されています。AsyncInfo.Run が、Cancel メソッドが呼び出されたときにキャンセルを要求する CancellationToken にデリゲートを渡すのと同じように、Report メソッドがコンシューマーの Progress デリゲートの呼び出しをトリガーする IProgress<TProgress> インスタンスにも渡すことができます。たとえば、前の例を変更して、最初に進捗状況 0% を報告し、応答が帰ってきたら進捗状況 50% を報告し、文字列への応答を解析したら進捗状況 100% を報告するようにした場合、次のようになります。
public static IAsyncOperationWithProgress<string,int> DownloadStringAsync(string uri) { return AsyncInfo.Run(async delegate( CancellationToken cancellationToken, IProgress<int> progress) { progress.Report(0); try { var response = await new HttpClient().GetAsync(uri, cancellationToken); progress.Report(50); response.EnsureSuccessStatusCode(); return await response.Content.ReadAsStringAsync(); } finally { progress.Report(100); } }); }
内部のしくみを見る
このすべてがどのように機能するかをよく理解するため、AsAsyncOperation と AsyncInfo.Run の実装について調べてみましょう。.NET 4.5 に存在する実装と同じではなく、堅牢でもありません。むしろ、内部のしくみをよく理解するための近似的なものであるため、運用環境での使用は意図されていません。
AsAsyncOperation
AsAsyncOperation メソッドは Task<TResult> を取得し、IAsyncOperation<TResult> を返します。
public static IAsyncOperation<TResult> AsAsyncOperation<TResult>( this Task<TResult> source);
このメソッドを実装するには、IAsyncOperation<TResult> を実装し、指定されたタスクをラップする型を作成する必要があります。
public static IAsyncOperation<TResult> AsAsyncOperation<TResult>( this Task<TResult> source) { return new TaskAsAsyncOperationAdapter<TResult>(source); } internal class TaskAsAsyncOperationAdapter<TResult> : IAsyncOperation<TResult> { private readonly Task<TResult> m_task; public TaskAsAsyncOperationAdapter(Task<TResult> task) { m_task = task; } ... }
この型におけるインターフェイス メソッドの各実装は、ラップされたタスクの機能のデリゲートとなります。まずは、最も簡単な要素を見てみましょう。
まず、IAsyncInfo.Close メソッドは、完了した非同期処理によって使用されたすべてのリソースを積極的にクリーンアップするためのものです。この例ではそのようなリソースがないため (オブジェクトはタスクをラップしているだけ)、実装は空です。
public void Close() { /* NOP */ }
IAsyncInfo.Cancel メソッドを使うと、非同期処理のコンシューマーはそのキャンセルを要求できます。これは純粋に要求であり、処理の終了を強制することは決してありません。要求が発生した CancellationTokenSource だけを使って格納します。
private readonly CancellationTokenSource m_canceler = new CancellationTokenSource(); public void Cancel() { m_canceler.Cancel(); }
IAsyncInfo.Status プロパティは AsyncStatus を返して、その非同期ライフサイクルに関する処理の現在のステータスを表します。これは、Started、Completed、Error、Canceled のいずれかの値になります。ほとんどの部分では、基になる Task の Status プロパティのデリゲートを作成し、返されたその TaskStatus を必要な AsyncStatus にマップすることができます。
マップ元の TaskStatus |
マップ先の AsyncStatus |
RanToCompletion |
Completed |
Faulted |
Error |
Canceled |
Canceled |
他のすべての値 (キャンセルが要求された) |
Canceled |
他のすべての値 (キャンセルが要求されなかった) |
Started |
Task がまだ完了しておらず、キャンセルが要求された場合、Started の代わりに Canceled を返す必要があります。これは、TaskStatus.Canceled は必ず終了状態ですが、AsyncStatus.Canceled は終了状態にも非終了状態にもなることを意味します。このため、IAsyncInfo は Canceled 状態で終了することができ、Canceled 状態の IAsyncInfo は AsyncStatus.Completed と AsyncStatus.Error のいずれかに遷移します。
public AsyncStatus Status { get { switch (m_task.Status) { case TaskStatus.RanToCompletion: return AsyncStatus.Completed; case TaskStatus.Faulted: return AsyncStatus.Error; case TaskStatus.Canceled: return AsyncStatus.Canceled; default: return m_canceler.IsCancellationRequested ? AsyncStatus.Canceled : AsyncStatus.Started; } } }
IAsyncInfo.Id プロパティは、処理の UInt32 識別子を返します。Task 自体が既にそのような識別子を公開しているため (Int32 として)、基になる Task プロパティのデリゲートを作成することでこのプロパティを簡単に実装することができます。
public uint Id { get { return (uint)m_task.Id; } }
WinRT は、HResult を返す IAsyncInfo.ErrorCode プロパティを定義します。しかし、CLR は内部で WinRT HResult を .NET Exception にマップするため、管理されたプロジェクションをとおして利用することができます。Task 自体は Exception プロパティを公開するため、デリゲートを作成するだけでかまいません。Task が Faulted 状態で終了した場合、その最初の例外を返します (Task は Task.WhenAll から Task が返されるなど、複数の例外が原因で失敗する可能性があります)。それ以外の場合は、null を返します。
public Exception ErrorCode { get { return m_task.IsFaulted ? m_task.Exception.InnerException : null; } }
IAsyncInfo の実装はこれで終わりです。次に、IAsyncOperation<TResult> により指定された 2 つの追加の要素 (GetResults と Completed) を実装する必要があります。
コンシューマーは、処理が正常に完了した後、または例外が発生した後、GetResults を呼び出します。前者の場合は計算結果が返され、後者の場合は関連する例外がスローされます。処理が Canceled で終了した場合や、まだ終了していない場合、GetResults を呼び出すことはできません。そのようなわけで、GetResults は次のように実装できます。タスクの awaiter の GetResult メソッドを利用しており、成功した場合は結果が返され、タスクが Faulted で終了した場合は適切な例外が伝播されます。
public TResult GetResults() { switch (m_task.Status) { case TaskStatus.RanToCompletion: case TaskStatus.Faulted: return m_task.GetAwaiter().GetResult(); default: throw new InvalidOperationException("Invalid GetResults call."); } }
最後に考えるのは Completed プロパティです。Completed は、処理が完了したときに呼び出す必要があるデリゲートを表しています。Completed が設定されたときに処理が既に完了している場合、指定されたデリゲートをすぐに呼び出すか、スケジュールする必要があります。さらに、コンシューマーはプロパティを 1 回だけ設定できます (複数回設定しようとすると、例外が発生します)。処理が完了すると、実装はメモリ リークを避けるためデリゲートへの参照を生成する必要があります。Task の ContinueWith メソッドを利用してこの動作の大部分を実装することができますが、ContinueWith は同じ Task インスタンス上で複数回使用されることがあるため、より制限の強い "1 回だけ設定" 動作を手動で実装する必要があります。
private AsyncOperationCompletedHandler<TResult> m_handler; private int m_handlerSet; public AsyncOperationCompletedHandler<TResult> Completed { get { return m_handler; } set { if (value == null) throw new ArgumentNullException("value"); if (Interlocked.CompareExchange(ref m_handlerSet, 1, 0) != 0) throw new InvalidOperationException("Handler already set."); m_handler = value; var sc = SynchronizationContext.Current; m_task.ContinueWith(delegate { var handler = m_handler; m_handler = null; if (sc == null) handler(this, this.Status); else sc.Post(delegate { handler(this, this.Status); }, null); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } }
このようにして、AsAsyncOperation の実装が完了します。どの Task<TResult> でも使うことができ、メソッドは IAsyncOperation<TResult> メソッドとして返されます。
AsyncInfo.Run
では、もっと高度な AsyncInfo.Run の場合はどうでしょうか。
public static IAsyncOperation<TResult> Run<TResult>( Func<CancellationToken,Task<TResult>> taskProvider);
この Run オーバーロードをサポートするには、上で作成したのと同じ TaskAsAsyncOperationAdapter<TResult> 型を使います (既存のどの実装でも使っていません)。実際、Task<TResult> の観点だけでなく、Func<CancellationToken,Task<TResult>> の観点で機能するように強化する必要があります。そのようなデリゲートが指定されたら、同期的に呼び出し、前の手順で定義した m_canceler CancellationTokenSource に渡して、返されたタスクを格納することができます。
internal class TaskAsAsyncOperationAdapter<TResult> : IAsyncOperation<TResult> { private readonly Task<TResult> m_task; public TaskAsAsyncOperationAdapter(Task<TResult> task) { m_task = task; } public TaskAsAsyncOperationAdapter( Func<CancellationToken,Task<TResult>> func) { m_task = func(m_canceler.Token); } ... } public static class AsyncInfo { public static IAsyncOperation<TResult> Run<TResult>( Func<CancellationToken, Task<TResult>> taskProvider) { return new TaskAsAsyncOperationAdapter<TResult>(taskProvider); } … }
この実装では、特殊なことは何も行われていないことが強調されています。AsAsyncAction、AsAsyncOperation、AsyncInfo.Run は、あくまでこのボイラープレートをすべて自分で記述しなくてもよいようにするヘルパー実装にすぎません。
まとめ
ここまでで、AsAsyncAction、AsAsyncOperation、AsyncInfo.Run が何を行うかに関してよく理解していただければさいわいです。Task または Task<TResult> を取得して IAsyncAction、IAsyncOperation<TResult> 、IAsyncActionWithProgress<TProgress> 、または IAsyncOperationWithProgress<TResult,TProgress> として公開することが簡単になります。C# と Visual Basic の async キーワードや await キーワードと組み合わせると、マネージ コードで新しい WinRT 非同期処理を実装するのがかなり簡単になります。
公開しようとしている機能が Task または Task<TResult> として利用可能であれば、WInRT 非同期インターフェイスを手動で実装しなくても、これらの組み込み機能を利用すると自動的に変換が行われます。公開しようとしている機能がまだ Task または Task<TResult> として利用可能でない場合は、まず Task または Task<TResult> として公開することを試みてから、組み込みの変換を利用してください。WinRT 非同期インターフェイス実装に関するセマンティクスをすべて正しく理解するのはかなり難しいため、自動的に処理してくれるこれらの変換が用意されています。C++ で WinRT 非同期処理を実装する場合は、Windows ランタイム ライブラリ (英語) で基本 AsyncBase クラスを使うか、並列パターン ライブラリ (英語) で create_async 関数を使うかにかかわらず、同様のサポートが用意されています。
非同期を楽しんでいただけると嬉しく思います。
--Stephen Toub (Visual Studio)