技术文章

Delphi 中的内存映射 PDF 滑动窗口读取

PDFlibPas 可以通过有界的只读内存映射视图打开本地 PDF:LoadFromMappedFileDAOpenMappedFile 在文件上保持恰好一个滑动窗口,按需重新映射,并通过绝对偏移读取提供每个对象切片。Delphi PDF 库不会把整个源文件放进内存,因此地址空间占用不会随文件增长。这个设计针对一种明确的工作负载:解析器已经完成加载,却仍在逐个对象、逐段流片段地回读磁盘上的 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 库产品页