技术文章

Delphi 中的并行 PDF 页面渲染:线程安全

在 Delphi 中并行渲染 PDF 页面可以归结为一条规则:为每个工作线程提供其自己的渲染器。losLab PDF Library 正是为此公开了 RenderPagesToFilesParallel,它将页面范围分散到 TTask 池中,每个工作线程对应一个 TPDFlib 实例,从而使多核机器将批量光栅化工作转化为接近核心数量的吞吐量。相反,如果在线程之间共享单个实例,运行不仅不会优雅地减慢,反而会损坏内存并导致崩溃

当夜间作业必须将 500 页的 PDF 转换为 500 张 PNG,而服务器有 16 个核心处于空闲状态,且您第一次尝试多线程处理时却在 GDI+ 内部崩溃,那么这就是您需要参考的文章。简而言之,这里的线程安全是一种结构属性,而不是您设置 the 标志,本文的其余部分将逐步解释为什么安全结构看起来是这样的,以及真正的加速上限究竟在哪里

TPDFlib 对于并行渲染是线程安全的吗?

不是,在围绕它进行设计之前,其原因值得去理解。单个 TPDFlib 实例是声明为单线程使用的,而最棘手的部分是 TPDFPageTree.GetPage:作为选择页面的副作用,它会在实例上写入一个共享的 FPagePointer 字段。调用同一个实例的两个线程会在此字段上产生竞态,因此当工作线程 B 将页面树重新指向第 40 页时,工作线程 A 可能刚处理到第 3 页的一半。API 没有限制您编写如下所示的代码,它甚至会在出错前运行几页,这正是此类 Bug 最糟糕的表现方式

// 请勿这样做:一个共享实例,多个线程
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // 每个线程都重新进入同一个实例 -> FPagePointer 上发生数据竞争
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

这种故障是非确定性的,这正是它为什么能通过快速冒烟测试,然后在使用不同核心数且文档更重的客户机器上暴露的原因。您也无法通过在 RenderPageToFile 外层包裹锁来廉价地解决此问题,因为在整个渲染调用期间持有互斥锁会使工作串行化,从而抛弃了您想要的并行性

为什么每个渲染工作线程都需要自己的 TPDFlib 实例?

因为实例就是隔离的单位。一旦每个工作线程都拥有加载了相同文件的私有 TPDFlib,每个线程就都有了自己的页面树、自己的 FPagePointer 和自己的渲染状态,因此没有任何共享资源可供竞争。这种安全性是有代价的,您应该提前评估:每个工作线程都会将整个文档解析到内存中,因此峰值内存占用大约是单个实例开销的 N 倍。在 300 MB 的 PDF 上运行 8 个工作线程意味着同时驻留 8 个完整的解析,在非常大的输入上,这是决定您工作线程数量的约束条件,而不是 CPU。当文档巨大且您受到内存限制而非 CPU 限制时,在 无需全文档解析即可处理大型 PDF 中提到的直接访问路径通常是比增加渲染线程更好的杠杆

单次调用 API:RenderPagesToFilesParallel

losLab PDF Library 将整个安全模式打包在单个方法后面,因此在常见情况下,您无需手动编写任何相关内容。RenderPagesToFilesParallel 接受文件名和密码、DPI、包含起始和结束页、直接传递给每页光栅化路径的 Options 值、其中 %p 被页码替换的输出模式,以及工作线程上限(等于或低于零的任何值都表示自动)。它返回成功渲染的页面数量,这是一个仅限 Windows 的路径,因为它依赖于 CoInitialize 和 GDI+

// 文件名、密码、DPI、起始页、结束页、选项、模式、最大工作线程数
Rendered := Pdf.RenderPagesToFilesParallel(
  'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> 自动:取页面数和 CPU 核心数中的较小值
WriteLn(Format('%d pages rendered', [Rendered]));

为什么在每个工作线程上都需要 CoInitialize?

GDI+ 是页面渲染底层的光栅化器,并且 GDI+ 是套间线程化的:它期望在任何调用它的线程上初始化 COM。VCL 应用程序的主线程通常已经设置好了这些,但新生成的 TTask 工作线程却没有,在未初始化的线程中调用渲染路径是崩溃的可靠途径。因此,每个工作线程在入口处将 CoInitialize(nil) 与出口处的 CoUninitialize 配对,从而框定了它的整个生命周期。这是主线程之外的任何 GDI+ 或 COM 工作都需要遵守的纪律,也是使每个工作线程隔离真正成立的后半部分,前半部分是私有实例。同样的 GDI+ 光栅化路径驱动了 选择用于 PDF 输出的渲染引擎 中涵盖的单线程引擎

静态分片与动态页面认领

在 8 个工作线程中拆分 500 页的显而易见的方法是,向每个工作线程分配大约 62 页的固定切片。losLab PDF Library 并不这样做,原因在于负载均衡。页面开销差异巨大:一页正文文本可在几毫秒内渲染完毕,而一页密集的矢量地图或全幅扫描图像可能需要五十倍的时间。如果将工作切割成固定分片,恰好抽取到沉重分片的工作线程在其他工作线程闲置后仍将运行很长时间,因此您的实际运行时间是由最不幸的分片决定的,而不是平均水平。相反,每个工作线程在简短的临界区下从共享计数器认领下一页,对其进行渲染,然后再回来认领下一页,这使每个核心都保持忙碌,直到整个范围耗尽

// 线程池内每个工作线程所做的工作(简化版)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ 是套间线程化的
    try
      LocalLib := TPDFlib.Create;   // 每个工作线程一个私有实例
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // 原子地认领下一页
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

跨工作线程的结构化日志记录

如果没有日志,调试一个在 500 页中的第 213 页死掉的批处理是极其痛苦的,而幼稚的日志本身也是并发 Bug。losLab PDF Library 附带了 TPDFlibLogger,通过 TPDFlib.Logger 属性挂接,默认情况下为 nil,从而使无日志路径保持零开销。它是回调优先的:您设置 OnLog 并将记录路由到宿主需要的任何地方,并由 llDebug / llInfo / llWarn / llError 级别进行过滤,并且 PDFlibErrorMessage 将原始数字代码转换为人类可读文本,以便 Error 记录读起来不仅仅是裸整数。可选的文件接收器是唯一的共享资源,并且它由 TCriticalSection 守护,这正是为了让多个工作线程安全地追加到同一个日志文件。请注意其客观界限:只有该文件接收器是同步的,因此如果您在手动构建的池中共享一个日志记录器,且您的 OnLog 触及 UI,您仍必须自行将其封送到主线程

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // 可选的共享接收器(锁守护)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // 自行封送到 UI 线程;OnLog 在工作线程上触发
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // 默认情况下为 nil;未设置时零开销
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // 错误现在携带文本,例如 401 -> "密码错误或权限被拒绝"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

您实际应该期待多少加速?

要诚实地面对时间的去处,因为并行渲染仅在工作真正受 CPU 限制时才有效。高 DPI 输出和复杂的矢量或阴影页面是计算密集型的,在使 CPU 饱和之前,它们与核心数量呈接近线性的扩展。平凡的页面则是另一回事:在那里,每个工作线程的 LoadFromFile 开销加上写入输出文件的磁盘开销,可能会淹没渲染本身,而在一个慢速磁盘上争用运行的 8 个工作线程可能比干净的串行循环完成得更慢。将 MaxWorkers 设置为您的物理核心数,而不是某些想当然的数字;当源 PDF 很大时请注意内存,如果批处理最终证明是受 IO 限制的,解决办法是更快的存储或更少的工作线程,而不是更多的线程。在它为之构建的任务上使用,这里展示的批量渲染路径是适用于 Delphi 和 C++Builder 的标准 losLab PDF Library 的一部分,它在不产生任何您原本需要自己解决的线程安全陷阱的情况下,将空闲的核心转化为渲染完毕的页面