C#の非同期/待機:落とし穴

C#で非同期/待機機能を使用するときに最もよくある落とし穴について説明し、それらの回避方法についても説明します。



非同期/待機の仕組み



非同期/待機の内部構造は、Alex Davis の著書でよく説明されているため、ここでは簡単に説明します。 次のコード例を検討してください。



public async Task ReadFirstBytesAsync(string filePath1, string filePath2) { using (FileStream fs1 = new FileStream(filePath1, FileMode.Open)) using (FileStream fs2 = new FileStream(filePath2, FileMode.Open)) { await fs1.ReadAsync(new byte[1], 0, 1); // 1 await fs2.ReadAsync(new byte[1], 0, 1); // 2 } }
      
      







この関数は、2つのファイルから最初の1バイトを読み取り、パスはパラメーターを介して渡されます。 「1」と「2」の行はどうなりますか? それらは並行して実行されますか? いや この関数は、「await」キーワードによって「1」に先行する部分、「1」と「2」の間の部分、および「2」に後続する部分の3つの部分に「分割」されます。



この関数は、行「1」で新しいI / Oバウンドストリームを開始し、それ自体の2番目の部分(「1」と「2」の間の部分)をコールバックとして転送し、制御を返します。 I / Oスレッドが完了すると、コールバックが呼び出され、メソッドの実行が継続されます。 このメソッドは、行 "2"に別のI / Oストリームを作成し、コールバックとして自身の3番目の部分を与え、再び制御を返します。 2番目のI / Oスレッドの実行が完了すると、残りのメソッドが起動されます。



魔法は、 反復子メソッドを変換する方法と同様に、キーワード「async」でマークされたメソッドを状態マシンに変換するコンパイラのおかげで存在します



async / awaitを使用する場合



async / awaitの使用が推奨される2つの主なシナリオがあります。



まず、この機能をシッククライアントで使用して、ユーザーに優れたユーザーエクスペリエンスを提供できます。 ユーザーがボタンを押して重い計算操作を開始する場合、UIストリームをブロックせずに非同期でこの操作を実行するのが最善の方法です。 .NET 4.5より前では、このようなロジックにはさらに多くの労力が必要でした。 これで、次のようにプログラムできます。



 private async void btnRead_Click(object sender, EventArgs e) { btnRead.Enabled = false; using (FileStream fs = new FileStream(“File path”, FileMode.Open)) using (StreamReader sr = new StreamReader(fs)) { Content = await sr.ReadToEndAsync(); } btnRead.Enabled = true; }
      
      







どちらの場合もEnabledフラグはUIスレッドによって設定されることに注意してください。 このアプローチにより、このようないコードを記述する必要がなくなります。



 if (btnRead.InvokeRequired) { btnRead.Invoke((Action)(() => btnRead.Enabled = false)); } else { btnRead.Enabled = false; }
      
      







つまり、すべての「軽い」コードは呼び出しスレッドによって実行され、「重い」部分は別のスレッド(I / OまたはCPUバインド)に委任されます。 このアプローチにより、UI要素へのアクセスを同期するために必要な労力を大幅に削減できます。 UIスレッドからのみ管理されます。



次に、Webアプリケーションでasync / awaitを使用して、スレッドをより有効に活用できます。 ASP.NET MVCチームは、非同期コントローラーの実装を非常に簡単にしました。 次の例のようにアクションメソッドを記述するだけで、ASP.NETが残りの作業を実行します。



 public class HomeController : Controller { public async Task<string> Index() { using (FileStream fs = new FileStream(“File path”, FileMode.Open)) using (StreamReader sr = new StreamReader(fs)) { return await sr.ReadToEndAsync(); // 1 } } }
      
      







この例では、メソッドを実行するワーカースレッドは、行「1」で新しいI / Oスレッドを開始し、スレッドプールに戻ります。 I / Oスレッドが完了すると、CLRはプールから新しいスレッドを選択し、メソッドの実行を継続します。 したがって、スレッドプールのCPUにバインドされたスレッドは、はるかに経済的に使用されます。



C#の非同期/待機:落とし穴



サードパーティのライブラリを開発している場合、メソッドの残りの部分がプールの任意のスレッドによって実行されるように、常にawaitを構成することが非常に重要です。 つまり、サードパーティライブラリのコードにConfigureAwait(false)を常に追加する必要があります。



まず第一に、サードパーティのライブラリは通常UIコントロールでは動作しません(もちろんUIライブラリでない限り)。そのため、UIストリームをバインドする必要はありません。 プール内の任意のスレッドでCLRにコードを実行させることにより、パフォーマンスを少し向上させることができます。 次に、デフォルトの実装(または明示的にConfigureAwait(true)を設定)を使用して、デッドロックの潜在的なホールを残します。 次の例を考えてみましょう。



 private async void button1_Click(object sender, EventArgs e) { int result = DoSomeWorkAsync().Result; // 1 } private async Task<int> DoSomeWorkAsync() { await Task.Delay(100).ConfigureAwait(true); //2 return 1; }
      
      







ここでボタンをクリックすると、デッドロックが発生します。 UIスレッドは、行 "2"で新しいI / Oストリームを開始し、行 "1"でスリープモードに入り、作業の完了を待ちます。 I / Oスレッドの実行が完了すると、DoSomeWorkAsyncメソッドの残りの部分が実行のために呼び出し側(UI)スレッドに渡されます。 ただし、この時点ではスリープモードになっており、メソッドが完了するまで待機しています。 デッドロック。



ASP.NETも同じように動作します。 ASP.NETには専用のUIスレッドがないという事実にもかかわらず、コントローラーのアクションのコードは、一度に複数のワーカースレッドで実行できません。



もちろん、デッドロックを回避するために、Resultプロパティにアクセスする代わりにawaitを使用できます。



 private async void button1_Click(object sender, EventArgs e) { int result = await DoSomeWorkAsync(); } private async Task<int> DoSomeWorkAsync() { await Task.Delay(100).ConfigureAwait(true); return 1; }
      
      







しかし、.NETには、デッドロックを回避できない場合が少なくとも1つあります。 ASP.NET MVC子アクション内で非同期メソッドを使用することはできません。 それらはサポートされていません。 したがって、Resultプロパティに直接アクセスする必要があり、コントローラーによって呼び出される非同期メソッドが正しく構成されていない場合、デッドロックが発生します。 たとえば、次のコードを記述しようとして、SomeActionがConfigureAwait(false)で構成されていない非同期メソッドのResultプロパティを参照する場合、再びデッドロックが発生します。



 @Html.Action(“SomeAction“, “SomeController“)
      
      







通常、ライブラリのユーザーはこれらのライブラリのコードに直接アクセスできないため、常に事前に非同期メソッドにConfigureAwait(false)を配置してください。



PLINQとasync / awaitを使用しない方法



例を考えてみましょう:



 private async void button1_Click(object sender, EventArgs e) { btnRead.Enabled = false; string content = await ReadFileAsync(); btnRead.Enabled = true; } private Task<string> ReadFileAsync() { return Task.Run(() => // 1 { using (FileStream fs = new FileStream(“File path”, FileMode.Open)) using (StreamReader sr = new StreamReader(fs)) { return sr.ReadToEnd(); // 2 } }); }
      
      







このコードは非同期に実行されますか? はい このコードは非同期コードを記述する有効な例ですか? いや ここのUIスレッドは、「1」行で新しいCPUバインドスレッドを開始し、制御を返します。 次に、このスレッドは、行 "2"で新しいI / Oストリームを開始し、スリープモードに入り、実行を待機します。



ここで何が起こっていますか? 単一のI / Oストリームを作成する代わりに、ライン「1」でCPUストリームとライン「2」でI / Oストリームの両方を作成します。 これはスレッドの無駄です。 これを修正するには、非同期バージョンのReadメソッドを使用する必要があります。



 private Task<string> ReadFileAsync() { using (FileStream fs = new FileStream(“File path”, FileMode.Open)) using (StreamReader sr = new StreamReader(fs)) { return sr.ReadToEndAsync(); } }
      
      







別の例:



 public void SendRequests() { _urls.AsParallel().ForAll(url => { var httpClient = new HttpClient(); httpClient.PostAsync(url, new StringContent(“Some data”)); }); }
      
      







リクエストを並行して送信しているようです。 はい、できますが、ここでは前の例と同じ問題があります。単一のI / Oストリームを作成する代わりに、各リクエストに対してI / OとCPUにバインドされたストリームの両方を作成します。 Task.WaitAllメソッドを使用して状況を修正できます。



 public void SendRequests() { IEnumerable<Task> tasks = _urls.Select(url => { var httpClient = new HttpClient(); return httpClient.PostAsync(url, new StringContent(“Some data”)); }); Task.WaitAll(tasks.ToArray()); }
      
      







CPUにバインドされたスレッドをバインドせずに、I / O操作を常に実行する必要がありますか?



それは状況次第です。 これが不可能な場合もあれば、コードが非常に複雑になる場合もあります。 たとえば、NHibernateには、データベースからデータを非同期的にロードする機能がありません。 一方、EntityFrameworkにありますが、それを使用することは必ずしも意味がありません。



また、シッククライアント(たとえば、WPFやWinFormsアプリケーション)には通常大きな負荷がないため、これらの最適化はほとんどの場合不要です。 ただし、いずれにしても、特定のケースごとに意識的な決定を下すには、この機能の「内部」で何が起こっているのかを知る必要があります。



元の記事へのリンク: Async / await in C#:pitfalls



All Articles