次の方法で共有


非同期プログラミング

非同期のパフォーマンス: 非同期と待機のコストについて

Stephen Toub

 

長い間、非同期プログラミングを扱えるのは、非常に熟練した自虐的な開発者、つまり、非線形の制御フローで繰り返されるコールバックについて考える時間、くせ、および精神的余裕がある開発者だけでした。このような開発者でなくても、Microsoft .NET Framework 4.5 を使えば C# と Visual Basic で非同期プログラムを作成できるようになります。すなわち、大多数の開発者が同期メソッドと同じように簡単に非同期メソッドを作成できるようになります。もうコールバックは必要ありません。同期コンテキストの遷移の中でコードのマーシャリングを明示的に行う必要はありません。結果や例外のフローを気にする必要はありません。また、非同期開発を容易にするために既存の言語機能を無理に捻じ曲げるテクニックも必要ありません。要するに、骨の折れる作業がなくなります。

もちろん、非同期メソッドの作成に簡単に着手できるようになっても (今月号の MSDN マガジンの Eric LippertMads Torgersen の記事を参考にしてください)、実際に適切に作成できるようになるには、内部のしくみを理解する必要があります。言語やフレームワークにおいて開発者がプログラミングできる抽象化のレベルが上がるたびに、常に潜在的なパフォーマンス コストも内包されます。多くの場合、このようなコストは問題になりません。大多数のシナリオでは、実装時にこのようなコストを無視する開発者がほとんどです。しかし、熟練の開発者は、後でこのようなコストが明らかになったときに必要な回避策を講じられるよう、どのようなコストが存在するか十分に理解しておく必要があります。このことは、C# と Visual Basic の非同期メソッド機能を作成する場合にも当てはまります。

この記事では、非同期メソッド全般について詳しく説明し、非同期メソッドの実装のしくみや、非同期メソッドに関連し、特別な意味合いを持つコストについて説明します。ただし、細かい最適化やパフォーマンスの改善を名目に、読みやすいコードをメンテナンスの難しいコードに無理に変更することを推奨するものではありません。非同期メソッドに関して発生する可能性がある問題の診断に役立つ情報を提供し、このような問題の解決に役立つ一連のツールを紹介することだけが目的です。また、この記事は、Microsoft .NET Framework 4.5 のプレビュー リリースに基づいているため、最終リリースまでに具体的な実装の詳細が変更される場合があります。

正しいメンタル モデルを確立する

この数十年間、開発者は C#、Visual Basic、F#、C++ などの高水準言語を使用して効率の良いアプリケーションを開発してきました。高水準言語を使用する開発者は、この経験からさまざまな操作に関連するコストについての情報を得、この情報から開発のベスト プラクティスを特定してきました。たとえば、ほとんどのユース ケースでは、同期メソッドを呼び出すとコストが比較的低くなり、コンパイラが呼び出し相手をその場所でインライン化してコード量を減らすことができればさらにコストを下がります。そのため、開発者はコードを小さく、メンテナンスが容易なメソッドにリファクタリングすることを学びます。一般にはメソッドの呼び出し回数が増えてもそれによる悪影響を考慮する必要はありません。このような開発者は、メソッドを呼び出す意味についてのメンタル モデルを持っています。

非同期メソッドを導入する場合は、新しいメンタル モデルが必要です。C# と Visual Basic の言語とコンパイラは、非同期メソッドを同期メソッドと同じように見せかけることができますが、内部ではこの 2 つのメソッドはまったく異なります。コンパイラが開発者の代わりに大量のコードを生成します。このコード量は、非同期機能を実装する開発者が以前に手動で作成してメンテナンスを行う必要があった定型コードの量に相当します。しかも、コンパイラが生成するコードは .NET Framework のライブラリ コードを呼び出すので、開発者の代わりに実行される処理はさらに増えます。正しいメンタル モデルを確立し、そのメンタル モデルを使用して開発について適切な決定を下すには、コンパイラが生成するコードを理解することが重要です。

細部ではなく全体から考える

同期コードを扱うときは、本体が空のメソッドは事実上無意味です。非同期メソッドではそうとも言えません。本体にステートメントが 1 つある、次の非同期メソッドを考えてみましょう (待機の処理がないため、このメソッドは同期をとって実行されることになります)。

public static async Task SimpleBodyAsync() {
  Console.WriteLine("Hello, Async World!");
}

中間言語 (IL) デコンパイラを使用すると、図 1 のような出力が生成され、コンパイル後のこの関数の本質が明らかになります。単純な 1 行のコードが 2 つのメソッドに拡張されています。一方のメソッドはヘルパー ステート マシン クラスに存在します。まず、開発者が作成したメソッドと同じ基本シグネチャを持つスタブ メソッドがあります (メソッドの名前、可視性、受け取るパラメーター、戻り値の型は同じです)。しかし、このスタブには、開発者が記述したコードがまったく含まれていません。代わりに、セットアップの定型コードが含まれています。セットアップ コードでは、非同期メソッドの表記に使用するステート マシンを初期化し、ステート マシンに存在する 2 つ目の MoveNext メソッドへの呼び出しを使用してステート マシンを開始します。このステート マシン型では、非同期メソッドの状態を保持することで、非同期待機ポイントから次のポイントまでの間で必要に応じて状態を維持できるようにします。また、開発者が記述したとおりのメソッド本文もステート マシン型に含まれていますが、結果と例外を戻り値の Task に変換したり、メソッドの現在位置を保持することで待機後にその位置から実行を再開したりできるように変更されています。

図 1 非同期メソッドの定型コード

[DebuggerStepThrough]     
public static Task SimpleBodyAsync() {
  <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
  d__.<>t__builder = AsyncTaskMethodBuilder.Create();
  d__.MoveNext();
  return d__.<>t__builder.Task;
}
 
[CompilerGenerated]
[StructLayout(LayoutKind.Sequential)]
private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine {
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;
 
  public void MoveNext() {
    try {
      if (this.<>1__state == -1) return;
      Console.WriteLine("Hello, Async World!");
    }
    catch (Exception e) {
      this.<>1__state = -1;
      this.<>t__builder.SetException(e);
      return;
    }
 
    this.<>1__state = -1;
    this.<>t__builder.SetResult();
  }
 
  ...
}

非同期メソッド呼び出しで発生するコストについて考える際は、この定型コードに留意します。MoveNext メソッドに try/catch ブロックがあることから、ほとんどの場合は Just-In-Time (JIT) コンパイラがメソッドをインライン化できないため、メソッド呼び出しに、少なくとも (メソッド本文が小さい) 同期メソッドの場合はあまり発生しなかったようなコストが発生します。.NET Framework のルーチン (SetResult など) を複数回呼び出し、ステート マシン型のフィールドに複数回書き込みます。もちろん、このコストは Console.WriteLine で発生するコストと比較して検討する必要があり、Console.WriteLine のコストは、関連するほかのすべてのコスト (ロックの取得、I/O の実行など) を上回ります。さらに、インフラストラクチャによって最適化が施されることに注意してください。たとえば、ステート マシン型は構造体です。このメソッドは完了していないインスタンスを待機しているため、この構造体がヒープ領域にボックス化されるのは、メソッドの実行を中断する必要がある場合だけです。しかも、この単純なメソッドでは、インスタンスが完了しません。したがって、この非同期メソッドの定型コードでは、ヒープの割り当ては行われません。コンパイラとランタイムが効果的に連携することで、インフラストラクチャに関する割り当ての回数を最小限に抑えています。

非同期処理の使用が適していない場合を把握する

.NET Framework は複数の最適化を行って、非同期メソッドの効率的な非同期実装の生成を試みます。しかし、コンパイラやランタイムが対象とする汎用性を考慮すると、多くの開発者が最適化を生み出せると考えている専門知識を、コンパイラやランタイムが行う最適化に自動的に当てはめて考えることは危険かつ軽率です。そのため、一部の限られたユース ケースでは、非同期メソッドを使用しない方が開発者にメリットがあり、特によりきめ細かい方法でアクセスするライブラリ メソッドの場合は顕著です。通常、このユース ケースに該当するのは、メソッドで使用するデータが既に使用可能なために同期をとってメソッドを完了できることがわかっている場合です。

以前の .NET Framework 開発者は、非同期メソッドの構築時に長い時間を費やして、オブジェクトの割り当て数を削減するよう最適化していました。これは、非同期メソッド インフラストラクチャで発生する大きなパフォーマンス コストの 1 つがオブジェクトの割り当てであるためです。オブジェクトの割り当ては、通常はかなりコストが低い処理です。オブジェクトの割り当ては、ショッピングカートに商品を入れる動作に似ています。ショッピング カートに商品を入れること自体はあまり労力は要らず、大量のリソースを費やす必要があるのは実際に財布を取り出して精算するときになってからであるという点です。通常、割り当て自体のコストは低いのですが、割り当て後に必要になるガベージ コレクションの時点でアプリケーションのパフォーマンスが大きく低下することがあります。ガベージ コレクションでは、その処理の一環として、現在割り当てられているオブジェクトの一部をスキャンして、参照されなくなったオブジェクトを検索します。割り当て済みのオブジェクトが増えるほど、参照されなくなったオブジェクトのマークにかかる時間が長くなります。さらに、割り当て済みのオブジェクトのサイズが大きくなり、数が増えるほど、ガベージ コレクショが必要になる頻度が高まります。このように、割り当てはシステム全体に影響を及ぼします。非同期メソッド自体のマイクロ ベンチマークでは高いコストが計測されなくても、非同期メソッドが生み出すガベージが多くなるほど、プログラム全体の実行速度が低下します。

(完了していないオブジェクトを待機するために) 実行を明け渡す非同期メソッドの場合、非同期メソッド インフラストラクチャは、メソッドから返す Task オブジェクトを割り当てる必要があります。これは、Task オブジェクトがこの呼び出しに対する一意の参照として機能するためです。しかし、多くの非同期メソッドの呼び出しは処理を明け渡すことなく完了できます。このような場合、非同期メソッド インフラストラクチャは、キャッシュされ、既に完了した Task オブジェクトを返すことができ、このオブジェクトを繰り返し使用して、不要な Task の割り当てを回避できます。ただし、このようなオブジェクトを使用できる状況は限られています。たとえば、非同期メソッドが非ジェネリック Task または Task<Boolean> の場合、あるいは Task<TResult> (TResult は参照型) で非同期メソッドの結果が null の場合にだけ使用できます。将来はより幅広い状況で使用できるようになる可能性もありますが、多くの場合は、実装対象のオペレーティング システムに関する専門知識があると役立ちます。

MemoryStream のような型を実装する場合を考えてみましょう。MemoryStream は Stream から派生しているため、.NET Framework 4.5 で Stream に追加された ReadAsync メソッド、WriteAsync メソッド、および FlushAsync メソッドをオーバーライドして、MemoryStream の性質に合わせて最適化した実装を用意できます。読み取り操作では、メモリ内のバッファーを参照するだけ、つまりメモリをコピーするだけなので、ReadAsync メソッドは同期をとって実行した方がパフォーマンスが向上します。この操作を非同期メソッドとして実装すると、次のようになります。

public override async Task<int> ReadAsync(
  byte [] buffer, int offset, int count,
  CancellationToken cancellationToken)
{
  cancellationToken.ThrowIfCancellationRequested();
  return this.Read(buffer, offset, count);
}

非常に簡単ですね。Read メソッドは同期呼び出しであり、制御を明け渡す待機処理がこのメソッドにないので、ReadAsync メソッドの呼び出しは、実際にはすべて同期をとって完了します。今度は、コピー操作など、ストリームの標準使用パターンについて考えてみましょう。

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  await source.WriteAsync(buffer, 0, numRead);
}

ここでは、必ず同じカウント パラメーター (バッファーの長さ) を使用してこの一連の呼び出しに対してコピー元ストリームの ReadAsync メソッドを呼び出すため、ほとんどの場合は、戻り値 (読み取られるバイト数) も同じ値の繰り返しになります。まれな状況を除けば、ReadAsync の非同期メソッド実装で、キャッシュされた Task を戻り値に使用できることはほとんどありませんが、手段はあります。

このメソッドを図 2 のように書き直すことを考えてみましょう。このメソッド固有の性質とメソッドの一般的な使用シナリオを考慮すると、基盤となるインフラストラクチャによる最適化では考えられなかったような方法で、共通パスから割り当てを削減するよう最適化できます。この方法では、ReadAsync メソッドを呼び出すたびに前回の ReadAsync の呼び出しと同じバイト数分を取得するので、前回の呼び出しで返した Task を返せば、ReadAsync メソッドで発生する割り当てのオーバーヘッドを完全に回避できます。また、非常に高速で繰り返し呼び出すと予想される上記のコードのような低レベルの操作では、このように最適化すると、特にガベージ コレクションの発生回数に関して顕著な違いが生まれることがあります。

図 2 タスク割り当ての最適化

private Task<int> m_lastTask;
 
public override Task<int> ReadAsync(
  byte [] buffer, int offset, int count,
  CancellationToken cancellationToken)
{
  if (cancellationToken.IsCancellationRequested) {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetCanceled();
    return tcs.Task;
  }
 
  try {
      int numRead = this.Read(buffer, offset, count);
      return m_lastTask != null && numRead == m_lastTask.Result ?
        m_lastTask : (m_lastTask = Task.FromResult(numRead));
  }
  catch(Exception e) {
    var tcs = new TaskCompletionSource<int>();
    tcs.SetException(e);
    return tcs.Task;
  }
}

シナリオでキャッシュの使用が義務付けられている場合、関連する最適化によってタスクの割り当てを回避できることがあります。特定の Web ページのコンテンツをダウンロードし、ダウンロードに成功したコンテンツを後でアクセスするためにキャッシュすることを目的としたメソッドについて、考えてみましょう。このような機能は、非同期メソッドを使用して、次のように記述できます (.NET Framework 4.5 の新しい System.Net.Http.dll ライブラリを使用します)。

private static ConcurrentDictionary<string,string> s_urlToContents;
 
public static async Task<string> GetContentsAsync(string url)
{
  string contents;
  if (!s_urlToContents.TryGetValue(url, out contents))
  {
    var response = await new HttpClient().GetAsync(url);
    contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
    s_urlToContents.TryAdd(url, contents);
  }
  return contents;
}

この実装は簡単です。GetContentsAsync メソッドの呼び出しでキャッシュから取得できない場合、このダウンロードを表す新しい Task<string> の作成にかかるオーバーヘッドは、ネットワーク関連コストに比べれば無視できます。ただし、キャッシュからコンテンツを取得できれば、無視できないコストは、単に使用できるデータをラップして返すオブジェクトの割り当てだけです。

このコストを回避するには (パフォーマンスの目標を達成するために回避する必要がある場合)、図 3 のようにこのメソッドを書き直すことができます。変更後のコードには、同期パブリック メソッドと、パブリック メソッドからデリゲートする非同期プライベート メソッドの 2 つが含まれています。ディクショナリは、生成したタスクをコンテンツの代わりにキャッシュすることになるため、既にダウンロードに成功したページを再度ダウンロードする場合は、ディクショナリにアクセスして既存のタスクを返すだけで済みます。メソッド内部では、Task の ContinueWith メソッドも使用して、Task の完了後に (ダウンロードに成功している場合にのみ) タスクの情報をディクショナリに格納できるようにしています。もちろん、このコードの方が複雑で、作成と管理についての考慮事項も増加します。そのため、あらゆるパフォーマンスの最適化と同様に、この複雑なコードに顕著で不可欠な効果があることをパフォーマンス テストで確認するまで、このようなコードの作成に時間をかけないでください。このような最適化に効果があるかどうかは、使用シナリオによって決まります。一般的な使用パターンを表すテスト スイートを作成し、このようなテストの分析を使用して、複雑なコードによってコードのパフォーマンスが顕著に向上するかどうか判断することをお勧めします。

図 3 タスクの手動キャッシュ

private static ConcurrentDictionary<string,Task<string>> s_urlToContents;
 
public static Task<string> GetContentsAsync(string url) {
  Task<string> contents;
  if (!s_urlToContents.TryGetValue(url, out contents)) {
      contents = GetContentsAsync(url);
      contents.ContinueWith(delegate {
        s_urlToContents.TryAdd(url, contents);
      }, CancellationToken.None,
        TaskContinuationOptions.OnlyOnRanToCompletion |
          TaskContinuatOptions.ExecuteSynchronously,
        TaskScheduler.Default);
  }
  return contents;
}
 
private static async Task<string> GetContentsAsync(string url) {
  var response = await new HttpClient().GetAsync(url);
  return response.EnsureSuccessStatusCode().Content.ReadAsString();
}

タスク関連の最適化に関しては、非同期メソッドから返される Task オブジェクトがそもそも必要かどうかについても考慮する必要があります。C# と Visual Basic の両方で、void を返す非同期メソッドの作成がサポートされています。void を返す場合、メソッド用に Task を割り当てることはありません。ライブラリ開発者には、コンシューマーがそのメソッドの完了を待機するかどうかわからないため、ライブラリからパブリックに公開する非同期メソッドは、必ず Task または Task<TResult> を返すように作成する必要があります。しかし、内部での一部の使用シナリオでは、void を返す非同期メソッドに存在意義があります。void を返す非同期メソッドの主な存在理由は、ASP.NET や Windows Presentation Foundation (WPF) など、既存のイベント駆動型環境をサポートするためです。void を返す非同期メソッドにより、非同期と待機を使用してボタン ハンドラーやページ読み込みイベントを簡単に実装できるようになります。非同期 void メソッドの使用を真剣に検討している場合は、例外処理に細心の注意を払ってください。これは、非同期 void メソッドでハンドルされない例外は、非同期 void メソッドの呼び出し時に使用されていた任意の SynchronizationContext にバブルアウトされるためです。

コンテキストに注意する

.NET Framework には、LogicalCallContext、SynchronizationContext、HostExecutionContext、SecurityContext、ExecutionContext など、さまざまな "コンテキスト" があります (これほど種類が膨大だと、.NET Framework 開発者には新しいコンテキストを導入するよう財政的支援がありそうだと期待されるでしょうが、そのような支援がないことは明言しておきます)。これらのコンテキストの一部は、機能面だけでなく非同期メソッドのパフォーマンス面に関しても、非同期メソッドと深い関連があります。

SynchronizationContext: SynchronizationContext は、非同期メソッドで重要な役割を果たします。"同期コンテキスト" とは、単に、特定のライブラリやフレームワーク固有の方法で、デリゲートの呼び出しのマーシャリング機能を抽象化したものです。たとえば、WPF には、Dispatcher オブジェクトの UI スレッドを表す DispatcherSynchronizationContext が用意されています。この同期コンテキストにデリゲートをポストすると、そのスレッドの Dispatcher で実行するようポストされたデリゲートがキューに入れられます。ASP.NET には AspNetSynchronizationContext が用意され、この同期コンテキストを使用すると、ASP.NET 要求の一環として発生する非同期操作が順次実行され、適切な HttpContext の状態に関連付けられます。これらはほんの一例にすぎません。.NET Framework には、パブリック コンテキストと内部コンテキストを含め、合計で約 10 種類の具体的な SynchronizationContext があります。

.NET Framework で提供されている待機可能な Task などの型を待機する場合、これらの型の "待機側" (TaskAwaiter など) では、待機処理の発行時に最新の SynchronizationContext をキャプチャします。待機可能な型の完了時に、キャプチャされた最新の SynchronizationContext があるときは、非同期メソッドの残りの処理の続行がその SynchronizationContext にポストされます。このしくみにより、UI スレッドから呼び出される非同期メソッドを作成する開発者は、UI コントロールを変更するために手動で呼び出しを元の UI スレッドにマーシャリングする必要がありません。このようなマーシャリングは .NET Framework インフラストラクチャによって自動的に実行されます。

残念ながら、自動マーシャリングにはコストがかかります。待機を使用して制御フローを実装するアプリケーション開発者の場合、この自動マーシャリングはほぼ確実に適切なソリューションです。しかし、ライブラリの場合は事情が異なります。アプリケーション開発者が自動マーシャリングを必要とする一般的な理由は、コードを実行するコンテキストに注意する必要があるためです (UI コントロールにアクセスできる、適切な ASP.NET 要求の HttpContext にアクセスできるなど)。これに対し、ほとんどのライブラリはこの制約を受けません。そのため、この自動マーシャリングは、まったく必要ないコストになることがよくあります。先ほどのストリーム間でデータをコピーするコードについて、もう一度考えてみましょう。

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  await source.WriteAsync(buffer, 0, numRead);
}

このコピー操作を UI スレッドから呼び出す場合、待機しているすべての読み取り操作と書き込み操作は、完了を UI スレッドに強制的に返します。非同期に読み取りと書き込みを完了するソース データと Stream が 1 MB ある場合 (ほとんどのデータが該当します)、バックグラウンド スレッドから UI スレッドに 500 回以上ホップすることになります。この問題に対処するため、Task 型と Task<TResult> 型には、ConfigureAwait メソッドが用意されています。ConfigureAwait メソッドは、このマーシャリング動作を制御するブール型の continueOnCapturedContext パラメーターを受け取ります。既定値の true を使用すると、待機処理は、キャプチャ済みの SynchronizationContext に完了を自動的に返します。一方、false を使用すると、SynchronizationContext が無視され、前に完了した非同期操作に関係なく、.NET Framework によって実行の続行が試みられます。このメソッドをストリームのコピー用コードと組み合わせると、次のようにより効果的なコードになります。

byte [] buffer = new byte[0x1000];
int numRead;
while((numRead = await
  source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {
  await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
}

ライブラリ開発者の場合、パフォーマンスにこのような影響があることだけでも、ConfigureAwait メソッドを必ず使用する十分な理由になります。ただし、まれに、ライブラリの環境に関する専門的な情報があり、正しいコンテキストにアクセスできる状態でメソッドの本体を実行する必要がある状況は例外です。

パフォーマンス以外にも、ライブラリ コードで ConfigureAwait メソッドを使用する理由はあります。次のように、前述の ConfigureAwait メソッドを使用しないコードが、CopyStreamToStreamAsync という WPF の UI スレッドから呼び出されたメソッドに含まれていたとしましょう。

private void button1_Click(object sender, EventArgs args) {
  Stream src = …, dst = …;
  Task t = CopyStreamToStreamAsync(src, dst);
  t.Wait(); // deadlock!
}

このコードでは、正しくは開発者は button1_Click を非同期メソッドとして作成し、Task の同期 Wait メソッドを使用せずに Task を非同期に待機する必要がありました。Wait メソッドには重要な用途がありますが、ほとんどの場合このような UI スレッドで待機するために使用するのは不適切です。Wait メソッドは、Task が完了するまで結果を返しません。CopyStreamToStreamAsync メソッドの場合、メソッド内部の各待機処理で Post を使用して、キャプチャされた SynchronizationContext にポストバックしようとするので、すべての Post が完了するまでメソッドが完了しません (メソッドの残りの処理に Post を使用するため)。しかし、Post を処理する UI スレッドが Wait の呼び出しでブロックされているので、これらの Post は完了しません。これが、デッドロックの原因となる循環依存関係です。ConfigureAwait(false) を使用して CopyStreamToStreamAsync を記述していれば、循環依存関係もデッドロックも発生しなかったはずです。

ExecutionContext: ExecutionContext は、.NET Framework に不可欠な機能ですが、ほとんどの開発者はその存在を意識することがありません。ExecutionContext は各種コンテキストの祖先に当たり、SecurityContext や LogicalCallContext などほかの複数のコンテキストをカプセル化し、コード内の非同期ポイント間で自動的に受け渡す必要があるすべての項目を表します。これまで、ThreadPool.QueueUserWorkItem、Task.Run、Delegate.BeginInvoke、Stream.BeginRead、WebClient.DownloadStringAsync など、.NET Framework の非同期操作を開発者が使用するたびに、内部では、可能であれば (ExecutionContext.Capture で) ExecutionContext をキャプチャし、キャプチャしたコンテキストを使用して、提供されたデリゲートを (ExecutionContext.Run で) 処理していました。たとえば、ThreadPool.QueueUserWorkItem を呼び出すコードでその時点の Windows ID を偽装していた場合、提供された WaitCallback デリゲートを実行する目的でも同じ Windows ID が偽装されました。また、Task.Run を呼び出すコードでデータを LogicalCallContext に格納しておいた場合、提供された Action デリゲート内の LogicalCallContext を介して同じデータにアクセスできました。ExecutionContext は、タスクの待機間でも受け渡されます。

.NET Framework には複数の最適化が施されているので、ExecutionContext をキャプチャする必要もキャプチャした ExecutionContext で実行する必要もない場合、コストが非常に高くなるおそれがあることから、キャプチャと実行が回避されます。しかし、Windows ID の偽装や LogicalCallContext へのデータの格納などの操作では、これらの最適化が適用されません。ExecutionContext を操作する処理 (WindowsIdentity.Impersonate、CallContext.LogicalSetData など) を回避すると、非同期メソッドの使用時や非同期機能全般の使用時におけるパフォーマンスが向上します。

ガベージ コレクションの対象にしない

ローカル変数に関して、非同期メソッドは都合の良い錯覚を引き起こします。同期メソッドでは、C# と Visual Basic のローカル変数はスタックに割り当てられ、ヒープ領域を割り当てる必要がありません。しかし、非同期メソッドでは、待機ポイントで非同期メソッドが一時停止しているときに、メソッドのスタックが解放されます。待機後の再開時にメソッドがローカル変数を使用できるようにするには、データをどこかに格納しておかなければなりません。そのため、C# と Visual Basic のコンパイラは、ローカル変数をステート マシン構造体に "退避" し、中断が発生する最初の待機でこのステート マシン構造体をヒープ領域にボックス化することで、待機ポイントから次の待機ポイントまでローカル変数を保持します。

記事の前半で、割り当て済みのオブジェクトの数によってガベージ コレクションのコストと頻度が変化すると同時に、ガベージ コレクションの頻度は、割り当て済みのオブジェクトのサイズによっても変化することを説明しました。割り当て済みのオブジェクトのサイズが大きくなるほど、ガベージ コレクションを実行する頻度が高くなります。したがって、非同期メソッドでは、ヒープに退避するローカル変数が増加するほど、ガベージ コレクションが頻繁に実行されることになります。

この記事の執筆時点では、C# と Visual Basic のコンパイラは、本当に必要としない場合でもローカル変数を退避することがあります。たとえば、次のコードを考えてみます。

public static async Task FooAsync() {
  var dto = DateTimeOffset.Now;
  var dt  = dto.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

待機ポイントの後で dto 変数が読み取られることはまったくないので、待機ポイントの前に dto 変数に書き込まれた値を待機後も保持する必要はありません。しかし、ローカルを格納するためにコンパイラによって生成されるステート マシン型では、dto の参照が保持されます (図 4 参照)。

図 4 ローカル変数の退避

[StructLayout(LayoutKind.Sequential), CompilerGenerated]
private struct <FooAsync>d__0 : <>t__IStateMachine {
  private int <>1__state;
  public AsyncTaskMethodBuilder <>t__builder;
  public Action <>t__MoveNextDelegate;
 
  public DateTimeOffset <dto>5__1;
  public DateTime <dt>5__2;
  private object <>t__stack;
  private object <>t__awaiter;
 
  public void MoveNext();
  [DebuggerHidden]
  public void <>t__SetMoveNextDelegate(Action param0);
}

この動作により、ヒープ オブジェクトのサイズは、実際に必要なサイズよりもやや大きくなります。予想以上に頻繁にガベージ コレクションが行われていることに気付いた場合は、非同期メソッドでコーディングした一時変数がすべて必要不可欠なものかどうか調べてみてください。上記の例を次のように書き直すと、ステート マシン クラスに余分なフィールドが作成されません。

public static async Task FooAsync() {
  var dt = DateTimeOffset.Now.DateTime;
  await Task.Yield();
  Console.WriteLine(dt);
}

そのうえ、.NET ガベージ コレクター (GC) は世代別のコレクターなので、一連のオブジェクトを世代というグループに分割します。大きく分けて、新しいオブジェクトは世代 0 に割り当てられ、ガベージ コレクション中にコレクションの対象にならなかったすべてのオブジェクトは 1 つ古い世代に昇格します (現在 .NET GC では、世代 0、1、および 2 を使用しています)。このため、既知のオブジェクト空間の一部だけから頻繁に GC でコレクションを実行できるので、コレクションの速度が向上します。このしくみは、新しく割り当てられたオブジェクトほどすぐに削除され、長期間存在しているオブジェクトほど長期間残されるという考え方に基づいています。つまり、オブジェクトが世代 0 でコレクションの対象にならなかった場合、そのオブジェクトはしばらくの間存在したままになり、追加された期間中はシステムを圧迫し続けます。また、オブジェクトが不要になりしだい、ガベージ コレクションの対象になるよう十分注意することも必要です。

前述の退避処理により、ローカル変数がクラスのフィールドに昇格され、非同期メソッドの実行中 (待機対象操作の完了時に呼び出すデリゲートへの参照を、待機対象オブジェクトで適切に保持している限り) 固定されます。同期メソッドでは、ローカル変数が不要になるタイミングを JIT コンパイラが追跡できます。GC ではこのようなタイミングを利用して、不要になった変数を大元から無視できるため、参照先オブジェクトがほかの箇所で参照されていなければ、その参照先オブジェクトをガベージ コレクションの対象にできます。しかし、非同期メソッドでは、ローカル変数が不要になっても参照されたままとなるため、ローカル変数であるにもかかわらず、変数の参照先オブジェクトが長期間ガベージ コレクションの対象にならないことがあります。使用後も長期間オブジェクトが存在し続けていることが判明した場合は、そのようなオブジェクトを参照しているローカル変数を、オブジェクトの使用後に NULL にすることを検討してください。繰り返しますが、この最適化は、不必要にコードが複雑になるのを避けるため、不要なローカル変数の参照がパフォーマンスの問題の原因になっている場合にのみ適用してください。さらに、最終リリースまでに、または今後のいずれかの時点で、C# と Visual Basic のコンパイラが更新され、開発者の代わりに処理されるシナリオがいっそう拡充される可能性があります。したがって、この記事で説明しているコードは、今後使用されなくなる可能性があります。

複雑になることを避ける

C# と Visual Basic のコンパイラは、ほとんどどこでも待機処理を使用できる点で、非常に優れています。待機の式は、より大きな式の一部として使用できるので、値を返す式がほかにもあるメソッドでha、Task<TResult> の複数のインスタンスを待機できます。たとえば、3 つのタスクの結果の合計を返す、次のコードについて考えてみましょう。

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  return Sum(await a, await b, await c);
}
 
private static int Sum(int a, int b, int c)
{
  return a + b + c;
}

C# コンパイラは、"await b" という式を Sum 関数の引数として使用することを許可します。しかし、このコードには、結果がパラメーターとして Sum 関数に渡される待機処理が複数存在します。しかも、検証ルールの順序とコンパイラでの非同期機能の実装方法のために、この例では、最初の 2 回の待機の一時的な結果をコンパイラが出力する必要があります。既に説明したように、待機ポイント間でローカル変数を保持するには、そのローカル変数をステート マシン クラスのフィールドに退避します。しかし、この例のように値が CLR 検証スタックに存在する場合、ローカル変数の値はステート マシン クラスに退避されず、1 つの一時オブジェクトに出力されてからステート マシンで参照されます。最初のタスクの待機が完了して 2 つ目のタスクの待機に移ると、コンパイラは、最初の結果をボックス化し、ボックス化したオブジェクトをステート マシン上の 1 つの <>t__stack フィールドに格納するコードを生成します。2 つ目のタスクの待機が完了して 3 つ目のタスクの待機に移ると、コンパイラは、最初の 2 つの値から Tuple<int,int> を作成し、このタプルを先ほどと同じ <>__stack フィールドに格納するコードを生成します。要するに、コードの記述方法に応じて、割り当てのパターンが全く異なる場合があります。次のように SumAsync を記述する場合を考えてください。

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  int ra = await a;
  int rb = await b;
  int rc = await c;
  return Sum(ra, rb, rc);
}

このように記述すると、コンパイラは、ra、rb、および rc を格納するさらに 3 つのフィールドをステート マシン クラスに作成し、出力は行いません。したがって、ステート マシン クラスのサイズを大きくして割り当て回数を減らすか、ステート マシン クラスのサイズを小さくして割り当て回数を増やすというトレードオフが必要になります。出力させる場合は、割り当てられるオブジェクトごとにメモリ オーバーヘッドが発生するのでメモリの合計サイズが大きくなりますが、最終的にはパフォーマンス テストでパフォーマンスに優れていることがわかるでしょう。一般に、このような小規模な最適化を検討するのは割り当てが実際に問題の原因になっている場合だけですが、それでも割り当ての方法を知っていると役に立ちます。

もちろん、前の例では、認識して積極的に検討する必要があるもっと大きなコストが存在します。3 つの待機処理がすべて完了するまでコードで Sum を呼び出せず、待機と待機の間に処理が実行されません。待機のたびにかなりの処理が必要になるため、待機の数が減るほどパフォーマンスが向上します。したがって、場合によっては Task.WhenAll ですべてのタスクを待機して、3 つのタスクすべてを 1 つのタスクにまとめることを考えます。

public static async Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  int [] results = await Task.WhenAll(a, b, c);
  return Sum(results[0], results[1], results[2]);
}

このコードの Task.WhenAll メソッドは、指定されたタスクがすべて完了するまで完了しない Task<TResult[]> を返し、その処理は、各タスクを個別に待機する場合よりもはるかに効果的です。また、このメソッドでは各タスクの結果を収集して、1 つの配列に格納します。このような配列を使用したくない場合は、Task<TResult> ではなく Task を使用して機能する非ジェネリックの WhenAll メソッドにバインディングすることでも同じ効果を得られます。パフォーマンスを最大限に高めるためには、複合手法も利用できます。この手法では、まずすべてのタスクが正常に完了しているかどうか確認し、完了している場合は結果をタスクごとに取得します。正常に完了していないタスクがある場合は、完了していないタスクの WhenAll メソッドを待機します。このようにすると、メソッドに渡すパラメーター配列の割り当てなど、WhenAll メソッドの呼び出しに伴う割り当てを、不要な場合に回避できます。また、このライブラリ関数ではコンテキストのマーシャリングを避けることもお勧めします。このようなソリューションを図 5 に示します。

図 5 複数の最適化の適用

public static Task<int> SumAsync(
  Task<int> a, Task<int> b, Task<int> c)
{
  return (a.Status == TaskStatus.RanToCompletion &&
          b.Status == TaskStatus.RanToCompletion &&
          c.Status == TaskStatus.RanToCompletion) ?
    Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
    SumAsyncInternal(a, b, c);
}
 
private static async Task<int> SumAsyncInternal(
  Task<int> a, Task<int> b, Task<int> c)
{
  await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
  return Sum(a.Result, b.Result, c.Result);
}

非同期とパフォーマンス

非同期メソッドは強力な生産性向上の道具であり、スケーラブルで応答性の高いライブラリやアプリケーションを容易に作成できます。ただし、非同期処理は個別の操作に対するパフォーマンスの最適化ではないことに注意することが重要です。1 つの同期操作に注目してそれを非同期にすると、その操作のパフォーマンスは必ず低下します。これは、同期操作の処理をすべて達成する必要性を残したまま、制約と考慮事項が増加しているためです。したがって、非同期処理を考慮する根拠は全体的なパフォーマンスということになります。つまり、I/O をオーバーラップさせたり、重要なリソースを実行時の本当に必要なときのみ使用することでシステムの使用率を向上させたりして、すべてを非同期に行うことで、システム全体のパフォーマンスが向上することが重要です。.NET Framework で提供される非同期メソッドの実装は適切に最適化され、多くの場合は既存のパターンを使用して巧みに作成された非同期実装と同じかそれ以上のパフォーマンスを最終的に発揮し、大量のコードを隠ぺいします。今後、.NET Framework で非同期コードを開発する予定であれば、非同期メソッドを利用することになるでしょう。それでも、.NET Framework によって開発者の代わりにこのような非同期メソッドで実行されているすべての処理を知っておくと、最終的にできる限り良い結果が得られるようにするうえで役に立ちます。

Stephen Toub は、マイクロソフトの並列コンピューティング プラットフォーム チームの主席アーキテクトです。

この記事のレビューに協力してくれた技術スタッフの Joe HoagEric LippertDanny ShihMads Torgersen に心より感謝いたします。