C#を用いたモダンなアプリケーション開発において、マルチスレッドや非同期処理の制御は避けて通れない重要なテーマです。

複数のタスクが同時に実行される環境では、限られたリソースへのアクセスを適切に制限しなければ、データの整合性が失われたり、システム全体が過負荷に陥ったりするリスクがあります。

本記事では、軽量で高機能な同期プリミティブであるSemaphoreSlimクラスに焦点を当て、その基礎から実践的な活用術までを詳しく掘り下げていきます。

効率的で安全な並列プログラミングを実現するための手法を、具体的なコード例と共に学んでいきましょう。

セマフォとSemaphoreSlimの基本概念

セマフォとは、共有リソースに同時にアクセスできるスレッドの数を制限するためのカウンティングメカニズムです。

C#には古くからSemaphoreクラスが存在していましたが、現在の主流はより軽量化されたSemaphoreSlimクラスの使用です。

SemaphoreとSemaphoreSlimの違い

Semaphoreクラスは、複数のプロセス間でリソースを共有するための名前付きセマフォをサポートしています。

これに対し、SemaphoreSlimは同一プロセス内での利用に最適化されており、OSのカーネルオブジェクトを生成しないため、オーバーヘッドが極めて小さいのが特徴です。

また、SemaphoreSlim非同期プログラミング(async/await)をフルサポートしているため、現代の.NET開発においてはこちらを選択するのが一般的です。

特徴SemaphoreSemaphoreSlim
スコーププロセス間共有可能単一プロセス内のみ
非同期対応非対応対応 (WaitAsync)
パフォーマンス低 (カーネル遷移あり)高 (ユーザーモード優先)

SemaphoreSlimの基本的な使い方

SemaphoreSlimを使用する際は、まずインスタンス化の際に「同時に許可するスレッド数」を指定します。

基本的な操作は、リソースの取得を待機するWait(またはWaitAsync)と、リソースを解放するReleaseの2つです。

基本的な同期処理の例

以下のコードは、最大で3つのスレッドだけが同時に特定の処理を実行できるように制限する例です。

C#
using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    // 同時に実行可能なスレッド数を3に設定
    private static SemaphoreSlim _semaphore = new SemaphoreSlim(3, 3);

    static void Main(string[] args)
    {
        for (int i = 1; i <= 10; i++)
        {
            int id = i;
            Task.Run(() => AccessResource(id));
        }

        Console.ReadLine();
    }

    static void AccessResource(int id)
    {
        Console.WriteLine($"スレッド {id} が待機しています...");
        
        // セマフォの空きを待つ
        _semaphore.Wait();

        try
        {
            Console.WriteLine($"スレッド {id} がリソースにアクセスしました。");
            // 擬似的な作業時間
            Thread.Sleep(2000);
        }
        finally
        {
            Console.WriteLine($"スレッド {id} がリソースを解放しました。");
            // セマフォを解放
            _semaphore.Release();
        }
    }
}
実行結果
スレッド 1 が待機しています...
スレッド 2 が待機しています...
スレッド 3 が待機しています...
スレッド 1 がリソースにアクセスしました。
スレッド 2 がリソースにアクセスしました。
スレッド 3 がリソースにアクセスしました。
(2秒後)
スレッド 1 がリソースを解放しました。
スレッド 4 がリソースにアクセスしました。
...

try-finallyブロックの重要性

Waitを呼び出した後は、必ずfinallyブロック内でReleaseを呼び出すように徹底してください。

もし例外が発生してReleaseが呼ばれない場合、セマフォのカウントが戻らず、他のスレッドが永久に待機し続ける「デッドロック」のような状態に陥ります。

非同期プログラミングにおけるSemaphoreSlimの真価

SemaphoreSlimの最大のメリットは、非同期メソッドであるWaitAsyncを備えている点にあります。

従来のlock構文は、awaitを含むコードブロック内では使用できないという制約があります。

async/await環境での排他制御

非同期処理の中で「一度に1つのタスクしか実行させたくない(クリティカルセクションの保護)」という場合、SemaphoreSlim(1, 1)を使用することで、非同期対応のミューテックスとして機能させることができます。

C#
private static SemaphoreSlim _asyncLock = new SemaphoreSlim(1, 1);

public async Task ProcessDataAsync()
{
    // 非同期で待機するため、スレッドをブロックしません
    await _asyncLock.WaitAsync();
    
    try
    {
        // 非同期作業を実行
        await Task.Delay(1000);
        // 重複実行が禁止された安全な処理
    }
    finally
    {
        _asyncLock.Release();
    }
}

このようにWaitAsyncを利用することで、待機中もスレッドを解放し、スレッドプールの効率的な運用を妨げないようにできます。

実践シナリオ1:API呼び出しのスロットリング(流量制限)

外部サービスのAPIを呼び出す際、短時間に大量のリクエストを送るとサーバー側でレート制限(429 Too Many Requests)がかかることがあります。

このような場合、SemaphoreSlimを使用して同時実行リクエスト数を制御するのが効果的です。

C#
public class ApiClient
{
    private readonly HttpClient _httpClient = new HttpClient();
    // 同時リクエスト数を5に制限
    private readonly SemaphoreSlim _throttler = new SemaphoreSlim(5);

    public async Task<string> FetchDataWithThrottlingAsync(string url)
    {
        await _throttler.WaitAsync();
        try
        {
            // 同時に実行されるリクエストは5つまでに制限される
            var response = await _httpClient.GetStringAsync(url);
            return response;
        }
        finally
        {
            _throttler.Release();
        }
    }
}
</string>

このパターンを導入することで、アプリケーション全体の安定性を向上させ、外部リソースへの負荷を抑制することが可能になります。

実践シナリオ2:DB接続や重いリソースの管理

データベースのコネクション数には上限があり、あまりに多くのクエリを並行して投げると、接続プールが枯渇する原因になります。

また、メモリを大量に消費する画像処理タスクなどを並列実行する場合も、無制限にタスクを立ち上げるとOutOfMemoryExceptionを誘発しかねません。

リソース管理における注意点

セマフォの初期数は、実行環境のCPUコア数やメモリ容量、依存する外部システムの仕様に基づいて慎重に設計する必要があります。

多くの場合、Environment.ProcessorCountを基準にして、最大並列数を動的に決定する手法が取られます。

タイムアウトとキャンセルへの対応

ネットワーク遅延やデッドロックの可能性を考慮し、無限に待機し続けるのは避けるべきです。

WaitAsyncメソッドには、タイムアウト時間やCancellationTokenを渡すオーバーロードが用意されています。

C#
public async Task ExecuteTaskWithTimeoutAsync(CancellationToken ct)
{
    // 5秒待機しても取得できない場合は諦める
    bool entered = await _semaphore.WaitAsync(TimeSpan.FromSeconds(5), ct);
    
    if (!entered)
    {
        throw new TimeoutException("セマフォの取得に失敗しました。");
    }

    try
    {
        // 処理実行
    }
    finally
    {
        _semaphore.Release();
    }
}

このように、タイムアウト処理を組み込むことで、システムのレジリエンス(回復力)を高めることができます。

SemaphoreSlimを使用する際のベストプラクティス

効率的な並列制御を行うために、以下のポイントに留意してください。

  • 適切な初期値の設定: 同時実行数を1に設定すれば「ミューテックス(排他制御)」、2以上に設定すれば「リソースプール」として機能します。
  • Disposeの実施: セマフォ自体が不要になった場合は、Dispose()を呼び出してアンマネージリソースを適切に解放してください。
  • 過剰なロックの回避: ロックの範囲(クリティカルセクション)は必要最小限に留め、パフォーマンスの低下を防ぎます。
  • 二重解放の禁止: Release()を1つのWait()に対して複数回呼び出すと、セマフォのカウントが不当に増加し、意図しない並列実行を許してしまいます。

よくある間違い:Waitの呼び出し忘れ

稀に、Releaseのみを記述してしまう、あるいは条件分岐によってWaitをバイパスしてしまうケースが見受けられます。

常に「WaitとReleaseはペアである」という意識を持ち、構造化されたコードを記述することが重要です。

高度な活用:動的なスロット数変更

SemaphoreSlimの興味深い特性として、実行中にReleaseを呼び出すことで、セマフォの最大容量(最初に設定した値)を超えてカウントを増やすことが可能です。

ただし、これは混乱を招く原因となるため、コンストラクタで指定した最大数(maxCount)を超えないように制御するのが一般的です。

負荷状況に応じて動的に同時実行数を制御したい場合は、セマフォの数を管理するラッパークラスを作成し、アプリケーションの稼働状況に合わせて調整を行う仕組みを構築することも検討に値します。

まとめ

SemaphoreSlimは、C#における並列処理と非同期制御の要となる非常に強力なクラスです。

スレッドセーフなアプリケーションを構築する上で、単なる排他制御に留まらず、リソースのスロットリングや非同期ロックなど、その用途は多岐にわたります。

特にWaitAsyncを活用することで、高いスケーラビリティを維持したまま、複雑な並行処理を安全に記述できる点は、他の同期プリミティブにはない大きな魅力です。

今回解説した基本的な使い方から、タイムアウトやキャンセル対応、実務レベルのスロットリング手法までをマスターすることで、堅牢でパフォーマンスの高いC#アプリケーションを開発できるようになるでしょう。

まずは小さなプロジェクトの非同期処理からSemaphoreSlimを導入し、その利便性を体感してみてください。