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

WaitForIdle Deadlock ในการ Render Async ของ Delphi PDFium

Batch render ค้างอยู่กลางทาง เพราะ executor ใน PDFium Component ไม่ถือว่า task หนึ่งเสร็จสิ้นจนกว่าการตอบกลับของมันจะถูก dispatch แล้ว ภายใต้ padSynchronize การตอบกลับนั้นรันบน main thread ถ้า main thread block อยู่โดยไม่ pump CheckSynchronize worker ก็จะรอ main thread ในขณะที่ main thread รอ idle

ภาพจาก debugger ชัดเจนไม่มีข้อสงสัยเมื่อคุณเคยเห็นมันมาแล้ว หยุด process ที่ค้างไว้แล้ว main thread จะอยู่ใน wait บน idle event หลาย frame ลึกลงไปจาก batch loop ของคุณเอง สลับไปดู worker thread ใดก็ตามแล้วมันจะอยู่ใน TThread.Synchronize ถือผลลัพธ์ที่เสร็จแล้วแต่ส่งมอบไม่ได้ ไม่มีอะไรกำลังหมุน ไม่มี CPU ถูกเผา process แค่จอดนิ่งอยู่เฉย ๆ บทความนี้ว่าด้วยเหตุผลว่าทำไม state นั้นถึงมีอยู่ตั้งแต่แรก และว่าด้วยกฎข้างเคียงสามข้อที่ตัดสินว่า worker pool ของ Delphi ที่วางอยู่บน PDFium จะทำงานดีหรือกัดคุณ: QueueCapacity จำกัดอะไรจริง ๆ การปิดระบบต้องยกเลิกตามลำดับไหน และ parallelism ไม่ได้ซื้ออะไรให้คุณตรงจุดไหนเมื่อพูดถึงความเป็นเจ้าของอ็อบเจกต์ของ PDFium

ทำไม WaitForIdle ถึงทำให้ main thread ค้าง

มันค้างเพราะ idle ใน TPdfAsyncExecutor ถูกนิยามให้รวมการ dispatch การตอบกลับด้วย ไม่ใช่แค่การทำงานของ worker เสร็จสิ้น ตัวนับการทำงานถูกเพิ่มขึ้นใน DequeueTask เมื่อ worker หยิบ task ขึ้นมา และถูกลดลงใน TaskFinished ซึ่ง worker เรียกก็ต่อเมื่อ TPdfAsyncTaskOperation.Execute คืนค่ากลับมาแล้วเท่านั้น method นั้นรัน worker body บันทึกผลลัพธ์ แล้วจึง dispatch การตอบกลับตาม TPdfAsyncDispatchMode ด้วย padSynchronize การ dispatch เป็นการเรียก TThread.Synchronize ดังนั้น Execute จึงไม่คืนค่าจนกว่า main thread จะรันมันแล้ว

Delphi วางครึ่งหลังของสัญญานั้นไว้ที่ตัวคุณ TThread.Synchronize ต่อ method เข้าไปในคิวส่วนกลางและ block thread ที่เรียกบน event หนึ่ง บางอย่างบน main thread ต้องเรียก CheckSynchronize ก่อนที่ event นั้นจะถูก signal เลย VCL message loop ทำสิ่งนี้ให้คุณระหว่าง message ต่าง ๆ ซึ่งเป็นเหตุผลพอดีที่บั๊กนี้มองไม่เห็นระหว่างการใช้งานแบบ interactive และปรากฏขึ้นทันทีที่คุณเขียน batch loop แบบ blocking main thread ที่ block คือ main thread ที่ออกจาก message loop ไปแล้ว และ main thread ที่อยู่นอก message loop ก็ไม่ได้ระบายอะไรให้ใครเลย

แผนภาพ deadlock ของ WaitForIdle ใน worker pool ของ PDFium บน Delphi ที่เธรดหลักรอ idle ขณะที่ worker ติดค้างใน TThread.Synchronize
แต่ละเธรดรอเงื่อนไขที่มีเพียงอีกฝ่ายปล่อยได้ ชุดงานทั้งหมดจึงจอดนิ่งด้วยต้นทุน CPU เป็นศูนย์
uses
  System.Classes, FPdfAsync, PDFium;

// รูปแบบที่ deadlock: การตอบกลับแบบ synchronized บวกกับเธรดหลักที่บล็อก
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // ตอนนี้เธรดหลักพักอยู่ในการรอของเคอร์เนล

// ในขณะเดียวกัน TPdfAsyncTaskOperation.Execute ไปถึง:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// ซึ่งเข้ารหัส DispatchReply ลงคิวและรอให้เธรดหลักระบายมัน
// เธรดหลักไม่ได้ระบายอะไรเลย ดังนั้นทั้งสองด้านจึงรอตลอดไป

การเสร็จสิ้นของ task กับ idle ของ executor เป็นสองจุดหมายที่ต่างกัน

ทั้งสองถูกแยกออกจากกันโดยตั้งใจ และการรู้ว่าคุณกำลังรออันไหนอยู่คือทางแก้ทั้งหมด IPdfAsyncTask.WaitFor พอใจในทันทีที่ผลลัพธ์ของ worker ถูกตัดสิน: Complete เขียน TPdfAsyncTaskState สุดท้ายและตั้ง done event ก่อนที่การตอบกลับใดจะถูกพิจารณาเลย TPdfAsyncExecutor.WaitForIdle พอใจทีหลัง เมื่อทั้งจำนวนที่คิวไว้และจำนวนที่กำลังรันเป็นศูนย์ทั้งคู่ และจำนวนที่กำลังรันจะไม่ลดลงจนกว่าการตอบกลับจะมาถึง ดังนั้น task หนึ่งอาจเป็น patsSucceeded และสังเกตได้ผ่าน Snapshot ในขณะที่ executor ยังคงยุ่งอยู่จริง ๆ

// TPdfAsyncExecutor.WaitForIdle pump ให้คุณแล้ว: มันรอที่ idle
// event ในสไลซ์สั้น ๆ และเรียก CheckSynchronize(0) ระหว่างนั้น.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// การรอบนเธรดหลักที่เขียนเองใด ๆ ต้องทำแบบเดียวกันอย่างชัดเจน.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // ปล่อยการตอบกลับ padSynchronize ที่ค้างอยู่
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

มีผลที่ตามมาหนึ่งอย่างที่ควรจดจำไว้: การตอบกลับที่ raise ไม่ได้เขียนประวัติศาสตร์ใหม่ DispatchReply จับ exception และเก็บมันไว้ใน ReplyErrorMessage ปล่อยให้ State, CancellationReason และ ErrorMessage เป็นตามที่ worker กำหนดไว้ทุกประการ callback ของ UI ที่ระเบิดตอนวาด thumbnail จึงไม่มีวันเปลี่ยนการ render ที่สำเร็จให้กลายเป็นล้มเหลว และ telemetry ของคุณก็ยังคงรายงานสิ่งที่ render engine ทำจริง ๆ ต่อไป ถ้าคุณต้องการ API รูปแบบ callback รอบ operation เดียวแทนที่จะเป็น pool การ render ในพื้นหลังด้วย future ที่ยกเลิกได้ ครอบคลุม path นั้นไว้

QueueCapacity จำกัด worker ที่กำลังรันด้วยหรือไม่

ไม่ QueueCapacity ใน PDFium Component นับแค่ task ที่อยู่ในคิวเท่านั้น ไม่เคยนับตัวที่กำลังรันอยู่บน worker แล้ว นี่จงใจให้เป็นแบบนั้น: ความจุมีไว้เพื่อแสดง backpressure จริงบนแถวรอ และการยุบ slot concurrency ที่ตายตัวเข้าไปในตัวเลขเดียวกันจะเป็นการนับซ้ำสอง ด้วย worker สี่ตัวและความจุแปด คุณสามารถมีสิบสอง task กำลังทำงานอยู่ได้ และ GetStats รายงานการแบ่งนั้นอย่างซื่อตรงผ่าน QueuedCount กับ RunningCount

แผนภาพแสดง QueueCapacity กำจำงาน PDFium ที่ต่อคิวแปดงานไว้ในแถวรอ ขณะที่ worker ของ Delphi สี่ตัววิ่งเป็นงบหนึ่งที่แยกขาด
Capacity วัดเฉพาะแถวคอย และ GetStats รายงานจำนวนที่รอคิวกับที่ทำงานอยู่แยกกัน
var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit ไม่เคย raise: มันคืน False เมื่อแถวรอเต็มหรือ
  // executor กำลังปิดเครื่องอยู่แล้ว และเพิ่ม RejectedCount
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount คือสิ่งที่ QueueCapacity จำกัด. RunningCount ถูกจำกัดโดย
    // WorkerCount และไม่ถูกหักจากความจุ
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

สี่ lane ของ TPdfAsyncPriority เข้มงวด ไม่ใช่แบบถ่วงน้ำหนัก DequeueTask ไล่จาก papCritical ลงมาถึง papLow และเอา lane แรกที่ไม่ว่างเปล่า โดยคง FIFO order ไว้ภายในแต่ละ lane นั่นทำให้คำขอแบบ interactive มีทางแซงหน้า batch ที่ยังไม่เริ่มได้อย่างสะอาด แต่มันไม่เคยขัดจังหวะงานที่กำลังรันอยู่แล้ว และผู้เรียกที่ป้อน papCritical ตลอดเวลาสามารถทำให้ papLow อดอาหารไปเรื่อย ๆ ได้ สงวน lane บนสุดสองอันไว้สำหรับสิ่งที่มนุษย์กำลังรอเห็นอยู่จริง และปล่อยให้ export ปริมาณมากอยู่บน papNormal หรือต่ำกว่า ใช้ Submit เมื่อคิวเต็มเป็น programming error ที่คุ้มค่าจะได้ EPdfAsyncQueueFull และใช้ TrySubmit เมื่อมันเป็นสภาวะปกติที่คุณตั้งใจจะจัดการเอง

ทำไม Shutdown ถึงยกเลิกนอก executor lock

เพราะการยกเลิกภายในมันจะพลิกลำดับของ lock และทำให้การปิดระบบที่คุณกำลังพยายามทำค้างไปเลย Shutdown(True) ยึด executor lock พลิก shutdown flag แล้วต่อ task ที่ค้างอยู่ทุกตัวเข้า snapshot array ท้องถิ่นผ่าน AppendSnapshot จากนั้นมันปล่อย lock และหลังจากนั้นเท่านั้นจึงไล่ผ่าน snapshot เรียก Cancel บนแต่ละ entry การยกเลิก task หนึ่งจุดชนวน callback ของผู้ใช้ที่ลงทะเบียนไว้บน token source ของมัน และ callback เหล่านั้นเป็นโค้ดแอปพลิเคชันธรรมดา: มันอาจ query GetStats ส่ง compensating work หรือรอ idle ทุกอันเหล่านั้นเข้า executor lock อีกครั้ง และ callback ที่ถูกเรียกในขณะที่ lock นั้นถูกยึดอยู่จะ deadlock กับตัวมันเอง

Token source ปฏิบัติตามวินัยเดียวกันนี้อีกระดับหนึ่งลงไป CancelWithReason ยึด source lock ตัดสินผู้ยกเลิกที่ชนะเพียงคนเดียว เขียน Reason, CancellationMessage และ CancelledAtTick แล้วจึงพลิก cancelled flag แบบ atomic หลังจากนั้นเท่านั้น การเผยแพร่ก่อนพลิกคือสิ่งที่ทำให้ metadata อ่านได้อย่างปลอดภัย: thread ใดก็ตามที่สังเกตเห็น IsCancelled เป็น True รับประกันได้ว่าจะพบเหตุผลที่สมบูรณ์อยู่เบื้องหลังมัน และผู้เรียกทีหลังแพ้การแข่งขัน คืนค่า False และเขียนทับเหตุผลแรกไม่ได้ callback ที่ลงทะเบียนไว้ถูก snapshot และล้างภายใน lock แต่ถูกเรียกนอกมัน แต่ละอันถูกห่อไว้เพื่อไม่ให้ handler ที่ล้มเหลวตัวหนึ่งไประงับตัวอื่น task ที่กำลังรันอยู่แล้วไม่เคยถูกฆ่าเลย มันจบลงแบบร่วมมือกันเมื่อ worker body ของมันเรียก ThrowIfCancelled ครั้งถัดไป ซึ่งเป็นเหตุผลที่ Shutdown จบลงด้วยการ pump WaitForIdle ก่อนที่จะ join thread

Worker แบบขนานผ่อนปรนความเป็นเจ้าของอ็อบเจกต์ของ PDFium หรือไม่

ไม่เลย และนี่คือขอบเขตที่ถูกเข้าใจผิดได้ง่ายที่สุด TPdfAsyncExecutor จัดตารางงาน มันไม่ได้อ้างสิทธิ์อะไรเกี่ยวกับ thread affinity ของสิ่งใดก็ตามที่คุณแตะต้องภายในงานนั้น instance TPdf ที่มีชีวิตอยู่ไม่ได้กลายเป็นเข้าถึงได้พร้อมกันเพียงเพราะ worker สองตัวบังเอิญเรียกเข้าไปในมัน และ internal render lock เป็นการป้องกันการเรียก render ที่ทับซ้อนกัน ไม่ใช่ใบอนุญาตให้แชร์เอกสารข้าม thread การ render หรือ export แบบขนานหมายถึง TPdf หนึ่งตัวต่อ worker หนึ่งตัว สร้างและทำลายภายในงานนั้นเอง

แผนภาพ worker PDFium ของ Delphi ที่แต่ละตัวสร้างและคืน instance TPdf ส่วนตัวของตัวเอง แทนการใช้เอกสารที่มีชีวิตตัวเดียวร่วมกันข้ามเธรด
การแยกตัวมีค่าใช้จ่ายเป็นการแยกวิเคราะห์และแคชหน้าของตัวเองต่อ worker แต่การพังหรือยกเลิกจะไม่มีวันทำลายเอกสารที่ใช้ร่วมกัน
type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // หนึ่งอินสแตนซ์เอกสารต่อ worker, ไม่เคยแชร์
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ความเป็นเจ้าของย้ายไปยังขั้นตอนการตอบกลับ
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

ต้นทุนนี้เป็นจริงและควรค่าแก่การเอ่ยถึง: worker แต่ละตัวจ่ายค่า parse ของตัวเองและ page cache ของตัวเอง ดังนั้นหน่วยความจำจึงขยายตามจำนวน worker มากกว่าตามจำนวนเอกสาร นั่นคือราคาของโมเดลที่ worker หนึ่งสามารถถูกยกเลิกหรือ crash ได้โดยไม่ทำให้คนอื่นเสียหาย ถ้า worker ของคุณแชร์ document instance ฝั่ง viewer แทน กฎการ lock รอบเรื่องนั้นครอบคลุมไว้ใน render lock กับการเรียกที่พลาดมัน และ path แบบเอกสารเดียวที่ยกเลิกได้อยู่ใน การ render แบบก้าวหน้าที่ยกเลิกได้

การขยาย published interface โดยไม่ทำ vtable พัง

IPdfCancellationToken กับ IPdfCancellationTokenSource เป็น interface สไตล์ COM ที่ไบนารีภายนอกอาจใช้อยู่แล้ว ดังนั้นการต่อ method เข้าไปในตัวใดตัวหนึ่งจะเลื่อน slot ทุกตัวที่ตามมาใน vtable และทำให้การเรียกที่ compile ไว้กับ layout เดิมเดินทางผิดเส้นอย่างเงียบ ๆ ความสามารถด้านการวินิจฉัย การรอ callback ที่ถอดออกได้ และการยกเลิกแบบ atomic จึงอยู่ใน IPdfCancellationTokenEx กับ IPdfCancellationTokenSourceEx ซึ่งสืบทอดแทนที่จะแก้ไข New กับ Run คง semantics เดิมไว้สำหรับผู้เรียกที่มีอยู่แล้ว โค้ดใหม่เอื้อมไปหา NewEx, NewTimeout และ RunEx เมื่อต้องการ CancelWithReason, WaitForCancellation หรือ IPdfCancellationRegistration ที่จัดการไว้ให้ การสืบทอดเป็นวิธีเดียวที่ปลอดภัยในการขยาย published interface และมันมีต้นทุนแค่หนึ่งชนิดเพิ่มเติมต่อรุ่น

NewTimeout ควรค่าแก่หมายเหตุที่ซื่อตรง แต่ละ timeout source เป็นเจ้าของ thread น้ำหนักเบาที่รอบน cancellation event หรือ deadline อันไหนมาถึงก่อน สำหรับ deadline สิบกว่าหรือไม่กี่สิบตัวนั่นเรียบง่าย latency ต่ำ และเหมือนกันทุกประการทั้งบน Delphi, Lazarus และ C++Builder สำหรับ deadline สั้น ๆ นับพันตัว มันเป็นรูปแบบที่ผิด และคุณควรขับเคลื่อนการยกเลิกจาก timer ระดับแอปพลิเคชันตัวเดียวแทน ไม่ใช่ถือ thread ที่กำลังรอนับพันตัวไว้

ไม่มีกฎข้อไหนในนี้ที่แปลกประหลาดเมื่อเขียนออกมาแล้ว แต่ทุกข้อล้วนเป็นเหตุการณ์ในการใช้งานจริงเมื่อไม่ทำตาม รอที่จุดหมายที่ถูกต้องและปล่อยให้บางอย่าง pump คิว synchronize อ่าน QueueCapacity เป็นแค่ขอบเขตของแถวรอเท่านั้น ยกเลิกนอก lock ของคุณ และให้ worker แต่ละตัวมีเอกสารของตัวเอง Async layer ที่อธิบายไว้ที่นี่มาพร้อมกับ Delphi PDFium Component ควบคู่ไปกับ API การ render, ข้อความ และฟอร์มที่มันจัดตารางให้