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

หกการเรียก PDFium ที่ลืม Render Lock ใน Delphi

render lock ของ PDFiumPas เป็น critical section ต่อเอกสาร คือ EnterRenderLock และ LeaveRenderLock หนุนหลังด้วยฟิลด์ TRTLCriticalSection บน TPdf มีไว้เพื่อห่อทุกการเรียกเข้าไปยัง rasterizer ของ PDFium เพื่อไม่ให้หน้าถูก unload หรือ reload ระหว่างที่การ render กำลังทำงานอยู่ six method แบ่งเท่าๆ กันระหว่าง TPdf กับ TPdfView เรียก API การดึงบิตแมปและ thumbnail ของ PDFium ตรงๆ และข้าม lock นั้นไปทั้งหมด ซึ่งเป็นช่องว่างที่ PDFiumPas v2.26.0 ปิดด้วยการห่อทั้งหกด้วยคู่ lock เดียวกับที่จุดเข้าการ render อื่นทุกจุดใช้อยู่แล้ว

ช่องว่างที่ครอบคลุมตรงนี้ไม่ใช่รอบการเสริมความแข็งแกร่งด้าน ABI ที่ครอบคลุมที่อื่นในบล็อกนี้ ซึ่งเดินผ่านความไม่ตรงกันของ calling-convention แบบ cdecl และการตัดทอนความกว้าง pointer ของ FPC Win64 ใน PDFium binding เดียวกัน สิ่งที่ตามมานี้แคบกว่าและเป็นกลไกมากกว่า คือ checklist ความครอบคลุมของ lock สำหรับหกจุดเรียกที่ล้วนเข้าไปยังเส้นทางการ render ของ PDFium ทำไมแต่ละจุดจึงพลาดได้ง่าย และทำไม race ที่ตามมาจากการขาด lock จึงเป็นหนึ่งในข้อบกพร่องที่ยากกว่าในโค้ดเบสนี้ที่จะทำให้เกิดซ้ำได้ตามต้องการ

Render Lock ปกป้องอะไรจริงๆ

PDFiumPas serialize การ render เพราะหน้าที่โหลดไว้ของ PDFium ไม่ปลอดภัยที่จะอ่านจาก thread หนึ่ง ในขณะที่ thread อื่นเป็นอิสระที่จะปล่อยมันได้ TPdf ถือ TRTLCriticalSection ไว้ใน FRenderLock เริ่มต้นใน constructor และเฝ้าด้วย flag FRenderLockReady ดังนั้นการเรียกที่มาถึงหลังการรื้อถอนจึงกลายเป็น no-op เงียบๆ แทนที่จะเข้าไปใน critical section ที่ถูกลบไปแล้ว EnterRenderLock และ LeaveRenderLock เป็นทางเข้าออกส่วนนั้นที่ได้รับอนุญาตทางเดียวเท่านั้น

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

TPdf.RenderPage, RenderTile และ RenderPageProgressive ปฏิบัติตามวินัยนั้นอยู่แล้วก่อนที่การตรวจสอบนี้โดยเฉพาะจะเริ่มด้วยซ้ำ แต่ละตัวถือ lock ก่อนเรียกเข้าไปยัง PDFium และปล่อยมันใน block finally เพื่อไม่ให้การ pre-render เบื้องหลังและ UnloadPage ที่ foreground บน instance TPdf เดียวกันซ้อนทับกันได้ ช่องว่างที่ PDFiumPas v2.26.0 พบไม่ได้อยู่ในจุดเข้าที่ชัดเจนเหล่านั้น มันปรากฏขึ้นในหก method ที่อ่านดูเหมือน accessor มากกว่าการ render ทั้งที่แต่ละตัวขอให้ PDFium rasterize พิกเซลก่อนที่มันจะคืนอะไรได้เลย

หกการเรียกไหนที่ข้าม Render Lock

TPdf.GetObjectBitmap, TPdf.GetBitmap และ TPdf.GetThumbnail ประกอบเป็นครึ่งหนึ่งของรายการ และ TPdfView.GetObjectBitmap, TPdfView.GetBitmap และ TPdfView.GetThumbnail ประกอบเป็นอีกครึ่ง เป็นการดำเนินการสามอย่างเดียวกัน ทำซ้ำข้ามสองคลาสคอมโพเนนต์ที่เปิดหน้าเดียวกันข้างใต้ ทั้งหกเรียก FPDFImageObj_GetBitmap หรือ FPDFPage_GetThumbnailAsBitmap ในที่สุด และจุดเข้า PDFium ทั้งสองนั้น rasterize ทันทีแทนที่จะส่ง reference กลับไปยังบางอย่างที่ render ไว้แล้ว ไม่มีอะไรในชื่อ method ทั้งหกที่บอกว่า render เลย ซึ่งเป็นคำอธิบายที่สมเหตุสมผลว่าทำไมพวกมันไม่ถูกเขียนตาม checklist เดียวกับ RenderPage และ RenderTile ในครั้งแรก

function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
  Bitmap: FPDF_BITMAP;
begin
  Result:= nil;
  EnterRenderLock;
  try
    Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
  finally
    LeaveRenderLock;
  end;
  if Bitmap<> nil then
    try
      Result:= ToBitmap(Bitmap);
    finally
      FPDFBitmap_Destroy(Bitmap);
    end;
end;

ทำไม TPdfView ถึงเฝ้าการเรียก Lock ของมันด้วยการตรวจสอบ Nil

TPdfView ไม่มี critical section เป็นของตัวเอง ทุกการเรียก lock ทั้งหกของมันส่งต่อไปยัง FPdf.EnterRenderLock และ FPdf.LeaveRenderLock ห่อไว้ในการตรวจสอบว่า reference TPdf ที่เกี่ยวข้องไม่ใช่ nil ก่อน การเฝ้านั้นมีอยู่เพราะ TPdfView สามารถนั่งอยู่บนฟอร์มตอน design time หรือช่วงสั้นๆ ระหว่างเอกสารหนึ่งปิดกับอีกอันเปิด โดยไม่มี TPdf ถูกกำหนดให้ FPdf เลย การข้ามการเฝ้าจะแลกความล้มเหลวหนึ่งกับอีกอันหนึ่ง เพราะการเรียก lock กับ reference ที่เป็น nil ล้มเหลวไม่นุ่มนวลไปกว่า race ที่ lock มีไว้เพื่อป้องกันเลย

function TPdfView.GetThumbnail: TBitmap;
var
  PdfBitmap: FPDF_BITMAP;
begin
  CheckActive;
  Result:= nil;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  try
    PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
  finally
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
  if PdfBitmap<> nil then
    try
      Result:= ToBitmap(PdfBitmap);
    finally
      FPDFBitmap_Destroy(PdfBitmap);
    end;
end;

ทำไม RenderPage(HDC) ถึงอยู่ในการตรวจสอบชุดเดียวกันนี้

TPdfView.RenderPage ที่ทำงานกับ device context ไม่ใช่หนึ่งในหกตัวนั้น มันปรากฏขึ้นก่อนหน้าหนึ่ง release ใน PDFiumPas v2.25.0 และได้ที่ทางใน checklist นี้เพราะมันคือข้อบกพร่องเดียวกันที่สวม signature ต่างออกไป overload นั้นเรียก FPDF_RenderPage ตรงๆ โดยไม่มีทั้ง EnterRenderLock และการเรียก SetArithmeticMask ที่เฝ้าไม่ให้เกิด FPU exception บนคอมไพเลอร์ Delphi รุ่นเก่า ในขณะที่ overload TBitmap ที่นั่งอยู่ไม่กี่บรรทัดข้างล่างในคลาสเดียวกันมีทั้งสองอย่างอยู่แล้ว การตรวจสอบสองรอบที่จับความล้มเหลวประเภทเดียวกันห่างกันหนึ่ง release บอกอะไรได้น้อยเกี่ยวกับ method ตัวใดตัวหนึ่ง แต่บอกอะไรได้มากกว่าเกี่ยวกับรูปร่างของบั๊ก มันซ่อนตัวอยู่ใน overload ตัวใดก็ตามที่ไม่มีใครอ่านซ้ำเมื่อพี่น้องของมันดูถูกต้องแล้ว

procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
  Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
  ArithmeticMask: TArithmeticMask;
begin
  CheckActive;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  ArithmeticMask:= SetArithmeticMask;
  try
    FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
      Ord(Rotation), EncodeRenderOptions(Options));
  finally
    RestoreArithmeticMask(ArithmeticMask);
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
end;

ทำไม Race นี้ถึงเกือบเป็นไปไม่ได้ที่จะทำให้เกิดซ้ำ

ช่องว่าง render-lock ของ PDFiumPas ไม่ล้มเหลวในทุกการรัน หรือแม้แต่ในการรันส่วนใหญ่ เพราะมันต้องการสองสิ่งเจาะจงที่มาลงเอยที่ instance TPdf เดียวกันพร้อมกัน คือการเรียก rasterization ที่กำลังทำงานอยู่แล้ว และ UnloadPage หรือ ReloadPage พร้อมกันที่มาถึงภายในหน้าต่างเดียวกันนั้น การทดสอบแบบ single-threaded ไม่เคยทดสอบเส้นทางนี้เลย และแม้แต่ workload ที่เป็น multi-threaded จริงๆ ก็สะดุดมันก็ต่อเมื่อการ render เบื้องหลังและ event วงจรชีวิตเอกสารบังเอิญซ้อนทับกันภายในช่วงอายุของหน้าเดียวเท่านั้น ตัวกระตุ้นที่สมจริงที่สุดคือการ pre-render PDF เบื้องหลังที่สร้างบน cancellable future ที่ worker thread rasterize หน้าถัดไป ในขณะที่ UI thread reload หรือ unload หน้าปัจจุบันตามอินพุตของผู้ใช้

FPDFImageObj_GetBitmap และ FPDFPage_GetThumbnailAsBitmap เดินผ่านโครงสร้าง page-object ที่ UnloadPage เป็นอิสระที่จะปล่อยกลางการเดิน ดังนั้น race ที่ยิงจริงจึงไม่เสมอไปที่จะสร้าง access violation ทันที การอ่านโครงสร้างช้าไปเพียงชั่วขณะก็สามารถส่งพิกเซลขยะกลับมาได้ง่ายพอๆ กัน หรือทำให้ heap metadata เสียหายซึ่งจะ crash การจัดสรรที่ไม่เกี่ยวข้องหลายตัวในภายหลังเท่านั้น ในฟังก์ชันที่ไม่เคยแตะหน้า PDF เลย นั่นคือเหตุผลตรงไปตรงมาว่าทำไมบั๊กประเภทนี้ถึงอยู่รอดในโค้ดเบสข้ามหลาย release cycle ได้ stack trace ณ จุดที่ล้มเหลวแทบไม่เคยชี้ไปที่ไหนใกล้กับหกบรรทัดที่ขาด lock จริงๆ เลย

อะไรเปลี่ยนไปสำหรับผู้เรียก

GetBitmap, GetObjectBitmap, GetThumbnail และ overload HDC ของ RenderPage คง signature สาธารณะของมันไว้ทุกประการ เพราะการแก้ไขนี้เป็น locking ภายในที่เพิ่มรอบการเรียกที่มีอยู่แล้ว ไม่ใช่การ migration ควรจำไว้ว่า render lock มีขอบเขตต่อ instance TPdf ไม่ใช่ทั่วทั้งโปรเซส ดังนั้นสอง thread ที่ render สองเอกสารที่โหลดแยกกันยังคงรันแบบขนานเต็มรูปแบบ lock แค่ serialize การดำเนินการเทียบกับเอกสารเดียวที่ทั้งสอง thread บังเอิญใช้ร่วมกันเท่านั้น ถ้า locking ของคุณมั่นคงอยู่แล้วและการ render ยังคงรู้สึกช้าเมื่อ zoom หรือ scroll นั่นเป็นคำถามที่ต่างออกไป ตอบไว้ในบทความกลยุทธ์ render cache และประสิทธิภาพ zoom ของ PDFium ความถูกต้องและความเร็วเป็นแกนที่แยกกันตรงนี้ และการแก้ไขนี้แตะแค่แกนแรกเท่านั้น

หก method และ overload พี่น้องหนึ่งตัวเป็นสัดส่วนเล็กน้อยของพื้นผิว PDFium ที่ PDFiumPas เปิดออกมา แต่มันคือสัดส่วนที่ทำงานผิดพลาดก็ต่อเมื่ออยู่ภายใต้ load ที่ไม่มีใครบังเอิญรันอยู่ใน debugger เท่านั้น render lock เอง และชุดจุดเข้าการ render เต็มรูปแบบที่มันครอบคลุมอยู่ในตอนนี้ มาพร้อมกับPDFium Componentสำหรับ Delphi, C++Builder และ Lazarus/FPC