需要多线程内存管理器

14

我很快就要创建一个多线程项目,但我看到了一些实验(delphitools.info/2011/10/13/memory-manager-investigations)表明默认的Delphi内存管理器在多线程方面存在问题。

图片描述

因此,我找到了这个SynScaleMM。有人可以对它或类似的内存管理器提供一些反馈吗?

谢谢


11
请问您能否提供所谓“不断听到”这个说法的引用?不应该根据谣言和听说做出设计决策。 - Rob Kennedy
2
你使用的是哪个版本的 Delphi?你是否已经转移到基于现代 FastMM 的 Delphi,还是仍在使用旧的 Borland MM? - David Heffernan
5
我听过人们说世界明天就要结束了,但这并不意味着它一定会发生。正如@Rob所说,你不应该基于经常听到的话做出重大决策,而内存管理器显然是其中之一。FastMM4在多线程应用程序中表现良好,除非你正在执行某些非常密集的任务;如果是这种情况,你肯定有特定的原因想要进行更改。 - Ken White
2
例如,我在QC中发布了一个错误,其中FastMM甚至在重度并发MT下可能会死锁。 - user160694
2
我要说的是:良好的架构价值相当于交换内存管理器的100倍,除非内存管理器非常糟糕(而FastMM相当不错)。如果您在线程之间使用消息传递,则可以将争用降至一个水平,以使其不再成为重大问题。如果您需要交换内存管理器(并且除非您正在严重地使用具有10个或更多核心的机器),否则我建议更改的是架构,而不是内存管理器。 - Misha
显示剩余5条评论
6个回答

47

我们的 SynScaleMM 目前仍处于实验阶段。

编辑:请参考更稳定的ScaleMM2 和全新的SAPMM。但是我下面的评论仍然值得关注:你分配的资源越少,扩展性就会越好!

在多线程服务器环境中,它按预期工作。在某些关键测试中,扩展性比 FastMM4 更好。

但是,在多线程应用程序中,内存管理器可能不是最大的瓶颈。如果不过度使用,FastMM4 可能表现良好。

以下是一些建议(并非教条主义,只是来自实验和对 Delphi RTL 低级别知识的了解),以编写快速的多线程 Delphi 应用程序:

  • 始终为字符串或动态数组参数使用 const,例如:MyFunc(const aString: String),以避免每次调用时分配临时字符串;
  • 避免使用字符串拼接(s := s+'Blabla'+IntToStr(i)),而依赖于缓冲写入(例如最新版本的 Delphi 中提供的 TStringBuilder);
  • TStringBuilder 也不完美:例如,它将为附加一些数字数据创建大量临时字符串,并在添加某些 integer 值时使用非常缓慢的SysUtils.IntToStr()函数。我不得不重写许多低级别函数,以避免我们在SynCommons.pas中定义的 TTextWriter 类中的大多数字符串分配。
  • 不要滥用关键部分,让它们尽可能小,但如果需要一些并发访问,请依赖于一些原子修改器 - 参见例如InterlockedIncrement / InterlockedExchangeAdd
  • InterlockedExchange(来自SysUtils.pas)是更新缓冲区或共享对象的好方法。您在线程中创建了某些内容的更新版本,然后在一个低级CPU操作中交换了指向数据的共享指针(例如TObject实例)。它将通过非常好的多线程扩展性向其他线程通知更改。你必须注意数据完整性,但实际上它很有效。
  • 不要在线程之间共享数据,而是制作自己的私有副本或依赖于一些只读缓冲区(使用RCU模式能够更好地扩展);
  • 不要使用索引访问字符串字符,而是依赖于一些优化的函数,如PosEx()
  • 不要混合使用AnsiString / UnicodeString类型的变量/函数,并通过Alt-F2检查生成的汇编代码以跟踪任何隐藏的不必要转换(例如call UStrFromPCharLen);
  • 最好在procedure中使用var参数,而不是返回字符串的function(返回string的函数会添加一个UStrAsg/LStrAsg调用,它具有LOCK,会刷新所有CPU核心);
  • 如果可以,在数据或文本解析中,请使用指针和一些静态堆栈分配的缓冲区,而不是临时字符串或动态数组;
  • 不要每次需要时都创建TMemoryStream,而是依赖于类中的一个私有实例,已经分配足够内存大小,在其中使用Position写入数据以检索数据结尾,而不改变其Size(这将是MM分配的内存块);
  • 限制您创建的类实例数量:尽量重复使用同一实例,并且如果可以,请使用已分配的内存缓冲区上的一些record/object指针,将数据映射而不将其复制到临时内存中;
  • 始终使用测试驱动开发,进行专门的多线程测试,尝试达到最坏情况的极限(增加线程数、数据内容,添加一些不连贯的数据,随机暂停,尝试应力网络或磁盘访问,在真实数据的时间基准测试...);
  • 永远不要相信自己的直觉,而要使用真实数据和进程的精确计时。

我在我们的Open Source框架中尝试遵循这些规则,如果您查看我们的代码,您将找到许多实际的样例代码。


7
大部分这份好建议清单都可以总结为“尽可能避免使用堆”。 - David Heffernan
5
@David...正如您在回答中所述!我只是想用精确的解决方案技巧和想法来澄清一下。 - Arnaud Bouchez
4
有趣的建议,但其中一些似乎是以速度为优先而牺牲可维护性(例如,函数返回字符串比具有变量参数的过程更加“自然”)。 因此,我还要补充一条建议:“不要过早地进行优化”。只有在真正需要速度时才进行这些更改。 - Jonathan Morgan
2
@Jonathan,你说得完全正确:这就是我最近两次建议的原因(首先是基准测试和分析)。但是,如果你希望你的多线程应用程序能够很好地与FastMM4和当前的引用计数实现(即asm LOCK)进行扩展,那么在所有情况下,你都必须摆脱循环中的(临时)字符串分配。 - Arnaud Bouchez
2
@Darian ShortString是AnsiString,使用VCL的任何方法之前都会转换为普通的String。因此,这里将有更多的内存分配。自Delphi 2009以来,您将失去Unicode功能。在某些情况下,ShortString可能很方便(用于处理数字数据或代码级标识符),但您必须仅使用ShortString方法以避免所有这些隐藏的转换为string。因此,在我看来,这不是一个通用的建议规则-它可能会减慢您的应用程序。 - Arnaud Bouchez
显示剩余3条评论

12
如果您的应用程序可以容纳GPL许可的代码,我推荐使用Hoard。您需要编写自己的包装器,但这非常容易。在我的测试中,我没有发现任何与此代码匹配的内容。如果您的代码无法容纳GPL,则可以通过支付显著费用获取Hoard的商业许可证。
即使您无法在您的代码的外部版本中使用Hoard,您也可以将其性能与FastMM进行比较,以确定您的应用程序是否存在堆分配可扩展性问题。
我还发现,Windows Vista及更高版本中分发的msvcrt.dll的内存分配器在线程争用下具有良好的可扩展性,当然比FastMM好得多。我通过以下Delphi MM使用这些例程。
unit msvcrtMM;

interface

implementation

type
  size_t = Cardinal;

const
  msvcrtDLL = 'msvcrt.dll';

function malloc(Size: size_t): Pointer; cdecl; external msvcrtDLL;
function realloc(P: Pointer; Size: size_t): Pointer; cdecl; external msvcrtDLL;
procedure free(P: Pointer); cdecl; external msvcrtDLL;

function GetMem(Size: Integer): Pointer;
begin
  Result := malloc(size);
end;

function FreeMem(P: Pointer): Integer;
begin
  free(P);
  Result := 0;
end;

function ReallocMem(P: Pointer; Size: Integer): Pointer;
begin
  Result := realloc(P, Size);
end;

function AllocMem(Size: Cardinal): Pointer;
begin
  Result := GetMem(Size);
  if Assigned(Result) then begin
    FillChar(Result^, Size, 0);
  end;
end;

function RegisterUnregisterExpectedMemoryLeak(P: Pointer): Boolean;
begin
  Result := False;
end;

const
  MemoryManager: TMemoryManagerEx = (
    GetMem: GetMem;
    FreeMem: FreeMem;
    ReallocMem: ReallocMem;
    AllocMem: AllocMem;
    RegisterExpectedMemoryLeak: RegisterUnregisterExpectedMemoryLeak;
    UnregisterExpectedMemoryLeak: RegisterUnregisterExpectedMemoryLeak
  );

initialization
  SetMemoryManager(MemoryManager);

end.

值得指出的是,在FastMM中线程争用成为性能障碍之前,您的应用程序必须相当频繁地使用堆分配器。根据我的经验,这通常发生在应用程序进行大量字符串处理时。

对于任何遭受堆分配线程争用问题的人,我的主要建议是重新设计代码,以避免频繁使用堆。这样不仅可以避免争用,还可以避免堆分配的开销-一举两得!


Hoard仅提供商业许可证,价格不便宜,但允许使用非GPL应用程序。 - user160694
@Idsandon 我已经更新了问题,以扩展Hoard的许可证。 - David Heffernan
你还推荐在2014年使用它吗? - mca64
1
@mca64我建议你使用最适合你的应用程序的选项。MM性能非常依赖于应用程序。尝试可能的选择,看看哪个对你最好。 - David Heffernan
从我的测试来看,ScaleMM2在多线程字符串连接方面比TBB和MSVCRT快4倍。@DavidHeffernan - user15124

3

重点在于锁定

需要注意的两个问题:

  1. Delphi本身(System.dcu)使用LOCK前缀的情况;
  2. FastMM4如何处理线程争用以及在未能获取锁之后所做的操作。

Delphi本身使用LOCK前缀的情况

Borland Delphi 5于1999年发布,引入了字符串操作中的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个以上的周期;它还承受着从Ring 3到Ring 0的转换成本,可以达到1000个以上的周期。至于Sleep(1) - 除了与Sleep(0)相关的成本之外 - 它还至少延迟执行1毫秒,将控制权让给其他线程,并且如果没有线程等待被物理CPU核心执行,则将核心置于休眠状态,有效降低CPU使用率和功耗。

这就是为什么在使用FastMM进行多线程工作时,CPU使用率从未达到100% - 因为FastMM4发出了Sleep(1)。这种获取锁的方式并不是最优的。更好的方式是使用大约5000个pause指令的自旋锁,如果锁仍然忙碌,则调用SwitchToThread() API调用。如果pause不可用(在没有SSE2支持的非常旧的处理器上)或SwitchToThread() API调用不可用(在早于Windows 2000的旧版Windows上),最好的解决方案是利用EnterCriticalSection/LeaveCriticalSection,它们不会伴随着Sleep(1)的延迟,并且可以非常有效地将CPU核心控制权移交给其他线程。
我提到的分叉使用了一种新的等待锁的方法,这是英特尔在其优化手册中向开发人员推荐的 - 通过pause+SwitchToThread()的自旋循环,如果其中任何一个不可用:使用CriticalSection代替Sleep()。使用这些选项,将永远不会使用Sleep(),而是改用EnterCriticalSection/LeaveCriticalSection。测试表明,在与内存管理器一起工作的线程数与物理核心数相同或更高的情况下,使用CriticalSection而不是Sleep(在FastMM4中默认使用)的方法提供了显着的收益。在具有多个物理CPU和非统一内存访问(NUMA)的计算机上,这种收益更为明显。我已经实现了编译时选项,以取代使用Sleep(InitialSleepTime)然后使用Sleep(AdditionalSleepTime)(或Sleep(0)和Sleep(1))的原始FastMM4方法,并使用EnterCriticalSection/LeaveCriticalSection来节省宝贵的CPU周期,避免由Sleep(0)浪费的时间并提高速度(降低延迟),每次Sleep(1)至少受到1毫秒的影响,因为关键部分比Sleep(1)更适合CPU,延迟肯定更低。
当启用这些选项时,FastMM4-AVX会检查以下内容:(1)CPU是否支持SSE2指令集以及“pause”指令,(2)操作系统是否具有SwitchToThread() API调用。如果两个条件都满足,则使用“pause”自旋循环5000次,然后使用SwitchToThread()而不是关键部分;如果CPU没有“pause”指令或Windows没有SwitchToThread() API函数,则使用EnterCriticalSection/LeaveCriticalSection。
您可以在该分支上看到测试结果,包括在具有多个物理CPU(插槽)的计算机上进行的测试。
另请参阅启用英特尔处理器超线程技术的长时间自旋等待循环文章。以下是英特尔对此问题的描述 - 它非常适用于FastMM4:
在这个线程模型中,长时间的自旋等待循环很少会在传统多处理器系统上引起性能问题。但是,在具有超线程技术的系统上,它可能会导致严重的惩罚,因为当主线程在等待工作线程时,处理器资源可以被消耗。在循环中使用Sleep(0)可以挂起主线程的执行,但只有当所有可用处理器在整个等待期间都被工作线程占用时才会发生。这种情况需要所有工作线程同时完成他们的工作。换句话说,分配给工作线程的工作负载必须平衡。如果其中一个工作线程比其他线程更快地完成了工作并释放了处理器,则主线程仍然可以在一个处理器上运行。
在传统的多处理器系统上,这不会引起性能问题,因为没有其他线程使用该处理器。但是,在具有超线程技术的系统上,主线程运行的处理器是一个逻辑处理器,与另一个工作线程共享处理器资源。
许多应用程序的性质使得难以保证分配给工作线程的工作负载是平衡的。例如,多线程3D应用程序可能将从世界坐标到视图坐标的块顶点变换任务分配给工作线程团队。工作线程的工作量不仅由顶点数目确定,还由顶点的剪裁状态确定,这在主线程分配工作线程的负载时是不可预测的。
Sleep函数中的非零参数会强制等待线程睡眠N毫秒,而不考虑处理器的可用性。如果等待期间设置正确,它可以有效地阻止等待线程消耗处理器资源。但是,如果从工作负载到工作负载的等待时间是不可预测的,则较大的N值可能会使等待线程睡眠太久,而较小的N值可能会导致它过早地唤醒。
因此,避免在长时间的自旋等待循环中浪费处理器资源的首选解决方案是使用操作系统线程阻塞API(例如Microsoft Windows*线程API WaitForMultipleObjects)替换循环。这个调用会导致操作系统阻塞等待线程,以防止其消耗处理器资源。

这指的是在英特尔Pentium 4处理器和英特尔Xeon处理器上使用自旋循环应用笔记。

你也可以在stackoverflow上找到一个非常好的自旋循环实现

它还会加载普通的负载来检查,然后再发出lock存储,在循环中不要洪水般地进行锁定操作,否则会锁定总线。

FastMM4本身非常优秀。只需改进锁定,就可以获得出色的多线程内存管理器。

请注意,FastMM4中每个小块类型都是单独锁定的。

您可以在小块控制区之间放置填充,使每个区域具有自己的缓存行,不与其他块大小共享,并确保它以缓存行大小边界开始。您可以使用CPUID来确定CPU缓存行的大小。

因此,如果正确实现了适合您需求的锁定(即无论您是否需要NUMA,是否使用lock释放等),您可以获得内存分配例程的结果会快几倍,并且不会受到线程争用的严重影响。


2

FastMM对多线程处理非常好。它是Delphi 2006及以上版本的默认内存管理器。

如果您正在使用较旧的Delphi版本(Delphi 5及以上),您仍然可以使用FastMM。它可以在SourceForge上获取。


2
事实上,在高度线程竞争的情况下,FastMM 的扩展相当不理想。 - David Heffernan
1
据我所知,在任何工作负载下,FastMM都不具有可扩展性。这就是为什么你会发现存在许多可扩展的内存管理器的原因。 - David Heffernan
1
就我所知,我的应用程序进行了大量的传输,并使用了约400个线程,而FastMM处理得很好。我认为使用一个已知和经过充分测试的内存管理器比潜在的改进和多线程故障的潜在风险更重要。 - mj2008
1
我在双四核和六核机器上使用FastMM来处理高度多线程服务器(具有100个以上并发线程)。其中一个特定的服务器已经运行了6个月,并处理了超过10亿个内部消息。在我使用FastMM的5年中,我从未遇到任何问题,因此您需要非常有说服力的理由才能切换。 - Misha
1
我在这里支持Bruce的观点。很多人谈论小幅度的性能提升(5%等),但考虑到新处理器或更多核心可以将性能提高数倍,这些小幅度提升对我来说似乎是浪费时间。同样,架构变化可以比更改内存管理器带来更大的性能提升。 - Misha
显示剩余16条评论

0

-1

Delphi 6 的内存管理器已经过时且非常糟糕。我们在高负载生产服务器和多线程桌面应用程序上都使用了 RecyclerMM,并且没有遇到任何问题:它快速、可靠,不会导致过度碎片化。(碎片化是 Delphi 内存管理器最严重的问题)。

RecyclerMM 唯一的缺点是它不能直接与 MemCheck 兼容。但是,进行小的源代码修改就足以使其兼容。


1
Delphi 6在这个问题中有什么关系?OP正在使用XE。还有谁会使用MemCheck呢?我甚至找不到RecyclerMM - 它还存在吗? - David Heffernan
RecyclerMM只是与Delphi 6默认值相比较好。FastMM比它要好得多,而且你可以在从6到最新的任何Delphi版本上使用FastMM。 - Warren P

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接