(An English version of this article is also available.)
C#ではいくつかの並列処理の方法が用意されていますが、それらに関してのメモです。C#が備える非同期・並列処理を網羅的にカバーしているわけではありませんが、個人的に使用しているものを中心に書いています。
タスク系
async/await処理
特に入出力関連で、async
処理は広く行われていますので、C#をそれなりに利用している人は必ずといって見たことがある処理だと思います。
構文的には以下のようになります。
private async Task Caller()
{
await DoProcess();
}
private async Task DoProcess()
{
// 処理
}
上記の場合は普通にawait
をかけているだけなので、事実上、同期処理と変わりません。これ役に立つのはプロセスを非同期的に行う必要があり、呼び出し側でその他の処理を行う余地がある場合です。例えば、以下のようなプロセスの場合、await process
の部分で、処理が終了するまで待機します。
private async Task Caller()
{
var process = DoProcess();
// 他のことを継続して実行
await process;
}
private async Task DoProcess()
{
// 非同期処理
}
async
を使う場合は、戻り値はTask
を使用します。Task
を使用する場合、戻り値を指定することはできませんので、戻り値を返したい場合は型を指定し、次のようにします。
private async Task Caller()
{
var process = DoProcess();
// 他のことを継続して実行
await process;
}
private async Task<int> DoProcess()
{
int x;
// 非同期処理
return x;
}
尚、await
を使うのには呼び出し側がasync
で定義されている必要がありますが、そうでない場合は、以下のような構文が利用できます。
private Task Caller()
{
DoProcess().Wait();
}
private async Task DoProcess()
{
// 非同期処理
}
非同期メソッドが戻り値を持つ場合は、.Wait()
の代わりに.Result
を使います。
private Task Caller()
{
var result = DoProcess().Result;
}
private async Task<int> DoProcess()
{
int x;
// 非同期処理
return x;
}
async
で定義されたメソッドにおいて、await
が使われていないと警告が出ます。これは以下で抑制できます。
private async Task Caller()
{
await Task.CompletedTask;
}
この形式は、非同期的な処理(入出力等)完了する時間が特定できない場合の処理などにおいて役に立ちますが、並列処理としての使用は非常に冗長的になり、あまり快適にはなりません。そういった場合は他の方法を使用します。
BackgroundWorker
もう一つ非同期の処理を実現する方法として、BackgroundWorkerがあります。これはWindows Formで非同期処理を実現したい場合において、使用できる(GUIの更新を伴うasync
やawait
の使用は安全ではないため)ので代わりに使用できる他、進捗ステータスのレポート、終了処理、キャンセル処理など定義することも可能で機能は多いのですが、その分、冗長な記述が必要になります。
処理中のライフタイムの管理が細かくできる点から、あるまとまった処理があり、それを一つの処理として非同期処理に回したい、という場合において便利に使用できる機能です。
並列系
PLINQ(コレクションに対しての並列実行)
LINQは言わずもがな、C#が備える強力な機能ですが、これの並列処理版が用意されています。これをPLINQといいます。
PLINQにおいては.AsParallel
を入れることにより、並列的なデータ取り出し等を行うことができます。
var data = source.AsParallel().Where(n => n % 10 == 0).Select(n => n);
この場合においてはライブラリ側でコレクションが精査され、並列処理が安全、またはメリットがあるという結果になった場合において、並列処理になります。そうでなければ逐次的に実行されます。
どの程度の並行処理を行うか、という設定は.WithDegreeOfParallelism()
で調整します。
var data = source.AsParallel().WithDegreeOfParallelism(2).Where(n => n % 10 == 0).Select(n => n);
ネットワークリソースアクセスが絡む場合など、同時アクセスに制約がある場合に使用できます。これが指定されない場合、コア数を元に自動的に設定されます。
この状態においては順序に関しては保証されないので、順序が重要になってくる場合は.AsOrdered()
を使用します。
var data = source.AsParallel().AsOrdered.Where(n => n % 10 == 0).Select(n => n);
これは使用しない場合に比べ、オーバーヘッドがより高くなります。
データの取り出しの他、何らかの処理をしたい、という場合があるかと思いますが、その場合は.ForAll()を使うことができます。
これを使用すると、コレクションに対し、ラムダ式を指定し、非同期的に処理を行うことができます。
source.AsParallel().ForAll(p => {
p.value = p.value * 2;
});
この実行時に.AsOrdered()
も使用することができますが、並列処理が開始される順序は既知である場合においても処理が実際に終了する時間は開きが出てくるため、必ずしも順序が保全されて出てくるとは限りませんので、この場合は後ほどソートなどを行う必要が出てくると思います。
Parallel処理(Task Parallel Library)
PLINQはコレクションに対して行うことのできる処理ですが、汎用的に並列処理を使用するための機構として、Task Parallel Libraryというものが用意されています。
これは以下のような構文を持ちます。
Parallel.ForEach(source, p => {
p.value = p.value * 2
});
PLINQと似ていますが、これは実際にはPLINQが同じ機構を利用しているためです。PLINQでは用意されていない機構として、Parallel.For
も使用できます。
Parallel.For(0, 100, index => {
// 処理
});
これはForループに似た機構を並列的に実行することができます。
ロック
並列的に実行しながら、他のコレクションに結果を追加していきたい、というようなニーズがあるかと思います。例えば次のようなシーンです。
source.AsParallel().ForAll(p => {
p.value = p.value * 2;
result.Add(p);
});
これはこのままだと問題が発生します。というのも、result.Add(p)
の実行タイミングによっては正常に追加されないからです。
このような場合は、データをロックする必要があります。
source.AsParallel().ForAll(p => {
p.value = p.value * 2;
lock(result)
{
result.Add(p);
}
});
その他
C#ではその他、よりローレベルなスレッド処理を実現するための機構が用意されています。より複雑な処理などを行うために必要な処理を実装するためにはこれらを使用することになります。
資料
マイクロソフトの資料で非同期・並列処理に関しては非常に詳しくまとめられています。
また、上記の資料にはここでは取り上げていない、それぞれの機能の更に詳細なメソッドやパラメーターなども説明されています。
文中で紹介した、BackgroundWorkerについては以下を参照して下さい。
最後に
多彩なデータを取り扱う場合に置いて非同期処理や並列処理は重要になります。非同期、並列処理は複雑になりがちですが、C#ではそれを簡単に実装するための機構が用意されています。解決する課題によって使用可能なツールが多数用意されているので、この記事がその活用の入り口になれればと考えております。