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 ได้ในทุกช่วงเวลา ไม่ว่าจะเปิดเอกสารกี่ฉบับ
ความเสียหายข้ามเอกสารใน 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ที่ macroCHECKภายในกับ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 ของไฟล์ถัดไปได้
การเปลี่ยนเล็ก ๆ อีกสองเรื่องมาพร้อมการแก้ ความล้มเหลวในการโหลดตอนนี้ขึ้น 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 เปิดเอกสารที่เซฟไว้ในโมดูลส่วนตัวของมัน เรนเดอร์หน้าของมันแบบเหลื่อม ๆ โดยเช็กการยกเลิกระหว่างขั้น แล้วทำลายไลบรารี ยูโหลดสำเนา และลบไฟล์
การแยกตัวนี้ไม่ฟรี และค่าเริ่มต้นก็สะท้อนมัน 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 := 1TPdf.RenderPagesParallelขนานได้จริง เพราะ worker แต่ละตัวโหลดสำเนาโมดูล PDFium แยกของตัวเอง- thread, task กับ future ของคุณเองต้องมี lock ระดับ process หนึ่งอันคลุม
TPdfแต่ละตัวจากCreateถึงFree
PDFium Component ห่อเอนจิน PDFium ไว้ให้ Delphi พร้อม preflight กับการตรวจแบบ batch, การเรนเดอร์ขนานแบบแยกโมดูล, งานเบื้องหลังที่ยกเลิกได้ และการวินิจฉัยการโหลดละเอียด รายละเอียดกับรุ่นอยู่ที่หน้าผลิตภัณฑ์ PDFium Component