מאמר טכני

חוזי קריאה ושומרי כתיבה של HotXLS לחוברות Delphi

תהליכון רקע היה מייצא דוח של 40,000 שורות כשתהליכון ה־UI קבע תא אחד, והקובץ שנחת על הדיסק לא תאם אף חוברת שהתקיימה אי פעם. HotXLS מטפל במחלקת הבאגים הזאת ב־lxWorkbookView.pas, שם IXLSWorkbookViewCore מנפיק חוזי קריאה של O(1) ושומרי כתיבה שנכשלים מהר: בזמן שחוזה פתוח, כל נקודת כניסת מוטציה זורקת במקום לכתוב

הכשל שמגיע בלי מחסנית קריאה

קריאת חוברת אינה פעולה אטומית אחת לעולם. מהלך דוח הוא עשרות אלפי קריאות תא בודדות שמתפרשות על שניות, ו־SetValue אחד שנוחת בין שתיים מהן מספיק לשנות את מה ששאר המהלך רואה. המנוע הקלאסי ממחיש זאת: TXLSCellRef.SetValue יכול לקרוא ל־FSST.Remove כדי להסיר רשומת מחרוזת משותפת, לאפס את FValueType ולפסול מצב מטמון נוסחה — והכול בזמן שתהליכון אחר באמצע דרפרנס בדיוק לאותן מבנים. שום דבר לא קורס במקום. אתם מקבלים דוח שסיכומי המשנה שלו אינם מסתדרים, או ייצוא שקורא בשקט מדד מחרוזת שמצביע עכשיו למקום אחר

HotXLS במכוון אינו פותר זאת בכך שהוא מגרם לכותבים לחכות. קורא יכול להחזיק חוברת משך שניות אחדות, ובאפליקציית VCL הכותב הוא לעיתים קרובות קולבק UI או מטפל אירוע בתהליכון הראשי — חסימת התהליכון הזה עד לסיום ייצוא רקע היא תוצאה גרועה מכשל של העריכה. לכן ליבת התיאום זורקת 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 אינו מבצע rollback לעריכה החלקית. המניין רושם שאף עסקה מוצלחת לא הושלמה, וזה בדיוק האות שמטמון צריך, אבל שחזור המודל למצבו הקודם אינו משהו ששומר עם מניין הפניות יכול לעשות עבורכם. אם כשל באמצע עסקה עלול להשאיר את החוברת בצורה שאינכם יכולים לשחרר, שמרו על קובץ המקור ופתחו אותו מחדש, במקום לסמוך על האובייקט בזיכרון

שני צירי זמן של עסקאות כתיבה של HotXLS בהשוואה: שומרים מקוננים בתהליכון אחד מגדילים את העומק ומקדמים את מונה הדורות רק כשהשומר החיצוני ביותר מושלם, בזמן שחריגה שמפרקת את השומרים בלי Complete משאירה את הדור ללא שינוי ואת העריכה החלקית במקומה
העומק עוקב אחר הקינון, אבל רק עסקה חיצונית שהושלמה מקדמת את הדור, ועסקה שנזנחה משאירה גם את המניין וגם את העריכה החלקית בדיוק היכן שהיו

מה מונה הדורות משתלם לכם

זיהוי התיישנות זול בלי סריקה. 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 אינם יודעים דבר זה על זה. שלישית, הערובה מגיעה רק למתקשרים שבאמת נוטלים חוזה — קריאה בלי חוזה עדיין הולכת בנתיב חם ללא מנעול, שהוא מהיר וחסר הגנה לחלוטין. זו ליבת תיאום, לא מסד נתונים טרנזקציוני

בשימוש בתחומי הגבולות האלה זהו פרימיטיב קטן וכן: תשע בדיקות רגרסיה ייעודיות מכסות קוראים מרובים, את שני כיווני ההתנגשות, חזרניות, שחרור שלא בסדר, עסקאות שנזנחו, ומירוצי קריאה/כתיבה וכתיבה/כתיבה חוצי תהליכונים, בתוך סוויטה של 1,328 בדיקות שעוברת ב־Win32 וב־Win64. שלבו אותו עם נתיב השמירה המדורג לקובץ זמני בטוח־מפני קריסה וייצוא רקע הופך לדבר שאפשר לחשוב עליו מקצה לקצה — עקבי בזמן שהוא קורא, אטומי כשהוא כותב. חוזי קריאה, שומרי כתיבה ומונה הדורות מגיעים כחלק מהמנועים הקלאסי והחבילה ב־HotXLS Delphi Component עבור Delphi ו־C++Builder, ללא צורך בהגדרה כלשהי כדי להפעילם