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

Read Lease กับ Write Guard ของเวิร์กบุ๊ก HotXLS ใน Delphi

เธรดพื้นหลังกำลังส่งออกรายงาน 40,000 แถวอยู่ เมื่อเธรด UI ตั้งค่าเซลล์หนึ่งเซลล์ แล้วไฟล์ที่ตกลงบนดิสก์กลับไม่ตรงกับเวิร์กบุ๊กใดที่เคยมีอยู่จริง HotXLS รับมือบั๊กชนิดนี้ใน lxWorkbookView.pas ซึ่ง IXLSWorkbookViewCore ออก read lease แบบ O(1) กับ write guard แบบ fail-fast: ขณะ lease เปิดอยู่ จุดเข้าการแก้ไขทุกจุดจะ raise แทนที่จะเขียน

ความล้มเหลวที่มาถึงโดยไม่มี stack trace

การอ่านเวิร์กบุ๊กไม่เคยเป็นการกระทำอะตอมมิกเดียว การเดินรายงานคือการอ่านเซลล์เป็นหมื่นเป็นแสนครั้งกระจายอยู่ในหลายวินาที การ SetValue หนึ่งครั้งที่ตกลงกลางสองการอ่านก็พอจะเปลี่ยนสิ่งที่การเดินส่วนที่เหลือเห็น เอนจินคลาสสิกทำให้เรื่องนี้จับต้องได้: TXLSCellRef.SetValue เรียก FSST.Remove เพื่อปลดรายการ shared string รีเซ็ต FValueType และทำให้สถานะแคชสูตรเสีย ทั้งหมดนั่นเกิดขณะอีกเธรดกำลัง dereference เป๊ะกับโครงสร้างเหล่านั้นอยู่กลางคัน ไม่มีอะไร crash ณ จุดนั้น คุณได้รายงานที่ยอดรวมย่อยไม่รวมกัน หรือการส่งออกที่อ่านดัชนีสตริงเงียบ ๆ ทั้งที่ดัชนีนั้นชี้ไปที่อื่นแล้ว

HotXLS ตั้งใจไม่แก้เรื่องนี้ด้วยการบังคับให้ฝั่งเขียนรอ ผู้อ่านหนึ่งตัวถือเวิร์กบุ๊กได้หลายวินาที และในแอป VCL ฝั่งเขียนมักเป็น UI callback หรือ event handler บนเธรดหลัก — การบล็อกเธรดนั้นจนกว่าการส่งออกพื้นหลังจะจบเป็นผลลัพธ์ที่แย่กว่าการปัดการแก้ไขทิ้ง แกนประสานงานจึงปล่อย EXLSWorkbookWriteGuardUnavailable ทันทีที่มีการพยายามเขียนบน lease ที่ยังเปิด ก่อนฟิลด์ใดจะถูกแตะเลย แล้วให้ผู้เรียกตัดสินใจว่าจะคิวการแก้ไข ลองใหม่ หรือบอกผู้ใช้ ความขัดแย้งแบบ fail-fast ไม่ใช่แบบต่อคิว

เมทริกซ์ประสานงานของ HotXLS แสดงว่า read lease อยู่ร่วมกันได้อย่างอิสระ, การพยายามเขียนบน lease ที่เปิดอยู่ปล่อย EXLSWorkbookWriteGuardUnavailable, การขอ lease ข้างในธุรกรรมเขียนปล่อย EXLSWorkbookReadLeaseUnavailable และเธรดเขียนสองเธรดไม่เคยกันกันเอง
ผู้อ่านอยู่ร่วมกันได้ ผู้เขียนชนกับพวกเขาแบบ fail-fast แต่แกนไม่เคยกันเธรดเขียนหนึ่งออกจากอีกเธรดหนึ่ง

เวิร์กบุ๊กอ่านจากสองเธรดปลอดภัยหรือไม่?

ปลอดภัย ตราบใดที่ผู้อ่านทั้งคู่ถือ lease และไม่มีใครเขียน IXLSWorkbookViewCore.AcquireReadLease รับ TCriticalSection เพิ่มเคาน์เตอร์ สแนปช็อต generation ปัจจุบัน และคืน IXLSWorkbookReadLease — คงที่ต่อเวลา ไม่ว่าเวิร์กบุ๊กจะมีเซลล์พันเซลล์หรือล้านเซลล์ lease กี่ตัวก็อยู่ร่วมกันได้ ปล่อยเป็นลำดับใดก็ได้ และแต่ละตัวตรึงแกนให้มีชีวิตผ่านการอ้างอิง interface ของตัวเอง lease ที่อายุยาวกว่าออบเจกต์ที่สร้างมันจึงปลอดภัย ไม่ใช่ dangling pointer เอนจินทั้งสองเข้าร่วม: TXLSWorkbook ใน lxHandle.pas กับ TXLSXWorkbook ใน lxHandleX.pas สร้างแกนใน constructor ของตัวเองและเปิดเผย _AcquireReadLease กับ _AcquireWriteGuard

สิ่งที่สำคัญไม่แพ้กันคือสิ่งที่ lease ไม่เพิ่มให้เส้นทางอ่าน critical section ครอบคลุมแค่การได้มาของ lease, การปล่อย lease และขอบเขตธุรกรรมเขียน — จบเท่านั้น การอ่านต่อเซลล์ธรรมดาไม่เคยเข้าล็อก มอนิเตอร์ หรือเคาน์เตอร์อะตอมมิก การถือ lease จึงมีต้นทุนหนึ่งการได้มากับหนึ่งการปล่อยต่อการสแกนทั้งชุด ไม่ใช่หนึ่งต่อเซลล์ นั่นคือสัญชาตญาณดีไซน์เดียวกับเบื้องหลัง งาน parse XLSX แบบขนานกับตัวจัดสรรหน่วยความจำ: จ่ายค่าประสานงานที่ขอบเขต ไม่เคยในลูปใน กฎสมมาตรก็ใช้ได้เช่นกัน — AcquireReadLease ปล่อย EXLSWorkbookReadLeaseUnavailable ทุกครั้งที่ WriteDepth ไม่เป็นศูนย์ คุณจึงเปิด lease จากข้างในธุรกรรมเขียนไม่ได้ แม้บนเธรดเขียนเอง

HotXLS จ่ายค่าประสานงานที่ขอบเขตของการสแกน: critical section ครอบคลุมเฉพาะการได้มาและการปล่อยของ lease กับขอบเขตธุรกรรมเขียน ขณะที่ write guard ถูกได้มาข้างใน TXLSCellRef.SetValue จึงกั้น API สะดวกทุกตัวที่อยู่เหนือมันหนึ่งครั้ง
หนึ่งการได้มากับหนึ่งการปล่อยครอบการสแกนห้าหมื่นเซลล์ และ guard ตัวเดียวข้างใน TXLSCellRef.SetValue ครอบเส้นทางเขียนสาธารณะทุกเส้นที่อยู่เหนือมัน
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // ปล่อย EXLSWorkbookReadLeaseUnavailable ถ้ากำลังมีการเขียนอยู่
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Lease หมดขอบเขตตรงนี้: reference count ร่วงเป็นศูนย์
  // ReleaseReadLease รัน และฝั่งเขียนกลับมาทำได้อีกครั้ง
end;

write guard นั่งอยู่ที่ไหนกันแน่?

ที่ชั้นที่แก้ไขได้ต่ำสุด ไม่เคยที่ API สะดวกชั้นบน _AcquireWriteGuard ถูกเรียกจากข้างใน TXLSCellRef.SetValue เอง หมายความว่าทุกเส้นทางสาธารณะที่ไหลลงมัน — Range.Value, การกำหนดข้อความของเวิร์กชีต, การคัดลอกทีละเซลล์, วาง — ถูกกั้นหนึ่งครั้ง แทนที่ wrapper แต่ละตัวจะทำซ้ำการตรวจที่ wrapper ลำดับถัดไปในอนาคตจะลืม พื้นที่ครอบคลุมถูกทำให้กว้างตั้งใจ: 55 จุดได้มาของ guard ใน lxHandle.pas และ 37 จุดใน lxHandleX.pas ณ ชุดที่แนะนำแกนตัวนี้

พื้นที่ที่ถูกกั้นกินค่าเซลล์กับฟอร์แมตเซลล์, TXLSWorkbook.Open, คัดลอกกับวาง, ชื่อที่นิยาม (Add, เปลี่ยนชื่อ, RefersTo, Visible, IsMacro, Comment, Delete), metadata เวิร์กชีตอย่าง Name, Zoom, Visible, StandardHeight, FreezePanes, Protect กับ Activate, page setup, page break และ Calculate ตำแหน่งการวางคือประเด็นทั้งหมด: guard ถูกได้มาก่อนฟิลด์แรกถูกเขียน ไม่ใช่ตรวจทีหลังด้วย notification hook การแก้ไขที่ถูกปฏิเสธจึงทิ้งโมเดลไว้เหมือนเดิมทีละไบต์ ชุดทดสอบถดถอยยืนยันเป๊ะแบบนั้น อ่านซ้ำชื่อชีต ซูม การมองเห็น ความสูงมาตรฐาน ระยะขอบ การวางแนว และจำนวน page break หลังการเรียกที่ถูกปฏิเสธทุกครั้ง เส้นทางโหลดได้รับการดูแลเช่นเดียวกันอีกชั้นลงไป ที่ซึ่ง ประตูอ่าน ZIP ประสานการคลายบีบอัดพร้อมกันสำหรับฟอร์แมตแพ็กเกจ

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // ถูกได้มาก่อนฟิลด์แรกถูกแตะ ไม่เคยหลัง
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // เฉพาะ guard ชั้นนอกสุดที่ complete เท่านั้นขยับ generation
  WriteGuard.Complete;
end;

ทำไมการเขียนแบบซ้อนจึงขยับ generation เพียงครั้งเดียว?

เพราะธุรกรรมเขียนถูกนิยามด้วย guard ชั้นนอกสุดของเธรด ไม่ใช่ด้วย guard แต่ละตัวเป็นเรี่ยราด แกนเก็บสถานะผู้เขียนต่อเธรดที่ถือ thread id, ความลึก และ flag ความสมบูรณ์ AcquireWriteGuard ตัวที่สองบนเธรดเดิมเจอสถานะนั้นและเพิ่ม Depth แทนการสร้างธุรกรรมใหม่ และเฉพาะเมื่อ Depth กลับลงเป็นศูนย์ — โดย guard ชั้นนอกสุดถูกมาร์ก Complete แล้ว — FGeneration จึงขยับ นี่คือสิ่งที่ทำให้การกระทำระดับสูงอย่าง Calculate หรือ Open เรียก primitive ที่ถูกกั้นสิบตัวข้างใต้แล้วยังลงทะเบียนเป็นการเปลี่ยนแปลงเดียว การเรียก Complete ชั้นในถูกบันทึกแต่ไม่ขยับเคาน์เตอร์ด้วยตัวเอง และ guard ปล่อยก่อนหลังสลับกันได้โดยไม่ทำบัญชีพัง

ทิศทางความล้มเหลวก็ชัดเจนเท่ากัน ถ้า guard ถูกปล่อยโดยไม่ผ่าน Complete — ผลลัพธ์ธรรมดาของ exception ที่คลี่ interface reference ออก — generation ไม่ขยับ เพราะธุรกรรมเขียนไม่เคยเรียกร้องความสำเร็จ มองให้เห็นตามตัวว่าแปลว่าอะไร: HotXLS ไม่ roll back การแก้ไขที่เสร็จครึ่ง ๆ เคาน์เตอร์บันทึกว่าไม่มีธุรกรรมใดสำเร็จแล้วปิดลง ซึ่งเป๊ะกับสัญญาณที่แคชต้องการ แต่การคืนโมเดลสู่สถานะก่อนหน้าไม่ใช่สิ่งที่ guard ที่อาศัย reference count ทำแทนคุณได้ ถ้าความล้มเหลวกลางธุรกรรมอาจทิ้งเวิร์กบุ๊กไว้ในรูปร่างที่ส่งมอบไม่ได้ ให้เก็บไฟล์ต้นฉบับไว้แล้วเปิดใหม่ แทนที่จะเชื่อออบเจกต์ในหน่วยความจำ

ไทม์ไลน์ธุรกรรมเขียนของ HotXLS สองแบบเทียบกัน: guard ที่ซ้อนกันบนเธรดเดียวเพิ่มความลึกและขยับเคาน์เตอร์ generation เฉพาะเมื่อ guard ชั้นนอกสุด complete ขณะที่ exception คลี่ guard ออกโดยไม่ผ่าน Complete ทิ้ง generation ให้คงเดิมและการแก้ไขครึ่ง ๆ ไว้ตรงนั้น
Depth ตามการซ้อน แต่เฉพาะธุรกรรมชั้นนอกสุดที่สมบูรณ์เท่านั้นขยับ generation ธุรกรรมที่ถูกยกเลิกทิ้งทั้งเคาน์เตอร์และการแก้ไขครึ่ง ๆ ไว้เป๊ะที่เดิม

เคาน์เตอร์ generation ซื้ออะไรให้คุณ

การตรวจความค้างเก่าแบบถูกโดยไม่ต้องสแกน Generation เป็น UInt64 เริ่มที่ 1 และข้าม 0 เวลาวนรอบ 0 จึงไม่เคยเป็นค่าที่แกนปล่อย และทำหน้าที่เซนติเนล "ยังไม่เคยสังเกต" ที่เชื่อถือได้ ค่าคงตัวสองข้อทำให้ใช้งานได้จริง: generation ไม่มีทางขยับขณะมี read lease อยู่ และธุรกรรมเขียนที่สำเร็จทุกธุรกรรมเพิ่มมันเป๊ะหนึ่งครั้ง IXLSWorkbookReadLease.Generation จึงเป็นสแนปช็อตที่คงค่าตลอดอายุ lease และ IXLSWorkbookWriteGuard.StartGeneration บอกผู้เขียนว่าโมเดลหน้าตาเป็นอย่างไรตอนธุรกรรมของมันเปิด กริด ตัวพรีวิวพิมพ์ หรือดัชนีที่ derive มาเทียบจำนวนเต็มหนึ่งตัวได้ แทนที่จะ diff แถว

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration เริ่มที่ 0 ซึ่งเป็นค่าที่ core ไม่มีวันปล่อย
  // รอบแรกสุดจึง rebuild เสมอ
end;

การประสานงานนี้ไม่สัญญาอะไร

สามข้อจำกัดควรพูดให้ตรง ๆ เพราะการสมมติอย่างอื่นคือวิธีที่กลไกนี้ถูกใช้ผิด หนึ่ง write guard ไม่ใช่ mutual exclusion ระหว่างผู้เขียน: แกนกันผู้อ่านกับผู้เขียนออกจากกัน แต่สองเธรดเขียนต่างคนต่างถือ write guard พร้อมกันได้ ต่างคนต่างขยับ generation อย่างอิสระ — การทดสอบถดถอยยืนยันพฤติกรรมนี้เป๊ะ การ serialize เธรดเขียนของคุณเองยังเป็นหน้าที่ของคุณ สอง ไม่มีอะไรในนี้เป็น file lock หรือ mutex ข้ามโพรเซส มันประสานเธรดข้างในโพรเซสเดียวกับเวิร์กบุ๊กอินสแตนซ์เดียว สองโพรเซสที่เปิด .xlsx ไฟล์เดียวกันไม่รู้จักกันเลย สาม การรับประกันเอื้อมถึงเฉพาะผู้เรียกที่ได้ถือ lease จริง — การอ่านที่ไม่มี lease ยังเดินบนเส้นทางร้อนที่ไม่ล็อก ซึ่งเร็วและไม่มีการป้องกันเลย นี่คือแกนประสานงาน ไม่ใช่ฐานข้อมูลเชิงธุรกรรม

ใช้ภายในขอบเขตนั้น มันคือ primitive ชิ้นเล็กที่ซื่อสัตย์: การทดสอบถดถอยเฉพาะทางเก้าตัวครอบคลุมผู้อ่านหลายตัว ความขัดแย้งทั้งสองทิศ ความ reentrant การปล่อยสลับลำดับ ธุรกรรมที่ถูกยกเลิก และการแข่งขันข้ามเธรดอ่าน/เขียนกับเขียน/เขียน ข้างในชุดทดสอบ 1,328 ตัวที่ผ่านบน Win32 กับ Win64 จับคู่มันกับ เส้นทางบันทึกไฟล์ temp แบบขั้นเป็นตอนที่ทนต่อการ crash การส่งออกพื้นหลังจึงกลายเป็นเรื่องที่ไล่เหตุผลได้ตั้งแต่ต้นจนจบ — สม่ำเสมอขณะอ่าน อะตอมมิกตอนเขียน read lease, write guard และเคาน์เตอร์ generation มาพร้อมเอนจินคลาสสิกกับแพ็กเกจใน HotXLS Delphi Component สำหรับ Delphi กับ C++Builder โดยไม่ต้องตั้งค่าใดเพื่อเปิดใช้