9 种可重复使用的并行数据结构和算法

本文详细介绍了并发编程中倒计数锁存和旋转等待的概念、应用及其实现方法,旨在提高程序员对并发控制的理解与实践能力。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

9 种可重复使用的并行数据结构和算法
2011年10月16日
  倒计数锁存 (Countdown Latch)Semaphore 之所以成为并发编程中一种较为知名的数据结构,原因是多方面的,而并不只是因为它在计算机科学领域有着悠久的历史(可以追溯到 19 世纪 60 年代的操作系统设计)。Semaphore 只是一种带有一个计数字段的数据结构,它只支持两种操作:放置和取走(通常分别称为 P 和 V)。一次放置操作会增加一个 semaphore 计数,而一次取走操作会减少一个 semaphore 计数。当 semaphore 计数为零时,除非执行一项并发的放置操作使计数变为非零值,否则任何后续的取走尝试都将阻塞(等待)。这两种操作均为不可再分 (atomic) 操作,并发时不会产生错误,能够确保并发的放置和取走操作有序地进行。Windows®具有基础内核和对 semaphore 对象的 Win32® 支持(请参阅 CreateSemaphore 和相关 API),并且在 .NET Framework 中这些对象可以通过 System.Threading.Semaphore 类公开到上层。Mutex 和 Monitor 所支持的临界区,通常被认为是一种特殊的 semaphore,其计数会在 0 和 1 之间来回切换,换句话说,是一个二进制的 semaphore。另外还有一种“反向 semaphore”也是非常有用。也就是说,有时您需要数据结构能够等待数据结构计数归零。Fork/join 式并行模式在数据并行编程中是极为常见的,其中由单个“主”线程控制执行若干“辅助”线程并等待线程执行完毕。在这类情况下,使用反向 semaphore 会很有帮助。大多数时候,您其实并不想唤醒线程来修改计数。因此在这种情况下,我们将结构称为倒计数“锁存”,用来表示计数的减少,同时还表明一旦设置为“Signaled”状态,锁存将保持“signaled”(这是一个与锁存相关的属性)。遗憾的是,Windows 和 .NET Framework 均不支持这种数据结构。但令人欣慰的是,构建这种数据闭锁并不难。要构建一个倒计数锁存,只需将其计数器的初始值设为 n,并让每项辅助任务在完成时不可再分地将 n 减掉一个计数,这一点可以通过为减量操作加上“锁”或调用 Interlocked.Decrement 来实现。接下来,线程可以不执行取走操作,而是减少计数并等待计数器归零;而当线程被唤醒时,它就可以得知已经有 n 个信号向锁存注册。在 while (count != 0) 循环中,让等待的线程阻塞通常是不错的选择(这种情况下,您稍后将不得不使用事件),而不是使用旋转。 [b]图 1[/b] 是一个简单的 CountdownLatch 类型的示例。
  
   Figure 1 CountdownLatch
  public class CountdownLatch { private int m_remain; private EventWaitHandle m_event; public CountdownLatch(int count) { m_remain = count; m_event = new ManualResetEvent(false); } public void Signal() { // The last thread to signal also sets the event. if (Interlocked.Decrement(ref m_remain) == 0) m_event.Set(); } public void Wait() { m_event.WaitOne(); }}
  这看上去极为简单,但要正确运用还需要技巧。稍后我们将通过一些示例来讲解如何使用这种数据结构。请注意,此处所示的基本实现还有很多可以改进的地方,例如:在事件上调用 WaitOne 之前添加某种程度的旋转等待、缓慢分配事件而不是在构造器中进行分配(以防足够的旋转会避免出现阻塞,如本专栏稍后介绍的 ThinEvent 演示的那样)、添加重置功能以及提供 Dispose 方法(以便在不再需要内部事件对象时将对象关闭)。这些都是留给读者作为练习之用。
  可重用旋转等待 (Spin Wait)虽然忙碌等待 (busy waiting) 更容易实现阻塞,但在某些情况下,您也许的确想在退回到真正的等待状态前先旋转 (spin) 一段时间。我们很难理解为何这样做会有帮助,而大多数人之所以一开始就避免旋转等待,是因为旋转看上去像是在做无用功;如果上下文切换(每当线程等待内核事件时都会发生)需要几千个周期(在 Windows 上确实是这样),我们称之为 c,并且线程所等待的条件出现的时间少于 2c 周期时间(1c 用于等待自身,1c 用于唤醒),则旋转可以降低等待所造成的系统开销和滞后时间,从而提升算法的整体吞吐量和可伸缩性。如果您决定使用旋转等待,就必须谨慎行事。因为如果这样做,您可能需要注意很多问题,比如:要确保在旋转循环内调用 Thread.SpinWait,以提高 Intel 超线程技术的计算机上硬件对其他硬件线程的可用性;偶尔使用参数 1 而非 0 来调用 Thread.Sleep,以避免优先级反向问题;通过轻微的回退 (back-off) 来引入随机选择,从而改善访问的局部性(假定调用方持续重读共享状态)并可能避免活锁;当然,在单 CPU 的计算机最好不要采用这种方法(因为在这种环境下旋转是非常浪费资源的)。SpinWait 类需要被定义为值类型,以便分配起来更加节省资源(请参见[b]图 2[/b])。现在,我们可以使用此算法来避免前述 CountdownLatch 算法中出现的阻塞。
  
   Figure 2 SpinWait
  public struct SpinWait { private int m_count; private static readonly bool s_isSingleProc = (Environment.ProcessorCount == 1); private const int s_yieldFrequency = 4000; private const int s_yieldOneFrequency = 3*s_yieldFrequency; public int Spin() { int oldCount = m_count; // On a single-CPU machine, we ensure our counter is always // a multiple of ‘s_yieldFrequency’, so we yield every time. // Else, we just increment by one. m_count += (s_isSingleProc ? s_yieldFrequency : 1); // If not a multiple of ‘s_yieldFrequency’ spin (w/ backoff). int countModFrequency = m_count % s_yieldFrequency; if (countModFrequency > 0) Thread.SpinWait((int)(1 + (countModFrequency * 0.05f))); else Thread.Sleep(m_count 0) { if (s.Spin() >= s_spinCount) m_event.WaitOne(); }}
  不可否认,选择频率和旋转计数是不确定的。与 Win32 临界区旋转计数类似,我们应该根据测试和实验的结果来选择合理的数值,而且即使合理的数值在不同系统中也会发生变化。例如,根据 Microsoft Media Center 和 Windows kernel 团队的经验,MSDN 文档建议临界区旋转计数为 4,000 ,但您的选择可以有所不同。理想的计数取决于多种因素,包括在给定时间等待事件的线程数和事件出现的频率等。大多数情况下,您会希望通过等待事件来消除显式让出时间,如锁存的示例中所示。您甚至可以选择动态调整计数:例如,从中等数量的旋转开始,每次旋转失败就增加计数。一旦计数达到预定的最大值,就完全停止旋转并立即发出 WaitOne。逻辑如下所示:您希望立即增加达到预定的最大周期数,但却无法超过最大周期数。如果您发现此最大值不足以阻止上下文切换,那么立即执行上下文切换总的算来占用的资源更少。慢慢您就会希望旋转计数能够达到一个稳定的值。屏障 (Barrier)屏障,又称集合点,是一种并发性基元,它无需另一“主”线程控制即可实现各线程之间简单的互相协调。每个线程在到达屏障时都会不可再分地发出信号并等待。仅当所有 n 都到达屏障时,才允许所有线程继续。这种方法可用于协调算法 (cooperative algorithms),该算法广泛应用于科学、数学和图形领域。很多计算中都适合使用屏障,实际上,甚至 CLR 的垃圾收集器都在使用它们。屏障只是将较大的计算分割为若干较小的协作阶段 (cooperative phase),例如:
  const int P = ...;Barrier barrier = new Barrier(P);Data[] partitions = new Data[P];// Running on ‘P’ separate threads in parallel:public void Body(int myIndex) { FillMyPartition(partitions[myIndex]); barrier.Await(); ReadOtherPartition(partitions[P
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值