記事内にはプロモーションが含まれています

【C#】排他制御の基本と実装パターン!lock・Mutex・SemaphoreSlimの使い分けまで

【C#】排他制御の基本と実装パターン!lock・Mutex・SemaphoreSlimの使い分けまで C#

C#でマルチスレッド処理や非同期処理を行う際、避けて通れないのが「排他制御(Exclusive Control)」です。

複数のスレッドが同時に同じデータやファイルにアクセスすると、データの不整合やファイルの破損、最悪の場合はアプリケーションのクラッシュを引き起こします。

「スレッドセーフなコードを書きたい」
「lockステートメントとMutexはどう違うの?」
「async/awaitの中でロックを使いたい」

こうした悩みを解決するために、この記事ではC#における排他制御の基本概念から、lock ステートメントを使った手軽な実装、プロセス間排他を実現する Mutex、そして非同期メソッドでも使える SemaphoreSlim まで、現在の開発現場で役立つ知識を徹底解説します。

【本記事の信頼性】
プロフィール
執筆者:マヒロ
  • 執筆者は元エンジニア
  • SES⇒大手の社内SE⇒独立
  • 現在はこじんまりとしたプログラミングスクールを運営
  • モットーは「利他の精神」

排他制御とは?なぜ必要なのか

排他制御とは、ある処理を行っている間、他の処理が割り込まないように「ロック(鍵)」をかける仕組みのことです。

例えば、銀行口座の残高を更新する処理を考えてみましょう。

「残高を読み取る」→「金額を足す」→「残高を書き込む」という一連の流れの途中で、別のスレッドが割り込んで残高を読み取ってしまうと、計算結果がおかしくなってしまいます(競合状態)。

これを防ぐために、「私が使い終わるまで、誰も手出ししないでね」と宣言するのが排他制御です。

最も基本的な排他制御「lock」ステートメント

C#でスレッド間の排他制御を行う最も一般的で簡単な方法は、lock ステートメントを使用することです。

lock ブロックで囲まれた範囲は、一度に一つのスレッドしか実行できません。

lockの基本的な書き方

using System;
using System.Threading;
using System.Threading.Tasks;

class BankAccount
{
    private int balance = 1000;
    // ロック専用のオブジェクトを用意する
    private readonly object lockObj = new object();

    public void Deposit(int amount)
    {
        // ここから排他制御を開始
        lock (lockObj)
        {
            Console.WriteLine($"現在の残高: {balance}, 預入額: {amount}");
            
            // 処理中に他のスレッドが来ても、ここで待たされる
            balance += amount;
            
            Console.WriteLine($"更新後の残高: {balance}");
        }
        // ここでロック解除
    }
}

class Program
{
    static void Main()
    {
        BankAccount account = new BankAccount();

        // 複数のスレッドから同時にアクセスさせる
        Parallel.Invoke(
            () => account.Deposit(100),
            () => account.Deposit(200),
            () => account.Deposit(300)
        );
    }
}

ソースコードの解説

  1. ロックオブジェクト: private readonly object lockObj = new object(); で、鍵となるオブジェクトを作成します。thistypeof(MyClass) をロックに使うのは、外部からのロックと干渉するリスクがあるため推奨されません。
  2. lock (lockObj): このブロックに入ろうとしたスレッドは、lockObj が誰にも使われていなければブロックに入り、ロックをかけます。もし他のスレッドが使用中であれば、ロックが解除されるまで待機します。

この仕組みにより、データの整合性が保たれます。

プロセス間での排他制御には「Mutex」

lock ステートメントは、あくまで「同じアプリケーション(プロセス)内のスレッド間」での排他制御にしか使えません。

「アプリを二重起動させたくない(多重起動防止)」といった、異なるプロセス間での排他制御には Mutex(ミューテックス)クラスを使用します。

アプリの多重起動を防止するサンプル

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        // Mutex名を指定して作成(Global\を付けると全ユーザーで共有)
        string mutexName = "Global\\MyUniqueAppMutex";
        bool createdNew;

        // Mutexの取得を試みる
        // createdNewがtrueなら、新しく作成された(=まだ起動していない)
        using (var mutex = new Mutex(false, mutexName, out createdNew))
        {
            if (!createdNew)
            {
                // すでに起動している場合
                Console.WriteLine("すでにアプリは起動しています。終了します。");
                return; // アプリ終了
            }

            Console.WriteLine("アプリを起動しました。Enterキーで終了...");
            Console.ReadLine();
        }
    }
}

new Mutex(..., "名前", out createdNew) で、システム全体で共有される名前付きのMutexを作成しようとします。

もし同じ名前のMutexが既に存在していれば、createdNewfalse になります。

これを利用して、2つ目以降の起動をブロックすることができます。

非同期処理(async/await)での排他制御「SemaphoreSlim」

現代のC#開発では async/await を多用しますが、実は lock ステートメントの中では await を使うことができません(コンパイルエラーになります)。

非同期メソッド内で排他制御を行いたい場合は、SemaphoreSlim(セマフォスリム)クラスを使用します。

asyncメソッド内でのロックの実装

using System;
using System.Threading;
using System.Threading.Tasks;

class AsyncResource
{
    // 初期値1、最大値1のセマフォ(=ロックと同じ動作)
    private static SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);

    public static async Task AccessAsync(string name)
    {
        Console.WriteLine($"{name} がロック待機中...");

        // ロックを取得(非同期に待機)
        await semaphore.WaitAsync();

        try
        {
            Console.WriteLine($"{name} がロックを取得!処理開始");
            await Task.Delay(1000); // 重い処理のシミュレーション
            Console.WriteLine($"{name} が処理終了");
        }
        finally
        {
            // 必ずfinallyで解放する
            semaphore.Release();
            Console.WriteLine($"{name} がロックを解放");
        }
    }
}

class Program
{
    static async Task Main()
    {
        // 3つの処理を同時に開始
        var t1 = AsyncResource.AccessAsync("Task A");
        var t2 = AsyncResource.AccessAsync("Task B");
        var t3 = AsyncResource.AccessAsync("Task C");

        await Task.WhenAll(t1, t2, t3);
    }
}

ソースコードの解説

  • SemaphoreSlim(1, 1): 同時にアクセスできる数を「1」に制限することで、排他制御として機能させます。
  • await semaphore.WaitAsync(): ロックが空くのを非同期に待ちます。スレッドをブロックしないため、UIスレッドなどをフリーズさせずに待機できます。
  • try-finally: lock 構文と違って自動で解放されないため、例外が発生しても確実に Release() が呼ばれるように try-finally ブロックで囲むのが鉄則です。

読み書きロック「ReaderWriterLockSlim」

「読み取りは同時に何人でもOKだけど、書き込み中だけは誰もアクセスさせたくない」というケースでは、ReaderWriterLockSlim を使うとパフォーマンスが向上します。

頻繁にデータを参照するキャッシュシステムなどで有効です。

using System;
using System.Threading;

class CacheData
{
    private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim();
    private int data = 0;

    // 読み取り(複数スレッド同時実行OK)
    public int Read()
    {
        rwLock.EnterReadLock();
        try
        {
            return data;
        }
        finally
        {
            rwLock.ExitReadLock();
        }
    }

    // 書き込み(他をブロックする)
    public void Write(int value)
    {
        rwLock.EnterWriteLock();
        try
        {
            data = value;
        }
        finally
        {
            rwLock.ExitWriteLock();
        }
    }
}

C#のスキルを活かして年収を上げる方法

以上、C#での排他制御の基本と実装パターンについて解説してきました。

なお、C#のスキルがある場合には、「転職して年収をアップさせる」「副業で稼ぐ」といった方法を検討するのがおすすめです。

業務システム開発やアプリ開発、ゲーム開発において需要の高いC#を扱えるエンジニアは、転職によって数十万円の年収アップはザラで、100万円以上年収が上がることも珍しくありません。

なお、転職によって年収を上げたい場合は、エンジニア専門の転職エージェントサービスを利用するのが最適です。

今すぐ転職する気がなくとも、とりあえず転職エージェントに無料登録しておくだけで、スカウトが届いたり、思わぬ好待遇の求人情報が送られてきたりするというメリットがあります。

併せて、副業案件を獲得できるエージェントにも登録しておくと、空いている時間を活かして稼げるようなC#の案件を探しやすくなります。

転職エージェントも副業エージェントも、登録・利用は完全無料なので、どんな求人や副業案件があるのか気になる方は、気軽に利用してみるとよいでしょう。

C#
スポンサーリンク
code-izumiをフォローする