もしI/O バウンドの要求(例えば、ネットワークからデータをリクエストする、データベースにアクセスする、ファイルシステムを読み書きする)を持っている場合、非同期プログラミング(Asynchronous programming)を使用する必要があります。また、CPU バウンドのコード(例えば、高価な計算を実行する)がある場合も、非同期コードを書く良いシナリオです。
C# には言語レベルの非同期プログラミングモデル(language-level asynchronous programming model)があり、このモデルにより、コールバックや非同期をサポートするライブラリを扱うことなく、簡単に非同期コードを書くことができます。このモデルは、いわゆる Task-based Asynchronous Pattern(TAP) に従っています。
非同期モデルの概要#
非同期プログラミングの核心は Task
と Task<T>
オブジェクトであり、これらは非同期操作のモデルです。これらは async
と await
キーワードによってサポートされており、ほとんどの場合、このモデルは非常にシンプルです:
- I/O バウンドコードの場合、非同期メソッド内で
Task
またはTask<T>
を返す操作をawait
できます。 - CPU バウンドコードの場合、Task.Run メソッドを使用してバックグラウンドスレッドで操作を開始し、await を使用してその完了を待つことができます。
await
キーワードは非同期プログラミングの鍵です。これは、await
を実行するメソッドの呼び出し元に制御を返し、最終的にインターフェースの応答性やサービスの弾力性を実現します。他の非同期コードの処理方法もありますが、この記事では言語レベルの構造に重点を置いています。
I/O バウンドの例:Web サービスからデータをダウンロード#
特定のボタンが押されたときに、Web サービスからデータをダウンロードする必要があるかもしれませんが、UI スレッドをブロックしたくありません。以下のように System.Net.Http.HttpClient クラスを使用できます:
private readonly HttpClient _httpClient = new HttpClient();
downloadButton.Clicked += async (o, e) =>
{
// この行のコードは、Webサービスにリクエストを送信する際に、制御をUIスレッドに返します。
//
// この時、UIスレッドは他の作業を自由に実行できます。
var stringData = await _httpClient.GetStringAsync(URL);
DoSomethingWithData(stringData);
};
このコードは、非同期でデータをダウンロードする意図を表現しており、Task
オブジェクトとの相互作用の詳細に陥ることはありません。
CPU バウンドの例:ゲームのために高価な計算を実行#
私たちがモバイルゲームを作成していると仮定しましょう。ゲーム内でボタンを押すと、画面上の複数の敵にダメージを与えることができます。ダメージ計算は非常に時間がかかる可能性があり、UI スレッド上で計算を実行すると、ゲームが計算のためにカクついてしまいます!
最良の対処法は、Task.Run を使用してバックグラウンドスレッドで計算を実行し、await
を使用してその結果を待つことです。これにより、ゲームのユーザーインターフェースはスムーズに保たれ、計算はバックグラウンドで行われます。
private DamageResult CalculateDamageDone()
{
// コードは省略されています:
//
// 高価な計算を行い、計算結果を返します。
}
calculateButton.Clicked += async (o, e) =>
{
// この行のコードは、CalculateDamageDone() が作業を実行している間、制御をUIに返します。UIスレッドは他の作業を自由に実行できます。
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
このコードは、ボタンのクリックイベントの意図を明確に表現しており、バックグラウンドスレッドを手動で管理する必要がなく、非ブロッキング(non-blocking)な方法で実現されています。
内部原理#
C# の場合、コンパイラ(compiler)はコードを状態機械(state machine)に変換し、await に遭遇したときの実行の一時停止を追跡し、バックグラウンドタスクが完了した後に実行を再開します。
理論を研究する人にとっては、これは 非同期性の Promise モデル(Promise Model of asynchrony) の実装です。
理解すべき重要なポイント#
- 非同期コードは I/O バウンドと CPU バウンドのコードの両方に使用できますが、各ケースで使用される方法は異なります。
- 非同期コードは
Task<T>
とTask
の 2 つの構造を使用してバックグラウンド作業の実行を模倣します。 async
キーワードはメソッドを非同期メソッドに変換し、メソッド内でawait
キーワードを使用できるようにします。await
キーワードを適用すると、呼び出し元のメソッドが一時停止し、制御が呼び出し元に返され、待機しているタスクが完了するまで待機します。- await は非同期メソッド内でのみ使用できます。
CPU バウンドと I/O バウンドの作業を識別する#
最初の 2 つの例は、I/O バウンドと CPU バウンドの作業で async
と await
を使用する方法を示しています。重要なのは、実行する必要があるタスクが I/O バウンドか CPU バウンドかを識別できることです。これは、コードのパフォーマンスに大きな影響を与え、特定の構造の誤用を引き起こす可能性があります。
コードを書く前に、次の 2 つの質問を考慮する必要があります:
- コードは何かを待っていますか(waiting)?例えば、データベースからのデータ?
もし「はい」であれば、そのタスクは I/O バウンド です。 - コードは高価な計算を実行していますか(performing an expensive computation)?
もし「はい」であれば、そのタスクは CPU バウンド です。
作業が I/O バウンドの場合、コード内で async
と await
を使用し、Task.Run
を使用する必要はありません。また、タスク並列ライブラリ(Task Parallel Library)を使用するべきではありません。
作業が CPU バウンドで、コードの応答性能に関心がある場合は、async
と await
を使用できますが、Task.Run
を介して別のスレッドで作業を実行する必要があります。その作業が並行および並列処理に適している場合は、タスク並列ライブラリ(Task Parallel Library) の使用を検討することもできます。
さらに、コードの実行を常に測定する必要があります。例えば、マルチスレッド環境では、コンテキストスイッチのオーバーヘッドと比較して、CPU バウンドの作業がそれほど高価ではないことがわかるかもしれません。各選択肢には利点と欠点があり、実際の状況に基づいて正しい選択を行う必要があります。
さらなる例#
以下の例は、C# で非同期コードを書くさまざまな方法を示しています。これらは、遭遇する可能性のあるいくつかの異なるシナリオをカバーしています。
ネットワークからデータを取得する#
このコードスニペットは、https://dotnetfoundation.org のホームページから HTML をダウンロードし、HTML 内の文字列「.NET」の出現回数を計算します。ASP.NET を使用して、タスクを実行し、回数を返す Web API コントローラーメソッドを定義しています。
private readonly HttpClient _httpClient = new HttpClient();
[HttpGet, Route("DotNetCount")]
public async Task<int> GetDotNetCount()
{
// GetDotNetCount() を一時停止(Suspend)して、呼び出し元(Webサーバー)が別のリクエストを受け取れるようにします。
var html = await _httpClient.GetStringAsync("https://dotnetfoundation.org");
return Regex.Matches(html, @"\.NET").Count;
}
以下は、一般的な Windows アプリケーション用に書かれた同じシナリオで、ボタンが押されたときに同じタスクを実行します:
private readonly HttpClient _httpClient = new HttpClient();
private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
// 後でバックグラウンドタスクを待機するために、ここでタスクハンドル(task handle)をキャプチャします。
var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");
// UIスレッドに関連する他の作業をここで行うことができます。例えば、プログレスバーを有効にします。
// これは重要であり、「await」呼び出しの前に完了する必要があります。そうしないと、ユーザーはこのメソッドの実行が一時停止する前にプログレスバーを見ることができません。
NetworkProgressBar.IsEnabled = true;
NetworkProgressBar.Visibility = Visibility.Visible;
// "await" 演算子は OnSeeTheDotNetsButtonClick() メソッドを一時停止し、制御を呼び出し元に返します。これがアプリケーションを応答させ、UIスレッドをブロックしないための鍵です。
var html = await getDotNetFoundationHtmlTask;
int count = Regex.Matches(html, @"\.NET").Count;
DotNetCountLabel.Text = $"dotnetfoundation.org の .NET の数: {count}";
NetworkProgressBar.IsEnabled = false;
NetworkProgressBar.Visibility = Visibility.Collapsed;
}
複数のタスクの完了を待つ#
場合によっては、複数のデータ片を並行して取得する必要があります。Task
API は、非ブロッキングで複数のバックグラウンドタスクを待機するための 2 つのメソッド、Task.WhenAll と Task.WhenAny を提供します。
以下の例は、一連のユーザー ID のユーザーデータを取得する方法を示しています:
public async Task<User> GetUserAsync(int userId)
{
// コードは省略されています:
//
// 指定されたユーザーID {userId} に対応するユーザーオブジェクトをデータベースから取得します。
}
public static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds)
{
var getUserTasks = new List<Task<User>>();
foreach (int userId in userIds)
{
getUserTasks.Add(GetUserAsync(userId));
}
return await Task.WhenAll(getUserTasks);
}
以下は、LINQ を使用してこの処理をより簡潔に書いた別の方法です:
public async Task<User> GetUserAsync(int userId)
{
// コードは省略されています:
//
// 指定されたユーザーID {userId} に対応するユーザーオブジェクトをデータベースから取得します。
}
public static async Task<User[]> GetUsersAsync(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
LINQ を使用するとコード量が減りますが、非同期コードと LINQ を混在させる際には注意が必要です。LINQ は遅延実行(deferred (lazy) execution)を使用しているため、.ToList()
または .ToArray()
を呼び出して生成されたシーケンスを強制的に反復しない限り、非同期呼び出しは即座には発生しません。上記の例では、Enumerable.ToArray を使用して、クエリを即座に実行し、結果を配列に格納しています。これにより、コード id => GetUserAsync(id)
が実行され、そのタスクが開始されます。
重要な情報と提案#
非同期プログラミングでは、予期しない動作を防ぐために覚えておくべきいくつかの詳細があります。
-
非同期メソッド(
async
methods)は、その本体内でawait
キーワードを使用する必要があります。そうしないと、結果を生成することはありません!これは非常に重要です。非同期メソッドの本体内で
await
を使用しない場合、C# コンパイラは警告を生成しますが、コードは通常のメソッドのようにコンパイルされ、実行されます。これは非常に非効率的であり、C# コンパイラが非同期メソッドのために生成する状態機械は何の機能も実装していません。 -
作成した各非同期メソッド名の後に「Async」をサフィックスとして追加します。
これは、.NET で使用される慣習であり、同期メソッドと非同期メソッドをより簡単に区別できます。イベントハンドラーや Web コントローラーメソッドなど、一部のメソッドにはこの慣習が必ずしも適用されない場合があります。これらはあなたのコードによって明示的に呼び出されないため、明確に命名することはそれほど重要ではありません。 -
async void
はイベントハンドラー(event handlers)にのみ使用するべきです。async void
は、イベントに戻り値がないため、非同期イベントハンドラーを機能させる唯一の方法です。async void
を使用する他のすべてのケースは TAP モデルに適合せず、使用が難しい場合があります。例えば:async void
メソッド内でスローされた例外は、そのメソッドの外でキャッチできません。async void
メソッドをテストするのが難しいです。- 呼び出し元がそれらを非同期であることを期待していない場合、
async void
メソッドは悪影響を引き起こす可能性があります。
-
LINQ 式内の非同期ラムダ式を使用する際には注意が必要です。
LINQ のラムダ式は遅延実行(deferred execution)を使用しているため、コードが望ましくないタイミングで実行される可能性があります。正しく記述しないと、ブロッキングタスクを導入することでデッドロックを引き起こす可能性があります。また、このようにネストされた非同期コードは、コードの実行を理解するのをさらに難しくする可能性があります。Async と LINQ はどちらも強力なツールですが、両者を組み合わせて使用する際にはできるだけ注意が必要です。
-
非ブロッキングの方法でタスクの完了を待つコードを書く。
現在のスレッドをブロックしてタスクの完了を待つことは、デッドロックやコンテキストスレッドのブロックを引き起こす可能性があり、より複雑なエラーハンドリングが必要になることがあります。以下の表は、非ブロッキングの方法でタスクを待機するためのガイドラインを提供します:
これを実行するには... 次のように使用します... ではなく… バックグラウンドタスクの結果を取得する await Task.Wait または Task.Result 任意のタスクが完了するのを待つ await Task.WhenAny Task.WaitAny すべてのタスクが完了するのを待つ await Task.WhenAll Task.WaitAll 一定の時間待つ await Task.Delay Thread.Sleep -
可能な場合は ValueTask の使用を検討する。
非同期メソッドから
Task
オブジェクトを返すことは、特定のパスでパフォーマンスのボトルネックを引き起こす可能性があります。Task
は参照型であるため、これを使用するとオブジェクトが割り当てられます。async
修飾子で宣言されたメソッドがキャッシュされた結果や同期的に完了した場合、追加の割り当てがコードのパフォーマンスの重要な部分で顕著な時間のオーバーヘッドになる可能性があります。これらの割り当てが密接にループ内で発生する場合、コストはさらに高くなります。詳細については、一般的な非同期戻り値の型(generalized async return types)を参照してください。ValueTask
構造体は、.NET が提供するパフォーマンス最適化のための代替手段です。ValueTask
は、特定の状況でTask
の割り当てを回避でき、非同期操作が直接結果を返す場合にパフォーマンスを向上させることができます。パフォーマンスが重要なシナリオでは、ValueTask
を使用して不必要なメモリ割り当てを減らし、パフォーマンスを向上させることを検討してください。 -
ConfigureAwait(false)
の使用を検討する。一般的な問題は、「
Task.ConfigureAwait(Boolean)
メソッドをいつ使用すべきか?」です。このメソッドは、Task
インスタンスがその awaiter を構成することを許可します。これは重要な考慮事項であり、設定が不適切な場合、パフォーマンスの問題やデッドロックを引き起こす可能性があります。ConfigureAwait
に関する詳細は、ConfigureAwait FAQを参照してください。非同期プログラミングでは、
ConfigureAwait(false)
を使用することで、待機操作を元のコンテキストに戻さないようにランタイムに指示し、パフォーマンスを向上させ、コンテキストスイッチのオーバーヘッドを減らすことができます。ただし、UI スレッドにアクセスする必要がある操作や、特定のコンテキストでコードを実行する必要がある場合など、特定の状況ではConfigureAwait(false)
の使用に慎重であるべきです。具体的な状況に応じてバランスを取り、決定を下して、コードの正確性とパフォーマンスの最適化を確保してください。 -
無状態のコードを書く。
グローバルオブジェクトの状態や特定のメソッドの実行に依存しないでください。代わりに、メソッドの戻り値にのみ依存してください。なぜでしょうか?
- コードがより理解しやすくなります。
- コードのテストが容易になります。
- 非同期コードと同期コードを混在させるのが簡単になります。
- 競合条件を完全に回避できることが多いです。
- 戻り値に依存することで、非同期コードの調整が簡単になります。
- (追加の利点)依存性注入と非常に相性が良いです。
状態への依存を減らすことで、コードの保守性とテスト可能性を向上させることができます。同時に、グローバル状態の使用を避けることで、競合条件や並行性の問題の発生を減らすことができます。メソッドの戻り値に依存することで、コードの動作をより明確に理解でき、コードの作成やデバッグが容易になります。要するに、無状態のプログラミングスタイルを採用することで、コードの品質と開発効率を向上させることができます。
提案された目標は、コード内で完全またはほぼ完全な参照透明性(Referential Transparency)を実現することです。これにより、予測可能でテスト可能、かつ保守しやすいコードベースが生まれます。
参考文献#
- 非同期プログラミング - C# | Microsoft Learn
- タスクベースの非同期パターン (TAP): はじめにと概要 | Microsoft Learn
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/async#return-types
- https://devblogs.microsoft.com/dotnet/configureawait-faq
- https://en.wikipedia.org/wiki/Referential_transparency_(computer_science)