C#の排他制御を完全解説|lock・Monitor・Mutex・SemaphoreSlimの違いと安全な使い方

はじめに

C#でマルチスレッド処理や非同期処理を書くときに避けて通れないのが「排他制御」です。

排他制御とは、複数のスレッドやタスクが同じデータ・同じファイル・同じリソースへ同時にアクセスしたときに、不整合やデータ破損が起きないようにするための仕組みです。

C#には、代表的な排他制御の手段として次のようなものがあります。

種類主な用途
lock同一プロセス内で最も基本的な排他制御
Monitorlockより細かく制御したい場合
Mutexプロセス間の排他制御
SemaphoreSlim非同期処理や同時実行数の制限
ReaderWriterLockSlim読み取りが多い処理の最適化
Interlocked単純な数値操作の高速な同期

この記事では、C#の排他制御について、lockMonitorMutexSemaphoreSlimの違いを中心に、実践的な使い方と注意点を解説します。

1. C#の排他制御とは?必要になる場面をわかりやすく解説

1-1. 排他制御とは「同時アクセスによる不整合」を防ぐ仕組み

排他制御とは、複数の処理が同じ共有リソースへ同時にアクセスしないように制御する仕組みです。

たとえば、複数のスレッドが同じ変数を同時に更新するケースを考えてみましょう。

C#
count++;

この1行は一見すると単純ですが、内部的には次のような処理に分かれます。

1. countの現在値を読み取る
2. 1を加算する
3. 結果をcountに書き戻す

複数のスレッドが同時にこの処理を実行すると、あるスレッドの更新結果が別のスレッドによって上書きされ、期待した値にならないことがあります。

このような問題を防ぐために、C#ではlockMonitorなどを使って「この処理中は他のスレッドを入れない」という制御を行います。

1-2. マルチスレッド・非同期処理で起きる競合状態とは

競合状態とは、複数のスレッドやタスクが同じデータへ同時にアクセスし、その実行順序によって結果が変わってしまう状態です。

たとえば、次のようなコードは競合状態を起こす可能性があります。

C#
private int _count = 0;

public void Increment()
{
_count++;
}

1つのスレッドだけが実行するなら問題ありません。しかし、複数のスレッドから同時にIncrementが呼び出されると、加算結果が失われることがあります。

たとえば1000回加算したはずなのに、結果が998や995になるといった現象が起きます。

このようなバグは、毎回必ず発生するとは限りません。タイミングに依存するため、開発環境では再現せず、本番環境の高負荷時だけ発生することもあります。

1-3. C#で排他制御が必要になる代表例

C#で排他制御が必要になる場面には、次のようなものがあります。

場面排他制御が必要な理由
共有変数の更新同時更新による値の不整合を防ぐ
List<T>Dictionary<TKey, TValue>の操作同時追加・削除による例外や破損を防ぐ
ファイル書き込み複数処理による書き込み競合を防ぐ
キャッシュ更新古い値の上書きや二重生成を防ぐ
アプリの二重起動防止同じアプリを複数起動させない
API呼び出し制限同時リクエスト数を制限する
非同期メソッドの多重実行防止同じ処理が重複実行されるのを防ぐ

特にWebアプリ、Windowsアプリ、バッチ処理、バックグラウンドサービス、非同期I/O処理では、排他制御の理解が重要です。

1-4. 排他制御をしないと発生するバグ・データ破損・例外

排他制御をしない場合、次のような問題が起きる可能性があります。

C#
private readonly List<string> _items = new();

public void AddItem(string item)
{
_items.Add(item);
}

このコードは、単一スレッドでは正常に動作します。しかし、複数スレッドから同時にAddItemが呼ばれると、List<T>の内部状態が壊れたり、例外が発生したりする可能性があります。

よくある問題は次のとおりです。

問題内容
更新漏れ加算・減算の結果が失われる
データ破損コレクションの内部状態が壊れる
例外発生列挙中の変更などで例外が発生する
二重実行本来1回だけの処理が複数回走る
デッドロック複数のロックが互いに待ち合って停止する
パフォーマンス低下不適切なロックで処理が詰まる

排他制御は、単に「ロックすればよい」というものではありません。適切な範囲・適切な仕組み・適切な粒度で設計することが重要です。

2. C#で使える主な排他制御の種類

2-1. lock:最も基本的な排他制御

lockは、C#で最もよく使われる排他制御の構文です。

C#
private readonly object _lock = new();

public void DoWork()
{
lock (_lock)
{
// 同時に1つのスレッドだけが実行できる処理
}
}

lockブロックに入れるのは、同時実行されると困る処理です。

たとえば、共有変数の更新、共有コレクションの操作、ファイル書き込みなどが該当します。

C#のlockステートメントは、共有リソースへのアクセスを同期するための構文で、従来は内部的にMonitor.EnterMonitor.Exitを使う形で扱われてきました。また、.NET 9およびC# 13以降では、専用のSystem.Threading.Lock型を使うことが推奨されています。

2-2. Monitor:lockの内部で使われる低レベルAPI

Monitorは、より細かくロック制御を行うためのAPIです。

C#
Monitor.Enter(_lock);
try
{
// 排他制御したい処理
}
finally
{
Monitor.Exit(_lock);
}

lockはシンプルに書けますが、Monitorを使うと次のような制御ができます。

機能内容
Monitor.Enterロックを取得する
Monitor.Exitロックを解放する
Monitor.TryEnterロック取得を試みる
Monitor.Waitロックを一時解放して待機する
Monitor.Pulse待機中のスレッドに通知する
Monitor.PulseAll待機中の全スレッドに通知する

通常はlockで十分ですが、タイムアウト付きのロックや待機・通知制御が必要な場合はMonitorを使います。

2-3. Mutex:プロセスをまたいだ排他制御に使う

Mutexは、スレッド間だけでなく、プロセス間でも排他制御できる仕組みです。

代表的な用途は、アプリケーションの二重起動防止です。

C#
using var mutex = new Mutex(false, "Global\\MyApplicationMutex");

if (!mutex.WaitOne(0))
{
Console.WriteLine("すでにアプリが起動しています。");
return;
}

try
{
Console.WriteLine("アプリを実行します。");
}
finally
{
mutex.ReleaseMutex();
}

lockMonitorは基本的に同一プロセス内の排他制御に使います。一方、名前付きMutexを使うと、別プロセスからも同じ名前のMutexを参照できるため、プロセス間の排他制御が可能です。Microsoftの.NETドキュメントでも、Mutexは排他的アクセスを提供し、プロセス間同期にも使える同期プリミティブとして説明されています。

2-4. SemaphoreSlim:同時実行数を制限できる軽量な仕組み

SemaphoreSlimは、同時に実行できる処理数を制限するための仕組みです。

C#
private readonly SemaphoreSlim _semaphore = new(3);

public async Task DoWorkAsync()
{
await _semaphore.WaitAsync();

try
{
// 最大3つまで同時実行される処理
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

SemaphoreSlim(3)とすると、同時に3つまで処理を許可できます。

また、SemaphoreSlim(1, 1)のように最大同時実行数を1にすれば、非同期処理に対応した排他制御として使えます。

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

public async Task UpdateAsync()
{
await _asyncLock.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_asyncLock.Release();
}
}

SemaphoreSlimは、単一アプリ内での同期に使う軽量なセマフォで、名前付きシステムセマフォはサポートしません。Semaphoreがプロセス間同期にも使えるのに対し、SemaphoreSlimは主に同一プロセス内の軽量な同期に向いています。

2-5. ReaderWriterLockSlim:読み取りが多い処理で使える排他制御

ReaderWriterLockSlimは、読み取り処理が多く、書き込み処理が少ない場合に有効な排他制御です。

通常のlockでは、読み取りだけの処理でも同時実行がブロックされます。

C#
lock (_lock)
{
return _cache[key];
}

一方、ReaderWriterLockSlimでは、複数の読み取りは同時に許可し、書き込み時だけ排他的にロックできます。

C#
private readonly ReaderWriterLockSlim _rwLock = new();
private readonly Dictionary<string, string> _cache = new();

public string? Get(string key)
{
_rwLock.EnterReadLock();
try
{
return _cache.TryGetValue(key, out var value) ? value : null;
}
finally
{
_rwLock.ExitReadLock();
}
}

public void Set(string key, string value)
{
_rwLock.EnterWriteLock();
try
{
_cache[key] = value;
}
finally
{
_rwLock.ExitWriteLock();
}
}

読み取りが圧倒的に多いキャッシュ処理などでは、lockより効率的になる場合があります。

ただし、実装が複雑になるため、まずはlockやスレッドセーフコレクションで解決できないか検討するのがよいでしょう。

2-6. Interlocked:単純な数値操作を高速にスレッドセーフ化する方法

Interlockedは、単純な数値の加算・減算・交換などをスレッドセーフに行うためのクラスです。

C#
private int _count = 0;

public void Increment()
{
Interlocked.Increment(ref _count);
}

次のような操作に向いています。

メソッド内容
Interlocked.Increment1加算する
Interlocked.Decrement1減算する
Interlocked.Add指定値を加算する
Interlocked.Exchange値を入れ替える
Interlocked.CompareExchange条件付きで値を更新する

単純なカウンターであれば、lockよりInterlockedの方が軽量です。

ただし、複数の変数をまとめて更新するような処理には向いていません。その場合はlockなどを使って、処理全体を排他制御する必要があります。

3. lockの使い方と注意点

3-1. lockの基本構文

lockの基本構文は次のとおりです。

C#
lock (ロック対象)
{
// 排他制御したい処理
}

実際には、専用のロックオブジェクトを用意して使います。

C#
private readonly object _lock = new();

public void Execute()
{
lock (_lock)
{
// 共有リソースにアクセスする処理
}
}

.NET 9およびC# 13以降では、次のようにSystem.Threading.Lockを使う選択肢もあります。

C#
private readonly Lock _lock = new();

public void Execute()
{
lock (_lock)
{
// 共有リソースにアクセスする処理
}
}

C# 13以降のlockでは、System.Threading.Lock型に対してより適した動作が行われるため、新しいプロジェクトでは検討する価値があります。一方、古い.NETやC#のバージョンを対象にする場合は、private readonly objectを使うのが一般的です。

3-2. lockで共有変数を安全に更新するサンプルコード

排他制御なしのコードは、複数スレッドで実行すると値が不正になる可能性があります。

C#
private int _count = 0;

public void Increment()
{
_count++;
}

lockを使うと、同時に1つのスレッドだけが更新処理を実行できます。

C#
private readonly object _lock = new();
private int _count = 0;

public void Increment()
{
lock (_lock)
{
_count++;
}
}

public int GetCount()
{
lock (_lock)
{
return _count;
}
}

更新だけでなく、読み取り側にも同じロックを使うのが重要です。

C#
public int Count
{
get
{
lock (_lock)
{
return _count;
}
}
}

書き込みだけロックしても、読み取りが同時に走ると不整合が発生する可能性があります。共有状態にアクセスするすべての箇所で、同じルールを守る必要があります。

3-3. lock対象にはprivate readonly objectを使う

従来のC#では、lock対象には次のような専用オブジェクトを使うのが基本です。

C#
private readonly object _syncRoot = new();

ポイントは次の3つです。

条件理由
private外部コードからロックされないようにする
readonly実行中に別のインスタンスへ差し替えられないようにする
専用オブジェクト他の用途とロックが混ざらないようにする

よくある実装例は次のとおりです。

C#
public class SafeCounter
{
private readonly object _syncRoot = new();
private int _count;

public void Increment()
{
lock (_syncRoot)
{
_count++;
}
}

public int Value
{
get
{
lock (_syncRoot)
{
return _count;
}
}
}
}

ロックオブジェクトは、排他制御の「鍵」のようなものです。意図しない場所から同じ鍵を使われないように、外部に公開しないことが重要です。

3-4. this・typeof・stringをlock対象にしてはいけない理由

lock対象として、次のようなものを使うのは避けるべきです。

C#
lock (this)
{
}

lock (typeof(MyClass))
{
}

lock ("my-lock")
{
}

これらが危険な理由は、外部コードから同じオブジェクトをロックできてしまう可能性があるからです。

避ける対象理由
thisクラス利用者が同じインスタンスをロックできる
typeof(...)型オブジェクトは広い範囲から参照できる
string文字列インターンにより同じインスタンスが共有される可能性がある

MicrosoftのC#ドキュメントでも、thisTypeインスタンス、文字列インスタンスをロック対象にしないよう案内されています。特に文字列リテラルはインターンされる可能性があるため、予期しないコードと同じロックを共有してしまう危険があります。

安全な書き方は次のとおりです。

C#
private readonly object _lock = new();

public void Update()
{
lock (_lock)
{
// 安全に保護された処理
}
}

3-5. lock内で時間のかかる処理をしない

lock内の処理は、できるだけ短くする必要があります。

悪い例は次のようなコードです。

C#
lock (_lock)
{
Thread.Sleep(5000);
File.WriteAllText("log.txt", "message");
CallExternalApi();
}

このように、時間のかかる処理をlock内で実行すると、他のスレッドが長時間待たされます。

改善例として、ロックが必要な部分だけを最小化します。

C#
string message;

lock (_lock)
{
message = CreateMessage();
}

File.WriteAllText("log.txt", message);

ロック内では共有データの読み書きだけを行い、ファイルI/O、ネットワークI/O、長時間の計算、外部API呼び出しなどはできるだけ外に出すのが基本です。

3-6. lockはasync/awaitと相性が悪い

lockブロック内ではawaitを使えません。

C#
lock (_lock)
{
await Task.Delay(1000); // コンパイルエラー
}

lockはスレッドに紐づく同期的な仕組みであり、awaitによって処理の継続が別スレッドで再開される可能性があるため、相性がよくありません。C#の仕様上も、lockステートメントの範囲内ではawait式を使用できないとされています。

非同期処理で排他制御したい場合は、SemaphoreSlimを使うのが一般的です。

C#
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task UpdateAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

async/awaitを使うメソッドでは、lockではなくSemaphoreSlim.WaitAsyncを使う、と覚えておくとよいでしょう。

4. Monitorの使い方とlockとの違い

4-1. Monitor.EnterとMonitor.Exitの基本

Monitorは、明示的にロックを取得・解放するAPIです。

C#
private readonly object _lock = new();

public void Execute()
{
Monitor.Enter(_lock);

try
{
// 排他制御したい処理
}
finally
{
Monitor.Exit(_lock);
}
}

Monitor.Enterでロックを取得し、Monitor.Exitでロックを解放します。

lockは、このような処理を簡潔に書くための構文と考えるとわかりやすいです。

C#
lock (_lock)
{
// 排他制御したい処理
}

通常の排他制御であれば、可読性の高いlockを使うのが基本です。

4-2. try-finallyで確実にロックを解放する

Monitorを使う場合、必ずtry-finallyExitを呼び出します。

悪い例は次のとおりです。

C#
Monitor.Enter(_lock);

// ここで例外が発生するとExitされない
DoSomething();

Monitor.Exit(_lock);

このコードでは、DoSomethingで例外が発生した場合、Monitor.Exitが呼ばれません。その結果、ロックが解放されず、他のスレッドが永久に待機する可能性があります。

正しい書き方は次のとおりです。

C#
Monitor.Enter(_lock);
try
{
DoSomething();
}
finally
{
Monitor.Exit(_lock);
}

lockを使えば、このtry-finally相当の処理をコンパイラが安全に扱ってくれます。

C#
lock (_lock)
{
DoSomething();
}

そのため、特別な理由がなければlockを使う方がシンプルで安全です。

4-3. Monitor.TryEnterでタイムアウト付きロックを実装する

Monitor.TryEnterを使うと、ロック取得を試み、取得できなかった場合に諦める処理を書けます。

C#
private readonly object _lock = new();

public bool TryUpdate()
{
if (Monitor.TryEnter(_lock, TimeSpan.FromSeconds(3)))
{
try
{
// ロックを取得できた場合の処理
return true;
}
finally
{
Monitor.Exit(_lock);
}
}

// 3秒以内にロックを取得できなかった場合
return false;
}

これは次のような場面で便利です。

場面理由
UIアプリ長時間待機して画面が固まるのを避けたい
バックグラウンド処理一定時間で処理を諦めたい
リトライ処理ロック取得失敗時に別の処理へ切り替えたい
監視処理デッドロックに近い状態を検知したい

lockでは、基本的にロックを取得できるまで待ち続けます。タイムアウトや取得失敗時の分岐が必要な場合は、Monitor.TryEnterを検討します。

4-4. Monitor.Wait・Pulse・PulseAllの役割

Monitor.WaitMonitor.PulseMonitor.PulseAllは、スレッド間の待機・通知を行うための仕組みです。

簡単に言うと、次のような役割があります。

メソッド役割
Monitor.Wait条件が満たされるまで待機する
Monitor.Pulse待機中のスレッドを1つ起こす
Monitor.PulseAll待機中のスレッドをすべて起こす

たとえば、キューにデータが入るまで待機する処理では、次のように使えます。

C#
private readonly object _lock = new();
private readonly Queue<string> _queue = new();

public void Enqueue(string item)
{
lock (_lock)
{
_queue.Enqueue(item);
Monitor.Pulse(_lock);
}
}

public string Dequeue()
{
lock (_lock)
{
while (_queue.Count == 0)
{
Monitor.Wait(_lock);
}

return _queue.Dequeue();
}
}

ポイントは、Waitifではなくwhileで囲むことです。

C#
while (_queue.Count == 0)
{
Monitor.Wait(_lock);
}

待機から戻っても、必ず条件を再確認する必要があります。別のスレッドが先にデータを取り出している可能性があるためです。

ただし、現代のC#では、こうした待機・通知処理を直接Monitorで書くより、BlockingCollection<T>Channel<T>SemaphoreSlimなどを使った方が安全で読みやすい場合も多いです。

4-5. lockとMonitorの使い分け

lockMonitorの使い分けは、次のように考えるとよいでしょう。

条件選択肢
単純に排他制御したいlock
タイムアウト付きでロックしたいMonitor.TryEnter
待機・通知を細かく制御したいMonitor.Wait / Pulse
可読性を重視したいlock
例外安全に簡潔に書きたいlock

通常のC#開発では、まずlockを選びます。

Monitorは、lockでは表現しにくい細かい制御が必要な場合に使う低レベルAPIです。

5. Mutexの使い方とプロセス間排他制御

5-1. Mutexとは何か

Mutexは、排他的に1つのスレッドだけがリソースを利用できるようにする同期プリミティブです。

lockと似ていますが、大きな違いはプロセス間の排他制御に使える点です。

C#
using var mutex = new Mutex(false, "Global\\SampleMutex");

mutex.WaitOne();

try
{
// 排他制御したい処理
}
finally
{
mutex.ReleaseMutex();
}

MutexはOSレベルの同期オブジェクトを利用するため、lockより重い仕組みです。

そのため、同一プロセス内の単純な排他制御であれば、通常はlockSemaphoreSlimを使います。

5-2. lockやMonitorとの違い

lockMonitorMutexの違いは次のとおりです。

項目lock / MonitorMutex
主な用途同一プロセス内の排他制御プロセス間の排他制御
重さ軽い比較的重い
名前付き同期不可可能
代表例共有変数の保護アプリの二重起動防止
解放方法lock終了、またはMonitor.ExitReleaseMutex

Mutexは、スレッドだけでなくプロセスをまたいだ制御が必要なときに使います。

逆に、同じアプリケーション内の変数やコレクションを保護するだけなら、Mutexを使う必要はほとんどありません。

5-3. 名前付きMutexでアプリの二重起動を防ぐ

Mutexの代表的な用途は、アプリケーションの二重起動防止です。

C#
using System.Threading;

class Program
{
static void Main()
{
const string mutexName = "Global\\MyUniqueApplicationName";

using var mutex = new Mutex(false, mutexName);

if (!mutex.WaitOne(0))
{
Console.WriteLine("このアプリケーションはすでに起動しています。");
return;
}

try
{
Console.WriteLine("アプリケーションを起動しました。");
Console.ReadLine();
}
finally
{
mutex.ReleaseMutex();
}
}
}

Global\\を付けると、Windows環境ではグローバル名前空間の名前付きMutexとして扱われます。

アプリケーション名、会社名、GUIDなどを含めて、他のアプリと衝突しない名前にすることが重要です。

C#
const string mutexName = "Global\\CompanyName.ProductName.AppMutex";

5-4. Mutexを使うべきケース・使わない方がよいケース

Mutexを使うべきケースは、プロセスをまたいだ排他制御が必要な場合です。

使うべきケース
アプリの二重起動防止デスクトップアプリを1つだけ起動する
複数プロセスからの共有ファイル操作同じファイルへの同時書き込みを防ぐ
複数サービス間の排他同じリソースを複数サービスが使う
OSレベルで同期したい別プロセスとの協調制御

一方、次のようなケースではMutexを使わない方がよいです。

避けるケース理由
同一プロセス内の変数保護lockの方が軽い
非同期処理の排他SemaphoreSlimの方が扱いやすい
高頻度の短い処理Mutexは重くなりやすい
Webリクエストごとの排他スケールアウト環境では別設計が必要

特にWebアプリでは、複数プロセス・複数サーバーで動作することがあります。その場合、ローカルのMutexだけでは全体の排他制御にならない可能性があります。

分散環境では、データベースのロック、Redisの分散ロック、キューイングなどを検討します。

5-5. Mutexの解放忘れと例外処理の注意点

Mutexを取得したら、必ずReleaseMutexで解放します。

C#
mutex.WaitOne();

try
{
DoWork();
}
finally
{
mutex.ReleaseMutex();
}

例外が発生しても解放されるように、try-finallyで囲むことが重要です。

また、Mutexは取得したスレッドが解放する必要があります。別スレッドで解放しようとすると問題が発生する可能性があります。

さらに、プロセスが異常終了した場合は、AbandonedMutexExceptionが発生することがあります。これは、別のプロセスやスレッドがMutexを解放しないまま終了したことを示します。

C#
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
// 前回の所有者が異常終了した可能性がある
// 共有リソースの整合性確認を検討する
}

AbandonedMutexExceptionを単に無視するのではなく、共有ファイルや共有データが壊れていないか確認する設計が望ましいです。

6. SemaphoreSlimの使い方と非同期処理での活用

6-1. SemaphoreSlimとは何か

SemaphoreSlimは、同時に実行できる処理数を制限するための軽量な同期プリミティブです。

C#
private readonly SemaphoreSlim _semaphore = new(2);

この場合、同時に2つの処理まで実行できます。

C#
public async Task ProcessAsync()
{
await _semaphore.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

WaitAsyncが使えるため、async/awaitと相性がよいのが大きな特徴です。

非同期メソッドで排他制御したい場合、lockではなくSemaphoreSlimを使うのが基本です。Microsoftの非同期調整プリミティブに関する資料でも、awaitをまたぐ相互排他にはカウント1のSemaphoreSlimを使う例が示されています。

6-2. 同時実行数を1にして排他制御として使う方法

SemaphoreSlimの同時実行数を1にすると、lockのような排他制御として使えます。

C#
private readonly SemaphoreSlim _semaphore = new(1, 1);

public async Task SaveAsync()
{
await _semaphore.WaitAsync();

try
{
// 同時に1つだけ実行したい処理
await Task.Delay(1000);
}
finally
{
_semaphore.Release();
}
}

new SemaphoreSlim(1, 1)の意味は次のとおりです。

引数意味
第1引数現在利用可能な数
第2引数最大利用可能数

排他制御として使う場合は、基本的に1, 1を指定します。

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

6-3. WaitAsyncを使ったasync/await対応の排他制御

非同期処理では、WaitAsyncを使ってロック取得を待ちます。

C#
public async Task UpdateCacheAsync()
{
await _asyncLock.WaitAsync();

try
{
var data = await LoadDataAsync();
_cache = data;
}
finally
{
_asyncLock.Release();
}
}

このように、awaitを含む処理全体を排他制御できます。

lockでは次のようなコードは書けません。

C#
lock (_lock)
{
await LoadDataAsync(); // 不可
}

非同期処理では、待機中にスレッドをブロックしないことが重要です。SemaphoreSlim.WaitAsyncは非同期に待機できるため、スレッドプールの無駄な消費を避けやすくなります。

6-4. API呼び出しやファイル処理の同時実行数を制限する

SemaphoreSlimは、排他制御だけでなく、同時実行数の制限にもよく使われます。

たとえば、外部APIを同時に5件まで呼び出す場合は次のように書けます。

C#
private readonly SemaphoreSlim _apiSemaphore = new(5, 5);

public async Task CallApiAsync(string url)
{
await _apiSemaphore.WaitAsync();

try
{
using var httpClient = new HttpClient();
var response = await httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
}
finally
{
_apiSemaphore.Release();
}
}

複数のURLを処理する場合は、次のように使えます。

C#
var tasks = urls.Select(url => CallApiAsync(url));
await Task.WhenAll(tasks);

この場合、タスク自体は多数作られても、実際にAPI呼び出しを実行するのは最大5件までになります。

ファイル処理でも同じ考え方が使えます。

C#
private readonly SemaphoreSlim _fileSemaphore = new(3, 3);

public async Task WriteFileAsync(string path, string content)
{
await _fileSemaphore.WaitAsync();

try
{
await File.WriteAllTextAsync(path, content);
}
finally
{
_fileSemaphore.Release();
}
}

6-5. Release忘れを防ぐtry-finallyの書き方

SemaphoreSlimでは、WaitAsyncで取得したら必ずReleaseします。

悪い例は次のとおりです。

C#
await _semaphore.WaitAsync();

await DoWorkAsync();

_semaphore.Release();

このコードでは、DoWorkAsyncで例外が発生するとReleaseが呼ばれません。

正しくは、必ずtry-finallyを使います。

C#
await _semaphore.WaitAsync();

try
{
await DoWorkAsync();
}
finally
{
_semaphore.Release();
}

これはlockMonitorと同じく、排他制御の基本です。

ロックやセマフォは「取得したら必ず解放する」ことが重要です。例外が発生する可能性を前提に、必ず解放処理を保証しましょう。

6-6. SemaphoreとSemaphoreSlimの違い

SemaphoreSemaphoreSlimは似ていますが、用途が異なります。

項目SemaphoreSemaphoreSlim
主な用途OSレベルのセマフォ同一プロセス内の軽量セマフォ
プロセス間同期可能不可
名前付きセマフォ可能不可
非同期対応基本は同期的WaitAsyncが使える
軽さ比較的重い軽量
よく使う場面プロセス間同期async/await、同時実行数制限

通常のC#アプリケーション内で非同期処理の排他制御や同時実行数制限をしたい場合は、SemaphoreSlimを使います。

プロセスをまたいだ同期が必要な場合は、SemaphoreMutexを検討します。

7. lock・Monitor・Mutex・SemaphoreSlimの違いを比較

7-1. それぞれの特徴を一覧表で比較

C#の排他制御でよく使うlockMonitorMutexSemaphoreSlimの違いを整理すると、次のようになります。

項目lockMonitorMutexSemaphoreSlim
主な用途基本的な排他制御詳細なロック制御プロセス間排他非同期・同時実行数制限
対象範囲同一プロセス内同一プロセス内プロセス間も可能同一プロセス内
async/await対応不向き不向き不向き向いている
タイムアウト直接は不可TryEnterで可能WaitOneで可能WaitAsyncで可能
同時実行数制限1のみ1のみ1のみ1以上を指定可能
書きやすさ簡単やや複雑やや複雑非同期では扱いやすい
パフォーマンス軽い軽い比較的重い軽い
代表的な用途共有変数の保護タイムアウト付きロック二重起動防止API同時実行制限

7-2. 同一プロセス内の排他制御ならlockが基本

同じアプリケーション内で共有変数や共有コレクションを保護したい場合は、まずlockを使います。

C#
private readonly object _lock = new();
private readonly List<string> _items = new();

public void Add(string item)
{
lock (_lock)
{
_items.Add(item);
}
}

lockはシンプルで読みやすく、例外が発生してもロック解放が保証されるため、基本的な排他制御に向いています。

ただし、awaitを含む非同期処理では使えないため、その場合はSemaphoreSlimを選びます。

7-3. タイムアウトや待機制御が必要ならMonitor

ロックを取得できるまで無制限に待ちたくない場合は、Monitor.TryEnterが使えます。

C#
if (Monitor.TryEnter(_lock, TimeSpan.FromSeconds(1)))
{
try
{
// ロック取得成功
}
finally
{
Monitor.Exit(_lock);
}
}
else
{
// ロック取得失敗
}

また、Monitor.WaitMonitor.Pulseを使えば、条件が満たされるまで待機し、別スレッドから通知するような制御もできます。

ただし、実装ミスが起きやすいため、単純な排他制御ではlockを使う方が安全です。

7-4. プロセス間で排他したいならMutex

複数のプロセス間で同じリソースを排他制御したい場合は、Mutexを使います。

C#
using var mutex = new Mutex(false, "Global\\MyAppMutex");

if (!mutex.WaitOne(0))
{
return;
}

try
{
// アプリ本体の処理
}
finally
{
mutex.ReleaseMutex();
}

アプリの二重起動防止や、複数プロセスから同じファイルへアクセスする場合に有効です。

ただし、同一プロセス内の排他制御だけなら、Mutexは重すぎることが多いため、lockSemaphoreSlimを使いましょう。

7-5. async/awaitで使うならSemaphoreSlim

async/awaitを使う非同期メソッドで排他制御する場合は、SemaphoreSlimが基本です。

C#
private readonly SemaphoreSlim _lock = new(1, 1);

public async Task ExecuteAsync()
{
await _lock.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_lock.Release();
}
}

lock内ではawaitできませんが、SemaphoreSlim.WaitAsyncを使えば非同期的に待機できます。

API呼び出し、ファイルI/O、DBアクセス、キャッシュ更新など、非同期処理を含む排他制御ではSemaphoreSlimを選びましょう。

7-6. 用途別の選び方早見表

C#の排他制御は、用途に応じて次のように選ぶとわかりやすいです。

用途推奨
共有変数を保護したいlock
List<T>Dictionary<TKey, TValue>を保護したいlockまたはスレッドセーフコレクション
単純なカウンターを更新したいInterlocked
非同期メソッドの多重実行を防ぎたいSemaphoreSlim(1, 1)
APIの同時呼び出し数を制限したいSemaphoreSlim
アプリの二重起動を防ぎたい名前付きMutex
タイムアウト付きでロックしたいMonitor.TryEnter
読み取りが多いキャッシュを保護したいReaderWriterLockSlim
プロセス間で共有リソースを保護したいMutexまたはSemaphore
分散環境で排他制御したいDBロック、Redis、分散ロックなど

迷った場合は、次の基準で考えるとよいです。

同期メソッドの単純な排他制御 → lock
非同期メソッドの排他制御 → SemaphoreSlim
プロセス間の排他制御 → Mutex
単純な数値更新 → Interlocked

8. 排他制御でよくある失敗と回避策

8-1. デッドロックが発生する原因

デッドロックとは、複数のスレッドが互いに相手のロック解放を待ち続け、処理が進まなくなる状態です。

典型的な例は、ロック取得順序が逆になるケースです。

C#
private readonly object _lockA = new();
private readonly object _lockB = new();

public void Method1()
{
lock (_lockA)
{
lock (_lockB)
{
// 処理
}
}
}

public void Method2()
{
lock (_lockB)
{
lock (_lockA)
{
// 処理
}
}
}

Method1_lockAを取得し、Method2_lockBを取得した状態になると、互いに相手のロックを待ち続ける可能性があります。

この状態になると、どちらの処理も進みません。

8-2. ロック取得順序を統一する

デッドロックを防ぐ基本は、ロック取得順序を統一することです。

悪い例は次のとおりです。

C#
// Method1: A → B
lock (_lockA)
{
lock (_lockB)
{
}
}

// Method2: B → A
lock (_lockB)
{
lock (_lockA)
{
}
}

改善例は次のとおりです。

C#
// どのメソッドでも A → B の順で取得する
lock (_lockA)
{
lock (_lockB)
{
// 処理
}
}

複数のロックを使う場合は、設計段階で「必ずこの順番で取得する」というルールを決めておくことが重要です。

8-3. ロック範囲を最小限にする

ロック範囲が広すぎると、待機時間が増え、デッドロックやパフォーマンス低下の原因になります。

悪い例です。

C#
lock (_lock)
{
var data = LoadLargeData();
var result = Calculate(data);
_cache = result;
}

このコードでは、データ読み込みや計算中もロックを保持しています。

改善例です。

C#
var data = LoadLargeData();
var result = Calculate(data);

lock (_lock)
{
_cache = result;
}

共有データにアクセスする部分だけをロックすれば、他のスレッドの待機時間を短くできます。

8-4. ロックのネストを避ける

ロックのネストは、デッドロックの原因になりやすいです。

C#
lock (_lockA)
{
lock (_lockB)
{
// 処理
}
}

どうしても必要な場合を除き、複数のロックを同時に取得する設計は避けましょう。

代替案として、次のような方法を検討します。

方法内容
ロックを1つにまとめる複数リソースを同じロックで保護する
データ構造を見直す同時に複数ロックが必要な設計を避ける
処理を分割するロックを取得するタイミングを分離する
スレッドセーフコレクションを使う明示的なロックを減らす

ただし、ロックを1つにまとめると競合が増える可能性もあります。安全性と性能のバランスを見ながら設計しましょう。

8-5. 例外発生時も必ずロックを解放する

MonitorMutexSemaphoreSlimを使う場合は、例外が発生しても必ず解放されるようにします。

C#
await _semaphore.WaitAsync();

try
{
await DoWorkAsync();
}
finally
{
_semaphore.Release();
}

Monitorの場合も同じです。

C#
Monitor.Enter(_lock);

try
{
DoWork();
}
finally
{
Monitor.Exit(_lock);
}

lockは構文として解放処理が保証されるため、単純な排他制御ではlockを使うと安全です。

8-6. UIスレッドで排他制御するときの注意点

WPF、Windows Forms、MAUIなどのUIアプリでは、UIスレッドをブロックしないことが重要です。

悪い例は次のようなコードです。

C#
lock (_lock)
{
Thread.Sleep(5000);
}

このような処理をUIスレッドで実行すると、画面が固まります。

また、UIスレッドで同期的に待機するコードも危険です。

C#
var result = SomeAsyncMethod().Result;

非同期処理を同期的に待つと、デッドロックやフリーズの原因になります。

UIアプリでは、次のような方針が基本です。

方針内容
UIスレッドをブロックしない長時間処理はawaitする
lock内でUI更新しないUI更新は必要な場所だけで行う
非同期排他はSemaphoreSlimを使うWaitAsyncで待機する
.Result.Wait()を避けるawaitで自然に書く

UIスレッドでは、排他制御そのものよりも「ブロックしない設計」が重要です。

9. スレッドセーフに実装するための実践パターン

9-1. カウンターを安全に更新する

単純なカウンターの更新には、lockまたはInterlockedを使います。

lockを使う例です。

C#
private readonly object _lock = new();
private int _count;

public void Increment()
{
lock (_lock)
{
_count++;
}
}

Interlockedを使う例です。

C#
private int _count;

public void Increment()
{
Interlocked.Increment(ref _count);
}

public int Count => Volatile.Read(ref _count);

単純な加算・減算だけなら、Interlockedの方が簡潔で高速です。

ただし、次のように複数の処理をまとめて一貫性を保ちたい場合はlockを使います。

C#
lock (_lock)
{
_count++;
_lastUpdated = DateTime.UtcNow;
}

9-2. ListやDictionaryを安全に操作する

List<T>Dictionary<TKey, TValue>は、複数スレッドからの同時書き込みに対して安全ではありません。

次のようにlockで保護します。

C#
private readonly object _lock = new();
private readonly List<string> _items = new();

public void Add(string item)
{
lock (_lock)
{
_items.Add(item);
}
}

public string[] GetAll()
{
lock (_lock)
{
return _items.ToArray();
}
}

ポイントは、コレクションそのものを外部に返さないことです。

悪い例です。

C#
public List<string> GetItems()
{
return _items;
}

このように返すと、呼び出し元がロックなしで変更できてしまいます。

安全にするには、コピーを返します。

C#
public IReadOnlyList<string> GetItems()
{
lock (_lock)
{
return _items.ToList();
}
}

Dictionaryの場合も同様です。

C#
private readonly object _lock = new();
private readonly Dictionary<string, int> _scores = new();

public void SetScore(string name, int score)
{
lock (_lock)
{
_scores[name] = score;
}
}

public bool TryGetScore(string name, out int score)
{
lock (_lock)
{
return _scores.TryGetValue(name, out score);
}
}

9-3. ConcurrentDictionaryなどのスレッドセーフコレクションを使う

明示的なlockを減らしたい場合は、System.Collections.Concurrent名前空間のスレッドセーフコレクションを使えます。

代表的なものは次のとおりです。

用途
ConcurrentDictionary<TKey, TValue>スレッドセーフな辞書
ConcurrentQueue<T>スレッドセーフなFIFOキュー
ConcurrentStack<T>スレッドセーフなLIFOスタック
ConcurrentBag<T>順序を問わないスレッドセーフなコレクション
BlockingCollection<T>生産者・消費者パターン

ConcurrentDictionaryの例です。

C#
private readonly ConcurrentDictionary<string, int> _scores = new();

public void SetScore(string name, int score)
{
_scores[name] = score;
}

public int GetOrAddScore(string name)
{
return _scores.GetOrAdd(name, _ => 0);
}

ただし、スレッドセーフコレクションを使えば常にロック不要になるわけではありません。

複数の操作をまとめて一貫性を保ちたい場合は、別途ロックが必要になることがあります。

C#
// こうした複合操作は注意が必要
if (!_dictionary.ContainsKey(key))
{
_dictionary[key] = value;
}

ConcurrentDictionaryでは、こうしたケースにGetOrAddAddOrUpdateを使います。

C#
_dictionary.GetOrAdd(key, value);

9-4. ファイル書き込みを排他制御する

複数の処理から同じファイルに書き込む場合は、排他制御が必要です。

同一プロセス内であれば、lockを使えます。

C#
private readonly object _fileLock = new();

public void WriteLog(string message)
{
lock (_fileLock)
{
File.AppendAllText("app.log", message + Environment.NewLine);
}
}

非同期でファイル書き込みを行う場合は、SemaphoreSlimを使います。

C#
private readonly SemaphoreSlim _fileLock = new(1, 1);

public async Task WriteLogAsync(string message)
{
await _fileLock.WaitAsync();

try
{
await File.AppendAllTextAsync("app.log", message + Environment.NewLine);
}
finally
{
_fileLock.Release();
}
}

複数プロセスから同じファイルへ書き込む場合は、lockでは不十分です。その場合は、名前付きMutex、ファイルロック、ログライブラリ、キューイングなどを検討します。

9-5. 非同期メソッドの多重実行を防ぐ

ボタン連打や定期実行により、同じ非同期メソッドが重複実行されることがあります。

SemaphoreSlimを使うと、多重実行を防げます。

C#
private readonly SemaphoreSlim _executionLock = new(1, 1);

public async Task ExecuteAsync()
{
if (!await _executionLock.WaitAsync(0))
{
// すでに実行中なら何もしない
return;
}

try
{
await DoLongRunningWorkAsync();
}
finally
{
_executionLock.Release();
}
}

このコードでは、すでに実行中の場合は即座にreturnします。

順番待ちさせたい場合は、タイムアウトなしで待機します。

C#
await _executionLock.WaitAsync();

try
{
await DoLongRunningWorkAsync();
}
finally
{
_executionLock.Release();
}

「重複実行を無視する」のか、「順番待ちする」のかは、要件によって決めましょう。

9-6. キャッシュ更新処理を安全に実装する

キャッシュ処理では、複数スレッドが同時にキャッシュを更新してしまうことがあります。

単純な実装です。

C#
private readonly SemaphoreSlim _cacheLock = new(1, 1);
private string? _cache;

public async Task<string> GetDataAsync()
{
if (_cache is not null)
{
return _cache;
}

await _cacheLock.WaitAsync();

try
{
if (_cache is not null)
{
return _cache;
}

_cache = await LoadDataAsync();
return _cache;
}
finally
{
_cacheLock.Release();
}
}

ポイントは、ロック取得後にもう一度キャッシュを確認することです。

C#
if (_cache is not null)
{
return _cache;
}

最初のチェック後、ロックを待っている間に別のタスクがキャッシュを更新している可能性があるためです。

このようなパターンは、二重チェックと呼ばれることがあります。

10. 排他制御のパフォーマンスと設計の考え方

10-1. 排他制御は安全性と性能のバランスが重要

排他制御は、データの整合性を守るために必要です。

しかし、ロックを増やしすぎると、並列処理のメリットが失われます。

排他制御では、次のバランスが重要です。

観点内容
安全性データ不整合や破損を防ぐ
性能ロック待ちを減らす
可読性複雑すぎる同期処理を避ける
保守性デッドロックしにくい設計にする

まずは正しく動作することを優先し、その上でボトルネックが明確になった場合に最適化するのが基本です。

10-2. ロック競合が多いと処理が遅くなる理由

ロック競合とは、複数のスレッドが同じロックを取り合う状態です。

C#
lock (_lock)
{
// 長い処理
}

ロック内の処理が長いほど、他のスレッドは待たされます。

その結果、次のような問題が起きます。

問題内容
待機時間が増えるスレッドがロック解放を待つ
スループットが下がる並列実行できる処理が減る
レスポンスが遅くなるUIやWebリクエストの応答が遅延する
デッドロックリスクが上がる複雑なロック構造になりやすい

ロックは「必要最小限」にすることが重要です。

10-3. 不要なロックを減らす設計

不要なロックを減らすには、共有状態を減らすことが効果的です。

たとえば、メソッド内のローカル変数はスレッドごとに独立しているため、通常はロック不要です。

C#
public int Calculate(int x, int y)
{
var result = x + y;
return result;
}

一方、フィールドやstatic変数など、複数スレッドから共有される可能性がある状態は注意が必要です。

C#
private int _count;

設計上のポイントは次のとおりです。

方法効果
ローカル変数を使う共有状態を減らす
状態をメソッド引数で渡すグローバルな状態を減らす
インスタンスを分離する同じデータを共有しない
immutableにする更新による競合をなくす
スレッドセーフコレクションを使う明示的なロックを減らす

10-4. immutableな設計で排他制御を減らす

immutableとは、作成後に状態が変わらない設計のことです。

たとえば、次のようなクラスは値を変更できません。

C#
public sealed class UserInfo
{
public UserInfo(string name, int age)
{
Name = name;
Age = age;
}

public string Name { get; }
public int Age { get; }
}

C#のrecordを使うと、immutableに近いデータ構造を簡潔に書けます。

C#
public record UserInfo(string Name, int Age);

状態を変更せず、新しいインスタンスを作る設計にすると、共有データの同時更新が減ります。

C#
var updated = user with { Age = 31 };

immutableな設計は、排他制御を減らす強力な手段です。

ただし、大量のオブジェクト生成が発生する場合は、メモリ使用量やパフォーマンスにも注意が必要です。

10-5. 読み取り中心ならReaderWriterLockSlimを検討する

読み取りが多く、書き込みが少ない場合は、ReaderWriterLockSlimが有効な場合があります。

通常のlockでは、読み取り同士も排他されます。

C#
lock (_lock)
{
return _data;
}

ReaderWriterLockSlimでは、読み取り同士は同時に実行できます。

C#
_rwLock.EnterReadLock();
try
{
return _data;
}
finally
{
_rwLock.ExitReadLock();
}

書き込み時だけ排他的にロックします。

C#
_rwLock.EnterWriteLock();
try
{
_data = newData;
}
finally
{
_rwLock.ExitWriteLock();
}

ただし、ReaderWriterLockSlimlockよりコードが複雑になります。

読み取りが非常に多い、書き込みが少ない、ロック競合が実際に問題になっている、といった場合に検討しましょう。

10-6. 単純な加算・減算ならInterlockedを使う

単純なカウンター操作では、Interlockedが便利です。

C#
private int _count;

public void Increment()
{
Interlocked.Increment(ref _count);
}

減算もできます。

C#
Interlocked.Decrement(ref _count);

指定値の加算もできます。

C#
Interlocked.Add(ref _count, 10);

値の入れ替えにはExchangeを使います。

C#
Interlocked.Exchange(ref _count, 0);

ただし、次のような複合的な条件判断と更新には注意が必要です。

C#
if (_count < 10)
{
_count++;
}

この処理は、読み取りと更新が分かれているため、Interlocked.Incrementだけでは完全には置き換えられません。

条件付き更新が必要な場合は、lockを使うか、CompareExchangeを使った慎重な実装が必要です。

11. C#の排他制御に関するよくある質問

11-1. lockとMonitorはどちらを使うべき?

通常はlockを使うべきです。

lockは読みやすく、例外発生時もロック解放が保証されるため、基本的な排他制御に向いています。

C#
lock (_lock)
{
// 排他制御
}

Monitorを使うのは、次のような場合です。

Monitorを使う場面理由
タイムアウト付きロックTryEnterを使える
ロック取得失敗時の分岐取得できなければ別処理に進める
待機・通知制御WaitPulseを使える
低レベルな同期制御より細かい制御ができる

単純な排他制御ならlock、細かい制御が必要ならMonitorと考えるとよいでしょう。

11-2. lockで非同期処理は書ける?

lockブロック内でawaitは使えません。

C#
lock (_lock)
{
await Task.Delay(1000); // 不可
}

非同期処理で排他制御したい場合は、SemaphoreSlimを使います。

C#
private readonly SemaphoreSlim _lock = new(1, 1);

public async Task ExecuteAsync()
{
await _lock.WaitAsync();

try
{
await Task.Delay(1000);
}
finally
{
_lock.Release();
}
}

同期処理ならlock、非同期処理ならSemaphoreSlimという使い分けが基本です。

11-3. SemaphoreSlimはlockの代わりになる?

条件によっては、SemaphoreSlimlockの代わりになります。

特に、非同期メソッドではSemaphoreSlim(1, 1)を排他制御として使えます。

C#
private readonly SemaphoreSlim _semaphore = new(1, 1);

ただし、同期処理だけであれば、lockの方がシンプルです。

状況推奨
同期メソッドlock
非同期メソッドSemaphoreSlim
同時実行数を制限したいSemaphoreSlim
単純な共有変数保護lock

SemaphoreSlimは便利ですが、常にlockの代わりに使うべきというわけではありません。

11-4. Mutexは通常のWebアプリでも必要?

通常のWebアプリでは、Mutexが必要になる場面は多くありません。

理由は、Webアプリでは次のような構成が多いためです。

構成注意点
複数プロセスローカルMutexだけでは不十分な場合がある
複数サーバーサーバーをまたいだ排他制御はできない
コンテナ環境インスタンスが増減する
クラウド環境分散ロックが必要になることがある

Webアプリで排他制御が必要な場合は、次のような仕組みを検討することが多いです。

方法用途
データベースのトランザクションデータ更新の整合性
楽観ロック更新競合の検出
Redis分散ロック複数サーバー間の排他
メッセージキュー処理の直列化
ジョブキューバックグラウンド処理の制御

単一プロセスのデスクトップアプリではMutexが有効ですが、Webアプリではアーキテクチャ全体を見て判断する必要があります。

11-5. static変数を排他制御するときの注意点

static変数はアプリケーション全体で共有されるため、複数スレッドからアクセスされる可能性があります。

C#
private static int _count;

排他制御する場合は、ロックオブジェクトもstaticにします。

C#
private static readonly object _lock = new();
private static int _count;

public static void Increment()
{
lock (_lock)
{
_count++;
}
}

インスタンスごとに異なるロックを使うと、static変数を正しく保護できません。

悪い例です。

C#
private readonly object _lock = new();
private static int _count;

この場合、インスタンスが複数あるとロックも複数になり、同じ_countを別々のロックで保護してしまいます。

staticな共有状態には、static readonlyなロックオブジェクトを使いましょう。

C#
private static readonly object _syncRoot = new();

11-6. 排他制御してもデッドロックするのはなぜ?

排他制御をしていても、ロックの使い方を誤るとデッドロックします。

主な原因は次のとおりです。

原因内容
ロック取得順序が不統一A→BとB→Aが混在する
ロック範囲が広すぎる長時間ロックを保持する
ロック中に外部処理を呼ぶ外部処理が別のロックを取る可能性がある
UIスレッドを同期的に待つ非同期処理と待機が衝突する
ReleaseExit忘れロックが解放されない

回避策は次のとおりです。

回避策内容
ロック順序を統一する複数ロックの順番を固定する
ロック範囲を短くする共有データアクセスだけを保護する
ロック中に外部APIを呼ばない制御不能な処理を避ける
try-finallyを徹底する必ず解放する
非同期ではSemaphoreSlimを使うlockawaitを混ぜない
設計を単純化するネストや共有状態を減らす

排他制御は、入れれば安全になるというものではありません。ロックの対象、範囲、順序、解放の設計が重要です。

まとめ

C#の排他制御は、マルチスレッド処理や非同期処理で共有データを安全に扱うために欠かせない仕組みです。

基本的な考え方は、「同時にアクセスされると困る共有リソースを、適切な範囲で保護する」ことです。

代表的な使い分けは次のとおりです。

目的使う仕組み
同一プロセス内の基本的な排他制御lock
タイムアウト付きロックや待機制御Monitor
プロセス間の排他制御Mutex
非同期処理の排他制御SemaphoreSlim
同時実行数の制限SemaphoreSlim
読み取り中心の共有データ保護ReaderWriterLockSlim
単純な数値操作Interlocked
コレクションのスレッドセーフ化ConcurrentDictionaryなど

特に重要なのは、次のポイントです。

同期処理の排他制御にはlock
非同期処理の排他制御にはSemaphoreSlim
プロセス間の排他制御にはMutex
単純なカウンターにはInterlocked

また、排他制御では次の失敗に注意が必要です。

注意点対策
デッドロックロック順序を統一する
ロック範囲が広すぎる必要最小限にする
解放忘れtry-finallyを使う
thisstringをロックする専用のロックオブジェクトを使う
lock内でawaitするSemaphoreSlim.WaitAsyncを使う
共有コレクションを直接公開するコピーや読み取り専用ビューを返す

C#の排他制御は、正しく使えばデータ破損や競合状態を防げます。一方で、過剰なロックや誤った設計は、デッドロックやパフォーマンス低下の原因になります。

まずはlockを基本として理解し、非同期処理ではSemaphoreSlim、プロセス間ではMutex、単純な数値更新ではInterlockedというように、用途に応じて適切な仕組みを選ぶことが、安全で保守しやすいC#コードを書くためのポイントです。