一次批次渲染會卡在一半,原因是 PDFium Component 中的執行器(executor),要等到某個工作的回覆被分派出去之後,才會認定它已經完成。在 padSynchronize 模式下,這個回覆會在主執行緒上執行。如果主執行緒處於阻塞狀態、卻沒有去抽取 CheckSynchronize 佇列,工作執行緒就會在等待主執行緒,而主執行緒卻在等待閒置狀態
除錯器裡看到的畫面,一旦你見過就再也不會認錯。暫停這個卡死的處理程序,主執行緒會停在等待閒置事件的地方,位於你自己的批次迴圈之下好幾層堆疊。切換到任何一個工作執行緒,它會停在 TThread.Synchronize 裡,手上握著一個已經完成、卻交不出去的結果。沒有任何東西在空轉,沒有任何 CPU 在燃燒,整個處理程序只是單純地停在原地。本文要探討的,正是這種狀態究竟為何會存在,以及另外三條相鄰的規則,它們決定了一個建構在 PDFium 之上的 Delphi 工作執行緒集區,究竟會乖乖聽話還是反咬你一口:QueueCapacity 實際限制的是什麼、關閉流程必須依什麼順序取消,以及在 PDFium 物件所有權這件事上,平行化並不能替你買到什麼
為何 WaitForIdle 會讓主執行緒掛住?
之所以會掛住,是因為 TPdfAsyncExecutor 對「閒置」的定義,涵蓋了回覆分派,而不只是工作執行完成而已。執行中計數,會在某個工作執行緒於 DequeueTask 取走工作時遞增,並在 TaskFinished 中遞減,而工作執行緒只有在 TPdfAsyncTaskOperation.Execute 回傳之後,才會呼叫 TaskFinished。這個方法會執行工作主體、記錄結果,接著依照 TPdfAsyncDispatchMode 分派回覆。在 padSynchronize 模式下,這個分派動作是一次 TThread.Synchronize 呼叫,所以 Execute 要等到主執行緒把它跑完,才會回傳
這份契約的後半段,Delphi 是丟給你自己去履行的。TThread.Synchronize 會把該方法附加到一個全域佇列裡,並讓呼叫端執行緒在某個事件上阻塞;主執行緒上必須有某個東西去呼叫 CheckSynchronize,那個事件才會被觸發。VCL 的訊息迴圈,會在處理訊息的間隙自動替你做這件事,這正是為何這個錯誤在互動式使用時完全不會顯現,卻在你寫出一個阻塞式的批次迴圈時立刻現形。一個阻塞中的主執行緒,就是一個已經離開訊息迴圈的主執行緒,而一個在訊息迴圈之外的主執行緒,是不會替任何人抽取佇列的
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,只計算排隊中的工作,絕不包含已經在某個工作執行緒上執行中的那些。這是刻意的設計:容量本來就是要表達等待隊列上真實的背壓(backpressure),如果把固定的並行插槽也折算進同一個數字裡,就會被重複計算一次。四個工作執行緒配上容量八的情況下,你可以同時有十二個工作在飛行中,而 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。取消一個工作,會觸發登記在其權杖來源(token source)上的使用者回呼,而這些回呼都是普通的應用程式碼:它們可能會查詢 GetStats、送出補償性工作,或等待閒置狀態。這每一種行為,都會再次進入執行器鎖,而一個在鎖已被持有的情況下被呼叫的回呼,會與自己形成死結
權杖來源在低一層,遵循著相同的紀律。CancelWithReason 會取得來源鎖、決定唯一勝出的取消發起者、寫入 Reason、CancellationMessage 與 CancelledAtTick,然後才以原子操作翻轉已取消旗標。「先發布、後翻轉」正是讓這些中繼資料能被安全讀取的關鍵:任何觀察到 IsCancelled 為 True 的執行緒,都保證能找到一個完整的取消原因在背後支撐;較晚的呼叫端則會在競爭中落敗、回傳 False,且無法覆寫第一個原因。已登記的回呼,會在鎖內完成快照與清除,卻在鎖外被呼叫,每一個都被包裹起來,讓一個失敗的處理常式,不會壓制住其餘的回呼。已經在執行中的工作,絕不會被強制終止;它們會在其工作主體下一次呼叫 ThrowIfCancelled 時,以合作方式結束,這正是為何 Shutdown 在結合各執行緒之前,最後要以一個會持續抽取佇列的 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 風格的介面,外部的二進位檔案很可能早就在使用它們了,所以在其中任何一個上附加一個方法,都會讓虛擬表(vtable)裡後面的每一個插槽都往後位移,並悄悄地讓那些依照舊版版面配置編譯出來的呼叫,被錯誤地路由。因此,診斷、等待、可移除回呼與原子取消這些能力,被放進了 IPdfCancellationTokenEx 與 IPdfCancellationTokenSourceEx 之中,這兩者是繼承而來、而不是修改原有介面。New 與 Run 對既有呼叫端而言,保持著原本的語意不變;當新程式碼想要用到 CancelWithReason、WaitForCancellation,或一個受管理的 IPdfCancellationRegistration 時,就改用 NewEx、NewTimeout 與 RunEx。繼承是擴充一個已公開介面唯一安全的做法,代價則是每一代都要多付出一個型別
NewTimeout 值得誠實地說明一下。每一個逾時來源,都擁有一個輕量級執行緒,會等待取消事件或截止時間,兩者以先到者為準。對於少數幾個、或幾十個截止時間而言,這種做法簡單、延遲低,而且在 Delphi、Lazarus 與 C++Builder 上行為一致。但對於數千個短期截止時間而言,這種形狀就不對了,這時你應該改用一個應用程式層級的計時器來驅動取消動作,而不是同時持有數千個等待中的執行緒
這些規則一旦寫下來,沒有一條顯得多麼稀奇古怪,但只要沒被遵守,每一條都會變成一次正式環境事故。要等在正確的里程碑上,並讓某個東西持續抽取同步佇列;把 QueueCapacity 只當成等待隊列的上限來理解;在你自己的鎖之外執行取消動作;並且讓每一個工作執行緒各自擁有自己的文件。本文所描述的非同步層,隨附於 Delphi PDFium Component 之中,與它所排程的渲染、文字及表單 API 一同出貨