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 扩展 (SSE) 寄存器的 16 字节读写和字符串操作可能不是原子性的。
不自然对齐的类型的读取和写入(例如,写入跨越四字节边界的 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 的旧值。 读取时也会发生类似的重新排列。
在这段代码中,一个线程向 sprite 数组添加了一个新条目:
// 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;
在下一个代码块中,另一个线程从 sprite 数组中读取:
// 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 );
}
}
为了使这个 sprite 系统安全,我们需要防止编译器和 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 的写入进入共享存储之前从共享存储中读取。 同时,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 | Xbox 360 |
---|---|---|
读取先于读取 | 否 | 是 |
写入先于写入 | 否 | 是 |
写入先于读取 | 否 | 是 |
读取先于写入 | 是 | 是 |
读取-获取和写入-释放屏障
用于防止读取和写入重新排序的主要构造称为读取-获取和写入-释放屏障。 读取-获取是对一个标志或其他变量的读取,以获得资源的所有权,并设置防止重新排序的屏障。 同样,写入-释放是对标志或其他变量的写入,以放弃资源的所有权,并设置防止重新排序的屏障。
Herb Sutter 提供的正式定义如下:
- 读取-获取在按程序顺序遵循读取操作的所有读取和写入之前执行。
- 写入-释放在程序顺序中位于其之前的同一线程进行所有读写操作后执行。
当代码获取某些内存的所有权时,无论是通过获取锁还是通过从共享链表(没有锁)中提取项,总是会涉及读取,测试一个标志或指针,看看是否已经获得了内存的所有权。 此读取可能是 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++ Standard 从未提到多线程,而且编译器不知道什么代码需要线程安全,因此编译器在决定可以安全地执行什么重新排列时,会假设代码是单线程。 因此,你需要告诉编译器何时不允许重新排序读取和写入。
借助 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;
在下面的代码中,另一个线程从 sprite 数组中读取:
// 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 重新排序比编译器重新排序更微妙。 你永远看不到它直接发生,而只会看到无法解释的错误。 为了防止 CPU 对读取和写入进行重新排序,需要在某些处理器上使用内存屏障指令。 。在 Xbox 360 和 Windows 上,内存屏障指令的通用名称是 MemoryBarrier。 此宏针对每个平台都进行了适当的实施。
在 Xbox 360 上,MemoryBarrier 定义为 lwsync(轻量同步),也可以通过 __lwsync 内部函数(在 ppcintrinsics.h 中定义)获得。 __lwsync 还充当编译器内存屏障,防止编译器重新排列读取和写入。
lwsync 指令是 Xbox 360 上的内存屏障,可将一个处理器核心与 L2 缓存同步。 它保证 lwsync 之前的所有写入都会在后续写入之前将其写入 L2 缓存。 它还保证在 lwsync 之后的任何读取都不会从 L2 获取比以前的读取更旧的数据。 它无法阻止的一种重新排序是读取先于写入到不同的地址。 因此,lwsync 强制执行与 x86 和 x64 处理器上的默认内存排序匹配的内存排序。 若要获取完整内存排序,需要更昂贵的同步指令(也称为重量级同步);但在大多数情况下,这不是必需的。 Xbox 360 上的内存重新排序选项如下表所示。
Xbox 360 重新排序 | 不同步 | lwsync | 同步 |
---|---|---|---|
读取先于读取 | 是 | 否 | 否 |
写入先于写入 | 是 | 否 | 否 |
写入先于读取 | 是 | 否 | 否 |
读取先于写入 | 是 | 是 | 否 |
PowerPC 还有同步指令 isync 和 eieio(用于控制对缓存被禁止的内存的重新排序)。 这些同步指令不应该用于正常的同步目的。
在 Windows 上,Winnt.h 中定义了 MemoryBarrier,并根据你是为 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 内存屏障,这意味着它们本身就是一个完整的读取-获取或写入-释放屏障。
在 Xbox 360 上,InterlockedXxx 函数不包含 CPU 内存屏障。 它们可防止编译器对读取和写入进行重新排序,但不能防止 CPU 重新排序。 因此,在大多数情况下,当在 Xbox 360 上使用 InterlockedXxx 函数时,应在它们之前或之后加上 __lwsync,使它们成为读取-获取或写入-释放屏障。 为方便和更易于读取,许多 InterlockedXxx 函数都有 Acquire 和 Release 两个版本。 它们附带了内置内存屏障。 例如,InterlockedIncrementAcquire 执行互锁增量,后跟 __lwsync 内存屏障,以提供完整的读取-获取功能。
建议你使用 InterlockedXxx 函数的 Acquire 和 Release 版本(其中大多数函数在 Windows 上可用,且无性能损失),以使意图更加明显,并更容易在正确的位置获取内存屏障指令。 任何在 Xbox 360 上没有内存屏障的情况下使用 InterlockedXxx 都应该非常仔细地检查,因为这通常是一个错误。
此示例演示了一个线程如何使用 InterlockedXxxSList 函数的 Acquire 和 Release 版本将任务或其他数据传递到另一个线程。 InterlockedXxxSList 函数是一系列函数,用于维护不带锁的共享单一链接列表。 请注意,这些函数的获取 Acquire 和 Release 变体在 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++ Standard 规定,对易失性变量的读取不能被缓存,易失性写入不能被延迟,并且易失性读取和写入不能相互移动。 这足以与硬件设备通信,这是 C++ Standard 中可变关键字的用途。
然而,该标准的保证不足以将易失性用于多线程。 C++ Standard 并没有阻止编译器相对于易失性读写重新排序非易失性读取和写入,也没有提到防止 CPU 重新排序。
Visual C++ 2005 超越了标准 C++,为易失性变量访问定义了多线程友好的语义。 从 Visual C++ 2005 开始,对易失性变量的读取被定义为具有读取-获取语义,对易失性变量的写入被定义为具有写入-释放语义。 这意味着编译器不会重新排列任何读写操作;在 Windows 上,它也会确保 CPU 不会这样做。
请务必了解,这些新保证仅适用于 Visual C++ 2005 和 Visual C++ 的未来版本。 来自其他供应商的编译器通常会实现不同的语义,而无需 Visual C++ 2005 的额外保证。 此外,在 Xbox 360 上,编译器不会插入任何指令来防止 CPU 重新排序读取和写入。
无锁数据管道的示例
管道是一个构造,允许一个或多个线程写入数据,然后由其他线程读取。 无锁版本的管道可以是一种巧妙而高效的方式,将工作从一个线程传递到另一个线程。 DirectX SDK 提供 LockFreePipe,这是 DXUTLockFreePipe.h 中提供的单读取器、单写入器无锁管道。 Xbox 360 SDK 中 AtgLockFreePipe.h 中也提供了相同的 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 时,请使用 Acquire 和 Release 变体。
参考
- "volatile (C++)." C++ 语言参考。
- Vance Morrison。 “了解多线程应用中低锁技术的影响。”MSDN 杂志,2005 年 10 月。
- Lyons, Michael。 “PowerPC 存储模型和 AIX 编程。‘’IBM developerWorks,2005 年 11 月 16 日。
- McKenney, Paul E.“现代微控制器的记忆排序,第二部分。”Linux 杂志,2005 年 9 月。 [本文介绍了一些 x86 详细信息。]
- Intel Corporation。 “Intel® 64 体系结构内存排序。”2007 年 8 月。 [适用于 IA-32 和 Intel 64 处理器。]
- Niebler, Eric。 “行程报告:关于 C++ 线程的特别会议。”C++ 来源,2006 年 10 月 17 日。
- Hart, Thomas E. 2006。 “快速实现无锁同步:内存回收的性能影响。”2006 年 4 月,希腊罗兹岛国际并行和分布式处理研讨会(IPDPS 2006)会议。