一次批量渲染跑到一半就卡死了,原因是PDFium Component中的执行器(executor)在一个任务的回复被分发出去之前,都不会认为这个任务已经完成。在padSynchronize模式下,这个回复是在主线程上运行的。如果主线程阻塞住了,没有去驱动CheckSynchronize,工作线程就会在等主线程,而主线程却在等空闲
一旦你见过这个调试器画面,就再也认不错了。暂停这个已经卡死的进程,主线程停在一个针对空闲事件的等待里,位于你自己那个批处理循环下面好几层调用栈的地方。切换到任意一个工作线程,它停在TThread.Synchronize里面,手里攥着一个算好了却交不出去的结果。没有任何东西在空转,没有CPU在燃烧,整个进程就是静静地停在那儿。这篇文章要讲的,正是这种状态为什么会存在,以及决定一个跑在PDFium之上的Delphi工作线程池是乖乖听话还是反咬一口的三条相邻规则:QueueCapacity到底约束的是什么,关闭时必须按什么顺序取消任务,以及在PDFium对象所有权这件事上,并行处理买不到什么
为什么WaitForIdle会把主线程挂起来?
之所以会挂起,是因为TPdfAsyncExecutor里"空闲"的定义包含了回复的分发过程,而不仅仅是工作线程完成任务。运行中计数在DequeueTask中、当一个工作线程取走一个任务时递增,在TaskFinished中递减,而这个函数只有在TPdfAsyncTaskOperation.Execute返回之后才会被工作线程调用。这个方法运行工作线程主体,记录结果,然后按TPdfAsyncDispatchMode分发回复。在padSynchronize模式下,这次分发是一次TThread.Synchronize调用,所以Execute要等到主线程真正执行完它才会返回
Delphi把这份契约的后半部分留给了你自己去履行。TThread.Synchronize会把这个方法追加进一个全局队列,然后让调用线程阻塞在一个事件上;必须有主线程上的某段代码去调用CheckSynchronize,这个事件才会被触发。VCL的消息循环会在处理消息的间隙自动帮你做这件事,这正是为什么这个bug在交互式使用中完全不可见,却在你写出一个阻塞式的批处理循环时立刻现身。一个阻塞住的主线程,就是一个已经离开了消息循环的主线程,而一个在消息循环之外的主线程,是不会替任何人排空队列的
uses
System.Classes, FPdfAsync, PDFium;
// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
padSynchronize);
Task.WaitFor(High(Cardinal)); // the main thread now parks in a kernel wait
// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
// TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.
任务完成和执行器空闲是两个不同的里程碑
这两者是刻意分开的,而搞清楚你到底在等哪一个,正是这个问题的整个解法所在。IPdfAsyncTask.WaitFor在工作线程的结果一确定下来就会满足:Complete会先写入最终的TPdfAsyncTaskState并设置完成事件,然后才会去考虑回复的事。TPdfAsyncExecutor.WaitForIdle满足的时机要晚一些,要等到排队计数和运行计数都归零,而运行计数在回复真正送达之前是不会下降的。所以一个任务完全可能已经是patsSucceeded状态、能通过Snapshot观察到,而执行器却依然合理地处于忙碌状态
// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
ReportBatchTimeout;
// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
ATimeoutMs: Cardinal): Boolean;
var
StartedAt: UInt64;
begin
StartedAt := PdfAsyncTick;
repeat
if ATask.WaitFor(10) then
Exit(True);
CheckSynchronize(0); // release any pending padSynchronize reply
Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
until not Result;
end;
有一个后果值得内化成本能:一个抛出异常的回复不会改写历史。DispatchReply会捕获这个异常,把它存进ReplyErrorMessage,而State、CancellationReason和ErrorMessage则原样保留工作线程当初确定下来的样子。所以一个在绘制缩略图时崩溃的UI回调,绝不会把一次成功的渲染变成一次失败的渲染,你的遥测数据记录的始终是渲染引擎实际做了什么。如果你想要的是围绕单个操作的、回调形态的API,而不是一整个线程池,带可取消future的后台渲染一文介绍了那条路径
QueueCapacity也约束正在运行的工作线程吗?
不会。PDFium Component中的QueueCapacity只统计排队中的任务,从不统计那些已经在某个工作线程上运行的任务。这是刻意为之的:容量本来就是要表达等待队列上真实的背压,如果把固定的并发槽位也折算进同一个数字里,就相当于重复计算了两遍。有四个工作线程、容量为八的情况下,你完全可以同时有十二个任务在飞行中,GetStats会通过QueuedCount和RunningCount诚实地把这个拆分报告出来
var
Stats: TPdfAsyncExecutorStats;
Task: IPdfAsyncTask;
begin
// TrySubmit never raises: it returns False when the waiting line is full or
// the executor is already shutting down, and bumps RejectedCount.
if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
padSynchronize) then
begin
Stats := Executor.GetStats;
// QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
// WorkerCount and is never charged against the capacity.
LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
Stats.RejectedCount);
Exit;
end;
TPdfAsyncPriority的四条车道是严格优先级,不是加权的。DequeueTask从papCritical一路往下扫到papLow,取走第一条非空车道里的任务,每条车道内部保持FIFO顺序。这让一个交互式请求能够干净利落地插到一个尚未开始的批处理任务前面,但它绝不会打断已经在运行中的工作,而且一个不断塞入papCritical任务的调用方,可能会把papLow无限期地饿死。把最高的两条车道留给用户能明显感知到自己正在等待的事情,批量导出这类工作放在papNormal或更低的优先级上。当一个队列满了应该被当作一个值得抛出EPdfAsyncQueueFull的编程错误时用Submit,当这只是一个你打算正常处理的情况时用TrySubmit
为什么Shutdown要在执行器锁之外执行取消操作?
因为如果在锁内部取消,会颠倒锁的获取顺序,让你正想执行的这次关闭操作自己把自己挂起来。Shutdown(True)会获取执行器锁,翻转关闭标志,并通过AppendSnapshot把每一个待处理任务追加进一份本地快照数组。然后它释放这把锁,只有到这之后才会遍历这份快照、对每一项调用Cancel。取消一个任务会触发注册在它令牌源上的用户回调,而这些回调是普通的应用代码:它们可能会查询GetStats、提交补偿性的工作,或者等待空闲。这些操作每一个都会重新进入执行器锁,而一个在锁已经被持有的情况下被调用的回调,会和自己死锁
令牌源在再往下一层也遵循同样的规范。CancelWithReason获取源锁,决定唯一的"胜出"取消者,写入Reason、CancellationMessage和CancelledAtTick,只有到这之后才会原子地翻转已取消标志。先发布、后翻转,这正是让元数据可以被安全读取的关键:任何观察到IsCancelled为True的线程,都能保证在它背后找到一个完整的取消原因,而后来者会在这场竞争中落败,返回False,也就没法覆盖掉第一个原因。注册的回调会在锁内被快照并清空,但在锁外被调用,每一个都被单独包裹起来,这样一个失败的处理器就不会压制住其余的处理器。已经在运行中的任务永远不会被强行杀死;它们会在下一次工作线程主体调用ThrowIfCancelled时协作式地结束,这也正是为什么Shutdown在join这些线程之前,要先跑一次带驱动效果的WaitForIdle
并行工作线程会放宽PDFium对象所有权的规则吗?
不会,而这正是最容易被误解的一条边界。TPdfAsyncExecutor负责调度工作,它对你在这次工作内部接触到的任何东西的线程亲和性不做任何承诺。一个活跃的TPdf实例不会仅仅因为两个工作线程碰巧都调用了它,就变得可以并发访问,内部的渲染锁防的是重叠的渲染调用,不是允许你跨线程共享一个文档的许可证。并行渲染或导出意味着每个工作线程一个TPdf,在这次任务内部创建,也在这次任务内部销毁
type
TPageRenderJob = class
private
FFileName: string;
FPageIndex: Integer;
public
procedure Run(const AToken: IPdfCancellationToken);
end;
procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
LocalPdf: TPdf; // one document instance per worker, never shared
Bmp: TBitmap;
begin
LocalPdf := TPdf.Create(nil);
try
LocalPdf.FileName := FFileName;
LocalPdf.Active := True;
LocalPdf.PageNumber := FPageIndex;
AToken.ThrowIfCancelled;
Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
try
HandOffBitmap(FPageIndex, Bmp); // ownership moves to the reply stage
finally
Bmp.Free;
end;
finally
LocalPdf.Free;
end;
end;
这个代价是实实在在的,值得点明:每个工作线程都要各自付出一次解析的成本、维护自己的一份页面缓存,所以内存消耗是随工作线程数量增长的,而不是随文档数量增长的。这正是这样一种模型必须付出的代价——在这种模型里,一个工作线程可以被取消或者崩溃,而不会破坏其他任何人。如果你的工作线程反而是共享同一个查看器侧的文档实例,围绕这种做法的加锁规则在渲染锁与遗漏的调用一文中有介绍,而单文档的可取消路径则在可取消的渐进式渲染一文中
在不破坏虚方法表的前提下扩展一个已发布的接口
IPdfCancellationToken和IPdfCancellationTokenSource是COM风格的接口,外部二进制文件可能已经在使用它们,所以给任何一个接口追加一个方法,都会让后面每一个槽位在虚方法表中的位置发生偏移,悄悄地让按旧布局编译出来的调用跑偏。因此,诊断、等待、可移除回调以及原子取消这些能力,被放进了IPdfCancellationTokenEx和IPdfCancellationTokenSourceEx这两个接口里,它们是继承而来的,而不是对原接口的修改。New和Run为现有调用方保留原有语义;新代码需要CancelWithReason、WaitForCancellation或者一个受管理的IPdfCancellationRegistration时,就转而使用NewEx、NewTimeout和RunEx。继承是扩展一个已发布接口的唯一安全方式,代价是每一代都要多一个类型
NewTimeout值得老实说明一下。每一个超时源都拥有一个轻量级线程,等待取消事件或者截止时间,两者谁先到就响应谁。对于几个或者几十个截止时间来说,这种做法简单、延迟低,而且在Delphi、Lazarus和C++Builder上表现一致。但对成千上万个短期截止时间来说,这就是错误的形状了,这种情况下你应该改用一个应用级别的定时器来统一驱动取消操作,而不是持有成千上万个正在等待的线程
这些规则一旦写下来,没有一条显得多么离奇,但它们中的每一条,一旦没被遵守,都是一次生产环境事故。要等在正确的里程碑上,并让某个东西去驱动同步队列;把QueueCapacity只当作等待队列的一个上限来理解;在你自己的锁之外执行取消操作;给每一个工作线程配上它自己独立的文档。这里介绍的异步层,随同它所调度的渲染、文本和表单API,一起作为Delphi PDFium Component的一部分提供