借助 C++ 进行 Windows 开发

Windows 和 C++ 同步功能的演变

Kenny Kerr

 

Kenny Kerr当我第一次开始写并发软件时,c + + 已不支持同步。Windows 本身了只有少数的同步基元,所有这些都在内核中实施。我倾向使用关键部分,除非跨进程,同步所需在这种情况下我使用互斥体。总体而言,这两个锁,或锁定的对象。

互斥体采用其名称从"相互排斥,"同步的另一个名字的概念。它是指只有一个线程可以在一次访问某些资源的保障。临界区从实际可能会访问此类资源的代码部分采用其名称。为确保正确性,只有一个线程可以一次执行此代码的临界区。这两个锁定对象具有不同的功能,但却不只是要记住他们是锁定、他们都提供相互排斥的保证和既可以用来标定代码的关键部分。

今天同步景观发生了巨大变化。有过多的 c + + 程序员的选择。Windows 现在支持更多的同步功能,和 c + + 本身终于提供并发和同步功能的有趣集合对于那些使用编译器支持 C + + 11 标准。

在本月的专栏我将探索在 Windows 和 c + + 中的同步的状态。我开始审查由 Windows 本身提供的同步基元,然后再考虑提供的标准 c + + 库的替代品。如果您主要关心的可移植性,新的 c + + 库添加内容将会非常吸引人。如果,然而,可移植性较少关注和性能极其重要,然后让你熟悉什么 Windows 现在提供了将重要。让我们深入右中。

关键节

第一次最多是关键节的对象。这锁由无数的应用程序使用的使用量,但已有肮脏的历史。当我第一次开始使用的关键部分时,她们真的很简单。若要创建这种锁,所有你需要是分配 CRITICAL_SECTION 结构并调用 InitializeCriticalSection 函数,以准备使用它。此函数不返回值,这意味着它不能失败。回来在那些日子里,不过,有必要为此函数来创建各种系统资源,尤其是内核事件对象,并有可能在极低内存的情况下这将失败,导致的引发的结构化异常。尽管如此,这是相当罕见,因此大多数开发人员忽略这种可能性。

COM 的普及,与关键节的使用暴涨因为许多 COM 类用于关键节进行同步,但在许多情况下是没有实际的争用的说话很少。多处理器计算机变得更加普遍,关键节内部事件看见更少使用因为关键节简要地将等待获取锁的同时旋转在用户模式下。小旋转计数,意味着许多短暂时期的争用可能避免内核过渡,大大提高性能。

在这段时间一些内核开发者意识到他们可以大大提高 Windows 的可扩展性,是否他们推迟临界区事件对象的创建,直到有足够的争用,要他们的存在。这似乎像个好主意,直至开发人员实现,这意味着虽然 InitializeCriticalSection 现在可能不可能会失败,EnterCriticalSection 函数,该函数 (用于等待锁所有权) 已不再可靠。这可不一样轻松地忽视由开发人员,因为它介绍了各种将已经取得关键节不可能以正确使用和破坏无数的应用程序的故障条件。仍然,可扩展性 wins 不能被忽视。

内核开发人员终于在解决方案中的一个新的和无证,内核事件对象称为键控的事件形式。你可以读起来有点有关它在书中,"Windows 内核,"由 Mark E.David A.Russinovich所罗门和约内斯库亚历克斯 (微软出版社,2012年),但基本上,而不是为每个关键节要求的事件对象,一个单一的键控的事件可用于所有关键节在系统中。这样做是因为键控的事件对象只是:它依靠的是只是一个指针大小标识符,自然是地址空间的关键地方。

肯定是更新关键节使用键控的事件完全是一种诱惑,但是如果内核未能分配一个定期事件对象因为许多调试器和其他工具依赖关键节的内部结构,键控的事件只使用作为最后的手段。

这听起来可能像很多不相干的历史但 Windows Vista 开发周期中,大大改进了性能的键控事件的事实,这导致引入一个全新的锁定对象,是更简单和更快 — — 但,在一分钟的时间更多。

关键节对象现时豁免低内存故障,它真的是非常简单的使用。图 1 提供了一个简单的包装。

图 1 的关键节锁

class lock
{
  CRITICAL_SECTION h;
  lock(lock const &);
  lock const & operator=(lock const &);
public:
  lock()
  {
    InitializeCriticalSection(&h);
  }
  ~lock()
  {
    DeleteCriticalSection(&h);
  }
  void enter()
  {
    EnterCriticalSection(&h);
  }
  bool try_enter()
  {
    return 0 != TryEnterCriticalSection(&h);
  }
  void exit()
  {
    LeaveCriticalSection(&h);
  }
  CRITICAL_SECTION * handle()
  {
    return &h;
  }
};

我已经提到的 EnterCriticalSection 函数被辅以一个 TryEnterCriticalSection 函数,提供无阻塞的替代方案。 LeaveCriticalSection 函数释放锁,并 DeleteCriticalSection 释放任何可能已拨出沿途的内核资源。

这样的关键部分是一个合理的选择。 它执行相当好,它会尝试避免内核转换和资源分配。 它仍有点由于其历史和应用程序的兼容性,它必须执行的行李。

互斥锁

Mutex 对象是一个真正的内核同步对象。 与关键章节不同互斥锁总是会消耗内核分配资源。 好处,当然,是内核然后就能够提供跨进程同步由于锁的认识。 作为内核对象,它提供了常用的属性 — — 如名称 — —,可以用于从其他进程打开的对象或只是确定在调试器中的锁定。 您还可以指定访问掩码来限制对该对象的访问。 作为一个 intraprocess 的锁,它的矫枉过正,稍微复杂的使用和慢好多。 图 2 为未命名的互斥体是有效过程本地提供一个简单的包装。

图 2 互斥锁

#ifdef _DEBUG
  #include <crtdbg.h>
  #define ASSERT(expression) _ASSERTE(expression)
  #define VERIFY(expression) ASSERT(expression)
  #define VERIFY_(expected, expression) ASSERT(expected == expression)
#else
  #define ASSERT(expression) ((void)0)
  #define VERIFY(expression) (expression)
  #define VERIFY_(expected, expression) (expression)
#endif
class lock
{
  HANDLE h;
  lock(lock const &);
  lock const & operator=(lock const &);
public:
  lock() :
    h(CreateMutex(nullptr, false, nullptr))
  {
    ASSERT(h);
  }
  ~lock()
  {
    VERIFY(CloseHandle(h));
  }
  void enter()
  {
    VERIFY_(WAIT_OBJECT_0, WaitForSingleObject(h, INFINITE));
  }
  bool try_enter()
  {
    return WAIT_OBJECT_0 == WaitForSingleObject(h, 0);
   }
  void exit()
  {
    VERIFY(ReleaseMutex(h));
  }
  HANDLE handle()
  {
    return h;
  }
};

CreateMutex 函数创建锁,并共同的 CloseHandle 函数关闭进程处理,其中有效锁的引用计数在内核中的递减。 等待锁所有权被通过普通用途的 WaitForSingleObject 函数,它检查,并可以选择等待各种内核对象的终止状态。 其第二个参数,指示在等待获取锁定时阻止调用线程应多长时间。 无限的常数是 — — 不奇怪 — — 无限期等待,虽然零值可以防止线程在所有等待和将只获取锁,如果是免费的。 最后,ReleaseMutex 函数释放锁。

互斥锁是大锤大量电能,但它也要付出代价的性能和复杂性。 在包装图 2 散落着断言来指示可能的方法它可能会失败,但它是取消资格互斥锁在大多数情况下的性能影响。

事件

我谈到高性能的锁之前,我需要引入一个更多内核同步对象,我已经提到的其中一个。 虽然实际上并没有一个锁,事件对象,因为它不能提供一个直接实现相互排斥的设施,是非常重要的工作线程之间的协调。 事实上,它是由关键节锁在内部使用的同一对象 — — 此外,它进来方便高效、 可扩展的方式实现所有类型的并发模式时。

CreateEvent 函数创建事件和 — — 喜欢互斥体 — — CloseHandle 功能关闭,释放内核对象的句柄。 因为它不是实际的锁,它有没有获得/释放语义。 它是由许多内核对象提供的信号传递功能的化身。 要了解如何信号的作品,你需要欣赏事件对象可以创建在两个国家之一。 如果您对 CreateEvent 的第二个参数传递,然后生成的事件对象是据说是手动重置事件 ; 否则创建自动重置事件。 手动重置事件需要您手动设置和重置该对象的终止的状态。 为此目的提供的 SetEvent 和 ResetEvent 的功能。 自动重置事件自动­◆ 重置 (更改从终止向受阻) 当释放等待线程。 所以自动重置事件非常有用的当一个线程需要协调与一个其他线程,而手动重置事件非常有用的当一个线程需要协调与任意数目的线程。 自动重置事件调用 SetEvent 将释放最一个线程,而与手动重置事件的调用将释放所有等待线程。 像互斥体,等待事件变为终止状态是 WaitForSingleObject 函数的情况下实现的。 图 3 提供一个简单的包装,为未命名的事件,可以在任一模式中构造。

图 3 事件信号

class event
{
  HANDLE h;
  event(event const &);
  event const & operator=(event const &);
public:
  explicit event(bool manual = false) :
    h(CreateEvent(nullptr, manual, false, nullptr))
  {
    ASSERT(h);
  }
  ~event()
  {
    VERIFY(CloseHandle(h));
  }
  void set()
  {
    VERIFY(SetEvent(h));
  }
  void clear()
  {
    VERIFY(ResetEvent(h));
  }
  void wait()
  {
    VERIFY_(WAIT_OBJECT_0, WaitForSingleObject(h, INFINITE));
  }
};

Slim 读取器锁/写入器锁

斯利姆读写器 (SRW) 锁的名称可能是一口,但重要的词是"减肥"。程序员可能会忽略此锁,因为它能够区分共享的读者和专属的作家,或许以为这大材小用,当他们需要的只是关键的一段。 事实证明,这是最简单的锁,以处理和还到目前为止最快,你当然不需要有共享的读者才能使用它。 它有此迅速的声誉,不只是因为它依赖高效键控的事件对象,而且还因为它大多是实施在用户模式下,仅回退到内核如果争用的线程会更好睡。 再次,关键节和 mutex 对象提供附加功能可能需要的情况下,如递归或进程间的锁,但往往不你需要的一切是快速和轻量级的锁,供内部使用。

这种锁完全依赖我之前,提到的键控事件,这种极轻量级尽管提供了大量的功能。 SRW 锁需要只有指针-­大小的存储,通过调用进程,而不是内核分配量。 出于此原因,初始化函数,InitializeSRWLock,不能失败,只是确保锁在使用之前包含相应的位模式。

等待锁所有权实现使用任一获取­SRWLockExclusive 函数的所谓编写器锁或使用 AcquireSRWLockShared 函数读取器锁。 但是,更适当的独占性和共享的术语。 有相应版本并尝试获取函数,是希望为这两个独占并共享模式。 图 4 为独占模式 SRW 锁提供一个简单的包装。 它不会为您要添加的共享模式功能,如果需要硬。 但是,请注意是没有析构函数,因为没有要释放的资源。

图 4 SRW 锁

class lock
{
  SRWLOCK h;
  lock(lock const &);
  lock const & operator=(lock const &);
public:
  lock()
  {
    InitializeSRWLock(&h);
  }
  void enter()
  {
    AcquireSRWLockExclusive(&h);
  }
  bool try_enter()
  {
    return 0 != TryAcquireSRWLockExclusive(&h);
  }
  void exit()
  {
    ReleaseSRWLockExclusive(&h);
  }
  SRWLOCK * handle()
  {
    return &h;
  }
};

条件变量

我需要介绍的最后同步对象是条件变量。 这也许是大多数程序员将会与不熟悉的人。 我,不过,注意条件变量的兴趣在最近几个月。 这可能会是与 C + + 11,但是这个想法不是新的并对这一概念流传了一段时间在 Windows 上的支持。 事实上,Microsoft.NET 框架以来一直支持条件变量模式最早的版本中,尽管它已合并到显示器类中,限制它在某些方面的作用。 但此新的兴趣也是由于允许条件变量将推出的 Windows Vista 中,令人惊叹的键控事件,他们才有改善以来。 虽然条件变量是只是一个并发模式,因此,可以与其他基元执行,在操作系统中的将其列入意味着它可以实现惊人的性能,并释放,程序员无需确保此类代码的正确性。 事实上,如果你正在雇用 OS 同步基元,是几乎不可能确保没有操作系统本身的帮助一些并发模式的正确性。

条件变量模式是很常见的如果你想想看。 程序需要等待才可应满足一些条件。 评估此条件涉及获取锁,评估一些共享的状态。 如果,然而,尚未满足还没条件,必须释放锁允许一些其他线程来满足该条件。 评价的线程必须等待,再一次获取锁之前满足的条件。 一旦重新获取锁,是条件必须重新评估避免明显争用条件。 执行本很难似乎因为事实上,有很多其他的陷阱需要担心 — — 和实施有效的方式将更加困难。 下面的伪代码阐释了这一问题:

lock-enter
while (!condition-eval)
{
  lock-exit
  condition-wait
  lock-enter
}
// Do interesting stuff here
lock-exit

但即使在此图中是一个微妙的 bug。 才能正常工作,必须对等条件之前退出该锁,但这样做不会工作因为锁将永远不会再被释放。 以原子方式释放一个对象和等待另一个的能力至关重要 Windows 提供了要做如此的某些内核对象的 SignalObjectAndWait 函数。 但因为 SRW 锁住大多是在用户模式下,需要一个不同的解决方案。 输入条件变量。

像 SRW 锁,条件变量占地只有单一指针大小的存储量,使用故障保护的 InitializeConditionVariable 函数初始化。 如用 SRW 锁,没有资源要释放,因此不再需要的条件变量时只是可以回收的内存。

因为本身的条件是特定于程序的它由调用方写一段作为模式正在单个调用 SleepConditionVariableSRW 函数的身体循环。 此函数以原子方式等以醒,一旦满足条件时释放 SRW 锁。 还有一个相应的 SleepConditionVariableCS 函数如果您想要使用条件变量与关键节锁相反。

WakeConditionVariable 函数调用来唤醒单一的等待,或睡觉的时候,线程。 在返回之前,倭的线程将重新获取锁。 或者,可以使用 WakeAllConditionVariable 函数来唤醒所有等待的线程。 图 5提供一个简单的包装,有必要的 while 循环。 请注意有可能无法预知醒睡线程并且 while 循环可以确保条件始终重新检查后重新获取该锁的线程。 它也是重要的是要注意始终计算该谓词时同时锁住。

图 5 的条件变量

class condition_variable
{
  CONDITION_VARIABLE h;
  condition_variable(condition_variable const &);
  condition_variable const & operator=(condition_variable const &);
public:
  condition_variable()
  {
    InitializeConditionVariable(&h);
  }
  template <typename T>
  void wait_while(lock & x, T predicate)
  {
    while (predicate())
    {
      VERIFY(SleepConditionVariableSRW(&h, x.handle(), INFINITE, 0));
    }
  }
  void wake_one()
  {
    WakeConditionVariable(&h);
  }
  void wake_all()
  {
    WakeAllConditionVariable(&h);
  }
};

阻塞队列

为此一些形状,我将作为示例使用阻断队列。 让我强调我不建议一般阻塞队列。 您可能较好,使用 I/O 完成端口或 Windows 线程池,其中超过前者或甚至并发运行 concurrent_queue 类是一个抽象的概念。 任何非阻塞是一般首选。 仍然,阻塞队列是一个简单的概念来把握和许多开发商似乎找到有用的东西。 无可否认,不是每个程序需要缩放,但每个程序需要是正确的。 阻断队列还提供了充分的机会聘请的正确性同步 — — 和,当然,充分机会搞错了。

应考虑实施与刚刚锁和事件的阻断队列。 锁保护共享的队列和事件信号给消费者生产者已经推到队列上的东西。 图 6 提供了一个简单的例子,使用自动重置事件。 我使用此事件模式,因为 push 方法队列只有一个元素,并因此,我只想要一个消费者要吵醒,其弹出该队列。 Push 方法获取该锁、 队列元素,然后信号要唤醒任何等待消费者的事件。 流行的方法获取该锁,然后等待,直到队列不为空之前出列元素并将其返回。 这两种方法使用 lock_block 类。 为简洁起见,它还没被列入,但它只需调用该锁在其构造函数和退出方法在其析构函数中输入方法。

图 6 自动重置阻塞队列

template <typename T>
class blocking_queue
{
  std::deque<T> q;
  lock x;
  event e;
  blocking_queue(blocking_queue const &);
  blocking_queue const & operator=(blocking_queue const &);
public:
  blocking_queue()
  {
  }
  void push(T const & value)
  {
    lock_block block(x);
    q.push_back(value);
    e.set();
  }
  T pop()
  {
    lock_block block(x);
    while (q.empty())
    {
      x.exit(); e.wait(); // Bug!
x.enter();
    }
    T v = q.front();
    q.pop_front();
    return v;
  }
};

但是,注意可能死锁,因为退出和等待调用不是原子。 如果互斥锁,我可以使用 SignalObjectAndWait 函数,但阻塞队列的性能会受到影响。

另一个选项是使用手动重置事件。 信号转导只要排队一个元素,而只需定义两个国家。 事件可以为终止状态,只要有元素在队列中,并无信号时它是空的。 这还将执行很多更好地因为有少到内核的调用来发出事件信号工作。 图 7 提供了这样一个示例。 请注意如何 push 方法设置的事件,如果队列有一个元素。 这样可以避免不必要调用 SetEvent 函数。 流行的方法尽职尽责地清除事件,如果它发现队列为空。 只要有多个排队的元素,任意数量的消费者可以弹出关闭队列元素而不涉及该事件对象,从而提高了可扩展性。

图 7 手动重置阻塞队列

template <typename T>
class blocking_queue
{
  std::deque<T> q;
  lock x;
  event e;
  blocking_queue(blocking_queue const &);
  blocking_queue const & operator=(blocking_queue const &);
public:
  blocking_queue() :
    e(true) // manual
  {
  }
  void push(T const & value)
  {
    lock_block block(x);
    q.push_back(value);
    if (1 == q.size())
    {
      e.set();
    }
  }
  T pop()
  {
    lock_block block(x);
    while (q.empty())
    {
      x.exit();
      e.wait();
      x.enter();
    }
    T v = q.front();
    q.pop_front();
    if (q.empty())
    {
      e.clear();
    }
    return v;
  }
};

在这种情况下没有潜在的死锁退出等待输入序列中另一个消费者无法窃取该事件,因为考虑到它是一个手动重置事件。 很难打败,在性能方面。 不过,另一种方法 (和或许更自然) 的解决方案是使用条件变量而不是一个事件。 这与 condition_variable 类中轻松地完成图 5 ,它类似于手动重置阻断队列,虽然它是简单些。 图 8 提供了一个示例。 请注意如何的语义和并发的意图更加清楚所雇用人数更高级的同步对象。 这种明确有助于避免经常困扰着更多晦涩的代码的并发 bug。

图 8 条件变量阻塞队列

template <typename T>
class blocking_queue
{
  std::deque<T> q;
  lock x;
  condition_variable cv;
  blocking_queue(blocking_queue const &);
  blocking_queue const & operator=(blocking_queue const &);
public:
  blocking_queue()
  {
  }
  void push(T const & value)
  {
    lock_block block(x);
    q.push_back(value);
    cv.wake_one();
  }
  T pop()
  {
    lock_block block(x);
    cv.wait_while(x, [&]()
    {
      return q.empty();
    });
    T v = q.front();
    q.pop_front();
    return v;
  }
};

最后,我应该提及,C + + 11 现在提供了一把锁,称为互斥体,以及 condition_variable。 C + + 11 互斥体与 Windows 互斥体无关。 同样,C + + 11 condition_variable 并不基于 Windows 的条件变量。 这是在可移植性方面的好消息。 可以使用任何地方都可以找到符合的 c + + 编译器。 另一方面,C + + 11 实现在 Visual c + + 2012年版本中执行很糟相比,Windows SRW 锁和条件变量。 图 9 提供一个示例与标准 C 执行阻断队列 + + 11 的库类型。

图 9 + + 11 阻塞队列

template <typename T>
class blocking_queue
{
  std::deque<T> q;
  std::mutex x;
  std::condition_variable cv;
  blocking_queue(blocking_queue const &);
  blocking_queue const & operator=(blocking_queue const &);
public:
  blocking_queue()
  {
  }
  void push(T const & value)
  {
    std::lock_guard<std::mutex> lock(x);
    q.push_back(value);
    cv.
notify_one();
  }
  T pop()
  {
    std::unique_lock<std::mutex> lock(x);
    cv.wait(lock, [&]()
    {
      return !q.empty();
    });
    T v = q.front();
    q.pop_front();
    return v;
  }
};

作为一般会并发磁带库的支持,在时间,无疑将改善标准 c + + 库执行。 C + + 委员会采取了一些小型的、 保守的步骤,对并发性支持,应该承认,但工作尚未完成。 讨论我最后三个列中时,未来的 c + + 并发是仍然问题。 现在,在 Windows 和先进的 c + + 编译器一些优秀同步基元的结合,使生产轻量和可扩展性的并发安全程序令人信服的工具包。

Kenny Kerr 是一位热衷于本机 Windows 开发的软件专家。您可以通过 kennykerr.ca 与他联系。

衷心感谢以下技术专家对本文的审阅:穆罕默德 · 阿米乃易卜拉欣