บทความเทคนิค

Thread safety ของ PDFium: ทำไม lock รายเอกสารไม่พอใน Delphi

PDFium ไม่ใช่ thread-safe ในระดับโมดูล สอง TPdf ที่ทำงานกับสองไฟล์ต่างกันในสอง thread ก็ยังทำให้กันและกันพังได้ PDFium Component สำหรับ Delphi รับมือเรื่องนี้สองทาง ตั้งแต่ v3.125.1 ValidatePdfFilesParallel ทำให้ call PDFium ระดับ native ทุกตัววิ่งต่อกันหลัง lock ระดับ process หนึ่งอัน ขณะที่ TPdf.RenderPagesParallel ให้ worker แต่ละตัวมีสำเนาโมดูล PDFium แยกของตัวเอง บั๊กที่บังคับให้แก้เป็นบั๊กแบบเว้นระยะที่แสบที่สุด เทสต์ batch validation ผ่านได้เกือบทุกครั้ง แล้วรายงานว่าไฟล์ดีตัวใดตัวหนึ่งล้มเหลว แล้วทำให้เทสต์ตัวถัดไปใน process เดิมพังด้วย access violation และบางครั้งก็พา runner ทั้งตัวตายไปพร้อม exit code โดยไม่ทิ้ง stack trace ให้เลย เทสต์ไม่มีอะไรผิด เอกสารไหนก็ไม่มีอะไรผิด สิ่งที่ผิดคือสมมติฐาน: TPdf หนึ่งตัวต่อ thread ไม่ใช่การแยกตัว

ทำไม TPdf หนึ่งตัวต่อ thread ถึงยังไม่พอ

TPdf หนึ่งตัวต่อ thread ยังไม่พอ เพราะ PDFium เก็บ state ที่ไม่ปลอดภัยไว้ที่ระดับโมดูล ไม่ใช่ที่ระดับเอกสาร แต่ละ TPdf ถือ FPDF_DOCUMENT handle ของตัวเอง แต่ทุก handle ใน process ถูกดูแลโดย DLL ตัวเดียวที่โหลดไว้ และ DLL ตัวนั้นถือ singleton ระดับ process ไว้หลายอย่าง: font cache, page module และโครงสร้าง global อื่นที่การโหลดเอกสาร การ parse กับการเรนเดอร์ล้วนแตะต้อง สอง thread ที่โหลดสองไฟล์ที่ไม่เกี่ยวกันคือสอง thread ที่เขียนลง font cache เดียวกันพร้อมกัน ฝั่ง Delphi ไม่มีใครเป็นเจ้าของข้อมูลชุดนั้น ฝั่ง Delphi จึงไม่มีอะไรจะ lock มันรายเอกสารได้

component มี lock อยู่จริง และมันง่ายเหลือเกินที่จะดึงข้อสรุปผิดจากมัน TPdf ห่อเส้นทางเรนเดอร์ของตัวเองด้วย critical section ภายใน (EnterRenderLock / LeaveRenderLock เมธอด private ของ TPdf) lock นั้นเป็นราย instance มันกันสอง thread ไม่ให้พร้อมกันขับเคลื่อน TPdf ตัวเดียว ซึ่งเป็นอันตรายจริง แต่มันมองไม่เห็น instance ที่สองบน thread อื่น ความพร้อมเพรียงข้าม instance จึงเดินฝ่ามันไปตรง ๆ กฎทั่วไปเรียบง่ายพอจะพูดจบในบรรทัดเดียว: ในโมดูล PDFium ที่โหลดไว้หนึ่งตัว มี thread ได้มากที่สุดหนึ่งตัวที่อยู่ข้างใน PDFium ได้ในทุกช่วงเวลา ไม่ว่าจะเปิดเอกสารกี่ฉบับ

แผนภาพ PDFium Component ของสอง thread ที่รัน TPdf แยกกันบนเอกสารต่างฉบับ ขณะที่ทุก call ลู่เข้าโมดูล pdfium.dll ที่โหลดไว้ตัวเดียวซึ่ง font cache, page module กับ global ระดับ process อื่นถูกแชร์กัน ผลคือความล้มเหลวในการโหลด access violation และการจบแบบ fail-fast
PDFium เก็บ state ที่ไม่ปลอดภัยไว้ที่ระดับโมดูล ไม่ใช่รายเอกสาร สอง TPdf บนสอง thread จึงเขียนลง font cache เดียวกัน ไม่ว่าสองไฟล์จะไม่เกี่ยวข้องกันแค่ไหน

ความเสียหายข้ามเอกสารใน process Delphi หน้าตาเป็นอย่างไร

ความเสียหายข้ามเอกสารหน้าตาเหมือนสุ่มจับมาผสมกันของความล้มเหลวที่ไม่เกี่ยวกัน และความเสียหายอยู่รอดยาวกว่าโค้ดที่ก่อมันขึ้น ก่อน v3.125.1 ValidatePdfFilesParallel สร้าง TPdf หนึ่งตัวต่อ worker thread แล้วรัน Active := True พร้อมการสร้างรายงาน preflight พร้อมกันบนโมดูลที่แชร์กัน อาการที่เจอบนบิลด์ทั้ง Delphi กับ Free Pascal ครบทุกระดับ:

  • ไฟล์ที่ถูกต้องโหลดไม่ขึ้น หรือกลับมาจาก batch ด้วยสถานะล้มเหลวทั้งที่ควรผ่าน
  • access violation โผล่ใน call ถัด ๆ ไปที่ไม่เกี่ยวข้อง มักอยู่ในเทสต์ตัวอื่นหรือเอกสารอื่น
  • External exception C000001D ปรากฏใน Delphi รหัสนี้คือ STATUS_ILLEGAL_INSTRUCTION ซึ่งถูกยิงโดยคำสั่ง ud2 ที่ macro CHECK ภายในกับ IMMEDIATE_CRASH ของ PDFium execute เมื่อ invariant พัง
  • process จบด้วย 0xC0000409 (fail-fast ถูกรายงานเป็น stack buffer overrun) หรือ 0xC0000374 (heap corruption) โดยไม่มี exception ฝั่ง Delphi ให้เห็นเลย

สองข้อสุดท้ายคือเหตุที่บั๊กนี้ตามตัวมันยากมาก การตรวจแบบขนานจบไปแล้ว แต่ global state ที่เสียหายยังค้างอยู่ และ fixture ตัวถัดไปใน process เดิมก็สะดุดมัน ในการรัน regression หนึ่งรอบบน Delphi Win64 คลื่นความล้มเหลว C000001D ตกลงบนเทสต์ที่ไม่เคยแตะ batch validation เลย พวกมันแค่เป็นโค้ดชุดแรกที่ใช้ PDFium หลังความเสียหายเกิดขึ้น ตัวเลขที่วัดได้ทำให้ขนาดของปัญหาชัดเจน probe ของ Delphi ที่รันตัวอย่างเดียวกันผ่าน worker สองตัวล้มเหลว 122 จาก 160 เอกสารในหนึ่งรอบ และ 138 จาก 160 ในอีกรอบ โดยหนึ่งในสองรอบนั้นยิง External exception C000001D มาตรง ๆ กรณี stress ของ 8 เอกสาร 4 worker กับ 5 รอบล้มเหลวหรือพังใน 5 จาก 5 รอบบน Free Pascal Win64 หลังแก้แล้ว probe เดิมล้มเหลว 0 จาก 1,200 เอกสาร

ValidatePdfFilesParallel คงความปลอดภัยตั้งแต่ v3.125.1 อย่างไร

ValidatePdfFilesParallel ตอนนี้ทำให้ครึ่ง native ของแต่ละงานวิ่งต่อกัน แล้วคงครึ่ง managed ให้ขนาน worker ทุกตัวยึด critical section ระดับยูนิตหนึ่งอันก่อนสร้าง TPdf ของตัวเอง แล้วถือมันตลอด FileName, Active := True, การสร้างรายงาน preflight และ Free การสร้างกับการทำลายถูกใส่ใน lock ตั้งใจไว้ เพราะการปิดเอกสารก็ call กลับเข้าโมดูลเหมือนการโหลด พอ worker ได้ record TPdfPreflightReport ที่จับไว้แล้ว มันจะคืน lock แล้วประเมินกฎการตรวจกับ record นั้น ซึ่งไม่แตะ state ของ PDFium การประเมินกฎของไฟล์หนึ่งจึงทับซ้อนกับงาน PDFium ของไฟล์ถัดไปได้

แผนภาพ ValidatePdfFilesParallel ของ PDFium Component แสดง worker แต่ละตัวถือ critical section ระดับ process หนึ่งอันครอบ TPdf ตั้งแต่สร้าง โหลด preflight ไปจนถึง free ขณะที่การประเมินกฎกับรายงานที่จับไว้รันนอก lock อย่างขนาน ครึ่ง PDFium ของ batch จึงเป็น serial ตามการออกแบบ
การสร้างกับการทำลายอยู่ใน lock เพราะการปิดเอกสาร call กลับเข้าโมดูลด้วย ขณะที่การประเมินรายงานไม่แตะ state ของ PDFium และทับซ้อนกับไฟล์ถัดไปได้

การเปลี่ยนเล็ก ๆ อีกสองเรื่องมาพร้อมการแก้ ความล้มเหลวในการโหลดตอนนี้ขึ้น EPdfError พร้อม LastLoadReport.ErrorMessage ErrorMessage ของรายการจึงระบุปัญหาการ parse จริง แทน error รองแบบ “ไม่มีเอกสารที่ Active” และต้นทุนก็ถูกพูดตรง ๆ ว่าอย่างไร: ส่วน PDFium ของ batch ตอนนี้เป็น serial บน batch ที่จุดถ่วงอยู่ที่การ parse กับ preflight การเพิ่ม worker ก็ซื้ออะไรไม่ค่อยได้ ถ้าคุณอยู่บนเวอร์ชันก่อน v3.125.1 เซ็ต WorkerCount เป็น 1 มันตัดความพร้อมเพรียงออกไปพร้อมกับความเสียหายในตัว

uses
  System.SysUtils, PDFium, FPdfPreflightReport;

procedure ValidateBatch(const Files: array of string);
var
  Registry: TPdfValidationRuleRegistry;
  Options: TPdfBatchValidationOptions;
  Report: TPdfBatchValidationReport;
  I: Integer;
begin
  Registry := CreateDefaultPdfValidationRuleRegistry;
  try
    Options := TPdfBatchValidationOptions.Default;
    Options.WorkerCount := 4;          // 0 = จำนวน processor ปิดเพดานที่ 8
    Options.Standards := [ppsPdfA];
    // ถ้าส่ง registry ชัด ๆ ต้องเลือก profile ที่ตรงกันเอง
    // Profiles ว่างจะรันกฎที่ลงทะเบียนไว้ทุกกฎ และกฎของมาตรฐานที่
    // คุณไม่ได้ preflight จะรายงานว่า "ไม่ผ่าน"
    SetLength(Options.ValidationOptions.Profiles, 1);
    Options.ValidationOptions.Profiles[0] := 'PDF/A';
    Report := ValidatePdfFilesParallel(Files, Registry, Options);
  finally
    Registry.Free;
  end;

  for I := 0 to High(Report.Results) do
    case Report.Results[I].Status of
      pbvisPass:  Writeln('PASS  ', Report.Results[I].FileName);
      pbvisFail:  Writeln('FAIL  ', Report.Results[I].FileName);
      pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
                    Report.Results[I].ErrorMessage);
    else
      Writeln('SKIP  ', Report.Results[I].FileName);   // pbvisCancelled
    end;
  Writeln(Report.PassedDocumentCount, ' passed, ',
    Report.FailedDocumentCount, ' failed, ',
    Report.ErrorDocumentCount, ' errors');
end;

การส่ง nil เป็น registry คือเส้นทางสั้นกว่า ValidatePdfFilesParallel จะสร้าง default registry เอง derive รายการ profile จาก Options.Standards แล้วคืนค่าไปพร้อมทำลาย registry ผลลัพธ์กลับมาเรียงตามลำดับ input เสมอ ไม่ว่า worker จะจบงานกันตามลำดับไหน เรื่องฟอร์แมตรายงานกับตัวห่อ command-line รอบเอนจินเดียวกัน ดูที่รายงาน preflight PDF แบบ batch ด้วย CLI ของ PDFium Component และว่าการเช็ก PDF/A คลุมอะไรบ้าง ดูที่การตรวจ PDF/A preflight ใน Delphi

RenderPagesParallel รันหน้าแบบขนานจริง ๆ ได้อย่างไร

TPdf.RenderPagesParallel ขนานได้จริง เพราะ worker ของมันไม่เคยแชร์โมดูล PDFium ด้วยกัน เมธอดเริ่มด้วยการเซฟเอกสารที่ Active อยู่ลงคลังต้นทางบน thread ผู้เรียก worker แต่ละตัวจากนั้นก๊อปปี้ DLL PDFium ที่โหลดไว้ไปเป็นไฟล์ที่มีชื่อไม่ซ้ำในไดเรกทอรี temp โหลดสำเนานั้นด้วย LoadLibrary แล้ว initialize มัน Windows ถือว่า DLL ที่โหลดจาก path ต่างกันเป็นโมดูลคนละตัว worker ทุกสำเนาจึงมี global ของตัวเองเต็มชุด: font cache ของตัวเอง page module ของตัวเอง ทุกอย่างของตัวเอง worker เปิดเอกสารที่เซฟไว้ในโมดูลส่วนตัวของมัน เรนเดอร์หน้าของมันแบบเหลื่อม ๆ โดยเช็กการยกเลิกระหว่างขั้น แล้วทำลายไลบรารี ยูโหลดสำเนา และลบไฟล์

แผนภาพ RenderPagesParallel ของ PDFium Component ที่ thread ผู้เรียกเซฟ snapshot เอกสาร แล้ว worker แต่ละตัวก๊อปปี้ DLL PDFium ไปไฟล์ temp ชื่อไม่ซ้ำ โหลดมันเป็นโมดูลแยกที่มี global ของตัวเอง เรนเดอร์หน้าของตัวโดยเช็กการยกเลิก แล้วยูโหลดสำเนาทิ้ง
ความขนานแท้มาจากการแยกโมดูล Windows ถือสำเนา DLL แต่ละตัวเป็นโมดูลคนละตัว worker จึงไม่แชร์อะไรกันเลยนอกจาก snapshot ที่ thread ผู้เรียกเซฟไว้ใต้ lock

การแยกตัวนี้ไม่ฟรี และค่าเริ่มต้นก็สะท้อนมัน worker แต่ละตัวจ่ายค่าสำเนา DLL บนดิสก์ ชุด global ของ PDFium ชุดที่สองในหน่วยความจำ และการ parse เอกสารใหม่ทั้งฉบับ MaxWorkers = 0 แปลว่าได้มากที่สุด 4 worker MaxPixelsPerPage กับ MaxTotalOutputBytes ปิดเพดาน output ดิบ และตัวเลือกเรนเดอร์แบบกลับสีกับ duotone ยามค่ำถูกปฏิเสธ เพราะ buffer ถูกคืนแบบดิบ ผลลัพธ์คือ TPdfParallelRenderReport ที่ array Results ถือ buffer 32 บิตมองจากบนลงล่างหนึ่งชุดต่อหน้าที่ขอ ในลำดับที่ขอ

procedure RenderAllPages(Pdf: TPdf);
var
  Options: TPdfParallelRenderOptions;
  Report: TPdfParallelRenderReport;
  Pages: array of Integer;
  I: Integer;
begin
  SetLength(Pages, Pdf.PageCount);
  for I := 0 to High(Pages) do
    Pages[I] := I + 1;                 // เลขหน้าเริ่มนับที่ 1

  Options := TPdfParallelRenderOptions.Default;
  Options.Dpi := 150;
  Options.MaxWorkers := 4;

  // snapshot ต้นทางถ่ายบน shared module จึงต้องถือ lock ระดับ
  // process ของ PDFium ถ้ามี thread อื่นใช้ TPdf ด้วย
  PdfiumLock.Acquire;
  try
    Report := Pdf.RenderPagesParallel(Pages, Options);
  finally
    PdfiumLock.Release;
  end;

  for I := 0 to High(Report.Results) do
    if Report.Results[I].Status = pprsSucceeded then
      SavePageBuffer(Report.Results[I])   // Width, Height, Stride, PixelFormat, Pixels
    else
      Writeln('Page ', Report.Results[I].PageNumber, ': ',
        Report.Results[I].ErrorMessage);
end;

สังเกต lock ที่ล้อมคำสั่ง call โมดูลของ worker เป็นของส่วนตัว แต่ขั้นถ่าย snapshot ตอนต้นรัน SaveAs บนโมดูลที่แชร์จาก thread ผู้เรียก ถ้าไม่มีอะไรอื่นใน process ของคุณแตะ TPdf พร้อมกัน คุณตัด lock ทิ้งได้ ถ้ามีบางอย่างทำอยู่ snapshot ก็ต้องได้รับการคุ้มครองแบบเดียวกับ call ที่แชร์โมดูลทุกตัว

รูปแบบปลอดภัยข้ามเอกสารงาน PDFium รันขนานต้นทุน
มี TPdf หนึ่งตัวต่อ thread ไม่มี lock ร่วมไม่ใช่ จนกว่าจะพังcrash เป็นครั้งคราว state ของ process เสียหาย
lock ระดับ process หนึ่งอันคลุม call PDFium ทั้งหมดใช่ไม่ส่วนของ PDFium เป็น serial
ValidatePdfFilesParallel ตั้งแต่ v3.125.1ใช่ไม่ การประเมินกฎเป็นแบบขนานparse กับ preflight เป็น serial
TPdf.RenderPagesParallelใช่ใช่สำเนา DLL หน่วยความจำ และ parse ใหม่หนึ่งรอบต่อ worker

ควรจัดโครงโค้ด PDFium แบบหลาย thread ของตัวเองอย่างไร

thread ของคุณควรแชร์ lock ระดับ process หนึ่งอันและถือมันตลอดชีวิตของ TPdf ทุกตัวที่ใช้ หรือไม่ก็ใช้ API ของ component ที่แยกโมดูลให้คุณแล้ว lock ต้องเป็นออบเจกต์เดียวของทั้ง process ไม่ใช่หนึ่งอันต่อ thread ต่อฟอร์มหรือต่อเอกสาร lock ที่สอง thread ไม่แชร์กันก็ป้องกันอะไรไม่ได้เลย แพทเทิร์นด้านล่างสะท้อนสิ่งที่ component ทำภายในตั้งแต่ v3.125.1: สร้าง โหลด อ่าน และ free ข้างใน lock แล้วทำทุกอย่างที่ไม่แตะ PDFium อยู่นอกมัน

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // lock เดียวสำหรับทั้ง process

type
  TTextExtractThread = class(TThread)
  private
    FFileName: string;
    FText: string;
  protected
    procedure Execute; override;
  public
    constructor Create(const AFileName: string);
    property ExtractedText: string read FText;
  end;

constructor TTextExtractThread.Create(const AFileName: string);
begin
  inherited Create(True);
  FFileName := AFileName;
end;

procedure TTextExtractThread.Execute;
var
  Pdf: TPdf;
  Page: Integer;
  Raw: TStringBuilder;
begin
  Raw := TStringBuilder.Create;
  try
    PdfiumLock.Acquire;
    try
      Pdf := TPdf.Create(nil);
      try
        Pdf.FileName := FFileName;
        Pdf.Active := True;
        if not Pdf.Active then
          raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
        for Page := 1 to Pdf.PageCount do
        begin
          Pdf.PageNumber := Page;
          Raw.AppendLine(Pdf.Text);
        end;
      finally
        Pdf.Free;                      // การปิดเอกสารก็เป็นงาน PDFium เหมือนกัน
      end;
    finally
      PdfiumLock.Release;
    end;
    // ใต้บรรทัดนี้ไม่มี PDFium แล้ว ส่วนนี้จึงรันขนานกันได้
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

initialization
  PdfiumLock := TCriticalSection.Create;
finalization
  PdfiumLock.Free;

กฎไม่กี่ข้อคอยให้แพทเทิร์นนี้ซื่อสัตย์อยู่ในแอปจริง:

  • วาง TPdf.Create กับ Free ใน lock ไม่ใช่แค่ call ที่เห็นชัด ๆ การโหลด การปิด การอ่าน property อย่าง PageCount การเปลี่ยนหน้า การสกัดข้อความ การเรนเดอร์ กับการเซฟ ล้วนยื่นมือเข้าไปในโมดูล
  • เช็ก Active หลัง assign ทันที การโหลดที่พังทิ้ง Active ไว้เป็น False และ LastLoadReport.ErrorMessage บอกเหตุผล
  • ถือ lock รายเอกสารแทนราย call การ lock ให้ละเอียดกว่านั้นเป็นไปได้ในทางทฤษฎี แต่เฉพาะเมื่อไม่มี member ไหนของ TPdf รันนอก lock เด็ดขาด และเวอร์ชันหยาบ ๆ นั่นแหละคือเวอร์ชันที่ตัว component เองพึ่งพา
  • เก็บงานช้าที่ไม่ใช่ PDFium อย่างการเขียนฐานข้อมูล การจัดดัชนี กับการ call เครือข่ายไว้นอก lock ไม่งั้น consumer ตัวเดียวที่ช้าจะทำให้ทุกอย่างวิ่งต่อกันหมด
  • อย่าถือ render lock ราย instance ตัว private ว่าเป็นตัวแทนของ lock นี้ มันเฝ้า TPdf หนึ่งตัวไม่ให้ขัดตัวเองเท่านั้น ไม่มีอะไรมากกว่านั้น

ความระวังแบบเดียวกันใช้กับโค้ดที่คุณไม่ได้เขียนเป็น thread ดิบ ๆ background future เป็นทางที่ดีในการพยุงการเรนเดอร์ยาว ๆ ไม่ให้ตกลงบน UI thread ตามที่เล่าในการเรนเดอร์ PDF เบื้องหลังด้วย future ที่ยกเลิกได้ แต่ตัว executor ของ future ไม่ได้เติม lock ระดับ global ของ PDFium ให้เอง ถ้ามี future หลายตัวอาจขับเคลื่อน TPdf หลาย instance พร้อมกัน ก็จงยึด lock ระดับ process ตัวเดิมข้างใน worker แต่ละตัว และถือ viewer บน main thread เป็น client ตัวหนึ่งของโมดูลที่แชร์กันด้วย การใช้ข้าม instance ผ่าน API แบบ asynchronous ยังไม่เคยถูก audit แยก สมมติฐานที่ระมัดระวังคือมันต้องการการทำให้วิ่งต่อกันแบบเดียวกับ thread ที่เขียนมือ เมื่อคุณต้องการความขนานแท้ของ PDFium กับงานอื่นที่ไม่ใช่การเรนเดอร์หน้า worker process แยกกันให้แต่ละงานมีโมดูลของตัวเองโดยการก่อสร้าง

สรุป: กฎการใช้ thread กับ PDFium ใน Delphi

  • state ที่ไม่ปลอดภัยของ PDFium อยู่ทั้งโมดูล: font cache, page module กับ global อื่นถูกแชร์โดยทุกเอกสารใน process
  • TPdf หนึ่งตัวต่อ thread ไม่ได้แยกอะไรเลย สอง instance บนสอง thread ยังพังกันได้
  • อาการทั่วไปคือความล้มเหลวในการโหลด access violation ในโค้ดถัดไป External exception C000001D และการจบด้วย 0xC0000409 หรือ 0xC0000374
  • ความเสียหายค้างอยู่ใน process call ที่พังมักไม่ใช่ call ที่ก่อมันขึ้น
  • ValidatePdfFilesParallel ปลอดภัยตั้งแต่ v3.125.1 บนเวอร์ชันเก่าใช้ WorkerCount := 1
  • TPdf.RenderPagesParallel ขนานได้จริง เพราะ worker แต่ละตัวโหลดสำเนาโมดูล PDFium แยกของตัวเอง
  • thread, task กับ future ของคุณเองต้องมี lock ระดับ process หนึ่งอันคลุม TPdf แต่ละตัวจาก Create ถึง Free

PDFium Component ห่อเอนจิน PDFium ไว้ให้ Delphi พร้อม preflight กับการตรวจแบบ batch, การเรนเดอร์ขนานแบบแยกโมดูล, งานเบื้องหลังที่ยกเลิกได้ และการวินิจฉัยการโหลดละเอียด รายละเอียดกับรุ่นอยู่ที่หน้าผลิตภัณฑ์ PDFium Component