C# で高効率な並列処理を実現するための実践テクニック

並列実行のボトルネックを取り除く 7 つの戦略

.NET アプリケーションでスループットを最大化するには、以下の観点から並列処理を設計・実装することが推奨される。

  1. ロックの衝突を最小化する:排他ロックはスレッドの待機を引き起こすため、InterlockedVolatile といったロックフリー操作を活用する。
  2. スレッドプールを活用する:手動で Thread インスタンスを生成するよりも ThreadPool または Task を利用することで、スレッドの再利用によるオーバーヘッドを削減できる。
  3. TPL の自動スケジューリングを信頼するParallel.ForTask.Run は CPU コア数や負荷を考慮した最適なスレッド割り当てを行う。
  4. コンテキストスイッチを減らす:非同期 I/O や await を用いてブロッキングを回避し、不要なスレッドの休眠・復帰を防ぐ。
  5. メモリの動的確保を抑制するArrayPool<T> やカスタムオブジェクトプールを導入し、GC 圧力を軽減する。
  6. Concurrent コレクションを標準化するConcurrentQueue<T>ConcurrentDictionary<TKey,TValue> は内部でロックの分割やロックフリーアルゴリズムを実装しており、自前の同期機構を削減できる。
  7. 非同期プログラミングを第一選択にする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 つのタスクが異なる順序でロックを取得しようとするため、永遠に待機する可能性がある。

タグ: C# ThreadPool TPL lock-free Interlocked

5月31日 08:42 投稿