並列実行のボトルネックを取り除く 7 つの戦略
.NET アプリケーションでスループットを最大化するには、以下の観点から並列処理を設計・実装することが推奨される。
- ロックの衝突を最小化する:排他ロックはスレッドの待機を引き起こすため、
InterlockedやVolatileといったロックフリー操作を活用する。 - スレッドプールを活用する:手動で
Threadインスタンスを生成するよりもThreadPoolまたはTaskを利用することで、スレッドの再利用によるオーバーヘッドを削減できる。 - TPL の自動スケジューリングを信頼する:
Parallel.ForやTask.Runは CPU コア数や負荷を考慮した最適なスレッド割り当てを行う。 - コンテキストスイッチを減らす:非同期 I/O や
awaitを用いてブロッキングを回避し、不要なスレッドの休眠・復帰を防ぐ。 - メモリの動的確保を抑制する:
ArrayPool<T>やカスタムオブジェクトプールを導入し、GC 圧力を軽減する。 - Concurrent コレクションを標準化する:
ConcurrentQueue<T>やConcurrentDictionary<TKey,TValue>は内部でロックの分割やロックフリーアルゴリズムを実装しており、自前の同期機構を削減できる。 - 非同期プログラミングを第一選択にする:
async/awaitパターンにより UI フリーズやスケーラビリティの問題を回避できる。
ロックフリー同期の実装パターン
1. インクリメンタルカウンタの高速化
単純なカウンタを複数スレッドから安全に更新するには、Interlocked API を用いる。
public sealed class AtomicCounter
{
private int _value;
public int Increment() => Interlocked.Increment(ref _value);
public int Decrement() => Interlocked.Decrement(ref _value);
public int Read() => Volatile.Read(ref _value);
}
2. 読み取りが多いデータ構造への ReaderWriterLockSlim の適用
読み取りが頻繁で書き込みが稀なリソースでは、ReaderWriterLockSlim を使って読み取り側の並行性を維持する。
public sealed class SharedCache
{
private readonly ReaderWriterLockSlim _rw = new();
private int _data;
public int Get()
{
_rw.EnterReadLock();
try { return _data; }
finally { _rw.ExitReadLock(); }
}
public void Set(int value)
{
_rw.EnterWriteLock();
try { _data = value; }
finally { _rw.ExitWriteLock(); }
}
}
3. 不変オブジェクトによる共有状態の排除
データを更新する際に新しいインスタンスを生成し、古い参照を置き換えることで、ロックを一切使用せずにスレッドセーフにする。
public sealed record MetricSnapshot(int Requests, int Errors);
public sealed class Metrics
{
private volatile MetricSnapshot _snapshot = new(0, 0);
public MetricSnapshot Read() => _snapshot;
public void AddRequest(bool isError)
{
var current = _snapshot;
var updated = new MetricSnapshot(
current.Requests + 1,
current.Errors + (isError ? 1 : 0));
_snapshot = updated; // 参照の更新はアトミック
}
}
非同期・並列プログラミングの基礎コードスニペット
スレッドの起動
ThreadPool.QueueUserWorkItem(_ => Console.WriteLine("Hello from ThreadPool"));
排他制御(lock)
object _sync = new();
int counter = 0;
Parallel.For(0, 1_000_000, _ =>
{
lock (_sync) counter++;
});
ミューテックスによるプロセス間同期
using var m = new Mutex(false, "Global\\MyUniqueName");
m.WaitOne();
try { /* クリティカルセクション */ }
finally { m.ReleaseMutex(); }
デッドロックを引き起こす典型的なコード
var a = new object();
var b = new object();
Task.Run(() => { lock (a) { Thread.Sleep(100); lock (b) { } } });
Task.Run(() => { lock (b) { Thread.Sleep(100); lock (a) { } } });
上記の例では、2 つのタスクが異なる順序でロックを取得しようとするため、永遠に待機する可能性がある。