مقاله فنی

خواندن PDF با memory map در Delphi: پنجره لغزان

PDFlibPas می‌تواند یک PDF محلی را از طریق یک view محدود و فقط‌خواندنی memory-mapped باز کند: LoadFromMappedFile و DAOpenMappedFile دقیقاً یک پنجره لغزان روی فایل نگه می‌دارند، هر زمان لازم باشد آن را remap می‌کنند و هر برش object را با خواندن از offset مطلق تحویل می‌دهند. کتابخانه PDF برای Delphi کل source را در memory نگه نمی‌دارد، بنابراین مصرف address space با بزرگ‌تر شدن فایل ثابت می‌ماند. این طراحی برای یک workload مشخص ساخته شده است: PDFهای چندگیگابایتی که parser بارگذاری اولیه را تمام کرده اما هنوز object به object و تکه stream به تکه stream به دیسک برمی‌گردد

چرا بعد از load شدن PDF، خواندن‌های پراکنده هنوز پرهزینه‌اند؟

load کردن PDF به معنای تمام شدن خواندن آن نیست و در فایل چندگیگابایتی، زمان اصلی در همین فاصله صرف می‌شود. cross-reference table یا cross-reference stream (ISO 32000-1 §7.5.4 و §7.5.8) فقط ثبت می‌کند هر indirect object از کجا شروع می‌شود. byteها بعداً، هنگام render شدن page، decode شدن font program یا extract شدن embedded file stream (ISO 32000-1 §7.11.4) خوانده می‌شوند. یک archive دو گیگابایتی با ده‌ها هزار object به ده‌ها هزار read کوچک و نامرتب تبدیل می‌شود و هنگام load هیچ‌کدام از آن‌ها هنوز معلوم نیستند

مسیر قدیمی این readها، یک Seek مشترک و سپس Read روی streamی با position بود و هم‌زمان از دو جهت مشکل داشت. هر fragment هزینه یک file read را می‌پرداخت، حتی وقتی page از قبل در cache سیستم‌عامل مقیم بود؛ cursor نیز state قابل‌تغییر و مشترک بود، بنابراین یک file محلی و source مبتنی بر byte range که پشت range loading تدریجی PDF با prefetch قرار دارد، نمی‌توانستند بدون درگیری بر سر position از همان parser code استفاده کنند. PDFlibPas هر دو مشکل را با تبدیل خواندن از offset مطلق، از یک optimization به یک contract، حل می‌کند

TPDFReadAtStream چه تضمینی می‌دهد؟

TPDFReadAtStream خواندن از یک offset مطلق را تضمین می‌کند؛ خواندنی که نه به cursor منطقی stream وابسته است و نه آن را تغییر می‌دهد. این کلاس یک descendant انتزاعی از TStream با دقیقاً یک virtual method است و هر دو source مستقل از cursor در library از آن مشتق می‌شوند: TReadOnlyMappedFileStream برای fileهای محلی و TByteRangeStream برای sourceهای remote که با range سرو می‌شوند. object-slice reader فقط یک بار بررسی می‌کند source آن TPDFReadAtStream هست یا نه و اگر نباشد به sequence قدیمی seek-then-read برمی‌گردد؛ بنابراین file stream یا memory stream معمولی بدون تغییر به کارش ادامه می‌دهد

type
  // streamهای فقط‌خواندنی که read مطلق آن‌ها از Seek و Read مشترک دوری می‌کند
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // دسترسی فقط‌خواندنی پنجره‌ای به یک file محلی
  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;

تفاوت از آنچه signature نشان می‌دهد مهم‌تر است. ReadAt از offsetی که به آن داده شده استفاده می‌کند و Position را دقیقاً در جای قبلی باقی می‌گذارد؛ همین ویژگی به levelهای تو‌در‌توی parser اجازه می‌دهد بدون dance مربوط به save-and-restore اطراف هر call، read صادر کنند. TReadOnlyMappedFileStream هنوز مانند هر TStream دیگر Read، Seek و Size را پیاده‌سازی می‌کند، Seek position منطقی را داخل file clamp می‌کند و Write همیشه 0 برمی‌گرداند، چون source فقط‌خواندنی باز شده است

باز کردن PDF از طریق mapped view در Delphi

دو entry point صریح یک source mapped را باز می‌کنند و هیچ‌کدام رفتار entry pointهایی را که از قبل استفاده می‌کنید تغییر نمی‌دهند. LoadFromMappedFile یک document را load و select می‌کند؛ DAOpenMappedFile یک Direct Access handle روی همان file برمی‌گرداند و همان حالتی است که هنگام merge و split کردن PDFهای چندگیگابایتی با Direct Access می‌خواهید. LoadFromFile و DAOpenFile semantics مربوط به file sharing، error و compatibility خود را بدون تغییر حفظ می‌کنند، بنابراین برای callerهایی که opt in نکرده‌اند چیزی جابه‌جا نمی‌شود. هر دو mapped entry point، WindowSize درخواستی را برحسب byte و یک bitmask به نام Options می‌گیرند و برای هرکدام مقدار 0 را می‌پذیرند

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize صفر، پیش‌فرض 64 MiB را انتخاب می‌کند و mapping در اینجا اجباری است
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // extraction تأخیری حالا به‌جای seek در پنجره‌های mapped حرکت می‌کند
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

PDF_MAPPED_FILE_REQUIRE_MAPPING دقیقاً چه چیزی را enforce می‌کند؟

PDF_MAPPED_FILE_REQUIRE_MAPPING یک fallback خاموش را به failureای فوری و قابل‌تشخیص هنگام open تبدیل می‌کند. وقتی Options روی 0 بماند، هر دو entry point یک fallback از نوع read-only file stream را می‌پذیرند: اگر platform کد mapping نداشته باشد یا call مربوط به mapping شکست بخورد، document همچنان باز می‌شود و همه readها از مسیر file stream معمولی می‌روند. با فعال بودن flag، PDFlibPas فقط زمانی input را می‌پذیرد که نخستین view ایجاد شده باشد و refusal را از طریق LastErrorCode با مقدار 401 گزارش می‌کند، به‌جای اینکه documentی load شود که بی‌سروصدا دقیقاً مثل مسیر قدیمی عمل می‌کند

در Windows، mapped stream یک handle دوم و فقط‌خواندنی با FILE_SHARE_READ، FILE_SHARE_WRITE و FILE_SHARE_DELETE به‌اضافه FILE_FLAG_RANDOM_ACCESS باز می‌کند، یک mapping از نوع PAGE_READONLY روی آن می‌سازد و نخستین window را داخل constructor map می‌کند. eager mapping تمام هدف این است: failure مربوط به «mapping الزامی است» در LoadFromMappedFile دیده می‌شود، نه در نخستین object read تنبل در میانه یک rendering job. با این حال باید بدانید guarantee کجا تمام می‌شود. کد mapping فقط برای targetهای Windows کامپایل می‌شود و file صفر بایتی اصلاً mapping را امتحان نمی‌کند، بنابراین PDF_MAPPED_FILE_REQUIRE_MAPPING درخواستی است که می‌تواند به‌طور قانونی fail شود، نه یک promise قابل‌حمل. WindowSize منفی یا هر bitی در Options غیر از مقدار مستندشده، با همان error 401 بی‌درنگ رد می‌شود

یک window که بر اساس allocation granularity دوباره map می‌شود

همیشه فقط یک view نگه داشته می‌شود و همین باعث می‌شود مصرف address space مستقل از اندازه file بماند. مقدار 0 برای WindowSize، 64 MiB را انتخاب می‌کند؛ مقداری کمتر از system allocation granularity تا همان مقدار بالا برده می‌شود؛ مقدار بیشتر از 1 GiB سقف می‌خورد؛ و نتیجه به تعداد صحیحی از واحدهای granularity گرد می‌شود، یعنی در Windows برابر 65536 byte مگر اینکه GetSystemInfo مقدار دیگری را در dwAllocationGranularity گزارش کند. وقتی read بیرون از view فعلی قرار بگیرد، PDFlibPas آن را unmap می‌کند، offset درخواستی را تا مرز granularity به پایین align می‌کند و window تازه‌ای در همان‌جا map می‌کند. آخرین window به اندازه فیزیکی file clamp می‌شود، بنابراین view هرگز از انتهای file عبور نمی‌کند

یک read می‌تواند از هر تعداد window عبور کند: loop هر مقدار را که view فعلی فراهم می‌کند copy می‌کند، remap می‌کند و ادامه می‌دهد؛ درخواستی که از انتهای file عبور کند به‌جای failure، count کوتاه برمی‌گرداند. PDFlibPas عمداً pointerی داخل view به شما نمی‌دهد، چون read بعدی بین دو window آن را invalid می‌کند و هیچ callerی نمی‌تواند به‌طور معقول در برابر این وضعیت دفاع کند. byteهای mapped مستقیم در bufferهای مقصد تحت مالکیت parser copy می‌شوند؛ این کار buffer ورودی اضافی file و جابه‌جایی position را حذف می‌کند، اما library درباره storage نهایی parser ادعای zero-copy ندارد. windowing در سمت read با سمت write هم ترکیب می‌شود، چون جابجایی referenceهای byte-level هنگام fast PDF merge byteهای object را هنگام ورود source mapped به بیرون stream می‌کند. بده‌بستان اندازه window روشن است: window کوچک‌تر address space کمتری می‌گیرد و دفعات remap را بیشتر می‌کند؛ معمولاً همین انتخاب داخل یک process 32-bit درست است

lock از چه چیزی محافظت می‌کند و GetMappedFileInfo چه گزارشی می‌دهد؟

یک critical section از mapped view، cursor مربوط به file fallback، position منطقی و statistics محافظت می‌کند و تقسیم کار بین دو read method مستقیماً از همین lock به دست می‌آید. ReadAt lock را می‌گیرد و reader داخلی lock-free را فراخوانی می‌کند؛ Read همان lock را می‌گیرد، همان reader داخلی را در position منطقی فعلی اجرا می‌کند و سپس آن را جلو می‌برد. استفاده مجدد از function داخلی، به‌جای public ReadAt، مانع recursive locking می‌شود و نگه داشتن lock در کل loop مربوط به copy است که remap تک‌window را در برابر callهای هم‌زمان درست نگه می‌دارد. پیش از port کردن، یک نکته Free Pascal را بدانید: unit مربوط به Windows در FPC رکوردی با نام TCriticalSection خودش دارد، پس field و ساخت آن باید به شکل SyncObjs.TCriticalSection نوشته شود. Delphi فرم بدون qualification را به‌راحتی کامپایل می‌کند، اما FPC آن را به رکوردی resolve می‌کند که Create، Enter یا Leave ندارد

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 هر زمان fallback قابل‌حمل file stream فعال باشد false است و تنها fieldی است که ثابت می‌کند mapping هرگز ایجاد نشده است
  • windowSize اندازه مؤثر و align‌شده window است، نه مقداری که درخواست کرده‌اید و mappedBytes در tail window از آن کوچک‌تر است
  • mappedOffset شروع view نگه‌داری‌شده و align‌شده با allocation است یا وقتی هیچ view فعالی وجود ندارد -1 است
  • readCalls تعداد درخواست‌های موفق read در محدوده را می‌شمارد، bytesRead تعداد byteهای copy‌شده برای callerها را می‌شمارد و remapCount، view اولیه را هم دربرمی‌گیرد

Regressionهای هدفمند، readهای مطلق از مرز window، حفظ cursor منطقی، readهای کوتاه در tail، offsetهای نامعتبر، writeهای ردشده، remap بین windowهای جدا، extraction تأخیری یک attachment غیرقابل‌فشرده 220 KB و invalid شدن statistics پس از DACloseFile را پوشش می‌دهند؛ suiteهای headless مربوط به Win32 و Win64 هرکدام 1467 test پیدا کردند و همه آن‌ها را بدون نتیجه ignored، failed، errored یا leaked با موفقیت گذراندند. اگر با PDFهای چندگیگابایتی در Delphi یا C++Builder کار می‌کنید و profiler شما به‌جای parsing مدام file read را نشان می‌دهد، entry pointهای mapped-file ارزش یک بعدازظهر اندازه‌گیری را دارند و GetMappedFileInfo می‌گوید واقعاً mapping گرفته‌اید یا نه. مرجع کامل API و build آزمایشی در صفحه کتابخانه PDF برای Delphi یعنی PDFlibPas قرار دارد