C#のスレッドセーフとは?初心者がつまずく原因と実装方法・注意点をわかりやすく解説
はじめに
C#でアプリケーションを開発していると、「スレッドセーフ」という言葉を目にすることがあります。
特に、Task、async/await、Parallel.For、Webアプリケーション、バックグラウンド処理、キャッシュ、ログ出力などを扱い始めると、スレッドセーフを意識しないまま書いたコードが思わぬ不具合を起こすことがあります。
スレッドセーフではないコードは、毎回必ずエラーになるとは限りません。あるときは正常に動き、別のタイミングでは値がずれたり、例外が出たり、まれに処理が止まったりします。そのため、初心者にとって原因を見つけにくい厄介なバグになりやすいです。
この記事では、C#のスレッドセーフとは何か、なぜ問題が起きるのか、lock、Interlocked、volatile、ConcurrentDictionary、SemaphoreSlimなどを使ってどのように安全なコードを書くのかを、初心者にもわかりやすく解説します。
1. C#のスレッドセーフとは何か
1-1. スレッドセーフの意味を初心者向けに簡単に説明
スレッドセーフとは、複数のスレッドから同時に使われても、データの整合性が壊れず、正しく動作する状態のことです。
たとえば、複数人が同じメモ帳に同時に文字を書き込む場面を想像してください。誰かが書いている途中に別の人が同じ場所を書き換えると、内容が壊れてしまう可能性があります。
C#のプログラムでも同じことが起こります。複数のスレッドが同じ変数、同じオブジェクト、同じコレクションに同時アクセスすると、想定外の結果になることがあります。
スレッドセーフなコードとは、こうした同時アクセスが起きても安全に動作するように設計されたコードです。
たとえば次のような状態を防ぐことが目的です。
カウンターの値が正しく増えない
ListやDictionaryの中身が壊れるデータの更新途中の状態を別スレッドが読んでしまう
まれに例外が発生する
本番環境だけで不具合が出る
C#でスレッドセーフを理解するには、「同時に同じデータを触ると危険な場合がある」と考えるのが第一歩です。
1-2. マルチスレッド・並行処理・非同期処理との関係
スレッドセーフは、マルチスレッドや並行処理と深い関係があります。
マルチスレッドとは、複数のスレッドを使って処理を行うことです。スレッドは、プログラム内で処理を実行する流れのようなものです。
並行処理とは、複数の処理が同じ時間帯に進行することです。実際に同時に実行される場合もあれば、非常に短い時間で切り替えながら実行される場合もあります。
C#では、次のようなコードで並行処理が発生します。
C#Task.Run(() => DoWork());
Parallel.For(0, 100, i => DoWork(i));
await SomeAsyncMethod();
ここで注意したいのは、async/awaitを使っているからといって、必ずしもスレッドセーフになるわけではないという点です。
async/awaitは非同期処理を扱いやすくする仕組みであり、共有データへの同時アクセスを自動的に守ってくれるものではありません。
たとえば、複数のTaskが同じ変数を更新する場合、async/awaitを使っていても競合状態が発生する可能性があります。
1-3. スレッドセーフではないコードで起こる問題
スレッドセーフではないコードでは、競合状態が起こることがあります。
競合状態とは、複数のスレッドが同じデータに同時アクセスし、実行タイミングによって結果が変わってしまう状態です。
たとえば、次のようなカウンターがあります。
C#private int _count = 0;
public void Increment()
{
_count++;
}
一見すると、_count++は単純な処理に見えます。しかし実際には、次のような複数の処理に分かれています。
現在の値を読み取る
1を足す
結果を書き戻す
複数のスレッドが同時にこの処理を行うと、同じ値を読み取ってしまい、片方の更新が失われることがあります。
たとえば、_countが10のとき、2つのスレッドが同時に_count++を実行すると、本来は12になるはずです。しかし、両方が10を読み取り、それぞれ11を書き戻すと、結果は11になってしまいます。
このように、見た目は単純な処理でも、スレッドセーフではない場合があります。
1-4. C#でスレッドセーフが必要になる代表的な場面
C#でスレッドセーフが必要になる代表的な場面は、共有データを複数の処理から扱うときです。
たとえば、次のようなケースがあります。
Webアプリケーションで複数リクエストが同じstatic変数を使う
バックグラウンド処理で共有カウンターを更新する
複数の
Taskが同じDictionaryにデータを追加するキャッシュを複数スレッドから参照・更新する
ログやファイルに複数スレッドから書き込む
Singletonインスタンスを遅延初期化する
UIアプリでバックグラウンド処理から画面部品を更新する
逆に、スレッドごとに完全に独立したデータだけを扱っている場合は、スレッドセーフの問題は起きにくくなります。
重要なのは、「共有される状態」があるかどうかです。共有される変数やオブジェクトがある場合は、スレッドセーフを意識する必要があります。
2. 初心者がC#のスレッドセーフでつまずく原因
2-1. 複数スレッドが同じ変数やオブジェクトを同時に操作してしまう
初心者が最初につまずきやすいのは、同じ変数やオブジェクトを複数スレッドから同時に操作していることに気づけない点です。
たとえば、次のようなコードです。
C#private int _total = 0;
public void Add(int value)
{
_total += value;
}
このコードは、1つのスレッドから呼ばれるだけなら問題ありません。しかし、複数のスレッドから同時に呼ばれると、更新が失われる可能性があります。
_total += valueも、実際には「読み取り」「加算」「書き込み」に分かれます。その途中で別スレッドが割り込むと、結果が正しくなくなることがあります。
C#では、変数をフィールドにしたり、staticにしたり、DIコンテナでSingletonとして共有したりすると、意図せず複数スレッドから使われることがあります。
そのため、「この変数は複数スレッドから触られる可能性があるか」を考えることが重要です。
2-2. 読み取りだけなら安全だと誤解してしまう
「読み取りだけなら安全」と考えてしまうのも、よくある誤解です。
確かに、完全に変更されないデータを複数スレッドから読むだけなら、多くの場合は安全です。しかし、別のスレッドが同時に更新しているデータを読む場合は注意が必要です。
たとえば、あるスレッドがオブジェクトの複数プロパティを更新している途中で、別のスレッドがそのオブジェクトを読むと、中途半端な状態を見てしまうことがあります。
C#public class UserStatus
{
public string Name { get; set; } = "";
public bool IsActive { get; set; }
}
たとえば、NameとIsActiveをセットで更新する必要がある場合、片方だけ更新された状態を別スレッドが読むと、矛盾したデータになります。
読み取り処理も、更新処理と同時に行われるならスレッドセーフを考える必要があります。
2-3. ListやDictionaryをそのまま共有してしまう
C#のList<T>やDictionary<TKey, TValue>は、一般的な用途ではとても便利です。しかし、複数スレッドから同時に更新する用途には向いていません。
たとえば、次のようなコードは危険です。
C#private readonly Dictionary<string, int> _scores = new();
public void AddScore(string name, int score)
{
_scores[name] = score;
}
このメソッドが複数スレッドから同時に呼ばれると、Dictionaryの内部状態が壊れたり、例外が発生したりする可能性があります。
また、列挙中に別スレッドが更新するのも危険です。
C#foreach (var item in _scores)
{
Console.WriteLine(item);
}
この列挙中に別スレッドが_scoresへ追加や削除を行うと、例外が発生することがあります。
複数スレッドから共有するコレクションには、ConcurrentDictionaryなどのスレッドセーフなコレクションを検討する必要があります。
2-4. async/awaitとスレッドセーフを混同してしまう
async/awaitを使うと、処理がわかりやすく書けるため、「これで安全になった」と思ってしまうことがあります。
しかし、async/awaitはスレッドセーフを保証する仕組みではありません。
たとえば、次のコードでは複数のTaskが同じ変数を更新しています。
C#private int _count = 0;
public async Task RunAsync()
{
var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() => _count++))
.ToArray();
await Task.WhenAll(tasks);
}
このコードでは、_countが必ず1000になるとは限りません。複数のTaskが同時に_count++を実行するため、競合状態が起こる可能性があります。
async/awaitは、待機処理を扱いやすくするためのものです。共有データを守るには、lock、Interlocked、SemaphoreSlim、スレッドセーフなコレクションなどが必要になります。
2-5. 再現しにくいバグの原因を見つけられない
スレッド関連のバグは、再現しにくいことが大きな特徴です。
同じコードを実行しても、毎回同じ結果になるとは限りません。CPUの状態、スレッドの切り替わるタイミング、処理速度、実行環境によって結果が変わります。
そのため、開発環境では問題が出ないのに、本番環境でだけ発生することがあります。また、デバッグ実行すると処理が遅くなり、問題が再現しなくなることもあります。
初心者は、エラーが毎回発生しないために「たまたまの不具合」と考えてしまいがちです。しかし、スレッドセーフではないコードは、条件がそろうと突然問題を起こします。
3. C#でスレッドセーフではない状態をコードで理解する
3-1. カウンターの加算処理で起こる競合状態の例
まずは、スレッドセーフではないカウンターの例を見てみましょう。
C#using System;
using System.Threading.Tasks;
class Program
{
private static int _count = 0;
static async Task Main()
{
var tasks = new Task[100];
for (int i = 0; i < tasks.Length; i++)
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
_count++;
}
});
}
await Task.WhenAll(tasks);
Console.WriteLine(_count);
}
}
このコードでは、100個のタスクがそれぞれ1万回ずつ_count++を実行しています。
期待する結果は次の値です。
C#100 * 10000 = 1000000
しかし、実際に実行すると1000000より小さい値になることがあります。
理由は、_count++が一つの不可分な処理ではないからです。複数スレッドが同じ値を読み取り、それぞれ加算した結果を書き戻すことで、更新の一部が失われます。
このような問題を競合状態と呼びます。
3-2. Dictionaryを複数スレッドで更新したときの危険性
次に、Dictionaryを複数スレッドから更新する例です。
C#using System;
using System.Collections.Generic;
using System.Threading.Tasks;
class Program
{
private static readonly Dictionary<int, int> _map = new();
static async Task Main()
{
var tasks = new Task[10];
for (int i = 0; i < tasks.Length; i++)
{
int taskNo = i;
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 1000; j++)
{
_map[taskNo * 1000 + j] = j;
}
});
}
await Task.WhenAll(tasks);
Console.WriteLine(_map.Count);
}
}
このコードは、複数のタスクから同じDictionaryにデータを追加しています。
Dictionary<TKey, TValue>は、複数スレッドから同時に書き込まれることを前提に作られていません。そのため、例外が発生したり、要素数が期待通りにならなかったり、内部状態が不正になる可能性があります。
複数スレッドから更新する場合は、lockで保護するか、ConcurrentDictionary<TKey, TValue>を使うのが基本です。
3-3. タイミングによって結果が変わる理由
スレッド関連の不具合で難しいのは、実行タイミングによって結果が変わることです。
たとえば、次の処理を2つのスレッドが同時に行うとします。
C#_count++;
内部的には、次のような流れです。
1. _countの値を読む
2. 読んだ値に1を足す
3. 結果を_countに書き戻す
もし_countが0のとき、スレッドAとスレッドBが次のように動くとします。
スレッドA: _countを読む → 0
スレッドB: _countを読む → 0
スレッドA: 1を計算する
スレッドB: 1を計算する
スレッドA: _countに1を書き込む
スレッドB: _countに1を書き込む
2回加算したはずなのに、結果は1になります。
このように、処理の割り込みタイミングによって結果が変わります。これが「再現したりしなかったりする」原因です。
3-4. デバッグしづらいスレッド関連バグの特徴
スレッド関連バグには、次のような特徴があります。
毎回再現しない
本番環境だけで起こる
ログを入れると発生しなくなる
デバッグ実行だと再現しない
エラー箇所と原因箇所が離れている
例外が出ず、値だけが少しずれる
まれにデッドロックで止まる
特に厄介なのは、ログ出力やブレークポイントによって処理タイミングが変わり、問題が隠れてしまうことです。
そのため、スレッドセーフの問題は、発生してから直すよりも、設計段階で防ぐことが大切です。
4. C#でスレッドセーフを実現する基本的な方法
4-1. lock文で共有リソースへの同時アクセスを防ぐ
C#でスレッドセーフを実現する代表的な方法がlock文です。
lockを使うと、指定した範囲を同時に1つのスレッドだけが実行できるようになります。
C#private readonly object _lock = new();
private int _count = 0;
public void Increment()
{
lock (_lock)
{
_count++;
}
}
このコードでは、複数のスレッドがIncrementを呼んでも、lockの中には同時に1つのスレッドしか入れません。
そのため、_count++の途中で別スレッドが割り込むことを防げます。
読み取りも更新とセットで保護したい場合は、読み取り側にもlockを使います。
C#public int GetCount()
{
lock (_lock)
{
return _count;
}
}
lockはシンプルで使いやすいですが、使い方を間違えるとパフォーマンス低下やデッドロックの原因になります。
4-2. Interlockedで数値操作を安全に行う
単純な数値の加算や減算には、Interlockedを使うと便利です。
C#using System.Threading;
private int _count = 0;
public void Increment()
{
Interlocked.Increment(ref _count);
}
Interlocked.Incrementは、複数スレッドから同時に呼ばれても安全に値を増やせます。
減算する場合は、Interlocked.Decrementを使います。
C#Interlocked.Decrement(ref _count);
指定した値を加算する場合は、Interlocked.Addを使います。
C#Interlocked.Add(ref _count, 10);
現在の値を安全に取得したい場合は、次のように書けます。
C#int current = Volatile.Read(ref _count);
または、用途によってはInterlocked.CompareExchangeを使うこともあります。
Interlockedはlockより軽量に使える場合がありますが、複雑な複数フィールドの更新には向いていません。単純なカウンターやフラグ更新に適しています。
4-3. volatileで変数の読み書きの見え方を制御する
volatileは、変数の読み書きに関する最適化を制御し、あるスレッドの変更が別スレッドから見えやすくなるようにするためのキーワードです。
たとえば、停止フラグのような用途で使われることがあります。
C#private volatile bool _stopped = false;
public void Stop()
{
_stopped = true;
}
public void Run()
{
while (!_stopped)
{
// 処理
}
}
ただし、volatileは万能ではありません。
特に、次のような複合操作を安全にするものではありません。
C#_count++;
volatileを付けても、_count++がスレッドセーフになるわけではありません。加算のような操作にはlockやInterlockedを使う必要があります。
volatileは、「最新の値が見えるかどうか」に関係するものであり、「複数操作をまとめて安全にする」ものではないと理解しておきましょう。
4-4. Monitor・Mutex・SemaphoreSlimの使い分け
C#では、lock以外にも同期制御の仕組みがあります。
Monitorは、lockの内部で使われている仕組みに近いものです。lockより細かく制御したい場合に使います。
C#Monitor.Enter(_lock);
try
{
// 共有リソースを操作
}
finally
{
Monitor.Exit(_lock);
}
通常はlockで十分ですが、タイムアウト付きでロック取得を試したい場合などにMonitor.TryEnterが使われます。
Mutexは、プロセスをまたいだ排他制御が必要な場合に使われます。同じアプリ内だけでなく、別プロセスとの同期にも利用できます。ただし、lockやMonitorより重くなりやすいです。
SemaphoreSlimは、同時に実行できる数を制限したい場合に使います。特に非同期処理では、WaitAsyncを使えるため便利です。
C#private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task ExecuteAsync()
{
await _semaphore.WaitAsync();
try
{
// 同時に1つだけ実行したい処理
}
finally
{
_semaphore.Release();
}
}
lockはawaitと一緒に使えません。そのため、非同期メソッド内で排他制御したい場合はSemaphoreSlimを使うことが多いです。
4-5. イミュータブルな設計で共有状態を減らす
スレッドセーフを実現するうえで、そもそも共有状態を減らすことも重要です。
イミュータブルとは、一度作成したら変更できない状態のことです。
たとえば、次のようなクラスはプロパティを書き換えられません。
C#public class UserInfo
{
public string Name { get; }
public int Age { get; }
public UserInfo(string name, int age)
{
Name = name;
Age = age;
}
}
C#のrecordを使うと、イミュータブルなデータをより簡潔に表現できます。
C#public record UserInfo(string Name, int Age);
変更可能な共有オブジェクトを複数スレッドから操作するから問題が起こります。逆に、共有するデータが変更されないものであれば、スレッドセーフにしやすくなります。
スレッドセーフ設計では、「どう守るか」だけでなく、「そもそも共有しない」「変更しない」という考え方も大切です。
5. スレッドセーフなコレクションの使い方
5-1. System.Collections.Concurrentとは
C#には、複数スレッドから安全に使うためのコレクションが用意されています。それがSystem.Collections.Concurrent名前空間のコレクションです。
代表的なものには、次のようなクラスがあります。
C#ConcurrentDictionary<TKey, TValue>
ConcurrentQueue<T>
ConcurrentStack<T>
ConcurrentBag<T>
BlockingCollection<T>
これらは、複数スレッドからの追加、削除、取得などを想定して設計されています。
通常のList<T>やDictionary<TKey, TValue>を無理にlockで囲むこともできますが、用途によってはConcurrent系コレクションを使ったほうが安全でわかりやすくなります。
ただし、Concurrent系コレクションを使えば何でも自動的に安全になるわけではありません。使い方によっては注意が必要です。
5-2. ConcurrentDictionaryの基本的な使い方
ConcurrentDictionary<TKey, TValue>は、複数スレッドから安全にキーと値を管理できる辞書です。
基本的な使い方は次のとおりです。
C#using System.Collections.Concurrent;
private readonly ConcurrentDictionary<string, int> _scores = new();
public void AddOrUpdateScore(string name, int score)
{
_scores[name] = score;
}
値を追加するにはTryAddを使えます。
C#bool added = _scores.TryAdd("Alice", 100);
値を取得するにはTryGetValueを使います。
C#if (_scores.TryGetValue("Alice", out int score))
{
Console.WriteLine(score);
}
存在しない場合だけ追加したいときはGetOrAddが便利です。
C#int value = _scores.GetOrAdd("Alice", 100);
既存の値を更新したい場合はAddOrUpdateを使います。
C#_scores.AddOrUpdate(
"Alice",
1,
(key, oldValue) => oldValue + 1
);
この例では、Aliceが存在しない場合は1を追加し、存在する場合は既存の値に1を足します。
5-3. ConcurrentQueue・ConcurrentBag・ConcurrentStackの違い
ConcurrentQueue<T>、ConcurrentBag<T>、ConcurrentStack<T>は、それぞれデータの取り出し方が異なります。
ConcurrentQueue<T>は、先に入れたものを先に取り出すコレクションです。FIFOと呼ばれます。
C#var queue = new ConcurrentQueue<string>();
queue.Enqueue("A");
queue.Enqueue("B");
if (queue.TryDequeue(out string? item))
{
Console.WriteLine(item);
}
キューは、複数スレッドから処理対象を追加し、別のスレッドで順番に処理するような場面に向いています。
ConcurrentStack<T>は、後に入れたものを先に取り出すコレクションです。LIFOと呼ばれます。
C#var stack = new ConcurrentStack<string>();
stack.Push("A");
stack.Push("B");
if (stack.TryPop(out string? item))
{
Console.WriteLine(item);
}
ConcurrentBag<T>は、順序を重視しないコレクションです。
C#var bag = new ConcurrentBag<string>();
bag.Add("A");
bag.Add("B");
if (bag.TryTake(out string? item))
{
Console.WriteLine(item);
}
順序が重要ならConcurrentQueue、最後に入れたものを先に処理したいならConcurrentStack、順序を気にしないならConcurrentBagを検討します。
5-4. DictionaryとConcurrentDictionaryの使い分け
DictionaryとConcurrentDictionaryは、どちらを使うべきか迷いやすいポイントです。
基本的には、次のように考えるとわかりやすいです。
単一スレッドでしか使わない、または外側で完全に同期制御している場合はDictionaryで問題ありません。
C#var dictionary = new Dictionary<string, int>();
複数スレッドから同時に追加、更新、取得する可能性がある場合はConcurrentDictionaryを検討します。
C#var dictionary = new ConcurrentDictionary<string, int>();
ただし、すべての場面でConcurrentDictionaryを使えばよいわけではありません。複数の操作をひとまとまりとして扱いたい場合は、別途lockが必要になることもあります。
たとえば、「複数のキーを同時に更新して、全体として一貫性を保つ」ような処理は、ConcurrentDictionaryだけでは守れません。
5-5. ConcurrentDictionaryでも注意すべきAddOrUpdate・GetOrAddの落とし穴
ConcurrentDictionaryのGetOrAddやAddOrUpdateは便利ですが、注意点があります。
たとえば、GetOrAddで値を生成する処理を書いた場合、その生成処理が複数回呼ばれる可能性があります。
C#var value = _cache.GetOrAdd(key, k =>
{
Console.WriteLine("値を作成");
return CreateExpensiveValue(k);
});
最終的に辞書に追加される値は1つでも、競合状況によっては値を作成する関数が複数回実行される場合があります。
そのため、値生成処理に副作用がある場合は注意が必要です。
副作用とは、ログ出力、DB更新、外部API呼び出し、ファイル作成など、単に値を返すだけではない処理のことです。
また、AddOrUpdateの更新関数も複数回呼ばれる可能性を考慮すべきです。
C#_dictionary.AddOrUpdate(
key,
1,
(k, oldValue) => oldValue + 1
);
このような単純な計算なら問題になりにくいですが、更新関数の中で重い処理や副作用のある処理を書くのは避けたほうが安全です。
6. lockを使うときの実装方法と注意点
6-1. lock用オブジェクトをprivate readonlyで用意する理由
lockを使うときは、専用のロック用オブジェクトを用意するのが基本です。
C#private readonly object _lock = new();
そして、共有リソースを操作する部分をlockで囲みます。
C#lock (_lock)
{
// 共有データを操作する
}
ロック用オブジェクトをprivate readonlyにする理由は、外部から勝手にロックされないようにするためです。
privateにすることで、クラスの外からそのオブジェクトを使ってロックできなくなります。
readonlyにすることで、ロック用オブジェクトが途中で別のインスタンスに差し替えられることを防げます。
C#private readonly object _lock = new();
この形は、C#でlockを使うときの基本パターンとして覚えておくとよいです。
6-2. lock(this)やlock(typeof(...))を避けるべき理由
初心者がやってしまいがちなNG例が、lock(this)です。
C#lock (this)
{
// 処理
}
thisは外部から参照できる可能性があります。外部のコードが同じインスタンスを使ってlockしてしまうと、予期しない待ち合わせやデッドロックの原因になります。
同じ理由で、次のようなコードも避けるべきです。
C#lock (typeof(MyClass))
{
// 処理
}
typeof(MyClass)はアプリケーション内で共有されるため、別の場所から同じ型を使ってロックされる可能性があります。
文字列でロックするのも危険です。
C#lock ("my-lock")
{
// 処理
}
文字列はインターンされることがあり、思わぬ場所で同じ文字列インスタンスが共有される可能性があります。
安全な基本形は、外部に公開しない専用オブジェクトを使うことです。
C#private readonly object _lock = new();
6-3. ロックする範囲をできるだけ短くする
lockは便利ですが、ロック中は他のスレッドが待たされます。そのため、ロックする範囲はできるだけ短くするのが基本です。
悪い例です。
C#lock (_lock)
{
var data = LoadFromDatabase();
_items.Add(data);
SendToExternalApi(data);
}
このコードでは、DBアクセスや外部API呼び出しの間もロックを保持しています。これでは他のスレッドが長時間待たされ、パフォーマンスが低下します。
改善例です。
C#var data = LoadFromDatabase();
lock (_lock)
{
_items.Add(data);
}
SendToExternalApi(data);
共有リソースを操作する部分だけをlockで守るようにすると、待ち時間を減らせます。
ただし、処理全体の整合性を保つ必要がある場合は、単純にロック範囲を短くすればよいとは限りません。守るべきデータの単位を考えて、必要最小限の範囲をロックすることが大切です。
6-4. ネストしたlockによるデッドロックに注意する
デッドロックとは、複数のスレッドがお互いのロック解除を待ち続け、処理が進まなくなる状態です。
たとえば、次のようなロックがあるとします。
C#private readonly object _lockA = new();
private readonly object _lockB = new();
スレッド1が_lockAを取得してから_lockBを待ち、スレッド2が_lockBを取得してから_lockAを待つと、どちらも進めなくなります。
スレッド1: lockAを取得 → lockBを待つ
スレッド2: lockBを取得 → lockAを待つ
これがデッドロックです。
デッドロックを防ぐには、複数のロックを取得する順序を必ず統一します。
C#lock (_lockA)
{
lock (_lockB)
{
// 処理
}
}
別の場所でも、必ず_lockAから_lockBの順に取得するようにします。
また、可能であればロックのネスト自体を減らす設計にすることが望ましいです。
6-5. パフォーマンス低下を防ぐ設計の考え方
スレッドセーフにするために何でもlockで囲むと、パフォーマンスが低下することがあります。
特に、多数のスレッドが同じロックを取り合う状態をロック競合と呼びます。ロック競合が多いと、並行処理にしているのに実際には順番待ちばかりになってしまいます。
パフォーマンス低下を防ぐには、次の考え方が重要です。
共有データを減らす
ロックする範囲を短くする
読み取り専用データはイミュータブルにする
用途に合うConcurrent系コレクションを使う
単純な数値操作には
Interlockedを使う重い処理をロックの外に出す
ロック対象を分割して競合を減らす
スレッドセーフは、「とりあえずlockすればよい」というものではありません。安全性とパフォーマンスのバランスを考える必要があります。
7. async/awaitとスレッドセーフの関係
7-1. async/awaitはスレッドセーフを保証しない
C#のasync/awaitは、非同期処理をわかりやすく書くための仕組みです。
しかし、async/awaitを使ったからといって、共有データへのアクセスが自動的に安全になるわけではありません。
たとえば、次のコードはスレッドセーフではありません。
C#private int _count = 0;
public async Task IncrementManyAsync()
{
var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() => _count++))
.ToArray();
await Task.WhenAll(tasks);
}
await Task.WhenAll(tasks)は、すべてのタスクが終わるのを待つだけです。各タスクが同じ_countを安全に更新するようにはしてくれません。
非同期処理とスレッドセーフは別の問題です。
非同期処理は「待ち時間を効率よく扱う」ための仕組みであり、スレッドセーフは「共有データを安全に扱う」ための考え方です。
7-2. Taskを複数実行すると共有データの競合が起こる
Taskを複数実行すると、複数の処理が同時に進む可能性があります。
たとえば、次のように複数のTaskで同じListに追加するコードは危険です。
C#private readonly List<int> _items = new();
public async Task AddItemsAsync()
{
var tasks = Enumerable.Range(0, 1000)
.Select(i => Task.Run(() => _items.Add(i)))
.ToArray();
await Task.WhenAll(tasks);
}
List<T>は複数スレッドから同時にAddされることを想定していません。
安全にするには、lockで保護します。
C#private readonly object _lock = new();
private readonly List<int> _items = new();
public async Task AddItemsAsync()
{
var tasks = Enumerable.Range(0, 1000)
.Select(i => Task.Run(() =>
{
lock (_lock)
{
_items.Add(i);
}
}))
.ToArray();
await Task.WhenAll(tasks);
}
または、用途に応じてConcurrentBag<T>などを使います。
C#private readonly ConcurrentBag<int> _items = new();
public async Task AddItemsAsync()
{
var tasks = Enumerable.Range(0, 1000)
.Select(i => Task.Run(() => _items.Add(i)))
.ToArray();
await Task.WhenAll(tasks);
}
7-3. 非同期処理でSemaphoreSlimを使う場面
非同期メソッド内で排他制御をしたい場合、lockは使いにくいです。なぜなら、lockの中でawaitできないからです。
次のようなコードは書けません。
C#lock (_lock)
{
await SomeAsyncMethod(); // コンパイルエラー
}
非同期処理で同時実行数を制御したい場合は、SemaphoreSlimを使います。
C#private readonly SemaphoreSlim _semaphore = new(1, 1);
public async Task SaveAsync()
{
await _semaphore.WaitAsync();
try
{
await WriteFileAsync();
}
finally
{
_semaphore.Release();
}
}
new SemaphoreSlim(1, 1)は、同時に1つの処理だけを通す設定です。つまり、非同期版の排他制御として使えます。
同時に3件まで処理したい場合は、次のようにします。
C#private readonly SemaphoreSlim _semaphore = new(3, 3);
外部API呼び出しやファイル書き込みなど、同時実行数を制限したい非同期処理でよく使われます。
7-4. UIスレッドとバックグラウンド処理で注意すべきこと
WPF、Windows Forms、MAUIなどのUIアプリでは、UI部品は基本的にUIスレッドから操作する必要があります。
バックグラウンドスレッドから直接UIを更新すると、例外や不具合の原因になります。
悪い例です。
C#await Task.Run(() =>
{
label.Text = "完了"; // UIスレッド以外から更新している
});
UIを更新する処理は、UIスレッドに戻して実行する必要があります。
Windows Formsでは、Invokeを使うことがあります。
C#this.Invoke(() =>
{
label.Text = "完了";
});
WPFでは、Dispatcherを使います。
C#Application.Current.Dispatcher.Invoke(() =>
{
LabelText = "完了";
});
UIアプリでは、スレッドセーフという観点に加えて、「UIはUIスレッドで更新する」というルールも重要です。
7-5. ConfigureAwaitとスレッドセーフの関係を整理する
ConfigureAwait(false)は、await後に元の同期コンテキストへ戻るかどうかを制御するためのものです。
C#await SomeAsyncMethod().ConfigureAwait(false);
ライブラリコードでは、不要なコンテキスト復帰を避けるために使われることがあります。
ただし、ConfigureAwait(false)を使ったからといって、共有データへのアクセスがスレッドセーフになるわけではありません。
むしろ、await後の処理が元のコンテキストに戻らないことで、UIスレッド以外で続きが実行される可能性があります。そのため、UI部品を操作するコードでは注意が必要です。
整理すると、次のようになります。
async/awaitは非同期処理のための仕組みです。
ConfigureAwaitは、await後の実行コンテキストを制御する仕組みです。
lock、Interlocked、SemaphoreSlim、Concurrent系コレクションは、共有データを安全に扱うための仕組みです。
それぞれ役割が違うため、混同しないようにしましょう。
8. 実務でよくあるスレッドセーフの実装パターン
8-1. 共有カウンターを安全に更新する
共有カウンターを安全に更新するには、Interlockedを使うのが簡単です。
C#using System.Threading;
public class AccessCounter
{
private int _count = 0;
public void Increment()
{
Interlocked.Increment(ref _count);
}
public int GetCount()
{
return Volatile.Read(ref _count);
}
}
単純なインクリメントであれば、lockよりもInterlockedのほうが簡潔に書けます。
複数の値をまとめて更新したい場合は、lockを使います。
C#public class Statistics
{
private readonly object _lock = new();
private int _success;
private int _failure;
public void AddSuccess()
{
lock (_lock)
{
_success++;
}
}
public void AddFailure()
{
lock (_lock)
{
_failure++;
}
}
public (int Success, int Failure) GetSnapshot()
{
lock (_lock)
{
return (_success, _failure);
}
}
}
複数の値を一貫した状態で読みたい場合は、読み取り側も同じロックで保護することが重要です。
8-2. キャッシュをConcurrentDictionaryで管理する
実務では、計算結果や外部APIのレスポンスをキャッシュしたい場面があります。
複数スレッドから参照されるキャッシュには、ConcurrentDictionaryがよく使われます。
C#using System.Collections.Concurrent;
public class UserCache
{
private readonly ConcurrentDictionary<int, User> _cache = new();
public User GetUser(int userId)
{
return _cache.GetOrAdd(userId, id => LoadUser(id));
}
private User LoadUser(int userId)
{
// DBやAPIからユーザー情報を取得する想定
return new User(userId, $"User-{userId}");
}
}
public record User(int Id, string Name);
このコードでは、指定したユーザーIDがキャッシュにあればそれを返し、なければLoadUserで取得してキャッシュに追加します。
ただし、GetOrAddの値生成関数が複数回呼ばれる可能性がある点には注意が必要です。
重い初期化処理を厳密に1回だけ実行したい場合は、Lazy<T>と組み合わせることもあります。
C#private readonly ConcurrentDictionary<int, Lazy<User>> _cache = new();
public User GetUser(int userId)
{
var lazy = _cache.GetOrAdd(
userId,
id => new Lazy<User>(() => LoadUser(id))
);
return lazy.Value;
}
8-3. キューを使って複数スレッドから安全に処理する
複数スレッドから処理対象を追加し、別の処理で順番に処理したい場合は、ConcurrentQueue<T>が便利です。
C#using System.Collections.Concurrent;
public class WorkQueue
{
private readonly ConcurrentQueue<string> _queue = new();
public void Enqueue(string item)
{
_queue.Enqueue(item);
}
public bool TryProcess()
{
if (_queue.TryDequeue(out var item))
{
Console.WriteLine($"処理: {item}");
return true;
}
return false;
}
}
Enqueueは追加、TryDequeueは取り出しです。
複数のスレッドが同時にEnqueueやTryDequeueを呼んでも、安全に処理できます。
より本格的な生産者・消費者パターンを作る場合は、Channel<T>やBlockingCollection<T>を検討することもあります。
8-4. Singletonをスレッドセーフに実装する
Singletonは、アプリケーション内でインスタンスを1つだけ持つ設計パターンです。
C#では、static readonlyを使うとシンプルにスレッドセーフなSingletonを実装できます。
C#public sealed class AppSettings
{
public static readonly AppSettings Instance = new();
private AppSettings()
{
}
public string ApplicationName { get; } = "SampleApp";
}
遅延初期化したい場合は、Lazy<T>を使うと安全です。
C#public sealed class AppSettings
{
private static readonly Lazy<AppSettings> _instance =
new(() => new AppSettings());
public static AppSettings Instance => _instance.Value;
private AppSettings()
{
}
}
Lazy<T>を使うことで、必要になるまでインスタンスを作らず、かつ複数スレッドから同時にアクセスされても安全に初期化できます。
古いコードでは、lockを使ってSingletonを実装する例もありますが、C#ではstatic readonlyやLazy<T>を使ったほうが簡潔です。
8-5. ログ出力やファイル書き込みを安全に行う
複数スレッドから同じファイルに書き込む場合も、スレッドセーフを考える必要があります。
単純な例では、lockでファイル書き込みを保護します。
C#public class FileLogger
{
private readonly object _lock = new();
private readonly string _path = "app.log";
public void Write(string message)
{
lock (_lock)
{
File.AppendAllText(_path, message + Environment.NewLine);
}
}
}
ただし、実務ではログ出力のたびにロックしてファイルへ直接書き込むと、パフォーマンスが問題になることがあります。
そのため、実際の開発ではSerilog、NLog、Microsoft.Extensions.Loggingなどのログライブラリを使うことが多いです。
自前で実装する場合は、キューにログを積み、専用の処理で順番に書き込む設計もあります。
C#private readonly ConcurrentQueue<string> _logs = new();
public void Log(string message)
{
_logs.Enqueue(message);
}
ログ出力やファイル書き込みは、アプリ全体から呼ばれやすいため、スレッドセーフを意識するべき代表的な処理です。
9. スレッドセーフ設計で避けるべきNG例
9-1. static変数を無防備に共有する
static変数は、アプリケーション内で共有されます。そのため、複数スレッドから同時にアクセスされる可能性があります。
次のようなコードは危険です。
C#public static class GlobalCounter
{
public static int Count = 0;
public static void Increment()
{
Count++;
}
}
Webアプリケーションでは、複数のリクエストが同時に同じstatic変数を操作する可能性があります。
安全にするには、Interlockedやlockを使います。
C#public static class GlobalCounter
{
private static int _count = 0;
public static void Increment()
{
Interlocked.Increment(ref _count);
}
public static int Count => Volatile.Read(ref _count);
}
staticは便利ですが、共有状態を作りやすいため注意が必要です。
9-2. コレクションの列挙中に別スレッドで更新する
ListやDictionaryを列挙している途中で、別スレッドが追加や削除を行うと問題が起きます。
C#foreach (var item in _items)
{
Console.WriteLine(item);
}
この処理中に別スレッドが_items.Add(...)や_items.Remove(...)を行うと、例外が発生することがあります。
対策としては、列挙中もlockで保護します。
C#lock (_lock)
{
foreach (var item in _items)
{
Console.WriteLine(item);
}
}
ただし、列挙処理が重い場合は、ロック中に長時間処理することになります。
その場合は、ロック中にコピーを作り、ロックの外で処理する方法もあります。
C#List<string> snapshot;
lock (_lock)
{
snapshot = _items.ToList();
}
foreach (var item in snapshot)
{
Console.WriteLine(item);
}
9-3. lockの中で重い処理や外部API呼び出しを行う
lockの中で重い処理を行うと、他のスレッドが長時間待たされます。
悪い例です。
C#lock (_lock)
{
var result = await CallApiAsync(); // そもそもlock内でawaitは不可
_items.Add(result);
}
lockの中でawaitは使えません。また、外部API呼び出しのような時間のかかる処理をロック内に置くべきではありません。
改善例です。
C#var result = await CallApiAsync();
lock (_lock)
{
_items.Add(result);
}
共有データを操作する部分だけをロックします。
DBアクセス、ファイルI/O、ネットワーク通信、重い計算処理などは、できるだけロックの外に出すことを考えましょう。
9-4. スレッドセーフなクラスに外側から不要なlockを重ねる
ConcurrentDictionaryなどのスレッドセーフなクラスを使っているのに、外側から何でもlockで囲むと、かえって設計が複雑になります。
C#lock (_lock)
{
_dictionary.TryAdd(key, value);
}
このようなコードが常に悪いわけではありませんが、単純な追加や取得だけであれば、ConcurrentDictionary自体の機能を使えば十分なことが多いです。
一方で、複数の操作をまとめて一貫性を保ちたい場合は、外側のlockが必要になることもあります。
つまり、重要なのは「何を守りたいのか」を明確にすることです。
単一操作を安全にしたいだけなら、Concurrent系コレクションの機能を使います。
複数操作をひとまとまりとして扱いたいなら、別途ロックを検討します。
9-5. テストで問題が出ないから安全だと判断する
スレッドセーフではないコードは、テストで問題が出ないことがあります。
しかし、問題が出なかったからといって安全とは限りません。
スレッド関連の不具合は、タイミングに依存します。開発者のPCでは再現しなくても、本番環境の負荷が高い状態で発生することがあります。
特に、次のような判断は危険です。
何回か実行して問題なかったから大丈夫
デバッグでは正常だから大丈夫
ユーザー数が少ないから大丈夫
例外が出ていないから大丈夫
スレッドセーフかどうかは、動いたかどうかではなく、設計とコードの構造から判断する必要があります。
10. C#のスレッドセーフを確認・検証する方法
10-1. 単体テストで並行実行を再現する
スレッドセーフを確認するには、単体テストで並行実行を再現する方法があります。
たとえば、カウンターのテストは次のように書けます。
C#[Fact]
public async Task Counter_Should_Be_ThreadSafe()
{
var counter = new SafeCounter();
var tasks = Enumerable.Range(0, 100)
.Select(_ => Task.Run(() =>
{
for (int i = 0; i < 1000; i++)
{
counter.Increment();
}
}))
.ToArray();
await Task.WhenAll(tasks);
Assert.Equal(100000, counter.Count);
}
ただし、このテストに通ったからといって完全に安全とは限りません。競合状態はタイミングに依存するため、テストで必ず検出できるとは限らないからです。
それでも、並行実行テストは問題を見つけるための有効な手段です。
10-2. Parallel.ForやTask.WhenAllで競合を検出する
Parallel.Forを使うと、簡単に並行実行を発生させられます。
C#var counter = new UnsafeCounter();
Parallel.For(0, 100000, _ =>
{
counter.Increment();
});
Console.WriteLine(counter.Count);
また、Task.WhenAllを使う方法もあります。
C#var tasks = Enumerable.Range(0, 1000)
.Select(_ => Task.Run(() => DoWork()))
.ToArray();
await Task.WhenAll(tasks);
このようなコードで、共有データに対する同時アクセスを意図的に増やすと、競合状態を発見しやすくなります。
ただし、競合が必ず再現するとは限らないため、何度も実行したり、ループ回数を増やしたりすることもあります。
10-3. ログを使ってスレッドIDと実行順序を確認する
スレッド関連の問題を調べるときは、スレッドIDや実行順序をログに出すと役立ちます。
C#Console.WriteLine(
$"ThreadId={Environment.CurrentManagedThreadId}, Count={_count}"
);
Environment.CurrentManagedThreadIdを使うと、現在のマネージドスレッドIDを確認できます。
ログには、次のような情報を出すと調査しやすくなります。
スレッドID
処理開始時刻
処理終了時刻
対象データのキー
更新前の値
更新後の値
ロック取得前後のタイミング
ただし、ログを追加すると処理タイミングが変わり、問題が再現しなくなる場合があります。そのため、ログだけに頼りすぎないことも大切です。
10-4. Visual Studioのデバッグ機能を活用する
Visual Studioには、スレッド関連の調査に役立つ機能があります。
たとえば、デバッグ中にスレッドウィンドウを使うと、現在動作しているスレッドを確認できます。
また、並列スタックやタスクウィンドウを使うと、複数のタスクがどのように動いているかを確認できます。
デッドロックが疑われる場合は、どのスレッドがどこで停止しているかを見ることが重要です。
ただし、デバッグ実行では処理速度やタイミングが変わるため、本番と同じ状態を完全に再現できるわけではありません。
Visual Studioのデバッグ機能は有効ですが、設計レビューやコードレビューと組み合わせて使うことが大切です。
10-5. コードレビューで確認すべきポイント
スレッドセーフは、コードレビューで事前に見つけることが重要です。
確認すべきポイントは次のとおりです。
共有される変数やオブジェクトがあるか
staticな可変状態がないかListやDictionaryを複数スレッドから更新していないか読み取りと更新が同じロックで守られているか
lock(this)やlock(typeof(...))を使っていないかロック範囲が広すぎないか
ネストしたロックの順序が統一されているか
asyncメソッド内で適切にSemaphoreSlimを使っているかConcurrent系コレクションのメソッドに副作用のある処理を渡していないか
イミュータブルにできるデータを可変にしていないか
スレッドセーフの問題は、実行してから見つけるよりも、コードの構造を見て判断するほうが効果的です。
11. C#のスレッドセーフに関するよくある質問
11-1. C#のListはスレッドセーフですか
List<T>は、複数スレッドから同時に更新する用途ではスレッドセーフではありません。
1つのスレッドだけが操作する場合や、外側で適切にlockしている場合は使えます。
複数スレッドから同時に追加する場合は、lockで保護するか、用途に応じてConcurrentBag<T>、ConcurrentQueue<T>などを検討します。
C#lock (_lock)
{
_list.Add(item);
}
読み取りだけの場合でも、別スレッドが同時に更新しているなら安全とは限りません。読み取りと更新の両方を適切に同期する必要があります。
11-2. C#のDictionaryはスレッドセーフですか
Dictionary<TKey, TValue>は、複数スレッドから同時に更新する用途ではスレッドセーフではありません。
複数スレッドから同時に追加、削除、更新を行う場合は、ConcurrentDictionary<TKey, TValue>を使うか、lockで保護します。
C#private readonly object _lock = new();
private readonly Dictionary<string, int> _dictionary = new();
public void Add(string key, int value)
{
lock (_lock)
{
_dictionary[key] = value;
}
}
単純なキー単位の追加や更新であれば、ConcurrentDictionaryが便利です。
C#private readonly ConcurrentDictionary<string, int> _dictionary = new();
11-3. ConcurrentDictionaryを使えばlockは不要ですか
単純な追加、取得、更新であれば、ConcurrentDictionaryの機能だけで十分なことが多いです。
しかし、複数の操作をまとめて一つの整合性として扱いたい場合は、ConcurrentDictionaryだけでは不十分な場合があります。
たとえば、複数のキーを同時に更新し、全体として矛盾がない状態を保ちたい場合は、外側でlockする設計が必要になることがあります。
また、GetOrAddやAddOrUpdateに渡す関数が複数回呼ばれる可能性がある点にも注意が必要です。
つまり、ConcurrentDictionaryは便利ですが、すべてのスレッドセーフ問題を自動で解決するものではありません。
11-4. staticメソッドはスレッドセーフですか
staticメソッドだからスレッドセーフ、というわけではありません。
staticメソッドがローカル変数だけを使い、共有状態を変更しないのであれば、スレッドセーフになりやすいです。
C#public static int Add(int a, int b)
{
return a + b;
}
このようなメソッドは、共有データを持たないため安全です。
一方で、staticフィールドを更新する場合は注意が必要です。
C#private static int _count;
public static void Increment()
{
_count++;
}
このコードは、複数スレッドから呼ばれるとスレッドセーフではありません。
staticかどうかではなく、共有される可変状態を扱っているかどうかで判断します。
11-5. readonlyやconstを使えばスレッドセーフになりますか
readonlyやconstを使うと、値の変更を制限できます。ただし、それだけで常にスレッドセーフになるわけではありません。
constはコンパイル時定数なので、変更されません。そのため、値そのものは安全に共有できます。
C#public const int MaxCount = 100;
readonlyは、フィールドの再代入を制限します。
C#private readonly List<string> _items = new();
しかし、この例では_itemsフィールドに別のListを代入できないだけで、Listの中身は変更できます。
C#_items.Add("A");
つまり、readonlyは参照先オブジェクトの中身まで不変にするものではありません。
本当にスレッドセーフにしたい場合は、イミュータブルな型を使う、コレクションを変更不可にする、適切に同期するなどの対策が必要です。
11-6. lockとSemaphoreSlimはどちらを使うべきですか
同期メソッド内で短い排他制御をしたい場合は、lockがシンプルです。
C#lock (_lock)
{
_count++;
}
一方、非同期メソッド内で排他制御をしたい場合は、SemaphoreSlimを使います。
C#await _semaphore.WaitAsync();
try
{
await SaveAsync();
}
finally
{
_semaphore.Release();
}
lockの中ではawaitできません。そのため、非同期処理ではSemaphoreSlimがよく使われます。
目安としては、次のように考えるとよいです。
同期処理で共有データを短時間守るならlock。
非同期処理で同時実行数を制限したいならSemaphoreSlim。
単純な数値操作ならInterlocked。
コレクションの共有ならConcurrent系コレクション。
このように、用途に応じて使い分けることが大切です。
まとめ
C#のスレッドセーフとは、複数のスレッドから同時に使われても、データの整合性が壊れず正しく動作する状態のことです。
初心者がつまずきやすいポイントは、_count++のような単純に見える処理でも実際には複数の操作に分かれており、複数スレッドから同時に実行すると競合状態が起こる可能性がある点です。
また、List<T>やDictionary<TKey, TValue>をそのまま複数スレッドで共有したり、async/awaitを使えば自動的に安全になると誤解したりすることも、よくある原因です。
C#でスレッドセーフを実現する主な方法には、次のようなものがあります。
lockは、共有リソースへの同時アクセスを防ぐ基本的な方法です。
Interlockedは、カウンターの加算や減算など、単純な数値操作を安全に行うために便利です。
volatileは、変数の読み書きの見え方を制御するために使いますが、複合操作を安全にするものではありません。
SemaphoreSlimは、非同期処理で同時実行数を制限したい場合に役立ちます。
ConcurrentDictionary、ConcurrentQueue、ConcurrentBagなどのConcurrent系コレクションは、複数スレッドから安全にコレクションを扱うために使えます。
ただし、どの方法も万能ではありません。重要なのは、共有されるデータがどこにあり、どの処理からアクセスされ、何を守る必要があるのかを明確にすることです。
スレッドセーフな設計をするためには、まず共有状態を減らし、可能であればイミュータブルにし、必要な部分だけを適切に同期することが大切です。
C#でマルチスレッド処理や非同期処理を書くときは、「このコードは複数スレッドから同時に呼ばれても安全か」という視点を持つようにしましょう。

