タスク非同期プログラミングモデル(TAP)は非同期コードに抽象を提供します。私たちは通常通りにコードを順番に書くことができ、各ステートメントは次のステートメントが始まる前に完了しているように見えます **。コンパイラは多くの変換を行います。なぜなら、これらのステートメントのいくつかは作業を開始し、進行中の作業を表す Task を返す可能性があるからです。
この構文の目的は、コードを一連のステートメントのように見せることですが、実行順序ははるかに複雑であり、外部リソースの割り当てとタスクの完了時間に基づいています。これは、非同期タスクを含むプロセスに指示を提供する方法に似ています。この記事では、朝食を作る例を使用して、async と await キーワードが一連の非同期指示を含むコードをどのように理解しやすくするかを示します。私たちは以下のリストのように指示を書いて、朝食を作る方法を説明できます:
- コーヒーを一杯注ぐ。
- フライパンを加熱し、卵を 2 つ焼く。
- ベーコンを 3 枚焼く。
- パンを 2 枚トーストする。
- パンにバターとジャムを塗る。
- オレンジジュースを注ぐ。
料理の経験があれば、これらの指示を非同期に実行します。最初に卵を焼く準備のためにフライパンを加熱し始め、その後ベーコンを焼き始めます。パンをトースターに入れ、その後卵を焼きます。プロセスの各ステップで、タスクを開始し、処理が必要なタスクに移ります。
朝食を作ることは、並行作業ではなく非同期作業の良い例です。一人(またはスレッド)がこれらのすべてのタスクを処理できます。朝食の比喩を続けると、一人が最初のタスクが完了する前に次のタスクを開始することで非同期に朝食を作ることができます。料理プロセスは、誰かが見ているかどうかに関係なく続行されます。卵を焼く準備のためにフライパンを加熱し始めると、ベーコンを焼き始めることができます。ベーコンが焼き始めると、パンをトースターに入れることができます。
並行アルゴリズム(Parallel algorithm)では、複数のシェフ(またはスレッド)が必要です。一人が卵を焼き、一人がベーコンを焼く、というように。各自が自分のタスクに集中します。各シェフ(またはスレッド)は、ベーコンが焼き上がるのを待つために同期的にブロックされるか、パンがポップアップするのを待ちます。
さて、これらの指示を C# ステートメントとして書くことを考えます:
namespace AsyncBreakfast
{
// これらのクラスはこの例のために意図的に空です。これらは単にデモ用のマーカークラスであり、プロパティを含まず、他に目的はありません。
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
Egg eggs = FryEggs(2);
Console.WriteLine("卵が焼き上がりました");
Bacon bacon = FryBacon(3);
Console.WriteLine("ベーコンが焼き上がりました");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("パンが焼き上がりました");
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが注がれました");
Console.WriteLine("朝食が準備できました!");
}
private static Juice PourOJ()
{
Console.WriteLine("オレンジジュースを注ぐ");
return new Juice();
![Untitled](ipfs://QmUUPaUCsngMZh4apLSSypnPovBtkymu2qhgSUUwz3JK8K)
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("パンにジャムを塗る");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("パンにバターを塗る");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("トースターにパンのスライスを入れる");
}
Console.WriteLine("パンを焼き始める...");
Task.Delay(3000).Wait();
Console.WriteLine("トースターからパンを取り出す");
return new Toast();
}
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"フライパンに{slices}枚のベーコンを入れる");
Console.WriteLine("ベーコンの最初の面を焼いています...");
Task.Delay(3000).Wait();
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("ベーコンの一枚をひっくり返す");
}
Console.WriteLine("ベーコンの第二の面を焼いています...");
Task.Delay(3000).Wait();
Console.WriteLine("ベーコンを皿に置く");
return new Bacon();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("卵焼き用のフライパンを温めています...");
Task.Delay(3000).Wait();
Console.WriteLine($"卵を{howMany}個割る");
Console.WriteLine("卵を焼いています...");
Task.Delay(3000).Wait();
Console.WriteLine("卵を皿に置く");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("コーヒーを注ぐ");
return new Coffee();
}
}
}
コンピュータは人間のようにこれらの指示を解釈しません。コンピュータは各ステートメントでブロックし、作業が完了するまで次のステートメントに進みません。これにより、朝食の準備時間が長くなり、いくつかの食べ物はテーブルに出される前に冷めてしまいます。
コンピュータに上記の指示を非同期に実行させるには、非同期コードを書く必要があります。
これらの問題は、今私たちが書いているプログラムにとって非常に重要です。クライアントプログラムを書くとき、ユーザーインターフェースはユーザー入力に応答する必要があります。ネットワークからデータをダウンロードしているとき、アプリケーションは携帯電話をフリーズさせるべきではありません。サーバープログラムを書くとき、スレッドがブロックされることは望ましくありません。これらのスレッドは他のリクエストを処理している可能性があります。非同期コードの代わりに同期コードを使用すると、プログラムをより低コストで拡張する能力に影響を与えます。ブロックされたスレッドに対してコストを支払う必要があります。
成功した現代のアプリケーションには非同期コードが必要です。言語のサポートがない場合、非同期コードを書くにはコールバック、完了イベント、または他の方法を使用する必要があり、これによりコードの本来の意図が隠されます。同期コードの利点は、ステップバイステップで実行され、スキャンしやすく理解しやすいことです。従来の非同期モデルは、コードの非同期特性により多くの注意を向けさせ、コードの基本的な操作を見失わせます。
ブロックせずに await する#
前のコードは、非同期操作を実行するために同期コードを書くという悪い実践を示しています。この書き方では、このコードはそれを実行しているスレッドが他の作業を行うのを妨げます。どんなタスクが進行中であっても、中断されることはありません。これは、パンをトースターに入れた後、トースターを見つめ続けるようなものです。パンがポップアップするまで、あなたに話しかける人には注意を払わないでしょう。
まず、このコードを更新して、タスクが実行中にスレッドがブロックされないようにしましょう。**await
キーワードは、タスクを開始するための非ブロッキングな方法を提供し、タスクが完了した後に実行を続けます **。朝食コードのシンプルな非同期バージョンは次のようになります:
static **async Task** Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
Egg eggs = await FryEggsAsync(2);
Console.WriteLine("卵が焼き上がりました");
Bacon bacon = await FryBaconAsync(3);
Console.WriteLine("ベーコンが焼き上がりました");
Toast toast = await ToastBreadAsync(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("パンが焼き上がりました");
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが注がれました");
Console.WriteLine("朝食が準備できました!");
}
卵やベーコンを料理しているとき、このコードはブロックされません。しかし、このコードは他のタスクを開始しません。あなたはまだパンのスライスをトースターに入れ、パンがポップアップするまでそれを見つめ続けます。しかし少なくとも、あなたは注意を引こうとする人に反応するでしょう。複数の注文があるレストランでは、シェフは最初の朝食を料理している間に別の朝食を始めることができます。
今や、朝食を作るスレッドは未完了のタスクを待っている間にブロックされません。特定のアプリケーションでは、このような変更を行うだけで済みます。グラフィカルユーザーインターフェイスアプリケーションは、このような変更を行うだけで、ユーザーに応答できます。しかし、この場合、あなたが必要とするのはもっと多くのことです。各コンポーネントタスクが順番に実行されることを望んでいません。前のタスクが完了する前に、各コンポーネントタスクを開始する方が良いです。
タスクを同時に開始する(Start tasks concurrently)#
多くのシナリオでは、複数の独立したタスクをすぐに開始したいと思うでしょう。そして、各タスクが完了したときに、他の準備が整った作業を続けることができます。朝食の比喩では、これは朝食をより早く完成させる方法です。すべてのことを同時に行うこともできます。熱々の朝食を得ることができます。
System.Threading.Tasks.Task および関連する型は、進行中のタスクを処理するために使用できるクラスです。これにより、朝食を作るコードに近いものを書くことができます。卵、ベーコン、トーストを同時に焼き始めます。各タスクがアクションを必要とするときに、そのタスクに移り、次のアクションを処理し、他のタスクに注意を向けることができます。
タスクを開始し、そのタスクを表す Task オブジェクトを保存します。各タスクの結果を処理する前に await
します。
朝食コードにこれらの変更を加えましょう。最初のステップは、タスクを保存して、タスクが開始されたときに操作することです。タスクを待つのではなく:
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("卵が焼き上がりました");
Task<Bacon> baconTask = FryBaconAsync(3);
Bacon bacon = await baconTask;
Console.WriteLine("ベーコンが焼き上がりました");
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("パンが焼き上がりました");
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが注がれました");
Console.WriteLine("朝食が準備できました!");
上記のコードは、朝食をより早く準備することはありません。タスクは開始後すぐに await
されます。次に、ベーコンと卵の await
ステートメントをメソッドの末尾に移動し、料理を出す前に待機します:
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("パンが焼き上がりました");
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが注がれました");
Egg eggs = await eggsTask;
Console.WriteLine("卵が焼き上がりました");
Bacon bacon = await baconTask;
Console.WriteLine("ベーコンが焼き上がりました");
Console.WriteLine("朝食が準備できました!");
この非同期朝食準備は約 20 分かかりました。時間を節約できたのは、いくつかのタスクが同時に実行されているからです。
前のコードはより良い結果をもたらします。すべての非同期タスクを一度に開始します。結果が必要なときだけ、各タスクを待機します。前のコードは、異なるマイクロサービスにリクエストを送信し、結果を単一のページに統合するウェブアプリケーションのコードに似ています。すべてのリクエストをすぐに発行し、すべてのタスクが完了するのを待って、ウェブページを組み合わせます。
タスクの組み合わせ(Composition with tasks)#
パンのスライスを除いて、朝食のすべてが同時に準備されます。パンのスライスを作ることには、非同期操作(トースト)と同期操作(バターとジャムを塗る)が含まれます。このコードを更新することで、重要な概念を示すことができます:
前のコードは、Task または Task オブジェクトを使用して、進行中のタスクを保存できることを示しています。結果を使用する前に、各タスクの完了を await
する必要があります。次のステップは、他の作業を組み合わせるメソッドを作成することです。朝食を供給する前に、トーストのタスクが完了するのを待ってから、バターとジャムを追加する必要があります。この作業は次のコードで表現できます:
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
上記のメソッドは、その署名に async
修飾子があります。これは、コンパイラにそのメソッドが await
ステートメントを含むことを示します;それは非同期操作を含みます。このメソッドは、トースト、バター、ジャムを塗るタスクを表します。このメソッドは、これらの 3 つの操作の組み合わせを表す Task を返します。今、主要なコードブロックは次のようになります:
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
Task<Egg> eggsTask = FryEggsAsync(2);
Task<Bacon> baconTask = FryBaconAsync(3);
Task<Toast> toastTask = MakeToastWithButterAndJamAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("卵が焼き上がりました");
Bacon bacon = await baconTask;
Console.WriteLine("ベーコンが焼き上がりました");
Toast toast = await toastTask;
Console.WriteLine("パンが焼き上がりました");
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが注がれました");
Console.WriteLine("朝食が準備できました!");
上記の変更は、非同期コードを処理するための重要なテクニックを示しています。操作を新しいメソッドに分離し、タスクを返すことでタスクを組み合わせることができます。タスクを待機するタイミングを選択でき、他のタスクを同時に開始することもできます。
非同期例外(Asynchronous exceptions)#
これまでのところ、すべてのタスクが成功裏に完了することを前提としています。非同期メソッドは、同期メソッドと同様に例外をスローします。非同期例外とエラー処理の目的は、一般的な非同期サポートと同じです:あなたが書いたコードは、同期ステートメントの一連のように読まれるべきです。タスクが成功裏に完了できない場合、それらは例外をスローします。await
で開始されたタスクを待機すると、クライアントコードはこれらの例外をキャッチできます。たとえば、トースターがパンを焼いているときに火事が発生したと仮定します。この状況をシミュレートするために、ToastBreadAsync
メソッドを次のように変更できます:
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("トースターにパンのスライスを入れる");
}
Console.WriteLine("パンを焼き始める...");
await Task.Delay(2000);
Console.WriteLine("火事だ!トーストが台無しになった!");
throw new InvalidOperationException("トースターが火事になった");
await Task.Delay(1000);
Console.WriteLine("トースターからパンを取り出す");
return new Toast();
}
これらの変更を加えた後にアプリケーションを実行すると、以下のような出力が表示されます:
....
ベーコンが焼き上がりました
未処理の例外。System.InvalidOperationException: トースターが火事になった
at AsyncBreakfast.Program.ToastBreadAsync(Int32 slices) in Program.cs:line 65
at AsyncBreakfast.Program.MakeToastWithButterAndJamAsync(Int32 number) in Program.cs:line 36
at AsyncBreakfast.Program.Main(String[] args) in Program.cs:line 24
at AsyncBreakfast.Program.<Main>(String[] args)
トースターが火事になったときと例外が観察されるまでの間に、多くのタスクがすでに完了していることに気付くでしょう。非同期で実行されるタスクが例外をスローすると、そのタスクは故障(faulted)します。タスクオブジェクトは、Task.Exception プロパティにスローされた例外を保存します。故障したタスクは、await 時に例外をスローします。
理解しておくべき重要なメカニズムが 2 つあります:例外が故障したタスクにどのように保存され、コードが故障したタスクを待機する際にどのように解凍されて再スローされるかです。
非同期で実行されるコードが例外をスローすると、その例外は Task に保存されます。Task.Exception プロパティは System.AggregateException です。なぜなら、非同期作業中に複数の例外がスローされる可能性があるからです。スローされた例外はすべて、AggregateException.InnerExceptions コレクションに追加されます。Exception
プロパティが null の場合、新しい AggregateException
が作成され、スローされた例外がコレクションの最初のアイテムになります。
故障したタスクの最も一般的なケースは、Exception
プロパティがちょうど 1 つの例外を含むことです。コードが故障したタスクを await
すると、AggregateException.InnerExceptions コレクションの最初の例外が再スローされます。これが、この例の出力が InvalidOperationException
を示し、AggregateException
ではない理由です。最初の内部例外を抽出することで、非同期メソッドの動作を同期メソッドにできるだけ近づけることができます。あなたのアプリケーションが複数の例外を生成する可能性がある場合、コード内の Exception
プロパティを確認できます。
続行する前に、ToastBreadAsync
メソッド内のこれらの 2 行をコメントアウトしてください。火事を引き起こしたくないでしょう:
Console.WriteLine("火事だ!トーストが台無しになった!");
throw new InvalidOperationException("トースターが火事になった");
タスクを効率的に待機する(Await tasks efficiently)#
前のコードでは、await
ステートメントの一連の使用を改善するために、Task
クラスのメソッドを使用できます。1 つの API は WhenAll で、これはパラメータリスト内のすべてのタスクが完了したときに Task を返します。次のようなコードで示されます:
await Task.WhenAll(eggsTask, baconTask, toastTask);
Console.WriteLine("卵が準備できました");
Console.WriteLine("ベーコンが準備できました");
Console.WriteLine("トーストが準備できました");
Console.WriteLine("朝食が準備できました!");
別の方法は、WhenAny を使用することで、これはそのパラメータのいずれかが完了したときに Task<Task>
を返します。返されたタスクを待機して、完了したことを確認できます。以下のコードは、WhenAny
を使用して最初に完了したタスクを待機し、その結果を処理する方法を示しています。完了したタスクの結果を処理した後、そのタスクを WhenAny
に渡されたタスクリストから削除できます。
var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("卵が準備できました");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("ベーコンが準備できました");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("トーストが準備できました");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
終わりに近づくと、await finishedTask
という行があります;await Task.WhenAny
は、完了したタスクを待機しません。待機するのは Task.WhenAny
が返す Task
です。Task.WhenAny
の結果は、完了(または故障)したタスクです。結果を取得したり、故障の原因となった例外がスローされることを確認するために、そのタスクを再度待機する必要があります。
これらのすべての変更の後、コードの最終バージョンは次のようになります:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// これらのクラスはこの例のために意図的に空です。これらは単にデモ用のマーカークラスであり、プロパティを含まず、他に目的はありません。
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static async Task Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("コーヒーが準備できました");
var eggsTask = FryEggsAsync(2);
var baconTask = FryBaconAsync(3);
var toastTask = MakeToastWithButterAndJamAsync(2);
var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
while (breakfastTasks.Count > 0)
{
Task finishedTask = await Task.WhenAny(breakfastTasks);
if (finishedTask == eggsTask)
{
Console.WriteLine("卵が準備できました");
}
else if (finishedTask == baconTask)
{
Console.WriteLine("ベーコンが準備できました");
}
else if (finishedTask == toastTask)
{
Console.WriteLine("トーストが準備できました");
}
await finishedTask;
breakfastTasks.Remove(finishedTask);
}
Juice oj = PourOJ();
Console.WriteLine("オレンジジュースが準備できました");
Console.WriteLine("朝食が準備できました!");
}
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
private static Juice PourOJ()
{
Console.WriteLine("オレンジジュースを注ぐ");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("トーストにジャムを塗る");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("トーストにバターを塗る");
private static async Task<Toast> ToastBreadAsync(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("トースターにパンのスライスを入れる");
}
Console.WriteLine("パンを焼き始める...");
await Task.Delay(3000);
Console.WriteLine("トースターからパンを取り出す");
return new Toast();
}
private static async Task<Bacon> FryBaconAsync(int slices)
{
Console.WriteLine($"フライパンに{slices}枚のベーコンを入れる");
Console.WriteLine("ベーコンの最初の面を焼いています...");
await Task.Delay(3000);
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("ベーコンの一枚をひっくり返す");
}
Console.WriteLine("ベーコンの第二の面を焼いています...");
await Task.Delay(3000);
Console.WriteLine("ベーコンを皿に置く");
return new Bacon();
}
private static async Task<Egg> FryEggsAsync(int howMany)
{
Console.WriteLine("卵焼き用のフライパンを温めています...");
await Task.Delay(3000);
Console.WriteLine($"卵を{howMany}個割る");
Console.WriteLine("卵を焼いています...");
await Task.Delay(3000);
Console.WriteLine("卵を皿に置く");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("コーヒーを注ぐ");
return new Coffee();
}
}
}
非同期に朝食を準備する最終バージョンは約 15 分かかりました。これは、いくつかのタスクが並行して実行され、コードが同時に複数のタスクを監視し、必要なときにのみアクションを取ることができるためです。
この最終版のコードは非同期です。それは、朝食を作る人の方法をより正確に反映しています。前のコードとこの記事の冒頭の最初のコードサンプルを比較してください。コードからは、コア操作が明確に見えます。あなたは、この記事の冒頭で朝食を作る方法についての説明を読むように、このコードを読むことができます。async
と await
の言語機能は、誰もが書かれた指示に従うための翻訳方法を提供します:できるだけタスクを開始し、タスクが完了するのをブロックしないようにします。