技术文章

HotXLS 的 Delphi 工作簿读租约与写守卫

一个后台线程正在导出一份四万行的报表,界面线程这时改了一个单元格,落到磁盘上的文件对不上任何曾经存在过的工作簿。HotXLS 在 lxWorkbookView.pas 里处理这一类 bug:IXLSWorkbookViewCore 发放 O(1) 读租约和失败即抛的写守卫——只要有租约开着,每个改动入口点都会抛异常而不是写入

没有堆栈跟踪的失败

读一个工作簿从来不是单个原子操作。一次报表遍历是分散在数秒内的几万次独立单元格读取,而一次 SetValue 落在其中两次读取之间,就足以改变遍历其余部分看到的内容。经典引擎把这一点变得很具体:TXLSCellRef.SetValue 可能调用 FSST.Remove 删掉一个共享字符串条目、重置 FValueType、让公式缓存状态失效,而这一切发生时另一个线程正在解引用恰恰就是这些结构。当场什么都不崩溃。你得到的是一份小计对不上的报表,或者一次导出悄悄读到了一个如今指向别处的字符串索引

HotXLS 刻意不靠让写者等待来解决这个问题。读者可能持有一个工作簿好几秒,而在 VCL 应用里写者往往是主线程上的界面回调或事件处理器——把那个线程阻塞到后台导出结束,比让这次编辑失败更糟。所以协调核心在针对打开租约尝试写入的那一刻、在任何字段被碰到之前就抛 EXLSWorkbookWriteGuardUnavailable,由调用方决定是把编辑排队、重试还是告诉用户。冲突要失败即抛,而不是排队

一张 HotXLS 协调矩阵图:读租约彼此自由共存,针对打开租约的写入抛 EXLSWorkbookWriteGuardUnavailable,在写事务内请求租约抛 EXLSWorkbookReadLeaseUnavailable,而两个写者线程从不互相排斥
读者共存,写者对它们失败即抛,但核心从不把一个写者线程与另一个写者线程互斥

工作簿可以从两个线程安全读取吗?

可以,前提是两个读者都持有租约且没人写入。IXLSWorkbookViewCore.AcquireReadLease 进入一个 TCriticalSection、递增计数器、快照当前世代号,然后返回一个 IXLSWorkbookReadLease——无论工作簿装着一千个还是一百万个单元格,耗时恒定。任意数量的租约可以共存,可以按任意顺序释放,每个租约都通过自己的接口引用把核心钉住保活,所以租约比创建它的对象活得久是安全的,而不是悬垂指针。两个引擎都参与:lxHandle.pas 里的 TXLSWorkbooklxHandleX.pas 里的 TXLSXWorkbook 都在构造函数里建一个核心,并暴露 _AcquireReadLease_AcquireWriteGuard

同样重要的是租约没有给读取路径加什么。临界区只覆盖租约获取、租约释放和写事务边界——不覆盖别的。普通的逐单元格读取从不进入锁、监视器或原子计数器,所以持有租约的代价是整次扫描一次获取加一次释放,而不是每单元格一次。这与并行 XLSX 解析和内存分配器那项工作背后是同一种设计直觉:在边界上为协调付费,绝不在内循环里付。对称的规则同样成立——只要 WriteDepth 非零,AcquireReadLease 就抛 EXLSWorkbookReadLeaseUnavailable,所以你不能在写事务内部打开租约,哪怕在写线程上也不行

HotXLS 在扫描边界上为协调付费:临界区只覆盖租约获取、释放和写事务边界,而写守卫在 TXLSCellRef.SetValue 内部获取,于是它上方每个便利 API 都被统一把关一次
一次获取加一次释放覆盖五万单元格的扫描,而 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;
  // 租约在此离开作用域:引用计数降到零,
  // ReleaseReadLease 执行,写者重新成为可能
end;

写守卫到底坐在哪里?

在最低的可变层,绝不在它之上的便利 API 层。_AcquireWriteGuard 是从 TXLSCellRef.SetValue 自身内部调用的,这意味着汇入它的每条公共路径——Range.Value、工作表文本赋值、逐单元格复制、粘贴——都被把关一次,而不是每个包装器重复一遍检查、将来某个包装器又忘记。覆盖面刻意很宽:在引入核心的那一批次里,lxHandle.pas 中有 55 处守卫获取,lxHandleX.pas 中有 37 处

被把关的面横跨单元格值和单元格格式、TXLSWorkbook.Open、复制与粘贴、定义名称(Add、重命名、RefersToVisibleIsMacroCommentDelete)、工作表元数据如 NameZoomVisibleStandardHeightFreezePanesProtectActivate、页面设置、分页符,以及 Calculate。位置就是全部要点:守卫在第一个字段被写入之前获取,而不是事后由通知钩子校验,所以被拒绝的改动让模型逐字节不变。回归套件断言的正是这一点,在每次被拒调用后重读表名、缩放、可见性、标准行高、页边距、方向和分页符数量。加载路径在下一层得到同等待遇,那里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;
  // 只有完成的最外层守卫才推进世代号
  WriteGuard.Complete;
end;

为什么嵌套写入只推进一次世代号?

因为写事务由一个线程上的最外层守卫定义,而不是由每个守卫各自定义。核心为每个线程保存一个写者状态,含线程 id、深度和完成标志。同一线程上第二次 AcquireWriteGuard 会找到那个状态并递增 Depth,而不是创建新事务;只有当 Depth 落回零——且最外层守卫已被标记 Complete——FGeneration 才推进。正因如此,CalculateOpen 这样的高层操作可以在底下调用十个带守卫的原语,仍只算作一次变更。内层的 Complete 调用会被记录但自身不移动计数器,守卫可以乱序释放而不破坏记账

失败方向同样明确。如果守卫没有 Complete 就被释放——异常展开接口引用的通常后果——世代号不推进,因为写事务从未宣称成功。要看清这意味着什么:HotXLS 不会把部分编辑回滚。计数器记录的是没有成功事务完成,这正是缓存需要的信号,但把模型恢复到先前状态不是引用计数守卫能替你做的事。如果事务中途失败可能让工作簿处于你无法交付的形状,就留着源文件重新打开它,而不是信任内存里的对象

两条 HotXLS 写事务时间线对比:一个线程上的嵌套守卫抬高深度,且只在最外层守卫完成时推进世代计数器;而异常未经 Complete 就展开守卫时,世代号不变,部分编辑留在原处
深度跟踪嵌套,但只有完成的最外层事务推进世代号,中止的事务让计数器和部分编辑都原样留在原地

世代计数器给你带来什么

零扫描的廉价陈旧检测。Generation 是一个 UInt64,从 1 开始,回绕时跳过 0,所以 0 从来不是核心发出的值,可以当作可靠的“从未观测”哨兵。两个不变量让它可用:只要存在任何读租约,世代号就不能移动;每个成功的写事务恰好把它递增一次。所以 IXLSWorkbookReadLease.Generation 是一个在租约整个生命周期内保持不变的快照,而 IXLSWorkbookWriteGuard.StartGeneration 告诉写者它的事务开启时模型是什么样子。表格、打印预览或派生索引可以比较一个整数,而不必逐行 diff

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration 从 0 开始,这是核心从不发出的值,
  // 所以第一遍总是重建
end;

这套协调不承诺什么

有三个限制值得明说,因为做相反假设正是这套机制被误用的方式。第一,写守卫不是写者之间的互斥:核心把读者与写者互斥,而两个不同线程可以同时各持一个写守卫,各自独立推进世代号——有一条回归测试断言的正是这个行为。把你自己的写者线程串行化仍然是你的事。第二,这里没有任何东西是文件锁或跨进程互斥体;它协调的是一个进程内针对一个工作簿实例的线程,两个进程打开同一个 .xlsx 互不知情。第三,保证只覆盖真正取了租约的调用方——未取租约的读取仍走无锁热路径,那条路很快且完全不受保护。这是一个协调核心,不是事务数据库

在这些界限内使用,它是一个小而诚实的原语:九个专门的回归测试覆盖多读者、两个冲突方向、可重入、乱序释放、中止事务,以及跨线程读/写和写/写竞争,包含在 Win32 和 Win64 上通过的 1,328 个测试套件中。把它与崩溃安全的分阶段临时文件保存路径配对,后台导出就变成你可以从头到尾推理的东西——读取时一致,写入时原子。读租约、写守卫和世代计数器作为经典引擎和包引擎的一部分,随面向 Delphi 和 C++Builder 的 HotXLS Delphi Component 一起交付,无需任何配置即可启用