เธรดพื้นหลังกำลังส่งออกรายงาน 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 ไม่ใช่แบบต่อคิว
เวิร์กบุ๊กอ่านจากสองเธรดปลอดภัยหรือไม่?
ปลอดภัย ตราบใดที่ผู้อ่านทั้งคู่ถือ 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 จากข้างในธุรกรรมเขียนไม่ได้ แม้บนเธรดเขียนเอง
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 ทำแทนคุณได้ ถ้าความล้มเหลวกลางธุรกรรมอาจทิ้งเวิร์กบุ๊กไว้ในรูปร่างที่ส่งมอบไม่ได้ ให้เก็บไฟล์ต้นฉบับไว้แล้วเปิดใหม่ แทนที่จะเชื่อออบเจกต์ในหน่วยความจำ
เคาน์เตอร์ 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 โดยไม่ต้องตั้งค่าใดเพื่อเปิดใช้