csharp lockとは?C#の排他制御・使い方・デッドロック回避まで初心者向けに解説

はじめに

C#でマルチスレッド処理や非同期処理を扱うと、「同じデータを複数の処理が同時に変更してしまう」問題が起こることがあります。たとえば、複数のスレッドが同じカウンターを増やしたり、同じListやDictionaryに要素を追加したり、同じファイルへログを書き込んだりするケースです。

このような場面で使われる代表的な仕組みが、C#のlock文です。

csharp lockは、C#における排他制御の基本です。排他制御とは、ある処理が共有データを操作している間、ほかの処理が同時にそのデータを触れないようにする仕組みです。lockを正しく使うと、競合状態を防ぎ、マルチスレッド環境でも安全にデータを扱いやすくなります。

一方で、lockは便利ですが、使い方を間違えるとパフォーマンス低下やデッドロックの原因になります。この記事では、C#のlockとは何か、基本構文、正しいlockオブジェクトの選び方、デッドロック回避、MonitorMutexSemaphoreSlimとの違い、実践的なサンプルコードまで初心者向けに解説します。

1. csharp lockとは?C#で排他制御が必要になる理由

1-1. lock文の役割を初心者向けにわかりやすく解説

C#のlock文は、特定のコードブロックを「同時に1つのスレッドだけが実行できるようにする」ための構文です。MicrosoftのC#言語仕様でも、lock文は指定されたオブジェクトに対して相互排他ロックを取得し、対象の処理を実行してからロックを解放する文として定義されています。

たとえば、次のような処理があるとします。

C#
lock (_lockObject)
{
// 共有データを安全に操作する
}

このコードでは、あるスレッドがlockブロックの中を実行している間、同じ_lockObjectを使っている別のスレッドは、そのブロックに入れません。先に入ったスレッドが処理を終えてロックを解放するまで待機します。

イメージとしては、共有データの前に「鍵付きのドア」を置くようなものです。鍵を持っているスレッドだけが中に入り、処理が終わったら鍵を返します。ほかのスレッドは鍵が返されるまで待つため、同時に共有データを書き換える事故を防げます。

1-2. マルチスレッド処理で起こる競合状態とは

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

たとえば、次のようなカウンターの加算処理を考えてみます。

C#
count++;

一見すると1つの処理に見えますが、内部的にはおおまかに次のような流れになります。

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

もし2つのスレッドが同時にcount++を実行すると、どちらも同じ現在値を読み取り、同じ結果を書き戻してしまうことがあります。

たとえばcountが10のとき、スレッドAとスレッドBが同時に処理すると、本来は12になってほしいのに、結果が11になる可能性があります。

スレッドA: countを読む -> 10
スレッドB: countを読む -> 10
スレッドA: 10 + 1を書き戻す -> 11
スレッドB: 10 + 1を書き戻す -> 11

このように、処理のタイミングによって結果が変わる不安定な状態が競合状態です。

1-3. 共有リソースを同時に書き換えると何が問題になるのか

共有リソースとは、複数のスレッドから参照または更新されるデータやオブジェクトのことです。たとえば、次のようなものが共有リソースになります。

・staticフィールド
・複数スレッドから使われるList
・複数スレッドから使われるDictionary
・キャッシュ
・ログファイル
・共有カウンター
・シングルトンインスタンス
・データベース接続や外部リソースへの状態管理

共有リソースを同時に書き換えると、次のような問題が発生します。

・値の更新が失われる
・コレクションの内部状態が壊れる
・例外が発生する
・ログの内容が混ざる
・キャッシュに不正な値が入る
・再現しにくいバグになる

特にマルチスレッドのバグは、毎回同じタイミングで発生するとは限りません。開発環境では問題がなくても、本番環境でアクセスが増えたときに突然発生することがあります。

そのため、共有データを複数スレッドから更新する可能性がある場合は、lockなどを使って安全に制御する必要があります。

1-4. lockを使うべき典型的なケース

C#でlockを使うべき典型的なケースは、「複数のスレッドが同じ可変データを読み書きする場合」です。

たとえば、次のような場面です。

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

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

この例では、_countという共有カウンターをlockで保護しています。複数のスレッドが同時にIncrementを呼び出しても、_count++は1スレッドずつ実行されます。

ほかにも、次のようなケースでlockが使われます。

・List<T>に複数スレッドからAddする
・Dictionary<TKey, TValue>を複数スレッドから更新する
・ログ出力を順番に行う
・キャッシュを初期化または更新する
・遅延初期化でインスタンスを1回だけ作る
・複数の値をまとめて整合性のある状態に更新する

ただし、単純な数値の加算だけならInterlockedで済む場合があります。また、DictionaryのようなコレクションではConcurrentDictionaryを使ったほうがよい場合もあります。lockは便利ですが、何でもlockすればよいわけではありません。

2. C#のlock文の基本構文と使い方

2-1. lock文の基本構文

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

C#
lock (ロック対象のオブジェクト)
{
// 同時に1スレッドだけ実行したい処理
}

実際のコードでは、次のように専用のロックオブジェクトを用意します。

C#
public class Counter
{
private readonly object _lockObject = new();
private int _count;

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

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

Incrementでは_countを更新し、GetCountでは_countを読み取っています。更新だけでなく、読み取りも同じlockで保護している点が重要です。

なぜなら、書き込み中に読み取りが行われると、中途半端な状態を見てしまう可能性があるからです。共有データに対するアクセスルールは、「書き込みだけlockする」ではなく、「同じ共有データに触る箇所を同じルールで守る」と考える必要があります。

2-2. lockに指定するオブジェクトの意味

lockに指定するオブジェクトは、「どの鍵を使って排他制御するか」を表します。

C#
lock (_lockObject)
{
// 保護したい処理
}

ここで重要なのは、_lockObject自体を守っているのではなく、_lockObjectを鍵としてコードブロックへの同時進入を制御しているという点です。

つまり、次の2つのコードは、同じ_lockObjectを使っているため、同時には実行されません。

C#
lock (_lockObject)
{
// 処理A
}

lock (_lockObject)
{
// 処理B
}

一方で、別々のロックオブジェクトを使っている場合は、互いに待ちません。

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

lock (_lockB)
{
// 処理B
}

同じ共有データを守る処理では、必ず同じロックオブジェクトを使う必要があります。逆に、無関係なデータまで同じロックオブジェクトで守ると、不要な待機が増えてパフォーマンスが落ちます。

2-3. カウンターを安全に更新するサンプルコード

次のコードは、複数のタスクからカウンターを更新する例です。

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

public class SafeCounter
{
private readonly object _lockObject = new();
private int _count;

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

public int Count
{
get
{
lock (_lockObject)
{
return _count;
}
}
}
}

public class Program
{
public static async Task Main()
{
var counter = new SafeCounter();
var tasks = new List<Task>();

for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
counter.Increment();
}
}));
}

await Task.WhenAll(tasks);

Console.WriteLine(counter.Count);
}
}

このコードでは、100個のタスクがそれぞれ1000回ずつIncrementを呼び出します。期待される結果は100,000です。

Incrementメソッドの中でlockを使っているため、_count++が同時に実行されることを防げます。結果として、更新の取りこぼしが起きにくくなります。

2-4. lockを使った場合と使わない場合の違い

lockを使わない場合、次のようなコードになります。

C#
public class UnsafeCounter
{
private int _count;

public void Increment()
{
_count++;
}

public int Count => _count;
}

このコードは単純ですが、複数スレッドから同時にIncrementされると、期待した回数より少ない値になることがあります。

一方、lockを使うと次のようになります。

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

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

public int Count
{
get
{
lock (_lockObject)
{
return _count;
}
}
}
}

違いは、_count++_countの読み取りを同じロックで守っていることです。

lockなしでは複数スレッドが同時に_countを読み書きします。lockありでは1スレッドずつ順番に処理します。その分、待ち時間は発生しますが、データの整合性を保ちやすくなります。

2-5. lock文の内部では何が行われているのか

C#のlock文は、内部的にはMonitor.EnterMonitor.Exitを使った処理に展開されます。Monitor.Enterは指定したオブジェクトの排他ロックを取得するメソッドで、取得できない場合はほかのスレッドが解放するまで待機します。

概念的には、次のようなコードに近い動きをします。

C#
object obj = _lockObject;
bool lockTaken = false;

try
{
System.Threading.Monitor.Enter(obj, ref lockTaken);

// lockブロック内の処理
}
finally
{
if (lockTaken)
{
System.Threading.Monitor.Exit(obj);
}
}

finallyMonitor.Exitが呼ばれるため、lockブロック内で例外が発生しても、ロックは解放されます。

また、.NET 9およびC# 13以降では、System.Threading.Lock型の専用インスタンスをlock対象にすると、より効率的なロックとして扱われます。Microsoft公式ドキュメントでは、.NET 9 / C# 13以降では専用のSystem.Threading.Lockインスタンスを使うことが推奨されています。

C#
private readonly System.Threading.Lock _lock = new();

public void DoWork()
{
lock (_lock)
{
// 保護したい処理
}
}

一方、古いバージョンのC#や.NETを使う場合は、従来どおりprivate readonly objectを使うのが一般的です。

3. lockで指定するオブジェクトの正しい選び方

3-1. private readonly objectを使うのが基本

従来のC#では、lock対象にはprivate readonly objectを使うのが基本です。

C#
private readonly object _lockObject = new();

この形が推奨される理由は、外部からアクセスされず、途中で別のオブジェクトに差し替えられないからです。

C#
public class Sample
{
private readonly object _lockObject = new();
private int _value;

public void Update()
{
lock (_lockObject)
{
_value++;
}
}
}

privateにすることで、クラスの外部から同じロックオブジェクトを使われるリスクを下げられます。readonlyにすることで、ロックオブジェクトが後から別のインスタンスに置き換わることを防げます。

C# 13 / .NET 9以降を前提にできる場合は、次のようにSystem.Threading.Lockを使う選択肢もあります。

C#
private readonly System.Threading.Lock _lock = new();

public void Update()
{
lock (_lock)
{
// 共有データを更新する
}
}

ただし、プロジェクトの対象フレームワークやC#バージョンによって使える機能が異なるため、既存プロジェクトではprivate readonly objectが今でも広く使われます。

3-2. thisをlock対象にしてはいけない理由

次のようにthislock対象にするのは避けるべきです。

C#
lock (this)
{
// 避けるべき
}

理由は、thisがクラスの外部から見える可能性があるからです。

たとえば、外部コードが同じインスタンスを使ってlockしてしまうと、自分のクラス内部のlockと干渉する可能性があります。

C#
var service = new MyService();

lock (service)
{
service.DoSomething();
}

このようなコードが外部にあると、クラス内部でlock (this)している処理と予期せず競合します。最悪の場合、デッドロックの原因にもなります。

ロックオブジェクトは、外部から触れない専用オブジェクトにするのが安全です。

C#
private readonly object _lockObject = new();

3-3. stringやTypeオブジェクトをlock対象にしてはいけない理由

stringlock対象にするのも避けるべきです。

C#
lock ("my-lock")
{
// 避けるべき
}

文字列リテラルはインターンされ、同じ文字列がアプリケーション内で共有されることがあります。そのため、自分のコードとは無関係な場所で同じ文字列をlockしていると、予期しない競合が発生する可能性があります。

また、Typeオブジェクトをlock対象にするのも避けるべきです。

C#
lock (typeof(MyClass))
{
// 避けるべき
}

typeof(MyClass)も外部コードから簡単に参照できます。そのため、外部コードとロックが干渉する可能性があります。

安全な書き方は、専用のprivate readonlyフィールドを使うことです。

C#
private readonly object _lockObject = new();

staticな共有データを守る場合も、typeof(MyClass)ではなく専用のstaticロックオブジェクトを用意します。

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

3-4. staticメンバーを守る場合のlockオブジェクト

staticフィールドは、クラス全体で共有されます。そのため、staticメンバーを守る場合は、ロックオブジェクトもstaticにする必要があります。

C#
public class GlobalCounter
{
private static readonly object _lockObject = new();
private static int _count;

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

public static int Count
{
get
{
lock (_lockObject)
{
return _count;
}
}
}
}

もしstaticデータをインスタンスごとのロックで守ってしまうと、別々のインスタンスから同時にstaticデータへアクセスできてしまいます。

C#
// よくない例
private readonly object _lockObject = new();
private static int _count;

staticデータにはstaticロック、インスタンスデータにはインスタンスロックを使う、という対応関係を意識しましょう。

3-5. インスタンスごとに分けるべきlockと共有すべきlock

ロックオブジェクトをインスタンスごとに分けるか、クラス全体で共有するかは、守りたいデータの範囲で決めます。

インスタンスごとに別々のデータを持つ場合は、インスタンスごとのロックで十分です。

C#
public class UserSession
{
private readonly object _lockObject = new();
private int _requestCount;

public void IncrementRequestCount()
{
lock (_lockObject)
{
_requestCount++;
}
}
}

この場合、別々のUserSessionインスタンスは互いに独立しているため、同じロックを共有する必要はありません。

一方、全インスタンスで共有するデータを守る場合は、staticロックが必要です。

C#
public class UserSession
{
private static readonly object _globalLock = new();
private static int _totalRequestCount;

public void IncrementTotalRequestCount()
{
lock (_globalLock)
{
_totalRequestCount++;
}
}
}

ポイントは、「同じデータを守るなら同じロック」「別々のデータなら別々のロック」です。

4. lockを使うときの注意点とよくある失敗

4-1. lock範囲を広げすぎるとパフォーマンスが落ちる

lockは、同時実行を制限する仕組みです。そのため、lock範囲が広すぎると、ほかのスレッドが待つ時間が長くなります。

よくない例です。

C#
lock (_lockObject)
{
var data = LoadDataFromFile();
var result = HeavyCalculation(data);
_cache = result;
}

このコードでは、ファイル読み込みや重い計算までlock内で実行しています。すると、その間ほかのスレッドはロックを取得できません。

改善例です。

C#
var data = LoadDataFromFile();
var result = HeavyCalculation(data);

lock (_lockObject)
{
_cache = result;
}

共有データを書き換える部分だけをlock内に入れることで、待機時間を短くできます。

lock範囲は、「共有データの整合性を守るために必要な最小限」にするのが基本です。

4-2. lock内で重い処理やI/O処理を行わない

lock内では、次のような処理をできるだけ避けるべきです。

・ファイル読み書き
・ネットワーク通信
・データベースアクセス
・外部API呼び出し
・長時間の計算処理
・ユーザー入力待ち
・別スレッドの完了待ち

これらの処理は時間がかかる可能性があります。lock内で実行すると、その間ほかのスレッドがすべて待たされます。

また、外部APIやデータベース処理の中で別のロックが使われている場合、予期しないデッドロックにつながることもあります。

よい設計では、時間のかかる処理はlockの外で行い、共有データの読み書きだけを短くlockします。

4-3. lock内で例外が発生した場合の動作

lockブロック内で例外が発生しても、ロックは解放されます。これは、lock文が内部的にtry/finallyを使い、finallyでロックを解放する形に展開されるためです。

C#
lock (_lockObject)
{
throw new InvalidOperationException();
}

この場合でも、例外によってlockブロックを抜けるとロックは解放されます。

ただし、注意点があります。ロックは解放されますが、共有データが中途半端な状態になる可能性はあります。

C#
lock (_lockObject)
{
user.Name = "Tanaka";
throw new Exception();
user.Age = 30;
}

この例では、Nameだけ更新され、Ageは更新されません。ロックは解放されますが、データの整合性は崩れる可能性があります。

そのため、lock内では例外が起きた場合の状態も考慮する必要があります。

4-4. lockで完全に安全になるとは限らない理由

lockを使っていても、必ず安全になるわけではありません。

理由は、共有データにアクセスするすべての場所で同じロックを使わなければ意味がないからです。

C#
public void Update()
{
lock (_lockObject)
{
_value++;
}
}

public int GetValue()
{
return _value; // lockしていない
}

このコードでは、更新時だけlockしていますが、読み取り時はlockしていません。読み取りと書き込みが同時に行われる可能性があるため、完全には安全とはいえません。

正しくは、読み取りも同じロックで保護します。

C#
public int GetValue()
{
lock (_lockObject)
{
return _value;
}
}

また、複数の値をまとめて整合性のある状態として扱う場合も注意が必要です。

C#
public int X { get; private set; }
public int Y { get; private set; }

XYをセットで更新するなら、両方を同じlockで守る必要があります。一部だけlockしても、読み取り側が中途半端な状態を見る可能性があります。

4-5. async/awaitとlockを一緒に使うときの注意点

C#では、lockブロックの中でawaitを使うことはできません。公式ドキュメントでも、lock文の本体内でawait式を使用できないと説明されています。

次のコードはコンパイルエラーになります。

C#
private readonly object _lockObject = new();

public async Task SaveAsync()
{
lock (_lockObject)
{
await Task.Delay(1000); // コンパイルエラー
}
}

awaitは処理を一時中断し、後で再開する仕組みです。lock中にawaitできてしまうと、ロックを保持したまま長時間待機するなど、危険な状態になりやすいため禁止されています。

async処理で排他制御したい場合は、SemaphoreSlimを検討します。SemaphoreSlimにはWaitAsyncがあり、非同期に待機できます。

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

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

try
{
await Task.Delay(1000);
// 非同期処理
}
finally
{
_semaphore.Release();
}
}

同期処理にはlock、非同期で待機が必要な処理にはSemaphoreSlim、と使い分けるのが基本です。

5. デッドロックとは?lockで起こりやすい原因と回避方法

5-1. デッドロックの基本的な仕組み

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

たとえば、スレッドAがロック1を持ったままロック2を待ち、スレッドBがロック2を持ったままロック1を待つと、どちらも永遠に待ち続けます。

スレッドA: lockAを取得 → lockBを待つ
スレッドB: lockBを取得 → lockAを待つ

この状態になると、処理は停止したように見えます。例外が発生するとは限らないため、原因調査が難しくなります。

デッドロックは、lockそのものが悪いのではなく、複数のロックを取得する順序や設計に問題があると発生します。

5-2. 複数のlockを逆順に取得すると危険

もっとも典型的なデッドロックの原因は、複数のロックを逆順に取得することです。

危険な例です。

C#
// スレッドA
lock (_lockA)
{
lock (_lockB)
{
// 処理
}
}

// スレッドB
lock (_lockB)
{
lock (_lockA)
{
// 処理
}
}

スレッドAが_lockAを取得し、スレッドBが_lockBを取得した状態になると、次に互いのロックを待ち合う可能性があります。

デッドロックを防ぐには、複数のロックを取得する順序を常に同じにすることが重要です。

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

どの処理でも必ず_lockAを先に取り、次に_lockBを取るように統一します。

5-3. デッドロックが発生するサンプルコード

次のコードは、デッドロックが発生する可能性のある例です。

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

public class DeadlockSample
{
private readonly object _lockA = new();
private readonly object _lockB = new();

public void Method1()
{
lock (_lockA)
{
Thread.Sleep(100);

lock (_lockB)
{
Console.WriteLine("Method1 completed");
}
}
}

public void Method2()
{
lock (_lockB)
{
Thread.Sleep(100);

lock (_lockA)
{
Console.WriteLine("Method2 completed");
}
}
}
}

public class Program
{
public static void Main()
{
var sample = new DeadlockSample();

var task1 = Task.Run(() => sample.Method1());
var task2 = Task.Run(() => sample.Method2());

Task.WaitAll(task1, task2);
}
}

Method1_lockAを先に取得し、次に_lockBを取得します。Method2は逆に_lockBを先に取得し、次に_lockAを取得します。

タイミングによっては、task1_lockAを持ち、task2_lockBを持ったまま、お互いのロックを待ち続けます。

このようなコードは、テストでは動いてしまうこともあります。しかし、本番環境で負荷がかかったときに突然停止する可能性があります。

5-4. lock取得順序を統一して回避する

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

改善例です。

C#
public class SafeLockOrderSample
{
private readonly object _lockA = new();
private readonly object _lockB = new();

public void Method1()
{
lock (_lockA)
{
lock (_lockB)
{
Console.WriteLine("Method1 completed");
}
}
}

public void Method2()
{
lock (_lockA)
{
lock (_lockB)
{
Console.WriteLine("Method2 completed");
}
}
}
}

どちらのメソッドでも、必ず_lockAを先に取得し、次に_lockBを取得しています。これにより、互いに逆順で待ち合う状態を避けられます。

複数のロックが必要な設計では、次のようなルールを決めておくと安全です。

・ロック取得順序を設計書やコメントに明記する
・コードレビューで順序が守られているか確認する
・ロックのネストをできるだけ減らす
・必要なら1つのロックにまとめる

5-5. lockのネストを減らす設計にする

lockのネストが増えるほど、デッドロックのリスクは高くなります。

C#
lock (_lockA)
{
lock (_lockB)
{
lock (_lockC)
{
// 複雑な処理
}
}
}

このようなコードは、どのロックが何を守っているのか分かりにくくなります。修正時に別の場所で逆順に取得してしまう危険もあります。

ネストを減らすためには、次のような設計を検討します。

・共有データの責務を分ける
・ロックが必要なデータを1つのクラスに閉じ込める
・複数データを同時に更新しない設計にする
・不変オブジェクトを使う
・処理をキューに積んで単一スレッドで処理する

単にlockを追加して対処するのではなく、共有状態そのものを減らすことが重要です。

5-6. Monitor.TryEnterを使ったタイムアウト付き制御

lock文は、ロックを取得できるまで待ち続けます。待機時間を制限したい場合は、Monitor.TryEnterを使います。

C#
using System;
using System.Threading;

public class TryEnterSample
{
private readonly object _lockObject = new();

public void DoWork()
{
bool lockTaken = false;

try
{
Monitor.TryEnter(_lockObject, TimeSpan.FromSeconds(3), ref lockTaken);

if (!lockTaken)
{
Console.WriteLine("ロックを取得できませんでした");
return;
}

// ロックを取得できた場合の処理
Console.WriteLine("処理を実行します");
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lockObject);
}
}
}
}

この例では、3秒以内にロックを取得できなければ処理を中断します。

Monitor.TryEnterを使うと、デッドロックそのものを完全に防げるわけではありません。しかし、「永久に待ち続ける」状態を避ける助けになります。

ただし、タイムアウト後にどう処理するかを明確に設計する必要があります。単に失敗を無視すると、別の不整合が起こる可能性があります。

6. lockとMonitor・Mutex・SemaphoreSlimの違い

6-1. lockとMonitorの関係

lockは、Monitorを簡単に使うための構文です。

通常は次のように書きます。

C#
lock (_lockObject)
{
// 保護したい処理
}

これは概念的には次のようなMonitor.EnterMonitor.Exitの処理に近いです。

C#
bool lockTaken = false;

try
{
Monitor.Enter(_lockObject, ref lockTaken);

// 保護したい処理
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lockObject);
}
}

通常の排他制御ではlockを使えば十分です。

一方で、次のような場合はMonitorを直接使うことがあります。

・タイムアウト付きでロックを取得したい
・ロック取得に失敗した場合の処理を分けたい
・Wait/Pulseを使ったスレッド間通知をしたい

初心者のうちは、まずlockを正しく使えるようになることが重要です。

6-2. Mutexとの違いと使い分け

Mutexは、プロセスをまたいだ排他制御にも使える同期オブジェクトです。

lockは基本的に同一プロセス内のスレッド間で使います。一方、Mutexは名前付きMutexを使うことで、別プロセス間でも同じリソースへのアクセスを制御できます。

たとえば、アプリケーションを二重起動させたくない場合などにMutexが使われます。

C#
using System;
using System.Threading;

public class Program
{
public static void Main()
{
using var mutex = new Mutex(false, "MyApplicationMutex");

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

Console.WriteLine("アプリケーションを実行します");
Console.ReadLine();
}
}

使い分けの目安は次のとおりです。

・同一プロセス内の共有データを守る → lock
・別プロセス間で排他制御したい → Mutex

Mutexlockより重い仕組みです。通常のクラス内のフィールド保護であれば、lockを使うほうがシンプルです。

6-3. SemaphoreSlimとの違いと使い分け

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

lockは同時に1つのスレッドだけ通します。一方、SemaphoreSlimは「同時に3つまで」のような制御ができます。

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

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

try
{
await Task.Delay(1000);
Console.WriteLine("処理完了");
}
finally
{
_semaphore.Release();
}
}

この例では、同時に最大3つの処理まで実行できます。

また、SemaphoreSlimにはWaitAsyncがあるため、async/awaitとの相性がよいです。公式ドキュメントでも、SemaphoreSlim.WaitAsyncは非同期的にセマフォへ入るためのメソッドとして提供されています。

使い分けの目安は次のとおりです。

・同期コードで1つの共有データを守る → lock
・async/awaitで排他制御したい → SemaphoreSlim
・同時実行数を制限したい → SemaphoreSlim

6-4. ReaderWriterLockSlimが向いているケース

ReaderWriterLockSlimは、読み取りは複数スレッドで同時に許可し、書き込みは1スレッドだけに制限するロックです。Microsoft公式ドキュメントでも、読み取りは複数スレッド、書き込みは排他的に管理するロックとして説明されています。

読み取りが多く、書き込みが少ないデータに向いています。

C#
using System.Threading;

public class ConfigStore
{
private readonly ReaderWriterLockSlim _lock = new();
private string _config = "";

public string GetConfig()
{
_lock.EnterReadLock();
try
{
return _config;
}
finally
{
_lock.ExitReadLock();
}
}

public void SetConfig(string config)
{
_lock.EnterWriteLock();
try
{
_config = config;
}
finally
{
_lock.ExitWriteLock();
}
}
}

通常のlockでは、読み取り同士も同時に実行できません。しかし、ReaderWriterLockSlimでは複数の読み取りを同時に許可できます。

ただし、構造が複雑になるため、読み取りが圧倒的に多いケースでなければ、普通のlockのほうが分かりやすい場合もあります。

6-5. 用途別に見る排他制御の選び方

C#で排他制御を選ぶときは、用途に応じて使い分けます。

・単純な共有データの保護
→ lock

・タイムアウト付きでロックしたい
→ Monitor.TryEnter

・数値の加算や交換など単純な原子的操作
→ Interlocked

・async/awaitで排他制御したい
→ SemaphoreSlim

・同時実行数を制限したい
→ SemaphoreSlim

・プロセス間で排他制御したい
→ Mutex

・読み取りが多く、書き込みが少ない
→ ReaderWriterLockSlim

・Dictionaryを複数スレッドで使いたい
→ ConcurrentDictionary

初心者は、まずlockを基本として理解し、そのうえで「もっと適した手段がないか」を考えるとよいでしょう。

7. lockの実践的なサンプルコード

7-1. ListやDictionaryを安全に操作する例

List<T>や通常のDictionary<TKey, TValue>は、複数スレッドから同時に更新する前提のコレクションではありません。複数スレッドから操作する場合は、lockで保護します。

Listの例です。

C#
using System.Collections.Generic;

public class SafeList
{
private readonly object _lockObject = new();
private readonly List<string> _items = new();

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

public List<string> GetSnapshot()
{
lock (_lockObject)
{
return new List<string>(_items);
}
}
}

ここで重要なのは、GetSnapshotで内部の_itemsをそのまま返していないことです。

C#
return _items; // よくない

内部のListをそのまま返すと、呼び出し元がロックなしで変更できてしまいます。安全にするには、コピーを返します。

Dictionaryの例です。

C#
using System.Collections.Generic;

public class SafeDictionary
{
private readonly object _lockObject = new();
private readonly Dictionary<string, int> _scores = new();

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

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

読み取りと書き込みの両方を同じlockで守ることがポイントです。

7-2. 複数スレッドからログを書き込む例

複数スレッドから同じログファイルに書き込む場合、出力が混ざったり、ファイルアクセスで例外が発生したりすることがあります。

簡単な例です。

C#
using System;
using System.IO;

public class FileLogger
{
private readonly object _lockObject = new();
private readonly string _filePath;

public FileLogger(string filePath)
{
_filePath = filePath;
}

public void Log(string message)
{
var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}";

lock (_lockObject)
{
File.AppendAllText(_filePath, line + Environment.NewLine);
}
}
}

このコードでは、ファイルへの書き込みをlockで保護しています。

ただし、ファイルI/Oは時間がかかる可能性があるため、高負荷なログ出力では専用のログライブラリやキューを使った非同期ログ設計を検討したほうがよいです。

たとえば、大量のログを扱う場合は、各スレッドが直接ファイルに書くのではなく、ログメッセージをキューに入れ、専用の1スレッドが順番に書き込む設計が有効です。

7-3. シングルトン生成時にlockを使う例

シングルトンは、インスタンスを1つだけ生成して共有する設計パターンです。遅延初期化でシングルトンを作る場合、複数スレッドが同時に初期化処理へ入らないようにする必要があります。

C#
public class Singleton
{
private static readonly object _lockObject = new();
private static Singleton? _instance;

private Singleton()
{
}

public static Singleton Instance
{
get
{
lock (_lockObject)
{
if (_instance == null)
{
_instance = new Singleton();
}

return _instance;
}
}
}
}

このコードでは、Instanceプロパティにアクセスしたとき、lock内でインスタンスが未生成かどうかを確認し、必要なら生成します。

ただし、C#ではLazy<T>を使うと、より簡潔にスレッドセーフな遅延初期化を実装できます。

C#
public class Singleton
{
private static readonly Lazy<Singleton> _instance = new(() => new Singleton());

private Singleton()
{
}

public static Singleton Instance => _instance.Value;
}

シングルトン目的だけなら、lockを自前で書くよりLazy<T>を使うほうが安全で読みやすい場合が多いです。

7-4. キャッシュ更新処理でlockを使う例

キャッシュは、複数スレッドから読み書きされることが多い共有データです。

次の例では、データがなければ生成してキャッシュに保存します。

C#
using System.Collections.Generic;

public class CacheService
{
private readonly object _lockObject = new();
private readonly Dictionary<string, string> _cache = new();

public string GetValue(string key)
{
lock (_lockObject)
{
if (_cache.TryGetValue(key, out var value))
{
return value;
}

var newValue = CreateValue(key);
_cache[key] = newValue;

return newValue;
}
}

private string CreateValue(string key)
{
return $"value:{key}";
}
}

このコードはシンプルですが、CreateValueが重い処理の場合、lock内で時間がかかってしまいます。

改善案として、重い処理を外に出す方法があります。

C#
public string GetValue(string key)
{
lock (_lockObject)
{
if (_cache.TryGetValue(key, out var value))
{
return value;
}
}

var newValue = CreateValue(key);

lock (_lockObject)
{
if (_cache.TryGetValue(key, out var value))
{
return value;
}

_cache[key] = newValue;
return newValue;
}
}

この形では、重い生成処理をlockの外で行っています。ただし、複数スレッドが同時に同じ値を生成する可能性はあります。重複生成を避けたいのか、待機時間を減らしたいのかによって設計を選ぶ必要があります。

7-5. スレッドセーフなクラス設計の例

スレッドセーフなクラスを設計するには、共有データを外部に直接公開しないことが重要です。

C#
using System.Collections.Generic;

public class ShoppingCart
{
private readonly object _lockObject = new();
private readonly List<string> _items = new();

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

public bool RemoveItem(string item)
{
lock (_lockObject)
{
return _items.Remove(item);
}
}

public IReadOnlyList<string> GetItems()
{
lock (_lockObject)
{
return _items.ToArray();
}
}

public int Count
{
get
{
lock (_lockObject)
{
return _items.Count;
}
}
}
}

このクラスでは、内部のList<string>を直接返していません。GetItemsでは配列コピーを返しています。

スレッドセーフなクラス設計のポイントは次のとおりです。

・共有データをprivateにする
・共有データに触るすべてのメソッドで同じlockを使う
・内部コレクションをそのまま外部に返さない
・lock範囲を小さくする
・外部から渡されたコールバックをlock内で呼ばない

lockは単なる構文ではなく、クラス全体のアクセスルールとして設計する必要があります。

8. lockを使わないほうがよいケースと代替手段

8-1. Interlockedで済むケース

単純な数値の加算、減算、交換であれば、lockよりInterlockedが適している場合があります。

Interlockedは、複数スレッドで共有される変数に対する原子的操作を提供するクラスです。公式ドキュメントでも、共有変数に対するアトミック操作を提供すると説明されています。

カウンターの加算は次のように書けます。

C#
using System.Threading;

public class AtomicCounter
{
private int _count;

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

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

Interlocked.Incrementは、指定した変数を原子的にインクリメントします。

単純なカウンターであれば、lockよりも簡潔で高速な場合があります。

ただし、複数の値をまとめて更新する場合や、複雑な条件判定を含む場合は、Interlockedだけでは不十分です。

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

このような「確認してから更新する」処理では、全体を1つの排他的な操作として守る必要があるため、lockのほうが分かりやすい場合があります。

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

複数スレッドからDictionaryを操作する場合、lockで守る方法もありますが、ConcurrentDictionary<TKey, TValue>を使う方法もあります。

ConcurrentDictionary<TKey, TValue>の公式ドキュメントでは、publicおよびprotectedメンバーはスレッドセーフで、複数スレッドから同時に使用できると説明されています。

C#
using System.Collections.Concurrent;

public class UserScoreStore
{
private readonly ConcurrentDictionary<string, int> _scores = new();

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

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

値を追加または更新したい場合は、AddOrUpdateを使えます。

C#
_scores.AddOrUpdate(
userId,
addValue: 1,
updateValueFactory: (_, current) => current + 1);

ただし、ConcurrentDictionaryを使えば常にすべての処理が自動的に安全になるわけではありません。複数の操作を組み合わせて1つの整合性を保ちたい場合は、別途設計が必要です。

8-3. immutableな設計で競合を避けるケース

そもそも共有データを書き換えなければ、競合は大きく減ります。

immutable、つまり不変な設計では、オブジェクトを作成後に変更しません。変更が必要な場合は、新しいオブジェクトを作ります。

C#
public record UserSettings(string Theme, int FontSize);

たとえば、設定情報を不変オブジェクトとして扱えば、複数スレッドが同時に読み取っても問題になりにくくなります。

C#
private UserSettings _settings = new("Light", 14);

public UserSettings GetSettings()
{
return _settings;
}

更新時には新しいインスタンスに差し替えます。

C#
public void UpdateTheme(string theme)
{
_settings = _settings with { Theme = theme };
}

もちろん、参照の差し替え自体をどう安全に行うかは考える必要があります。しかし、内部状態を細かく変更する設計より、競合の範囲を小さくできます。

lockに頼りすぎるより、共有する可変状態を減らす設計のほうが安全で保守しやすいことがあります。

8-4. async処理ではSemaphoreSlimを検討する

async/awaitを使う処理では、lockではなくSemaphoreSlimを検討します。

lockブロックの中ではawaitできません。

よくない例です。

C#
public async Task UpdateAsync()
{
lock (_lockObject)
{
await SaveAsync(); // コンパイルエラー
}
}

代わりに、SemaphoreSlimを使います。

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

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

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

SemaphoreSlimnew(1, 1)で作ると、同時に1つの処理だけ通す排他制御として使えます。

async処理では、スレッドをブロックしないことが重要です。そのため、同期的に待つlockではなく、非同期に待てるWaitAsyncを使えるSemaphoreSlimが適しています。

8-5. lockに頼りすぎない設計の考え方

lockは便利ですが、多用するとコードが複雑になります。

次のようなコードは注意が必要です。

・あちこちで同じデータをlockしている
・複数のlockが深くネストしている
・どのlockが何を守っているのか分からない
・lock内で外部メソッドをたくさん呼んでいる
・後からlockを追加しないとバグが直らない

このような場合、単にlockを増やすのではなく、設計を見直すべきです。

改善の方向性としては、次のようなものがあります。

・共有データを減らす
・データの所有者を1つのクラスに集約する
・不変オブジェクトを使う
・ConcurrentDictionaryなどの専用コレクションを使う
・キューで処理を直列化する
・単純なカウンターはInterlockedを使う

良いマルチスレッド設計では、lockの数を増やすより、lockが必要な場所を明確に減らします。

9. C# lockのベストプラクティス

9-1. lock対象はprivate readonlyにする

lock対象は、外部から触れない専用オブジェクトにします。

C#
private readonly object _lockObject = new();

C# 13 / .NET 9以降を使える場合は、専用のSystem.Threading.Lockも選択肢になります。Microsoft公式ドキュメントでは、.NET 9 / C# 13以降では専用のSystem.Threading.Lockインスタンスを使うことが推奨されています。

C#
private readonly System.Threading.Lock _lock = new();

避けるべき例です。

C#
lock (this) { }
lock (typeof(MyClass)) { }
lock ("my-lock") { }

外部から参照できるオブジェクトや共有される可能性があるオブジェクトをロック対象にすると、予期しない競合やデッドロックの原因になります。

9-2. lock範囲はできるだけ小さくする

lock範囲は、共有データの整合性を守るために必要な最小限にします。

よくない例です。

C#
lock (_lockObject)
{
var data = DownloadData();
var result = ConvertData(data);
_cache = result;
}

改善例です。

C#
var data = DownloadData();
var result = ConvertData(data);

lock (_lockObject)
{
_cache = result;
}

ロック中の時間が短いほど、ほかのスレッドが待つ時間も短くなります。

ただし、範囲を小さくしすぎて整合性が崩れてはいけません。複数の共有データをまとめて更新する必要がある場合は、その一連の処理全体をlockで守る必要があります。

9-3. lock内では外部メソッド呼び出しを避ける

lock内で外部メソッドを呼ぶと、そのメソッドの中で何が起こるか分かりにくくなります。

C#
lock (_lockObject)
{
externalService.DoSomething();
}

DoSomethingの中で時間のかかる処理や別のロック取得が行われると、パフォーマンス低下やデッドロックにつながる可能性があります。

特に避けたいのは、次のような呼び出しです。

・外部API
・データベース
・ファイルI/O
・イベント通知
・コールバック
・仮想メソッド
・ユーザー定義のdelegate

lock内では、できるだけ自分で制御できる短い処理だけを行いましょう。

9-4. 複数lockの順序を統一する

複数のロックを取得する必要がある場合は、必ず順序を統一します。

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

別の場所でも同じ順序にします。

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

逆順にするとデッドロックの危険があります。

C#
lock (_lockB)
{
lock (_lockA)
{
// 危険
}
}

複数lockの順序は、コメントや設計ドキュメントに残しておくと安全です。

9-5. 共有データへのアクセスルールを明確にする

lockを正しく使うには、「どのデータを、どのロックで守るのか」を明確にする必要があります。

たとえば、次のようにコメントを書くと分かりやすくなります。

C#
private readonly object _lockObject = new();

// _itemsへのアクセスは必ず_lockObjectで保護する
private readonly List<string> _items = new();

そして、_itemsにアクセスするすべてのコードで同じ_lockObjectを使います。

C#
public void Add(string item)
{
lock (_lockObject)
{
_items.Add(item);
}
}

public int Count
{
get
{
lock (_lockObject)
{
return _items.Count;
}
}
}

共有データへのアクセスルールが曖昧だと、一部のコードだけlockを忘れてバグになります。

9-6. コードレビューで確認すべきポイント

lockを使ったコードをレビューするときは、次の点を確認します。

・lock対象がprivate readonlyになっているか
・this、string、typeofをlockしていないか
・同じ共有データに同じlockを使っているか
・読み取りも必要に応じてlockしているか
・lock範囲が広すぎないか
・lock内でI/Oや外部メソッドを呼んでいないか
・複数lockの取得順序が統一されているか
・asyncメソッドでlock内awaitをしようとしていないか
・InterlockedやConcurrentDictionaryで代替できないか
・デッドロック時に調査しやすい設計になっているか

特に重要なのは、「データを守るルールが一貫しているか」です。lockが書かれているかどうかだけでなく、共有データにアクセスするすべての経路を確認する必要があります。

10. csharp lockに関するよくある質問

10-1. lockは何のために使うのか

lockは、複数のスレッドが同じ共有データを同時に操作しないようにするために使います。

たとえば、カウンター、List、Dictionary、キャッシュ、ログファイルなどを複数スレッドから更新する場合、競合状態が起きる可能性があります。

lockを使うと、指定したコードブロックに同時に入れるスレッドを1つに制限できます。これにより、共有データの整合性を守りやすくなります。

10-2. lockは遅いのか

lockには一定のオーバーヘッドがあります。また、ロックを取得できないスレッドは待機するため、使い方によってはパフォーマンスが落ちます。

ただし、lock自体が常に悪いわけではありません。共有データの整合性を守るためには必要な場面があります。

パフォーマンスを悪化させないためには、次の点を意識します。

・lock範囲を小さくする
・lock内で重い処理をしない
・無関係なデータを同じlockで守らない
・単純な加算はInterlockedを検討する
・コレクションにはConcurrentDictionaryなどを検討する

「lockは遅いから使わない」ではなく、「必要な場所に最小限使う」のが正しい考え方です。

10-3. lockとvolatileの違いは何か

lockは、排他制御を行う仕組みです。あるスレッドがlockブロックを実行している間、ほかのスレッドは同じロックを取得できません。

一方、volatileは、主に変数の読み書きに関する最適化やメモリ可視性に関係するキーワードです。volatileを付けても、count++のような複合操作が原子的になるわけではありません。

たとえば、次の処理はvolatileだけでは安全になりません。

C#
private volatile int _count;

public void Increment()
{
_count++;
}

_count++は読み取り、加算、書き込みの複数ステップで構成されるため、競合が起きる可能性があります。

単純なフラグの可視性にはvolatileが使われることがありますが、共有データの整合性を守るにはlockInterlockedを検討する必要があります。

10-4. lock内でawaitできるのか

lock内でawaitはできません。C#では、lock文の本体内でawait式を使用できないとされています。

次のコードはコンパイルエラーです。

C#
lock (_lockObject)
{
await Task.Delay(1000);
}

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

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

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

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

同期処理ではlock、非同期処理ではSemaphoreSlimを検討しましょう。

10-5. static lockとインスタンスlockの違いは何か

static lockは、クラス全体で共有されるロックです。staticフィールドを守るときに使います。

C#
private static readonly object _staticLock = new();
private static int _totalCount;

インスタンスlockは、各インスタンスごとに別々のロックです。インスタンスフィールドを守るときに使います。

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

使い分けの基本は次のとおりです。

・staticデータを守る → static lock
・インスタンスごとのデータを守る → インスタンスlock

staticデータをインスタンスlockで守ると、別インスタンスから同時にアクセスできてしまうため注意が必要です。

10-6. lockを使ってもデッドロックするのか

はい、lockを使ってもデッドロックすることがあります。

特に、複数のロックを逆順に取得するコードは危険です。

C#
// スレッドA
lock (_lockA)
{
lock (_lockB)
{
}
}

// スレッドB
lock (_lockB)
{
lock (_lockA)
{
}
}

このようなコードでは、タイミングによって互いにロック解放を待ち続ける可能性があります。

デッドロックを防ぐには、次の対策が有効です。

・複数lockの取得順序を統一する
・lockのネストを減らす
・lock内で外部メソッドを呼ばない
・lock内で長時間待機しない
・必要に応じてMonitor.TryEnterでタイムアウトを設ける

lockは競合状態を防ぐための仕組みですが、設計を誤るとデッドロックの原因にもなります。

まとめ

C#のlockは、複数スレッドから共有データへ同時にアクセスする場面で、データの整合性を守るための基本的な排他制御です。

lockを使うと、指定したロックオブジェクトに対して相互排他ロックを取得し、同じロックを使うコードブロックに同時に1つのスレッドだけが入れるようになります。内部的には従来Monitor.EnterMonitor.Exitを使った形に展開され、例外が発生してもロックが解放されるようになっています。

基本的な書き方は次のとおりです。

C#
private readonly object _lockObject = new();

lock (_lockObject)
{
// 共有データを操作する
}

C# 13 / .NET 9以降では、専用のSystem.Threading.Lockインスタンスを使う方法も推奨されています。

C#
private readonly System.Threading.Lock _lock = new();

lock (_lock)
{
// 共有データを操作する
}

lockを使うときの重要ポイントは次のとおりです。

・lock対象はprivate readonlyにする
・this、string、typeofをlockしない
・共有データに触るすべての箇所で同じlockを使う
・lock範囲は必要最小限にする
・lock内で重い処理やI/Oを行わない
・lock内でawaitしない
・複数lockの取得順序を統一する
・デッドロックを意識して設計する

また、すべてをlockで解決しようとするのではなく、用途に応じて代替手段を選ぶことも大切です。

・単純なカウンター → Interlocked
・スレッドセーフなDictionary → ConcurrentDictionary
・async/awaitでの排他制御 → SemaphoreSlim
・読み取りが多い共有データ → ReaderWriterLockSlim
・プロセス間の排他制御 → Mutex
・共有状態を減らす → immutableな設計

csharp lockを正しく理解することは、C#で安全なマルチスレッド処理を書くための第一歩です。lockの構文だけでなく、何を守るのか、どの範囲を守るのか、デッドロックをどう避けるのかまで意識して使うことで、安定したスレッドセーフなコードを書けるようになります。