การเรนเดอร์หน้า PDF แบบขนานจาก Delphi นั้นสรุปได้เป็นกฎข้อเดียวคือ: ให้เธรดทำงาน (worker thread) แต่ละเธรดมีตัวเรนเดอร์เป็นของตัวเอง losLab PDF Library มีเมธอด RenderPagesToFilesParallel สำหรับงานนี้โดยเฉพาะ โดยจะกระจายช่วงหน้ากระดาษไปยังพูล TTask pool โดยมีอินสแตนซ์ TPDFlib หนึ่งตัวต่อผู้ทำงานหนึ่งคน ช่วยให้เครื่องที่มีหลายคอร์สามารถแปลงงานแรสเตอร์แบบเป็นชุด (batch raster job) ให้มีทรูพุต (throughput) ที่เพิ่มขึ้นใกล้เคียงกับจำนวนคอร์ แต่หากใช้อินสแตนซ์เดียวร่วมกันในหลายเธรด การทำงานจะไม่เพียงแค่ช้าลงเท่านั้น แต่จะทำให้หน่วยความจำเสียหายและโปรแกรมแครชได้
นี่คือบทความที่คุณควรอ่านเมื่อมีงานประจำคืนที่ต้องแปลง PDF ขนาด 500 หน้าให้เป็นไฟล์ PNG 500 ไฟล์ ในขณะที่เครื่องคอมพิวเตอร์มีคอร์ว่างอยู่ 16 คอร์ แต่ความพยายามครั้งแรกของคุณในการเขียนโค้ดแบบมัลติเธรดกลับล้มเหลวภายใน GDI+ สรุปสั้นๆ คือ ความปลอดภัยของเธรดในกรณีนี้เป็นเรื่องของคุณสมบัติเชิงโครงสร้าง ไม่ใช่แค่แฟล็กที่คุณจะเปิดหรือปิดได้ และเนื้อหาต่อจากนี้จะพาไปเจาะลึกว่าเหตุใดโครงสร้างที่ปลอดภัยจึงเป็นเช่นนั้น และขีดจำกัดความเร็วที่แท้จริงอยู่ที่ตรงไหน
TPDFlib มีความปลอดภัยของเธรดสำหรับการเรนเดอร์แบบขนานหรือไม่?
ไม่ปลอดภัย และเหตุผลเป็นสิ่งที่คุณควรทำความเข้าใจก่อนจะนำไปออกแบบระบบ อินสแตนซ์ TPDFlib เพียงตัวเดียวถูกประกาศขึ้นมาสำหรับการใช้งานแบบเธรดเดียว และจุดที่อันตรายคือ TPDFPageTree.GetPage: ซึ่งจะเขียนข้อมูลลงในฟิลด์ FPagePointer ที่ใช้ร่วมกันบนอินสแตนซ์ซึ่งเป็นผลข้างเคียงจากการเลือกหน้า เธรดสองเธรดที่เรียกใช้งานอินสแตนซ์เดียวกันจะเกิดการแย่งชิงข้อมูล (race condition) ในฟิลด์นั้น ตัวอย่างเช่น ผู้ทำงาน A อาจกำลังเรนเดอร์หน้า 3 ไปได้ครึ่งทาง ในขณะที่ผู้ทำงาน B เปลี่ยนตำแหน่งของโครงสร้างต้นไม้หน้าเว็บไปชี้ที่หน้า 40 ไม่มีสิ่งใดใน API ที่จะห้ามคุณเขียนโค้ดในลักษณะด้านล่างนี้ และมันอาจจะทำงานได้ดีในช่วงสองสามหน้าแรกก่อนจะเกิดข้อผิดพลาด ซึ่งเป็นพฤติกรรมที่แย่ที่สุดของบั๊กประเภทนี้
// DO NOT do this: one shared instance, many threads
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// every thread reenters the same instance -> data race on FPagePointer
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
ความล้มเหลวนี้ไม่แน่นอน ซึ่งเป็นเหตุผลว่าทำไมมันถึงผ่านการทดสอบเบื้องต้นไปได้ แล้วไปพบบั๊กบนเครื่องลูกค้าที่มีจำนวนคอร์และความหนักหน่วงของเอกสารที่แตกต่างกัน นอกจากนี้ยังไม่มีการล็อกใดๆ ที่สามารถใส่รอบ RenderPageToFile เพื่อแก้ไขปัญหานี้ได้อย่างคุ้มค่า เพราะการล็อกผ่าน mutex สำหรับงานเรนเดอร์ทั้งหมดจะทำให้การประมวลผลกลายเป็นการรันแบบทีละตัวตามลำดับและสูญเสียประโยชน์จากการทำงานแบบขนานไป
เหตุใดผู้ทำงานเรนเดอร์แต่ละคนจึงต้องการอินสแตนซ์ TPDFlib ของตัวเอง?
เนื่องจากอินสแตนซ์ คือ หน่วยของการแยกส่วน (isolation) เมื่อผู้ทำงานแต่ละคนมี TPDFlib ส่วนตัวที่โหลดไฟล์แยกกัน แต่ละคนก็จะมีโครงสร้างต้นไม้หน้าเว็บ, ฟิลด์ FPagePointer และสถานะการเรนเดอร์ของตัวเอง ทำให้ไม่มีทรัพยากรใดที่ต้องแชร์และแย่งชิงกัน อย่างไรก็ตาม ความปลอดภัยนี้มีสิ่งที่ต้องแลกเปลี่ยนซึ่งคุณควรประเมินไว้ล่วงหน้า: ผู้ทำงานทุกคนจะวิเคราะห์ (parse) เอกสารทั้งหมดลงในหน่วยความจำ ดังนั้นการใช้หน่วยความจำสูงสุดจะอยู่ที่ประมาณ N เท่าของอินสแตนซ์เดียว ผู้ทำงานแปดคนบนไฟล์ PDF ขนาด 300 MB หมายถึงการวิเคราะห์เอกสารแปดครั้งพร้อมกันในหน่วยความจำ และเมื่อมีอินพุตขนาดใหญ่มาก ข้อจำกัดนี้จะเป็นตัวกำหนดจำนวนผู้ทำงานที่คุณใช้ ไม่ใช่ CPU เมื่อเอกสารมีขนาดใหญ่มากและมีข้อจำกัดเรื่องหน่วยความจำแทนที่จะเป็น CPU การใช้แนวทางเข้าถึงโดยตรงที่อธิบายใน processing large PDFs without full-document parsing มักจะเป็นตัวเลือกที่ดีกว่าการเพิ่มเธรดเรนเดอร์
API ที่เรียกใช้งานในครั้งเดียว: RenderPagesToFilesParallel
losLab PDF Library รวบรวมรูปแบบการทำงานที่ปลอดภัยทั้งหมดไว้ภายใต้เมธอดเดียว ดังนั้นในกรณีทั่วไปคุณจึงไม่จำเป็นต้องเขียนขึ้นเอง RenderPagesToFilesParallel จะรับค่าชื่อไฟล์และรหัสผ่าน, DPI, หน้าเริ่มต้นและหน้าสิ้นสุด, ค่า Options ที่ส่งผ่านไปยังพาธแรสเตอร์รายหน้า, รูปแบบเอาต์พุตที่ %p จะถูกแทนที่ด้วยหมายเลขหน้า และขีดจำกัดของผู้ทำงานซึ่งค่าที่น้อยกว่าหรือเท่ากับศูนย์จะหมายถึงการตั้งค่าอัตโนมัติ โดยเมธอดจะส่งกลับจำนวนหน้าที่เรนเดอร์สำเร็จ และพาธนี้รองรับเฉพาะระบบปฏิบัติการ Windows เท่านั้นเนื่องจากต้องใช้ CoInitialize และ GDI+
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> auto: min(page count, CPU cores)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
เหตุใดจึงต้องเรียกใช้ CoInitialize ในทุกเธรดทำงาน?
GDI+ คือตัวแปลงให้เป็นแรสเตอร์ (rasterizer) ที่อยู่เบื้องหลังการเรนเดอร์หน้ากระดาษ และ GDI+ เป็นแบบ apartment-threaded: มันต้องการให้ COM ได้รับการเตรียมความพร้อม (initialize) บนเธรดใดๆ ก็ตามที่เรียกใช้งาน เธรดหลักของแอป VCL มักจะมีการตั้งค่าส่วนนี้ไว้เรียบร้อยแล้ว แต่เธรดทำงาน TTask ที่สร้างขึ้นใหม่ไม่มี และการเรียกพาธเรนเดอร์จากเธรดที่ยังไม่ได้เตรียมความพร้อมเป็นสาเหตุที่ทำให้โปรแกรมแครชได้อย่างแน่นอน ดังนั้นผู้ทำงานแต่ละคนจึงต้องจับคู่ CoInitialize(nil) เมื่อเริ่มทำงานและ CoUninitialize เมื่อเสร็จงานเพื่อครอบคลุมตลอดอายุการทำงานของมัน นี่คือแนวปฏิบัติเดียวกับที่งาน GDI+ หรือ COM ใดๆ นอกเธรดหลักต้องการ และเป็นส่วนที่สองที่ช่วยให้อันสแตนซ์ส่วนตัวของผู้ทำงานแต่ละคนสามารถแยกส่วนการทำงานได้อย่างปลอดภัย ส่วนเอนจิน GDI+ แรสเตอร์ตัวเดียวกันนี้ยังขับเคลื่อนเอนจินเธรดเดียวที่กล่าวไว้ใน choosing a rendering engine for PDF output
การแบ่งส่วนข้อมูลแบบคงที่ (Static Sharding) เทียบกับการจองหน้าแบบไดนามิก (Dynamic Page Claiming)
วิธีปกติในการแบ่ง 500 หน้าให้กับผู้ทำงาน 8 คนคือ การส่งมอบส่วนแบ่งที่คงที่คนละประมาณ 62 หน้า แต่วิธีนี้ไม่ใช่สิ่งที่ losLab PDF Library ทำ เนื่องจากต้องการรักษาสมดุลของภาระงาน (load balance) ทรัพยากรที่ใช้ในแต่ละหน้ามีความแตกต่างกันมาก: หน้ากระดาษที่เป็นข้อความธรรมดาอาจใช้เวลาเรนเดอร์เพียงเสี้ยววินาที ในขณะที่หน้ากระดาษที่เป็นแผนที่เวกเตอร์หนาแน่นหรือรูปภาพสแกนแบบเต็มหน้าอาจใช้เวลามากกว่าถึงห้าสิบเท่า หากคุณหั่นงานเป็นส่วนคงที่ ผู้ทำงานที่ได้รับส่วนที่หนักจะต้องทำงานต่อไปอีกนานหลังจากคนอื่นว่างงานแล้ว ทำให้เวลาการทำงานโดยรวมของคุณถูกกำหนดโดยส่วนแบ่งที่โชคร้ายที่สุด ไม่ใช่ค่าเฉลี่ย ในทางตรงกันข้าม ผู้ทำงานแต่ละคนจะจองหน้าถัดไปจากตัวนับที่ใช้ร่วมกันภายใต้ส่วนวิกฤต (critical section) สั้นๆ เรนเดอร์หน้านั้น แล้วกลับมาจองหน้าใหม่ ซึ่งจะช่วยให้ทุกคอร์ทำงานอยู่ตลอดเวลาจนกว่างานทั้งหมดจะเสร็จสิ้น
// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+ is apartment-threaded
try
LocalLib := TPDFlib.Create; // one private instance per worker
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // claim the next page atomically
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
การทำบันทึกโครงสร้าง (Structured Logging) ข้ามเธรดทำงาน
การดีบั๊กงานแบบกลุ่มที่ล้มเหลวในหน้าที่ 213 จาก 500 หน้าเป็นเรื่องยากหากไม่มีไฟล์บันทึก (log) และไฟล์บันทึกที่เขียนไม่ดีก็อาจเป็นสาเหตุของบั๊กด้านความพร้อมกัน (concurrency bug) ได้เช่นกัน losLab PDF Library จึงมาพร้อมกับ TPDFlibLogger ซึ่งจะเชื่อมต่อผ่านคุณสมบัติ TPDFlib.Logger และมีค่าเริ่มต้นเป็น nil เพื่อไม่ให้เกิดค่าใช้จ่ายเมื่อไม่ได้ใช้งาน ระบบทำงานแบบเรียกกลับ (callback-first): คุณกำหนดเหตุการณ์ OnLog และส่งต่อบันทึกข้อมูลไปยังที่ใดก็ตามที่โฮสต์ต้องการ โดยกรองตามระดับ llDebug / llInfo / llWarn / llError และฟังก์ชัน PDFlibErrorMessage จะแปลงรหัสตัวเลขดิบให้เป็นข้อความที่เข้าใจง่าย เพื่อให้บันทึก Error อ่านรู้เรื่องมากกว่าเป็นตัวเลขธรรมดา สำหรับการบันทึกไฟล์ปลายทางที่เป็นตัวเลือกเสริมนั้นเป็นทรัพยากรเดียวที่ใช้ร่วมกัน ซึ่งระบบจะป้องกันด้วย TCriticalSection เพื่อให้ผู้ทำงานหลายคนสามารถเขียนต่อท้ายไฟล์บันทึกเดียวกันได้อย่างปลอดภัย โปรดทราบขีดจำกัดตามจริงว่า: เฉพาะการบันทึกไฟล์ปลายทางนั้นเท่านั้นที่ได้รับการซิงโครไนซ์ ดังนั้นหากคุณแชร์ตัวบันทึกหนึ่งตัวในพูลที่เขียนขึ้นเองและการทำงานของ OnLog เข้าไปยุ่งเกี่ยวกับ UI คุณยังคงต้องส่งข้อมูลนั้นกลับไปยังเธรดหลักด้วยตัวเอง
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // optional shared sink (lock-guarded)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// marshal to the UI thread yourself; OnLog fires on worker threads
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // nil by default; zero-cost when unset
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
finally
Pdf.Free;
Log.Free;
end;
end;
คุณควรคาดหวังความเร็วเพิ่มขึ้นเท่าใด?
โปรดประเมินตามความเป็นจริงว่าเวลาส่วนใหญ่ถูกใช้ไปกับส่วนใด เนื่องจากการเรนเดอร์แบบขนานจะช่วยลดเวลาได้ก็ต่อเมื่อภารกิจนั้นขึ้นอยู่กับประสิทธิภาพของ CPU จริงๆ การส่งออกข้อมูลความละเอียดสูง (high-DPI) และหน้ากระดาษที่มีเวกเตอร์หรือเฉดสีที่ซับซ้อนจะต้องใช้ทรัพยากรการคำนวณสูง และการประมวลผลประเภทนี้จะสามารถขยายขีดความสามารถได้ใกล้เคียงกับความสัมพันธ์เชิงเส้นกับจำนวนคอร์จนกระทั่ง CPU ทำงานเต็มขีดจำกัด แต่สำหรับหน้ากระดาษธรรมดาผลลัพธ์จะแตกต่างออกไป: เนื่องจากค่าใช้จ่ายของ LoadFromFile ในผู้ทำงานแต่ละราย ตลอดจนความเร็วในการเขียนไฟล์เอาต์พุตลงดิสก์ อาจใช้เวลามากกว่าตัวการเรนเดอร์เอง และผู้ทำงานแปดคนที่แย่งกันเข้าใช้งานดิสก์ที่ช้าอาจใช้เวลาทำงานเสร็จช้ากว่าการรันแบบเธรดเดียวเสียอีก ควรกำหนดค่า MaxWorkers ให้เท่ากับจำนวนคอร์จริงของเครื่อง ไม่ใช่จำนวนคอร์สมมติ หมั่นตรวจสอบหน่วยความจำเมื่อ PDF ต้นทางมีขนาดใหญ่ และหากพบว่าภาระงานแบบกลุ่มนั้นติดขัดที่การเขียนอ่านข้อมูล (IO-bound) แนวทางแก้ไขคือการใช้สื่อบันทึกข้อมูลที่เร็วขึ้นหรือลดจำนวนผู้ทำงานลง ไม่ใช่การเพิ่มเธรด เมื่อนำไปใช้กับงานที่ตรงวัตถุประสงค์แล้ว พาธการเรนเดอร์แบบกลุ่มที่แสดงในที่นี้ซึ่งเป็นส่วนหนึ่งของ losLab PDF Library สำหรับ Delphi และ C++Builder จะช่วยแปลงคอร์ที่ว่างอยู่ให้เป็นหน้าที่เรนเดอร์เสร็จสมบูรณ์โดยปราศจากปัญหาความปลอดภัยของเธรดที่คุณต้องแก้ไขด้วยตนเอง