Menu

C#のTask:Task.Run、WhenAll、WhenAny、キャンセル、スレッド

Taskは.NETにおける非同期処理と並列処理の単位です。CPUバウンドの処理のためのTask.Run、結果の読み取り、WhenAllとWhenAnyによるタスクの組み合わせ、継続、CancellationTokenによるキャンセル、TaskとThreadの違い、そしてParallel.Forのほうが適している場面を学びます。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

Task は進行中の処理を表すオブジェクトです。スレッドプールのスレッドで実行されているかもしれないし、タイマーやネットワークの応答を待っているかもしれません。いずれにしても、終わったかどうかを尋ね、結果を取得し、それを待ち、さらに処理を付け加えることができます。asyncとawaitは、タスクの上にある言語の構文です。このページでは Task のAPIそのものと、スレッドを扱います。

Task.Run:スレッドプールでの処理

Task.Run はラムダを受け取り、それをスレッドプールのキューに入れます。スレッドプールは、処理のたびに新しいスレッドを作らなくて済むように.NETが保持しているスレッドの集まりです。Task を返し、ラムダが値を返すなら Task<T> を返します。

出力:

Started, completed yet: False
Primes up to 200,000: 17984
Status: RanToCompletion

Result は、タスクが終わるまで呼び出し元のスレッドをブロックします。コンソールプログラムの Main なら許容できますが、非同期コードの中では、ブロックせずに待つ int primes = await task; と書きます。結果のない Task に対する task.Wait() も同じです。

Task.Run はCPUバウンドの処理、つまりコアを忙しくさせる計算のためのものです。ファイルの読み込みやWeb APIの呼び出しのようなI/Oには、ライブラリがすでに提供している非同期メソッド(File.ReadAllTextAsync、HttpClient.GetStringAsync)を使い、直接awaitします。I/Oの完了を待っている間、awaitされた非同期呼び出しはスレッドを一つも保持しないので、Task.Run で包んでも得るものはなく、スレッドプールを経由する手間が一回増えるだけです。プールのスレッドを本当に占有するのは、File.ReadAllText のような同期のブロッキング呼び出しを Task.Run で包んだ場合です。そのスレッドはディスクやネットワークが応答するまで何もせずに待ち続けます。

複数のタスクを同時に:WhenAll

独立した処理をタスクに分け、すべてを開始し、Task.WhenAll で結果をまとめます。複数のコアを持つマシンでは、各部分が並列に実行されます。

出力:

Parts: 14999995, 14999999, 15000003, 15000000
Total: 59999997

各チャンクは独自の範囲を処理して独自の合計を返すので、タスクは何も共有せず、ロックも不要です。WhenAll は、スレッドがどの順で終わったかに関係なく、タスクの順序で結果を返します。タスクが共有の状態を更新する必要がある場合は、lockを参照してください。

WhenAnyとタイムアウト

Task.WhenAny は、タスクのうち最初のものが完了したときに完了し、そのタスクを返します。本来の処理を Task.Delay と競わせると、タイムアウトになります。

出力:

fast mirror answered after 100 ms
Timed out after 300 ms

WhenAny が返すのは、勝ったタスクの結果ではなくタスクそのものなので、値(失敗した場合は例外)を得るにはもう一度 await します。負けたタスクは実行され続けることに注意してください。WhenAny はそれらを止めません。遅い処理を実際に止めるには、後で示すようにトークンでキャンセルします。.NET 6以降では、await work.WaitAsync(TimeSpan.FromMilliseconds(300)) で同じタイムアウトを1回の呼び出しで表せます。

ContinueWithによる継続

await 以前は、タスクが終わった後にコードを実行する方法は ContinueWith でした。古いコードでは今でも見かけます。

出力:

Report total: 42

各継続は前のタスクを受け取り、それが完了したときに実行されます。await は同じことをはるかに少ない手間で行い、例外を自然に扱い、正しいコンテキストに戻るので、新しいコードではそちらを優先します。ContinueWith は前のタスクが失敗したりキャンセルされたりしても実行されるので、古いコードでは、失敗したタスクの .Result を呼ばないように、t.IsFaulted を確認するか TaskContinuationOptions を渡す必要があります。

CancellationTokenによるキャンセル

タスクは協調的にキャンセルされます。CancellationTokenSource がトークンを発行し、処理を行うコードがトークンを確認して自分で止まります。強制的に中断されるものは何もありません。

出力:

Cancelled before finishing all 10 orders
Processed 3

トークンはすべての層を通して渡され、Task.Delay、HttpClient、データベースの呼び出しなどの非同期APIもトークンを受け取るので、キャンセルは待機の途中でもそれらを止めます。OperationCanceledException をキャッチします。一部のAPIが投げる TaskCanceledException はそこから派生しています。CancellationTokenSource は IDisposable なので、using を使っています。

ThreadとTaskの比較

Thread は自分で作るオペレーティングシステムのスレッドです。より重く、それぞれが独自のスタック(Windowsでは既定で1 MB)を持ち、開始にはそれなりの時間がかかり、結果も例外処理も提供しません。

出力:

Thread started
Result: 42
ThreadTask
実行される場所新しい専用のOSのスレッドプールのスレッド(I/Oならスレッドなし)
開始のコスト高い低い
結果の値なし、変数を共有するTask<T>.Result / await
例外処理されないとプロセスがクラッシュするタスクに保存され、await で再スローされる
キャンセル手動のフラグCancellationToken が組み込み
完了を待つJoin()await、Wait()、WhenAll

Thread を使うのは、プールでは得られないものが必要なときだけにします。プールのスレッドを決して借りるべきでない長時間のループ、特定の優先度、COMのためのシングルスレッドアパートメントなどです。長時間実行されるタスクには、Task.Factory.StartNew(work, TaskCreationOptions.LongRunning) を使えば、タスクのAPIを保ったまま専用のスレッドを得られます。

Parallel.ForとParallel.ForEach

「すべてのコアを使って、すべての要素にこれを行う」なら、System.Threading.Tasks.Parallel のほうが手動でタスクを作るより簡単です。範囲をスレッドに分割し、すべての反復が終わったときに戻ります。

出力例:

image 4 on thread 7
image 3 on thread 6
image 5 on thread 8
image 0 on thread 1
image 1 on thread 4
image 2 on thread 5
Total pixels: 385425

画像ごとの行はスレッドが実行した順序で出力され、スレッドIDも実行のたびに変わります。決まっているのは合計だけです。これが並列ループの性質です。反復は互いの順序に依存してはならず、共有の状態には同期が必要です(ここでは Interlocked.Add)。Parallel.ForEach は任意のコレクションに対して同じことを行い、.NET 6では非同期の本体のための Parallel.ForEachAsync が追加されました。

よくある間違い

  • 非同期コードの中での .Result や .Wait()。 スレッドをブロックし、UIアプリではデッドロックすることがあります。代わりに await します。
  • .Result の周りで元の例外の型をキャッチする。 失敗は AggregateException に包まれて届きます。await ならそれを展開します。
  • 投げっぱなし。 awaitもタスクの保存もしない Task.Run(...) は、その例外を失います。
  • I/Oを Task.Run で包む。 非同期メソッドを包むとスレッドプールを経由する手間が増えるだけで、File.ReadAllText のようなブロッキングするメソッドを包むと待ち時間の間ずっとプールのスレッドを一つ占有します。非同期のI/Oメソッドを直接awaitします。
  • トークンを無視する。 処理がチェックしない限り、CancellationToken を渡しても何も起きません。
  • 並列コードでの同期されていない共有の状態。 Interlocked や lock を使うか、各タスクに独自のデータを持たせます。

よくある質問

C#のTaskとは何ですか?

Task は、将来終わるかもしれない処理を表します。スレッドプールでのコードの実行、タイマー、I/Oの要求などです。awaitでき、完了したか、失敗したか、キャンセルされたかを報告し、Task<T> は結果も持ちます。タスクは async/await の土台となる部品です。

C#のTaskとThreadの違いは何ですか?

Thread は自分で作って管理するオペレーティングシステムのスレッドで、独自のスタックを持ち、開始するコストが高く、値を返しません。Task は処理を表し、Task.Run はそれを再利用可能なスレッドの共有プールで実行するよう予約します。結果を返し、例外を伝え、キャンセルでき、awaitできます。特別な設定を持つ専用の長寿命なスレッドが必要でない限り、タスクを使います。

Task.Runはいつ使うべきですか?

現在のスレッドから外したいCPUバウンドの処理に使います。画像処理、大きな計算、メモリ上の大きなファイルの解析などで、特にUIの応答性を保つためです。I/Oを Task.Run で包んではいけません。ReadAllTextAsync のような非同期I/Oのメソッドは、待っている間すでにスレッドを解放しています。

C#でTaskをキャンセルするには?

CancellationTokenSource を作り、その Token を処理に渡し、ソースに対して Cancel()(または CancelAfter(timeout))を呼びます。処理は協調しなければなりません。ループの中で token.IsCancellationRequested を確認するか token.ThrowIfCancellationRequested() を呼び、Task.Delay のような非同期APIにトークンを渡します。キャンセルされたタスクをawaitすると OperationCanceledException が投げられます。

Task.WhenAllとTask.WhenAnyの違いは何ですか?

Task.WhenAll はすべてのタスクが完了したときに完了し、すべての結果を返します。Task.WhenAny は最初のタスクが完了した時点で完了し、そのタスクを返します。WhenAny は、処理を Task.Delay と競わせてタイムアウトを加える一般的な方法です。

Task.ResultがAggregateExceptionを投げるのはなぜですか?

タスクは一般に複数の例外を保持できるので、.Result と .Wait() はどんな失敗も AggregateException で包みます。await はそれを展開して最初の内部例外を直接再スローします。これが await を優先する理由の1つです。どうしてもブロックする必要があるなら、.GetAwaiter().GetResult() も元の例外を投げます。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める