Фоновый поток выгружал отчёт на 40 000 строк, когда поток интерфейса изменил одну ячейку — и файл, оказавшийся на диске, не совпадал ни с одной из когда-либо существовавших книг. HotXLS закрывает этот класс ошибок в lxWorkbookView.pas, где IXLSWorkbookViewCore выдаёт аренды чтения за O(1) и защиты записи с быстрым отказом: пока аренда открыта, каждая точка входа для изменения вызывает исключение вместо записи
Сбой, который приходит без трассировки стека
Чтение книги никогда не является одной атомарной операцией. Обход отчёта — это десятки тысяч отдельных чтений ячеек, растянутых на секунды, и одного SetValue, пришедшегося между двумя из них, достаточно, чтобы изменить то, что увидит оставшаяся часть обхода. Классический движок делает это осязаемым: TXLSCellRef.SetValue может вызвать FSST.Remove, чтобы удалить запись общей строки, сбросить FValueType и инвалимировать состояние кэша формул — и всё это пока другой поток находится посреди разыменования именно этих структур. Ничего не падает на месте. Вы получаете отчёт, в котором промежуточные итоги не сходятся, или выгрузку, которая молча читает строковый индекс, указывающий уже в другое место
HotXLS сознательно не решает эту проблему тем, что заставляет пишущие потоки ждать. Читатель может держать книгу несколько секунд, а в VCL-приложении пишущим часто является обратный вызов интерфейса или обработчик события в главном потоке — блокировать этот поток до завершения фоновой выгрузки хуже, чем отклонить изменение. Поэтому ядро координации вызывает EXLSWorkbookWriteGuardUnavailable в момент попытки записи при открытой аренде, до касания первого поля, а вызывающий решает, поставить ли изменение в очередь, повторить или сообщить пользователю. Конфликты завершаются быстро, а не встают в очередь
Безопасно ли читать книгу из двух потоков?
Да, если оба читателя держат аренду и никто не пишет. IXLSWorkbookViewCore.AcquireReadLease захватывает TCriticalSection, увеличивает счётчик, фиксирует текущее поколение и возвращает IXLSWorkbookReadLease — за постоянное время, независимо от того, тысяча ячеек в книге или миллион. Любое число аренд сосуществует, освобождать их можно в любом порядке, и каждая удерживает ядро живым через собственную ссылку на интерфейс, поэтому аренда, пережившая создавший её объект, безопасна, а не является висячим указателем. Участвуют оба движка: TXLSWorkbook в lxHandle.pas и TXLSXWorkbook в lxHandleX.pas создают ядро в конструкторе и предоставляют _AcquireReadLease и _AcquireWriteGuard
Не менее важно то, чего аренда не добавляет на путь чтения. Критическая секция покрывает захват аренды, её освобождение и границы транзакций записи — и ничего больше. Обычное чтение отдельной ячейки никогда не входит в блокировку, монитор или атомарный счётчик, поэтому удержание аренды стоит один захват и одно освобождение на весь проход, а не по одному на ячейку. Это тот же замысел, что стоит за работой по параллельному разбору XLSX и аллокатору памяти: платить за координацию на границе, но никогда во внутреннем цикле. Симметричное правило тоже действует — AcquireReadLease вызывает EXLSWorkbookReadLeaseUnavailable всегда, когда WriteDepth не равен нулю, поэтому аренду нельзя открыть внутри транзакции записи, даже в пишущем потоке
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, присваивание текста листа, копирование ячейка за ячейкой, вставка — проверяется один раз, вместо того чтобы каждый обёрточный слой повторял проверку, которую будущий слой забудет. Покрытие сознательно широкое: 55 захватов защиты в lxHandle.pas и 37 в lxHandleX.pas на момент выпуска, внедрившего ядро
Проверяемая поверхность охватывает значения и форматирование ячеек, 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;
Почему вложенная запись сдвигает поколение только один раз?
Потому что транзакция записи определяется самой внешней защитой на потоке, а не каждой защитой по отдельности. Ядро хранит состояние пишущей стороны для каждого потока: идентификатор потока, глубину и флаг завершения. Второй AcquireWriteGuard в том же потоке находит это состояние и увеличивает Depth, а не создаёт новую транзакцию, и только когда Depth возвращается к нулю — при том что самая внешняя защита отмечена Complete, — FGeneration сдвигается. Именно это позволяет операции высокого уровня вроде Calculate или Open вызвать десять защищённых примитивов внутри себя и всё равно зарегистрироваться как одно изменение. Внутренние вызовы Complete фиксируются, но сами по себе счётчик не сдвигают, а защиты можно освобождать не по порядку, не ломая учёт
Направление отказа описано столь же явно. Если защита освобождается без Complete — обычное следствие исключения, раскручивающего ссылку на интерфейс, — поколение не сдвигается, потому что транзакция записи ни разу не заявила об успехе. Трезво понимайте, что это значит: HotXLS не откатывает частичное изменение. Счётчик фиксирует, что ни одна успешная транзакция не завершилась, — именно этот сигнал и нужен кэшу, — но восстановление модели в прежнее состояние подсчётом ссылок вам не обеспечит. Если сбой посреди транзакции может оставить книгу в виде, который вы не сможете отгрузить, храните исходный файл и открывайте его заново, вместо того чтобы доверять объекту в памяти
Что даёт вам счётчик поколений
Дешёвое обнаружение устаревания без сканирования. Generation — это UInt64, который начинается с 1 и пропускает 0 при переполнении, поэтому 0 — значение, которое ядро никогда не выдаёт, и оно работает как надёжный маркер «ещё не наблюдалось». Два инварианта делают его пригодным к использованию: поколение не может сдвинуться, пока существует хоть одна аренда чтения, и каждая успешная транзакция записи увеличивает его ровно один раз. Поэтому IXLSWorkbookReadLease.Generation — снимок, постоянный на всём сроке жизни аренды, а IXLSWorkbookWriteGuard.StartGeneration сообщает пишущей стороне, как выглядела модель при открытии её транзакции. Сетка, предварительный просмотр печати или производный индекс могут сравнить одно целое число вместо построчного сравнения
var
Lease: IXLSWorkbookReadLease;
begin
Lease := FWorkbook._AcquireReadLease;
if Lease.Generation <> FCachedGeneration then
begin
FCachedGeneration := Lease.Generation;
RebuildRowHeightCache;
end;
PaintVisibleRows;
// FCachedGeneration начинается с 0 — значения, которое ядро никогда не выдаёт,
// поэтому самый первый проход всегда выполняет перестройку
end;
Чего эта координация не обещает
Три ограничения стоит назвать прямо, потому что предположение об обратном — типичный способ неправильно использовать этот механизм. Во-первых, защита записи — не взаимное исключение между пишущими: ядро разделяет читателей и пишущих, и два разных потока могут одновременно держать по защите записи, каждый независимо сдвигая поколение, — регрессионный тест проверяет именно такое поведение. Сериализовать собственные пишущие потоки по-прежнему ваша задача. Во-вторых, здесь нет ни файловой блокировки, ни межпроцессного мьютекса: она согласует потоки внутри одного процесса и одного экземпляра книги, а два процесса, открывшие один и тот же .xlsx, ничего не знают друг о друге. В-третьих, гарантия достигает только тех вызывающих, кто действительно берёт аренду — чтение без аренды по-прежнему идёт по незаблокированному быстрому пути, который стремителен и полностью незащищён. Это ядро координации, а не транзакционная база данных
В этих границах это небольшой честный примитив: девять отдельных регрессионных тестов покрывают несколько читателей, оба направления конфликта, реентерабельность, освобождение не по порядку, прерванные транзакции и межпоточные гонки чтение/запись и запись/запись — внутри набора из 1 328 тестов, проходящих на Win32 и Win64. Дополните это защищённым от сбоев поэтапным сохранением во временный файл, и фоновая выгрузка станет тем, о чём можно рассуждать от начала до конца, — согласованной при чтении, атомарной при записи. Аренды чтения, защиты записи и счётчик поколений входят в состав классического и пакетного движков в HotXLS Delphi Component для Delphi и C++Builder, без какой-либо настройки для их включения