Bài viết kỹ thuật

Read lease và write guard cho workbook Delphi trong HotXLS

Một thread nền đang xuất báo cáo 40.000 hàng khi UI thread đặt một ô, và tệp đáp xuống đĩa không khớp với bất kỳ workbook nào từng tồn tại. HotXLS xử lý lớp bug đó trong lxWorkbookView.pas, nơi IXLSWorkbookViewCore phát read lease O(1) và write guard fail-fast: trong khi một lease còn mở, mọi entry point biến đổi đều raise thay vì ghi

Cái hỏng đến mà không có stack trace

Đọc một workbook không bao giờ là một thao tác nguyên tử duy nhất. Một lượt đi báo cáo là hàng chục nghìn phép đọc ô riêng lẻ trải trên nhiều giây, và một lần SetValue đáp xuống giữa hai phép đọc là đủ thay đổi những gì phần còn lại của lượt đi nhìn thấy. Engine classic làm điều này rất cụ thể: TXLSCellRef.SetValue có thể gọi FSST.Remove để bỏ một mục shared string, reset FValueType, và vô hiệu một trạng thái cache công thức, tất cả trong khi thread khác đang ở giữa việc dereference đúng những cấu trúc đó. Không gì crash ngay tại chỗ. Bạn nhận về một báo cáo mà các subtotal không cộng khớp, hay một bản xuất lặng lẽ đọc một chỉ số string nay trỏ sang chỗ khác

HotXLS cố ý không giải việc này bằng cách khiến writer chờ. Một reader có thể giữ workbook trong vài giây, và trong một ứng dụng VCL, writer thường là một callback UI hay event handler trên main thread — chặn thread đó cho tới khi một bản xuất nền xong là một kết quả tệ hơn cả việc từ chối thao tác sửa. Nên lõi điều phối raise EXLSWorkbookWriteGuardUnavailable ngay khoảnh khắc một thao tác ghi bị thử trên một lease còn mở, trước khi một field duy nhất bị chạm vào, và caller quyết định xếp hàng sửa, thử lại, hay báo người dùng. Xung đột fail-fast, không xếp hàng

Ma trận điều phối của HotXLS cho thấy read lease cùng tồn tại tự do, thao tác ghi thử trên một lease còn mở raise EXLSWorkbookWriteGuardUnavailable, lease xin trong một giao dịch ghi raise EXLSWorkbookReadLeaseUnavailable, và hai thread writer không bao giờ loại trừ nhau
Reader cùng tồn tại và writer fail-fast trước chúng, nhưng lõi không bao giờ loại trừ một thread writer này khỏi thread writer kia

Workbook có an toàn khi đọc từ hai thread không?

Có, với điều kiện cả hai reader đều giữ một lease và không ai ghi. IXLSWorkbookViewCore.AcquireReadLease lấy một TCriticalSection, tăng một bộ đếm, chụp nhanh generation hiện tại, và trả về một IXLSWorkbookReadLease — thời gian hằng số bất kể workbook chứa một nghìn ô hay một triệu ô. Bao nhiêu lease cùng tồn tại tùy thích, chúng có thể được giải phóng theo bất kỳ thứ tự nào, và mỗi lease ghim lõi sống qua tham chiếu interface của chính nó, nên một lease sống lâu hơn đối tượng tạo ra nó là an toàn chứ không phải con trỏ treo. Cả hai engine cùng tham gia: TXLSWorkbook trong lxHandle.pasTXLSXWorkbook trong lxHandleX.pas mỗi cái dựng một lõi trong constructor và phơi _AcquireReadLease cùng _AcquireWriteGuard

Quan trọng không kém là những gì lease không thêm vào đường đọc. Critical section chỉ phủ lấy việc xin lease, nhả lease và các biên giao dịch ghi — không gì khác. Phép đọc từng ô bình thường không bao giờ vào một lock, một monitor hay một bộ đếm nguyên tử, nên giữ một lease tốn đúng một lần xin và một lần nhả cho cả lượt quét, chứ không phải một lần mỗi ô. Đó cũng là bản năng thiết kế đằng sau công việc parse XLSX song song và memory allocator: trả tiền điều phối ở biên, không bao giờ trong vòng lặp trong. Quy tắc đối xứng cũng đúng — AcquireReadLease raise EXLSWorkbookReadLeaseUnavailable bất cứ khi nào WriteDepth khác 0, nên bạn không thể mở một lease từ trong một giao dịch ghi, kể cả trên chính thread đang ghi

HotXLS trả tiền điều phối ở biên của một lượt quét: critical section chỉ phủ việc xin lease, nhả lease và các biên giao dịch ghi, trong khi write guard được giữ bên trong TXLSCellRef.SetValue nên mọi API tiện lợi phía trên đều được chốt một lần
Một lần xin và một lần nhả phủ trọn một lượt quét năm mươi nghìn ô, và một guard duy nhất bên trong TXLSCellRef.SetValue chốt mọi đường ghi công khai phía trên nó
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Raise EXLSWorkbookReadLeaseUnavailable nếu một thao tác ghi đang chạy
  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 ra khỏi scope tại đây: reference count của nó về 0,
  // ReleaseReadLease chạy, và writer được phép trở lại
end;

Write guard thực sự ngồi ở đâu?

Ở tầng có thể biến đổi thấp nhất, không bao giờ ở API tiện lợi phía trên nó. _AcquireWriteGuard được gọi từ bên trong chính TXLSCellRef.SetValue, nghĩa là mọi đường công khai dẫn vào nó — Range.Value, gán văn bản worksheet, copy từng ô, paste — được chốt một lần thay vì mỗi wrapper lặp lại một phép kiểm tra mà một wrapper sau này sẽ quên. Mặt được che phủ là cố ý rộng: 55 lần giữ guard trong lxHandle.pas và 37 trong lxHandleX.pas tính đến đợt giới thiệu lõi

Bề mặt được chốt trải từ giá trị ô và định dạng ô, TXLSWorkbook.Open, copy và paste, defined names (Add, đổi tên, RefersTo, Visible, IsMacro, Comment, Delete), metadata worksheet như Name, Zoom, Visible, StandardHeight, FreezePanes, ProtectActivate, page setup, page break, và Calculate. Vị trí đặt mới là tất cả: guard được giữ trước khi field đầu tiên được ghi, chứ không được kiểm chứng sau bằng một hook thông báo, nên một thao tác biến đổi bị từ chối để model đúng từng byte. Bộ regression khẳng định chính xác điều đó, đọc lại tên sheet, zoom, trạng thái hiển thị, chiều cao chuẩn, lề, hướng và số page break sau mỗi lời gọi bị từ chối. Các đường load nhận cách đối xử tương tự một tầng sâu hơn, nơi cổng đọc ZIP điều phối inflate đồng thời cho các định dạng package

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Giữ trước khi field đầu tiên bị chạm, không bao giờ sau
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Chỉ guard ngoài cùng hoàn thành mới đẩy generation
  WriteGuard.Complete;
end;

Vì sao một thao tác ghi lồng nhau chỉ đẩy generation một lần?

Vì một giao dịch ghi được định nghĩa bởi guard ngoài cùng trên một thread, không phải bởi từng guard riêng lẻ. Lõi giữ một trạng thái writer theo thread chứa thread id, một depth và một cờ hoàn thành. Lần AcquireWriteGuard thứ hai trên cùng thread tìm thấy trạng thái đó và tăng Depth thay vì tạo giao dịch mới, và chỉ khi Depth rơi về 0 — với guard ngoài cùng đã được đánh dấu CompleteFGeneration mới tiến. Đó là điều cho phép một thao tác cấp cao như Calculate hay Open gọi mười primitive có guard bên dưới mà vẫn đăng ký như một thay đổi. Các lời gọi Complete bên trong được ghi nhận nhưng không tự ý đẩy bộ đếm, và các guard có thể được nhả lệch thứ tự mà không phá sổ sách

Chiều thất bại cũng tường minh như vậy. Nếu một guard bị nhả mà không Complete — hệ quả thường thấy của một ngoại lệ đang bóc các tham chiếu interface — generation không tiến, vì giao dịch ghi chưa từng tuyên bố thành công. Hãy nhìn thẳng vào ý nghĩa của điều đó: HotXLS không rollback phần sửa dở. Bộ đếm ghi nhận rằng không giao dịch thành công nào kết thúc, chính là tín hiệu mà một cache cần, nhưng khôi phục model về trạng thái trước đó không phải thứ một guard đếm tham chiếu làm được thay bạn. Nếu một thất bại giữa giao dịch có thể để workbook ở một hình dạng mà bạn không thể đóng gói, hãy giữ tệp nguồn và mở lại, thay vì tin vào đối tượng trong bộ nhớ

Hai dòng thời gian giao dịch ghi của HotXLS đặt cạnh nhau: các guard lồng nhau trên một thread đẩy depth và chỉ tăng bộ đếm generation khi guard ngoài cùng hoàn thành, còn một ngoại lệ bóc các guard mà không Complete để nguyên generation và phần sửa dở tại chỗ
Depth đuổi theo độ lồng, nhưng chỉ một giao dịch ngoài cùng hoàn thành mới đẩy generation, và một giao dịch bị bỏ dở để cả bộ đếm lẫn phần sửa dở đúng nguyên chỗ cũ

Bộ đếm generation mua cho bạn thứ gì

Phát hiện lỗi thời giá rẻ mà không phải quét. Generation là một UInt64 bắt đầu từ 1 và bỏ qua 0 khi tràn số, nên 0 không bao giờ là giá trị lõi phát ra và làm nổi bật một sentinel "chưa từng quan sát" đáng tin. Hai bất biến khiến nó dùng được: generation không thể dịch chuyển trong khi bất kỳ read lease nào còn tồn tại, và mỗi giao dịch ghi thành công tăng nó đúng một lần. Nên IXLSWorkbookReadLease.Generation là một bản chụp giữ nguyên suốt vòng đời của lease, và IXLSWorkbookWriteGuard.StartGeneration cho writer biết model trông thế nào khi giao dịch của nó mở. Một grid, một print preview hay một chỉ số phái sinh có thể so một số nguyên thay vì diff từng hàng

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration bắt đầu từ 0, giá trị lõi không bao giờ phát,
  // nên lượt đầu tiên luôn dựng lại
end;

Những gì cơ chế điều phối này không hứa

Ba giới hạn đáng nói rõ, vì giả định điều ngược lại chính là cách cơ chế này bị dùng sai. Thứ nhất, write guard không phải loại trừ lẫn nhau giữa các writer: lõi loại trừ reader trước writer, và hai thread khác nhau có thể cùng giữ một write guard tại cùng thời điểm, mỗi cái đẩy generation độc lập — một test regression khẳng định đúng hành vi này. Việc xếp hàng các thread writer của chính bạn vẫn là việc của bạn. Thứ hai, không thứ gì ở đây là một khóa tệ hay mutex liên tiến trình; nó điều phối các thread trong một tiến trình trên một thực thể workbook, và hai tiến trình mở cùng một .xlsx không biết gì về nhau. Thứ ba, cam kết chỉ với tới những caller thực sự lấy lease — một phép đọc không lease vẫn đi trên đường nóng không khóa, nhanh và hoàn toàn không được bảo vệ. Đây là một lõi điều phối, không phải một cơ sở dữ liệu giao dịch

Dùng trong những giới hạn đó, nó là một primitive nhỏ và trung thực: chín test regression chuyên trách phủ nhiều reader, cả hai hướng xung đột, reentrancy, nhả lệch thứ tự, giao dịch bỏ dở, và race đọc/ghi lẫn ghi/ghi xuyên thread, trong một bộ 1.328 test chạy xanh trên Win32 và Win64. Ghép nó với đường lưu tệp tạm theo giai đoạn an toàn trước crash và một bản xuất nền trở thành thứ bạn có thể suy luận trọn vẹn từ đầu đến cuối — nhất quán khi đọc, nguyên tử khi ghi. Read lease, write guard và bộ đếm generation đi kèm trong hai engine classic và package của HotXLS Delphi Component cho Delphi và C++Builder, không cần cấu hình gì để bật chúng