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 内部,与开了多少文档无关
跨文档损坏在 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 工作重叠
修复还带了两处小改。加载失败现在抛带 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 在私有模块里打开存下的文档,逐步渲染它的页面、步间带取消检查,然后销毁库、卸载副本、删掉文件
隔离不免费,默认值也如实反映。每个 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 := 1TPdf.RenderPagesParallel是真并行,因为每个 worker 加载隔离的 PDFium 模块副本- 你自己的线程、任务和 future 需要一把进程级锁,把每个
TPdf从Create罩到Free
PDFium Component 为 Delphi 包装 PDFium 引擎,带批量 preflight 与校验、隔离的并行渲染、可取消的后台工作和详尽的加载诊断。详情与版本见 PDFium Component 产品页