我刚刚阅读了MSDN文章"Synchronization and Multiprocessor Issues",该文章解决了多处理器计算机上的内存缓存一致性问题。这真的让我大开眼界,因为我不会想到他们提供的例子中可能存在竞争条件。本文解释了对内存的写入可能实际上并不是按照我的代码中编写的顺序发生的(从其他cpu的角度来看)。这对我来说是个新概念!
本文提供了两种解决方案:
该文章还提到“以下同步函数使用适当的障碍来确保内存排序:•进入或离开关键部分的函数”。
这是我不明白的部分。这是否意味着对限制使用关键部分的函数的内存写入不受缓存一致性和内存排序问题的影响?我对Interlock *()函数没有任何反对意见,但我的工具带中的另一个工具会很好!
答案 0 :(得分:8)
这篇MSDN文章只是多线程应用程序开发的第一步:简而言之,它意味着“用锁(也就是关键部分)保护你的共享变量”,因为你不确定你读/写的数据是所有线程都相同“。
CPU每核心缓存只是可能出现的问题之一,这将导致读取错误的值。可能导致竞争条件的另一个问题是两个线程同时写入资源:之后无法知道将存储哪个值。
由于代码期望数据一致,因此某些多线程程序可能会出错。对于多线程,当您处理共享变量时,您不确定通过单个指令编写的代码是否按预期执行。
InterlockedExchange/InterlockedIncrement
函数是具有LOCK前缀的低级asm操作码(或者由设计锁定,如XCHG EDX,[EAX]
操作码),这确实会强制所有CPU内核的高速缓存一致性,因此asm操作码执行线程安全。
例如,下面是分配字符串值时如何实现字符串引用计数(请参阅System.pas中的_LStrAsg
- 这来自our optimized version of the RTL for Delphi 7/2002 - 因为Delphi原始代码受版权保护):
MOV ECX,[EDX-skew].StrRec.refCnt
INC ECX { thread-unsafe increment ECX = reference count }
JG @@1 { ECX=-1 -> literal string -> jump not taken }
.....
@@1: LOCK INC [EDX-skew].StrRec.refCnt { ATOMIC increment of reference count }
MOV ECX,[EAX]
...
第一个INC ECX
和LOCK INC [EDX-skew].StrRec.refCnt
之间存在差异 - 不仅是第一个增量ECX而不是引用计数变量,但第一个不是线程安全的,而第二个是前缀的因此,LOCK将是线程安全的。
顺便说一句,这个LOCK前缀是multi-thread scaling in the RTL的问题之一 - 对于更新的CPU来说它更好,但仍然不完美。
因此,使用关键部分是使代码线程安全的最简单方法:
var GlobalVariable: string;
GlobalSection: TRTLCriticalSection;
procedure TThreadOne.Execute;
var LocalVariable: string;
begin
...
EnterCriticalSection(GlobalSection);
LocalVariable := GlobalVariable+'a'; { modify GlobalVariable }
GlobalVariable := LocalVariable;
LeaveCriticalSection(GlobalSection);
....
end;
procedure TThreadTwp.Execute;
var LocalVariable: string;
begin
...
EnterCriticalSection(GlobalSection);
LocalVariable := GlobalVariable; { thread-safe read GlobalVariable }
LeaveCriticalSection(GlobalSection);
....
end;
使用局部变量可使关键部分更短,因此您的应用程序将更好地扩展并充分利用CPU核心的全部功能。在EnterCriticalSection
和LeaveCriticalSection
之间,只有一个线程正在运行:其他线程将在EnterCriticalSection
调用中等待...因此,临界区越短,应用程序就越快。一些错误设计的多线程应用程序实际上可能比单线程应用程序慢!
不要忘记,如果关键部分中的代码可能引发异常,则应始终编写显式try ... finally LeaveCriticalSection() end;
块以保护锁定释放,并防止应用程序死锁。
如果使用锁(即关键部分)保护共享数据,Delphi是完全线程安全的。请注意,即使在其RTL函数中存在LOCK,也应该保护引用计数变量(如字符串):此LOCK用于假设正确的引用计数并避免内存泄漏,但它不是线程安全的。为了尽可能快地完成see this SO question。
InterlockExchange
和InterlockCompareExchange
的目的是更改共享指针变量值。您可以将其视为访问指针值的关键部分的“轻型”版本。
在所有情况下,编写工作多线程代码并不容易 - 甚至 hard ,as a Delphi expert just wrote in his blog。
您应该编写简单的线程,根本没有共享数据(在线程启动之前创建数据的私有副本,或者使用只读共享数据 - 本质上是线程安全的),或者调用一些设计良好的和经验证的库 - 如http://otl.17slon.com - 这将为您节省大量的调试时间。
答案 1 :(得分:7)
首先,根据语言标准,volatile不会像文章所说的那样做。 volatile的获取和释放语义是MSVC特定的。如果您使用其他编译器或其他平台进行编译,则可能会出现问题。 C ++ 11引入了语言支持的原子变量,希望在适当的时候最终终止(错误地)使用volatile作为线程构造。
确实实现了关键部分和互斥锁,以便从所有线程中正确地看到受保护变量的读写。
我认为考虑关键部分和互斥锁(锁)的最佳方式是实现序列化的设备。也就是说,由这种锁保护的代码块是一个接一个地连续执行而没有重叠。序列化也适用于内存访问。由于缓存一致性或读/写重新排序,不会出现任何问题。
使用内存总线上的基于硬件的锁实现互锁功能。这些函数由无锁算法使用。这意味着他们不使用像重要部分那样的重型锁,而是使用这些重量轻的硬件锁。
无锁算法比基于锁的算法更有效,但无锁算法可能非常难以正确编写。除非性能影响是可辨别的,否则优先选择关键部分而不是锁定。
另一篇值得一读的文章是The "Double-Checked Locking is Broken" Declaration。