次の方法で共有



2015 年 7 月

Volume 30 Number 7

非同期プログラミング - 非同期への変換

Stephen Cleary | 2015 年 7 月

Visual Studio Async CTP の発表当時は恵まれた環境で作業していました。2 つの比較的小さなグリーンフィールド アプリケーション (新規開発のアプリケーション) を 1 人で担当していて、async と await のメリットを十分に生かせていました。当時は筆者を含め MSDN フォーラムのさまざまなメンバーが非同期処理に関する複数のベスト プラクティスについて調査、議論、実装を行っていました。最も重要なベスト プラクティスについては、2013 年 3 月の MSDN マガジンの記事「非同期プログラミングのベスト プラクティス」(msdn.microsoft.com/magazine/jj991977) を参照してください。

既存のコード ベースに async や await を当てはめるとなると、まったくの別問題になります。こうしたブラウンフィールド アプリケーション (再開発するアプリケーション) は扱いにくく、シナリオが複雑化するおそれがあります。そこで、既存のコードを再開発して非同期にする際に便利だと考えられるテクニックをいくつか紹介します。コードを非同期にする場合、実際設計にまで影響が及ぶ可能性があります。既存のコードを複数の層に分離するためにリファクタリングが必要な場合は、非同期に変換する前に行っておくことをお勧めします。今回は説明の前提として、図 1 に示すようなアプリケーション アーキテクチャを使用しているものとします。

図 1 サービス層とビジネス ロジック層を備えたシンプルなコード構造

public interface IDataService
{
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

非同期を使用する場面

最も一般的なアプローチとしては、まず、アプリケーションが実際にどのような処理を行うかを考えます。入出力を頻繁に繰り返す操作は非同期にすべきですが、それ以外の場合はもっと優れた選択肢が存在することもあります。非同期にすることが最適ではないシナリオには、CPU を集中的に使用するコードやデータ ストリームなどがあります。

CPU を集中的に使用するコードの場合は、Parallel クラスや Parallel LINQ を検討します。操作の進行中に実際のコードがほとんど実行されないイベント ベースのシステムには非同期処理が適しています。CPU を集中的に使用するコードは、非同期メソッドの内部に含めたとしても同期処理として実行されることになります。

ただし、Task.Run の結果を待機することで、CPU を集中的に使用するコードを非同期処理のように扱うことができます。これは、CPU を集中的に使用する作業を UI スレッドに配置する優れた方法です。次のコードは、非同期コードと並列コードの間の橋渡しとして、Task.Run を使用する例を示しています。

await Task.Run(() => Parallel.ForEach(...));

非同期にすることが適していないもう 1 つのシナリオが、データ ストリームを扱うアプリケーションです。非同期操作には明確な開始と終了があります。たとえば、リソースのダウンロードはリソースが要求されたときに始まり、ダウンロードが完了すると操作は終了です。着信するデータがストリームやサブスクリプションになることが多い場合、非同期処理が最適なアプローチとはいえないかもしれません。たとえば、シリアル ポートに接続されたデバイスを考えてみてください。このポートにはおそらく常時サービスを提供する必要があります。

イベント ストリームに async/await を使用することは可能です。この場合、データが着信したらアプリケーションがデータを読み取るまでそのデータをバッファリングしておくために、なんらかのシステム リソースが必要になります。ソースがイベント サブスクリプションの場合は、Reactive Extensions (Rx) や TPL Dataflow を使用することを検討します。この方が、プレーンな非同期処理よりも自然な感じになります。Rx と Dataflow はどちらも、非同期コードと適切に相互運用されます。

非同期処理は、かなり多くのコードで最適なアプローチになりますが、すべてのコードに最適なアプローチになるとは限りません。ここからは、タスク並列ライブラリや Rx/Dataflow を検討した結果、async/await が最適なアプローチであるという結論に達したという想定で話を進めます。

同期コードから非同期コードへの変換

既存の同期コードを非同期コードに変換する場合には標準の手順があります。これは、かなりわかりやすい手順です。それでも何回か繰り返すうちに面倒だと感じるようになるかもしれません。本稿執筆時点では、同期処理から非同期処理への自動変換はサポートされていませんが、この種の自動コード変換が数年以内に導入されることを期待しています。

変換の手順は、低レベルの層からユーザー レベルへと順番に取り組むのがベストです。つまり、データベースや Web API にアクセスするデータ層のメソッドから非同期処理の導入を始めます。続いて、サービスのメソッド、ビジネス ロジック、ユーザー層の順に非同期処理を導入していきます。コードが適切に階層化されていなくても async/await に変換することはできますが、やや困難になります。

最初に、本来非同期にすべき低レベルの操作を変更の対象として特定します。I/O ベースの操作はすべて非同期操作の主要候補です。非同期操作の一般的な例は、データベースのクエリやコマンド、Web API 呼び出し、ファイル システム アクセスなどです。こうした低レベルの操作には、既に既存の非同期 API が用意されていることもよくあります。

基盤となるライブラリに非同期対応の API が用意されている場合は、同期メソッドの名前にサフィックスとして Async (または TaskAsync) を追加するだけです。たとえば、Entity Framework の First への 呼び出しは、FirstAsync への呼び出しに置き換えることができます。場合によっては、別の形式が必要になることもあります。たとえば、WebClient と HttpWebRequest は、非同期処理に適した HttpClient に置き換えることができます。場合によっては、ライブラリ バージョンのアップグレードが必要になることもあります。たとえば、Entity Framework の場合、非同期 API はバージョン 6 で導入されています。

図 1 のコードについて考えてみます。このコードは、サービス層とビジネス ロジック層を備えたシンプルな例です。この例には、WebDataService.Get で Web API から frob 識別子文字列を取得する低レベルの操作が 1 つだけあります。非同期への変換は、ここから始めるのが妥当です。今回の場合、開発者は、WebClient.DownloadString を WebClient.DownloadStringTaskAsync に置き換えるか、WebClient を非同期に適した HttpClient に置き換えるかを選択できます。

次に、同期 API の呼び出しを非同期 API の呼び出しに変更し、タスクから戻るのを待機します。コードから非同期メソッドを呼び出すときは、一般に、タスクから戻るのを待機します。この時点では、コンパイラでエラーが発生します。次のコードを使用すると、コンパイル エラーが発生し、「'await' 演算子は、非同期メソッド内でのみ使用できます。このメソッドに 'async' 修飾子を指定し、戻り値の型を 'Task<文字列>' に変更することを検討してください」というエラー メッセージが表示されます。

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

コンパイラは、次の手順に進むようガイドします。メソッドを async とマークし、戻り値の型を変更します。同期メソッドの戻り値の型が void 型の場合、非同期メソッドの戻り値の型は Task 型にする必要があります。それ以外の場合、戻り値の型が T 型の同期メソッドでは、非同期メソッドの戻り値の型を Task<T> にする必要があります。戻り値の型を Task/Task<T> に変更した場合、タスク ベースの非同期パターンのガイドラインに従って、Async で終わるメソッド名に変更することも必要です。次のコードは、非同期メソッドとして機能する最終的なメソッドを示します。

public sealed class WebDataService : IDataService
{
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

次に進む前に、このメソッドの残りの部分をチェックして、非同期にできる他のブロッキング呼び出しや同期 API 呼び出しを見つけます。非同期メソッドはブロックすべきではないため、このメソッドは、利用可能な場合に非同期 API を呼び出すべきです。このシンプルな例では、呼び出しをブロックするものは他にありません。実際のコードでは、再試行のロジックとオプティミスティックな競合解決に注意してください。

Entity Framework については特別な注意が必要です。理解しにくいのは、関連エンティティの遅延読み込みです。遅延読み込みは常に同期をとって実行されます。可能であれば、遅延読み込みではなく、明示的な非同期クエリを追加で使用します。

これでこのメソッドは完成です。次は、このメソッドを参照するすべてのメソッドに移動し、再度上記の手順に従います。今回の場合、WebDataService.Get はインターフェイス実装の一部なので、次に示すようにインターフェイスを変更して、非同期の実装を有効にしなければなりません。

public interface IDataService
{
  Task<string> GetAsync(int id);
}

続いて、呼び出し側のメソッドに移動して、同じ手順に従います。最終的には、図 2 に示すようなコードにします。残念ながら、呼び出し側のメソッドをすべて非同期処理に変換し、さらにそれを呼び出すメソッドすべてを非同期処理に変換し、という具合にすべてを非同期処理に変換するまで、コードはコンパイルされません。この非同期の連鎖的な性質は、コードを再開発する場合に厄介な側面です。

図 2 すべての呼び出し側メソッドを非同期処理に変換

public interface IDataService
{
  string Get(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
}

最終的には、コード ベースの非同期操作のレベルを、コード内の別のメソッドから呼び出されないメソッドに達するまで広げていきます。最上位レベルのメソッドは、どのフレームワークを使用していても直接呼び出されます。ASP.NET MVC などの一部のフレームワークでは、非同期コードが直接許可されます。たとえば、ASP.NET MVC のコントローラーのアクションは、Task 型または Task<T> 型を直接返すことができます。Windows Presentation Foundation (WPF) などのフレームワークでは、非同期のイベント ハンドラーが許可されます。たとえば、ボタンのクリック イベントは async void にすることができます。

限界への到達

アプリケーション全体に非同期コードのレベルを広げていくにつれて、それ以上続行できない限界に達することになります。最も一般的な例がオブジェクト指向の構造です。この構造は、機能から見た非同期コードの性質には適合しません。コンストラクター、イベント、プロパティには、それぞれ独自の課題があります。

一般に、こうした問題点を回避する最善の方法は、設計を見直すことです。よくある例の 1 つがコンストラクターです。同期コードの場合、コンストラクターのメソッドは入出力をブロックします。非同期環境の場合、コンストラクターの代わりに非同期ファクトリ メソッドを使用するのが 1 つの解決策です。プロパティでも同じことが起こります。プロパティが同期をとるために入出力をブロックする場合、そのプロパティはおそらくメソッドにすべきです。非同期に変換する場合、時間の経過と共にコード ベースに紛れ込むこの種の設計上の問題を明らかにしていくのが優れた手法です。

変換のヒント

非同期コードへの変換は、初めは尻込みするかもしれませんが、少し実践すればすぐに慣れます。同期コードから非同期への変換に慣れてきたら、変換処理の過程で使用できる、以下のヒントを試してみてください。

コードを変換するときに、同時実行の可能性があるか考えてみます。同時実行可能なコードを非同期に実行すると、同期をとって実行するよりも短くて簡単になることがよくあります。たとえば、REST API から 2 つの異なるリソースをダウンロードしなければならないメソッドがあるとします。このメソッドの同期バージョンでは、ほぼ間違いなく 1 つのリソースをダウンロードしてからもう 1 つのリソースをダウンロードすることになります。しかし、非同期バージョンでは、両方のダウンロードを簡単に開始し、Task.WhenAll を使用して両方の完了を非同期に待機できます。

他にもキャンセルについて考慮します。通常、同期アプリケーションの場合、ユーザーには待ち時間が生じます。新しいバージョンで UI の応答性を高める場合は、操作をキャンセルできるようにします。非同期コードでは、特別な理由がない限り、通常キャンセルをサポートすべきです。ほとんどの場合、CancellationToken 引数を受け取り、それを呼び出す非同期メソッドに渡すだけで、独自の非同期コードでキャンセルをサポートすることができます。

Thread または BackgroundWorker を使用するコードは、Task.Run を使用するように変換できます。Task.Run は、Thread や BackgroundWorker よりも構成がはるかに簡単です。たとえば、プリミティブなスレッド構造を使用するよりも、近代的な await や Task.Run を使用して、「2 つのバックグラウンド計算を開始して両方の計算が完了した時点で別の処理を行う」コードを記述するほうがずっと簡単です。

垂直分割

ここまで説明したアプローチは、アプリケーションの開発を 1 人で担当し、非同期への変換作業に影響する問題や要求がなければ、適切に機能します。しかしあまり現実的な話ではありません。

コード ベース全体を一括して非同期処理に変換するだけの時間がない場合は、変更を極めて少量に抑える垂直分割という手法を使って変換作業に取り組みます。このテクニックを使えば、コードの特定のセクションだけを非同期に変換することができます。垂直分割は、非同期コードを「手軽に試してみる」場合に便利です。

垂直分割を行うには、非同期にしようと考えているユーザーレベルのコードを特定します。たとえば、データベースへの保存を行う UI ボタンのイベント ハンドラー (UI の応答性を高い状態で維持するコード部分) や、頻繁に使用され同じ処理を行う ASP.NET 要求 (特定の要求に必要なリソースの削減を検討しているコード部分) などが候補です。コード全体を見直し、そのメソッドの呼び出しツリーのレイアウトを作成します。低レベルのメソッドから始めて、このツリーを遡るかたちで変換します。

こうした低レベルのメソッドは他のコードでも使用されていることは間違いありません。こうしたコードをすべてすぐに非同期に変換することはできないため、解決策としてはそのメソッドのコピーを作成します。その後、そのコピーを非同期に変換します。こうすれば、各段階で解決策を構築することができます。ユーザーレベルのコードまで変換作業を行ったら、アプリケーション内に非同期コードの垂直分割が作成されることになります。今回のサンプル コードを基盤とする垂直分割は、図 3 に示すようになります。

図 3 垂直分割を使用してコードのセクションを非同期に変換

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    using (var client = new WebClient())
      return client.DownloadString("http://www.example.com/api/values/" + id);
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  public string GetFrob()
  {
    // Try to get the new frob id.
    var result = _dataService.Get(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return _dataService.Get(13);
  }
  public async Task<string> GetFrobAsync()
  {
    // Try to get the new frob id.
    var result = await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetAsync(13);
  }
}

このソリューションのコードは、一部重複しているのがわかります。同期メソッドと非同期メソッドのすべてのロジックが重複していているため、適切な状態ではありません。垂直分割におけるコードの重複は、一時的な状態に限定します。重複するコードは、アプリケーションが完全に変換されるまでの間のみ、ソース管理に存在します。この時点で、使用しなくなった同期 API をすべて削除できます。

ただし、重複を取り除けない状況もあります。ライブラリを開発している場合 (社内で使用するライブラリに限定しても)、旧バージョンとの互換性が主な検討事項になります。かなり長い間、同期 API のメンテナンスが必要になる可能性があります。

この状況への対応策は 3 つあります。1 つ目の対応策として、非同期 API の導入を促します。ライブラリに非同期処理がある場合は、非同期 API を公開します。2 つ目の対応策として、旧バージョンとの互換性を確保するために、コードの重複を必要悪と考えて受け入れます。この対応策は、チームの統制がきちんととれているか、旧バージョンとの互換性の制約が一時的な場合にのみ受け入れ可能です。

3 つ目の対応策としては、今回概要を説明するいずれかの裏技を当てはめます。以下に説明する裏技はあまり推奨できませんが、いざというときに役立ちます。紹介する操作は本質的に非同期なので、以下で説明する各裏技は、本来非同期の性質を備える操作に同期 API を提供することを中心に据えています。これはアンチ パターンとして知られ、Server & Tools のブログ投稿 (bit.ly/1JDLmWD、英語) で詳しく取り上げられています。

ブロックの裏技

最もわかりやすいアプローチは、単純に非同期バージョンをブロックする方法です。Wait や Result ではなく、GetAwaiter().GetResult を使ってブロックすることをお勧めします。Wait や Result では、AggregateException 内にすべての例外をラップすることになります。そのため、エラー処理の複雑さが増します。サンプルのサービス層のコードでブロックの裏技を使用すると、図 4 に示すコードのようになります。

図 4 ブロックの裏技を使用するサービス層コード

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    // This code will not work as expected.
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

残念ながら、コメントに示されているとおり、このコードは実際には機能しません。前述の記事「非同期プログラミングのベスト プラクティス」に示されているように、よくあるデッドロックに陥ります。

これが、裏技には注意が必要な点です。通常の単体テストには合格しますが、同じコードを UI や ASP.NET のコンテキストから呼び出すとデッドロックに陥ります。ブロックの裏技を使用する場合は、こうした動作をチェックするための単体テストも記述します。図 5 に示すコードでは、AsyncEx ライブラリの AsyncContext 型を使用しています。これは、UI や ASP.NET のコンテキストと同様のコンテキストを作り出します。

図 5 AsyncContext 型の使用

[TestClass]
public class WebDataServiceUnitTests
{
  [TestMethod]
  public async Task GetAsync_RetrievesObject13()
  {
    var service = new WebDataService();
    var result = await service.GetAsync(13);
    Assert.AreEqual("frob", result);
  }
  [TestMethod]
  public void Get_RetrievesObject13()
  {
    AsyncContext.Run(() =>
    {
      var service = new WebDataService();
      var result = service.Get(13);
      Assert.AreEqual("frob", result);
    });
  }
}

非同期の単体テストには合格しますが、同期の単体テストは完了しません。これは昔からあるデッドロックの問題です。非同期コードは現在のコンテキストをキャプチャし、そのコンテキストで再開を試みますが、同期ラッパーはそのコンテキストのスレッドをブロックし、非同期操作の完了を妨げます。

上記の場合、非同期コードに ConfigureAwait(false) が欠落しています。ただし、WebClient を使用しても同じ問題が発生する可能性があります。WebClient は旧式のイベント ベースの非同期パターン (EAP) を使用します。このパターンは、常にコンテキストをキャプチャします。そのため、コードで ConfigureAwait(false) を使用しても、WebClient コードから同じデッドロックが発生する可能性があります。この場合、WebClient をより非同期に適した HttpClient に置き換えて、デスクトップで動作するようにします (図 6 参照)。

図 6 ConfigureAwait(false) と HttpClient を使用したデッドロックの防止

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return GetAsync(id).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new HttpClient())
      return await client.GetStringAsync(
      "http://www.example.com/api/values/" + id).ConfigureAwait(false);
  }
}

ブロックの裏技を使用する場合は、チームの統制がきちんととれている必要があります。メンバーの全員が、あらゆる場面で ConfigureAwait(false) を使用するようにします。また、すべての従属ライブラリが同じ規律に従うようにもしなければなりません。このようなことが不可能な場面もあります。本稿執筆時点では、HttpClient さえも一部のプラットフォームではコンテキストをキャプチャします。

ブロックの裏技のもう 1 つの弱点は、ConfigureAwait(false) を使用する必要があることです。非同期コードの実際の処理をキャプチャしたコンテキストで再開する必要がある場合、単純に ConfigureAwait(false) を使用できません。ブロックの裏技を使用する場合は、AsyncContext やその他同様のシングル スレッド コンテキストを使用して単体テストを実行し、デッドロックの可能性を特定することを強くお勧めします。

スレッド プールの裏技

ブロックの裏技に似たアプローチとして、非同期操作をスレッド プールにオフロードしてから、結果のタスクをブロックする方法があります。この裏技を使用するコードは、図 7 に示すコードのようになります。

図 7 スレッド プールの裏技のコード

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return Task.Run(() => GetAsync(id)).GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

Task.Run への呼び出しは、スレッド プールのスレッドで非同期メソッドを実行します。ここでは、実行にコンテキストが必要ないため、デッドロックは回避されます。このアプローチの問題点の 1 つは、コンテキストを指定して非同期メソッドを実行できないことです。そのため、UI 要素や ASP.NET の HttpContext.Current を使用できません。

もう 1 つ微妙な問題があります。非同期メソッドがスレッド プールのどのスレッドで再開されるかが決まっていないことです。このことは、ほとんどのコードで問題になりません。問題になるのは、メソッドがスレッド単位の状態を使用する場合や、UI コンテキストによってもたらされる同期を暗黙のうちに利用している場合などです。

バックグラウンド スレッド用にコンテキストを作成することができます。AsyncEx ライブラリの AsyncContext 型は、"メイン ループ" を備えるシングル スレッド コンテキストを導入します。これにより、非同期コードを同じスレッドで再開するように強制されます。その結果、スレッド プールの裏技の微妙な問題が回避されます。スレッド プールのスレッド用にメイン ループを使用するコード例は、図 8 に示すコードのようになります。

図 8 スレッド プールの裏技用メイン ループの使用

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    var task = Task.Run(() => AsyncContext.Run(() => GetAsync(id)));
    return task.GetAwaiter().GetResult();
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

もちろん、このアプローチにも弱点があります。スレッド プールのスレッドは、非同期メソッドが完了するまで AsyncContext 内でブロックされます。このようにブロックされたスレッドだけでなく、同期 API を呼び出しているプライマリ スレッドもプール内にあります。そのため、呼び出しの間、ブロックされているスレッドが 2 つになります。特に ASP.NET の場合、このアプローチはアプリケーションのスケーラビリティを大幅に低下させます。

フラグ引数の裏技

これは、これまでに使用したことがなかった裏技です。本稿技術レビュー担当の Stephen Toub が教えてくれた裏技です。これは優れたアプローチで、ここで紹介する裏技の中でもお気に入りの 1 つです。

フラグ引数の裏技は、オリジナルのメソッドを取り込み、それをプライベートにし、そのメソッドを同期実行するか非同期実行するかを示すフラグを追加するというものです。その後、同期メソッドと非同期メソッドを 2 つのパブリック API として公開します (図 9 参照)。

図 9 API を 2 つ公開するフラグ引数の裏技

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
}
public sealed class WebDataService : IDataService
{
  private async Task<string> GetCoreAsync(int id, bool sync)
  {
    using (var client = new WebClient())
    {
      return sync
        ? client.DownloadString("http://www.example.com/api/values/" + id)
        : await client.DownloadStringTaskAsync(
        "http://www.example.com/api/values/" + id);
    }
  }
  public string Get(int id)
  {
    return GetCoreAsync(id, sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetAsync(int id)
  {
    return GetCoreAsync(id, sync: false);
  }
}

上記の GetCoreAsync メソッドには重要な特徴が 1 つあります。つまり、メソッドの sync 引数が true の場合は、常に、タスクが完了するまで待機してから戻ります。このメソッドは、フラグ引数が同期動作を要求するときはブロックします。それ以外の場合は、通常の非同期メソッドであるかのように動作します。

同期処理に対応する Get ラッパーは、フラグ引数に true を渡して、操作の結果を取得します。タスクは既に完了しているため、デッドロックが発生する可能性はありません。ビジネス ロジックは同様のパターンに従います (図 10 参照)。

図 10 ビジネス ロジックに適用したフラグ引数の裏技

public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = sync
      ? _dataService.Get(17)
      : await _dataService.GetAsync(17);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return sync
      ? _dataService.Get(13)
      : await _dataService.GetAsync(13);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

サービス層から CoreAsync メソッドを公開するオプションもあります。そうすれば、ビジネス ロジックはシンプルになります。しかし、フラグ引数を受け取るメソッドを細部まで詳細に実装することになります。コードが明確になるメリットと、実装の詳細が公開されるデメリットを比較検討する必要があります (図 11 参照)。この裏技の長所は、基本的にメソッドのロジックを変えなくても済むことです。フラグ引数の値に基づいて異なる API を呼び出すだけです。この裏技は、同期 API と非同期 API が 1 対 1 に対応する場合にうまく機能します (通常は 1 対 1 に対応します)。

図 11 実装の詳細は公開されるが、明確なコード

public interface IDataService
{
  string Get(int id);
  Task<string> GetAsync(int id);
  Task<string> GetCoreAsync(int id, bool sync);
}
public sealed class BusinessLogic
{
  private readonly IDataService _dataService;
  public BusinessLogic(IDataService dataService)
  {
    _dataService = dataService;
  }
  private async Task<string> GetFrobCoreAsync(bool sync)
  {
    // Try to get the new frob id.
    var result = await _dataService.GetCoreAsync(17, sync);
    if (result != string.Empty)
      return result;
    // If the new one isn't defined, get the old one.
    return await _dataService.GetCoreAsync(13, sync);
  }
  public string GetFrob()
  {
    return GetFrobCoreAsync(sync: true).GetAwaiter().GetResult();
  }
  public Task<string> GetFrobAsync()
  {
    return GetFrobCoreAsync(sync: false);
  }
}

非同期のコード パスに同時実行を追加する場合や、1 対 1 に対応する非同期 API がない場合、この裏技は機能しないかもしれません。たとえば、WebDataService では WebClient よりも HttpClient を使用する方がお勧めですが、そのメリットと GetCoreAsync メソッドが複雑になってしまうデメリットを比較検討しなければなりません。

この裏技の主な弱点は、フラグ引数が著名なアンチ パターンであることです。ブール型のフラグ引数を使用すると、実際には 1 つのメソッドが 2 つの異なるメソッドの役割を果たすことがわかってしまいます。ただし、(CoreAsync メソッドを公開しない限り) アンチパターンは 1 つのクラスの実装の詳細内にとどめられ、最小限に抑えられます。このような弱点はありますが、お気に入りの裏技であることに変わりはありません。

入れ子になったメッセージ ループの裏技

最後の裏技は、あまりお勧めではありません。UI スレッド内に入れ子になったメッセージ ループを設定し、そのループ内で非同期コードを実行するというのが、この裏技の考え方です。このアプローチは、ASP.NET では利用できません。また、UI プラットフォームが変わると、異なるコードが必要になる可能性もあります。たとえば、WPF アプリケーションでは入れ子になったディスパッチャ フレームを使用するのに対し、Windows フォーム アプリケーションではループ内で DoEvents を使用します。非同期メソッドが特定の UI プラットフォームに依存しなければ、AsyncContext を使用して入れ子になったループを実行することもできます (図 12 参照)。

図 12 AsyncContext による入れ子になったメッセージの実行

public sealed class WebDataService : IDataService
{
  public string Get(int id)
  {
    return AsyncContext.Run(() => GetAsync(id));
  }
  public async Task<string> GetAsync(int id)
  {
    using (var client = new WebClient())
      return await client.DownloadStringTaskAsync(
      "http://www.example.com/api/values/" + id);
  }
}

このサンプル コードの簡潔さにごまかされないでください。この裏技は、再入性 (リエントラント) について考慮しなければならないため最も危険です。再入性については、コードで入れ子になったディスパッチャ フレームや DoEvents を使用する場合特に危険になります。このような場合は、UI 層全体で予期しない再入性に対処しなければならなくなります。再入に対応するアプリケーションには、かなり慎重な考察と計画が必要になります。

まとめ

同期から非同期へと比較的簡単なコード変換を行うだけで、すべてがうまく機能するのが理想です。現実には、同期コードと非同期コードを共存せざるを得ない状況になることがよくあります。非同期を手軽に試してみる場合は、非同期の使用に満足するまで、(コードの重複と) 垂直分割を作成します。旧バージョンとの互換性を確保するために同期コードを保持しなければならない場合は、コードの重複を許可するか、今回紹介した裏技を当てはめます。

いずれ、非同期操作を非同期 API だけで表現できるようになります。そのときまでは、現実の環境で努力する必要があります。今回紹介したテクニックから最も適切なものを選んで、既存のアプリケーションを非同期に実行できるようになればさいわいです。


Stephen Cleary はミシガン北部在住の夫、父親兼プログラマです。彼は、マルチスレッドと非同期プログラミングに 16 年間取り組み、最初の CTP から Microsoft .NET Framework の非同期サポートを使ってきました。彼のプロジェクトとブログ投稿については、stephencleary.com (英語) をご覧ください。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの James McCaffery と Stephen Toub に心より感謝いたします。