Windows API の待機関数

DynWaitList: ID を基にした Windows イベントの多重化

Alex Gimenez

コード サンプルのダウンロード

Microsoft Windows では、WaitForMultipleObjects メソッドとそのバリエーションを使って、複数イベントの待機を多重化することができます。これらの関数は強力ですが、イベント リストが動的になるときは実に不便です。

イベントのシグナルをオブジェクト ハンドルの配列の "インデックス" によって特定していることが、困難さを生み出しています。このようなインデックスは、イベントが配列に追加されたり、配列から削除されたりすると値が変化します。

通常、この種の問題は、ハンドルを格納するコンテナーを用意して配列をラップし、クライアント アプリケーションに代ってハンドルの挿入、削除、および検索を実行することで解決します。

この記事では、そのようなコンテナー クラスの設計と実装について説明します。このコンテナーは、WaitForMultipleObjects で使用されるイベント ハンドルを格納します。コンテナー クラスのユーザーは、コンテナーの有効期間であれば、イベントの追加や削除によって変化しない数値 ID を使って、各ハンドルを参照します。

問題点の把握

WaitForMultipleObjects や MsgWaitForMultipleObjects のインターフェイスは、次のような単純なケースには最適です。

  • 待機するハンドルの数が事前にわかっている
  • 待機するハンドルの数が時間と共に変化しない

ハンドルがシグナル状態になると、そのハンドルのインデックスを戻り値として受け取ります。このインデックスは、入力として渡したイベントの配列内の位置を表し、直接的な意味はありません。これらの関数から入手したい本当の情報は、シグナル状態になったハンドルか、そのハンドルについての一時的ではない情報です。

問題点を表している例を図 1 に示します。このコード (仮想メディア ストリーミング アプリケーションの一部) は、オーディオ デバイスまたはネットワークからのシグナルを待機しています (この記事で使用しているコード サンプルは、いずれも付属のコード ダウンロードから入手できます)。

図 1 シグナルを待機しているメディア ストリーミング アプリケーション

#define MY_AUDIO_EVENT (WAIT_OBJECT_0)
#define MY_SOCKET_EVENT (WAIT_OBJECT_0 + 1)
HANDLE handles[2];
handles[0] = audioInHandle;
handles[1] = socketHandle;
...
switch( WaitForMultipleObjects(handles) )
{
  case MY_AUDIO_EVENT:
  // Handle audio event 
  break;
  case MY_SOCKET_EVENT:
  // Handle socket event 
  // What happens if we need to stop audio here?
  break;
}

WAIT_OBJECT_0 (インデックス 0) という結果を取得すれば、オーディオ デバイスからシグナルを受けた状態です。インデックス 1 を取得すれば、ネットワークからシグナルを受けた状態です。ここで、socketHandle からトリガーされたイベントに応答して、audioInHandle を閉じる必要があるとしたらどうなるでしょう。この場合、ハンドルの配列内からインデックス 0 を取り除く必要があります。その結果、1 以上のインデックスが変化します。つまり、MY_SOCKET_EVENT 値は定数ではなく動的にする必要があります。

もちろん、この状況に対処する方法はあります (たとえば、配列の末尾にオプションのハンドルを保持しておく方法や、配列の開始位置をずらす方法などがあります)。しかし、イベントの数が増えたり、エラー パス (WAIT_ABANDONED_0 のインデックス) の処理を追加したりすると、複雑さが急速に増します。

一見すると、イベント ハンドルを特定するのに定数を使用できないことが問題のように見えますが、じっくりと見てみると、イベント ハンドルの特定に配列のインデックスを使用していることが、このインターフェイスの根本的問題であることがわかります。このインデックスは、メモリ内でのハンドルの位置を表すと同時に、イベントがシグナルを受けた状態になったことも表すという、不自由な 2 つの役割を果たしています。

シグナルを受けた状態になったイベントを、配列のインデックスとは別の方法で特定できれば問題ありません。まさにこれを行うのが DynWaitList クラスです。

DynWaitList の使用

DynWaitList クラスは、WaitForMultipleObjects メソッドに渡されるハンドルの配列用のコンテナー リストです。ハンドルの内部コレクションには、静的最大サイズがあります。このクラスは、コレクションの最大サイズが唯一のテンプレート パラメーターであるテンプレートとして実装されます。

コンテナーのインターフェイスは、イベントを挿入してその ID を指定する Add、イベントを削除する Remove、Wait のいくつかのバリエーションなど、必要なメソッドを備えています。先ほどの問題を解決するのに、DynWaitList をどのように使用するかを図 2 に示します。

図 2 DynWaitList の使用

WORD idAudioEvent, idSocketEvent;
DynWaitList<10> handles(100);  // Max 10 events, first ID is 100  

handles.Add( socketHandle,  &idSocketEvent );
handles.Add( audioInHandle, &idAudioEvent  );
...
switch( handles.Wait(2000)  )
{
  case (HANDLE_SIGNALED| idAudioEvent ):
  // Handle audio event
  break;
  case (HANDLE_SIGNALED| idSocketEvent): 
  // Handle socket event
  if( decided_to_drop_audio )
  {
    // Array will shift within; the same ID
    // can be reused later with a different
    // handle if, say, we reopen audio
    handles.Remove(idAudioEvent);

    // Any value outside the 
    // 100...109 range is fine
    idAudioEvent = 0;
  }
  break;

  case (HANDLE_ABANDONED| idSocketEvent):
  case (HANDLE_ABANDONED| idAudioEvent):
  // Critical error paths
  break;

  case WAIT_TIMEOUT:
  break;
}

DynWaitList の一般的な用途

この例では少数の既知のイベント ID しか扱っていませんが、事前にはわからない多数の ID が存在する場合もあり得ます。いくつかの一般的なケースを示します。

  • TCP サーバー。TCP サーバーは、現在接続しているクライアント ソケットごとにイベント ID を保持します。つまり、クライアント ソケットが接続と切断を繰り返すたびに変化する動的なイベント リストを使用するケースです。
  • 音声のミキシング アプリケーションや IP 電話のアプリケーション。このようなアプリケーションには、システムの各オーディオ デバイスのフレーム準備完了またはタイマーのシグナルを待機するハンドルがあります。

こうした例は、ハンドルの動的リストがアプリケーション周囲の外部環境の変化を表すという共通のテーマを示しています。

設計とパフォーマンスに関する考慮事項

コンテナーを実装することは、パフォーマンス、簡潔さ、およびストレージ容量という相反する目標の中から、微妙なバランスをとって最適な選択を行うという取り組みです。これらは、先ほど挙げたように、最も頻繁に使用するコンテナーを考慮して評価する必要があります。つまり、次のように、コンテナーで実行する操作と、その発生率を列挙して評価します。

  • ハンドルの追加: かなり頻度が高い
  • ハンドルの削除: ハンドルの追加とほぼ同じ頻度
  • ハンドルの変更: 不可 (Windows では既存のオブジェクトのハンドルを変更できない)
  • コンテナーを Windows が必要とするフラットな配列に変換: かなり頻度が高い
  • シグナル状態になった直後のハンドル値の取得: かなり頻度が高い

これらの操作を念頭に置き、今回は内部ストレージ、イベント ハンドラーの配列 (Windows が必要とする配列)、および 16 ビット値 ID の並列配列を使用することにしました。このように並列配列に整列することによって、インデックスとイベント ID との変換を効率よく行うことができます。具体的には、次のことを実現できます。

  • Windows が必要とする配列が常時利用可能
  • Windows から返されるインデックスを前提とすると、order-1 検索で ID を検出できる

もう 1 つ重要な考慮事項は、スレッド セーフであることです。このコンテナーの目的から、操作をシリアル化する必要があるのは当然なので、内部配列を保護しないことにしました。

図 3 は、主要なインターフェイスとコンテナーの内部を表すクラスの宣言です。

図 3 主要インターフェイスとコンテナー内部を表すクラス宣言

class DynWaitlistImpl
{
  protected: 
    DynWaitlistImpl( WORD nMaxHandles, HANDLE *pHandles, 
      WORD *pIds, WORD wFirstId );

    // Adds a handle to the list; returns TRUE if successful
    BOOL Add( HANDLE hNewHandle, WORD *pwNewId );

    // Removes a handle from the list; returns TRUE if successful
    BOOL Remove( WORD wId );

    DWORD Wait(DWORD dwTimeoutMs, BOOL bWaitAll = FALSE);

    // ... Some snipped code shown later ...

  private:
    HANDLE *m_pHandles;
    WORD *m_pIds;
    WORD m_nHandles;
    WORD m_nMaxHandles;
};

template <int _nMaxHandles> class DynWaitlist: public DynWaitlistImpl
{
  public:
    DynWaitlist(WORD wFirstId): 
    DynWaitlistImpl( _nMaxHandles, handles, ids, wFirstId ) { }
    virtual ~DynWaitlist() { }

  private:
    HANDLE handles[ _nMaxHandles ];
    WORD ids[ _nMaxHandles ];
};

クラスを 2 つに分けたこと、基本クラスが配列ポインターを保持していること、およびテンプレート派生クラスが実際のストレージを保持していることに注目してください。異なるテンプレート クラスを派生させることにより、(必要な場合に) 動的な配列の確保を柔軟にしています。この実装は、静的ストレージのみを使用します。

ハンドルの追加

ハンドルを配列に追加することは簡単ですが、新しく作成されたイベントを表すために、空いている ID を見つける動作は簡単ではありません。選択した設計では、コンテナー内に ID の配列を用意します。この配列は、コンテナーが保持できる最大数まで ID を保持できるように事前に確保します。したがって、この配列には、次の 2 つのグループの ID が保持されることになります。

  • 先頭から "N" 個の要素は "使用中" の ID。"N" は、実際に割り当てられているハンドル数。
  • 残りの要素は、空き ID のプール。

これを可能にするため、この ID の配列のすべての要素には、構築時に ID 値として有効な値を設定しておく必要があります。このようにしておけば、最後に使用した ID の "直後" の ID を空き ID として簡単に見つけることができます。検索は必要ありません。クラス コンストラクターと Add メソッドのコードを図 4 に示します。これら 2 つのメソッドは、空き ID のプールを構築して使用するために連携しています。

図 4 クラス コンストラクターと Add メソッド

DynWaitlistImpl::DynWaitlistImpl(  
  WORD nMaxHandles,  // Number of handles
  HANDLE *pHandles,   // Pointer to array of handle slots
  WORD *pIds,         // Pointer to array of IDs
  WORD wFirstID)      // Value of first ID to use
// Class Constructor. Initializes object state
:  m_nMaxHandles(nMaxHandles)
,  m_pHandles(pHandles)
,  m_pIds(pIds)
,  m_nHandles(0)
{
  // Fill the pool of available IDs
  WORD wId = wFirstID;
  for( WORD i = 0; i < nMaxHandles; ++i )
  {
    m_pIds[i] = wId;
    wId++;
  }
}


BOOL DynWaitlistImpl::Add(
  HANDLE hNewHandle, // Handle to be added
  WORD *pwNewId ) // OUT parameter - value of new ID picked
// Adds one element to the array of handles
{
  if( m_nHandles >= m_nMaxHandles )
  {
    // No more room, no can do
    return FALSE;
  }
  m_pHandles[ m_nHandles ] = hNewHandle;

  // Pick the first available ID
  (*pwNewId) = m_pIds[ m_nHandles ];

  ++m_nHandles;
  return TRUE;
}

ハンドルの削除

ID を指定してコンテナーからハンドルを削除するには、ハンドルのインデックスを見つける必要があります。インデックスから ID への変換は、この実装では order-1 検索に最適化していますが、変換パフォーマンスの低下という犠牲のもとに成り立っています。ID を考えると、配列内のハンドルのインデックスを見つけるためには、線形探索 (order-N) が必要です。サーバーの場合、ユーザーは切断時の応答時間は気に留めないため、これを受け入れることにしました。削除するインデックスを見つけてしまえば、削除操作はすばやく簡単に実行できます。見つかったハンドルを、最後の "使用中" ハンドルと交換するだけです (図 5 参照)。

図 5 削除操作

BOOL DynWaitlistImpl::Remove(
  WORD wId ) // ID of handle being removed
// Removes one element from the array of handles
{
  WORD i;
  BOOL bFoundIt = FALSE;
  for( i = 0; i < m_nHandles; ++i )
  {
    // If we found the one we want to remove
    if( m_pIds[i] == wId )
    {
      // Found it!
      bFoundIt = TRUE;
      break;
    }
  }

  // Found the ID we were looking for?
  if( bFoundIt )
  {
    WORD wMaxIdx = (m_nHandles - 1);
    if( i < wMaxIdx ) // if it isn't the last item being removed
    {
      // Take what used to be the last item and move it down,
      // so it takes the place of the item that was deleted
      m_pIds    [i] = m_pIds    [ wMaxIdx ];
      m_pHandles[i] = m_pHandles[ wMaxIdx ];

      // Save the ID being removed, so it can be reused in a future Add
      m_pIds    [ wMaxIdx ] = wId;
    }
    --m_nHandles;
    m_pHandles[m_nHandles] = 0;
    return TRUE;
  }
  else
  {
    return FALSE;
  }
}

シグナルの検出

シグナルの検出は、DynWaitList が実行する主要作業です。すべてのデータは、呼び出し用に事前にアーカイブされたままなので、WaitForMultipleObjects の呼び出しは簡単です。ID は並列配列になっているため、検出されたシグナルを上位層で参照できる ID に変換することも簡単です。図 6 に示しているこのコードが、"待機" メソッドの本質です。待機にはいくつかのバリエーションがありますが、いずれも、インデックスを ID に変換するために内部の TranslateWaitResult メソッドを使用します。

図 6 シグナルの検出

// Snippet from the header file – Wait is a quick, inline method
DWORD Wait(DWORD dwTimeoutMs, BOOL bWaitAll = FALSE)
{
  return TranslateWaitResult(
    WaitForMultipleObjects( m_nHandles,
    m_pHandles,
    bWaitAll,
    dwTimeoutMs )
    );
}

// Snippet from the CPP file – method called by all flavors of Wait
DWORD DynWaitlistImpl::TranslateWaitResult(
  DWORD dwWaitResult ) // Value returned by WaitForMultipleObjectsXXX
  // translates the index-based value returned by Windows into
  // an ID-based value for comparison
{

  if( (dwWaitResult >= WAIT_OBJECT_0) && 
    (dwWaitResult < (DWORD)(WAIT_OBJECT_0 + m_nHandles) ) )
  {
    return HANDLE_SIGNALED | m_pIds[dwWaitResult - WAIT_OBJECT_0];
  }
  if( (dwWaitResult >= WAIT_ABANDONED_0) && 
    (dwWaitResult < (DWORD)(WAIT_ABANDONED_0 + m_nHandles) ) )
  {
    return HANDLE_ABANDONED | m_pIds[dwWaitResult - WAIT_ABANDONED_0];
  }

  return dwWaitResult; // No translation needed for other values
}

マルチコアについての考慮事項

現在、複数のスレッドで作業を実行することによってコンピューターの効率を高める、"メニーコア" のコンピューターの世界へと進化しています。このような世界になっても、イベントの多重化は必要なのでしょうか。ほとんどのアプリケーションが、スレッド単位に 1 つのイベントを処理することになるため、DynWaitList のメリットがなくなってしまうのでしょうか。

おそらく、そうではありません。イベントの多重化は、マルチコアのコンピューターでも重要だと考えます。それには、少なくとも以下 2 つの理由があります。

  • 直列にアクセスする必要があるハードウェア デバイスにアクセスするため、並列処理の恩恵を受けない操作があります。低レベルのネットワークがその一例です。
  • (特にユーティリティ ライブラリにおける) イベントの多重化の重要なメリットの 1 つは、アプリケーションに特定のスレッド モデルを押し付けないことです。最上位のアプリケーションでは、スレッド モデルを指定します。この方法だと、アプリケーションは、スレッドへのイベントの分配を自由に選べるようになるため、イベントの待機リストのカプセル化がさらに重要になります。

コードは簡単に、バグは少なく

総括すると、一時的ではない ID を、Windows WaitForMultipleObjects 関数に渡される各イベントに関連付けると、コードが簡略化され、バグの可能性が少なくなります。これは、意味を持たないイベントのインデックスを、有効なオブジェクト ハンドルまたはポインターに変換するためにアプリケーションが果たさなければならない負担が取り除かれるためです。DynWaitList クラスは、この関連付けプロセスを、これらの Windows API 待機関数を囲むラッパーに効率よくカプセル化します。関与する操作はすべて order-1 検索ですが、構築とハンドルの削除は order-N 検索です。配列の並べ替えを行うと、さらに最適化できますが、ハンドルの削除ははるかに高速になっても、ハンドル追加のパフォーマンスはわずかに低下します。

Alex Gimenez は、シアトル近郊で開発者として勤務しており、Microsoft Lync を提供するチームと共に、リアルタイムのオーディオ、ビデオ、およびネットワーク ソフトウェアを開発しています。これまでは、約 20 年にわたって、販売時点管理ソフトウェア、埋め込みソフトウェア、デバイス ドライバー ソフトウェア、および通信ソフトウェアの開発に携わってきました。余暇には、サイクリングやドラム、そして日本語の勉強を楽しんでいます。連絡先は、alexgim@microsoft.com (英語のみ) です。

この記事のレビューに協力してくれた技術スタッフの Bart HolmbergWarren LamJiannan Zheng に心より感謝いたします。