Windows API Wait Functions

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을 제거하고 0보다 큰 인덱스를 이동해야 하므로 MY_SOCKET_EVENT 값이 상수가 아닌 동적이어야 합니다.

물론 이러한 상황을 해결하는 방법이 없는 것은 아니지만(예를 들어 선택적인 핸들을 배열 끝에 유지하거나 배열의 시작을 이동), 이벤트를 더 추가하거나 오류 경로(WAIT_ABANDONED_0의 인덱스를 해제)를 처리하기 시작하면 금방 까다로워집니다.

처음에는 이벤트 핸들을 식별하는 데 상수를 사용할 수 없다는 것이 문제인 것처럼 보입니다. 그러나 근본 원인을 들여다보면 이 인터페이스가 이벤트 핸들을 식별하는 데 배열 인덱스를 사용한다는 것이 바로 핵심입니다. 인덱스는 여기에서 불편하게도 메모리에서 핸들의 위치를 나타내는 것과 이벤트 신호가 전달된 것을 알리는 것의 두 가지 역할을 수행합니다.

신호가 전달된 이벤트를 배열의 인덱스와는 별도로 식별할 수 있다면 좋을 것입니다. DynWaitList 클래스의 역할이 바로 이것입니다.

DynWaitList 사용

DynWaitList 클래스는 WaitForMultipleObjects 메서드에 전달될 핸들 배열의 컨테이너 목록입니다. 핸들의 내부 컬렉션은 정적인 최대 크기를 가집니다. 클래스는 템플릿으로 구현되며, 컬렉션의 최대 크기가 유일한 템플릿 매개 변수입니다.

예상할 수 있겠지만 컨테이너 인터페이스에는 이벤트를 삽입하고 해당 ID를 지정하는 Add 메서드와 이벤트를 제거하는 Remove 메서드, 그리고 몇 가지 Wait 메서드의 변형이 있습니다. 그림 2에는 DynWaitList를 사용하여 앞서 소개한 문제를 해결하는 방법이 나옵니다.

그림 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의 수가 많고 사전에 알려지지 않은 경우도 있습니다. 다음은 몇 가지 일반적인 경우입니다.

  • 현재 연결된 각 클라이언트 소켓의 이벤트 ID를 저장해야 하는 TCP 서버. 클라이언트 소켓은 연결과 해제를 반복하므로 동적 이벤트 목록을 가장 잘 활용할 수 있는 예입니다.
  • 시스템의 각 오디오 장치에서 프레임 준비/타이머 신호를 기다려야 하는 핸들이 있는 오디오 믹스 응용 프로그램 또는 IP 전화 응용 프로그램.

지금까지 예는 핸들의 동적 목록이 응용 프로그램 주변의 변화하는 외부 환경을 나타낸다는 공통적인 주제를 보여 주고 있습니다.

디자인 및 성능 고려 사항

컨테이너를 구현하는 작업은 성능, 단순성, 그리고 저장소 공간이라는 서로 충돌하는 목표 간의 균형을 맞추는 과정입니다. 이러한 목표는 앞서 소개했던 가장 자주 수행하는 컨테이너 작업을 고려하여 평가해야 합니다. 그러면 컨테이너에서 수행할 작업과 해당 발생 빈도를 열거하는 데 도움이 됩니다.

  • 핸들 추가: 상당히 빈번
  • 핸들 제거: 핸들 추가와 거의 비슷한 빈도
  • 핸들 변경: 적용되지 않음(Windows에서 기존 개체의 핸들은 변경할 수 없음)
  • 컨테이너를 Windows가 필요로 하는 일반 배열로 변환: 상당히 빈번
  • 신호가 전달된 핸들의 값 검색: 상당히 빈번

이러한 작업을 고려하여 이벤트 핸들의 배열(Windows에서 필요)과 16비트 값인 ID의 병렬 배열을 더한 구조를 내부 저장소로 사용하기로 결정했습니다. 이러한 병렬-배열 구조를 통해 인덱스와 이벤트 ID 간 변환을 효율적으로 수행할 수 있습니다. 구체적으로 설명하면 다음과 같습니다.

  • Windows에 필요한 배열은 항상 제공됩니다.
  • Windows에서 반환된 인덱스가 있으면 해당 ID 조회는 order-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 ];
};

클래스가 배열 포인터를 포함하는 기본 클래스와 실제 저장소를 포함하는 템플릿 파생 클래스의 두 부분으로 나뉘는 것을 확인할 수 있습니다. 이를 통해 필요한 경우 다른 클래스 템플릿을 파생하여 동적 배열 할당이라는 유연성을 제공할 수 있습니다. 이 구현은 정적 저장소만 사용합니다.

핸들 추가

새로 생성된 이벤트를 나타내는 사용 가능한 ID를 찾는 과정을 제외하면 배열에 핸들을 추가하는 작업은 간단합니다. 선택한 디자인별로 컨테이너에 ID 배열이 있습니다. 이 배열은 컨테이너가 저장할 수 있는 최대 ID 수까지 미리 할당됩니다. 따라서 배열은 편리하게 두 ID 그룹을 저장할 수 있습니다.

  • 첫 번째 N 요소는 사용 중인 ID이며, 여기에서 N은 실제로 할당되는 핸들의 수입니다.
  • 나머지 요소는 사용 가능한 ID의 풀입니다.

이를 위해서는 생성 시 ID 배열을 사용 가능한 모든 ID 값으로 채워야 합니다. 즉, 마지막으로 사용된 바로 다음 ID를 통해 사용 가능한 ID를 수월하게 찾을 수 있으며, 검색할 필요가 없습니다. 그림 4에는 클래스 생성자와 Add 메서드의 코드가 나옵니다. 이러한 두 메서드를 함께 사용하여 사용 가능한 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에 나오는 코드는 Wait 메서드의 주요 부분입니다. Wait에는 몇 가지 변형이 있으며 모두 인덱스-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
}

다중 코어 고려 사항

이제 우리는 여러 스레드로 작업을 수행하여 컴퓨터의 효율을 끌어올릴 수 있는 “다중 코어”의 세계로 들어서고 있습니다. 이 세계에서도 이벤트 멀티플렉스가 중요할까요? 대부분의 응용 프로그램이 스레드당 이벤트를 하나씩 처리하게 되어 DynWaitList의 장점이 희석되지는 않을까요?

그렇지는 않을 것입니다. 다중 코어가 있는 컴퓨터에서도 이벤트 멀티플렉스가 중요할 것으로 예상하는 데는 적어도 다음과 같은 두 가지 이유가 있습니다.

  • 직렬로 액세스해야 하는 하드웨어 장치를 사용하므로 병렬화의 장점이 없는 작업이 일부 있습니다. 저수준 네트워킹이 한 예입니다.
  • 이벤트 멀티플렉스의 한 가지 장점(특히 유틸리티 라이브러리에서)은 응용 프로그램에 특정 스레드 모델을 강요하지 않는다는 것입니다. 최상위 응용 프로그램이 스레드 모델을 결정해야 합니다. 이러한 측면에서 응용 프로그램이 자체 스레드에 자유롭게 이벤트 배포를 선택할 수 있어야 하므로 이벤트 대기 목록의 캡슐화가 더 중요합니다.

단순한 코드, 적은 버그

요약하자면 지속성 있는 ID를 Windows WaitForMultipleObjects 함수에 전달되는 각 이벤트와 연결하면 응용 프로그램이 무의미한 이벤트 인덱스를 유용한 개체 핸들이나 포인터로 변환해야 하는 부담을 덜 수 있어 코드가 간소해지고 버그가 발생할 가능성이 낮아집니다. DynWaitList 클래스는 이 연결 프로세스를 이러한 Windows API 대기 함수를 포함하는 래퍼로 효과적으로 캡슐화합니다. order-N인 구성과 제거 처리를 제외하고 모든 작업에는 order-1이 사용됩니다. 배열을 정렬하면 추가적인 최적화를 달성할 수 있으며, 추가 작업에 약간의 성능 저하가 발생하지만 제거를 훨씬 빠르게 수행할 수 있습니다.

Alex Gimenez는 시애틀 근교에서 개발자로 근무하면서 Microsoft Lync를 제공하는 팀과 함께 실시간 오디오/비디오/네트워킹 소프트웨어를 개발하고 있습니다. 그는 거의 20년간 POS, 임베디드, 장치 드라이버 및 통신 소프트웨어를 개발한 경력을 가지고 있습니다. 여가 시간에는 자전거, 드럼 연주, 그리고 일본어 공부를 하고 있습니다. 문의 사항이 있으면 alexgim@microsoft.com으로 문의하시기 바랍니다.

이 문서를 검토하는 데 많은 도움을 주신 기술 전문가인 Bart Holmberg, Warren LamJiannan Zheng에게 감사 인사를 전합니다.