Xbox 360 和 Microsof Windows 的无锁编程注意事项

无锁编程是一种安全地共享多个线程之间更改数据的方法,无需获取和释放锁的成本。 这听起来像是一种灵丹妙药,但无锁编程是复杂而微妙的,有时不会赋予它承诺的好处。 无锁编程在Xbox 360尤其复杂。

无锁编程是多线程编程的有效技术,但不应轻用。 在使用它之前,必须了解复杂性,并且应该仔细衡量,以确保它实际上是给你预期的收益。 在许多情况下,有更简单、更快的解决方案,例如共享数据的频率较低,应该改用这些数据。

正确且安全地使用无锁编程需要对硬件和编译器有重大了解。 本文概述了尝试使用无锁编程技术时要考虑的一些问题。

使用锁编程

编写多线程代码时,通常需要在线程之间共享数据。 如果多个线程同时读取和写入共享数据结构,则可能会出现内存损坏。 解决此问题的最简单方法是使用锁。 例如,如果一次只能由一个线程执行 ManipulateSharedData,则可以使用CRITICAL_SECTION来保证这一点,如以下代码所示:

// Initialize
CRITICAL_SECTION cs;
InitializeCriticalSection(&cs);

// Use
void ManipulateSharedData()
{
    EnterCriticalSection(&cs);
    // Manipulate stuff...
    LeaveCriticalSection(&cs);
}

// Destroy
DeleteCriticalSection(&cs);

此代码相当简单且简单,并且很容易判断它是正确的。 但是,使用锁编程具有几个潜在的缺点。 例如,如果两个线程尝试获取相同的两个锁,但以不同的顺序获取它们,你可能会获得死锁。 如果程序持有锁定时间过长(由于设计不佳或线程已由优先级较高的线程交换),则其他线程可能会长时间被阻止。 对于Xbox 360,这种风险特别大,因为软件线程由开发人员分配硬件线程,并且操作系统不会将它们移动到另一个硬件线程,即使有一个线程处于空闲状态也是如此。 Xbox 360也没有保护优先级反转,其中高优先级线程在等待低优先级线程释放锁时在循环中旋转。 最后,如果延迟的过程调用或中断服务例程尝试获取锁,可能会获得死锁。

尽管存在这些问题,但同步基元(如关键部分)通常是协调多个线程的最佳方法。 如果同步基元太慢,最佳解决方案通常是使用它们的频率较低。 但是,对于那些能够承受额外复杂性的人来说,另一个选项是无锁编程。

无锁编程

无锁编程(顾名思义)是一系列技术,用于在不使用锁的情况下安全地操作共享数据。 有一些无锁算法可用于传递消息、共享列表和数据队列和其他任务。

执行无锁编程时,必须处理两个难题:非原子操作和重新排序。

非原子操作

原子操作是不可分割的,其中另一个线程保证在完成一半时永远不会看到该操作。 原子操作对于无锁编程非常重要,因为如果没有它们,其他线程可能会看到半写的值,否则状态不一致。

在所有新式处理器上,可以假定自然对齐的本机类型的读取和写入是原子的。 只要内存总线的宽度至少与读取或写入的类型一样宽,CPU 会在单个总线事务中读取和写入这些类型,从而使其他线程无法以半完成状态查看它们。 在 x86 和 x64 上,不能保证读取和写入大于 8 个字节是原子的。 这意味着流式 SIMD 扩展的 16 字节读取和写入 (SSE) 寄存器和字符串操作可能不是原子操作。

不自然对齐的类型读取和写入(例如,写入跨四字节边界的 DWORD)不能保证是原子的。 CPU 可能必须以多个总线事务的形式执行这些读取和写入操作,从而允许另一个线程在读取或写入中间修改或查看数据。

复合操作(如递增共享变量时发生的读-修改-写序列)不是原子操作。 在Xbox 360,这些操作作为多个指令实现, (lwz、addi 和 stw) ,线程可以通过序列交换出部分。 在 x86 和 x64 上,有一个指令 (inc) 可用于递增内存中的变量。 如果使用此指令,递增变量在单处理器系统上是原子的,但在多处理器系统上它仍然不是原子的。 在基于 x86 和 x64 的多处理器系统上创建 inc 原子需要使用锁前缀,这可以防止另一个处理器在读取和 inc 指令的写入之间执行自己的读写修改-写入序列。

以下代码显示了部分示例:

// This write is not atomic because it is not natively aligned.
DWORD* pData = (DWORD*)(pChar + 1);
*pData = 0;

// This is not atomic because it is three separate operations.
++g_globalCounter;

// This write is atomic.
g_alignedGlobal = 0;

// This read is atomic.
DWORD local = g_alignedGlobal;

保证原子性

可以通过以下组合来确保使用原子操作:

  • 自然原子操作
  • 用于包装复合操作的锁
  • 实现常用复合操作原子版本的操作系统函数

递增变量不是原子操作,如果对多个线程执行数据损坏,则递增可能会导致数据损坏。

// This will be atomic.
g_globalCounter = 0;

// This is not atomic and gives undefined behavior
// if executed on multiple threads
++g_globalCounter;

Win32 附带了一系列函数,这些函数提供了多个常见操作的原子读写版本。 这些是 InterlockedXxx 函数系列。 如果共享变量的所有修改都使用这些函数,则修改将是线程安全的。

// Incrementing our variable in a safe lockless way.
InterlockedIncrement(&g_globalCounter);

重组

更微妙的问题是重新排序。 读取和写入并不总是按照你在代码中编写它们的顺序发生,这可能会导致非常令人困惑的问题。 在许多多线程算法中,线程会写入一些数据,然后写入一个标志,告知其他线程数据已准备就绪。 这称为写入发布。 如果重新排序写入,则其他线程可能会在看到写入数据之前设置标志。

同样,在许多情况下,线程从标志读取,然后读取一些共享数据(如果标志表示线程已获取对共享数据的访问权限)。 这称为读取获取。 如果重新排序读取,则可以在标志前从共享存储读取数据,并且看到的值可能不是最新的。

编译器和处理器可以对读取和写入进行重新排序。 编译器和处理器多年来进行了这种重新排序,但在单处理器计算机上,这一问题就更少了。 这是因为单处理器计算机上读取和写入的 CPU 重新排列不可见, (非设备驱动程序代码的非设备驱动程序代码) ,并且编译器重新排列读取和写入不太可能在单处理器计算机上引起问题。

如果编译器或 CPU 重新排列以下代码中显示的写入,则另一个线程可能会看到活动标志已设置,同时仍看到 x 或 y 的旧值。 读取时可能会发生类似的重新排列。

在此代码中,一个线程向子画面数组添加新条目:

// Create a new sprite by writing its position into an empty
// entry and then setting the ‘alive' flag. If ‘alive' is
// written before x or y then errors may occur.
g_sprites[nextSprite].x = x;
g_sprites[nextSprite].y = y;
g_sprites[nextSprite].alive = true;

在下一个代码块中,另一个线程从子画面数组中读取:

// Draw all sprites. If the reads of x and y are moved ahead of
// the read of ‘alive' then errors may occur.
for( int i = 0; i < numSprites; ++i )
{
    if( g_sprites[nextSprite].alive )
    {
        DrawSprite( g_sprites[nextSprite].x,
                g_sprites[nextSprite].y );
    }
}

若要使此子画面系统安全,我们需要防止编译器和 CPU 重新排序读取和写入。

了解写入的 CPU 重新排列

某些 CPU 重新排列写入,使其在外部对非程序顺序的其他处理器或设备可见。 单线程非驱动程序代码从不可见此重新排列,但它可能会导致多线程代码出现问题。

Xbox 360

虽然Xbox 360 CPU 没有重新排序指令,但它会重新排列写入操作,这些操作在指令本身之后完成。 PowerPC内存模型专门允许重新排列写入。

Xbox 360上的写入操作不会直接转到 L2 缓存。 相反,为了改进 L2 缓存写入带宽,它们通过存储队列,然后存储收集缓冲区。 存储收集缓冲区允许在一个操作中将 64 字节块写入 L2 缓存。 有八个存储收集缓冲区,允许高效写入到多个不同内存区域。

存储收集缓冲区通常以先出先出 (FIFO) 顺序写入 L2 缓存。 但是,如果写入的目标缓存行不在 L2 缓存中,则在从内存提取缓存行时,该写入可能会延迟。

即使以严格的 FIFO 顺序将存储收集缓冲区写入 L2 缓存,这不能保证将单个写入按顺序写入 L2 缓存。 例如,假设 CPU 写入位置0x1000,然后写入位置0x2000,然后写入位置0x1004。 第一个写入分配存储收集缓冲区,并将其置于队列的前面。 第二个写入分配另一个存储收集缓冲区,并将其放在队列中。 第三次写入会将数据添加到第一个存储收集缓冲区,该缓冲区保留在队列的前面。 因此,第三个写入最终将转到第二个写入前的 L2 缓存。

存储收集缓冲区引起的重新排序是根本不可预知的,特别是因为核心上的两个线程共享存储收集缓冲区,使存储收集缓冲区的分配和清空高度可变。

这是如何重新排序写入的一个示例。 可能存在其他可能性。

x86 和 x64

即使 x86 和 x64 CPU 执行重新排序指令,但它们通常不会对其他写入操作重新排序。 写入组合内存存在一些异常。 此外, (MOVS 和 STOS) 和 16 字节 SSE 写入的字符串操作可以在内部重新排序,但否则,写入不会相互重新排序。

了解读取的 CPU 重新排列

一些 CPU 重新排列读取,以便它们实际上来自非程序顺序的共享存储。 对于单线程非驱动程序代码,这种重新排列从不可见,但在多线程代码中可能会导致问题。

Xbox 360

缓存未命中可能会导致某些读取延迟,这实际上会导致读取按顺序来自共享内存,并且这些缓存未命中时间从根本上不可预知。 预提取和分支预测还可能导致数据按顺序来自共享内存。 这些只是有关如何重新排序读取的几个示例。 可能存在其他可能性。 PowerPC内存模型专门允许重新排列读取。

x86 和 x64

即使 x86 和 x64 CPU 执行重新排序指令,但它们通常不会相对于其他读取重新排序读取操作。 字符串操作 (MOVS 和 STOS) 和 16 字节 SSE 读取可以内部重新排序,但否则,读取不会相互重新排序。

其他重新排序

即使 x86 和 x64 CPU 不会相对于其他写入重新排序写入,或者对相对于其他读取重新排序读取,但它们也可以对相对于写入的读取重新排序。 具体而言,如果程序写入到一个位置,然后从其他位置读取,则读取数据可能来自共享内存,然后写入数据就在那里。 这种重新排序可能会中断某些算法,例如 Dekker 的相互排除算法。 在 Dekker 算法中,每个线程设置一个标志以指示它想要进入关键区域,然后检查另一个线程的标志,以查看另一个线程是否位于关键区域或尝试输入它。 初始代码遵循。

volatile bool f0 = false;
volatile bool f1 = false;

void P0Acquire()
{
    // Indicate intention to enter critical region
    f0 = true;
    // Check for other thread in or entering critical region
    while (f1)
    {
        // Handle contention.
    }
    // critical region
    ...
}


void P1Acquire()
{
    // Indicate intention to enter critical region
    f1 = true;
    // Check for other thread in or entering critical region
    while (f0)
    {
        // Handle contention.
    }
    // critical region
    ...
}

问题是,P0Acquire 中 f1 的读取可以在写入 f0 之前从共享存储读取到共享存储。 同时,在写入 f1 之前,P1Acquire 中的 f0 读取可以从共享存储读取,然后写入 f1 即可将其写入共享存储。 净效果是,两个线程将标志设置为 TRUE,两个线程将另一个线程的标志视为 FALSE,因此它们都进入关键区域。 因此,虽然在基于 x86 和 x64 的系统上重新排序的问题与Xbox 360相比不太常见,但它们肯定仍可能发生。 Dekker 的算法在上述任何平台上都不会使用硬件内存屏障。

x86 和 x64 CPU 不会在上一次读取之前重新排序写入。 如果 x86 和 x64 CPU 面向不同的位置,则仅重新排序之前写入。

PowerPC CPU 可以在写入之前重新排序读取,并且可以在读取之前对写入重新排序,只要它们与不同的地址相同。

重新排序摘要

与 x86 和 x64 CPU 相比,Xbox 360 CPU 重新排序比 x86 和 x64 CPU 更积极,如下表所示。 有关更多详细信息,请参阅处理器文档。

重新排序活动 x86 和 x64 Xbox 360
读取操作在读取之前移动
写入操作在写入之前移动
写入操作在读取之前移动
读取操作在写入之前移动

 

Read-Acquire和Write-Release障碍

用于防止重新排序读取和写入的主要构造称为读取获取和写入释放屏障。 读取获取是一个标志或其他变量的读取,用于获取资源的所有权,加上阻止重新排序的障碍。 同样,写发布是标记或其他变量的写入,用于放弃资源的所有权,加上阻止重新排序的障碍。

赫布·萨特的正式定义包括:

  • 读取获取在按程序顺序遵循该线程的所有读取和写入之前执行。
  • 写入释放在按程序顺序排列之前的所有读取和写入操作之后执行。

当代码获取某些内存的所有权时,无论是通过获取锁还是将项从共享链接列表拉离 (而不) ,始终存在读取相关内容-测试标志或指针,以查看是否已获取内存所有权。 此读取可能是 InterlockedXxx 操作的一部分,在这种情况下,它涉及读取和写入,但它是指示是否已获取所有权的读取。 获取内存所有权后,值通常从该内存中读取或写入该内存,并且这些读取和写入在获取所有权后执行非常重要。 读取获取屏障可以保证这一点。

当释放某些内存的所有权(通过释放锁或将项目推送到共享链接列表)时,始终涉及写入,通知其他线程内存现在可供它们使用。 虽然代码拥有内存的所有权,但它可能在释放所有权之前读取或写入内存,并且这些读取和写入操作非常重要。 写入释放屏障可以保证这一点。

最简单的方式是将读取获取和写入释放屏障视为单个操作。 但是,它们有时必须从两个部分构造:读取或写入以及不允许读取或写入的屏障在读取或写入之间移动。 在这种情况下,屏障的位置至关重要。 对于读取获取屏障,先读取标志,再读取屏障,然后读取和写入共享数据。 对于写入释放屏障,共享数据的读取和写入先是屏障,然后是标志的写入。

// Read that acquires the data.
if( g_flag )
{
    // Guarantee that the read of the flag executes before
    // all reads and writes that follow in program order.
    BarrierOfSomeSort();

    // Now we can read and write the shared data.
    int localVariable = sharedData.y;
    sharedData.x = 0;

    // Guarantee that the write to the flag executes after all
    // reads and writes that precede it in program order.
    BarrierOfSomeSort();
    
    // Write that releases the data.
    g_flag = false;
}

读取获取和写入释放之间的唯一区别是内存屏障的位置。 读取获取在锁定操作后具有屏障,写入释放之前具有屏障。 在这两种情况下,屏障位于对锁定内存的引用和对锁的引用之间。

若要了解获取数据时和发布数据时为何都需要屏障,最好 (和最准确的) 将这些屏障视为保证与共享内存的同步,而不是与其他处理器同步。 如果一个处理器使用写版本将数据结构发布到共享内存,另一个处理器使用读取获取从共享内存访问该数据结构,则代码将正常工作。 如果任一处理器未使用适当的屏障,则数据共享可能会失败。

使用正确的屏障防止平台的编译器和 CPU 重新排序至关重要。

使用操作系统提供的同步基元的优点之一是,所有这些基元都包含适当的内存屏障。

阻止编译器重新排序

编译器的作业是积极优化代码,以提高性能。 这包括在何处重新排列指令,无论它在哪里都很有用,无论它在哪里都不会更改行为。 由于 C++ 标准从不提及多线程处理,并且编译器不知道哪些代码需要线程安全,因此编译器假定代码在决定可以安全地进行重新排列时是单线程的。 因此,当不允许编译器重新排序读取和写入时,需要告知编译器。

使用 Visual C++,可以使用编译器内部 _ReadWriteBarrier来阻止编译器重新排序。 在代码中插入 _ReadWriteBarrier 的位置,编译器不会移动读取和写入。

#if _MSC_VER < 1400
    // With VC++ 2003 you need to declare _ReadWriteBarrier
    extern "C" void _ReadWriteBarrier();
#else
    // With VC++ 2005 you can get the declaration from intrin.h
#include <intrin.h>
#endif
// Tell the compiler that this is an intrinsic, not a function.
#pragma intrinsic(_ReadWriteBarrier)

// Create a new sprite by filling in a previously empty entry.
g_sprites[nextSprite].x = x;
g_sprites[nextSprite].y = y;
// Write-release, barrier followed by write.
// Guarantee that the compiler leaves the write to the flag
// after all reads and writes that precede it in program order.
_ReadWriteBarrier();
g_sprites[nextSprite].alive = true;

在以下代码中,另一个线程从子画面数组读取:

// Draw all sprites.
for( int i = 0; i < numSprites; ++i )
{

    // Read-acquire, read followed by barrier.
    if( g_sprites[nextSprite].alive )
    {
    
        // Guarantee that the compiler leaves the read of the flag
        // before all reads and writes that follow in program order.
        _ReadWriteBarrier();
        DrawSprite( g_sprites[nextSprite].x,
                g_sprites[nextSprite].y );
    }
}

请务必了解 ,_ReadWriteBarrier 不会插入任何其他指令,并且它不会阻止 CPU 重新排列读取和写入-它只阻止编译器重新排列读取和写入。 因此,当你在 x86 和 x64 (上实现写释放屏障时, _ReadWriteBarrier 就足够了,因为 x86 和 x64 不重新排序写入,并且正常写入足以释放锁) ,但在大多数情况下,还需要防止 CPU 重新排序读取和写入。

还可以在写入不可缓存的写入组合内存时使用 _ReadWriteBarrier ,以防止重新排序写入。 在这种情况下 ,_ReadWriteBarrier 通过保证写入以处理器的首选线性顺序进行,从而帮助提高性能。

还可以使用 _ReadBarrier_WriteBarrier 内部函数更精确地控制编译器重新排序。 编译器不会在 _ReadBarrier之间移动读取,也不会在 _WriteBarrier之间移动写入。

防止 CPU 重新排序

CPU 重新排序比编译器重新排序更微妙。 你永远看不到它直接发生,你只会看到难以理解的 bug。 为了防止对读取和写入进行 CPU 重新排序,需要在某些处理器上使用内存屏障指令。 内存屏障指令(Xbox 360和Windows)的所有用途名称为 MemoryBarrier。 此宏是针对每个平台正确实现的。

在Xbox 360,MemoryBarrier 定义为 lwsync (轻型同步) ,也可以通过 ppcintrinsics.h 中定义的__lwsync内部函数获得。 __lwsync 还充当编译器内存屏障,防止编译器重新排列读取和写入。

lwsync 指令是Xbox 360上的内存屏障,用于将一个处理器核心与 L2 缓存同步。 它保证 lwsync 之前的所有写入都会在后续写入之前将其写入 L2 缓存。 它还保证以下 lwsync 的任何读取不会从 L2 获取早于以前读取的数据。 它不会阻止的一种类型的重新排序是在写入到其他地址之前进行读取。 因此, lwsync 强制实施与 x86 和 x64 处理器上默认内存排序匹配的内存排序。 若要获取完整内存排序,需要更昂贵的同步指令 (也称为重量级同步) ,但在大多数情况下,这不是必需的。 下表显示了Xbox 360上的内存重新排序选项。

Xbox 360重新排序 无同步 lwsync sync
读取操作在读取之前移动
写入操作在写入之前移动
写入操作在读取之前移动
读取操作在写入之前移动

 

PowerPC还有同步指令 isynceieio (,用于控制缓存抑制内存) 的重新排序。 出于正常同步目的,不需要这些同步说明。

在Windows中,MemoryBarrier 在 Winnt.h 中定义,并提供不同的内存屏障指令,具体取决于是要编译 x86 还是 x64。 内存屏障指令充当完整屏障,防止对屏障上的读取和写入进行重新排序。 因此,Windows上的 MemoryBarrier 提供比Xbox 360更强的重新排序保证。

在Xbox 360和其他许多 CPU 上,可以阻止 CPU 进行读取重新排序的另一种方法。 如果读取指针,然后使用该指针加载其他数据,则 CPU 保证指针的读取时间不早于指针的读取。 如果锁标志是指针,并且共享数据的所有读取都脱离了指针,则可以省略 MemoryBarrier ,以节省适度的性能。

Data* localPointer = g_sharedPointer;
if( localPointer )
{
    // No import barrier is needed--all reads off of localPointer
    // are guaranteed to not be reordered past the read of
    // localPointer.
    int localVariable = localPointer->y;
    // A memory barrier is needed to stop the read of g_global
    // from being speculatively moved ahead of the read of
    // g_sharedPointer.
    int localVariable2 = g_global;
}

MemoryBarrier 指令仅阻止对可缓存内存的读取和写入重新排序。 如果将内存分配为PAGE_NOCACHE或PAGE_WRITECOMBINE,则对于设备驱动程序作者和Xbox 360上的游戏开发人员来说,MemoryBarrier 对访问此内存没有任何影响。 大多数开发人员不需要同步不可缓存的内存。 这超出了本文的范围。

互锁函数和 CPU 重新排序

有时,获取或释放资源的读取或写入是使用 InterlockedXxx 函数之一完成的。 在Windows,这简化了操作;因为在Windows,InterlockedXxx 函数都是全内存屏障。 它们实际上在 CPU 内存屏障之前和之后都有一个 CPU 内存屏障,这意味着它们是完全读取获取或写入释放屏障本身。

在Xbox 360上,InterlockedXxx 函数不包含 CPU 内存屏障。 它们可防止编译器重新排序读取和写入,但不会对 CPU 重新排序。 因此,在大多数情况下,在Xbox 360上使用 InterlockedXxx 函数时,应先使用__lwsync遵循它们,使其成为读取获取或写入释放屏障。 为方便起见,并且更易于阅读,有许多 InterlockedXxx 函数的 AcquireRelease 版本。 它们附带内置内存屏障。 例如, InterlockedIncrementAcquire 执行互锁增量,后跟 __lwsync 内存屏障,以提供完整的读取获取功能。

建议使用 InterlockedXxx 函数的“获取发布”版本 (其中大多数功能在Windows上可用,) 没有性能损失,以便使意向更加明显,并更轻松地在正确的位置获取内存屏障指令。 在不使用内存屏障的 Xbox 360 上使用 InterlockedXxx 时,应非常仔细地检查,因为它通常是一个 bug。

此示例演示了一个线程如何使用 InterlockedXxxSList 函数的 AcquireRelease 版本将任务或其他数据传递给另一个线程。 InterlockedXxxSList 函数是一系列函数,用于维护不带锁的共享单声链接列表。 请注意,这些函数的获取发布变体在Windows不可用,但这些函数的常规版本是Windows的完整内存屏障。

// Declarations for the Task class go here.

// Add a new task to the list using lockless programming.
void AddTask( DWORD ID, DWORD data )
{
    Task* newItem = new Task( ID, data );
    InterlockedPushEntrySListRelease( g_taskList, newItem );
}

// Remove a task from the list, using lockless programming.
// This will return NULL if there are no items in the list.
Task* GetTask()
{
    Task* result = (Task*)
        InterlockedPopEntrySListAcquire( g_taskList );
    return result;
}

可变变量和重新排序

C++ 标准版表示,无法缓存易失变量的读取,易失性写入无法延迟,并且无法相互移动易失读取和写入。 这足以与硬件设备通信,这是 C++ 标准中的易失性关键字的用途。

但是,标准保证不足以对多线程使用易失性。 C++ 标准不会阻止编译器重新排序相对于易失性读取和写入的非易失性读取和写入,并且它不说明阻止 CPU 重新排序。

Visual C++ 2005 超越标准 C++ ,以定义多线程友好语义,以便进行可变变量访问。 从 Visual C++ 2005 开始,从易失变量读取定义为具有读取获取语义,对易失变量的写入定义为具有写发布语义。 这意味着编译器不会重新排列任何读取和写入,并且Windows它将确保 CPU 不会这样做。

请务必了解,这些新保证仅适用于 Visual C++ 2005 和 Visual C++ 的未来版本。 来自其他供应商的编译器通常会实现不同的语义,而无需提供 Visual C++ 2005 的额外保证。 此外,在Xbox 360,编译器不会插入任何指令,以防止 CPU 重新排序读取和写入。

Lock-Free数据管道的示例

管道是一个构造,允许一个或多个线程写入数据,然后由其他线程读取。 管道的无锁版本可以是将工作从线程传递到线程的优雅高效方法。 DirectX SDK 提供 LockFreePipe,这是 DXUTLockFreePipe.h 中提供的单读取器单编写器无锁管道。 AtgLockFreePipe.h 中的 Xbox 360 SDK 中提供了相同的 LockFreePipe。

当两个线程具有生成者/使用者关系时,可以使用 LockFreePipe。 生成者线程可以向管道中写入数据,以便使用者线程在以后处理,而无需阻止。 如果管道填满,写入会失败,并且生成者线程稍后必须重试,但只有在生成者线程提前时,才会发生这种情况。 如果管道清空,读取失败,并且使用者线程稍后必须重试,但只有在使用者线程无法执行时,才会发生这种情况。 如果两个线程平衡良好,并且管道足够大,则管道允许它们顺利传递数据,且不会延迟或块。

Xbox 360性能

Xbox 360上的同步指令和函数的性能因运行其他代码而异。 如果另一个线程当前拥有锁,获取锁需要更长的时间。 如果其他线程写入同一缓存行,则 InterlockedIncrement 和关键节操作需要更长的时间。 存储队列的内容也会影响性能。 因此,所有这些数字只是从非常简单的测试生成的近似值:

  • lwsync 被测量为采用 33-48 个周期。
  • InterlockedIncrement 测量为采用 225-260 个周期。
  • 获取或释放关键部分被测量为大约 345 个周期。
  • 获取或释放互斥体被测量为大约 2350 个周期。

Windows 性能

Windows上的同步指令和函数的性能因处理器类型和配置以及运行其他代码而异。 多核和多套接字系统通常需要更长的时间来执行同步指令,如果另一个线程当前拥有锁,获取锁需要更长的时间。

但是,即使是从非常简单的测试生成的一些度量值也很有用:

  • MemoryBarrier 被测量为采用 20-90 个周期。
  • InterlockedIncrement 测量为采用 36-90 周期。
  • 获取或释放关键部分被测量为采用 40-100 个周期。
  • 获取或释放互斥体测量为大约 750-2500 个周期。

这些测试是在一系列不同处理器上的 Windows XP 上完成的。 短时间位于单处理器计算机上,而多处理器计算机上的时间更长。

虽然获取和释放锁比使用无锁编程更昂贵,但最好更频繁地共享数据,从而完全避免成本。

性能思考

获取或释放关键部分包括内存屏障、 InterlockedXxx 操作和一些额外的检查来处理递归,并在必要时回退到互斥体。 你应该谨慎地实现自己的关键部分,因为旋转在循环中等待锁释放,而不回退到互斥体,可能会浪费相当大的性能。 对于大量竞争但未保留很长时间的关键部分,应考虑使用 InitializeCriticalSectionAndSpinCount ,以便操作系统会在等待关键部分可用时旋转一段时间,而不是在尝试获取关键部分时立即延迟到互斥体。 若要确定可从旋转计数中获益的关键部分,必须测量典型等待特定锁的长度。

如果共享堆用于内存分配(默认行为),则每个内存分配和可用都涉及获取锁。 随着线程数和分配数的增加,性能级别会降低,最终开始减少。 使用每线程堆或减少分配数可以避免这种锁定瓶颈。

如果一个线程正在生成数据,另一个线程正在使用数据,则它们最终可能会频繁共享数据。 如果一个线程正在加载资源,另一个线程正在呈现场景,则可能会出现这种情况。 如果呈现线程引用每个绘图调用上的共享数据,则锁定开销会很高。 如果每个线程都有专用数据结构,则性能要好得多,然后每个帧或更短的时间同步一次。

不保证无锁算法比使用锁的算法更快。 在尝试避免锁之前,应检查锁是否确实导致问题,并且应该衡量无锁代码是否确实提高了性能。

平台差异摘要

  • InterlockedXxx 函数可防止在Windows上重新排序 CPU 读/写,但不能在Xbox 360上重新排序。
  • 使用 Visual Studio C++ 2005 读取和写入易失变量可防止对Windows进行 CPU 读/写重新排序,但在Xbox 360,它只会阻止编译器读/写重新排序。
  • 写入在Xbox 360上重新排序,但不在 x86 或 x64 上重新排序。
  • 读取在Xbox 360上重新排序,但在 x86 或 x64 上,它们仅相对于写入重新排序,并且仅当读取和写入面向不同位置时。

建议

  • 尽可能使用锁,因为它们更易于正确使用。
  • 避免锁定过于频繁,因此锁定成本不会变得显著。
  • 避免长时间持有锁,以避免长时间停止。
  • 适当时使用无锁编程,但请确保增益证明复杂性是正当的。
  • 在禁止其他锁的情况下使用无锁编程或旋转锁,例如,在延迟过程调用和正常代码之间共享数据时。
  • 仅使用已证明正确的标准无锁编程算法。
  • 执行无锁编程时,请务必根据需要使用可变标志变量和内存屏障指令。
  • 在 Xbox 360 上使用 InterlockedXxx 时,请使用“获取”和“发布”变体。

参考