Техническая статья

Аренды чтения и защита записи в книгах HotXLS для Delphi

Фоновый поток выгружал отчёт на 40 000 строк, когда поток интерфейса изменил одну ячейку — и файл, оказавшийся на диске, не совпадал ни с одной из когда-либо существовавших книг. HotXLS закрывает этот класс ошибок в lxWorkbookView.pas, где IXLSWorkbookViewCore выдаёт аренды чтения за O(1) и защиты записи с быстрым отказом: пока аренда открыта, каждая точка входа для изменения вызывает исключение вместо записи

Сбой, который приходит без трассировки стека

Чтение книги никогда не является одной атомарной операцией. Обход отчёта — это десятки тысяч отдельных чтений ячеек, растянутых на секунды, и одного SetValue, пришедшегося между двумя из них, достаточно, чтобы изменить то, что увидит оставшаяся часть обхода. Классический движок делает это осязаемым: TXLSCellRef.SetValue может вызвать FSST.Remove, чтобы удалить запись общей строки, сбросить FValueType и инвалимировать состояние кэша формул — и всё это пока другой поток находится посреди разыменования именно этих структур. Ничего не падает на месте. Вы получаете отчёт, в котором промежуточные итоги не сходятся, или выгрузку, которая молча читает строковый индекс, указывающий уже в другое место

HotXLS сознательно не решает эту проблему тем, что заставляет пишущие потоки ждать. Читатель может держать книгу несколько секунд, а в VCL-приложении пишущим часто является обратный вызов интерфейса или обработчик события в главном потоке — блокировать этот поток до завершения фоновой выгрузки хуже, чем отклонить изменение. Поэтому ядро координации вызывает EXLSWorkbookWriteGuardUnavailable в момент попытки записи при открытой аренде, до касания первого поля, а вызывающий решает, поставить ли изменение в очередь, повторить или сообщить пользователю. Конфликты завершаются быстро, а не встают в очередь

Матрица координации HotXLS: аренды чтения свободно сосуществуют, запись при открытой аренде вызывает EXLSWorkbookWriteGuardUnavailable, аренда внутри транзакции записи вызывает EXLSWorkbookReadLeaseUnavailable, а два пишущих потока никогда не исключают друг друга
Читатели сосуществуют, а пишущие потоки быстро завершаются с ошибкой, но ядро никогда не исключает один пишущий поток из другого

Безопасно ли читать книгу из двух потоков?

Да, если оба читателя держат аренду и никто не пишет. IXLSWorkbookViewCore.AcquireReadLease захватывает TCriticalSection, увеличивает счётчик, фиксирует текущее поколение и возвращает IXLSWorkbookReadLease — за постоянное время, независимо от того, тысяча ячеек в книге или миллион. Любое число аренд сосуществует, освобождать их можно в любом порядке, и каждая удерживает ядро живым через собственную ссылку на интерфейс, поэтому аренда, пережившая создавший её объект, безопасна, а не является висячим указателем. Участвуют оба движка: TXLSWorkbook в lxHandle.pas и TXLSXWorkbook в lxHandleX.pas создают ядро в конструкторе и предоставляют _AcquireReadLease и _AcquireWriteGuard

Не менее важно то, чего аренда не добавляет на путь чтения. Критическая секция покрывает захват аренды, её освобождение и границы транзакций записи — и ничего больше. Обычное чтение отдельной ячейки никогда не входит в блокировку, монитор или атомарный счётчик, поэтому удержание аренды стоит один захват и одно освобождение на весь проход, а не по одному на ячейку. Это тот же замысел, что стоит за работой по параллельному разбору XLSX и аллокатору памяти: платить за координацию на границе, но никогда во внутреннем цикле. Симметричное правило тоже действует — AcquireReadLease вызывает EXLSWorkbookReadLeaseUnavailable всегда, когда WriteDepth не равен нулю, поэтому аренду нельзя открыть внутри транзакции записи, даже в пишущем потоке

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, присваивание текста листа, копирование ячейка за ячейкой, вставка — проверяется один раз, вместо того чтобы каждый обёрточный слой повторял проверку, которую будущий слой забудет. Покрытие сознательно широкое: 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 не откатывает частичное изменение. Счётчик фиксирует, что ни одна успешная транзакция не завершилась, — именно этот сигнал и нужен кэшу, — но восстановление модели в прежнее состояние подсчётом ссылок вам не обеспечит. Если сбой посреди транзакции может оставить книгу в виде, который вы не сможете отгрузить, храните исходный файл и открывайте его заново, вместо того чтобы доверять объекту в памяти

Сравнение двух временных шкал транзакций записи HotXLS: вложенные защиты на одном потоке увеличивают глубину и сдвигают счётчик поколений только когда самая внешняя защита завершается, а исключение, раскручивающее защиты без Complete, оставляет поколение неизменным и частичное изменение на месте
Глубина отслеживает вложенность, но только завершённая внешняя транзакция сдвигает поколение, а прерванная оставляет и счётчик, и частичное изменение ровно там, где они были

Что даёт вам счётчик поколений

Дешёвое обнаружение устаревания без сканирования. 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, без какой-либо настройки для их включения