مقاله فنی

لیز خواندن و گارد نوشتن HotXLS برای کتاب‌کار Delphi

یک thread پس‌زمینه داشت گزارشی 40,000 سطری را خروجی می‌گرفت که thread UI یک سلول ست کرد، و فایلی که روی دیسک فرود آمد با هیچ کتاب‌کاری که تا به حال وجود داشت match نمی‌شد. HotXLS آن دسته از باگ‌ها را در lxWorkbookView.pas مدیریت می‌کند، جایی که IXLSWorkbookViewCore لیزهای خواندن O(1) و گاردهای نوشتن fail-fast صادر می‌کند: تا وقتی یک لیز باز است، هر مدخل جهش به‌جای نوشتن پرتاب می‌کند

خرابی‌ای که بدون stack trace می‌رسد

خواندن یک کتاب‌کار هرگز یک عملیات اتمیک نیست. یک پیمایش گزارش ده‌ها هزار خواندن سلول تکی است که روی چند ثانیه پخش شده، و یک SetValue تکی که بین دو تای آن‌ها فرود بیاید کافی است تا آنچه باقی پیمایش می‌بیند را عوض کند. موتور کلاسیک این را عینی می‌کند: TXLSCellRef.SetValue می‌تواند FSST.Remove را صدا بزند تا یک مدخل shared string را بیندازد، FValueType را ریست کند، و یک state کش فرمول را نامعتبر کند، همگی در حالی که thread دیگری درست وسط dereference کردن همان ساختارهاست. هیچ چیز همان لحظه کرش نمی‌کند. گزارشی تحویل می‌گیرید که زیرجمع‌هایش جمع نمی‌شود، یا خروجی‌ای که بی‌سروصدا یک ایندکس رشته را می‌خواند که حالا به جای دیگری اشاره می‌کند

HotXLS عمداً این را با منتظر گذاشتن نویسنده‌ها حل نمی‌کند. یک خواننده می‌تواند کتاب‌کار را چند ثانیه نگه دارد، و در یک برنامه VCL نویسنده اغلب یک callback رابط کاربری یا یک event handler روی thread اصلی است — بلاک کردن آن thread تا تمام شدن یک خروجی پس‌زمینه بدتر از شکست خوردن ویرایش است. پس هسته هماهنگی EXLSWorkbookWriteGuardUnavailable را همان لحظه‌ای که نوشتن روی یک لیز باز تلاش می‌شود پرتاب می‌کند، پیش از آنکه حتی یک فیلد لمس شده باشد، و فراخوان تصمیم می‌گیرد ویرایش را صف کند، دوباره تلاش کند، یا به کاربر بگوید. تعارض‌های fail-fast، نه صف‌شده

ماتریس هماهنگی HotXLS که نشان می‌دهد لیزهای خواندن آزادانه همزیستی دارند، نوشتن روی یک لیز باز EXLSWorkbookWriteGuardUnavailable پرتاب می‌کند، لیز درخواستی داخل یک تراکنش نوشتن EXLSWorkbookReadLeaseUnavailable پرتاب می‌کند، و دو thread نویسنده هرگز از هم مستثنی نمی‌شوند
خواننده‌ها همزیستی دارند و نویسنده‌ها برخلافشان سریع شکست می‌خورند، اما هسته هرگز یک thread نویسنده را از دیگری مستثنی نمی‌کند

آیا یک کتاب‌کار از دو thread خواندنش امن است؟

بله، به شرطی که هر دو خواننده لیز داشته باشند و هیچ‌کس ننویسد. IXLSWorkbookViewCore.AcquireReadLease یک TCriticalSection می‌گیرد، یک شمارنده را افزایش می‌دهد، از نسل جاری snapshot می‌گیرد، و یک IXLSWorkbookReadLease برمی‌گرداند — زمان ثابت بدون توجه به اینکه کتاب‌کار هزار سلول دارد یا یک میلیون. هر تعداد لیز همزیستی دارند، می‌توانند به هر ترتیبی آزاد شوند، و هر کدام از طریق ارجاع اینترفیسی خودش هسته را زنده پین می‌کند، پس لیزی که از شیء سازنده‌اش دیرتر می‌میرد امن است نه یک اشاره‌گر آویزان. هر دو موتور مشارکت می‌کنند: TXLSWorkbook در lxHandle.pas و TXLSXWorkbook در lxHandleX.pas هر کدام در سازنده‌شان یک هسته می‌سازند و _AcquireReadLease و _AcquireWriteGuard را در معرض می‌گذارند

به همان اندازه مهم این است که لیز چه چیزی را به مسیر خواندن اضافه نمی‌کند. بخش بحرانی تخصیص لیز، آزادسازی لیز و مرزهای تراکنش نوشتن را پوشش می‌دهد — هیچ چیز دیگر را. خواندن معمولی به ازای هر سلول هرگز وارد قفل یا مانیتور یا شمارنده اتمیک نمی‌شود، پس نگه داشتن یک لیز برای کل اسکن هزینه یک تخصیص و یک آزادسازی دارد، نه یکی به ازای هر سلول. همان غریزه طراحی پشت کار تجزیه موازی XLSX و تخصیص‌دهنده حافظه است: هزینه هماهنگی را در مرز بدهید، هرگز در حلقه داخلی نه. قاعده متقارن هم برقرار است — AcquireReadLease هر زمان که WriteDepth ناصفر باشد EXLSWorkbookReadLeaseUnavailable پرتاب می‌کند، پس نمی‌توانید از داخل یک تراکنش نوشتن لیز باز کنید، حتی روی thread نویسنده نه

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;
  // لیز این‌جا از scope خارج می‌شود: شمارنده ارجاعش به صفر می‌رسد،
  // ReleaseReadLease اجرا می‌شود، و نوشتن‌ها دوباره ممکن می‌شوند
end;

گارد نوشتن واقعاً کجا می‌نشیند؟

در پایین‌ترین لایه قابل جهش، هرگز در API راحتی بالای آن نه. _AcquireWriteGuard از داخل خود TXLSCellRef.SetValue صدا زده می‌شود، یعنی هر مسیر عمومی که به آن قیف می‌شود — Range.Value، تخصیص متن worksheet، کپی سلول به سلول، paste — یک بار گیت می‌شود به‌جای اینکه هر wrapper چکی را تکرار کند که یک wrapper آینده فراموشش خواهد کرد. پوشش عمداً وسیع است: 55 تخصیص گارد در lxHandle.pas و 37 تا در lxHandleX.pas تا زمان بچی که هسته را معرفی کرد

سطح گیت‌شده مقادیر سلول و قالب‌بندی سلول را در بر می‌گیرد، TXLSWorkbook.Open، کپی و paste، نام‌های تعریف‌شده (Add، تغییر نام، RefersTo، Visible، IsMacro، Comment، Delete)، متادیتای worksheet مثل Name و Zoom و Visible و StandardHeight و FreezePanes و Protect و Activate، page setup، شکست‌های صفحه، و Calculate. جای‌گذاری تمام نکته است: گارد قبل از نوشتن اولین فیلد تخصیص می‌یابد، نه اینکه بعداً با یک notification hook اعتبارسنجی شود، پس یک جهش ردشده مدل را بایت به بایت یکسان باقی می‌گذارد. سوئیت regression دقیقاً همین را assert می‌کند و بعد از هر فراخوانی ردشده نام شیت و zoom و visibility و ارتفاع استاندارد و حاشیه‌ها و orientation و شمارش شکست صفحه را دوباره می‌خواند. مسیرهای لود یک لایه پایین‌تر همان درمان را می‌گیرند، جایی که گیت خواندن ZIP همزمان‌سازی 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;
  // فقط یک گارد بیرونی‌ترینِ تکمیل‌شده نسل را جلو می‌برد
  WriteGuard.Complete;
end;

چرا یک نوشتن تودرتو نسل را فقط یک بار جلو می‌برد؟

چون تراکنش نوشتن توسط بیرونی‌ترین گارد روی یک thread تعریف می‌شود، نه توسط هر گارد به‌تنهایی. هسته یک state نویسنده به ازای هر thread نگه می‌دارد که thread id و عمق و فلگ تکمیل را حمل می‌کند. یک AcquireWriteGuard دوم روی همان thread آن state را پیدا می‌کند و به‌جای ساختن تراکنش جدید Depth را افزایش می‌دهد، و فقط وقتی Depth به صفر برگردد — با گارد بیرونی‌ترینی که Complete علامت خورده — FGeneration جلو می‌رود. همین چیزی است که اجازه می‌دهد یک عملیات سطح بالا مثل Calculate یا Open ده پریمیتیو گیت‌شده در زیر صدا بزند و همچنان به‌شکل یک تغییر ثبت شود. فراخوان‌های Complete داخلی ثبت می‌شوند اما خودشان شمارنده را جلو نمی‌برند، و گاردها می‌توانند خارج از ترتیب آزاد شوند بدون شکستن حسابداری

جهت خرابی به همان اندازه صریح است. اگر گاردی بدون Complete آزاد شود — نتیجه معمولِ unwind شدن ارجاع اینترفیسی توسط یک استثنا — نسل جلو نمی‌رود، چون تراکنش نوشتن هرگز ادعای موفقیت نکرده. روشن باشید درباره اینکه این یعنی چه: HotXLS ویرایش ناتمام را rollback نمی‌کند. شمارنده ثبت می‌کند که هیچ تراکنش موفقی تکمیل نشده، که دقیقاً همان سیگنالی است که یک کش نیاز دارد، اما برگرداندن مدل به state قبلی چیزی نیست که یک گارد شمارنده-ارجعی از جانب شما انجام دهد. اگر یک شکست وسط تراکنش می‌تواند کتاب‌کار را در شکلی بگذارد که قابل ارسال نیست، فایل مبدأ را نگه دارید و دوباره بازش کنید، به‌جای اعتماد به شیء درون-حافظه‌ای

مقایسه دو جدول زمانی تراکنش نوشتن HotXLS: گاردهای تودرتو روی یک thread عمق را بالا می‌برند و شمارنده نسل را فقط وقتی گارد بیرونی‌ترین تکمیل می‌شود جلو می‌برند، در حالی که استثنایی که گاردها را بدون Complete unwind می‌کند نسل را بدون تغییر و ویرایش ناتمام را سر جایش باقی می‌گذارد
عمق تودرتو را دنبال می‌کند، اما فقط یک تراکنش بیرونی‌ترینِ تکمیل‌شده نسل را جلو می‌برد، و تراکنش لغوشده هم شمارنده و هم ویرایش ناتمام را دقیقاً سر جای قبلی‌شان باقی می‌گذارد

شمارنده نسل چه چیزی برای شما می‌خرد

تشخیص کهنگی ارزان بدون اسکن. Generation یک UInt64 است که از 1 شروع می‌شود و هنگام دور زدن 0 را رد می‌کند، پس 0 هرگز مقداری نیست که هسته صادر کند و به‌شکل سنتینل قابل اعتمادِ «هرگز مشاهده نشده» کار می‌کند. دو ناوردایی آن را قابل استفاده می‌کنند: نسل تا وقتی هر لیز خواندنی وجود دارد نمی‌تواند جابه‌جا شود، و هر تراکنش نوشتن موفق دقیقاً یک بار آن را افزایش می‌دهد. پس IXLSWorkbookReadLease.Generation یک snapshot است که کل عمر لیز ثابت می‌ماند، و IXLSWorkbookWriteGuard.StartGeneration به نویسنده می‌گوید مدل وقتی تراکنشش باز شد چه شکلی داشت. یک گرید، یک پیش‌نمایش چاپ، یا یک ایندکس مشتق می‌تواند یک عدد صحیح را مقایسه کند به‌جای diff گرفتن سطرها

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration از 0 شروع می‌شود، مقداری که هسته هرگز صادر نمی‌کند،
  // پس همان اولین pass همیشه دوباره می‌سازد
end;

این هماهنگی چه چیزی را وعده نمی‌دهد

سه محدودیت ارزش بیان صریح دارند، چون فرض خلاف آن‌ها همان چیزی است که مکانیزم را سوءاستفاده‌پذیر می‌کند. اول، یک گارد نوشتن تفکیک متقابل بین نویسنده‌ها نیست: هسته خواننده‌ها را بر بر نویسنده‌ها مستثنی می‌کند، و دو thread متفاوت می‌توانند همزمان هر کدام یک گارد نوشتن داشته باشند، هر کدام نسل را مستقل جلو ببرند — یک تست regression دقیقاً همین رفتار را assert می‌کند. سریال کردن threadهای نویسنده خودتان همچنان کار شماست. دوم، هیچ‌چیز این‌جا یک file lock یا mutex بین‌پردازشی نیست؛ threadهای داخل یک پروسه را بر بر یک نمونه کتاب‌کار هماهنگ می‌کند، و دو پروسه که همان .xlsx را باز می‌کنند هیچ چیز درباره هم نمی‌دانند. سوم، تضمین فقط به فراخوان‌هایی می‌رسد که واقعاً لیز می‌گیرند — یک خواندن بدون لیز همچنان از یک مسیر داغ بدون قفل عبور می‌کند، که سریع است و کاملاً محافظت‌نشده. این یک هسته هماهنگی است، نه یک دیتابیس تراکنشی

درون همان محدوده‌ها یک پریمیتیو کوچک و صادقانه است: نه تست regression اختصاصی خواننده‌های متعدد و هر دو جهت تعارض و reentrancy و آزادسازی خارج از ترتیب و تراکنش‌های لغوشده و رقابت‌های خواندن/نوشتن و نوشتن/نوشتن بین-thread را پوشش می‌دهند، داخل سوئیتی از 1,328 تست که روی Win32 و Win64 پاس می‌شود. آن را با مسیر ذخیره مرحله‌ای فایل موقت crash-safe جفت کنید و یک خروجی پس‌زمینه به چیزی تبدیل می‌شود که می‌توانید سر تا ته درباره‌اش استدلال کنید — سازگار وقتی می‌خواند، اتمیک وقتی می‌نویسد. لیزهای خواندن و گاردهای نوشتن و شمارنده نسل به‌عنوان بخشی از موتورهای کلاسیک و پکیجی در HotXLS Delphi Component برای Delphi و C++Builder عرضه می‌شوند، بدون هیچ پیکربندی برای فعال کردنشان