C# lockとは?使い方・スレッドセーフ・デッドロック対策を初心者向けに徹底解説

はじめに

C#でマルチスレッド処理を書くときに避けて通れないのが、lockです。Task.RunParallel.ForEach、Webアプリの同時リクエスト、タイマー処理、バックグラウンド処理など、複数の処理が同じデータに同時アクセスする場面では、思わぬ不具合が発生することがあります。

たとえば、複数のスレッドが同じカウンターを同時に加算したり、同じListDictionaryに同時に書き込んだりすると、値がずれたり、例外が発生したり、データの整合性が壊れたりします。

lockは、こうした共有データへの同時アクセスを制御するためのC#の基本的な仕組みです。C#のlock文は、指定したロック対象を取得し、ブロック内の処理を実行してからロックを解放します。ロック中は、他のスレッドは同じロックを取得できず、処理の順番を待ちます。MicrosoftのC#リファレンスでも、lockは共有リソースへの排他的アクセスを保証する構文として説明されています。

この記事では、C#のlockとは何か、基本構文、スレッドセーフな使い方、ロック対象オブジェクトの選び方、デッドロック対策、Monitorとの関係、InterlockedSemaphoreSlimなど他の同期手段との違いまで、初心者にもわかりやすく解説します。

1. C#のlockとは?まず押さえる基本概念

1-1. lockは複数スレッドから共有データを守るための仕組み

C#のlockは、複数のスレッドが同じデータを同時に変更しないようにするための仕組みです。

たとえば、次のような共有カウンターがあるとします。

C#
private int _count = 0;

この_countを複数のスレッドから同時に加算すると、期待どおりの結果にならないことがあります。

C#
_count++;

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

1. 現在の値を読み取る
2. 1を足す
3. 結果を書き戻す

複数のスレッドが同時にこの処理を行うと、「読み取った値が古い」「別のスレッドの更新を上書きする」といった問題が起こります。そこでlockを使い、同時に1つのスレッドだけが共有データを操作できるようにします。

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

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

このようにすると、あるスレッドがlockブロックを実行している間、他のスレッドは同じ_lockを使ったlockブロックに入れません。

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

競合状態、またはレースコンディションとは、複数の処理が同じデータに同時アクセスし、その実行タイミングによって結果が変わってしまう状態です。

たとえば、2つのスレッドが同時に_count++を実行した場合を考えます。

_count の初期値: 0

スレッドA: _countを読む → 0
スレッドB: _countを読む → 0
スレッドA: 0 + 1 を書き込む → 1
スレッドB: 0 + 1 を書き込む → 1

本来は2回加算されたので結果は2になってほしいところですが、実際には1になる可能性があります。これが競合状態です。

競合状態は再現性が低く、テストでは問題が出ないのに本番環境で突然発生することがあります。そのため、共有データに対する読み書きがある場合は、最初からスレッドセーフを意識して設計することが重要です。

1-3. lockを使うと何がスレッドセーフになるのか

lockを使うと、「同じロックオブジェクトを使って囲まれた範囲」が同時に1つのスレッドだけ実行されるようになります。つまり、lockが守るのは変数そのものではなく、lockブロックで囲んだコード領域です。

C#
lock (_lock)
{
// この中は同時に1スレッドだけ実行される
}

ここで重要なのは、同じ共有データにアクセスするすべての箇所で、同じロックオブジェクトを使う必要があるという点です。

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

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

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

加算処理だけをlockしても、読み取り側がlockされていなければ、完全に安全とはいえない場合があります。スレッドセーフにしたいデータへアクセスする経路を整理し、読み取りと書き込みの両方を適切に保護することが大切です。

1-4. lockが必要になる代表的な場面

C#でlockが必要になる代表的な場面は次のとおりです。

・複数スレッドから同じ変数を更新する
・複数スレッドからListやDictionaryを変更する
・複数スレッドから同じファイルに書き込む
・キャッシュを複数スレッドで共有する
・シングルトンなどの初期化処理を一度だけ実行したい
・共有リソースの状態を一貫性のある形で更新したい

一方で、ローカル変数のようにスレッドごとに独立しているデータには、通常lockは不要です。

C#
public int Calculate()
{
int localValue = 10; // このメソッド内だけで使うなら通常lock不要
return localValue * 2;
}

lockが必要かどうかを判断するときは、「複数のスレッドが同じデータにアクセスするか」「その中に書き込みが含まれるか」を確認しましょう。

2. C# lockの基本的な使い方

2-1. lock文の基本構文

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

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

一般的には、クラス内にprivate readonlyなロック専用オブジェクトを用意します。

C#
public class Sample
{
private readonly object _lock = new();

public void DoWork()
{
lock (_lock)
{
// 共有データを操作する処理
}
}
}

.NET 9およびC# 13以降では、System.Threading.Lock型を使う選択肢もあります。MicrosoftのC#リファレンスでは、.NET 9とC# 13以降では専用のSystem.Threading.Lockインスタンスをロックすることが推奨されています。一方、古い.NETやC#では、他の用途に使わない専用の参照型オブジェクトをロック対象にします。

C#
using System.Threading;

public class Sample
{
private readonly Lock _lock = new();

public void DoWork()
{
lock (_lock)
{
// 共有データを操作する処理
}
}
}

ただし、既存コードや入門記事ではprivate readonly object _lock = new();の形が今でもよく使われます。利用している.NETやC#のバージョンに合わせて選びましょう。

2-2. 最小コードで理解するlockのサンプル

まずは、カウンターをlockで保護するシンプルな例を見てみましょう。

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

public class Counter
{
private readonly object _lock = new();
private int _count = 0;

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

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

public class Program
{
public static async Task Main()
{
var counter = new Counter();

var tasks = new Task[10];

for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
counter.Increment();
}
});
}

await Task.WhenAll(tasks);

Console.WriteLine(counter.Value);
}
}

このコードでは、10個のタスクがそれぞれ1万回ずつIncrementを呼び出します。合計10万回の加算が行われるため、期待値は100000です。

Incrementメソッド内でlockを使っているため、_count++が同時に実行されることを防げます。

2-3. lockしない場合に起こる問題

次のようにlockを使わないコードでは、結果が期待値より小さくなる可能性があります。

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

public class UnsafeCounter
{
private int _count = 0;

public void Increment()
{
_count++;
}

public int Value => _count;
}

public class Program
{
public static async Task Main()
{
var counter = new UnsafeCounter();

var tasks = new Task[10];

for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
counter.Increment();
}
});
}

await Task.WhenAll(tasks);

Console.WriteLine(counter.Value);
}
}

このコードでは、実行するたびに結果が変わる可能性があります。100000になることもありますが、9998099850のように少なくなることもあります。

原因は、_count++が不可分な操作ではないためです。読み取り、加算、書き込みの途中で別スレッドが割り込むと、更新が失われることがあります。

2-4. lockを使った場合の動作の違い

lockを使うと、同じロックオブジェクトに対するlockブロックは同時に1つのスレッドだけが実行できます。

C#
lock (_lock)
{
_count++;
}

このときの流れは次のようになります。

スレッドA: lockを取得する
スレッドA: _count++ を実行する
スレッドB: lockを取得しようとするが待機する
スレッドA: lockを解放する
スレッドB: lockを取得する
スレッドB: _count++ を実行する
スレッドB: lockを解放する

同時実行ではなく順番に実行されるため、共有データの整合性を保ちやすくなります。

2-5. lockブロック内で実行される処理の流れ

lockブロックでは、おおまかに次の処理が行われます。

1. ロックオブジェクトのロック取得を試みる
2. 取得できたらlockブロック内の処理を実行する
3. 処理が終わったらロックを解放する
4. 例外が発生してもロックを解放する

C#のlock文は、参照型オブジェクトに対する従来のロックでは内部的にMonitor.EnterMonitor.Exitを使う形に展開されます。また、try-finallyにより、lockブロック内で例外が発生してもロックが解放されるようになっています。

イメージとしては、次のコードに近い動作です。

C#
object lockObj = _lock;
bool lockTaken = false;

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

// lockブロック内の処理
_count++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(lockObj);
}
}

通常はこのようなコードを直接書く必要はありません。基本的にはlock文を使ったほうが簡潔で安全です。

3. lockで使うオブジェクトの選び方

3-1. lock用オブジェクトはprivate readonlyで用意する

lockで使うオブジェクトは、原則としてロック専用に用意します。

C#
private readonly object _lock = new();

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

private   : 外部から勝手にlockされないようにする
readonly : 途中で別のオブジェクトに差し替わらないようにする
object : ロック専用の参照型オブジェクトとして使う

良い例は次のような形です。

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

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

.NET 9/C# 13以降であれば、次のようにSystem.Threading.Lockを使うことも検討できます。

C#
using System.Threading;

public class SafeCounter
{
private readonly Lock _lock = new();
private int _count;

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

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

thisをロック対象にするのは避けるべきです。

C#
lock (this)
{
// 非推奨
}

理由は、thisはクラスの外部からも参照できる可能性があるためです。

C#
var service = new MyService();

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

外部コードが同じインスタンスをlockしてしまうと、クラス内部のlock (this)と干渉します。その結果、予期しない待機やデッドロックの原因になります。

MicrosoftのC#リファレンスでも、thisTypeインスタンス、文字列インスタンスをロック対象にすることは避けるよう示されています。

悪い例です。

C#
public class BadExample
{
private int _count;

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

良い例です。

C#
public class GoodExample
{
private readonly object _lock = new();
private int _count;

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

3-3. typeofをlockしてはいけない理由

typeofで取得できるTypeオブジェクトをロック対象にするのも避けるべきです。

C#
lock (typeof(MyClass))
{
// 非推奨
}

typeof(MyClass)で得られるTypeインスタンスは、アプリケーション内のさまざまな場所から取得できます。自分のクラス内部だけで使っているつもりでも、別のコードが同じTypeオブジェクトをロックしてしまう可能性があります。

特にライブラリや共通部品では、利用者側のコードとロックが衝突する危険があります。

代わりに、staticな専用ロックオブジェクトを用意します。

C#
public class MyClass
{
private static readonly object _staticLock = new();

public static void DoSomething()
{
lock (_staticLock)
{
// staticな共有データを操作する
}
}
}

3-4. stringをlockしてはいけない理由

文字列をロック対象にするのも危険です。

C#
lock ("my-lock")
{
// 非推奨
}

文字列リテラルはインターンされることがあり、同じ文字列がアプリケーション内で共有される可能性があります。つまり、別の場所で同じ文字列を使ってlockしていると、意図せず同じロックを共有してしまうことがあります。

C#
lock ("global")
{
// どこか別のコードの lock("global") と衝突する可能性がある
}

文字列の代わりに、必ず専用のオブジェクトを使いましょう。

C#
private readonly object _lock = new();

public void DoSomething()
{
lock (_lock)
{
// 安全
}
}

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

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

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

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

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

もしstaticな共有データを、インスタンスごとのロックオブジェクトで守ろうとすると、インスタンスごとに別々のロックになってしまい、正しく排他制御できません。

悪い例です。

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

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

この場合、BadGlobalCounterのインスタンスが複数作られると、それぞれ異なる_lockを持つため、static _countを守れません。

3-6. インスタンスごとに分けるlockと全体で共有するlockの違い

インスタンスごとにデータを持つ場合は、ロックもインスタンスごとで問題ありません。

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

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

この場合、各UserSessionインスタンスの_requestCountは独立しているため、ロックもインスタンスごとで自然です。

一方、アプリケーション全体で共有するデータを守る場合は、static readonlyなロックオブジェクトを使います。

C#
public class AppCache
{
private static readonly object _lock = new();
private static readonly Dictionary<string, string> _cache = new();

public static void Set(string key, string value)
{
lock (_lock)
{
_cache[key] = value;
}
}
}

判断の基準は、「守りたいデータがインスタンス単位か、クラス全体で共有されるものか」です。

4. lockでスレッドセーフにする実践例

4-1. カウンターの加算処理をスレッドセーフにする

もっとも基本的な例は、カウンターの加算処理です。

C#
public class Counter
{
private readonly object _lock = new();
private int _value;

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

public int GetValue()
{
lock (_lock)
{
return _value;
}
}
}

ポイントは、書き込みだけでなく読み取りも同じ_lockで保護していることです。

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

単純な整数値の加算だけであれば、後述するInterlocked.Incrementのほうが適している場合もあります。しかし、複数の値をまとめて更新する場合や、条件分岐を含む場合はlockのほうがわかりやすく安全に書けます。

4-2. ListやDictionaryへの同時アクセスを守る

List<T>Dictionary<TKey, TValue>は、複数スレッドから同時に追加・削除されることを前提にしたコレクションではありません。Microsoftの.NETドキュメントでも、System.Collections.GenericList<T>Dictionary<TKey,TValue>などはスレッド同期を提供しないため、複数スレッドで同時に追加・削除する場合はユーザーコード側で同期が必要と説明されています。

たとえば、複数スレッドからList<string>に追加する場合は次のようにします。

C#
public class MessageStore
{
private readonly object _lock = new();
private readonly List<string> _messages = new();

public void Add(string message)
{
lock (_lock)
{
_messages.Add(message);
}
}

public List<string> GetAll()
{
lock (_lock)
{
return new List<string>(_messages);
}
}
}

GetAllで内部の_messagesをそのまま返していない点も重要です。

悪い例です。

C#
public List<string> GetAll()
{
lock (_lock)
{
return _messages; // 内部Listを外部に渡してしまう
}
}

このようにすると、呼び出し元がロックなしで_messagesを変更できてしまいます。安全にするには、コピーを返すほうが無難です。

Dictionaryも同様です。

C#
public class Cache
{
private readonly object _lock = new();
private readonly Dictionary<string, string> _items = new();

public void Set(string key, string value)
{
lock (_lock)
{
_items[key] = value;
}
}

public bool TryGet(string key, out string? value)
{
lock (_lock)
{
return _items.TryGetValue(key, out value);
}
}

public bool Remove(string key)
{
lock (_lock)
{
return _items.Remove(key);
}
}
}

読み取り、書き込み、削除のすべてを同じロックで守ることが大切です。

4-3. ファイル書き込み処理をlockで制御する

複数スレッドから同じファイルに書き込む場合も、lockが役立つことがあります。

C#
public class FileLogger
{
private readonly object _lock = new();
private readonly string _filePath;

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

public void WriteLine(string message)
{
lock (_lock)
{
File.AppendAllText(_filePath, message + Environment.NewLine);
}
}
}

ただし、ファイルI/Oは時間がかかる処理です。lock中にファイル書き込みを行うと、その間、他のスレッドは待たされます。

ログ出力のように高頻度で呼ばれる処理では、次のような設計も検討します。

・ログ専用のキューに追加する
・バックグラウンドスレッドがまとめて書き込む
・既存のロギングライブラリを使う
・スレッドセーフなチャネルやConcurrentQueueを使う

lockで簡単に守れるからといって、常に最適とは限りません。処理頻度や性能要件を考えて選びましょう。

4-4. シングルトン実装でlockを使う例

古典的なシングルトン実装では、インスタンス生成をlockで保護することがあります。

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

private Singleton()
{
}

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

return _instance;
}
}
}
}

ただし、現在のC#では、static readonlyLazy<T>を使うほうが簡潔で安全なことが多いです。

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

private Singleton()
{
}

public static Singleton Instance => _instance.Value;
}

単純なシングルトンであれば、無理にlockを書くよりも、言語やライブラリが提供する仕組みを使うほうがミスを減らせます。

4-5. lockが必要な処理と不要な処理の見分け方

lockが必要かどうかは、次の質問で判断できます。

Q1. 複数スレッドから同じデータにアクセスするか?
Q2. そのアクセスに書き込みが含まれるか?
Q3. 複数の値を一貫性のある状態で更新する必要があるか?
Q4. ListやDictionaryなど、スレッドセーフでないオブジェクトを共有しているか?
Q5. ファイル、キャッシュ、状態管理などの共有リソースを扱っているか?

これらに当てはまる場合は、lockや他の同期手段を検討します。

一方、次のような場合は通常lock不要です。

・メソッド内だけで使うローカル変数
・不変オブジェクト
・スレッドごとに独立したデータ
・読み取り専用で初期化後に変更されないデータ
・すでにスレッドセーフなクラスを使っている場合

たとえば、次のコードではローカル変数しか使っていないため、通常lockは不要です。

C#
public int Add(int a, int b)
{
int result = a + b;
return result;
}

5. C# lockで起こりやすい注意点

5-1. lock範囲を広げすぎると処理が遅くなる

lockは便利ですが、使いすぎるとパフォーマンスに影響します。lock中は、同じロックを必要とする他のスレッドが待たされるためです。

悪い例です。

C#
public void Process()
{
lock (_lock)
{
var data = LoadDataFromDatabase();
var result = HeavyCalculation(data);
_items.Add(result);
SendNotification(result);
}
}

このコードでは、データベースアクセス、重い計算、通知処理までlock内に入っています。これでは他のスレッドが長時間待たされます。

改善例です。

C#
public void Process()
{
var data = LoadDataFromDatabase();
var result = HeavyCalculation(data);

lock (_lock)
{
_items.Add(result);
}

SendNotification(result);
}

共有データにアクセスする最小限の部分だけをlockするのが基本です。MicrosoftのC#リファレンスでも、ロック競合を減らすためにロック保持時間はできるだけ短くすることが示されています。

5-2. lock中に時間のかかる処理を入れない

lock中に時間のかかる処理を入れると、他のスレッドが待機する時間が長くなります。

避けたい処理の例です。

・大きなファイルの読み書き
・データベースアクセス
・HTTPリクエスト
・外部API呼び出し
・重い計算処理
・Thread.Sleep
・ユーザー入力待ち

悪い例です。

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

この場合、他のスレッドは5秒間待たされます。

良い例です。

C#
Thread.Sleep(5000);

lock (_lock)
{
_count++;
}

共有データを触らない処理は、できるだけlockの外に出しましょう。

5-3. lock中に外部API・DB・ファイルI/Oを呼ぶリスク

lock中に外部API、データベース、ファイルI/Oを呼ぶと、次のようなリスクがあります。

・処理時間が読めない
・タイムアウトまで他のスレッドが待たされる
・外部処理の中で別のlockが発生する可能性がある
・例外発生時の影響範囲が広がる
・デッドロックの原因になる

特に外部APIやDBアクセスは、ネットワーク状況や相手側の状態によって遅くなることがあります。その間ずっとロックを保持すると、アプリケーション全体の応答性が悪化します。

基本方針は次のとおりです。

1. lock内ではメモリ上の共有データだけを短時間で操作する
2. 外部APIやDBアクセスはlockの外で行う
3. 必要な値だけをlock内で取り出す、または更新する

例です。

C#
public void UpdateUserName(int userId, string newName)
{
User? user;

lock (_lock)
{
user = _users.FirstOrDefault(x => x.Id == userId);
if (user != null)
{
user.Name = newName;
}
}

if (user != null)
{
SaveUserToDatabase(user);
}
}

ただし、この例ではuserオブジェクト自体が共有され続ける可能性があります。実際の設計では、コピーを作る、更新処理を一元化する、DBを正とするなど、データの所有権も考慮しましょう。

5-4. 例外が発生した場合のlockの挙動

lockブロック内で例外が発生しても、ロックは解放されます。

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

これは、lock文が内部的にtry-finallyを使ってロックを解放する仕組みになっているためです。Microsoftのドキュメントでも、lock文はtry-finallyにより、例外が発生してもロックが解放される形で動作すると説明されています。

ただし、ロックが解放されるからといって、データの状態が必ず安全とは限りません。

C#
lock (_lock)
{
_user.Name = "New Name";

// ここで例外が発生
_user.Email = "new@example.com";
}

この場合、Nameだけ更新され、Emailは更新されない中途半端な状態になる可能性があります。lockは同時実行を防ぐ仕組みであって、処理の整合性やロールバックを自動で保証するものではありません。

5-5. async/awaitとlockを組み合わせる際の注意点

C#のlockブロック内では、awaitを使えません。MicrosoftのC#リファレンスでも、lock文の本体ではawait式を使用できないと明記されています。

次のコードはコンパイルできません。

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

lockは同期的な排他制御です。一方、async/awaitは非同期処理を途中で中断・再開する仕組みです。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();
}
}

SemaphoreSlimは、同時にアクセスできるスレッド数やタスク数を制限する軽量な同期手段として提供されています。

6. デッドロックとは?原因と対策

6-1. デッドロックが発生する仕組み

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

たとえば、次のような状況です。

スレッドA: lockAを取得した。次にlockBを取得したい。
スレッドB: lockBを取得した。次にlockAを取得したい。

スレッドA: lockBが解放されるのを待つ
スレッドB: lockAが解放されるのを待つ

この状態になると、どちらのスレッドも先に進めません。

lockは排他制御に便利ですが、複数のロックを扱うときはデッドロックに注意が必要です。

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

デッドロックの典型例は、複数のロックを異なる順番で取得するコードです。

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から_lockBの順で取得します。
Method2_lockBから_lockAの順で取得します。

この2つが同時に実行されると、次のようなデッドロックが起こる可能性があります。

Method1: _lockAを取得
Method2: _lockBを取得
Method1: _lockBを待つ
Method2: _lockAを待つ

複数のロックを使う場合は、取得順序を必ず統一しましょう。

6-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 Run()
{
var task1 = Task.Run(() =>
{
lock (_lockA)
{
Console.WriteLine("Task1: lockA acquired");
Thread.Sleep(100);

lock (_lockB)
{
Console.WriteLine("Task1: lockB acquired");
}
}
});

var task2 = Task.Run(() =>
{
lock (_lockB)
{
Console.WriteLine("Task2: lockB acquired");
Thread.Sleep(100);

lock (_lockA)
{
Console.WriteLine("Task2: lockA acquired");
}
}
});

Task.WaitAll(task1, task2);
}
}

このコードは、タイミングによってはTask.WaitAllから戻ってこなくなります。Thread.Sleepはデッドロックを再現しやすくするために入れています。

6-4. lockの取得順序を統一する

デッドロック対策の基本は、複数のロックを取得する順序を統一することです。

悪い例です。

C#
lock (_lockA)
{
lock (_lockB)
{
}
}

lock (_lockB)
{
lock (_lockA)
{
}
}

良い例です。

C#
lock (_lockA)
{
lock (_lockB)
{
}
}

lock (_lockA)
{
lock (_lockB)
{
}
}

すべてのコードで「必ず_lockAを先に取り、次に_lockBを取る」というルールを守れば、逆順待ちによるデッドロックを避けやすくなります。

より大きなコードベースでは、ロック順序をコメントや設計書に明記しておくと安全です。

C#
// ロック取得順序:
// 1. _userLock
// 2. _orderLock
// 3. _paymentLock

6-5. lockのネストを避ける

最も安全なのは、そもそもlockのネストを避けることです。

C#
lock (_lockA)
{
lock (_lockB)
{
// できれば避けたい
}
}

複数の共有データを同時に守る必要がある場合は、次のような設計を検討します。

・1つのロックオブジェクトでまとめて守る
・データの所有者を1つのクラスに集約する
・処理をキューに流して単一スレッドで処理する
・ConcurrentDictionaryなどのスレッドセーフコレクションを使う
・不変オブジェクトとして扱う

ネストしたlockは、コードが増えるほど危険性が高まります。初心者のうちは、「lockの中で別のlockを取らない」を原則にするとよいでしょう。

6-6. タイムアウトが必要な場合はMonitor.TryEnterを使う

lock文は、ロックを取得できるまで待ち続けます。一定時間だけ待って、取得できなければ別の処理をしたい場合は、Monitor.TryEnterを使います。

C#
private readonly object _lock = new();

public void DoWork()
{
bool lockTaken = false;

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

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

// ロック取得後の処理
Console.WriteLine("ロックを取得しました。");
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lock);
}
}
}

Monitorクラスは、EnterTryEnterExitなどを使って特定のオブジェクトに対するロックの取得と解放を制御できます。

TryEnterを使うと、ロック取得に失敗した場合の処理を書けます。

・一定時間後にあきらめる
・ログを出す
・リトライする
・別の処理に切り替える
・エラーとして返す

ただし、Monitor.EnterMonitor.TryEnterを直接使う場合は、必ずfinallyMonitor.Exitを呼び出すようにしましょう。

7. lockとMonitorの関係

7-1. lockはMonitor.Enter/Exitの簡略構文

C#のlockは、従来の参照型オブジェクトに対してはMonitor.EnterMonitor.Exitを使った排他制御を簡単に書くための構文です。

次のlock文は、

C#
lock (_lock)
{
_count++;
}

おおまかには次のようなコードに相当します。

C#
bool lockTaken = false;

try
{
Monitor.Enter(_lock, ref lockTaken);
_count++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lock);
}
}

MicrosoftのMonitorクラスのドキュメントでも、EnterExitで提供される機能はC#のlock文と同等であり、言語構文ではtry-finallyMonitor.Exitが確実に呼ばれるようにされると説明されています。

7-2. lock文が内部的に行っていること

lock文は、主に次のことを行っています。

1. 指定したロックオブジェクトに対してロック取得を試みる
2. 取得できるまで必要に応じて待機する
3. ロック取得後、ブロック内の処理を実行する
4. 処理終了後、ロックを解放する
5. 例外が発生してもfinallyでロックを解放する

そのため、通常の用途ではMonitor.EnterMonitor.Exitを直接書くより、lock文を使ったほうが安全です。

悪い例です。

C#
Monitor.Enter(_lock);

// ここで例外が発生するとExitされない可能性がある
_count++;

Monitor.Exit(_lock);

良い例です。

C#
lock (_lock)
{
_count++;
}

または、Monitorを直接使う場合は次のようにします。

C#
bool lockTaken = false;

try
{
Monitor.Enter(_lock, ref lockTaken);
_count++;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lock);
}
}

7-3. Monitor.TryEnterを使うべきケース

通常はlockで十分ですが、次のような場合はMonitor.TryEnterを検討します。

・ロック取得を無期限に待ちたくない
・一定時間待って取得できなければ処理を中断したい
・デッドロック調査のためにログを出したい
・ロック取得に失敗した場合の代替処理を書きたい

例です。

C#
private readonly object _lock = new();

public bool TryUpdate()
{
bool lockTaken = false;

try
{
Monitor.TryEnter(_lock, 1000, ref lockTaken);

if (!lockTaken)
{
return false;
}

// 更新処理
return true;
}
finally
{
if (lockTaken)
{
Monitor.Exit(_lock);
}
}
}

このコードでは、1秒以内にロックを取得できなければfalseを返します。

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

基本方針はシンプルです。

通常の排他制御                 : lock
タイムアウトや取得失敗を扱いたい : Monitor.TryEnter
細かくEnter/Exitを制御したい : Monitor
非同期処理で待機したい : SemaphoreSlim

初心者は、まずlockを正しく使えるようになることを優先しましょう。Monitorを直接使うのは、lockでは表現できない要件が出てきたときで十分です。

8. lock以外の排他制御・同期手段

8-1. Interlockedで単純な数値操作を高速に行う

単純な数値の加算、減算、交換などであれば、lockではなくInterlockedを使える場合があります。

C#
private int _count;

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

値を読み取る場合は次のようにできます。

C#
public int GetValue()
{
return Volatile.Read(ref _count);
}

または、用途によってはInterlocked.CompareExchangeを使います。

Interlockedは、単純な数値操作をアトミックに行いたい場合に向いています。一方、複数の変数をまとめて更新する場合や、条件分岐を含む複雑な処理ではlockのほうが書きやすいです。

C#
lock (_lock)
{
if (_stock > 0)
{
_stock--;
_sold++;
}
}

このように「在庫を確認してから減らし、販売数を増やす」といった複数操作の一貫性を守る場合は、lockが適しています。

8-2. Mutexとlockの違い

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

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

例です。

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

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

ただし、通常のアプリケーション内で共有変数を守るだけなら、lockのほうが簡単です。

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

8-3. SemaphoreSlimとlockの違い

SemaphoreSlimは、同時に実行できる数を制限するための同期手段です。lockは同時に1つのスレッドだけを通しますが、SemaphoreSlimは「最大3つまで」のような制御もできます。

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

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

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

この例では、同時に最大3つの処理だけが中に入れます。

SemaphoreSlimは、非同期処理と組み合わせやすい点も大きな特徴です。lockブロック内ではawaitできませんが、SemaphoreSlim.WaitAsyncを使えば非同期に待機できます。

同期処理で1つだけ通したい       : lock
async/awaitで排他制御したい : SemaphoreSlim
同時実行数を2以上に制限したい : SemaphoreSlim

8-4. ReaderWriterLockSlimを使うべきケース

ReaderWriterLockSlimは、読み取りが多く、書き込みが少ない場面で使われる同期手段です。

通常のlockでは、読み取り同士でも同時に実行できません。

読み取りA: 実行中
読み取りB: 待機
書き込みC: 待機

一方、ReaderWriterLockSlimでは、複数の読み取りを同時に許可し、書き込みは排他的に行えます。

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

public string? Get(string key)
{
_lock.EnterReadLock();

try
{
return _cache.TryGetValue(key, out var value) ? value : null;
}
finally
{
_lock.ExitReadLock();
}
}

public void Set(string key, string value)
{
_lock.EnterWriteLock();

try
{
_cache[key] = value;
}
finally
{
_lock.ExitWriteLock();
}
}

ただし、ReaderWriterLockSlimlockより複雑です。読み取りが非常に多く、書き込みが少ないことが明確な場合に検討するとよいでしょう。

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

Dictionary<TKey, TValue>lockで守る代わりに、ConcurrentDictionary<TKey, TValue>を使える場合があります。

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

public void Set(string key, string value)
{
_cache[key] = value;
}

public bool TryGet(string key, out string? value)
{
return _cache.TryGetValue(key, out value);
}

.NETのドキュメントでは、複数スレッドから同時に追加・削除する場合、System.Collections.Concurrent名前空間の同時実行コレクションを使うことが推奨されています。

代表的なスレッドセーフコレクションには次のようなものがあります。

ConcurrentDictionary<TKey, TValue>
ConcurrentQueue<T>
ConcurrentStack<T>
ConcurrentBag<T>
BlockingCollection<T>

ただし、ConcurrentDictionaryを使えばすべての複合処理が自動的に安全になるわけではありません。

悪い例です。

C#
if (!_cache.ContainsKey(key))
{
_cache[key] = value;
}

このような「存在確認してから追加」のような複合処理では、別スレッドが間に割り込む可能性があります。ConcurrentDictionaryでは、GetOrAddAddOrUpdateなどの専用メソッドを使いましょう。

C#
_cache.GetOrAdd(key, value);

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

async/awaitを使う非同期処理では、lockよりもSemaphoreSlimが適していることが多いです。

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

public async Task SaveAsync(string text)
{
await _lock.WaitAsync();

try
{
await File.WriteAllTextAsync("sample.txt", text);
}
finally
{
_lock.Release();
}
}

このコードでは、非同期ファイル書き込みを同時に1つだけ実行するように制御しています。

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

C#
lock (_lockObject)
{
await File.WriteAllTextAsync("sample.txt", text); // コンパイルエラー
}

非同期メソッドで排他制御したい場合は、まずSemaphoreSlimを検討しましょう。

9. lockを安全に使うためのベストプラクティス

9-1. lock専用オブジェクトを用意する

lockには、専用のオブジェクトを使います。

C#
private readonly object _lock = new();

避けるべき例です。

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

.NET 9/C# 13以降では、System.Threading.Lockを使う選択肢もあります。

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

いずれの場合も、「他の目的に使わない」「外部から触れない」「途中で差し替えない」ことが大切です。

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

lock内に入れる処理は、共有データへのアクセスに必要な最小限にします。

悪い例です。

C#
lock (_lock)
{
var data = DownloadData();
_items.Add(data);
}

良い例です。

C#
var data = DownloadData();

lock (_lock)
{
_items.Add(data);
}

ロック範囲が短いほど、他のスレッドの待ち時間が短くなり、デッドロックや性能低下のリスクも下がります。

9-3. lock内で別のlockを取得しない

lock内で別のlockを取得すると、デッドロックのリスクが上がります。

C#
lock (_lockA)
{
lock (_lockB)
{
// できれば避ける
}
}

どうしても複数のロックが必要な場合は、取得順序を統一します。

C#
lock (_lockA)
{
lock (_lockB)
{
// 常にA → Bの順で取得する
}
}

ただし、設計を見直してロックを1つにまとめられるなら、そのほうが安全です。

9-4. lock内で外部処理を呼ばない

lock内で外部API、DB、ファイルI/O、イベント通知、コールバックなどを呼ぶのはできるだけ避けましょう。

特に危険なのは、lock内で外部から渡されたデリゲートを実行するパターンです。

C#
lock (_lock)
{
callback(); // callback内部で何が起こるかわからない
}

callbackの中で別のロックを取得したり、長時間待機したりすると、デッドロックや性能劣化の原因になります。

改善例です。

C#
Action? callbackToRun;

lock (_lock)
{
callbackToRun = _callback;
}

callbackToRun?.Invoke();

共有データから必要な情報だけをlock内で取り出し、外部処理はlockの外で実行するのが基本です。

9-5. 共有データにアクセスする箇所を一元化する

共有データを安全に扱うには、アクセス箇所を一元化することが重要です。

悪い例です。

C#
// クラス内のあちこちで直接_usersを操作している
_users.Add(user);
_users.Remove(user);
_users.Clear();

良い例です。

C#
public class UserStore
{
private readonly object _lock = new();
private readonly List<User> _users = new();

public void Add(User user)
{
lock (_lock)
{
_users.Add(user);
}
}

public bool Remove(User user)
{
lock (_lock)
{
return _users.Remove(user);
}
}

public List<User> GetAll()
{
lock (_lock)
{
return new List<User>(_users);
}
}
}

共有データを直接公開せず、必ずメソッドを通してアクセスするようにすると、lock漏れを防ぎやすくなります。

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

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

・lock対象がprivate readonlyな専用オブジェクトになっているか
・this、typeof、stringをlockしていないか
・守りたい共有データに対して同じlockを使っているか
・読み取りと書き込みの両方が必要に応じて保護されているか
・lock範囲が広すぎないか
・lock内で外部API、DB、ファイルI/Oを呼んでいないか
・lock内で別のlockを取得していないか
・複数lockの取得順序が統一されているか
・async/awaitと無理に組み合わせていないか
・ConcurrentDictionaryなどで置き換えられないか

lockの問題は、コードを見ただけではすぐに不具合として現れないことが多いです。そのため、レビュー時に設計レベルで確認することが大切です。

10. 初心者がつまずきやすいQ&A

10-1. lockすれば必ずスレッドセーフになる?

必ずしもそうではありません。

lockで安全になるのは、同じロックオブジェクトを使って適切に保護された範囲だけです。

悪い例です。

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

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

public int GetValue()
{
lock (_lock2)
{
return _count;
}
}

このコードでは、書き込みと読み取りで別々のロックを使っているため、_countを正しく保護できていません。

良い例です。

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

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

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

lockは正しく設計して初めて効果があります。

10-2. lockはパフォーマンスに悪い?

lockには待機コストがありますが、必要な場面で使うこと自体が悪いわけではありません。

問題になるのは、次のような使い方です。

・lock範囲が広すぎる
・lock内で重い処理をしている
・高頻度で大量のスレッドが同じlockを取り合っている
・不要な箇所までlockしている

単純なカウンター加算ならInterlocked、複数スレッドのコレクション操作ならConcurrentDictionaryなど、より適した手段がある場合もあります。

ただし、複数の値を一貫性のある状態で更新したい場合は、lockがわかりやすく安全な選択肢です。

10-3. staticなlockオブジェクトはいつ使う?

staticな共有データを守るときに使います。

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

例です。

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

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

逆に、インスタンスごとに独立したデータを守る場合は、インスタンスフィールドのロックで十分です。

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

判断基準は、「守りたいデータがstaticかどうか」です。

10-4. lockとasync/awaitは一緒に使える?

lockブロック内で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. Dictionaryはlockすれば安全に使える?

Dictionary<TKey, TValue>は、すべてのアクセスを同じlockで適切に保護すれば、複数スレッドから扱いやすくなります。

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);
}
}

ただし、Dictionaryを直接外部に返すと危険です。

C#
public Dictionary<string, int> GetScores()
{
lock (_lock)
{
return _scores; // 非推奨
}
}

コピーを返すほうが安全です。

C#
public Dictionary<string, int> GetScores()
{
lock (_lock)
{
return new Dictionary<string, int>(_scores);
}
}

また、用途によってはConcurrentDictionary<TKey, TValue>を使うほうが適しています。

10-6. lockを使わないほうがよいケースは?

次のようなケースでは、lock以外の方法を検討したほうがよいです。

・単純な数値加算だけ → Interlocked
・非同期処理で待機したい → SemaphoreSlim
・Dictionaryを高頻度で並行更新したい → ConcurrentDictionary
・読み取りが圧倒的に多い → ReaderWriterLockSlim
・そもそも共有状態をなくせる → 不変オブジェクトやローカル変数
・プロセス間で排他制御したい → Mutex

lockは万能ではありません。共有データを守る基本的な道具ですが、処理内容によってはより適した同期手段があります。

まとめ

C#のlockは、複数スレッドから共有データを安全に扱うための基本的な排他制御の仕組みです。

lockを使うと、同じロックオブジェクトを使ったブロック内の処理を、同時に1つのスレッドだけが実行できるようになります。これにより、カウンターの加算、ListDictionaryの更新、共有キャッシュの操作などで起こる競合状態を防ぎやすくなります。

基本形は次のとおりです。

C#
private readonly object _lock = new();

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

.NET 9/C# 13以降では、System.Threading.Lockを使う選択肢もあります。

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

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

安全に使うための重要ポイントは次のとおりです。

・lock専用オブジェクトをprivate readonlyで用意する
・this、typeof、stringをlockしない
・共有データへのアクセスは同じlockで守る
・lock範囲はできるだけ短くする
・lock内で外部API、DB、ファイルI/Oを呼ばない
・lock内で別のlockを取得しない
・複数lockが必要な場合は取得順序を統一する
・async/awaitではSemaphoreSlimを検討する
・単純な数値操作ではInterlockedも検討する
・コレクションにはConcurrentDictionaryなども検討する

lockは、正しく使えば非常に強力ですが、使い方を誤るとパフォーマンス低下やデッドロックの原因になります。大切なのは、「何を守りたいのか」「どの範囲を排他制御すべきか」「本当にlockが最適か」を意識することです。

初心者のうちは、まずprivate readonly object _lock = new();を用意し、共有データにアクセスする最小限の範囲をlockで囲むところから始めましょう。そのうえで、非同期処理ならSemaphoreSlim、単純な数値操作ならInterlocked、コレクションならConcurrentDictionaryといった選択肢を使い分けられるようになると、より安全で効率的なC#コードを書けるようになります。