PDFiumPas 的渲染锁是按文档划分的临界区——由 TPdf 上的 TRTLCriticalSection 字段支持的 EnterRenderLock 和 LeaveRenderLock,用于包围每一次进入 PDFium 光栅器的调用,从而避免页面在正在执行的渲染过程中被卸载或重新加载。列表中有六个方法,TPdf 和 TPdfView 各占三个,它们直接调用 PDFium 的位图和缩略图提取 API,却完全跳过了这把锁。PDFiumPas v2.26.0 通过为这六个方法都加上与其他渲染入口相同的锁定配对,补上了这个缺口
这里讨论的缺口并不是本博客其他文章介绍的 ABI 加固问题,后者分析了同一 PDFium 绑定中的 cdecl 调用约定不匹配和 FPC Win64 指针宽度截断。本文范围更窄,也更偏机械性:逐一检查六个都会进入 PDFium 渲染路径的调用点,说明它们为什么容易漏掉,以及缺少锁后产生的竞争为什么是这套代码中最难按需复现的缺陷之一
渲染锁实际保护了什么
PDFiumPas 会串行化渲染,因为当另一个线程可以释放已加载页面时,PDFium 的已加载页面无法安全地由一个线程继续读取。TPdf 在 FRenderLock 中持有一个 TRTLCriticalSection,在构造函数中初始化,并由 FRenderLockReady 标志保护,因此拆卸完成后到达的调用会静默返回,而不会进入已经删除的临界区。EnterRenderLock 和 LeaveRenderLock 是进入和离开该临界区的唯一规范方式
procedure TPdf.EnterRenderLock;
begin
if FRenderLockReady then
EnterCriticalSection(FRenderLock);
end;
procedure TPdf.LeaveRenderLock;
begin
if FRenderLockReady then
LeaveCriticalSection(FRenderLock);
end;
TPdf.RenderPage、RenderTile 和 RenderPageProgressive 在这次专项审计开始前就已经遵循了这一纪律:它们都会在调用 PDFium 前获取锁,并在 finally 块中释放锁,因此同一个 TPdf 实例上的后台预渲染不会与前台 UnloadPage 重叠。这次 PDFiumPas v2.26.0 发现的缺口不在这些显眼的入口中,而是在六个看似访问器的方法里,尽管它们每一个都要请求 PDFium 光栅化像素后才能返回结果
哪六个调用跳过了渲染锁
TPdf.GetObjectBitmap、TPdf.GetBitmap 和 TPdf.GetThumbnail 构成列表的一半,TPdfView.GetObjectBitmap、TPdfView.GetBitmap 和 TPdfView.GetThumbnail 构成另一半——同样的三个操作分别出现在两个公开相同底层页面的组件类中。六个方法最终都会调用 FPDFImageObj_GetBitmap 或 FPDFPage_GetThumbnailAsBitmap,而这两个 PDFium 入口都会当场执行光栅化,并不会返回已经渲染好的内容引用。六个方法的名称都没有出现 render,这很好地解释了为什么它们第一次实现时没有按照 RenderPage 和 RenderTile 所用的同一份检查清单处理
function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
Bitmap: FPDF_BITMAP;
begin
Result:= nil;
EnterRenderLock;
try
Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
finally
LeaveRenderLock;
end;
if Bitmap<> nil then
try
Result:= ToBitmap(Bitmap);
finally
FPDFBitmap_Destroy(Bitmap);
end;
end;
为什么 TPdfView 的锁调用要先检查 nil
TPdfView 不拥有自己的临界区——它的六次锁调用都会转发到 FPdf.EnterRenderLock 和 FPdf.LeaveRenderLock,并先检查关联的 TPdf 引用是否为 nil。之所以需要这个保护,是因为设计时的窗体上可能放着一个 TPdfView,或者在一个文档关闭、下一个文档打开之间短暂存在,此时 FPdf 还没有分配 TPdf。跳过这个检查只会用另一个崩溃替代原本要防止的竞争,因为对 nil 引用执行锁调用并不会比这场竞争更温和地失败
function TPdfView.GetThumbnail: TBitmap;
var
PdfBitmap: FPDF_BITMAP;
begin
CheckActive;
Result:= nil;
if FPdf<> nil then
FPdf.EnterRenderLock;
try
PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
finally
if FPdf<> nil then
FPdf.LeaveRenderLock;
end;
if PdfBitmap<> nil then
try
Result:= ToBitmap(PdfBitmap);
finally
FPDFBitmap_Destroy(PdfBitmap);
end;
end;
为什么 RenderPage(HDC) 也属于同一审计范围
使用设备上下文的 TPdfView.RenderPage 不在这六个方法之内——它早一个版本出现,在 PDFiumPas v2.25.0 中被发现,但仍应列入这份检查清单,因为它是同一种缺陷换了一个签名。那个重载直接调用 FPDF_RenderPage,既没有 EnterRenderLock,也没有在较旧 Delphi 编译器上防止 FPU 异常的 SetArithmeticMask 调用,而同一类中位于下面几行的 TBitmap 重载已经同时具备这两项保护。两个审计轮次相隔一个版本发现同一种失败模式,说明的与其说是某个方法的问题,不如说是缺陷的形状:只要兄弟重载看起来正确,没人重新检查的往往就是另一个重载
procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
ArithmeticMask: TArithmeticMask;
begin
CheckActive;
if FPdf<> nil then
FPdf.EnterRenderLock;
ArithmeticMask:= SetArithmeticMask;
try
FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
Ord(Rotation), EncodeRenderOptions(Options));
finally
RestoreArithmeticMask(ArithmeticMask);
if FPdf<> nil then
FPdf.LeaveRenderLock;
end;
end;
为什么这种竞争几乎无法复现
PDFiumPas 的渲染锁缺口不会在每次运行中失败,甚至大多数运行也不会失败,因为它需要两个特定条件同时落在同一个 TPdf 实例上:一个光栅化调用已经在执行,以及并发的 UnloadPage 或 ReloadPage 在同一时间窗口内到达。单线程测试根本不会经过这条路径,即使是真正的多线程负载,也只有后台渲染和文档生命周期事件恰好在某个页面的生命周期内重叠时才会触发。最现实的触发场景是基于可取消 future 的后台 PDF 预渲染,工作线程光栅化下一页的同时,用户输入又让 UI 线程重新加载或卸载当前页
FPDFImageObj_GetBitmap 和 FPDFPage_GetThumbnailAsBitmap 会遍历页面对象结构,而 UnloadPage 可以在遍历过程中释放这些结构,因此真正触发的竞争也不一定会立即产生访问冲突。稍晚发生的结构读取同样可能返回错误像素,或者破坏堆元数据,直到几次无关的分配之后才在完全没有接触 PDF 页面的函数中崩溃。这正是这类缺陷能在代码库中跨越多个发布周期而未被发现的真实原因:故障发生处的堆栈跟踪很少会指向实际缺少锁的那六行代码附近
调用方需要做什么改变
GetBitmap、GetObjectBitmap、GetThumbnail 以及 RenderPage 的 HDC 重载都保持原有公开签名不变,因为这次修复是在现有调用周围加入内部锁定,而不是迁移接口。需要记住的是,渲染锁按 TPdf 实例划分,并不是进程级全局锁,因此两个线程渲染两个分别加载的文档时仍会完全并行——只有当两个线程恰好共享同一个文档时,锁才会串行化相关操作。如果锁定已经正确而缩放或滚动时渲染仍然缓慢,那是另一个问题,可参考PDFium 渲染缓存与缩放性能技巧文章;正确性和速度是两个不同维度,这次修复只涉及前者
六个方法和一个兄弟重载只占 PDFiumPas 所公开 PDFium 表面的一小部分,但它们正是只会在调试器未覆盖的高负载下出问题的那一部分。渲染锁本身,以及现在覆盖的完整渲染入口集合,已经作为面向 Delphi、C++Builder 和 Lazarus/FPC 的PDFium Component提供