技術文章

使用可取消的 Future 在 Delphi 中進行背景 PDF 渲染

在 PDFium 中渲染頁面是同步的。您呼叫函式庫,它會將其光柵化為您傳遞的點陣圖,並在寫入像素後返回控制權。對於單一螢幕大小、單一縮放級別的頁面,這需要幾毫秒,沒有人會注意到。對於匯出 300 dpi 的 200 頁文件,或者必須一次光柵化每個頁面的縮圖條,相同的呼叫會花費數秒鐘。如果您從主執行緒發出呼叫,訊息迴圈將停止,視窗將停止重新繪製,而 Windows 會在您的標題列上顯示可怕的「沒有回應」。工作本身是正確的。但您執行它的位置錯了

解決方法是將耗時的渲染移至背景執行緒,並將結果帶回主執行緒,然後在此處將點陣圖交給控制項。PDFium 本身並不阻止您執行此操作,但是綁定必須使交接安全,因為圍繞「在工作執行緒上執行,在 UI 上回覆」的錯誤層面很廣,並且失敗是間歇性的。PDFiumPas 中的 FPdfAsync 單元的存在是為了給這個模式一個正確的實作,並帶有一個符合長時間渲染實際行為的取消模型

工作的形式

在渲染時間超過一個影格的情況中,有三個操作佔了主導地位。批次渲染遍歷頁面範圍並將每個頁面光柵化(通常至磁碟)。多頁匯出執行相同的操作,但將輸出組裝到一個檔案中。背景頁面渲染是檢視器在使用者跳到尚未快取的頁面時執行的操作,因此點陣圖會在執行緒外產生並在準備好時顯示。這三者具有相同的約束。它們執行的時間夠長,以至於 UI 執行緒無法託管它們,它們產生了 UI 執行緒最終需要的結果,並且使用者可能會放棄它們。關閉文件、捲動過該頁面或按下取消應該會停止工作,而不是強迫使用者等待他們不再需要的輸出

最後一個約束塑造了這個設計。一個無法取消的渲染是一個在答案不再重要之後,依然保持文件開啟並消耗 CPU 的渲染。因此,該單元圍繞兩個組合的原語建立:將結果帶回的 future,以及將取消請求向前傳遞的 token

射後不理的 Future

TPdfFuture<T>.Run 接收一個 worker(工作者)、一個 reply(回覆)和一個可選的取消 token(權杖)。它在背景執行緒上啟動 worker,當 worker 完成時,它會在主執行緒上傳遞 reply。泛型參數 T 是渲染產生的任何內容,通常是點陣圖控制代碼或狀態記錄。worker 在背景執行緒執行;reply 則在觸碰 VCL 是安全的位置執行

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

故意省略的是任何類型的 Wait(等待)。沒有方法可以封鎖呼叫者直到 future 完成,這並非疏忽。從主執行緒呼叫 Wait 是死結 UI 的經典方式:worker 需要主執行緒透過 Synchronize 執行其 reply,主執行緒停在 Wait 內,雙方都無法繼續。透過拒絕提供此原語,future 排除了一些嘗試自己編寫程式碼的人最常失敗的模式。真正需要封鎖的程式碼應該使用普通的 TThread 並承擔後果。future 適用於射後不理的情況,這也是背景渲染的實際情況

結果包裝在 TPdfFutureResult<T> 中,這是一個記錄,告訴 reply 發生了三件事中的哪一件。IsSuccess 表示 worker 正常返回,並且 Value 包含渲染結果。IsCancelled 表示 token 被觸發,並且 worker 在取消點退出。IsFailure 表示 worker 拋出了例外,並且 ErrorMessage 攜帶文字。reply 檢查一次狀態並進行分支,而不是從哨兵值猜測返回的點陣圖是否真實

改變 reply 傳遞方式的 v1.61.0 競賽

這個單元最具啟發性的部分是一行花了一些時間才理解的變更。在早期版本中,worker 執行緒使用 TThread.Queue 傳遞其 reply。Queue 將 reply 發佈到主執行緒的佇列並立即返回,這看起來完全是射後不理 future 想要的。它是錯誤的,其原因值得說明,因為這是一種能通過您能想到的每一項測試的錯誤

worker 執行緒在建立時設定為 FreeOnTerminate := True。這意味著在 Execute 返回的瞬間,執行緒會進行拆解,且 TThread.Destroy 會作為清理的一部分呼叫 RemoveQueuedEvents(Self)RemoveQueuedEvents 會清除目標為該即將結束之執行緒的任何已排入佇列的方法。因此順序是:worker 完成,將其自身的 reply 排入佇列,Execute 返回,執行緒將自己銷毀,然後 RemoveQueuedEvents 刪除了主執行緒尚未執行的 reply。結果就這樣消失了。更糟的是,在主執行緒提取已排入佇列的 reply 並在執行緒被釋放的同時開始執行的狹小空檔中,reply 觸碰了半銷毀物件的欄位,這是一種釋放後使用(use-after-free)

v1.61.0 中的修復是使用 Synchronize 而不是 Queue 來傳遞 reply。Synchronize 會封鎖 worker 執行緒,直到主執行緒將 reply 執行完成。當 reply 執行時,worker 仍然存活,因此它底下沒有任何東西需要釋放,並且在 reply 傳遞完成之前,執行緒不會從 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;

這個通用教訓比具體的修復更長久。射後不理的非同步回呼是最容易產生微妙錯誤的並行模式,因為順利的路徑在第一次嘗試時就能運作,而錯誤則存在於執行緒拆解順序與佇列之間的互動中。它無法按需重現。它取決於主執行緒是否碰巧在 worker 碰巧完成銷毀自身之前耗盡了佇列,這是排程器每次執行都會做出不同決定的時機。在綁定中正確實作一次的原語,遠比在每個需要背景渲染的應用程式中重新衍生相同的程式碼更有價值

為何回呼是方法指標

worker 和 reply 不是匿名方法。它們是 procedure of object 類型,即 TPdfFutureWorker<T>TPdfFutureReply<T>,這個選擇是受編譯器矩陣限制的。PDFiumPas 可在 Delphi XE5 及更高版本上編譯,並且也可在 Delphi 模式下的 Free Pascal 3.2 上編譯,而該模式下的 FPC 3.2 不支援匿名方法。捕獲區域變數的程序參考(reference-to-procedure)回呼可以在 Delphi 上編譯但在 FPC 上會失敗,因此該單元使用了兩個編譯器都能接受的最小公分母

實際的結果是狀態所屬的位置。匿名方法會封閉在區域變數之上;方法指標則不會。因此 worker 需要的任何狀態(頁面索引、縮放比例、輸出路徑),以及 reply 需要更新的任何狀態(目標圖片控制項或進度標籤),都必須掛在傳遞其方法的物件上。在檢視器中,該物件通常是表單或它所擁有的渲染控制器。這並不是勉強施加的變通辦法;它保持該狀態的所有權在接收物件上明確可見,而不是隱藏在閉包中

協作式取消,而非強制終止

這裡的取消是協作式的。沒有 API 可以觸及 worker 執行緒並將其終止,因為在渲染中途終止執行緒會讓 PDFium 保持鎖定狀態及部分寫入的點陣圖,且強制終止後的進程狀態是無法推論的。取而代之的是,worker 會獲得一個唯讀 token 並且預計它會檢查該 token,而渲染迴圈則被編寫為在頁面之間或圖塊之間進行檢查,這樣停止時會很乾淨

該 token 提供了三種觀察取消的方式。IsCancelled 是一個廉價的布林值輪詢,適合想要自行測試與決定的迴圈。ThrowIfCancelled 是常見的情況:在自然的取消點呼叫它,如果已請求取消,它會引發 EPdfOperationCancelled 例外,這會將 worker 直接展開回到 future。RegisterCallback 附加一個單次回呼通知,在原始來源被取消時觸發一次,這在 worker 被封鎖在它可以中斷的事務中(而不是停留在一個緊密迴圈中)時很有用

例外是執行緒邊界發揮作用的地方。當 worker 引發 EPdfOperationCancelled 時,future 會捕獲它並將其轉換為取消狀態,因此 reply 會看到 IsCancelled 而不是失敗。例外物件本身永遠不會被編組(marshaled)到主執行緒。它在 worker 執行緒上生存與消亡;只有它的訊息字串被複製到 ErrorMessage 中。跨執行緒編組一個存活的例外物件,意味著觸及由一個即將完成的執行緒所擁有的記憶體,這與 Synchronize 修復所要防止的錯誤屬於同一類。狀態碼和字串能乾淨地跨越邊界;物件則不然

兩個介面,使 worker 無法取消自身

取消被刻意分拆到兩個介面。IPdfCancellationTokenSource 是寫入端:它有 Cancel,建立它的擁有者(通常是表單)會保留它,並在使用者點擊按鈕或表單關閉時呼叫 CancelIPdfCancellationToken 是讀取端:它有 IsCancelledThrowIfCancelledRegisterCallback,這也是 worker 所收到的全部內容。一個具體的物件實作了這兩者,但 worker 永遠只會獲得 token,因此它無法取消其正在執行的操作。這種分拆是 API 級別的安全防護。一個能透過其 token 呼叫 Cancel 的 worker,將會引發令人困惑的程式碼去取消它自己,而型別系統消除了這種可能性

對於呼叫者想要渲染但從不打算取消它的情況,有一個匹配的細節。與其強迫每次呼叫都使用全新的 source,該單元公開了 PdfNoCancellationToken,這是一個永遠處於未取消狀態的單例 token。當 token 參數為 nil 時,Run 會使用它來替代。該單例是在單元初始化期間及早建構的,而不是在首次使用時延遲建構,原因又是因為並行性。如果不同 worker 執行緒上的幾個 Run 呼叫同時去存取延遲建立的單例,它們可能會在其建構時發生競態,洩漏一個副本,或者短暫觀察到一個半初始化的執行個體。在任何 worker 可以執行之前建置它,便完全消除了這種競態

執行可取消的渲染

在實際應用中,您建立一個 source,將其保留在表單上,並將它的 Token 與 worker 方法及 reply 方法一起傳遞給 Run,並將取消按鈕連線到該 source。worker 在渲染時檢查 token;一旦結果返回,reply 就會更新 UI。因為回呼是方法指標,所以 worker 和 reply 會從表單的欄位中讀取它們需要的任何內容

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;

reply 處理所有三個結果,因為所有三個都是可達成的。完成的渲染報告成功,按下取消的使用者會看到取消分支,而無法寫入的檔案或解析失敗的頁面則作為帶有訊息的失敗到達。這些分支都不會封鎖,它們都不會觸碰 worker 執行緒,而且 worker 產生的點陣圖或狀態,只有在 future 於擁有 UI 的執行緒上將它傳遞之後才會被讀取

相同的執行緒紀律在檢視器的其他地方也得到了回報。在縮放變化之間保留並重複使用渲染點陣圖的方式,在我們關於渲染快取和縮放效能的筆記中有說明,而關於在 Delphi 下保持 PDFium 邊界安全更廣泛的問題,則在強化 PDFium VCL ABI 的記憶體安全性中探討。此處描述的非同步基礎設施作為 Delphi 和 C++Builder 的 PDFium 元件 的一部分提供,以及本部落格其他地方涵蓋的渲染、文本和表單 API