WinAPI / C++ 中的轻量级事件

4

在WinAPI/C++中是否有一些轻量级(因此快速)的事件?特别是,当事件被设置时,我对最小化等待事件所花费的时间很感兴趣(例如WaitForSingleObject())。以下是一个代码示例,以进一步说明我的意思:

#include <Windows.h>
#include <chrono>
#include <stdio.h>

int main()
{
  const int64_t nIterations = 10 * 1000 * 1000;
  HANDLE hEvent = CreateEvent(nullptr, true, true, nullptr);
  auto start = std::chrono::high_resolution_clock::now();
  for (int64_t i = 0; i < nIterations; i++) {
    WaitForSingleObject(hEvent, INFINITE);
  }
  auto elapsed = std::chrono::high_resolution_clock::now() - start;
  double nSec = 1e-6 * std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
  printf("%.3lf Ops/sec\n", nIterations / nSec);
  return 0;
}

在3.85GHz Ryzen 1800X上,我每秒获得7209623.405个操作,意味着平均每个检查事件是否设置需要534个CPU时钟(或138.7纳秒)。
然而,我想在性能关键代码中使用此事件,其中大多数时间实际上设置了事件,因此只是一个特殊情况的检查,在这种情况下,控制流程进入不是性能关键的代码(因为这种情况很少发生)。
WinAPI事件(使用CreateEvent创建)由于安全属性和名称而变得笨重。它们用于进程间通信。也许WaitForSingleObject()很慢是因为即使事件被设置,它仍然从用户模式切换到内核模式,然后再切换回来。此外,这个函数必须为手动和自动复位事件表现出不同的行为,检查事件类型也需要时间。
我知道可以使用atomic_flag实现快速用户模式互斥锁(自旋锁)。可以通过在自旋循环中添加std::this_thread::yield()来让其他线程在自旋时运行。
对于事件,我不想完全等价于自旋锁,因为当事件未设置时,可能需要相当长的时间才能再次设置。如果每个需要设置事件的线程都开始自旋直到再次设置它,那将是对CPU电力的巨大浪费(虽然如果它们调用std::this_thread::yield,不应影响系统性能)。
因此,我更喜欢临界区的类比,通常只在用户模式下执行工作,当它意识到需要等待时(超过自旋次数),它将切换到内核模式并在重量级同步对象(如互斥量)上等待。
UPDATE1: 我发现 .NET 有 ManualResetEventSlim,但在 WinAPI / C++ 中找不到相应的东西。
UPDATE2: 因为需要使用事件的详细信息,所以在这里提供。我正在实现一个知识库,可以在常规模式和维护模式之间切换。一些操作仅适用于维护模式,一些操作仅适用于常规模式,某些操作可以在两种模式下工作,但其中一些在维护模式下更快,另一些在常规模式下更快。每个操作在启动时都需要知道它是在维护模式还是常规模式下运行,因为逻辑会改变(或者操作根本不会执行)。用户可以随时请求在维护模式和常规模式之间切换,但这很少见。当此请求到达时,旧模式中不能启动新操作(尝试这样做将失败),应用程序等待旧模式中当前操作完成,然后切换模式。因此,轻量级事件是此数据结构的一部分:除了模式切换之外的操作必须快速完成,因此它们需要快速设置/重置/等待事件。

1
所以我更喜欢一个关键部分的类比,为什么不使用它或者说新的Slim Reader/Writer Locks?或者你需要进程间同步? - RbMm
1
IOCP(输入/输出完成端口)是什么? - sailfish009
atomic_bool 与手动重置事件对象结合起来应该很容易。 当您设置或重置事件时,也设置或重置布尔值。 在等待事件之前,请检查布尔值;如果已设置,则不需要等待。(我想 atomic_bool 实现效率很高,但是如果您愿意,也可以使用 Win32 原子操作。) - Harry Johnston
不了解你的情况,很难给出建议。你的目标是什么?需要同步或通知哪些代码? - RbMm
@RbMm,我认为我已经解释了那个光事件的预期使用方式:就像在我的代码示例中一样,该事件通常是设置的,因此当事件被设置时,我希望等待它尽可能快(实际上没有任何等待)。 - Serge Rogatch
显示剩余8条评论
2个回答

2

从win8开始,您可以使用WaitOnAddress (代替WaitForSingleObject)、WakeByAddressAll (类似于SetEventNotificationEvent)和WakeByAddressSingle (类似于SynchronizationEvent)来实现更好的解决方案。更多详细信息请阅读:WaitOnAddress让您创建同步对象

实现方法如下:

class LightEvent 
{
    BOOLEAN _Signaled;
public:
    LightEvent(BOOLEAN Signaled)
    {
        _Signaled = Signaled;
    }

    void Reset()
    {
        _Signaled = FALSE;
    }

    void Set(BOOLEAN bWakeAll)
    {
        _Signaled = TRUE;
        (bWakeAll ? WakeByAddressAll : WakeByAddressSingle)(&_Signaled);
    }

    BOOL Wait(DWORD dwMilliseconds = INFINITE)
    {
        BOOLEAN Signaled = FALSE;

        while (!_Signaled)
        {
            if (!WaitOnAddress(&_Signaled, &Signaled, sizeof(BOOLEAN), dwMilliseconds))
            {
                return FALSE;
            }
        }
        return TRUE;
    }
};

不要忘记将Synchronization.lib添加到链接器输入中。
这个新API的代码非常有效,它们不会为等待(如事件)创建内部内核对象,而是使用专门为此目标设计的新APIZwAlertThreadByThreadIdZwWaitForAlertByThreadId
在Win8之前如何实现这个?乍一看很简单——布尔变量+事件句柄。必须像这样:
void Set()
{
  SetEvent(_hEvent);
   // Sleep(1000); // simulate thread innterupted here
  _Signaled = true;
}

void Reset()
{
  _Signaled = false;
  // Sleep(1000); // simulate thread innterupted here
  ResetEvent(_hEvent);
}

void Wait(DWORD dwMilliseconds = INFINITE)
{
  if(!_Signaled) WaitForSingleObject(_hEvent);
}

但是这段代码确实是错误的。问题在于我们在SetReset)中进行了两个操作——更改_Signaled_hEvent的状态,并且无法以原子/交换操作的方式从用户模式进行此操作。这意味着在线程执行这两个操作之间可能会被中断。假设有2个不同的线程在并发调用SetReset,则在大多数情况下,操作将按照以下顺序执行:
  SetEvent(_hEvent);
  _Signaled = true;
  _Signaled = false;
  ResetEvent(_hEvent);

这里一切正常。但是可能会出现下一个订单(取消注释一个Sleep以测试)。

  SetEvent(_hEvent);
  _Signaled = false;
  ResetEvent(_hEvent);
  _Signaled = true;

作为结果,当_Signaledtrue时,_hEvent将处于复位状态。
自己实现这个原子操作而没有操作系统的支持是不简单但可能的。但首先我会看一下这个的使用方式-用于什么?事件类似的行为是否正好是你需要完成的任务所需?

设置/重置操作只需要在彼此之间是原子的,而不需要与任何其他线程相关,因此可以使用临界区来完成。 (甚至可能不必要;很可能只有一个特定的线程将修改事件的状态。)如果您不需要支持Windows 7,则WaitOnAddress会为您节省一些工作。但这当然不是必需的 - Harry Johnston
我认为变量应该是原子的或通过交错操作访问,以确保其他CPU核心可以及时看到“_Signaled = true;”。至少它需要是易失性的,以确保编译器不会优化测试,如果Wait()函数被内联。 - Harry Johnston
@HarryJohnston - 关于 _Signaled 上的原子操作 - 我认为我们在这里没有任何收益 - 是的,可能存在这样的情况,当我们在并发调用 WaitSet 时 - 我们已经执行了 _Signaled = true; 但是调用 Wait 的线程仍然从 _Signaled 中读取到 false 并等待事件(无论如何,在这种情况下我们已经设置了事件)。但是,即使我们对 _Signaled = true; 使用原子/交换操作,这种情况仍然可能发生 - 调用 Wait 的线程仍然可以在我们原子/交换将其设置为 true 之前读取 _Signaled 的状态。 - RbMm
另一方面,如果频繁使用lock前缀(当我们修改_Signaled时),这可能会降低性能。如果SetReset总是从同一个线程调用 - 在这种情况下,_Signaled_hEvent修改之间不会出现问题。然而,对我来说仍然不清楚所有这些将如何/用于什么。例如,在并发中调用WaitResetWait将在重置之前执行,当_Signaled仍为true时,这将是可以的 - 当事实上_hEvent已经被重置时,在工作线程中做一些事情? - RbMm
也许 OP 只需要这样的代码吗?do { if (_bTaskToDo) DoSomething(); else WaitForSingleObject(_hEvent); } while (!bQuit);set(){ _bTaskToDo = TRUE; SetEvent(_hEvent);}reset(){ ResetEvent(_hEvent);_bTaskToDo = FALSE; } - RbMm
抱歉,我没有看到这个问题,请跳过。不过这可能很有趣。谢谢! - RbMm

1
另一个答案非常好,如果您可以放弃对Windows 7的支持。

但是在Win7上,如果您从多个线程多次设置/重置事件,但只需要很少地睡眠,则所提出的方法相当慢。

相反,我使用由临界区保护的布尔值,并使用条件变量来唤醒/休眠。

等待方法将使用SleepConditionVariableCS API进入内核以进行睡眠,这是预期的并且是您想要的。

然而,设置和重置方法将完全在用户模式下工作:设置单个布尔变量非常快,即在99%的情况下,临界区将执行其用户模式无锁魔术。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接