技術文章

Delphi 記憶體映射 PDF 滑動視窗讀取

PDFlibPas 可以透過有界的唯讀記憶體映射檢視開啟本機 PDF:LoadFromMappedFileDAOpenMappedFile 在檔案上維持恰好一個滑動視窗,按需重新映射,並透過絕對偏移讀取提供每個物件切片。Delphi PDF Library 不會把整個來源檔案放進記憶體,因此位址空間用量不會隨檔案增長。這個設計針對一種明確的工作負載:解析器已經完成載入,卻仍然要逐個物件、逐段串流片段地回讀磁碟上的 GB 級 PDF

PDF 載入完成後,為什麼稀疏讀取仍然昂貴

載入 PDF 不等於讀完它,而在多 GB 檔案上,耗時就集中在這段差距裡。交叉參照表或交叉參照串流(ISO 32000-1 第 7.5.4 與 7.5.8 節)只記錄每個間接物件的起始位置。真正的位元組會在渲染頁面、解碼字型程式或擷取嵌入檔案串流(ISO 32000-1 第 7.11.4 節)時才到達。一個包含數萬個物件的 2 GB 歸檔會變成數萬個小型無序讀取,而且這些讀取在載入時沒有一個是已知的

這些讀取過去會透過共用的 Seek,再對一個有位置的串流呼叫 Read,這會同時在兩個方向上失敗。即使頁面已經駐留在作業系統快取中,每個片段仍然要付出一次檔案讀取的代價;游標又是共用的可變狀態,因此本機檔案和帶預取的漸進式 PDF 範圍載入背後的位元組範圍來源,無法在不爭搶位置的情況下執行同一套解析器程式碼。PDFlibPas 透過把絕對偏移讀取從最佳化手段提升為契約,同時解決了這兩個問題

TPDFReadAtStream 保證什麼

TPDFReadAtStream 保證在絕對偏移處讀取,而且既不依賴也不影響邏輯串流游標。它是抽象的 TStream 後代,恰好只有一個虛擬方法;程式庫中兩個與游標無關的來源都衍生自它:用於本機檔案的 TReadOnlyMappedFileStream,以及用於範圍服務遠端來源的 TByteRangeStream。物件切片讀取器只需一次判斷來源是否為 TPDFReadAtStream,如果不是就回退到舊的先 Seek 後 Read 序列,因此普通檔案串流或記憶體串流仍然可以不加修改地工作

type
  // 唯讀串流透過絕對讀取避免共用的 Seek 加 Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // 對單一本機檔案的視窗化唯讀存取
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

這個差異比簽名看起來重要得多。ReadAt 使用傳入的偏移,並讓 Position 完全保持原狀,讓巢狀解析器層可以發起讀取,而不必在每次呼叫外做保存和恢復。TReadOnlyMappedFileStream 仍像普通 TStream 一樣實作 ReadSeekSizeSeek 會把邏輯位置限制在檔案範圍內,而 Write 一律回傳 0,因為來源以唯讀方式開啟

在 Delphi 中透過映射檢視開啟 PDF

有兩個明確的入口點可以開啟映射來源,而且都不會改變現有入口點的行為。LoadFromMappedFile 載入並選取文件;DAOpenMappedFile 回傳覆蓋同一個檔案的 Direct Access 控制代碼,這正是透過 Direct Access 合併和拆分 GB 級 PDF時需要的模式。LoadFromFileDAOpenFile 保留原有的檔案共用、錯誤和相容性語意,因此不選擇映射的呼叫方不會受到影響。兩個映射入口都接受以位元組為單位的 WindowSizeOptions 位元遮罩,二者也都接受 0 作為任一參數的值

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 為 0 時選擇 64 MiB 預設值;這裡要求必須映射
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // 延遲擷取現在遍歷映射視窗,而不是執行 Seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

PDF_MAPPED_FILE_REQUIRE_MAPPING 到底強制了什麼

PDF_MAPPED_FILE_REQUIRE_MAPPING 會把靜默回退變成開啟時立即可診斷的失敗。Options 保持為 0 時,兩個入口都會接受唯讀檔案串流回退:如果平台沒有映射程式碼,或者映射呼叫失敗,文件仍然會開啟,所有讀取都透過普通檔案串流完成。設定旗標後,PDFlibPas 只有在第一個檢視建立成功時才接受輸入,並透過 LastErrorCode 回報錯誤 401,而不是載入一份悄悄表現得與舊路徑完全一樣的文件

在 Windows 上,映射串流會以 FILE_SHARE_READFILE_SHARE_WRITEFILE_SHARE_DELETE 以及 FILE_FLAG_RANDOM_ACCESS 開啟第二個唯讀控制代碼,在其上建立 PAGE_READONLY 映射,並在建構函式中映射第一個視窗。提前映射正是關鍵:映射必需的失敗會在 LoadFromMappedFile 處暴露,而不是在渲染工作進行到一半、第一次惰性物件讀取時才暴露。不過要明確保證的邊界。映射程式碼只為 Windows 目標編譯,零位元組檔案也根本不會嘗試映射,因此 PDF_MAPPED_FILE_REQUIRE_MAPPING 是一個可能合理失敗的要求,而不是可攜式承諾。負數 WindowSizeOptions 中除文件值之外的任何位元,也會以同一個錯誤 401 被直接拒絕

一個視窗,按分配粒度重新映射

始終只保留一個檢視,這正是位址空間用量與檔案大小無關的原因。WindowSize 為 0 時選擇 64 MiB;低於系統分配粒度的值會提升到該粒度;高於 1 GiB 的值會被限制;最後結果會向上捨入到完整的粒度單位,在 Windows 上通常是 65536 位元組,除非 GetSystemInfo 回報不同的 dwAllocationGranularity。當讀取落在目前檢視之外時,PDFlibPas 取消映射目前檢視,把要求偏移向下對齊到粒度邊界,並在那裡映射新的視窗。最後一個視窗會限制在實體檔案大小以內,因此檢視不會越過檔案結尾

一次讀取可以跨過任意數量的視窗:迴圈複製目前檢視能提供的內容,重新映射後繼續讀取;越過檔案結尾的要求會回傳短計數,而不是失敗。PDFlibPas 有意不向呼叫方交出檢視內的指標,因為下一次跨視窗讀取就會使它失效,呼叫方也沒有合理辦法防範這一點。映射位元組會直接複製到解析器擁有的目標緩衝區,移除了額外的檔案輸入緩衝區和位置切換,但程式庫並不聲稱最終解析器儲存實作了零拷貝。讀取端視窗化也能和寫入端組合,因為快速 PDF 合併中的位元組級參照移位會在映射來源串流流入物件位元組的同時將其流出。視窗大小的取捨很直觀:視窗越小,佔用的位址空間越少,但重新映射更頻繁,這通常是 32 位元程序中的正確選擇

鎖保護什麼,GetMappedFileInfo 回報什麼

同一個臨界區涵蓋映射檢視、回退檔案游標、邏輯位置和統計資料,兩個讀取方法的分工正是從這裡直接推導出來的。ReadAt 取得鎖後呼叫無鎖的內部讀取器;Read 取得同一把鎖,在目前邏輯位置呼叫同一個內部讀取器,然後推進位置。重用內部函式而不是公開的 ReadAt,可以避免遞迴加鎖;把鎖持有到整個複製迴圈結束,則保證並行呼叫下單一視窗重新映射的正確性。移植前還需要知道一個 Free Pascal 細節:FPC 的 Windows 單元宣告了一個同名的 TCriticalSection 記錄,因此欄位及其建構必須寫成 SyncObjs.TCriticalSection。Delphi 可以順利編譯不限定的寫法,但 FPC 會把它解析成一個沒有 CreateEnterLeave 的記錄

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped 在啟用可攜式檔案串流回退時為 false,這是唯一能證明從未建立映射的欄位
  • windowSize 是實際對齊後的視窗,而不是要求值;末尾視窗中的 mappedBytes 會小於它
  • mappedOffset 是目前保留檢視按分配粒度對齊後的起始位置,沒有活動檢視時為 -1
  • readCalls 統計成功的範圍內讀取要求,bytesRead 統計複製給呼叫方的位元組數,remapCount 包含初始檢視

針對性的回歸涵蓋跨視窗絕對讀取、邏輯游標保持、末尾短讀、無效偏移、拒絕寫入、分離視窗之間重新映射、延遲擷取 220 KB 不可壓縮附件,以及 DACloseFile 後統計資訊失效;Win32 和 Win64 無頭測試套件都發現了 1467 項測試,並在沒有忽略、失敗、錯誤或洩漏結果的情況下全部通過。如果你在 Delphi 或 C++Builder 中處理 GB 級 PDF,而效能分析器一直指向檔案讀取而不是解析,那麼值得花一個下午測量這些映射檔案入口;GetMappedFileInfo 會告訴你是否真的得到了映射。完整 API 參考和試用版位於 PDFlibPas Delphi PDF Library產品頁