技术文章

在 Delphi 中使用可取消的 Futures 进行后台 PDF 渲染

在 PDFium 中渲染页面是同步的。你调用该库,它光栅化到位图中,当像素写入完成后控制权返回。对于单一屏幕尺寸、单一缩放级别的页面,这只需几毫秒,没人会注意到。但对于 200 页文档的 300 dpi 导出,或者必须一次性光栅化每一页的缩略图条,同样的调用可能需要数秒。如果从主线程进行调用,消息循环会停止,窗口停止重绘,并且 Windows 会在你的标题栏上绘制可怕的“未响应”。工作没有错。你运行它的地方错了

解决方法是将耗时的渲染移至后台线程,并将结果带回主线程,在主线程中可以将位图交给控件。PDFium 本身并不阻止你这样做,但绑定必须确保切换是安全的,因为围绕“在工作线程上运行,在 UI 上回复”的 Bug 表面很宽,而且故障是间歇性的。PDFiumPas 中的 FPdfAsync 单元旨在为该模式提供一个正确的实现,并带有符合长渲染实际行为模式的取消模型

工作的形态

在渲染超过一帧的情况下,有三种操作占主导地位。批量渲染遍历页面范围并将每一页光栅化,通常是光栅化到磁盘。多页导出也做同样的事情,但将输出组装成一个文件。后台页面渲染是查看器在用户跳转到尚未在缓存中的页面时执行的操作,因此位图是在线程外生成的,并在准备就绪时显示。这三者都有着相同的限制。它们的运行时间太长,以至于 UI 线程无法托管它们,它们生成了 UI 线程最终需要的结果,并且用户可能会放弃它们。关闭文档、滚动经过页面或按下取消,都应该停止工作,而不是迫使等待他们不再想要的输出

最后一个限制塑造了设计。不可取消的渲染是一个在结果不再重要后仍保持文档打开并消耗 CPU 的渲染。因此,该单元围绕两个组合基元构建:一个将结果带回的 future,以及一个将取消请求向前传递的令牌

即发即弃的 future

TPdfFuture<T>.Run 接收一个工作方法(worker)、一个回复方法(reply),以及一个可选的取消令牌。它在后台线程上启动工作方法,当工作方法完成时,它在主线程上交付回复。泛型参数 T 是渲染产生的任何内容,通常是位图句柄或状态记录。工作方法在线程外运行;回复在可以安全接触 VCL 的地方运行

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

这里刻意省略了任何类型的 Wait。没有方法会阻塞调用者直到 future 完成,这并非疏忽。从主线程调用的 Wait 是导致 UI 死锁的经典方式:工作方法需要主线程通过 Synchronize 运行其回复,而主线程停在 Wait 内部,双方都无法继续。通过拒绝提供这个基元,future 排除了最常导致尝试自己编写此代码的人失败的模式。真正需要阻塞的代码应该使用原生的 TThread 并承担后果。future 适用于即发即弃(fire-and-forget)的情况,而后台渲染正是如此

结果被包装在 TPdfFutureResult<T> 中,这是一个告知回复发生了三种情况之一的记录。IsSuccess 表示工作方法正常返回,而 Value 包含渲染结果。IsCancelled 表示令牌触发,工作方法在取消点退出。IsFailure 表示工作方法引发了异常,而 ErrorMessage 包含文本。回复检查一次状态并进行分支,而不是通过标记值猜测返回的位图是否真实

改变回复交付的 v1.61.0 竞态条件

本单元中最具启发性的是一行单行更改,这花了一些时间才被理解。在早期版本中,工作线程使用 TThread.Queue 交付其回复。Queue 将回复发布到主线程的队列并立即返回,这看起来正是即发即弃的 future 所需要的。它是错误的,原因值得详细说明,因为这正是那种能通过你所有能想到的测试的 Bug

工作线程在创建时带有 FreeOnTerminate := True。这意味着 Execute 返回的瞬间,线程就会拆解自身,并且 TThread.Destroy 会在清理过程中调用 RemoveQueuedEvents(Self)RemoveQueuedEvents 清除目标为正在消亡的线程的任何排队方法。因此,序列是:工作方法完成,它将针对自身的回复排队,Execute 返回,线程销毁自身,然后 RemoveQueuedEvents 删除了主线程尚未运行的回复。结果就这样消失了。更糟糕的是,在主线程将排队的回复拉下并开始运行的同时,线程正好被释放的那一狭窄窗口期中,回复触及了半销毁对象的字段,这就是一种释放后使用(use-after-free)

v1.61.0 中的修复方法是使用 Synchronize 而不是 Queue 来交付回复。Synchronize 会阻塞工作线程,直到主线程运行回复完成为止。工作线程在它的回复执行时仍然存活,因此没有任何东西可以从它的底层被释放,而且线程直到回复已交付后才从 Execute 返回(因此也不会开始销毁自己)。这保证了交付,并关闭了释放后使用的窗口期

procedure TPdfFutureThread<T>.Execute;
begin
  FResult.Status := pfsSuccess;
  FResult.ErrorMessage := '';
  try
    FToken.ThrowIfCancelled;          // already cancelled? skip the worker
    FResult.Value := FWorker(FToken);
  except
    on E: EPdfOperationCancelled do
    begin
      FResult.Status := pfsCancelled;
      FResult.ErrorMessage := E.Message;
    end;
    on E: Exception do
    begin
      FResult.Status := pfsFailure;
      FResult.ErrorMessage := E.Message;
    end;
  end;

  if Assigned(FReply) then
    // Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
    // could be dropped by RemoveQueuedEvents before the main thread ran it.
    Synchronize(DispatchReply);
end;

总体教训比具体的修复更有价值。即发即弃的异步回调是最容易出错的并发模式,因为正常路径通常一次就能成功,而 Bug 存在于线程拆卸顺序与队列之间的交互中。它不会按需重现。它取决于主线程是否刚好在工作线程碰巧完成销毁自身之前排空了队列,这是一个调度器每次运行都会做出不同决定的时序。在绑定中只需正确一次的基元,远比在每个需要后台渲染的应用程序中重新推导的代码有价值得多

为什么回调是方法指针

工作方法和回复不是匿名方法。它们是 procedure of object 类型,即 TPdfFutureWorker<T>TPdfFutureReply<T>,这个选择是受编译器矩阵所迫。PDFiumPas 可在 Delphi XE5 及更高版本,以及 Free Pascal 3.2 的 Delphi 模式下编译,而 FPC 3.2 在该模式下不支持匿名方法。捕获局部变量的引用到过程(reference-to-procedure)回调会在 Delphi 上编译但在 FPC 上失败,因此该单元使用了两种编译器都接受的最低共同点

实际后果是状态存放在哪里。匿名方法封装在闭包上;而方法指针则不然。因此,工作方法需要的任何状态,比如页面索引、缩放级别、输出路径,以及回复方法需要更新的任何状态,比如目标图像控件或进度标签,都必须挂在方法被传递的对象上。在查看器中,该对象通常是表单(form)或它拥有的渲染控制器。这并非不情愿施加的变通方案;它使得接收对象上的该状态的所有权保持明确和可见,而不是隐藏在闭包内部

协作式取消,而不是强制终止

此处的取消是协作式的。没有任何 API 会深入工作线程并将其终止,因为在中途终止线程会使 PDFium 保持持有锁和部分写入的位图,而且强制终止后的进程状态是无法推理的。相反,工作线程会收到一个只读令牌并需要去检查它,渲染循环被编写为在页面之间或切片之间进行检查,在这些地方停止是干净利落的

该令牌提供三种观察取消的方式。IsCancelled 是为想要测试并自行决定的循环提供的廉价布尔型轮询。ThrowIfCancelled 是最常见的情况:在自然取消点调用它,如果请求了取消,它会引发 EPdfOperationCancelled,这会将工作方法直接退回到 future。RegisterCallback 附加了一个一次性的通知,当源被取消时触发,这对于阻塞在它可以中断的地方而不是处于紧凑循环中的工作方法很有用

例外情况是线程边界重要的地方。当工作方法引发 EPdfOperationCancelled 时,future 会捕获它并将其转换为取消状态,以便回复方法看到的是 IsCancelled 而不是失败。异常对象本身从未被封送到主线程。它在工作线程上产生和消亡;只有它的消息字符串被复制到 ErrorMessage 中。跨线程封送活动的异常对象意味着深入触及即将结束的线程所拥有的内存,这与 Synchronize 修复旨在防止的错误属于同一类。状态码和字符串可以干净地跨越边界;而对象则不能

两个接口,所以工作方法不能取消自身

取消有意被拆分到了两个接口上。IPdfCancellationTokenSource 是写入端:它有 Cancel,创建它的所有者(通常是表单)保留它,并在用户单击按钮或表单关闭时调用 CancelIPdfCancellationToken 是读取端:它有 IsCancelledThrowIfCancelledRegisterCallback,这是工作线程永远只会接收到的东西。一个具体对象实现了两者,但工作线程只被递交了令牌,所以它没有办法取消它正在运行的操作。这种拆分是 API 级别的护栏。如果工作线程能通过它的令牌调用到 Cancel,就会诱导一段混乱的代码去取消它自己,而类型系统消除了这种可能性

对于调用者想要渲染但永远不想取消的情况,也有匹配的细节。该单元没有强迫每次调用都创建一个全新的源,而是公开了 PdfNoCancellationToken,这是一个永久处于未取消状态的单例令牌。当令牌参数保留为 nil 时,Run 会替换它。该单例是在单元初始化期间及早构建的,而不是在首次使用时延迟构建,其原因再次归结于并发。如果不同工作线程上的几个 Run 调用同时去访问一个延迟创建的单例,它们可能会在构建时产生竞争,泄漏重复项,或者短暂地观察到一个半初始化的实例。在任何工作线程运行之前构建它,能完全消除该竞争

运行可取消的渲染

在实践中,您创建一个源,将其保留在表单上,将其 Token 连同一个工作方法和一个回复方法传递到 Run,并将取消按钮与源连接起来。工作线程在渲染时检查令牌;回复方法在结果返回后更新 UI。由于回调是方法指针,工作线程和回复方法会从表单字段中读取它们所需的任何内容

procedure TMainForm.StartRender;
begin
  FCancelSource := TPdfCancellationTokenSource.New;  // field, lives on the form
  TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;

procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
  if Assigned(FCancelSource) then
    FCancelSource.Cancel;   // worker observes this at its next cancel point
end;

// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
  PageIndex: Integer;
begin
  for PageIndex := FFirstPage to FLastPage do
  begin
    AToken.ThrowIfCancelled;        // clean stop between pages
    RenderOnePage(PageIndex);       // synchronous PDFium rasterisation
  end;
  Result := True;
end;

// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
  if AResult.IsSuccess then
    StatusLabel.Caption := 'Render complete'
  else if AResult.IsCancelled then
    StatusLabel.Caption := 'Cancelled'
  else
    StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;

回复方法处理了全部三种结果,因为这三种结果都是可达的。已完成的渲染报告成功,按下取消的用户看到取消的分支,而无法写入的文件或解析失败的页面则作为带有消息的失败到达。这些分支中没有一个会发生阻塞,没有一个触及工作线程,并且工作线程产生的位图或状态只有在 future 将其交付到拥有 UI 的线程之后才被读取

相同的线程原则在查看器的其他地方也有回报。关于跨缩放变化保留并重用已渲染的位图的方式,包含在我们关于渲染缓存和缩放性能的说明中,而在 Delphi 下保持 PDFium 边界安全的更广泛问题在强化 PDFium VCL ABI 的内存安全性中讨论。这里描述的异步基础设施作为 Delphi 和 C++Builder 的 PDFium Component 的一部分发布,以及在本博客其他地方介绍的渲染、文本和表单 API 一同提供