複数スレッドからC#のList<T>に対してAdd/RemoveAtなどの更新を行うと、例外やデータ破損が発生することがあります。「lockで囲んでいるのに落ちる」「ロックを分けた方が速い?」と悩みがちなポイントを、正しいロック設計とConcurrentコレクションの選び方まで含めて整理します。
結論:List<T>を複数スレッドで更新するなら「同じロックを1つ」で直列化する
まず結論から。守りたい対象が1つのListなら、ロックオブジェクトは1つで十分で、むしろ1つに統一すべきです。
ポイントは「Add用」「Delete用」と用途で分けるのではなく、同じリストに触れる全ての処理を、同じlockで囲むことです。これができて初めて、複数スレッドからの同時更新が起きなくなります。
安全な最小例
private static readonly object _packageListLock = new object();
private static readonly List<PackageLabel> ListPackageLabel = new List<PackageLabel>();
public static void AddPackage(PackageLabel currentPackage, L type)
{
lock (_packageListLock)
{
switch (type)
{
case L.FiFo:
ListPackageLabel.Add(currentPackage);
break;
case L.LiFo:
ListPackageLabel.Insert(0, currentPackage);
break;
}
}
}
public static void DeletePackage()
{
lock (_packageListLock)
{
if (ListPackageLabel.Count > 0)
{
ListPackageLabel.RemoveAt(0);
}
}
}
上の例では、更新系の操作(Add/Insert/RemoveAt)を同一ロックで保護しています。ここまでやれば「更新が同時に走る」事故は防げます。
なぜList<T>はスレッドセーフではないのか
List<T>は高速で便利な代わりに、内部では可変の配列を持ち、要素数(Count)や格納領域(Capacity)を更新しながら動きます。複数スレッドが同時に更新すると、次のような問題が起こりやすくなります。
| 起きやすい症状 | 代表例 | 原因のイメージ |
|---|---|---|
| 例外が出る | ArgumentOutOfRangeException、InvalidOperationExceptionなど | 別スレッドが先に削除/挿入してインデックスがズレる、列挙中に変更される |
| 要素が欠ける/重複する | 入れたはずの要素が見当たらない、同じ要素が2回ある | Count更新と配列書き込みの順序が競合し、途中状態が見える |
| たまにしか再現しない | 負荷試験や本番だけで発生 | タイミング依存のレースコンディション |
「たまにしか起きない」ことこそが厄介です。ロックが一部にしか掛かっていない、あるいはロックが統一されていない場合、低頻度で不整合が混入します。
lockの仕組み:ロックしているのは「List」ではなく「ロックオブジェクト」
C#のlock (obj) { ... }は、objに紐づく排他制御(Monitor)を取得します。つまり、ロック対象は「Listそのもの」ではなく、開発者が選んだ「目印のオブジェクト」です。
重要なのは次の約束事です。
- 同じリストに触るコードは、必ず同じロックオブジェクトを使う
- ロックオブジェクトは外部から触れないようにprivateにする
- ロックオブジェクトは差し替え不能にするためreadonlyにする
この「同じ目印で順番待ちさせる」ルールが守られていないと、lockを書いていても実質的に同時更新が起きます。
ロックを2個に分けるのがNGな理由
「追加は追加でロック」「削除は削除でロック」と分けたくなることがあります。しかし、守る対象が同じListなら、ロックを分けるとスレッドセーフではなくなります。
たとえば次のようにロックを分けたとします。
static readonly object _lockAdd = new object();
static readonly object _lockDelete = new object();
void Add(PackageLabel p)
{
lock (_lockAdd)
{
ListPackageLabel.Add(p);
}
}
void Delete()
{
lock (_lockDelete)
{
ListPackageLabel.RemoveAt(0);
}
}
この場合、スレッドAが_lockAddを取得して追加中でも、スレッドBは_lockDeleteを取得して削除に入れてしまいます。結果として、同じListを同時に更新できてしまい、レースが発生します。
ロックを分けるのは、守る対象が分かれているとき(例:独立した別リスト、別リソース)だけです。「処理の種類」で分けるのではなく、「競合してはいけない共有状態」で分ける、と覚えておくと判断を誤りにくくなります。
staticなロックオブジェクトは1個でいい? それとも複数必要?
質問でよくあるのが「static object LockPackageList = new object();は1個でいいのか?」という点です。答えは基本的に1個で十分です。
ロックをstaticにするべきケース
- 守る対象のListがstatic(全インスタンスで共有)である
- アプリケーション全体で1つの共有キュー/スタックとして扱う設計である
ロックをstaticにしない方がいいケース
- Listがインスタンスごとに別(各インスタンスが独立)
- インスタンス間で同期する必要がないのに、static lockで無駄に直列化してしまう
つまり、Listがstaticならlockもstatic、Listがインスタンスならlockもインスタンスが基本です。不要に広い範囲をロックするとスループットが落ちるので、共有範囲に合わせて設計します。
安全性を上げるコツ:Listを直接触らせない(カプセル化)
実務で事故が多いのは「あるメソッドではlockしているが、別の場所でListを直接触ってしまった」というパターンです。これを防ぐには、Listをprivateにして、操作用メソッドだけ公開するのが効果的です。
public sealed class PackageBuffer
{
private readonly object _sync = new object();
private readonly List<PackageLabel> _list = new List<PackageLabel>();
public void Add(PackageLabel item, L type)
{
lock (_sync)
{
if (type == L.FiFo) _list.Add(item);
else _list.Insert(0, item);
}
}
public bool TryDelete(out PackageLabel removed)
{
lock (_sync)
{
if (_list.Count == 0)
{
removed = null;
return false;
}
removed = _list[0];
_list.RemoveAt(0);
return true;
}
}
public int Count
{
get { lock (_sync) return _list.Count; }
}
}
この形にすると、呼び出し側はPackageBufferのAPIだけを通るため、ロック漏れを作りにくくなります。さらに、戻り値をbool TryXXXにしておけば、空のときの例外も避けられます。
RemoveAt(0)の前にCountチェックが必要な理由
RemoveAt(0)は、要素がない状態で呼ぶと例外になります。複数スレッド環境では「あると思っていた要素が、直前に別スレッドで消される」ことが普通に起こるため、チェックが必要です。
ここで大切なのは、CountチェックとRemoveAtを同じlock内で行うことです。次のようにlockの外でCountを見てしまうと、チェック後に状態が変わる可能性があります。
// 悪い例:Countを見た直後に別スレッドが削除するかもしれない
if (ListPackageLabel.Count > 0)
{
lock (_packageListLock)
{
ListPackageLabel.RemoveAt(0);
}
}
正しくは、確認から削除まで一続きの操作としてロックに入れます。
性能面の注意:Insert(0) / RemoveAt(0)はO(n)で重くなりやすい
スレッドセーフの話とは別に、実装として見落としがちなのが先頭操作のコストです。Listの先頭にInsertしたり、先頭をRemoveAtすると、後ろの要素を詰めるために配列コピー(シフト)が発生します。要素数が増えるほどコストが増え、ロック競合も起きやすくなります。
| やりたいこと | Listの先頭 | Listの末尾 | 向いているデータ構造 |
|---|---|---|---|
| FIFOで追加 | OK(Add) | OK(Add) | Queue / ConcurrentQueue |
| FIFOで取り出し | 重い(RemoveAt(0)でシフト) | 不自然(末尾だとFIFOにならない) | Queue / ConcurrentQueue / BlockingCollection |
| LIFOで追加 | 重い(Insert(0)でシフト) | 軽い(Add) | Stack / ConcurrentStack |
| LIFOで取り出し | OKだが先頭運用は非推奨 | 軽い(RemoveAt(Count-1)) | Stack / ConcurrentStack |
もし「LIFO(スタック)」のつもりで運用しているなら、Listの先頭ではなく末尾を使うだけでも改善します。
// LIFOをListでやるなら末尾運用が素直(シフトが不要)
lock (_packageListLock)
{
ListPackageLabel.Add(currentPackage); // push
}
// pop
lock (_packageListLock)
{
if (ListPackageLabel.Count > 0)
{
var lastIndex = ListPackageLabel.Count - 1;
var item = ListPackageLabel[lastIndex];
ListPackageLabel.RemoveAt(lastIndex);
}
}
FIFOについては、Listで先頭を抜く設計自体がスケールしにくいので、Queue系へ寄せるのが基本です。
読み取りも油断禁物:Count参照・インデクサ・foreachも「触っている」
「書き込みだけlockすればいい」と思いがちですが、List<T>は書き込み中に読み取られるだけでも危険です。代表的には次のような箇所です。
Countを読むlist[0]などインデクサで読むforeachで列挙する(列挙中の変更で例外が出る)
読み取りが多く、更新が少ない場合は、次のように「スナップショット」を作ってから処理すると、ロック時間を短くできます。
PackageLabel[] snapshot;
lock (_packageListLock)
{
snapshot = ListPackageLabel.ToArray();
}
// lockの外で安全に処理できる
foreach (var item in snapshot)
{
DoSomething(item);
}
ロック内で重い処理(IOや長い計算)を行うと、他スレッドが待たされます。「ロックは短く」「共有状態の更新だけ」を意識すると安定します。
読み取りが圧倒的に多いならReaderWriterLockSlimも選択肢
「ほとんど読むだけで、たまに更新する」ようなケースでは、ReaderWriterLockSlimで読み取りの同時実行を許す設計もあります。読み取りロックは複数スレッドで共有でき、書き込み時だけ排他になります。
ただし運用を誤ると複雑化しやすいので、まずは1ロックのlockで正しく動かす→必要なら最適化、の順序がおすすめです。
デッドロックを避ける考え方
ロックが1つだけなら、典型的な相互待ち(デッドロック)は起きにくく、設計も単純です。一方、複数ロックを扱うときは、ロック取得順序の逆転でデッドロックが起こります。
| 状況 | 安全性 | 実務の指針 |
|---|---|---|
| 1リストにつき1ロック | 高い | まずはこれで設計する。複雑化しない。 |
| 複数リストに複数ロック | 中 | 取得順序を固定する(例:A→Bの順に必ず取る)。 |
| 外部コードをロック内で呼ぶ | 低い | コールバックやイベント発火は原則ロック外に出す。 |
「今はロックが1つだから大丈夫」でも、将来機能追加でロックが増えることはよくあります。共有状態を小さく保ち、ロックを増やさない設計は長期的に効きます。
Lazy<T>シングルトンは何を解決して、何を解決しないのか
スレッドセーフなシングルトンとしてLazy<T>が紹介されることがあります。これはインスタンス生成を1回にすること、そして生成処理を複数スレッドから安全に行うことが主目的です。
public sealed class Sample
{
private static readonly Lazy<Sample> _lazy =
new Lazy<Sample>(() => new Sample());
public static Sample Instance => _lazy.Value;
public List<Person> People { get; }
private Sample()
{
People = new List<Person>();
}
}
ただし、ここで重要なのは、People(List<Person>)のAdd/Removeがスレッドセーフになるわけではない点です。シングルトンは「生成」の話、List操作は「共有データ更新」の話で、レイヤーが違います。
もしSample.Instance.Peopleを複数スレッドが触るなら、結局は次のどちらかが必要です。
- Peopleを触る箇所をlockで保護する
- Peopleをスレッドセーフなコレクションに置き換える
要素型(PackageLabel)がスレッドセーフでもListは別問題
要素クラスが単純なDTOであっても、List<T>の危険性は消えません。今回の本質は「要素の中身」ではなく、リスト構造そのもの(要素数・並び・内部配列)を変更する操作が同時実行されることです。
public class PackageLabel
{
public int QuantityPackage { get; set; }
public string BatchNumber { get; set; }
}
このようにプロパティだけのクラスであっても、List<PackageLabel>に対するAdd/Insert/RemoveAtが同時に走れば壊れます。逆に言えば、List側を正しく同期できれば、要素型がシンプルでも複雑でもまずは安全に扱えます。
代替案:目的に合ったスレッドセーフコレクションを選ぶ
「List+lock」を自前で正しく保つのは、規模が大きくなるほど難しくなります。要件が合うなら、.NET標準のスレッドセーフコレクションを選ぶ方が事故が減ります。
代表的な選択肢
| コレクション | 向いている用途 | 特徴 | 注意点 |
|---|---|---|---|
| ConcurrentQueue<T> | FIFOキュー | 複数スレッドからEnqueue/Dequeueが安全 | 任意位置のInsert/Removeはできない |
| ConcurrentStack<T> | LIFOスタック | Push/Popが安全 | 「先頭にInsert」のような操作はできない |
| BlockingCollection<T> | 生産者/消費者、待ち行列 | Takeで待機できる(ブロッキング) | 完了通知(CompleteAdding)など運用ルールが必要 |
| Channel<T> | 非同期処理のパイプライン | async/awaitと相性が良い | 学習コストが少し上がる |
| ImmutableList<T> | 読み取り中心、スナップショット | 変更は新しいリストとして生成(参照は不変) | 更新頻度が高いとGC負荷が上がる |
FIFOとLIFOが切り替わる設計なら、Listで無理に両方を兼ねるより、要件を整理して「実際はFIFOだけで良い」「LIFOだけで良い」とできないか見直すのが近道です。どうしても両対応が必要なら、内部表現をListにせず、用途ごとにコレクションを切り替える方が結果的に読みやすくなります。
ConcurrentQueue/ConcurrentStackを使った例
public sealed class PackagePipe
{
private readonly ConcurrentQueue<PackageLabel> _fifo = new ConcurrentQueue<PackageLabel>();
private readonly ConcurrentStack<PackageLabel> _lifo = new ConcurrentStack<PackageLabel>();
public void Add(PackageLabel item, L type)
{
if (type == L.FiFo) _fifo.Enqueue(item);
else _lifo.Push(item);
}
public bool TryDelete(out PackageLabel removed, L type)
{
if (type == L.FiFo) return _fifo.TryDequeue(out removed);
return _lifo.TryPop(out removed);
}
}
このアプローチは「先頭要素を消す」「先頭に入れる」といったList特有の操作を捨て、FIFOならQueue、LIFOならStackというデータ構造の基本に戻す形です。要件が許すなら、ロック設計が大幅に単純化します。
それでもList+lockを選ぶなら押さえるべき実務ポイント
既存コードとの互換や、任意位置の挿入・削除が必要で、どうしてもListを使いたいケースもあります。その場合は「lockを書いた」だけで安心せず、次のポイントを満たしているか確認してください。
| チェック項目 | OK例 | NG例 |
|---|---|---|
| ロックは1つに統一 | _sync だけを使う | Add用/Remove用でロックを分割 |
| ロック対象はprivate | private readonly object _sync | public object SyncRoot、lock(this) |
| 読み取りもロック | Countやlist[0]もlock内 | Countだけロック外で参照 |
| 列挙はスナップショット | ToArrayしてからforeach | lockなしでforeach、またはlock中に重い処理 |
| 境界条件を考慮 | 空ならfalseを返すTryパターン | 空でもRemoveAt(0)して例外 |
よくある落とし穴
- lockするオブジェクトを途中で差し替える(readonlyにしない)
- lock対象に文字列やTypeを使う(別の場所と偶然衝突する)
- Listそのものをlockする(外部が同じListをlockして予期せぬ待ちが発生)
- イベント発火やログ出力をロック内で行う(外部コードが絡みデッドロックしやすい)
- 「一部だけlock」になっている(例:追加はlock、削除はlock、検索はlockなし)
再現しにくい不具合を潰す:簡易ストレステストの作り方
スレッド競合は再現が難しいため、短時間で負荷を掛ける簡易テストを作っておくと安心です。例えば、複数タスクでAdd/Deleteを大量に回し、例外が出ないか、最終的なCountが想定範囲かを確認します。
var buffer = new PackageBuffer();
var tasks = new List<Task>();
for (int i = 0; i < 8; i++)
{
tasks.Add(Task.Run(() =>
{
for (int n = 0; n < 100_000; n++)
{
buffer.Add(new PackageLabel { QuantityPackage = n }, L.FiFo);
buffer.TryDelete(out _);
}
}));
}
Task.WaitAll(tasks.ToArray());
Console.WriteLine(buffer.Count);
本番相当のスレッド数・処理量に寄せるほど「たまに落ちる」系の不具合が見つかりやすくなります。ロック漏れがある場合、この手のテストで例外や不整合が表面化することが多いです。
まとめ:迷ったら「共有状態を1ロックで守る」か「Concurrentへ置き換える」
- List<T>は複数スレッドでの更新に対してスレッドセーフではない
- 同じListを守るならロックオブジェクトは1つに統一し、読み書きすべてを囲う
- Add用/Delete用にロックを分けると、同時更新を許してしまい逆効果
- Lazy<T>はシングルトン生成の安全性であり、内部List操作の安全性とは別問題
- 要件が合うならConcurrentQueue/ConcurrentStack/BlockingCollection/Channelなどを使うと設計が簡単になる
- 性能面でも、先頭Insert/Removeを多用するならデータ構造を見直す価値が高い

コメント