无锁编程是一种在多个线程之间安全共享更改数据的方法,无需获取和释放锁的成本。 这听起来就像一种万能的,但无锁编程是复杂而细微的,有时无法带来它承诺的好处。 无锁编程在应用程序上Xbox 360。
无锁编程是多线程编程的有效技术,但不应轻轻松用。 在使用它之前,必须了解复杂性,并且应仔细衡量,以确保它确实可让你获得预期收益。 在许多情况下,有更简单、更快的解决方案,例如不太频繁地共享数据,应改为使用。
正确且安全地使用无锁编程需要具备硬件和编译器方面的大量知识。 本文概述了尝试使用无锁编程技术时要考虑的一些问题。
使用锁编程
编写多线程代码时,通常需要在线程之间共享数据。 如果多个线程同时读取和写入共享数据结构,则可能会发生内存损坏。 解决此问题的最简单方法就是使用锁。 例如,如果一次只应由一个线程执行 ManipulateSharedData,可以使用 CRITICAL SECTION 来保证这一点, _ 如以下代码所示:
syntax
// 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 个字节的读取和写入是原子的。 这意味着,SSE (SSE) 流式处理 SIMD 扩展的 16 字节读写操作和字符串操作可能不是原子的。
不自然对齐的类型(例如,写入跨越四字节边界的 DWORD)的读取和写入不保证是原子的。 CPU 可能必须作为多个总线事务执行这些读取和写入,这允许另一个线程在读取或写入中间修改或查看数据。
复合操作(如递增共享变量时发生的读-修改-写序列)不是原子操作。 在Xbox 360,这些操作作为多个指令实现 (lwz、addi 和 stw) ,并且线程可以在序列的中途交换。 在 x86 和 x64 上, () 一个指令,可用于递增内存中的变量。 如果使用此指令,在单处理器系统上递增变量是原子的,但它在多处理器系统上仍不是原子的。 在基于 x86 和 x64 的多处理器系统上使 inc atomic 需要使用锁前缀,这可以防止另一个处理器在 inc 指令的读取和写入之间执行自己的读-修改-写序列。
以下代码显示了部分示例:
syntax
// 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;
保证原子性
可以通过以下组合确保使用原子操作:
- 自然原子操作
- 包装复合操作锁
- 实现常用复合操作原子版本的操作系统函数
递增变量不是原子操作,如果在多个线程上执行,则递增可能会导致数据损坏。
syntax
// This will be atomic.
g_globalCounter = 0;
// This is not atomic and gives undefined behavior
// if executed on multiple threads
++g_globalCounter;
Win32 附带了一系列函数,这些函数提供多个常见操作原子读/修改-写版本。 这些是 InterlockedXxx 系列函数。 如果共享变量的所有修改都使用这些函数,则修改将是线程安全的。
syntax
// Incrementing our variable in a safe lockless way.
InterlockedIncrement(&g_globalCounter);
重组
更细微的问题是重新排序。 读取和写入并不总是按在代码中写入的顺序发生,这可能会导致令人困惑的问题。 在许多多线程算法中,线程写入一些数据,然后写入一个标志,该标志告知其他线程数据已准备就绪。 这称为写入发布。 如果对写入重新排序,其他线程可能会看到标志已设置,然后才能看到写入的数据。
同样,在许多情况下,如果标志显示线程已获取对共享数据的访问权限,则线程从标志读取,然后读取一些共享数据。 这称为读取获取。 如果对读取重新排序,则数据可能在 标志之前从共享存储读取,并且看到的值可能不是最新的。
编译器和处理器都可以对读取和写入重新排序。 编译器和处理器已执行这种重新排序多年,但在单处理器计算机上,问题更少。 这是因为对于不是设备驱动程序) 一部分的非设备驱动程序代码,单处理器计算机 (上的读取和写入的 CPU 重新排列不可见,并且编译器重新排列读取和写入不太可能在单处理器计算机上导致问题。
如果编译器或 CPU 重新排列以下代码中显示的写入,则另一个线程可能会看到活动标志已设置,同时仍看到 x 或 y 的旧值。 读取时可能会发生类似的重新排列。
在此代码中,一个线程将新条目添加到子画面数组:
syntax
// 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;
在此下一个代码块中,另一个线程从子画面数组中读取:
syntax
// 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 缓存 (FIFO) 。 但是,如果写入的目标缓存行不在 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 (MOVS 和 STOS) 和 16 字节 SSE 读取的字符串操作可以在内部重新排序,但否则,读取操作不会彼此重新排序。
其他重新排序
即使 x86 和 x64 CPU 不会对相对于其他写入的写入重新排序,也不对相对于其他读取的读取重新排序,它们也可以相对于写入对读取重新排序。 具体而言,如果程序先写入一个位置,然后从另一个位置读取,则读取数据可能来自共享内存,然后再写入写入数据。 这种重新排序可能会破坏一些算法,例如 De更er 的互斥算法。 在 De且er 算法中,每个线程设置一个标志以指示它想要进入关键区域,然后检查另一个线程的 标志,以查看另一个线程是否位于关键区域或尝试输入它。 初始代码如下所示。
syntax
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
...
}
问题在于,在写入到 f0 之前,P0Acquire 中的 f1 读取可以从共享存储读取。 同时,在写入 f1 之前,读取 P1Acquire 中的 f0 可以从共享存储中读取,这会使它成为共享存储。 净效果是两个线程将标志设置为 TRUE,并且两个线程将另一个线程的标志视为 FALSE,因此两个线程都进入关键区域。 因此,尽管基于 x86 和 x64 的系统上重新排序的问题比在基于 x86 和 x64 的系统上Xbox 360,但确实仍可能会发生。 如果上述任何平台上没有硬件内存屏障,Deer 算法将不起作用。
x86 和 x64 CPU 不会在上一次读取之前对写入重新排序。 如果 x86 和 x64 CPU 以不同位置为目标,它们只会在以前的写入操作之前重新排序读取。
PowerPCCPU 可以在写入前对读取重新排序,并且只要写入地址不同,就可以在读取前重新排序写入。
重新排序摘要
与Xbox 360 x86 和 x64 CPU 更主动地对 CPU 重新排序,如下表所示。 有关更多详细信息,请参阅处理器文档。
重新排序活动 | x86 和 x64 | Xbox 360 |
---|---|---|
读取操作在读取前移动 | 否 | 是 |
写入前写入 | 否 | 是 |
写入操作提前读取 | 否 | 是 |
读取操作在写入前进行 | 是 | 是 |
Read-AcquireWrite-Release屏障
用于防止读取和写入重新排序的主要构造称为读写释放屏障。 读取获取是一种对标志或其他变量的读取,用于获取资源的所有权,以及防止重新排序的屏障。 同样,写发布是标志或其他变量的写入,用于放弃资源的所有权,并加上防止重新排序的屏障。
由 Herb Sutter 提供的正式定义包括:
- 读取获取在按程序顺序跟随它的同一线程执行所有读取和写入操作之前执行。
- 写入发布在所有读取和写入操作后执行,该线程以程序顺序在它之前。
当代码获取某些内存的所有权时,无论是通过获取锁还是从共享链接列表 (拉取项而没有锁) ,始终会涉及读取-测试标志或指针以查看是否获取了内存的所有权。 此读取可能是 InterlockedXxx 运算的一部分,在这种情况下,它同时涉及读取和写入,但读取指示是否已获得所有权。 获取内存的所有权后,值通常从该内存读取或写入该内存,并且这些读取和写入在获取所有权后执行非常重要。 读取获取屏障可保证这一点。
释放某些内存的所有权时,通过释放锁或将项推送到共享链接列表,始终会涉及写入,该写入会通知其他线程内存现在可供它们使用。 虽然代码拥有内存的所有权,但它可能从内存读取或写入内存,在释放所有权之前执行这些读取和写入非常重要。 写入释放屏障可保证这一点。
最简单的方法就是将读取获取和写入发布屏障视为单个操作。 但是,它们有时必须从两个部分构造:读取或写入,以及不允许读取或写入跨其中移动的屏障。 在这种情况下,屏障的位置至关重要。 对于读取获取屏障,先读取标志,然后读取该屏障,然后读取和写入共享数据。 对于写入释放屏障,先读取和写入共享数据,然后是屏障,然后是标志的写入。
syntax
// 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 时,编译器不会跨代码移动读取和写入。
syntax
#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;
在下面的代码中,另一个线程从子画面数组中读取:
syntax
// 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 还提供了同步说明 isync 和 eieio (,用于控制对缓存抑制内存) 的重新排序。 对于普通同步,不需要这些同步说明。
在 Windows 上,在 Winnt .h 中定义 thread.memorybarrier ,并根据你是针对 x86 还是 x64 进行编译,为你提供不同的内存屏障指令。 内存屏障指令用作完全屏障,阻止对关卡的所有读取和写入操作进行重新排序。 因此,Windows 上的 thread.memorybarrier 比在 Xbox 360 上的重新排序保证更强。
在 Xbox 360 上,在许多其他 Cpu 上,可通过另一种方法来防止 CPU 进行读取重新排序。 如果读取了一个指针,然后使用该指针加载其他数据,则 CPU 可保证对指针的读取不早于读取指针。 如果锁定标记是一个指针,并且共享数据的所有读取都不是指针,则可以省略 thread.memorybarrier ,以适度地节省性能。
syntax
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;
}
Thread.memorybarrier指令只会阻止对可缓存内存的读取和写入操作重新排序。 如果你将内存分配为 PAGE _ NOCACHE 或 page _ WRITECOMBINE,则设备驱动程序作者和 Xbox 360 上的游戏开发人员的一种常用方法是对此内存的访问不起作用。 大多数开发人员不需要同步不可缓存的内存。 这超出了本文的范围。
联锁函数和 CPU 重新排序
有时,使用 InterlockedXxx 函数之一来获取或释放资源的读取或写入操作。 在 Windows 上,这简化了一些任务;由于 Windows 上的 InterlockedXxx 函数都是全部内存障碍。 它们实际上在前后都具有 CPU 内存屏障,这意味着它们都是完全的读获取或写释放屏障。
在 Xbox 360 上, InterlockedXxx 函数不包含 CPU 内存障碍。 它们会阻止编译器对读取和写入操作,而不会重新排序 CPU。 因此,在大多数情况下,使用 Xbox 360 上的 InterlockedXxx 函数时,应将其置于 _ _ lwsync 之前或之后,以使其成为读-购置或写释放屏障。 为方便起见,为方便起见,有许多 InterlockedXxx 函数的 获取 版本和 发行 版。 它们附带内置的内存屏障。 例如, InterlockedIncrementAcquire执行联锁增量后跟 _ _ lwsync 内存关卡,以提供完整的读取获取功能。
建议你使用 InterlockedXxx 函数的 获取 版本和 发行 版, (其中的大多数函数在 Windows 上也是可用的,并且不会对性能产生负面) 影响,因为这样可以更轻松地在正确的位置获取内存关卡说明。 如果在不使用内存屏障的情况下使用 InterlockedXxx on Xbox 360,则应仔细检查,因为这通常是一个 bug。
此示例演示了一个线程如何使用 InterlockedXxxSList 函数的 获取 和 释放 版本将任务或其他数据传递给另一个线程。 InterlockedXxxSList 函数是用于在不使用锁的情况下维护共享的单向链接列表的一系列函数。 请注意,这些函数的 获取 和 释放 变体在 Windows 上不可用,但这些函数的常规版本是 Windows 上的完全内存屏障。
syntax
// 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 + + 标准中的 volatile 关键字的用途。
但是,标准的保证并不足以用于多线程处理。 C + + 标准不会阻止编译器对非易失性读和写操作进行重新排序(相对于易失性读和写),也不会显示任何关于避免 CPU 重新排序的内容。
Visual C++ 2005 超越标准 c + + 来定义可变变量访问的多线程友好的语义。 从 Visual C++ 2005 开始,可变变量的读取被定义为具有读取捕获语义,而可变变量的写入定义为具有写释放语义。 这意味着编译器不会重新排列超出它们的任何读取和写入,并且在 Windows 它将确保 CPU 不会这样做。
必须了解,这些新保证仅适用于 Visual C++ 的 Visual C++ 2005 和未来版本。 来自其他供应商的编译器通常会实现不同的语义,而不会有 Visual C++ 2005 的额外保证。 此外,在 Xbox 360 上,编译器不会插入任何说明,以防止 CPU 对读取和写入操作进行重新排序。
Lock-Free 数据管道的示例
管道是一个构造,它允许一个或多个线程写入数据,然后由其他线程读取。 管道的 lockless 版本可以是将工作从线程传递到线程的巧妙而有效的方法。 DirectX SDK 提供 LockFreePipe,这是 DXUTLockFreePipe 中提供的单个读取器单个编写器 lockless 管道。 在 AtgLockFreePipe 中,Xbox 360 SDK 中提供了同样的 LockFreePipe 。
当两个线程具有一个生成者/使用者关系时,可以使用 LockFreePipe 。 制造者线程可以将数据写入管道,以便使用者线程在以后进行处理,而无需阻塞。 如果管道已填满,写入将失败,并且创建者线程将不得不稍后重试,但这种情况仅在创建者线程提前时才会发生。 如果管道已清空,读取将失败,并且使用者线程将必须稍后重试,但仅当使用者线程没有工作时才会发生这种情况。 如果这两个线程经过合理平衡,并且管道足够大,则管道使它们能够顺畅地传递数据,无需延迟或块。
Xbox 360 性能
在 Xbox 360 上,同步指令和函数的性能将随其他代码的运行而变化。 如果其他线程当前拥有该锁,则获取锁的时间将更长。 如果其他线程写入同一缓存行, InterlockedIncrement和关键部分操作将需要更长的时间。 存储队列的内容也会影响性能。 因此,所有这些数字只是从非常简单的测试中生成的:
- lwsync 是采用33-48 循环度量的。
- InterlockedIncrement 是采用225-260 循环度量的。
- 获取或释放关键部分的衡量标准是约345循环。
- 获取或释放 mutex 的衡量标准是大约2350个周期。
Windows 性能
Windows 上的同步说明和函数的性能会有很大差异,具体取决于处理器类型和配置,以及运行其他哪些代码。 多核和多套接字系统通常需要更长的时间来执行同步指令,如果另一个线程当前拥有该锁,则获取锁定所需的时间更长。
不过,即使是非常简单的测试生成的某些度量值也很有用:
- Thread.memorybarrier 是采用20-90 循环度量的。
- InterlockedIncrement 是采用36-90 循环度量的。
- 获取或释放关键部分的衡量标准是采用40-100 循环。
- 获取或释放 mutex 的衡量标准是大约750-2500 个周期。
这些测试是在 Windows XP 上的一系列不同处理器上完成的。 短时间是在单处理器计算机上,较长的时间是在多处理器计算机上。
尽管获取和释放锁的开销比使用 lockless 编程更昂贵,但最好不要频繁地共享数据,从而避免成本的降低。
性能想法
获取或释放关键部分包括内存屏障、 InterlockedXxx 操作和一些额外的检查,以处理递归并在必要时回退到互斥体。 你应该在实现自己的关键部分时保持警惕,因为在等待锁定空闲的循环中,如果没有回退到互斥体,则会显著提高性能。 对于过度争用但暂不长的关键部分,应考虑使用 InitializeCriticalSectionAndSpinCount ,以便在等待关键部分可用时,操作系统将旋转,而不是在你尝试获取关键节时立即将其推迟到互斥体。 为了识别可以从自旋计数中获益的关键部分,必须度量特定锁的典型等待的长度。
如果对内存分配使用共享堆(默认行为),则每次内存分配和释放都涉及获取锁。 当线程数和分配数增加时,性能级别会下降,最终会降低。 如果使用每个线程堆或减少分配数,则可以避免此锁定瓶颈。
如果一个线程正在生成数据,而另一个线程正在使用数据,则它们可能最终会频繁地共享数据。 如果一个线程正在加载资源,另一个线程正在呈现场景,就会发生这种情况。 如果渲染线程在每次绘图调用时都引用共享数据,则锁定开销会很高。 如果每个线程都具有专用数据结构,然后每个帧或更少地同步一次,则可以实现更好的性能。
Lockless 算法不能保证比使用锁的算法更快。 在尝试避免锁定之前,应检查锁是否确实导致了问题,并应测量是否确实会提高性能。
平台差异摘要
- 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 变体。
参考资料
- MSDN 库。 "volatile (C++)"。 C++ 语言参考。
- Vance)"10000001" "了解多线程Low-Lock技术的影响。" MSDN 杂志,2005 年 10 月。
- Michaels, Michael. "PowerPC 存储模型和 AIX 编程"。 IBM developerWorks,2005 年 11 月 16 日。
- McKenney, Paul E. "Memory Ordering in Modern 微处理器, Part II." Linux 日记,2005 年 9 月。 [本文包含一些 x86 详细信息。]
- Intel Corporation。 "Intel® 64 体系结构内存排序"。 2007 年 8 月。 [适用于 IA-32 和 Intel 64 处理器。]
- Niebler,Eric。 "行程报告:C++ 中的线程临时会议"。 C++ 源,2006 年 10 月 17 日。
- Thomas E. 2006. "使无锁同步快速:内存回收的性能影响"。 IpDPS 2006 (IpDPS 2006) 国际并行和分布式处理研讨会(2006 年 4 月) )
Xbox 360 和 Microsof Windows 的无锁编程注意事项 - Win32 apps | Microsoft Docs