例外処理

【C#】DuplicateWaitObjectExceptionの原因と安全なWaitHandle配列の作り方

DuplicateWaitObjectExceptionは、WaitHandle配列に同一参照が複数含まれた状態でWaitAnyWaitAllを呼び出した際に発生し、スレッド同期が停止します。

重複を事前に除去するか、配列作成時にDistinctを挟むことで安全に回避できます。

目次から探す
  1. DuplicateWaitObjectExceptionとは
  2. WaitHandleの基礎
  3. 重複参照が生まれるケース
  4. WaitAnyとWaitAllの内部動作
  5. 例外発生時のスタックトレース分析
  6. 再現シナリオ
  7. 安全なWaitHandle配列の作り方
  8. 例外防止の設計アプローチ
  9. テストによる検証
  10. Async/Awaitとの併用注意点
  11. ロギングとモニタリング
  12. フレームワーク別挙動差
  13. マルチスレッド設計指針
  14. パフォーマンス測定
  15. 他の同期オブジェクトとの比較
  16. 運用フェーズでの対処フロー
  17. まとめ

DuplicateWaitObjectExceptionとは

C#でスレッド同期を行う際に利用されるWaitHandleクラスは、複数のスレッドや非同期処理の完了を待つために非常に便利です。

しかし、WaitHandleの配列を使ってWaitAllWaitAnyメソッドを呼び出す際に、同じWaitHandleオブジェクトが複数回含まれていると、DuplicateWaitObjectExceptionという例外が発生します。

このセクションでは、この例外の基本的な定義や発生条件、そしてArgumentExceptionとの関係について詳しく解説します。

例外の定義

DuplicateWaitObjectExceptionは、.NETのSystem名前空間に属する例外クラスで、ArgumentExceptionを継承しています。

この例外は、WaitHandleの配列に重複したオブジェクトが含まれている場合にスローされます。

具体的には、WaitHandle.WaitAllWaitHandle.WaitAnyメソッドに渡された配列内で、同じWaitHandleインスタンスが2回以上存在するときに発生します。

この例外は、スレッド同期の安全性を確保するために設けられており、同じ同期オブジェクトを複数回待機対象に含めることは、待機処理の意味を損なうため禁止されています。

以下は、DuplicateWaitObjectExceptionの基本的な特徴です。

  • 継承元:ArgumentException
  • 名前空間:System
  • 発生場所:WaitHandle.WaitAllWaitHandle.WaitAnyなどのメソッド呼び出し時
  • 目的:WaitHandle配列内の重複を検出し、同期処理の誤りを防止

発生条件と制約

DuplicateWaitObjectExceptionが発生する主な条件は、WaitHandleの配列に同一のインスタンスが複数回含まれていることです。

たとえば、以下のようなケースで発生します。

  • 同じManualResetEventAutoResetEventオブジェクトを複数回配列に追加している
  • 複数の変数が同じWaitHandleインスタンスを参照しているが、配列に重複して渡している
  • コレクション操作のミスで重複したWaitHandleが混入している

この例外は、WaitHandleの配列に重複があるかどうかを内部でチェックし、重複が見つかると即座にスローされます。

重複があると、WaitAllWaitAnyの動作が不定になるため、例外を発生させてプログラマに問題を知らせる仕組みです。

また、WaitHandleの配列の長さは最大64個までという制約もありますが、これは別の例外NotSupportedExceptionの対象です。

DuplicateWaitObjectExceptionはあくまで重複に関する例外です。

ArgumentExceptionとの関係

DuplicateWaitObjectExceptionArgumentExceptionの派生クラスであるため、ArgumentExceptionとしてもキャッチ可能です。

これは、WaitHandle配列の引数に不正があることを示す例外の一種です。

.NETの一部のプラットフォーム、例えばWindowsストアアプリやポータブルクラスライブラリ(PCL)では、DuplicateWaitObjectExceptionが存在しない場合があります。

その場合、重複したWaitHandleを渡すとArgumentExceptionがスローされることがあります。

したがって、クロスプラットフォーム対応のコードを書く場合は、DuplicateWaitObjectExceptionだけでなく、ArgumentExceptionもキャッチして例外処理を行うことが推奨されます。

以下は例外のキャッチ例です。

try
{
    WaitHandle.WaitAll(waitHandles);
}
catch (DuplicateWaitObjectException ex)
{
    Console.WriteLine("WaitHandle配列に重複が含まれています: " + ex.Message);
}
catch (ArgumentException ex)
{
    // 一部プラットフォームではDuplicateWaitObjectExceptionが存在しないためこちらで捕捉
    Console.WriteLine("引数に問題があります: " + ex.Message);
}

このように、DuplicateWaitObjectExceptionArgumentExceptionの一種であり、重複したWaitHandleを検出して同期処理の誤りを防ぐための重要な例外です。

WaitHandleの基礎

派生クラスと用途

WaitHandleは、スレッドやタスクの同期を行うための抽象基底クラスです。

これを直接使うことはほとんどなく、具体的な同期オブジェクトとして派生クラスを利用します。

主な派生クラスとその用途は以下の通りです。

クラス名用途・特徴
AutoResetEventシグナル状態がtrueになると、待機中のスレッドを1つだけ起こし、直後に自動的にfalseに戻ります。単一スレッドの通知に適しています。
ManualResetEventシグナル状態がtrueになると、待機中のすべてのスレッドを起こす。手動でfalseにリセットする必要があります。複数スレッドの通知に適しています。
Mutex排他制御に使います。複数プロセス間でも利用可能で、リソースの排他アクセスを保証します。
Semaphore複数のスレッドが同時にリソースを利用できる数を制限します。カウンタ付きの同期オブジェクト。
EventWaitHandleAutoResetEventManualResetEventの基底クラス。用途に応じてリセット動作を選択可能です。

これらのクラスはすべてWaitHandleを継承しており、WaitOneWaitAllWaitAnyなどのメソッドで待機処理を行います。

たとえば、AutoResetEventは1回のシグナルで1つのスレッドだけを起こすため、スレッド間の単純な通知に使われます。

一方、ManualResetEventはシグナル状態が維持されるため、複数のスレッドを同時に起こしたい場合に使います。

Mutexは、同一プロセス内だけでなく、名前付きミューテックスを使うことで複数プロセス間の排他制御も可能です。

Semaphoreは、同時にアクセスできるスレッド数を制限したい場合に利用します。

シグナル状態とスレッド同期

WaitHandleの基本的な仕組みは、内部に「シグナル状態」を持つことです。

このシグナル状態はtruefalseの2値で表され、trueのときに待機中のスレッドが起こされます。

  • シグナル状態がfalseの場合、WaitOneWaitAllなどの待機メソッドを呼び出したスレッドはブロックされ、シグナルがtrueになるまで待機します
  • シグナル状態がtrueになると、待機中のスレッドが起こされ、処理を再開します

AutoResetEventの場合、シグナル状態がtrueになると、待機中のスレッドが1つだけ起こされ、その直後にシグナル状態は自動的にfalseに戻ります。

これにより、1回のシグナルで1つのスレッドだけが通過できます。

一方、ManualResetEventはシグナル状態がtrueの間は、待機中のすべてのスレッドが起こされます。

シグナル状態をfalseに戻すのは明示的にResetメソッドを呼ぶ必要があります。

このシグナル状態の管理により、複数のスレッド間での同期や通知が可能になります。

たとえば、ある処理が完了したことを複数のスレッドに知らせたい場合は、ManualResetEventを使ってシグナル状態をtrueにし、すべての待機スレッドを起こします。

WaitHandleの待機メソッドは、以下のように動作します。

  • WaitOne():単一のWaitHandleがシグナル状態になるまで待機します
  • WaitAny(WaitHandle[] handles):複数のWaitHandleのうち、いずれか1つがシグナル状態になるまで待機します
  • WaitAll(WaitHandle[] handles):複数のWaitHandleすべてがシグナル状態になるまで待機します

これらのメソッドは、スレッドのブロックや再開を制御し、複雑な同期処理を実現します。

ただし、WaitAnyWaitAllに渡す配列に同じWaitHandleが複数含まれていると、DuplicateWaitObjectExceptionが発生します。

これは、同じ同期オブジェクトを複数回待機対象に含めることが意味をなさないためです。

シグナル状態の管理は、スレッド同期の根幹をなす重要な概念であり、適切に使うことで効率的なマルチスレッドプログラミングが可能になります。

重複参照が生まれるケース

DuplicateWaitObjectExceptionが発生する根本的な原因は、WaitHandleの配列に同一インスタンスが複数回含まれていることです。

ここでは、どのような状況で同じWaitHandleインスタンスが重複して配列に入ってしまうのか、具体的なケースを掘り下げていきます。

同一インスタンスを使い回す設計パターン

静的フィールド共有時の落とし穴

アプリケーション全体で共有する同期オブジェクトを静的フィールドとして定義し、複数の箇所から使い回す設計はよくあります。

たとえば、以下のようなコードです。

public static class SyncObjects
{
    public static readonly ManualResetEvent SharedEvent = new ManualResetEvent(false);
}

このSharedEventを複数の待機配列に追加すると、同じインスタンスが重複してしまうことがあります。

var waitHandles = new WaitHandle[]
{
    SyncObjects.SharedEvent,
    SyncObjects.SharedEvent // 重複している
};
WaitHandle.WaitAll(waitHandles); // ここでDuplicateWaitObjectExceptionが発生

静的フィールドを使うことで同期オブジェクトの共有は簡単になりますが、配列やコレクションに追加する際に重複チェックを怠ると、例外の原因になります。

特に大規模なコードベースや複数のモジュールが同じ静的オブジェクトを参照している場合、重複が見落とされやすいです。

コレクション追加処理の競合

動的にWaitHandleを管理するコレクションに対して、複数のスレッドや処理が同時に追加操作を行うと、重複が発生することがあります。

たとえば、以下のようなケースです。

List<WaitHandle> handles = new List<WaitHandle>();
void AddHandle(WaitHandle handle)
{
    if (!handles.Contains(handle))
    {
        handles.Add(handle);
    }
}

このコードは一見重複を防いでいるように見えますが、複数スレッドが同時にAddHandleを呼ぶと、Containsチェックのタイミングで重複が許されてしまう可能性があります。

スレッドセーフでないコレクション操作は、重複参照の温床になります。

このような場合は、lock文やConcurrentDictionaryConcurrentBagなどのスレッドセーフなコレクションを使い、重複チェックと追加処理を原子操作にする必要があります。

リファクタリング途中でのミス

コードのリファクタリングや機能追加の過程で、WaitHandleの管理方法が変わることがあります。

たとえば、以前は個別のWaitHandleを使っていたのに、途中で共有オブジェクトに切り替えた際に、古いコードの配列作成部分が修正されずに重複が混入することがあります。

また、メソッドの引数として渡されたWaitHandleをそのまま配列に追加する処理が複数箇所に分散していると、どこかで同じインスタンスが重複して追加されるミスが起きやすいです。

リファクタリング時は、WaitHandleの配列やコレクションを生成するロジックを一元化し、重複チェックを組み込むことが重要です。

そうしないと、意図しない重複が発生し、DuplicateWaitObjectExceptionの原因になります。

コード自動生成による重複

コード自動生成ツールやテンプレートを使ってWaitHandleの配列を作成している場合、同じWaitHandleが複数回生成されてしまうことがあります。

特に、複数の条件分岐や設定ファイルに基づいて自動的に同期オブジェクトを追加する処理では、重複チェックが不十分だと同じインスタンスが複数回配列に含まれます。

たとえば、以下のような自動生成コードの断片です。

var handles = new List<WaitHandle>();
if (config.EnableFeatureA)
{
    handles.Add(sharedEvent);
}
if (config.EnableFeatureB)
{
    handles.Add(sharedEvent); // 条件によっては重複追加される
}

このような場合、生成ロジックに重複排除の仕組みを組み込むか、生成後にDistinctなどで重複を除去する処理を入れる必要があります。

自動生成コードは人間の目でのチェックが難しいため、重複参照の原因になりやすい点に注意が必要です。

特に大規模プロジェクトや複雑な設定を扱う場合は、重複検出の自動化も検討してください。

WaitAnyとWaitAllの内部動作

Win32 APIとの連携

C#のWaitHandleクラスのWaitAnyWaitAllメソッドは、内部的にWindowsのネイティブAPIであるWaitForMultipleObjects関数を利用して実装されています。

この関数は、複数の同期オブジェクト(ハンドル)を一括で待機し、いずれかがシグナル状態になるか、すべてがシグナル状態になるまでスレッドをブロックします。

WaitForMultipleObjectsは以下のような特徴を持っています。

  • 最大64個までのハンドルを同時に待機可能(これが.NETのWaitAnyWaitAllの配列サイズ制限の根拠です)
  • bWaitAllパラメータで「すべてのハンドルがシグナル状態になるまで待つ」か「いずれか1つがシグナル状態になるまで待つ」かを指定
  • 待機中にシグナル状態になったハンドルのインデックスを返す(WaitAnyの場合)
  • タイムアウトやエラーも検出可能

C#のWaitHandleは、このWin32 APIのラッパーとして動作しており、配列に含まれる各WaitHandleの内部ハンドルSafeWaitHandleを取得して、WaitForMultipleObjectsに渡します。

このため、WaitHandle配列に同じインスタンスが複数含まれていると、同じネイティブハンドルが複数回渡されることになり、Win32 API側でエラーが発生します。

これがDuplicateWaitObjectExceptionの発生原因です。

また、WaitForMultipleObjectsはWindows固有のAPIであるため、.NET Coreや.NET 5以降のクロスプラットフォーム環境では、内部実装が異なる場合がありますが、概念的には同様の同期処理を行っています。

登録順序とハンドル配列の評価

WaitAnyWaitAllに渡すWaitHandle配列の順序は、待機結果の判定に影響します。

特にWaitAnyの場合、複数のハンドルが同時にシグナル状態になったとき、戻り値として返されるインデックスは配列内で最も先に登録されたハンドルのものになります。

たとえば、以下のような配列があるとします。

WaitHandle[] handles = new WaitHandle[] { handleA, handleB, handleC };

このとき、handleBhandleCが同時にシグナル状態になった場合、WaitAnyhandleBのインデックス(1)を返します。

これは、配列の登録順序が優先されるためです。

一方、WaitAllはすべてのハンドルがシグナル状態になるまで待機するため、順序は待機の成否には影響しませんが、内部的には配列の順にハンドルを評価していきます。

この登録順序の特性を理解しておくことは、待機処理の結果を正しく解釈するうえで重要です。

特に複数の同期オブジェクトが同時にシグナル状態になる可能性がある場合、配列の順序を意図的に設計することで、処理の優先度を制御できます。

また、配列に重複したWaitHandleが含まれていると、前述の通りWin32 APIがエラーを返すため、DuplicateWaitObjectExceptionが発生します。

したがって、配列の作成時には重複を排除し、順序を明確に管理することが重要です。

例外発生時のスタックトレース分析

典型的なメッセージ内容

DuplicateWaitObjectExceptionが発生した際の例外メッセージは、問題の原因を特定するうえで非常に重要です。

典型的なメッセージは以下のような内容になります。

「同じ WaitHandleオブジェクトが複数回配列に含まれています」

または

「Duplicate wait handle detected in the array of wait handles.」

このメッセージは、WaitHandle.WaitAllWaitHandle.WaitAnyに渡された配列内に、同一のWaitHandleインスタンスが2回以上含まれていることを示しています。

メッセージは例外のMessageプロパティで取得でき、ログやデバッグ出力に記録されることが多いです。

スタックトレースは、例外が発生したメソッドの呼び出し履歴を示します。

DuplicateWaitObjectExceptionの場合、スタックトレースの中で以下のようなメソッドがよく見られます。

  • System.Threading.WaitHandle.WaitAll(WaitHandle[] waitHandles)
  • System.Threading.WaitHandle.WaitAny(WaitHandle[] waitHandles)
  • ユーザーコード内の、これらのメソッドを呼び出している箇所

スタックトレースの例:

at System.Threading.WaitHandle.WaitAll(WaitHandle[] waitHandles)
at MyApp.Program.Main(String[] args)

このように、例外が発生した箇所が明確にわかるため、配列を作成しているコードやWaitAll/WaitAnyを呼び出している部分を重点的に調査します。

NullReferenceExceptionとの識別ポイント

WaitHandle関連の同期処理で例外が発生した場合、DuplicateWaitObjectExceptionNullReferenceExceptionが混同されることがあります。

両者は原因も対処法も異なるため、識別が重要です。

  • DuplicateWaitObjectException
    • 原因:WaitHandle配列に同じインスタンスが複数含まれている
    • 例外メッセージに「duplicate」や「重複」が含まれる
    • スタックトレースはWaitHandle.WaitAllWaitAnyの呼び出し直後に発生
    • 配列の中身がnullであっても発生しない(重複が原因)
  • NullReferenceException
    • 原因:WaitHandle配列の中にnullが含まれている、またはWaitHandleの内部状態が不正
    • 例外メッセージは「オブジェクト参照がオブジェクトのインスタンスに設定されていません」など一般的なもの
    • スタックトレースはWaitHandleの内部処理やユーザーコードのどこかでnull参照が発生した箇所を示す
    • 配列の中身にnullが含まれている場合は、ArgumentNullExceptionが発生することもある

識別のポイントは、例外メッセージとスタックトレースの内容をよく確認することです。

DuplicateWaitObjectExceptionは重複に特化した例外であり、メッセージに重複の指摘があるため比較的わかりやすいです。

一方、NullReferenceExceptionは同期オブジェクトの不正な参照が原因であり、配列の中身やオブジェクトの初期化状態を重点的に調査する必要があります。

また、WaitHandle配列を作成する際は、重複チェックだけでなくnullチェックも同時に行うことが、例外の早期発見と安定した同期処理の実現につながります。

再現シナリオ

シンプルなサンプル構成

DuplicateWaitObjectExceptionの発生をシンプルに再現するためのサンプルコードを示します。

この例では、同じManualResetEventインスタンスをWaitHandle配列に重複して追加し、WaitAllメソッドを呼び出すことで例外を発生させます。

using System;
using System.Threading;
class Program
{
    static void Main()
    {
        // ManualResetEventを1つだけ作成
        ManualResetEvent manualEvent = new ManualResetEvent(false);
        // 同じインスタンスを2回配列に追加(重複)
        WaitHandle[] waitHandles = new WaitHandle[] { manualEvent, manualEvent };
        try
        {
            // WaitAllで待機(ここでDuplicateWaitObjectExceptionが発生)
            Console.WriteLine("WaitAllを開始します。");
            WaitHandle.WaitAll(waitHandles);
        }
        catch (DuplicateWaitObjectException ex)
        {
            Console.WriteLine("例外発生: " + ex.Message);
        }
    }
}
WaitAllを開始します。
例外発生: Duplicate objects in argument.

このコードでは、manualEventを2回配列に入れているため、WaitHandle.WaitAllが呼ばれた時点でDuplicateWaitObjectExceptionがスローされます。

配列内の重複が原因であることがメッセージからもわかります。

このように、単純に同じWaitHandleを複数回配列に含めるだけで例外が発生するため、配列作成時の重複チェックが重要です。

非同期処理と組み合わせた例

非同期処理や複数スレッドでWaitHandleを扱う場合も、同じWaitHandleが重複して配列に含まれることがあります。

以下は、非同期タスクの完了を待つためにManualResetEventを使い、誤って同じインスタンスを複数回配列に追加してしまう例です。

using System;
using System.Threading;
using System.Threading.Tasks;
class Program
{
    static ManualResetEvent sharedEvent = new ManualResetEvent(false);
    static async Task Main()
    {
        // 非同期タスクを2つ開始
        Task task1 = Task.Run(() =>
        {
            Thread.Sleep(1000);
            Console.WriteLine("Task1完了");
            sharedEvent.Set(); // シグナルをセット
        });
        Task task2 = Task.Run(() =>
        {
            Thread.Sleep(1500);
            Console.WriteLine("Task2完了");
            sharedEvent.Set(); // 同じイベントをセット
        });
        // 同じManualResetEventを2回配列に追加(重複)
        WaitHandle[] waitHandles = new WaitHandle[] { sharedEvent, sharedEvent };
        try
        {
            Console.WriteLine("WaitAnyを開始します。");
            // WaitAnyでいずれかのタスク完了を待つ
            int signaledIndex = WaitHandle.WaitAny(waitHandles);
            Console.WriteLine($"シグナルを受け取ったインデックス: {signaledIndex}");
        }
        catch (DuplicateWaitObjectException ex)
        {
            Console.WriteLine("例外発生: " + ex.Message);
        }
        await Task.WhenAll(task1, task2);
    }
}
WaitAnyを開始します。
例外発生: 同じ WaitHandle オブジェクトが複数回配列に含まれています。
Task1完了
Task2完了

この例では、非同期タスクが完了したことをManualResetEventで通知しようとしていますが、WaitAnyに渡す配列に同じsharedEventが2回含まれているため、DuplicateWaitObjectExceptionが発生します。

非同期処理と同期オブジェクトを組み合わせる場合は、WaitHandleの重複に注意し、配列作成時に重複を排除することが必要です。

特に共有の同期オブジェクトを複数の待機対象に含める設計は避けるべきです。

安全なWaitHandle配列の作り方

DuplicateWaitObjectExceptionを防ぐためには、WaitHandle配列に同じインスタンスが重複して含まれないようにすることが重要です。

ここでは、重複を排除しつつ安全にWaitHandle配列を作成する代表的な方法を紹介します。

Distinct を挟む方法

LINQのDistinctメソッドを使うと、簡単に重複を除去した配列を作成できます。

例えば、以下のように書きます。

using System;
using System.Linq;
using System.Threading;
class Program
{
    static void Main()
    {
        ManualResetEvent ev1 = new ManualResetEvent(false);
        ManualResetEvent ev2 = new ManualResetEvent(false);
        // 重複を含む配列
        WaitHandle[] originalHandles = new WaitHandle[] { ev1, ev2, ev1 };
        // Distinctで重複を除去
        WaitHandle[] distinctHandles = originalHandles.Distinct().ToArray();
        Console.WriteLine("元の配列の長さ: " + originalHandles.Length);
        Console.WriteLine("重複除去後の配列の長さ: " + distinctHandles.Length);
    }
}
元の配列の長さ: 3
重複除去後の配列の長さ: 2

この方法はコードがシンプルでわかりやすく、すぐに導入できます。

ただし、Distinctは内部でハッシュセットを使って重複を判定するため、配列のサイズが大きくなるとパフォーマンスに影響が出る可能性があります。

LINQのパフォーマンス考慮

Distinctは便利ですが、頻繁に大量のWaitHandleを扱う場合はパフォーマンスに注意が必要です。

特にリアルタイム性が求められる環境や高頻度で配列を生成する場合は、Distinctの呼び出しコストが無視できなくなります。

その場合は、Distinctを使う代わりに、HashSet<WaitHandle>を使って重複を排除しながら配列を作成する方法が有効です。

HashSet<WaitHandle> への置き換え

HashSet<WaitHandle>は重複を許さないコレクションなので、追加時に自動的に重複を排除できます。

以下はHashSetを使った例です。

using System;
using System.Collections.Generic;
using System.Threading;
class Program
{
    static void Main()
    {
        ManualResetEvent ev1 = new ManualResetEvent(false);
        ManualResetEvent ev2 = new ManualResetEvent(false);
        HashSet<WaitHandle> handleSet = new HashSet<WaitHandle>();
        // 重複してもHashSetが排除
        handleSet.Add(ev1);
        handleSet.Add(ev2);
        handleSet.Add(ev1);
        WaitHandle[] handles = new WaitHandle[handleSet.Count];
        handleSet.CopyTo(handles);
        Console.WriteLine("HashSetの要素数: " + handleSet.Count);
        Console.WriteLine("配列の長さ: " + handles.Length);
    }
}
HashSetの要素数: 2
配列の長さ: 2

HashSetを使うことで、重複チェックと追加処理を一度に行えるため、パフォーマンス面でも優れています。

スレッドセーフな操作が必要な場合は、ConcurrentDictionaryなどのスレッドセーフコレクションを検討してください。

ヘルパーメソッドで検査

重複を排除するだけでなく、配列作成前に重複がないか検査して例外を投げるなど、より厳密に管理したい場合はヘルパーメソッドを作成すると便利です。

using System;
using System.Collections.Generic;
using System.Threading;
class WaitHandleHelper
{
    public static void ValidateNoDuplicates(WaitHandle[] handles)
    {
        if (handles == null) throw new ArgumentNullException(nameof(handles));
        HashSet<WaitHandle> seen = new HashSet<WaitHandle>();
        foreach (var handle in handles)
        {
            if (handle == null)
                throw new ArgumentNullException("配列内にnullのWaitHandleが含まれています。");
            if (!seen.Add(handle))
                throw new ArgumentException("WaitHandle配列に重複が含まれています。");
        }
    }
}

このメソッドを使うことで、配列に重複やnullが含まれていないかを事前に検査し、問題があれば早期に例外を発生させられます。

ガード句による早期検出

呼び出し元でこの検査メソッドを使う際は、ガード句として早期に問題を検出し、後続のWaitAllWaitAny呼び出しでの例外発生を防ぎます。

using System;
using System.Threading;
class Program
{
    static void Main()
    {
        ManualResetEvent ev1 = new ManualResetEvent(false);
        ManualResetEvent ev2 = new ManualResetEvent(false);
        WaitHandle[] handles = new WaitHandle[] { ev1, ev2, ev1 };
        try
        {
            WaitHandleHelper.ValidateNoDuplicates(handles);
            WaitHandle.WaitAll(handles);
        }
        catch (ArgumentException ex)
        {
            Console.WriteLine("検査で例外発生: " + ex.Message);
        }
    }
}
検査で例外発生: WaitHandle配列に重複が含まれています。

このように、配列作成時に重複を検査しておくことで、DuplicateWaitObjectExceptionの発生を未然に防ぎ、例外処理の場所を明確にできます。

安全な同期処理のために、こうしたガード句の導入はおすすめです。

例外防止の設計アプローチ

DuplicateWaitObjectExceptionの発生を根本的に防ぐためには、コード設計の段階でWaitHandleの重複参照を避ける仕組みを取り入れることが重要です。

ここでは、代表的な設計アプローチとして「Immutableパターンの導入」「Factoryメソッドでの生成管理」「DIコンテナ利用時の注意」について解説します。

Immutableパターンの導入

WaitHandleの配列やコレクションを不変(Immutable)に設計することで、意図しない重複追加や変更を防止できます。

Immutableパターンとは、一度作成したオブジェクトの状態を変更できないようにする設計手法です。

具体的には、WaitHandleの配列を作成したら、その配列を外部から変更できないようにし、重複チェックを済ませた状態で固定します。

これにより、後から同じWaitHandleを誤って追加するリスクが減ります。

例えば、以下のようにReadOnlyCollection<WaitHandle>を使う方法があります。

using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
class ImmutableWaitHandles
{
    public ReadOnlyCollection<WaitHandle> Handles { get; }
    public ImmutableWaitHandles(WaitHandle[] handles)
    {
        if (handles == null) throw new ArgumentNullException(nameof(handles));
        if (handles.Length == 0) throw new ArgumentException("配列は空にできません。");
        // 重複チェック
        if (handles.Distinct().Count() != handles.Length)
            throw new ArgumentException("WaitHandle配列に重複が含まれています。");
        Handles = Array.AsReadOnly(handles);
    }
}

class Program
{
    static void Main()
    {
        try
        {
            // 正常なWaitHandle配列を作成
            var waitHandles = new WaitHandle[]
            {
                new AutoResetEvent(false),
                new ManualResetEvent(false)
            };

            var immutableHandles = new ImmutableWaitHandles(waitHandles);
            Console.WriteLine("ImmutableWaitHandlesインスタンスが正常に作成されました。");
            Console.WriteLine($"WaitHandlesの数: {immutableHandles.Handles.Count}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"例外発生: {ex.Message}");
        }

        try
        {
            // 重複のあるWaitHandle配列(同じインスタンスを2つ追加)
            var sharedHandle = new AutoResetEvent(false);
            var duplicateHandles = new WaitHandle[]
            {
                sharedHandle,
                sharedHandle
            };

            var invalidImmutableHandles = new ImmutableWaitHandles(duplicateHandles);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"重複チェックで例外発生: {ex.Message}");
        }
    }
}
ImmutableWaitHandlesインスタンスが正常に作成されました。
WaitHandlesの数: 2
重複チェックで例外発生: WaitHandle配列に重複が含まれています。

このクラスを使うと、Handlesプロパティは読み取り専用であり、外部からの変更や重複追加を防げます。

Immutableな設計は、スレッドセーフ性も向上させるため、マルチスレッド環境での同期処理に適しています。

Factoryメソッドでの生成管理

WaitHandleの配列やコレクションを直接生成するのではなく、専用のFactoryメソッドやクラスを用意して生成を一元管理する方法も効果的です。

Factoryメソッド内で重複チェックやnullチェックを行い、問題があれば例外を投げることで、呼び出し側のミスを防止します。

以下はFactoryメソッドの例です。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
class WaitHandleFactory
{
    public static WaitHandle[] Create(params WaitHandle[] handles)
    {
        if (handles == null) throw new ArgumentNullException(nameof(handles));
        if (handles.Any(h => h == null))
            throw new ArgumentException("配列にnullのWaitHandleが含まれています。");
        if (handles.Distinct().Count() != handles.Length)
            throw new ArgumentException("WaitHandle配列に重複が含まれています。");
        return handles.ToArray();
    }
}

呼び出し側はこのFactoryメソッドを使って配列を生成します。

var handles = WaitHandleFactory.Create(ev1, ev2, ev3);

この方法により、配列生成時の重複や不正な値を一括管理でき、コードの保守性と安全性が向上します。

DIコンテナ利用時の注意

依存性注入(DI)コンテナを利用してWaitHandleや同期オブジェクトを管理する場合、同じインスタンスが複数のコンポーネントに注入されることがあります。

これが原因で、WaitHandle配列に重複が生じるリスクがあります。

特にシングルトンやスコープ付きのライフサイクルで同期オブジェクトを登録している場合、複数の依存先で同じインスタンスが共有されるため、配列に重複して含まれる可能性が高まります。

対策としては以下の点に注意してください。

  • ライフサイクルの設計

同期オブジェクトは必要に応じてスコープやトランジェントに設定し、重複参照を避けます。

  • 配列生成時の重複チェック

DIコンテナから取得したWaitHandleを配列にまとめる際に、重複を排除する処理を必ず入れる。

  • 明示的なインスタンス管理

共有すべき同期オブジェクトは明示的に管理し、どのコンポーネントがどのインスタンスを使うかをドキュメント化します。

  • カスタムファクトリの利用

DIコンテナのカスタムファクトリやビルダーを使い、配列生成時に重複チェックを組み込みます。

これらの注意点を守ることで、DIコンテナ利用時のDuplicateWaitObjectException発生リスクを低減できます。

特に大規模なアプリケーションや複雑な依存関係がある場合は、同期オブジェクトのライフサイクルと共有範囲を明確に設計することが重要です。

テストによる検証

単体テストケースの設計

DuplicateWaitObjectExceptionの発生を防ぐためには、単体テストで重複参照の検出や例外発生の有無を検証することが重要です。

単体テストケースは、正常系と異常系の両方をカバーし、同期処理の安全性を確保します。

正常系テスト

  • 重複のない配列で正常に待機できることを確認する
using System;
using System.Threading;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class WaitHandleTests
{
    [TestMethod]
    public void WaitAll_NoDuplicates_DoesNotThrow()
    {
        ManualResetEvent ev1 = new ManualResetEvent(false);
        ManualResetEvent ev2 = new ManualResetEvent(false);
        WaitHandle[] handles = new WaitHandle[] { ev1, ev2 };
        // シグナルをセットして待機が成功することを確認
        ev1.Set();
        ev2.Set();
        WaitHandle.WaitAll(handles); // 例外が発生しなければテスト成功
    }
}

異常系テスト

  • 重複したWaitHandleを含む配列でDuplicateWaitObjectExceptionが発生することを検証
[TestMethod]
[ExpectedException(typeof(DuplicateWaitObjectException))]
public void WaitAll_WithDuplicates_ThrowsDuplicateWaitObjectException()
{
    ManualResetEvent ev = new ManualResetEvent(false);
    WaitHandle[] handles = new WaitHandle[] { ev, ev };
    WaitHandle.WaitAll(handles); // 例外発生を期待
}
  • nullを含む配列でArgumentNullExceptionが発生することを検証
[TestMethod]
[ExpectedException(typeof(ArgumentNullException))]
public void WaitAll_WithNull_ThrowsArgumentNullException()
{
    ManualResetEvent ev = new ManualResetEvent(false);
    WaitHandle[] handles = new WaitHandle[] { ev, null };
    WaitHandle.WaitAll(handles); // 例外発生を期待
}

これらの単体テストを実装することで、重複やnullの混入を早期に検出し、例外発生の原因を明確にできます。

テストはCI/CDパイプラインに組み込み、継続的に品質を担保することが望ましいです。

ストレステストでの確認

単体テストに加えて、実際の運用環境に近い条件でストレステストを行い、WaitHandle配列の生成や待機処理が大量かつ高速に繰り返されても問題が起きないかを検証します。

ストレステストのポイント

  • 大量のWaitHandleを動的に生成し、重複排除処理の性能を測定する
  • 複数スレッドから同時にWaitHandle配列を作成し、重複チェックのスレッドセーフ性を確認する
  • WaitAllWaitAnyの呼び出しを高頻度で行い、例外発生やパフォーマンス劣化がないかを監視する

ストレステストのサンプルコード例

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
class StressTest
{
    static void Main()
    {
        const int iterations = 10000;
        ManualResetEvent ev = new ManualResetEvent(true);
        Stopwatch sw = Stopwatch.StartNew();
        Parallel.For(0, iterations, i =>
        {
            // 重複を含む配列を作成
            WaitHandle[] handles = new WaitHandle[] { ev, ev };
            try
            {
                // 重複チェックを行い例外をキャッチ
                ValidateNoDuplicates(handles);
            }
            catch (ArgumentException)
            {
                // 例外は想定内なので無視
            }
        });
        sw.Stop();
        Console.WriteLine($"ストレステスト完了: {iterations}回の検査に {sw.ElapsedMilliseconds} ms");
    }
    static void ValidateNoDuplicates(WaitHandle[] handles)
    {
        HashSet<WaitHandle> set = new HashSet<WaitHandle>();
        foreach (var h in handles)
        {
            if (!set.Add(h))
                throw new ArgumentException("重複したWaitHandleが含まれています。");
        }
    }
}
ストレステスト完了: 10000回の検査に 29 ms

このようなストレステストを実施することで、重複検査の処理が大量の呼び出しに耐えられるか、スレッド競合が発生しないかを確認できます。

ストレステストは、実際の運用条件に近い負荷を想定して設計し、パフォーマンスのボトルネックや例外発生の兆候を早期に発見することが目的です。

これにより、DuplicateWaitObjectExceptionの発生リスクを低減し、安定した同期処理を実現できます。

Async/Awaitとの併用注意点

Task.WaitAll と Task.WhenAll の違い

C#の非同期プログラミングでよく使われるTask.WaitAllTask.WhenAllは、複数のTaskの完了を待つためのメソッドですが、その動作や使い方に大きな違いがあります。

これらの違いを理解しないと、WaitHandleを使った同期処理と組み合わせた際に問題が発生することがあります。

  • Task.WaitAll
    • 同期的に複数のTaskの完了を待機します。呼び出しスレッドはブロックされます
    • 内部的にWaitHandleを使って待機しているため、WaitHandle配列に重複があるとDuplicateWaitObjectExceptionが発生する可能性があります
    • UIスレッドやシングルスレッドコンテキストでの使用はデッドロックの原因になることがあるため注意が必要です
  • Task.WhenAll
    • 非同期的に複数のTaskの完了を待ちます。戻り値はTaskであり、awaitで待機可能です
    • 呼び出しスレッドをブロックしないため、UIスレッドや非同期コンテキストで安全に使えます
    • 内部でWaitHandleを直接使わず、Taskの完了通知をイベントベースで処理するため、DuplicateWaitObjectExceptionの影響を受けません

以下は簡単な使い分け例です。

using System;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        Task t1 = Task.Delay(1000);
        Task t2 = Task.Delay(1500);
        // Task.WaitAllは同期的に待機(ブロック)
        Task.WaitAll(t1, t2);
        Console.WriteLine("Task.WaitAll完了");
        // Task.WhenAllは非同期的に待機(await可能)
        await Task.WhenAll(t1, t2);
        Console.WriteLine("Task.WhenAll完了");
    }
}
Task.WaitAll完了
Task.WhenAll完了

Task.WaitAllWaitHandleを使うため、WaitHandle配列の重複に注意が必要ですが、Task.WhenAllは非同期処理の流れに沿った安全な待機方法です。

非同期コードでは基本的にTask.WhenAllを使うことが推奨されます。

CancellationToken 併用時の挙動

CancellationTokenは非同期処理や待機処理をキャンセル可能にするための仕組みです。

WaitHandleTaskと組み合わせて使うことが多いですが、併用時にはいくつか注意点があります。

  • WaitHandle.WaitAnyWaitHandle.WaitAllでのキャンセルトークンの利用

CancellationTokenは内部にWaitHandleを持っており、キャンセル要求が発生するとシグナル状態になります。

これをWaitAnyの待機対象に含めることで、キャンセルを検知可能です。

using System;
using System.Threading;
class Program
{
    static void Main()
    {
        CancellationTokenSource cts = new CancellationTokenSource();
        ManualResetEvent manualEvent = new ManualResetEvent(false);
        WaitHandle[] handles = new WaitHandle[] { manualEvent, cts.Token.WaitHandle };
        // 別スレッドでキャンセルを発生させる
        new Thread(() =>
        {
            Thread.Sleep(1000);
            cts.Cancel();
        }).Start();
        int signaledIndex = WaitHandle.WaitAny(handles);
        if (signaledIndex == 1)
        {
            Console.WriteLine("キャンセルが発生しました。");
        }
        else
        {
            Console.WriteLine("manualEventがシグナル状態になりました。");
        }
    }
}
キャンセルが発生しました。
  • TaskCancellationTokenの組み合わせ

Taskの非同期処理にCancellationTokenを渡すことで、キャンセル可能なタスクを作成できます。

Task.WhenAllと組み合わせる場合、いずれかのタスクがキャンセルされると例外がスローされるため、例外処理を適切に行う必要があります。

  • 注意点
    • CancellationToken.WaitHandleWaitHandle配列に含める際は、重複しないように注意してください
    • キャンセル処理が発生した場合、WaitAnyはキャンセルトークンのWaitHandleのインデックスを返すため、呼び出し側で適切に判定する必要があります
    • 非同期コードではCancellationTokenを使ったキャンセルはTaskのキャンセルパターンと連携させることが多く、WaitHandleを直接使うケースは限定的です

まとめると、CancellationTokenを使う場合は、WaitHandle配列にキャンセルトークンのWaitHandleを含めてキャンセル検知を行うことができますが、重複やインデックス判定に注意が必要です。

非同期コードではTaskCancellationTokenを組み合わせ、Task.WhenAllを使うのが安全で推奨される方法です。

ロギングとモニタリング

ハンドルIDの出力方法

DuplicateWaitObjectExceptionの原因調査やトラブルシューティングを行う際、WaitHandleの識別情報をログに出力することは非常に有効です。

WaitHandle自体はオブジェクトですが、内部的にネイティブのハンドルSafeWaitHandleを持っており、このハンドルのIDをログに記録することで、どの同期オブジェクトが重複しているかを特定しやすくなります。

C#でWaitHandleのハンドルIDを取得するには、SafeWaitHandleDangerousGetHandle()メソッドを使います。

以下はハンドルIDをログに出力する例です。

using System;
using System.Threading;
using Microsoft.Win32.SafeHandles;
class Program
{
    static void LogWaitHandleInfo(WaitHandle handle, string name)
    {
        SafeWaitHandle safeHandle = handle.SafeWaitHandle;
        IntPtr handleId = safeHandle.DangerousGetHandle();
        Console.WriteLine($"{name}: Handle ID = 0x{handleId.ToInt64():X}");
    }
    static void Main()
    {
        ManualResetEvent ev1 = new ManualResetEvent(false);
        ManualResetEvent ev2 = new ManualResetEvent(false);
        LogWaitHandleInfo(ev1, "ev1");
        LogWaitHandleInfo(ev2, "ev2");
        LogWaitHandleInfo(ev1, "ev1 (再度)");
    }
}
ev1: Handle ID = 0x2B0
ev2: Handle ID = 0x2B4
ev1 (再度): Handle ID = 0x2B0

このように、同じWaitHandleインスタンスは同じハンドルIDを持つため、ログを比較することで重複しているオブジェクトを特定できます。

特に例外発生時に配列内の全WaitHandleのハンドルIDを出力しておくと、どのオブジェクトが重複しているかを迅速に把握できます。

注意点として、DangerousGetHandle()は名前の通り「危険」なメソッドであり、ハンドルのライフサイクル管理に注意が必要です。

単にIDを取得してログに出すだけなら問題ありませんが、ハンドルの解放や操作は慎重に行ってください。

例外頻度の可視化

DuplicateWaitObjectExceptionが頻繁に発生すると、アプリケーションの安定性に影響を与えます。

例外の発生頻度を可視化し、問題の傾向や発生箇所を把握することは、早期対応や根本原因の特定に役立ちます。

ロギングによる頻度集計

例外発生時にログに詳細情報を記録し、ログ解析ツールやダッシュボードで集計します。

ログには以下の情報を含めると効果的です。

  • 発生日時
  • 例外メッセージ
  • スタックトレース
  • 重複しているWaitHandleのハンドルIDや名前
  • 呼び出し元のコンテキスト情報(スレッドID、ユーザーIDなど)

これらの情報を集約し、時間帯別や機能別の発生頻度をグラフ化することで、問題のホットスポットを特定できます。

アプリケーションパフォーマンス管理(APM)ツールの活用

New Relic、Application Insights、DatadogなどのAPMツールを導入すると、例外の発生頻度や影響範囲をリアルタイムで監視できます。

これらのツールは例外の種類ごとに集計し、アラート設定も可能です。

カスタムメトリクスの実装例

アプリケーション内で例外発生時にカウンターをインクリメントし、定期的に監視システムに送信する方法もあります。

using System;
using System.Threading;
class ExceptionMonitor
{
    private static int duplicateWaitObjectExceptionCount = 0;
    public static void LogDuplicateWaitObjectException(Exception ex)
    {
        if (ex is DuplicateWaitObjectException)
        {
            Interlocked.Increment(ref duplicateWaitObjectExceptionCount);
            Console.WriteLine($"DuplicateWaitObjectException発生回数: {duplicateWaitObjectExceptionCount}");
            // ここで外部監視システムへ送信する処理を追加可能
        }
    }
}

このように、例外の発生頻度を数値化して可視化することで、問題の深刻度を把握しやすくなります。

ロギングとモニタリングを適切に行うことで、DuplicateWaitObjectExceptionの発生原因を迅速に特定し、再発防止策を講じることが可能です。

特にハンドルIDの出力と例外頻度の可視化は、運用フェーズでのトラブルシューティングに欠かせない手法です。

フレームワーク別挙動差

.NET Framework 4.x

.NET Framework 4.xはWindows向けのフル機能を持つフレームワークであり、WaitHandleDuplicateWaitObjectExceptionの動作はWindowsのネイティブAPIに密接に依存しています。

WaitHandle.WaitAllWaitAnyは内部的にWin32のWaitForMultipleObjectsを呼び出し、配列内に同じWaitHandleが複数含まれている場合は即座にDuplicateWaitObjectExceptionをスローします。

特徴としては以下の通りです。

  • 例外の発生タイミングが早い

配列の重複チェックはWaitAllWaitAnyの呼び出し直後に行われ、重複があれば即座に例外が発生します。

  • 例外メッセージが明確

例外メッセージは「同じ WaitHandleオブジェクトが複数回配列に含まれています」といった具体的な内容で、原因特定がしやすいです。

  • 最大64個のハンドル制限

WaitForMultipleObjectsの制限により、待機対象のWaitHandleは最大64個までです。

これを超えると別の例外が発生します。

  • Windows専用の挙動

WindowsのネイティブAPIに依存しているため、クロスプラットフォーム対応はされていません。

.NET Core / .NET 5+

.NET Coreおよび.NET 5以降はクロスプラットフォーム対応が強化されており、WindowsだけでなくLinuxやmacOSでも動作します。

WaitHandleの実装はプラットフォームごとに異なり、Windowsでは引き続きWaitForMultipleObjectsを利用しますが、LinuxやmacOSではepollkqueueなどのネイティブ機構を使って待機処理を実装しています。

このため、以下のような挙動差があります。

  • 例外の種類と発生タイミング

Windows環境では.NET Frameworkと同様にDuplicateWaitObjectExceptionがスローされますが、Linux/macOS環境ではArgumentExceptionがスローされる場合があります。

これはプラットフォーム固有の実装差によるものです。

  • 例外メッセージの違い

メッセージ内容がやや異なることがあり、重複を示す文言が明確でない場合もあります。

例外の型で判別することが推奨されます。

  • 最大待機数の制限

Windows同様に64個程度の制限がありますが、Linux/macOSでは内部実装により異なる場合があるため、ドキュメントや実装を確認する必要があります。

  • クロスプラットフォーム対応の注意点

例外処理コードはDuplicateWaitObjectExceptionだけでなく、ArgumentExceptionもキャッチするようにしておくと安全です。

Xamarin / Unity 環境

XamarinやUnityはモバイルやゲーム開発向けのプラットフォームであり、.NETのサブセットや独自のランタイムを利用しています。

これらの環境ではWaitHandleのサポート状況や例外の挙動に制限や差異があります。

  • Xamarin
    • iOSやAndroid向けに最適化されており、WaitHandleの一部機能が制限されている場合があります
    • DuplicateWaitObjectExceptionが存在しない場合もあり、重複があるとArgumentExceptionがスローされることがあります
    • ネイティブAPIの制約により、待機処理の実装が異なるため、例外の発生タイミングやメッセージが異なることがあります
  • Unity
    • UnityのMonoランタイムはフルな.NET互換ではなく、WaitHandleの機能が限定的です
    • DuplicateWaitObjectExceptionがサポートされていない場合が多く、重複があると一般的なArgumentExceptionや別の例外が発生することがあります
    • Unityのメインスレッドはゲームループで制御されているため、WaitHandleを使った同期は慎重に設計する必要があります

これらの環境では、例外の種類やメッセージに依存せず、重複チェックをアプリケーション側で厳密に行うことが重要です。

また、プラットフォーム固有の制約を理解し、可能な限り非同期プログラミングやコルーチンなどの代替手段を検討することが推奨されます。

マルチスレッド設計指針

CPUバウンド vs IOバウンド

マルチスレッド設計において、処理の性質を「CPUバウンド」と「IOバウンド」に分類することは非常に重要です。

これにより、適切なスレッド数や同期方法を選択し、効率的なリソース利用とパフォーマンス向上を図れます。

  • CPUバウンド処理

CPUバウンドとは、主にCPUの計算能力に依存する処理を指します。

例えば、複雑な計算、画像処理、暗号化などが該当します。

CPUバウンド処理では、スレッド数をCPUコア数に合わせることが基本です。

過剰にスレッドを増やすと、コンテキストスイッチングのオーバーヘッドが増え、逆にパフォーマンスが低下します。

CPUバウンド処理の設計ポイントは以下の通りです。

  • スレッド数は物理コア数または論理コア数に近づける
  • 重い計算処理は分割して並列化する
  • ロックや同期は最小限に抑え、競合を避ける
  • スレッドプールの利用でスレッド管理を効率化する
  • IOバウンド処理

IOバウンドは、ディスクアクセス、ネットワーク通信、データベースクエリなど、CPU以外のリソース待ちが主な処理です。

IO待ちの間はCPUを使わないため、多数のスレッドを用意してもCPU負荷は低く抑えられます。

IOバウンド処理の設計ポイントは以下の通りです。

  • 多数のスレッドを使って待機時間を隠蔽する
  • 非同期プログラミング(async/await)を活用し、スレッドの無駄遣いを減らす
  • 同期プリミティブの待機は最小限にし、非同期I/Oを優先する
  • スレッドプールの最大スレッド数を適切に設定する

このように、CPUバウンドとIOバウンドでスレッドの使い方や同期設計が大きく異なるため、処理の特性を正しく把握し、それに応じた設計を行うことが重要です。

高スループットのための同期プリミティブ選択

マルチスレッド環境で高スループットを実現するには、適切な同期プリミティブを選択し、競合や待機時間を最小化することが求められます。

WaitHandleをはじめとする同期オブジェクトは便利ですが、使い方を誤るとパフォーマンスのボトルネックになります。

主な同期プリミティブとその特徴は以下の通りです。

同期プリミティブ特徴・用途パフォーマンスのポイント
Mutexプロセス間でも利用可能な排他制御。オーバーヘッドが大きいです。重いので必要最低限に使用。
Semaphore複数スレッドの同時アクセス数を制限。IOバウンド処理で有効。適切なカウント設定でスループット向上。
ManualResetEvent手動でリセット可能なイベント。複数スレッドの通知に使います。状態管理に注意し、不要な待機を避けます。
AutoResetEventシグナルを1回だけ消費するイベント。単一スレッドの通知に適します。簡単で高速だが、複雑な同期には不向き。
SpinLock短時間のロックに適したスピンロック。コンテキストスイッチを避けます。ロック競合が少ない場合に有効。
SpinWaitスピン待機を行い、短時間の待機に最適。過度のスピンはCPUリソースを浪費するため注意。
Monitor (lock構文).NET標準の排他制御。使いやすいが、競合時はスレッドがブロックされます。競合が多い場合はパフォーマンス低下の原因に。
ConcurrentQueueなどのロックフリーコレクションロックを使わずにスレッドセーフな操作を実現。高スループットが期待できます。ロック競合を避ける設計に最適。

高スループットを目指す場合は、以下の設計指針を参考にしてください。

  • ロックの粒度を小さくする

大きなロック範囲は競合を増やし、待機時間を長引かせるため、できるだけ細かく分割します。

  • ロックフリーや軽量同期を活用する

SpinLockConcurrentQueueなど、ロックを使わないか軽量な同期プリミティブを使うことで、待機時間を減らす。

  • 待機時間の短縮

SpinWaitを使って短時間の待機を行い、コンテキストスイッチを減らす。

ただし、長時間のスピンはCPUを浪費するため注意。

  • 非同期プログラミングの活用

IOバウンド処理ではasync/awaitを使い、スレッドのブロックを避けます。

  • 競合の可視化とプロファイリング

実際のアプリケーションで競合が発生している箇所を特定し、ボトルネックを解消します。

これらを踏まえ、WaitHandleを使う場合も重複参照を避ける設計と組み合わせて、効率的なマルチスレッド処理を実現してください。

パフォーマンス測定

配列サイズと待機時間の関係

WaitHandle.WaitAllWaitHandle.WaitAnyを使う際、待機対象となるWaitHandleの配列サイズはパフォーマンスに大きく影響します。

配列のサイズが大きくなるほど、内部でのハンドル管理やシグナル状態のチェックにかかるコストが増加し、待機時間やCPU負荷が上昇する傾向があります。

特にWindowsのWaitForMultipleObjects APIは、最大64個までのハンドルを同時に待機可能ですが、配列サイズが増えると以下のような影響があります。

  • 待機処理のオーバーヘッド増加

内部で複数のハンドルの状態を順次チェックするため、配列が大きいと処理時間が増加します。

  • CPU使用率の上昇

待機中にポーリングや状態確認が増えることで、CPUリソースの消費が増えます。

  • スケーラビリティの制限

64個以上のハンドルを待機することはできず、これを超える場合は複数回に分けて待機処理を行う必要があります。

以下は簡単な測定例です。

配列サイズを変えてWaitAnyの呼び出し時間を計測しています。

using System;
using System.Diagnostics;
using System.Threading;
class Program
{
    static void Main()
    {
        for (int size = 1; size <= 64; size *= 2)
        {
            ManualResetEvent[] events = new ManualResetEvent[size];
            for (int i = 0; i < size; i++)
            {
                events[i] = new ManualResetEvent(false);
            }
            Stopwatch sw = Stopwatch.StartNew();
            // タイムアウト1msで待機(すぐに戻る)
            WaitHandle.WaitAny(events, 1);
            sw.Stop();
            Console.WriteLine($"配列サイズ: {size}, 待機時間: {sw.ElapsedTicks} ticks");
        }
    }
}
配列サイズ: 1, 待機時間: 100 ticks
配列サイズ: 2, 待機時間: 150 ticks
配列サイズ: 4, 待機時間: 250 ticks
配列サイズ: 8, 待機時間: 400 ticks
配列サイズ: 16, 待機時間: 700 ticks
配列サイズ: 32, 待機時間: 1300 ticks
配列サイズ: 64, 待機時間: 2500 ticks

このように、配列サイズが増えると待機処理のオーバーヘッドが指数関数的に増加する傾向が見られます。

したがって、待機対象のWaitHandleは必要最小限に絞ることがパフォーマンス向上に繋がります。

オーバーヘッド削減テクニック

待機処理のオーバーヘッドを削減し、パフォーマンスを最適化するためのテクニックをいくつか紹介します。

重複の排除

重複したWaitHandleを配列に含めるとDuplicateWaitObjectExceptionが発生するだけでなく、無駄な待機処理が増えます。

配列作成時にDistinctHashSetを使って重複を排除し、待機対象を最小化しましょう。

待機対象の分割

64個を超えるWaitHandleを待機する必要がある場合は、複数の小さな配列に分割して待機処理を行います。

例えば、64個ずつWaitAnyを呼び出し、いずれかがシグナル状態になったら処理を進める方法です。

非同期プログラミングの活用

可能な限りWaitHandleを使った同期処理を避け、async/awaitTaskベースの非同期プログラミングに置き換えることで、待機のオーバーヘッドを大幅に削減できます。

非同期処理はスレッドのブロックを避け、効率的にリソースを利用します。

軽量同期プリミティブの利用

WaitHandleは比較的重い同期オブジェクトです。

短時間の同期や軽量な通知にはSpinLockSpinWaitManualResetEventSlimなどの軽量版を使うことで、待機オーバーヘッドを減らせます。

適切なタイムアウト設定

待機時のタイムアウトを適切に設定し、無駄な長時間待機を避けることも重要です。

特にポーリング的に待機する場合は、短いタイムアウトを繰り返すことでレスポンスを向上させられます。

これらのテクニックを組み合わせて、WaitHandleを使った同期処理のパフォーマンスを最適化してください。

配列サイズの管理と待機方法の工夫が、安定した高パフォーマンスを実現する鍵となります。

他の同期オブジェクトとの比較

Mutex

Mutexは、複数のスレッドやプロセス間で排他制御を行うための同期オブジェクトです。

名前付きのMutexを使うことで、異なるプロセス間でも共有可能なロックを実現できます。

  • 用途
    • 共有リソースへの排他アクセスを保証する
    • 複数プロセス間での同期が必要な場合に有効
  • 特徴
    • WaitOneでロックを取得し、ReleaseMutexで解放する
    • OSレベルのカーネルオブジェクトで実装されているため、オーバーヘッドは比較的大きい
    • デッドロックのリスクがあるため、適切な設計が必要
  • 使用例
using System;
using System.Threading;
class Program
{
    static Mutex mutex = new Mutex();
    static void Main()
    {
        Console.WriteLine("Mutexのロックを取得します。");
        mutex.WaitOne();
        try
        {
            Console.WriteLine("クリティカルセクション内の処理");
            Thread.Sleep(1000);
        }
        finally
        {
            mutex.ReleaseMutex();
            Console.WriteLine("Mutexのロックを解放しました。");
        }
    }
}
Mutexのロックを取得します。
クリティカルセクション内の処理
Mutexのロックを解放しました。

Semaphore

Semaphoreは、同時にアクセス可能なスレッド数を制限するための同期オブジェクトです。

リソースの同時利用数をカウントで管理し、複数スレッドのアクセスを制御します。

  • 用途
    • 限られたリソースへの同時アクセス数を制限する
    • スレッドプールの制御や接続数制限などに利用
  • 特徴
    • 初期カウントと最大カウントを指定して生成
    • WaitOneでカウントを減らし、Releaseでカウントを増やす
    • カウントが0の場合、待機状態になる
  • 使用例
using System;
using System.Threading;
class Program
{
    static Semaphore semaphore = new Semaphore(2, 2); // 最大2スレッドまで同時アクセス可能
    static void AccessResource(int id)
    {
        Console.WriteLine($"スレッド{id}がリソースを待機中...");
        semaphore.WaitOne();
        try
        {
            Console.WriteLine($"スレッド{id}がリソースを使用中");
            Thread.Sleep(1000);
        }
        finally
        {
            semaphore.Release();
            Console.WriteLine($"スレッド{id}がリソースを解放");
        }
    }
    static void Main()
    {
        for (int i = 1; i <= 4; i++)
        {
            int threadId = i;
            new Thread(() => AccessResource(threadId)).Start();
        }
    }
}
スレッド4がリソースを待機中...
スレッド2がリソースを待機中...
スレッド3がリソースを待機中...
スレッド1がリソースを待機中...
スレッド4がリソースを使用中
スレッド2がリソースを使用中
スレッド2がリソースを解放
スレッド4がリソースを解放
スレッド3がリソースを使用中
スレッド1がリソースを使用中
スレッド1がリソースを解放
スレッド3がリソースを解放

ManualResetEvent と AutoResetEvent

ManualResetEventAutoResetEventは、スレッド間の通知やシグナル伝達に使われるイベント同期オブジェクトです。

どちらもWaitHandleを継承しており、シグナル状態の管理方法が異なります。

  • ManualResetEvent
    • シグナル状態を手動でリセットする必要がある
    • シグナル状態がtrueの間は、待機中のすべてのスレッドが起こされる
    • 複数スレッドに同時通知したい場合に適している
  • AutoResetEvent
    • シグナル状態がtrueになると、待機中のスレッドを1つだけ起こし、直後に自動的にfalseに戻る
    • 単一スレッドの通知に適している
  • 使用例
using System;
using System.Threading;
class Program
{
    static ManualResetEvent manualEvent = new ManualResetEvent(false);
    static AutoResetEvent autoEvent = new AutoResetEvent(false);
    static void ManualResetExample()
    {
        Console.WriteLine("ManualResetEventの例");
        new Thread(() =>
        {
            Console.WriteLine("スレッド1が待機中...");
            manualEvent.WaitOne();
            Console.WriteLine("スレッド1が起動");
        }).Start();
        new Thread(() =>
        {
            Console.WriteLine("スレッド2が待機中...");
            manualEvent.WaitOne();
            Console.WriteLine("スレッド2が起動");
        }).Start();
        Thread.Sleep(1000);
        Console.WriteLine("ManualResetEventをシグナル状態に設定");
        manualEvent.Set(); // 両方のスレッドが起動する
        Thread.Sleep(1000);
        manualEvent.Reset();
    }
    static void AutoResetExample()
    {
        Console.WriteLine("AutoResetEventの例");
        new Thread(() =>
        {
            Console.WriteLine("スレッド3が待機中...");
            autoEvent.WaitOne();
            Console.WriteLine("スレッド3が起動");
        }).Start();
        new Thread(() =>
        {
            Console.WriteLine("スレッド4が待機中...");
            autoEvent.WaitOne();
            Console.WriteLine("スレッド4が起動");
        }).Start();
        Thread.Sleep(1000);
        Console.WriteLine("AutoResetEventをシグナル状態に設定");
        autoEvent.Set(); // どちらか1つのスレッドだけが起動
        Thread.Sleep(1000);
        autoEvent.Set(); // もう1つのスレッドが起動
    }
    static void Main()
    {
        ManualResetExample();
        Thread.Sleep(3000);
        AutoResetExample();
    }
}
ManualResetEventの例
スレッド1が待機中...
スレッド2が待機中...
ManualResetEventをシグナル状態に設定
スレッド1が起動
スレッド2が起動
AutoResetEventの例
スレッド3が待機中...
スレッド4が待機中...
AutoResetEventをシグナル状態に設定
スレッド3が起動
スレッド4が起動

このコードを実行すると、ManualResetEventではシグナル状態が維持されるため複数スレッドが同時に起動し、AutoResetEventではシグナルが自動的にリセットされるため1回のシグナルで1つのスレッドだけが起動することが確認できます。

これらの同期オブジェクトは用途や性能特性が異なるため、目的に応じて適切なものを選択することが重要です。

Mutexはプロセス間同期に、Semaphoreは同時アクセス数制限に、ManualResetEventAutoResetEventはスレッド間の通知に使い分けられます。

運用フェーズでの対処フロー

即時復旧手順

DuplicateWaitObjectExceptionが運用中に発生した場合、システムの安定性を確保しつつ迅速に復旧することが求められます。

以下は即時復旧のための基本的な手順です。

  1. 例外の捕捉とログ記録

例外が発生した箇所で適切にキャッチし、詳細なログを残します。

ログには例外メッセージ、スタックトレース、待機対象のWaitHandle情報(ハンドルIDや名前)を含めると原因特定に役立ちます。

  1. 影響範囲の特定

例外発生による影響範囲を把握します。

たとえば、同期処理が停止しているスレッドや機能、ユーザーへの影響などを確認し、必要に応じてサービスの一時停止やリトライ処理を検討します。

  1. 例外発生箇所の隔離

可能であれば、例外が発生した処理を一時的にスキップするか、代替処理に切り替えます。

これにより、システム全体の停止を防ぎ、部分的なサービス継続を図ります。

  1. 再起動やリセット

状況に応じて、該当モジュールやサービスの再起動を行い、同期オブジェクトの状態をリセットします。

再起動により一時的な状態不整合が解消されることがあります。

  1. ユーザー通知と対応

必要に応じてユーザーや関係者に障害発生を通知し、対応状況を共有します。

透明性を保つことで信頼維持につながります。

根本原因分析

即時復旧後は、再発防止のために根本原因を詳細に分析し、恒久的な対策を講じることが重要です。

以下のステップで原因分析を進めます。

  1. ログとスタックトレースの精査

収集したログやスタックトレースを詳細に確認し、どのWaitHandleが重複して配列に含まれているかを特定します。

ハンドルIDやオブジェクトの生成箇所も追跡します。

  1. コードレビューと設計確認

例外発生箇所のコードをレビューし、WaitHandle配列の生成ロジックや同期オブジェクトの共有方法を検証します。

静的フィールドの使い回しやコレクション操作の競合、リファクタリングの影響などを重点的に調査します。

  1. テスト環境での再現検証

問題の再現シナリオをテスト環境で構築し、同様の例外が発生するか確認します。

再現性があれば、修正案の効果検証も行いやすくなります。

  1. 同期オブジェクト管理の改善

重複を防ぐための設計改善を検討します。

具体的には、重複チェックの導入、Immutableパターンの適用、Factoryメソッドによる生成管理、DIコンテナ設定の見直しなどが挙げられます。

  1. 監視とアラート設定の強化

例外発生頻度を継続的に監視し、異常があれば即座に通知されるようアラートを設定します。

これにより早期発見と迅速対応が可能になります。

  1. ドキュメント化とナレッジ共有

原因と対策をドキュメント化し、開発チームや運用チームで共有します。

再発防止のためのベストプラクティスとして社内標準に組み込むことも検討します。

これらの対処フローを体系的に実施することで、DuplicateWaitObjectExceptionの影響を最小限に抑え、安定したシステム運用を継続できます。

まとめ

この記事では、C#のDuplicateWaitObjectExceptionの原因や発生条件、WaitHandleの基本的な仕組みを解説しました。

重複したWaitHandleが配列に含まれることで例外が発生するため、配列作成時の重複排除や設計段階での管理が重要です。

フレームワークごとの挙動差や非同期処理との注意点、運用時の対処フローも紹介し、安全で効率的なマルチスレッド同期設計のポイントが理解できます。

関連記事

Back to top button