Технічна стаття

Read Leases і Write Guards для книг HotXLS у Delphi

Фоновий потік експортував звіт на 40 000 рядків, коли потік UI встановив одну клітинку, — і файл, що опинився на диску, не збігався з жодною книгою, яка коли-небудь існувала. HotXLS обробляє той клас багів у lxWorkbookView.pas, де IXLSWorkbookViewCore видає O(1) read leases і fail-fast write guards: поки lease відкритий, кожна точка входу мутації кидає виняток замість писати

Відмова, що приходить без трасування стека

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

HotXLS свідомо не розвʼязує це тим, що змушує письменників чекати. Читач може тримати книгу кілька секунд, а у VCL-застосунку письменник часто є UI-callback або обробником подій на головному потоці — блокувати той потік до завершення фонового експорту є гіршим результатом, ніж провалене редагування. Тож координаційне ядро кидає EXLSWorkbookWriteGuardUnavailable у мить, коли запис намагаються зробити проти відкритого lease, ще до того, як чіпано хоч одне поле, а викликач вирішує, чи поставити редагування в чергу, повторити, чи сказати користувачеві. Конфлікти fail-fast, а не в черзі

Координаційна матриця HotXLS показує, що read leases вільно співіснують, що запис проти відкритого lease кидає EXLSWorkbookWriteGuardUnavailable, що lease, запитаний усередині транзакції запису, кидає EXLSWorkbookReadLeaseUnavailable, і що два потоки-письменники ніколи не виключають одне одного
Читачі співіснують, а письменники проти них відмовляють швидко, але ядро ніколи не виключає один потік-письменник від іншого

Чи безпечно читати книгу з двох потоків?

Так, якщо обидва читачі тримають lease і ніхто не пише. IXLSWorkbookViewCore.AcquireReadLease бере TCriticalSection, збільшує лічильник, знімає знімок поточного покоління і повертає IXLSWorkbookReadLease — сталий час незалежно від того, чи книга тримає тисячу клітинок, чи мільйон. Будь-яка кількість lease співіснує, їх можна вивільняти в будь-якому порядку, і кожен тримає ядро живим через власне посилання на інтерфейс, тож lease, що переживе обʼєкт, який його створив, — це безпечно, а не звисаючий вказівник. Обидва двигуни беруть участь: TXLSWorkbook у lxHandle.pas і TXLSXWorkbook у lxHandleX.pas кожен будує ядро в конструкторі та виставляє _AcquireReadLease і _AcquireWriteGuard

Не менш важливе те, чого lease не додає до шляху читання. Критична секція покриває здобуття lease, вивільнення lease і межі транзакцій запису — нічого іншого. Звичайне читання клітинки за клітинкою ніколи не входить у блокування, монітор чи атомарний лічильник, тож тримання lease коштує одне здобуття й одне вивільнення на весь скан, а не одне на клітинку. Це той самий дизайнерський інстинкт, що за роботою з паралельним розбором XLSX і алокатором памʼяті: платіть за координацію на межі, ніколи у внутрішньому циклі. Симетричне правило теж діє — AcquireReadLease кидає EXLSWorkbookReadLeaseUnavailable щоразу, коли WriteDepth ненульовий, тож не можна відкрити lease зсередини транзакції запису, навіть на потоці, що пише

HotXLS платить за координацію на межі скану: критична секція покриває лише здобуття lease, вивільнення і межі транзакцій запису, тоді як write guard здобувається всередині TXLSCellRef.SetValue, тож кожен convenience API над ним оточений один раз
Одне здобуття й одне вивільнення покривають скан на пʼятдесят тисяч клітинок, а один guard усередині 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;
  // Lease виходить зі скоупу тут: його reference count падає до нуля,
  // ReleaseReadLease виконується, і письменники знову стають можливими
end;

Де write guard насправді сидить?

На найнижчому змінному шарі, ніколи на convenience API над ним. _AcquireWriteGuard кличеться зсередини самого TXLSCellRef.SetValue, а це означає, що кожен публічний шлях, який воронкою стікає в нього — Range.Value, присвоєння тексту аркуша, копіювання клітинка за клітинкою, вставка — оточений один раз, замість щоб кожен обгорток повторював перевірку, яку майбутній обгорток забуде. Покриття свідомо широке: 55 здобуттів guard у lxHandle.pas і 37 у lxHandleX.pas станом на партію, що ввела ядро

Оточена поверхня охоплює значення клітинок і форматування клітинок, TXLSWorkbook.Open, копіювання та вставку, defined names (Add, перейменування, RefersTo, Visible, IsMacro, Comment, Delete), метадані аркуша на кшталт Name, Zoom, Visible, StandardHeight, FreezePanes, Protect і Activate, setup сторінки, розриви сторінок і Calculate. Розміщення — вся суть: guard здобувається до того, як записано перше поле, а не валідовується після гачком сповіщень, тож відхилена мутація лишає модель байт-у-байт ідентичною. Регресійний набір стверджує рівно це, перечитуючи назву аркуша, зум, видимість, стандартну висоту, поля, орієнтацію та кількості розривів сторінок після кожного відхиленого виклику. Шляхи завантаження отримують те саме поводження на шар нижче, де ZIP read gate координує конкурентний inflate для пакетних форматів

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;
  // Лише завершений зовнішній guard просуває покоління
  WriteGuard.Complete;
end;

Чому вкладений запис просуває покоління лише один раз?

Бо транзакцію запису визначає найзовнішній guard на потоці, а не кожен guard окремо. Ядро тримає на-потоковий стан письменника з id потоку, глибиною та прапорцем завершення. Другий AcquireWriteGuard на тому самому потоці знаходить цей стан і збільшує Depth, замість створювати нову транзакцію, і лише коли Depth падає назад до нуля — з найзовнішнім guard, позначеним Complete, — FGeneration просувається. Саме це дозволяє високорівневій операції на кшталт Calculate чи Open кликати десять guarded-примітивів під собою і все одно зареєструватися як одна зміна. Внутрішні виклики Complete записуються, але самі не рухають лічильник, а guard можна вивільняти не за порядком, не ламаючи облік

Напрям відмови так само явний. Якщо guard вивільнено без Complete — звичайний наслідок винятку, що розмотує посилання на інтерфейс, — покоління не просувається, бо транзакція запису ніколи не заявляла успіху. Тверезо дивіться, що це означає: HotXLS не відкочує часткове редагування назад. Лічильник записує, що жодна успішна транзакція не завершилася — рівно той сигнал, якого потребує кеш, — але відновлення моделі в попередній стан не є тим, що guard з підрахунком посилань зробить за вас. Якщо відмова посеред транзакції може лишити книгу в формі, яку не можна випускати, тримайте вихідний файл і відкрийте його заново, замість довіряти обʼєкту в памʼяті

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

Що вам купує лічильник поколінь

Дешеве виявлення застарілості без сканування. Generation — це UInt64, що стартує з 1 і пропускає 0 при обході кола, тож 0 — ніколи не значення, яке видає ядро, і працює як надійний сентинел «ніколи не спостерігалося». Дві інваріанти роблять його придатним: покоління не може рухатися, поки існує хоч один read lease, і кожна успішна транзакція запису збільшує його рівно один раз. Тож IXLSWorkbookReadLease.Generation — знімок, сталий протягом усього життя lease, а 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;

Чого ця координація не обіцяє

Три межі варто сказати просто, бо припущення інакше — це спосіб, яким механізм зловживають. Перше: write guard — не взаємне виключення між письменниками: ядро виключає читачів проти письменників, і два різні потоки можуть кожен тримати write guard водночас, кожен просуваючи покоління незалежно — регресійний тест стверджує рівно цю поведінку. Серіалізувати власні потоки-письменники — все ще ваша робота. Друге: нічого тут не є файловим блокуванням чи міжпроцесним мʼютексом; воно координує потоки всередині одного процесу проти одного примірника книги, і два процеси, що відкривають той самий .xlsx, не знають нічого одне про одного. Третє: гарантія сягає лише викликачів, які справді беруть lease — read без lease досі ходить незаблокованим гарячим шляхом, який швидкий і цілком незахищений. Це координаційне ядро, а не транзакційна база даних

У межах цих обмежень це маленький чесний примітив: девʼять спеціальних регресійних тестів покривають кількох читачів, обидва напрямки конфлікту, повторний вхід, вивільнення не за порядком, перервані транзакції та перехресно-потокові змагання читання/запису і запису/запису, всередині набору з 1 328 тестів, що проходять на Win32 і Win64. Поставте його поруч із шляхом збереження через crash-safe staged temp-файли, і фоновий експорт стає тим, про що можна міркувати від початку до кінця — узгоджений під час читання, атомарний при записі. Read leases, write guards і лічильник поколінь постачаються як частина класичного та пакетного двигунів у HotXLS Delphi Component для Delphi і C++Builder, без жодної конфігурації для ввімкнення