Windows API 等待函数

DynWaitList:基于 ID 的 Windows 事件多路复用

Alex Gimenez

下载代码示例

Microsoft Windows 提供了通过 WaitForMultipleObjects 方法及其变体对多个事件进行多路复用侦听的功能。 这些函数功能强大,但不便于在动态事件列表中使用。

困难在于事件信号用索引 标识在对象句柄数组中。 当在该数组中间添加或删除事件时,此类索引将变换。

通常,此类问题通过使用存储句柄的容器、包装数组并代表客户端应用程序执行插入、删除和查找来解决。

本文将讨论此类容器类的设计和实现。 容器存储 WaitForMultipleObjects 方法使用的事件句柄。 容器类的用户通过数字 ID 引用各个句柄,在容器的生存期内,甚至在添加或删除事件时,该数字 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 意味着已向网络发送信号。 现在,如果需要关闭 audioInHandle 以响应从 socketHandle 触发的事件,会出现什么情况? 您将需要删除句柄数组中的索引 0,这将变换大于 0 的索引,意味着 MY_SOCKET_EVENT 值需要是动态的,而不是常量。

虽然有多种解决这种问题的方法(例如,在数组末尾保留可选句柄或变换数组的开头),但随着事件增多以及错误路径的处理(索引偏离 WAIT_ABANDONED_0),这些方法将很快变得混乱。

乍一看,问题是您不能使用常量标识事件句柄。 但通过仔细观察,我们发现此接口的根本问题在于它使用数组索引来标识事件句柄。 这样,索引在此处承担着双重任务:既表示句柄在内存中的位置又表示已向事件发送信号的事实,这造成了不便。

理想的情况是,被发送信号的事件可从数组中的索引独立标识。 这正是 DynWaitList 类的用武之处。

使用 DynWaitList

DynWaitList 类是要为 WaitForMultipleObjects 方法传递的句柄数组的容器列表。 句柄的内部集合具有静态最大大小。 类作为模板实现,而集合的最大大小是唯一的模板参数。

容器接口包含所需的方法:用于插入事件并指定其 ID 的 Add 方法,用于删除事件以及一些 Wait 变体的 Remove 方法。 图 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 很多、事先不熟悉的情形。 下面是一些常见的情形:

  • TCP 服务器,它存放当前连接的每个客户端套接字的事件 ID。 由于客户端套接字随各个连接切换,这种情形最能充分利用动态事件列表。
  • 混音应用程序或 IP 电话应用程序,其中包含等待系统上每个音频设备帧就绪/计时器信号的句柄。

至此,这些示例有一个共同的主题:动态句柄列表代表应用程序所处的不断变化的外部环境。

设计和性能方面的注意事项

实现容器需要平衡选取性能、简单性以及存储空间这些冲突的目标。 这些需要根据最常见的容器使用情况(如前文所述)进行评估。 这有助于枚举要在容器上执行的操作以及这些操作的出现频率:

  • 添加句柄: 频繁
  • 删除句柄: 与添加句柄的频率大致相同
  • 更改句柄: 不适用(无法在 Windows 中更改现有对象的句柄)
  • 将容器转换为 Windows 需要的单根数组: 频繁
  • 检索刚发送信号的句柄的值: 频繁

鉴于这些操作,我决定将内部存储设置为一个事件句柄数组(Windows 需要的那种)以及一个并行的 ID(16 位值)数组。 通过这种并行数组安排可以在索引和事件 ID 之间进行有效的转换。 具体来说:

  • Windows 需要的数组始终可用。
  • 在给定 Windows 返回索引的情况下,查找其 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 ];
};

请注意该类是如何拆分为两个类的,其中一个基类存放数组指针,一个模板派生类存放实际存储。 这样,通过派生不同的模板类,可以灵活进行动态数组分配(如果需要)。 这种实现完全使用静态存储。

添加句柄

将句柄添加到数组十分简单,但查找空闲 ID 来表示新建事件的操作除外。 根据所选设计,容器中将存在一个 ID 数组。 此数组是预先分配的,用于支持容器可以存放的最大 ID 数。 因此,数组可以方便地存放两组 ID:

  • N 个元素是正在使用的 ID,其中 N 表示实际分配的句柄数。
  • 剩余的元素构成一个空闲 ID 池。

这要求在构造时使用所有可能的 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 的转换优化为 1 阶,但这会降低转换的性能。 在给定 ID 的情况下,需要执行线性搜索(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 也十分简单。 Wait 方法的主要内容是代码,如图 6 所示。 Wait 有一些变体,所有变体都使用内部 TranslateWaitResult 方法执行索引到 ID 的转换。

图 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 等待函数的包装内。 涉及的所有操作都属于 1 阶,但构造和句柄删除除外,它们属于 N 阶。 通过对数组排序可以进行进一步优化,这在提高句柄删除速度的同时,会轻微降低句柄添加操作的性能。

Alex Gimenez 是一名开发人员,当前在西雅图地区工作,与负责交付 Microsoft Lync 的团队一起编写实时音频/视频/联网软件。他有约二十年的经验,涉及销售点、嵌入式、设备驱动程序和电信软件。在业余时间,他喜欢骑车旅行、打鼓和学习日语。可通过 alexgim@microsoft.com 与他联系。

衷心感谢以下技术专家对本文的审阅:Bart Holmberg、Warren LamJiannan Zheng