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
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 C000001Dxuất hiện trong Delphi. Mã đó làSTATUS_ILLEGAL_INSTRUCTION, nêu bởi lệnhud2mà các macroCHECKvàIMMEDIATE_CRASHnộ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) hay0xC0000374(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ế
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ự 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ình | An toàn chéo tài liệu | Việc PDFium chạy song song | Cái giá |
|---|---|---|---|
Một TPdf mỗi thread, không khóa dùng chung | Không | Có, cho tới khi hỏng | Crash 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 PDFium | Có | Không | Phần PDFium là tuần tự |
ValidatePdfFilesParallel kể từ v3.125.1 | Có | Không; phần đánh giá luật là song song | Parse và preflight là tuần tự |
TPdf.RenderPagesParallel | Có | 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.CreatevàFreebê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
Activesau khi gán. Một lần nạp thất bại đểActiveởFalse, vàLastLoadReport.ErrorMessagenó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
TPdfnà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
TPdftrướ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
TPdfmỗ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ới0xC0000409hay0xC0000374 - 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ó
ValidatePdfFilesParallelan toàn kể từ v3.125.1; trên các phiên bản cũ dùngWorkerCount := 1TPdf.RenderPagesParallelsong 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
TPdftừCreatetớiFree
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