Bài viết kỹ thuật

PDFium Thread Safety: Khóa theo tài liệu vì sao thất bại

PDFium không thread-safe ở cấp module, nên hai instance TPdf làm việc trên hai tệp khác nhau trong hai thread vẫn có thể hỏng nhau. PDFium Component cho Delphi xử lý việc này theo hai đường: kể từ v3.125.1, ValidatePdfFilesParallel xếp tuần tự mọi lời gọi PDFium native đằng sau một khóa toàn tiến trình, trong khi TPdf.RenderPagesParallel trao cho mỗi worker một bản sao module PDFium cô lập riêng. Bug buộc ra bản sửa là dạng ngắt quãng tệ nhất. Một test batch validation qua phần lớn thời gian, rồi report một trong hai tệp tốt là fail, rồi crash test kế tiếp trong cùng tiến trình bằng một access violation, và có khi kéo cả runner sụp theo với một exit code thay vì stack trace. Test chẳng sai gì, mỗi tài liệu chẳng sai gì. Sai là giả định: một TPdf mỗi thread không phải cô lập

Vì sao một TPdf mỗi thread chưa đủ?

Một TPdf mỗi thread chưa đủ vì PDFium giữ trạng thái không an toàn của nó trong module, không phải trong tài liệu. Mỗi TPdf sở hữu FPDF_DOCUMENT handle riêng, nhưng mọi handle trong tiến trình đều được cùng một DLL đã nạp phục vụ, và DLL đó giữ các singleton toàn tiến trình: font cache, page module, và các cấu trúc toàn cục khác mà việc nạp, parse, render tài liệu đều chạm tới. Hai thread nạp hai tệp chẳng liên quan là hai thread ghi vào cùng một font cache cùng lúc. Phía Delphi chẳng ai sở hữu dữ liệu đó, nên chẳng gì phía Delphi khóa nó theo từng tài liệu nổi

Component có khóa đấy, và dễ rút ra một kết luận sai từ nó. TPdf bọc các đường render riêng của nó trong một critical section nội bộ (EnterRenderLock / LeaveRenderLock, private method của TPdf). Khóa đó là theo từng instance. Nó cản hai thread cùng lái một TPdf ngay lúc này, một mối nguy có thật, nhưng nó không nhìn thấy một instance thứ hai trên thread khác, nên concurrency chéo instance đi thẳng qua nó. Quy luật chung đơn giản đến mức gói trong một dòng: trong một module PDFium đã nạp, tối đa một thread được đứng bên trong PDFium tại bất kỳ khoảnh khắc nào, bất kể đang mở bao nhiêu tài liệu

Sơ đồ PDFium Component về hai thread chạy các instance TPdf riêng trên các tài liệu khác nhau trong khi mọi lời gọi hội tụ về một module pdfium.dll đã nạp với font cache, page module và các biến toàn cục toàn tiến trình dùng chung, cho ra các lần nạp thất bại, access violation và thoát fail-fast
PDFium giữ trạng thái không an toàn trong module, không phải trong tài liệu, nên hai instance TPdf trên hai thread ghi vào cùng một font cache bất kể các tệp không liên quan thế nào

Hỏng chéo tài liệu trông ra sao trong một tiến trình Delphi?

Hỏng chéo tài liệu trông như một hỗn hợp ngẫu nhiên của các thất bại không liên quan, và thiệt hại sống lâu hơn code gây ra nó. Trước v3.125.1, ValidatePdfFilesParallel tạo một TPdf mỗi worker thread rồi chạy Active := True cùng phần dựng preflight report đồng thời trên module dùng chung. Các triệu chứng thấy trên cả bản dựng Delphi lẫn Free Pascal phủ kín phổ:

  • Một tệp hợp lệ không nạp nổi, hay quay về từ batch với kết quả fail khi lẽ ra phải pass
  • Một access violation lộ ra ở một lời gọi sau, không liên quan, thường trong một test khác hay một tài liệu khác
  • External exception C000001D xuất hiện trong Delphi. Mã đó là STATUS_ILLEGAL_INSTRUCTION, nêu bởi lệnh ud2 mà các macro CHECK và IMMEDIATE_CRASH nội bộ của PDFium thực thi khi một bất biến gãy
  • Tiến trình thoát với 0xC0000409 (fail-fast, được report là stack buffer overrun) hay 0xC0000374 (heap corruption), chẳng có exception Delphi nào cả

Hai ý cuối là lý do bug khó ghim đến thế. Phần validation song song kết thúc, trạng thái toàn cục đã hỏng ở lại, và fixture kế tiếp trong cùng tiến trình vấp phải nó. Trong một lần chạy regression Delphi Win64, một đợt thất bại C000001D trúng các test chưa từng đụng batch validation; chúng đơn giản là đoạn code đầu tiên dùng PDFium sau thiệt hại. Các con số đo được làm rõ quy mô. Một probe Delphi chạy cùng mẫu qua hai worker fail 122 trên 160 tài liệu trong một lần chạy và 138 trên 160 trong lần khác, và một trong các lần chạy ấy nêu thẳng External exception C000001D. Một stress case 8 tài liệu, 4 worker và 5 vòng fail hay crash 5 trên 5 lần chạy trên Free Pascal Win64. Sau bản sửa, cùng probe đó fail 0 trên 1,200 tài liệu

ValidatePdfFilesParallel giữ an toàn kể từ v3.125.1 ra sao

ValidatePdfFilesParallel giờ xếp tuần tự nửa native của mỗi job và giữ nửa managed song song. Mỗi worker chiếm một critical section cấp unit trước khi tạo TPdf của mình, và giữ nó xuyên suốt FileName, Active := True, phần dựng preflight report, và Free. Việc tạo và hủy nằm trong khóa là có chủ đích: đóng một tài liệu cũng gọi ngược vào module y như nạp. Khi worker đã cầm một record TPdfPreflightReport thu được, nó nhả khóa và đánh giá các luật validation trên record đó, thứ không chạm tới trạng thái PDFium nào, nên phần đánh giá luật của một tệp chồng lên phần việc PDFium của tệp kế

Sơ đồ ValidatePdfFilesParallel của PDFium Component cho thấy mỗi worker giữ một critical section toàn tiến trình xuyên qua create, load, preflight và free của TPdf trong khi phần đánh giá luật trên report đã chụp chạy ngoài khóa một cách song song, nên nửa PDFium của batch là tuần tự theo thiết kế
Việc tạo và hủy ở lại trong khóa vì đóng một tài liệu gọi ngược vào module, còn phần đánh giá report không chạm trạng thái PDFium nào và chồng lên tệp kế tiếp

Hai thay đổi nhỏ đi kèm bản sửa. Một lần nạp thất bại giờ nêu EPdfError với LastLoadReport.ErrorMessage, nên ErrorMessage của item nêu đúng vấn đề parse thật thay vì một lỗi thứ cấp "no active document". Và cái giá được nói thẳng: phần PDFium của batch giờ là tuần tự, nên với một batch mà parse và preflight chiếm đa số, thêm worker chẳng mua được gì. Nếu bạn đang trên một phiên bản trước v3.125.1, hãy đặt WorkerCount thành 1; cái đó bỏ concurrency và bỏ luôn hỏng hóc

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 = số processor, chặn ở 8
    Options.Standards := [ppsPdfA];
    // Với một registry tường minh, tự chọn profile khớp.
    // Một danh sách Profiles rỗng chạy mọi luật đã đăng ký, còn luật cho
    // các standard bạn chưa preflight report "did not pass"
    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;

Đưa nil làm registry là con đường ngắn hơn: ValidatePdfFilesParallel khi đó tự tạo registry mặc định, suy danh sách profile từ Options.Standards, và giải phóng registry khi trả về. Kết quả luôn quay về theo thứ tự input, bất kể thứ tự worker kết thúc. Về các định dạng report và wrapper dòng lệnh quanh cùng engine đó, xem batch PDF preflight report với CLI của PDFium Component, còn các phép kiểm PDF/A tự nó phủ những gì, xem PDF/A preflight validation trong Delphi

RenderPagesParallel chạy các trang song song thật sự thế nào?

TPdf.RenderPagesParallel chạy song song thật sự vì các worker của nó chẳng bao giờ dùng chung một module PDFium. Method trước hết lưu tài liệu active vào một nguồn lưu trữ trên thread gọi. Mỗi worker sau đó chép DLL PDFium đã nạp thành một tệp có tên duy nhất trong thư mục temp, nạp bản sao đó bằng LoadLibrary, và khởi tạo nó. Windows coi một DLL nạp từ một đường dẫn khác là một module khác, nên mỗi bản sao có riêng các biến toàn cục của nó: riêng font cache, riêng page module, riêng mọi thứ. Worker mở tài liệu đã lưu trong module riêng tư của nó, render các trang của nó từng bước với các phép kiểm hủy giữa các bước, rồi hủy thư viện, dỡ bản sao và xóa tệp

Sơ đồ RenderPagesParallel của PDFium Component nơi thread gọi chụp một snapshot tài liệu, rồi mỗi worker chép DLL PDFium thành một tệp temp duy nhất, nạp nó như một module riêng với các biến toàn cục riêng, render các trang của nó với các phép kiểm hủy và dỡ bản sao
Song song thật sự đến từ cô lập module: Windows coi mỗi bản sao DLL là một module khác, nên các worker chẳng chia sẻ gì ngoài snapshot mà thread gọi đã lưu dưới khóa

Sự cô lập không miễn phí, và các mặc định phản ánh điều đó. Mỗi worker trả giá cho một bản sao DLL trên đĩa, một bộ PDFium globals thứ hai trong bộ nhớ, và một lượt parse tài liệu mới tinh. MaxWorkers = 0 nghĩa là tối đa 4 worker, MaxPixelsPerPage và MaxTotalOutputBytes chặn đầu ra thô, còn các tùy chọn render âm bản và duotone-ban-đêm bị từ chối vì các buffer được trả về dạng thô. Kết quả là một TPdfParallelRenderReport mà mảng Results giữ một buffer 32-bit top-down cho mỗi trang được đòi, theo thứ tự yêu cầu

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;                 // số trang tính từ 1

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

  // Snapshot nguồn được chụp trên module dùng chung, nên giữ
  // khóa PDFium toàn tiến trình nếu thread khác cũng dùng 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;

Chú ý cái khóa bao quanh lời gọi. Các module worker là riêng tư, nhưng bước snapshot ở đầu chạy SaveAs trên module dùng chung từ thread gọi. Nếu chẳng có gì khác trong tiến trình của bạn đụng TPdf đồng thời, có thể bỏ khóa; nếu có gì đó, snapshot cần cùng sự bảo hộ như mọi lời gọi dùng chung module khác

Mô hìnhAn toàn chéo tài liệuViệc PDFium chạy song songCái giá
Một TPdf mỗi thread, không khóa dùng chungKhôngCó, cho tới khi hỏngCrash ngắt quãng, trạng thái tiến trình tổn hại
Một khóa toàn tiến trình quanh mọi lời gọi PDFiumCóKhôngPhần PDFium là tuần tự
ValidatePdfFilesParallel kể từ v3.125.1CóKhông; phần đánh giá luật là song songParse và preflight là tuần tự
TPdf.RenderPagesParallelCóCóBản sao DLL, bộ nhớ và một lượt parse mới mỗi worker

Nên cấu trúc code PDFium đa thread của riêng bạn thế nào?

Các thread của riêng bạn nên dùng chung một khóa toàn tiến trình và giữ nó suốt vòng đời của mọi TPdf chúng dùng, hoặc dùng một API component đã cô lập module giùm bạn. Khóa phải là một object duy nhất cho cả tiến trình, không phải một cái mỗi thread, mỗi form hay mỗi tài liệu; một khóa mà hai thread không dùng chung chẳng bảo vệ gì. Mẫu dưới đây phản chiếu điều component tự làm nội bộ kể từ v3.125.1: tạo, nạp, đọc và giải phóng bên trong khóa, rồi làm mọi thứ không chạm PDFium bên ngoài nó

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

var
  PdfiumLock: TCriticalSection;        // một khóa cho cả tiến trình

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;                      // đóng tài liệu cũng là việc của PDFium
      end;
    finally
      PdfiumLock.Release;
    end;
    // Không còn PDFium nào dưới dòng này, nên phần này chạy song song
    FText := Raw.ToString.Trim;
  finally
    Raw.Free;
  end;
end;

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

Vài luật giữ cho mẫu này còn trung thực trong một ứng dụng thật:

  • Đặt TPdf.Create và Free bên trong khóa, đừng chỉ bọc những lời gọi dễ thấy. Nạp, đóng, các lần đọc property như PageCount, đổi trang, trích text, render và save đều với vào module
  • Kiểm tra Active sau khi gán. Một lần nạp thất bại để Active ở False, và LastLoadReport.ErrorMessage nói vì sao
  • Giữ khóa theo từng tài liệu thay vì từng lời gọi. Khóa mịn hơn là khả thi về nguyên tắc, nhưng chỉ khi không một member TPdf nào chạy ngoài nó, còn bản thô chính là bản component tự nó dựa vào
  • Giữ việc chậm không phải PDFium, như ghi database, đánh chỉ mục và gọi mạng, bên ngoài khóa, nếu không một consumer chậm nào sẽ xếp tuần tự mọi thứ
  • Đừng coi khóa render theo từng instance riêng tư là một sự thay thế. Nó canh một TPdf trước chính nó và chẳng hơn thế

Sự thận trọng đó áp dụng cho cả code bạn không tự viết thành thread thô. Background future là một cách tốt để giữ các render dài khỏi UI thread, như đã nói trong render PDF nền với các future có thể hủy, nhưng trình thực thi future không tự thêm một khóa PDFium toàn cục nào. Nếu vài future có thể lái các instance TPdf khác nhau cùng lúc, hãy chiếm cùng khóa toàn tiến trình bên trong mỗi worker, và coi một viewer trên main thread như một client nữa của module dùng chung. Dùng chéo instance qua các API bất đồng bộ chưa được audit riêng, nên giả định bảo thủ là nó cần cùng sự xếp tuần tự như thread viết tay. Khi bạn cần song song PDFium thật sự cho một việc khác render trang, các worker process riêng biệt trao mỗi job một module riêng ngay từ cấu trúc

Tra nhanh: luật threading PDFium cho Delphi

  • Trạng thái không an toàn của PDFium là toàn module: font cache, page module và các biến toàn cục khác được mọi tài liệu trong tiến trình dùng chung
  • Một TPdf mỗi thread chẳng cô lập được gì; hai instance trên hai thread vẫn có thể hỏng nhau
  • Các triệu chứng điển hình là nạp thất bại, access violation ở code sau, External exception C000001D, và thoát với 0xC0000409 hay 0xC0000374
  • Hỏng hóc dai dẳng trong tiến trình, nên lời gọi lộ thất bại thường không phải cái gây ra nó
  • ValidatePdfFilesParallel an toàn kể từ v3.125.1; trên các phiên bản cũ dùng WorkerCount := 1
  • TPdf.RenderPagesParallel song song thật sự vì mỗi worker nạp một bản sao module PDFium cô lập
  • Các thread, task và future của riêng bạn cần một khóa toàn tiến trình phủ mỗi TPdf từ Create tới Free

PDFium Component bọc engine PDFium cho Delphi với batch preflight và validation, render song song cô lập, việc nền có thể hủy và chẩn đoán nạp chi tiết. Chi tiết và các edition nằm trên trang sản phẩm PDFium Component