locking 与众不同!
有两个问题需要注意:
- Delphi 本身 (System.dcu) 使用
LOCK 前缀;
- FastMM4 如何处理线程争用以及它在获取锁失败后的作用。
Delphi 本身使用LOCK 前缀
1999 年发布的 Borland Delphi 5 是在字符串操作中引入 lock 前缀的版本。如您所知,当您将一个字符串分配给另一个字符串时,它不会复制整个字符串,而只会增加字符串内的引用计数器。如果修改字符串,就是取消引用,减少引用计数器并为修改后的字符串分配单独的空间。
在 Delphi 4 和更早的版本中,增加和减少引用计数器的操作是正常的内存操作。使用过 Delphi 的程序员知道,并且,如果他们在线程间使用字符串,即将字符串从一个线程传递到另一个线程,那么只对相关字符串使用他们自己的锁定机制。程序员也确实使用了只读字符串副本,它不会以任何方式修改源字符串并且不需要锁定,例如:
function AssignStringThreadSafe(const Src: string): string;
var
L: Integer;
begin
L := Length(Src);
if L <= 0 then Result := '' else
begin
SetString(Result, nil, L);
Move(PChar(Src)^, PChar(Result)^, L*SizeOf(Src[1]));
end;
end;
但是在 Delphi 5 中,Borland 在字符串操作中添加了 LOCK 前缀,与 Delphi 4 相比,它们变得非常慢,即使对于单线程应用程序也是如此。
为了克服这种缓慢,程序员开始使用带有锁注释的“单线程”SYSTEM.PAS 补丁文件。
请查看https://synopse.info/forum/viewtopic.php?id=57&p=1了解更多信息。
FastMM4 线程争用
您可以修改 FastMM4 源代码以获得更好的锁定机制,或使用任何现有的 FastMM4 分支,例如 https://github.com/maximmasiutin/FastMM4
FastMM4 对于多核操作来说并不是最快的,尤其是当线程数多于物理套接字数时,因为它在默认情况下处于线程争用状态(即当一个线程无法获取对数据的访问时,被锁定)另一个线程)调用 Windows API 函数 Sleep(0),然后,如果锁仍然不可用,则在每次检查锁后调用 Sleep(1) 进入循环。
每次调用 Sleep(0) 都会经历昂贵的上下文切换成本,可能是 10000 多个周期;它还承受从环 3 到环 0 转换的成本,可能是 1000 多个周期。至于 Sleep(1)——除了与 Sleep(0) 相关的成本之外——它还会将执行延迟至少 1 毫秒,将控制权让给其他线程,并且,如果没有线程等待物理 CPU 内核执行,使内核进入睡眠状态,有效降低 CPU 使用率和功耗。
这就是为什么在使用 FastMM 的多线程 wotk 上,CPU 使用率从未达到 100% - 因为 FastMM4 发出了 Sleep(1)。这种获取锁的方式并不是最优的。更好的方法是使用大约 5000 个pause 指令的自旋锁,如果锁仍然忙,则调用 SwitchToThread() API 调用。如果pause 不可用(在不支持 SSE2 的非常旧的处理器上)或 SwitchToThread() API 调用不可用(在 Windows 2000 之前的非常旧的 Windows 版本上),最好的解决方案是利用 EnterCriticalSection/LeaveCriticalSection,没有与 Sleep(1) 相关的延迟,并且还非常有效地将 CPU 内核的控制权交给了其他线程。
我提到的分叉使用了一种新的方法来等待锁定,这是英特尔在其Optimization Manual 中为开发人员推荐的——pause + SwitchToThread() 的自旋循环,如果其中任何一个不是可用:CriticalSections 而不是 Sleep()。使用这些选项,将永远不会使用 Sleep(),而是使用 EnterCriticalSection/LeaveCriticalSection。测试表明,使用 CriticalSections 而不是 Sleep(之前在 FastMM4 中默认使用)的方法在使用内存管理器的线程数与物理内核数相同或更高的情况下提供了显着的收益。这种增益在具有多个物理 CPU 和非统一内存访问 (NUMA) 的计算机上更为明显。我已经实现了编译时选项,以取消使用 Sleep(InitialSleepTime) 和 Sleep(AdditionalSleepTime)(或 Sleep(0) 和 Sleep(1))的原始 FastMM4 方法,并用 EnterCriticalSection/LeaveCriticalSection 替换它们以节省宝贵的 CPU 周期被 Sleep(0) 浪费了,并提高了每次被 Sleep(1) 影响至少 1 毫秒的速度(减少延迟),因为关键部分对 CPU 更友好,并且延迟肯定比 Sleep(1) 低.
启用这些选项后,FastMM4-AVX 会检查:(1) CPU 是否支持 SSE2 以及“暂停”指令,以及 (2) 操作系统是否有 SwitchToThread() API 调用,以及,如果满足这两个条件,使用“暂停”自旋循环进行 5000 次迭代,然后使用 SwitchToThread() 而不是临界区;如果 CPU 没有“暂停”指令或 Windows 没有 SwitchToThread() API 函数,它将使用 EnterCriticalSection/LeaveCriticalSection。
您可以查看测试结果,包括在该 fork 中具有多个物理 CPU(插槽)的计算机上进行的测试。
另请参阅Long Duration Spin-wait Loops on Hyper-Threading Technology Enabled Intel Processors 文章。以下是英特尔关于这个问题的文章——它非常适用于 FastMM4:
这种线程模型中的长时间自旋等待循环很少会导致传统多处理器系统出现性能问题。但它可能会给采用超线程技术的系统带来严重的损失,因为主线程在等待工作线程时可能会消耗处理器资源。循环中的 Sleep(0) 可能会暂停主线程的执行,但前提是在整个等待期间所有可用的处理器都已被工作线程占用。这种情况要求所有工作线程同时完成它们的工作。换句话说,分配给工作线程的工作负载必须是平衡的。如果其中一个工作线程比其他工作线程更早完成工作并释放处理器,则主线程仍然可以在一个处理器上运行。
在传统的多处理器系统上,这不会导致性能问题,因为没有其他线程使用该处理器。但在采用超线程技术的系统上,运行主线程的处理器是一个逻辑处理器,它与其他工作线程之一共享处理器资源。
许多应用程序的性质使得很难保证分配给工作线程的工作负载是平衡的。例如,多线程 3D 应用程序可以将用于将顶点块从世界坐标转换为查看坐标的任务分配给一组工作线程。工作线程的工作量不仅取决于顶点的数量,还取决于顶点的裁剪状态,当主线程为工作线程分配工作量时,这是无法预测的。
Sleep 函数中的非零参数会强制等待线程休眠 N 毫秒,而不管处理器可用性如何。如果等待时间设置得当,它可以有效地阻止等待线程消耗处理器资源。但是如果等待周期在不同工作负载之间是不可预测的,那么较大的 N 值可能会使等待线程休眠时间过长,而较小的 N 值可能会导致其唤醒过快。
因此,避免在长时间自旋等待循环中浪费处理器资源的首选解决方案是将循环替换为操作系统线程阻塞 API,例如 Microsoft Windows* 线程 API,
等待多个对象。此调用导致操作系统阻止等待线程消耗处理器资源。
参考Using Spin-Loops on Intel Pentium 4 Processor and Intel Xeon Processor应用笔记。
你还可以找到一个非常好的自旋循环实现here at stackoverflow。
它还加载正常负载,只是为了在发出lock-ed 存储之前进行检查,只是为了不让 CPU 在循环中被锁定操作淹没,这会锁定总线。
FastMM4 本身非常好。只需改进锁定,您将获得出色的多线程内存管理器。
另请注意,每个小块类型在 FastMM4 中都是单独锁定的。
您可以在小块控制区域之间放置填充,以使每个区域都有自己的缓存线,不与其他块大小共享,并确保它从缓存线大小边界开始。您可以使用 CPUID 来确定 CPU 缓存行的大小。
因此,正确实现锁定以满足您的需求(即是否需要NUMA,是否使用lock-ing 版本等,您可能会获得内存分配例程会快几倍的结果并且不会受到线程争用的严重影响。