【问题标题】:Need multi-threading memory manager需要多线程内存管理器
【发布时间】:2011-08-29 15:49:40
【问题描述】:

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

所以,我找到了这个 SynScaleMM。任何人都可以就它或类似的内存管理器提供一些反馈?

谢谢

【问题讨论】:

  • 您能否提供一个引文,说明您“一直听到”的地方?您不应该根据谣言和传闻做出设计决策。
  • 您使用的是哪个版本的 Delphi?您是否已迁移到基于现代 FastMM 的 Delphi,还是仍在使用旧的 Borland MM?
  • 我听人说世界明天就会终结;事实并非如此。正如@Rob 所说,您不应该根据您“不断听到”的内容做出重大决定(内存管理器绝对是其中之一)。 FastMM4 在多线程应用程序中的表现相当不错,除非你正在做一些非常密集的事情;如果是这样的话,你就会有具体的理由想要改变。
  • 例如,我在 QC 中发布了一个错误,其中 FastMM 甚至可以在大量并发 MT 下死锁。
  • 我会这样说:好的架构值得 100 倍于交换内存管理器的好处,除非内存管理器非常糟糕(而 FastMM 非常好)。如果您在线程之间使用消息传递,那么您可以将争用减少到这不是一个重要问题的水平。如果您需要更换内存管理器(除非您真的要让一台拥有 10 多个内核的机器达到极限),那么我建议您需要更改架构,而不是内存管理器。

标签: delphi delphi-xe


【解决方案1】:

我们的SynScaleMM 仍处于试验阶段。

编辑:看看更稳定的ScaleMM2 和全新的SAPMM。但我在下面的评论仍然值得关注:你做的分配越少,你的规模就越大!

但它在多线程服务器环境中按预期工作。对于一些关键测试,缩放比 FastMM4 好得多。

但内存管理器可能不是多线程应用程序中更大的瓶颈。如果您不强调它,FastMM4 可以很好地工作。

如果您想在 Delphi 中编写 FAST 多线程应用程序,这里有一些建议(不是教条,只是来自实验和低级 Delphi RTL 知识):

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

我尝试在我们的开源框架中遵循这些规则,如果您查看我们的代码,您会发现很多真实世界的示例代码。

【讨论】:

  • +1 这个好建议列表中的大部分内容可以概括为“尽可能不要使用堆”
  • @David ...正如您在回答中所说的那样!我只是想用精确的解决技巧和想法让它更清楚。
  • 有趣的建议,但其中一些似乎是以可维护性为代价优先考虑速度(例如,返回字符串的函数是一种比使用 var 参数的过程更“自然”的代码编写方式)。因此,我还要添加“不要过早优化”的建议。仅在您确实需要速度时才进行其中一些更改。
  • @Jonathan 你是完全正确的:这就是我最后两个建议(第一个基准和配置文件)的原因。但是,如果您希望您的多线程应用程序能够与 FastMM4 和当前的引用计数实现(即 asm LOCK)很好地扩展,那么您将在所有情况下摆脱循环中的(临时)字符串分配。
  • @Darian ShortString 是一个 AnsiString,在使用 VCL 的任何方法之前将被转换为一个普通的String。因此,您将在这里获得更多的内存分配。从 Delphi 2009 开始,您将失去 Unicode 功能。 ShortString 在某些情况下可能很方便(用于处理数字数据或代码级标识符),但您必须仅使用 ShortString 方法来避免所有这些隐藏到 string 的转换。所以恕我直言,这不是这里建议的一般规则 - 它可能会减慢您的应用程序。
【解决方案2】:

如果您的应用可以容纳 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 中的线程争用成为性能障碍之前,您的应用程序必须非常努力地锤击堆分配器。通常,根据我的经验,当您的应用程序执行大量字符串处理时会发生这种情况。

对于任何遭受堆分配线程争用的人,我的主要建议是重新编写代码以避免碰到堆。您不仅避免了争用,而且还避免了堆分配的费用——一个经典的 twofer!

【讨论】:

  • Hoard 仅提供商业许可。不便宜,但它允许非 GPL 应用程序。
  • @Idsandon 我已更新问题以扩展 Hoard 的许可证。
  • 2014年你还推荐吗?
  • @mca64 我推荐最适合您的应用程序的方法。 MM perf 非常依赖于应用程序。尝试可能的选项,看看最适合您的选项。
  • @DavidHeffernan 根据我的测试,ScaleMM2 比 TBB 和 MSVCRT 快 4 倍。多线程字符串连接。
【解决方案3】:

locking 与众不同!

有两个问题需要注意:

  1. Delphi 本身 (System.dcu) 使用 LOCK 前缀;
  2. 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 版本等,您可能会获得内存分配例程会快几倍的结果并且不会受到线程争用的严重影响。

【讨论】:

    【解决方案4】:

    FastMM 可以很好地处理多线程。它是 Delphi 2006 及更高版本的默认内存管理器。

    如果您使用的是旧版本的 Delphi(Delphi 5 及更高版本),您仍然可以使用 FastMM。在SourceForge 上可用。

    【讨论】:

    • 事实上,FastMM 在严重的线程争用下扩展性相当差。
    • @Bruce 据我所知,在任何工作负载下。 FastMM 根本不可扩展。这就是为什么您会发现存在大量可扩展 MM 的原因。
    • FWIW,我有一个应用程序进行大量传输并使用大约 400 个线程,FastMM 处理得很好。我认为使用已知且经过良好测试的 MM 比多线程时潜在的改进和潜在的故障陷阱更重要。
    • 我将 FastMM 用于双四核和六核机器上的高度多线程服务器(具有 100 多个并发线程)。一台特定的服务器已经运行了 6 个月,并处理了超过 1,000,000,000 条内部消息。在我使用 FastMM 的 5 年里,我从来没有遇到过任何问题,所以你需要一个非常有说服力的理由来切换。
    • 我和布鲁斯在一起。很多人都在谈论小的性能提升(5% 等),考虑到更新的处理器或更多的内核可以将性能提高几倍,这在我看来只是浪费时间。同样,架构更改可以比更改内存管理器更有效地提高性能。
    【解决方案5】:

    您可以使用 TopMM: http://www.topsoftwaresite.nl/

    您也可以尝试 ScaleMM2(SynScaleMM 基于 ScaleMM1),但我必须修复有关线程间内存的错误,因此尚未准备好生产 :-( http://code.google.com/p/scalemm/

    【讨论】:

      【解决方案6】:

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

      RecyclerMM 的唯一缺点是它与开箱即用的MemCheck 不兼容。然而,一个小的源代码更改就足以使其兼容。

      【讨论】:

      • Delphi 6 哪里来的这个问题,OP 用的是XE?谁再使用 MemCheck?我什至找不到 RecyclerMM - 它还活着吗?
      • RecyclerMM 仅比 Delphi 6 默认值好。 FastMM 比这要好得多,您可以在从 6 到最新的任何 delphi 版本上使用 FastMM。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2014-07-14
      • 2011-06-27
      • 1970-01-01
      • 2012-01-12
      • 1970-01-01
      • 2018-06-19
      相关资源
      最近更新 更多