C# Listをスレッドセーフにする方法|lock設計とConcurrentQueue/BlockingCollectionの選び方

複数スレッドから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用でロックを分割
ロック対象はprivateprivate readonly object _syncpublic object SyncRoot、lock(this)
読み取りもロックCountやlist[0]もlock内Countだけロック外で参照
列挙はスナップショットToArrayしてからforeachlockなしで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を多用するならデータ構造を見直す価値が高い

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次