PDFiumPas 的轉譯鎖是每份文件各自的臨界區,由 TPdf 上的 TRTLCriticalSection 欄位支援,透過 EnterRenderLock 與 LeaveRenderLock 包住每次進入 PDFium 光柵化器的呼叫,避免頁面在進行中的轉譯期間被卸載或重新載入。六個分屬 TPdf 與 TPdfView 的方法直接呼叫 PDFium 的點陣圖與縮圖 API,完全跳過這個鎖;PDFiumPas v2.26.0 以相同的鎖定配對補上這六個缺口
這裡的缺口不同於本文其他文章介紹的 ABI 強化工作,後者處理 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 入口都會立即進行光柵化。方法名稱沒有一個包含轉譯,這正是它們第一次未被列入與 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。單執行緒測試完全不會走到這條路徑,多執行緒工作負載也只有在背景轉譯與文件生命週期事件恰好重疊時才會觸發。最符合實際的觸發情境是建立在可取消工作上的背景 PDF 預先轉譯,工作執行緒光柵化下一頁時,使用者輸入讓使用者介面執行緒重新載入或卸載目前頁面
FPDFImageObj_GetBitmap 與 FPDFPage_GetThumbnailAsBitmap 會走訪頁面物件結構,而 UnloadPage 可以在走訪中途釋放它們,因此真正觸發的競態不一定立即造成存取違規。結構讀取晚了一瞬間,同樣可能回傳錯誤像素,或破壞堆積中繼資料,直到數個無關配置之後才在從未接觸 PDF 頁面的函式中當機。這就是這類錯誤能在程式庫中存活數個版本週期的原因:失敗時的堆疊追蹤通常離真正缺少鎖的六行程式碼很遠
呼叫端有哪些變化
GetBitmap、GetObjectBitmap、GetThumbnail 與 HDC 多載的 RenderPage 都維持原有公開簽名,因為修正是在既有呼叫周圍加入內部鎖定,而不是進行遷移。轉譯鎖是每個 TPdf 實例各自擁有,而非程序全域,因此兩個執行緒轉譯兩份分別載入的文件仍會完全平行執行;只有共用同一份文件的操作才會被序列化。如果鎖定本來就正確,而縮放或捲動時的轉譯仍然緩慢,那是另一個問題,可參閱PDFium 轉譯快取與縮放效能技巧文章;正確性與速度是不同面向,這項修正只處理前者
六個方法與一個同類多載只是 PDFiumPas 公開的 PDFium 介面中很小的一部分,但它們正是只有在沒有人恰好以偵錯工具執行的負載下才會失常的部分。轉譯鎖與目前涵蓋的完整轉譯入口,都是適用於 Delphi、C++Builder 與 Lazarus/FPC 的 PDFium Component 的一部分