一个后台线程正在导出一份四万行的报表,界面线程这时改了一个单元格,落到磁盘上的文件对不上任何曾经存在过的工作簿。HotXLS 在 lxWorkbookView.pas 里处理这一类 bug:IXLSWorkbookViewCore 发放 O(1) 读租约和失败即抛的写守卫——只要有租约开着,每个改动入口点都会抛异常而不是写入
没有堆栈跟踪的失败
读一个工作簿从来不是单个原子操作。一次报表遍历是分散在数秒内的几万次独立单元格读取,而一次 SetValue 落在其中两次读取之间,就足以改变遍历其余部分看到的内容。经典引擎把这一点变得很具体:TXLSCellRef.SetValue 可能调用 FSST.Remove 删掉一个共享字符串条目、重置 FValueType、让公式缓存状态失效,而这一切发生时另一个线程正在解引用恰恰就是这些结构。当场什么都不崩溃。你得到的是一份小计对不上的报表,或者一次导出悄悄读到了一个如今指向别处的字符串索引
HotXLS 刻意不靠让写者等待来解决这个问题。读者可能持有一个工作簿好几秒,而在 VCL 应用里写者往往是主线程上的界面回调或事件处理器——把那个线程阻塞到后台导出结束,比让这次编辑失败更糟。所以协调核心在针对打开租约尝试写入的那一刻、在任何字段被碰到之前就抛 EXLSWorkbookWriteGuardUnavailable,由调用方决定是把编辑排队、重试还是告诉用户。冲突要失败即抛,而不是排队
工作簿可以从两个线程安全读取吗?
可以,前提是两个读者都持有租约且没人写入。IXLSWorkbookViewCore.AcquireReadLease 进入一个 TCriticalSection、递增计数器、快照当前世代号,然后返回一个 IXLSWorkbookReadLease——无论工作簿装着一千个还是一百万个单元格,耗时恒定。任意数量的租约可以共存,可以按任意顺序释放,每个租约都通过自己的接口引用把核心钉住保活,所以租约比创建它的对象活得久是安全的,而不是悬垂指针。两个引擎都参与:lxHandle.pas 里的 TXLSWorkbook 和 lxHandleX.pas 里的 TXLSXWorkbook 都在构造函数里建一个核心,并暴露 _AcquireReadLease 和 _AcquireWriteGuard
同样重要的是租约没有给读取路径加什么。临界区只覆盖租约获取、租约释放和写事务边界——不覆盖别的。普通的逐单元格读取从不进入锁、监视器或原子计数器,所以持有租约的代价是整次扫描一次获取加一次释放,而不是每单元格一次。这与并行 XLSX 解析和内存分配器那项工作背后是同一种设计直觉:在边界上为协调付费,绝不在内循环里付。对称的规则同样成立——只要 WriteDepth 非零,AcquireReadLease 就抛 EXLSWorkbookReadLeaseUnavailable,所以你不能在写事务内部打开租约,哪怕在写线程上也不行
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、重命名、RefersTo、Visible、IsMacro、Comment、Delete)、工作表元数据如 Name、Zoom、Visible、StandardHeight、FreezePanes、Protect 和 Activate、页面设置、分页符,以及 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 才推进。正因如此,Calculate 或 Open 这样的高层操作可以在底下调用十个带守卫的原语,仍只算作一次变更。内层的 Complete 调用会被记录但自身不移动计数器,守卫可以乱序释放而不破坏记账
失败方向同样明确。如果守卫没有 Complete 就被释放——异常展开接口引用的通常后果——世代号不推进,因为写事务从未宣称成功。要看清这意味着什么:HotXLS 不会把部分编辑回滚。计数器记录的是没有成功事务完成,这正是缓存需要的信号,但把模型恢复到先前状态不是引用计数守卫能替你做的事。如果事务中途失败可能让工作簿处于你无法交付的形状,就留着源文件重新打开它,而不是信任内存里的对象
世代计数器给你带来什么
零扫描的廉价陈旧检测。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 一起交付,无需任何配置即可启用