技术文章

PDFium 线程安全:按文档加锁在 Delphi 里为什么不够

PDFium 在模块级就不是线程安全的,所以两个 TPdf 实例在两个线程里处理两个不同文件,照样能互相弄坏对方。PDFium Component for Delphi 用两手应对:自 v3.125.1 起,ValidatePdfFilesParallel 把每一次原生 PDFium 调用都串行到一把进程级锁后面,而 TPdf.RenderPagesParallel 给每个 worker 一份隔离的 PDFium 模块副本。逼出这个修复的 bug 是最恼人的那种间歇型:批量校验测试大多数时候通过,然后报两个好文件之一为失败,然后让同进程里的下一个测试以 access violation 崩掉,有时干脆带着退出码拖垮整个 runner,连栈都留不下。测试没问题,任何单个文档也没问题,错的是那个假设:每线程一个 TPdf 不等于隔离

每线程一个 TPdf 为什么不够?

每线程一个 TPdf 不够,是因为 PDFium 把不安全状态存在模块里,不在文档里。每个 TPdf 拥有自己的 FPDF_DOCUMENT 句柄,但进程里每个句柄都由同一个已加载 DLL 服务,而那个 DLL 持有进程级单例:字体缓存、页面模块,以及文档加载、解析、渲染都要碰的其他全局结构。两个线程加载两个毫不相干的文件,就是两个线程同时往同一个字体缓存里写。Delphi 侧没有任何人拥有那份数据,所以 Delphi 侧也没有任何东西能按文档给它上锁

组件确实有一把锁,而且很容易从它得出错误结论。TPdf 把自己的渲染路径包在内部临界区里(EnterRenderLock / LeaveRenderLock,TPdf 的私有方法)。那把锁按实例计。它拦得住两个线程同时驱动同一个 TPdf——这确实是真隐患——但它看不见另一线程上的第二个实例,跨实例并发径直穿堂而过。通用规则一句话说得完:在一个已加载的 PDFium 模块里,任一时刻至多允许一个线程待在 PDFium 内部,与开了多少文档无关

PDFium Component 示意图:两个线程在不同文档上跑各自的 TPdf 实例,而每次调用都汇聚到同一个已加载的 pdfium.dll 模块,其字体缓存、页面模块等进程级全局为共享,产出加载失败、access violation 与 fail-fast 退出
PDFium 把不安全状态存在模块里而不是文档里,两个线程上的两个 TPdf 实例写的是同一个字体缓存,无论文件多不相干

跨文档损坏在 Delphi 进程里长什么样?

跨文档损坏长成一堆不相关失败的随机混合,而且伤害比闯祸的代码活得更久。v3.125.1 之前,ValidatePdfFilesParallel 给每个 worker 线程建一个 TPdf,并在共享模块上并发跑 Active := True 加 preflight 报告构建。Delphi 与 Free Pascal 构建上见过的症状覆盖了整个光谱:

  • 合法文件加载失败,或从批次里回来时被报为失败,本该通过
  • access violation 在稍后一个不相干的调用里浮出,常常在另一个测试或另一份文档里
  • Delphi 里出现 External exception C000001D。那个代码是 STATUS_ILLEGAL_INSTRUCTION,由 PDFium 内部 CHECK 和 IMMEDIATE_CRASH 宏在不变量破坏时执行的 ud2 指令触发
  • 进程以 0xC0000409(fail-fast,报成栈缓冲区溢出)或 0xC0000374(堆损坏)退出,一个 Delphi 异常都没有

后两条正是这个 bug 难以钉住的原因。并行校验跑完了,被污染的全局状态留了下来,同进程里的下一个夹具一头撞上。一次 Delphi Win64 回归跑里,一波 C000001D 失败砸在从没碰过批量校验的测试上——它们只是损坏之后第一批用 PDFium 的代码。实测数字把规模说得明明白白:同一个样本过两个 worker 的 Delphi 探针,一次跑挂了 160 份文档里的 122 份,另一次挂了 138 份,其中一次直接抛出 External exception C000001D。8 份文档、4 个 worker、5 轮的压力用例,Free Pascal Win64 上 5 跑挂 5。修复之后,同一探针 1,200 份文档挂 0

v3.125.1 起 ValidatePdfFilesParallel 怎么保住安全

ValidatePdfFilesParallel 现在把每个任务的原生那一半串行化、托管那一半保持并行。每个 worker 在创建 TPdf 之前取一个单元级临界区,并握着它穿过 FileName、Active := True、preflight 报告构建和 Free。创建与销毁放进锁内是刻意的:关文档和加载一样要回调进模块。worker 拿到捕获的 TPdfPreflightReport 记录后就放锁,对着那份记录评估校验规则——这步不碰任何 PDFium 状态,于是一个文件的规则评估与下一个文件的 PDFium 工作重叠

PDFium Component 的 ValidatePdfFilesParallel 示意图:每个 worker 握着一把进程级临界区穿过 TPdf 的创建、加载、preflight 与释放,而捕获报告的规则评估在锁外并行,批次的 PDFium 一半因此按设计串行
创建与销毁留在锁内,因为关文档会回调进模块;报告评估不碰 PDFium 状态,与下一个文件重叠

修复还带了两处小改。加载失败现在抛带 LastLoadReport.ErrorMessage 的 EPdfError,条目的 ErrorMessage 于是点名真正的解析问题,而不是一个次要的「no active document」。代价也如实交代:批次的 PDFium 部分现在是串行的,在解析与 preflight 占大头的批次上,多加 worker 收益甚微。还在 v3.125.1 之前的版本上,把 WorkerCount 设为 1;并发没了,损坏也没了

uses
  System.SysUtils, PDFium, FPdfPreflightReport;

procedure ValidateBatch(const Files: array of string);
var
  Registry: TPdfValidationRuleRegistry;
  Options: TPdfBatchValidationOptions;
  Report: TPdfBatchValidationReport;
  I: Integer;
begin
  Registry := CreateDefaultPdfValidationRuleRegistry;
  try
    Options := TPdfBatchValidationOptions.Default;
    Options.WorkerCount := 4;          // 0 = 处理器数,上限 8
    Options.Standards := [ppsPdfA];
    // 用显式 registry 时,自己挑匹配的 profile。
    // Profiles 为空列表会跑每条注册规则,没 preflight 的标准的规则
    // 会报「did not pass」
    SetLength(Options.ValidationOptions.Profiles, 1);
    Options.ValidationOptions.Profiles[0] := 'PDF/A';
    Report := ValidatePdfFilesParallel(Files, Registry, Options);
  finally
    Registry.Free;
  end;

  for I := 0 to High(Report.Results) do
    case Report.Results[I].Status of
      pbvisPass:  Writeln('PASS  ', Report.Results[I].FileName);
      pbvisFail:  Writeln('FAIL  ', Report.Results[I].FileName);
      pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
                    Report.Results[I].ErrorMessage);
    else
      Writeln('SKIP  ', Report.Results[I].FileName);   // pbvisCancelled
    end;
  Writeln(Report.PassedDocumentCount, ' passed, ',
    Report.FailedDocumentCount, ' failed, ',
    Report.ErrorDocumentCount, ' errors');
end;

registry 传 nil 是条捷径:ValidatePdfFilesParallel 会自己建默认 registry、从 Options.Standards 推导 profile 列表、返回时释放它。结果永远按输入顺序回来,不管 worker 以什么次序完工。报告格式与围绕同一引擎的命令行包装见用 PDFium Component CLI 出批量 PDF preflight 报告;PDF/A 检查本身覆盖什么,见Delphi 里的 PDF/A preflight 校验

RenderPagesParallel 怎么真正做到并行渲染页面?

TPdf.RenderPagesParallel 之所以并行,是因为它的 worker 从不共享 PDFium 模块。方法先在调用线程上把活动文档存进一个源存储。每个 worker 随后把已加载的 PDFium DLL 复制成临时目录里一个唯一命名的文件,用 LoadLibrary 加载那份副本并初始化。Windows 把从不同路径加载的 DLL 当不同模块,于是每份副本有自己的全局:自己的字体缓存、自己的页面模块、自己的一切。worker 在私有模块里打开存下的文档,逐步渲染它的页面、步间带取消检查,然后销毁库、卸载副本、删掉文件

PDFium Component 的 RenderPagesParallel 示意图:调用线程存下文档快照,随后每个 worker 把 PDFium DLL 复制成唯一的临时文件、作为带自己全局的独立模块加载、带取消检查渲染页面并卸载副本
真并行来自模块隔离:Windows 把每份 DLL 副本当不同模块,worker 之间除调用线程在锁下存下的快照外一无所共享

隔离不免费,默认值也如实反映。每个 worker 付出一份磁盘上的 DLL 副本、内存里第二套 PDFium 全局,以及对文档的一次全新解析。MaxWorkers = 0 意为至多 4 个 worker,MaxPixelsPerPage 和 MaxTotalOutputBytes 给原始输出封顶,反色与夜间双色调渲染选项被拒,因为缓冲区按原样返回。结果是一个 TPdfParallelRenderReport,其 Results 数组按请求顺序为每个请求页持有一个自上而下的 32 位缓冲

procedure RenderAllPages(Pdf: TPdf);
var
  Options: TPdfParallelRenderOptions;
  Report: TPdfParallelRenderReport;
  Pages: array of Integer;
  I: Integer;
begin
  SetLength(Pages, Pdf.PageCount);
  for I := 0 to High(Pages) do
    Pages[I] := I + 1;                 // 页号从 1 起算

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // 源快照在共享模块上拍,其他线程也用 TPdf 的话就握住
  // 进程级 PDFium 锁
  PdfiumLock.Acquire;
  try
    Report := Pdf.RenderPagesParallel(Pages, Options);
  finally
    PdfiumLock.Release;
  end;

  for I := 0 to High(Report.Results) do
    if Report.Results[I].Status = pprsSucceeded then
      SavePageBuffer(Report.Results[I])   // Width, Height, Stride, PixelFormat, Pixels
    else
      Writeln('Page ', Report.Results[I].PageNumber, ': ',
        Report.Results[I].ErrorMessage);
end;

注意调用周围那把锁。worker 模块是私有的,但开头的快照一步在共享模块上、从调用线程跑 SaveAs。进程里没别的碰 TPdf 就可以省掉锁;只要有,快照就需要与所有其他共享模块调用相同的保护

模式跨文档安全PDFium 工作并行代价
每线程一个 TPdf,无共享锁否是,直到损坏为止间歇崩溃、进程状态受损
一把进程级锁罩住全部 PDFium 调用是否PDFium 部分串行
ValidatePdfFilesParallel,自 v3.125.1 起是否;规则评估并行解析与 preflight 串行
TPdf.RenderPagesParallel是是每 worker 一份 DLL 副本、内存与一次全新解析

自己的多线程 PDFium 代码该怎么组织?

你自己的线程应当共享一把进程级锁,并为所用每个 TPdf 的完整生命周期握住它,否则就用替你隔离模块的组件 API。锁必须是整个进程唯一的对象,不是每线程一把、每窗体一把、每文档一把;两个线程不共享的锁什么也保护不了。下面的模式镜像组件自 v3.125.1 起的内部做法:在锁内创建、加载、读取、释放,不碰 PDFium 的活全放到锁外

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // 整个进程一把锁

type
  TTextExtractThread = class(TThread)
  private
    FFileName: string;
    FText: string;
  protected
    procedure Execute; override;
  public
    constructor Create(const AFileName: string);
    property ExtractedText: string read FText;
  end;

constructor TTextExtractThread.Create(const AFileName: string);
begin
  inherited Create(True);
  FFileName := AFileName;
end;

procedure TTextExtractThread.Execute;
var
  Pdf: TPdf;
  Page: Integer;
  Raw: TStringBuilder;
begin
  Raw := TStringBuilder.Create;
  try
    PdfiumLock.Acquire;
    try
      Pdf := TPdf.Create(nil);
      try
        Pdf.FileName := FFileName;
        Pdf.Active := True;
        if not Pdf.Active then
          raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
        for Page := 1 to Pdf.PageCount do
        begin
          Pdf.PageNumber := Page;
          Raw.AppendLine(Pdf.Text);
        end;
      finally
        Pdf.Free;                      // 关文档同样是 PDFium 的活
      end;
    finally
      PdfiumLock.Release;
    end;
    // 这行以下没有 PDFium,所以这部分并行跑
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

几条规矩让这个模式在真实应用里站得住:

  • TPdf.Create 和 Free 放进锁内,别只盯显眼的调用。加载、关闭、PageCount 这类属性读取、翻页、提取文本、渲染和保存都伸进模块
  • 赋值之后检查 Active。加载失败会让 Active 停在 False,LastLoadReport.ErrorMessage 说明缘由
  • 锁按文档握,不按调用握。更细的锁原则上可行,但前提是没有任何 TPdf 成员跑到锁外,而组件自己倚仗的就是粗粒度版
  • 慢的非 PDFium 工作——数据库写入、建索引、网络调用——放锁外,否则一个慢消费者串行化一切
  • 别把每实例私有渲染锁当替代品。它只防一个 TPdf 跟自己打架,仅此而已

同样的谨慎也适用于不是裸线程写的代码。后台 future 是把长渲染挡在 UI 线程之外的好办法,用可取消 future 做后台 PDF 渲染有讲,但 future 执行器不会自带全局 PDFium 锁。如果几个 future 可能同时驱动不同的 TPdf 实例,就在每个 worker 内部取同一把进程级锁,并把主线程上的查看器当共享模块的又一个客户。跨实例经异步 API 的用法没有单独审计过,保守起见,假定它需要与手写线程相同的串行化。当页面渲染之外还需要真 PDFium 并行时,独立的 worker 进程在构造上就给每个任务自己的模块

速查:Delphi 的 PDFium 线程规则

  • PDFium 的不安全状态是模块级的:字体缓存、页面模块等全局被进程里每份文档共享
  • 每线程一个 TPdf 什么也隔离不了;两个线程上的两个实例照样能互相损坏
  • 典型症状:加载失败、稍后代码里的 access violation、External exception C000001D,以及以 0xC0000409 或 0xC0000374 退出
  • 损坏驻留进程,出错的调用常常不是闯祸的那个
  • ValidatePdfFilesParallel 自 v3.125.1 起安全;老版本用 WorkerCount := 1
  • TPdf.RenderPagesParallel 是真并行,因为每个 worker 加载隔离的 PDFium 模块副本
  • 你自己的线程、任务和 future 需要一把进程级锁,把每个 TPdf 从 Create 罩到 Free

PDFium Component 为 Delphi 包装 PDFium 引擎,带批量 preflight 与校验、隔离的并行渲染、可取消的后台工作和详尽的加载诊断。详情与版本见 PDFium Component 产品页