HotXLS, ספריית רכיב האקסל הילידית עבור דלפי ו-C++Builder, מבצעת inflate לכמה גיליונות עבודה של XLSX בו-זמנית מתוך חבילת ZIP פתוחה יחידה. המנגנון הוא TZipReadGate, מחלקה קטנה ב-lxZipArchive.pas שמחזיקה את זרם החבילה בתוספת קטע קריטי אחד וחושפת בדיוק שיטה אחת. היא מסדרת את זוג ה-seek-and-read. כל מה שמעל הזוג הזה רץ במקביל
הבעיה שכפתה את העיצוב הזה היא כזו שכל מפתח דלפי שפתח אי-פעם חוברת עבודה גדולה נתקל בה. xlsx בגודל 80 מגה-בייט הוא 80 מגה-בייט של XML דחוס, וחלקי גיליון-העבודה בתוכו מתרחבים בערך פי חמש עד עשר. אם נתיב הפתיחה שלך מחלץ כל גיליון-עבודה לזרם זיכרון לפני פענוחו, אתה משלם על הבייטים המנופחים בנוסף לחוברת העבודה שאתה בונה, והשיא מגיע לפני שתא בודד נוצר. המאמר הזה עוסק במקביליות ברמת-החבילה שמסירה את שלב ההצבה הזה. תקרת המקצה שיושבת מעליה מכוסה בהמאמר על פענוח XLSX מקבילי ומנהל הזיכרון, וה-API של קריאה-פעם-אחת, אף פעם-לא-לממש מכוסה בהסקירה של הקורא הישיר הזורם
למה נתיב הפתיחה הישן העמיד כל גיליון-עבודה ב-RAM
הפתיחה המקבילית המקורית ב-HotXLS הייתה pipeline תלת-שלבי, והשלב האמצעי היה היחיד שרץ על workers. שלב A הלך על רשימת הגיליונות באורח סדרתי, יצר כל גיליון-עבודה, קרא את חלק היחס שלו, והעתיק את כל ה-XML של גיליון-העבודה המנופח לתוך TMemoryStream פרטי. שלב B פרש ParseWorksheetXml על פני מאגר ה-workers. שלב C חזר לארכיון על ה-thread הקורא עבור החלקים הלוויין הקטנים: הערות, הערות משורשרות, ציורים, תרשימים, טבלאות. הצורה הזו נבחרה מסיבה מוצהרת. הערת הכותרת ב-lxParallelParse.pas נהגה לומר, במילים רבות, שארכיון ה-zip ומצב ה-inflate שלו אינם thread-safe, וההערות הפנימיות הלכו רחוק יותר: אל תטרח לנעול את הארכיון, כי ברגע שמצב ה-inflate מסודר לכל רשומה, הנעילה לא קונה כלום. שלב A היה קיים כדי לשמור כל מגע בארכיון על thread אחד. העלות הייתה שחוברת עבודה עם שמונה גיליונות עמוסים החזיקה שמונה מאגרי XML של גיליונות-עבודה מנופחים לגמרי בזיכרון בו-זמנית, והמאגרים האלה הם האובייקטים החולפים הגדולים ביותר בכל נתיב הפתיחה
האם שני threads יכולים לבצע inflate מזרם ZIP אחד?
כן, וההערכה הישנה הייתה שגויה בדרך ספציפית וניתנת-לאיתור: היא קיפלה שני חתיכות מצב שונות למשפט אחד. מצב inflate באמת אינו ניתן-לשיתוף. z_stream של zlib נושא את החלון המחליק, טבלאות ה-Huffman ומיקום הסיביות עבור חבר דחוס אחד, ושני threads שדוחפים בייטים דרך אותו אחד מייצרים זבל. מקור הבייטים העומד בבסיס הוא שאלה שונה לגמרי, והתשובה שם היא שלזרם קובץ יש בדיוק חתיכת מצב-משותף-משתנה אחת ששווה להגן עליה, סמן המיקום שלו
קונטיינר ה-ZIP הופך את ההפרדה לחוקית. כל חבר בארכיון ZIP דחוס עצמאית: כותרת הקובץ המקומית שלו עצמו, זרם הביטים deflate שלו עצמו ב-DataOffset שלו עצמו, ה-CRC32 והגדלים שלו עצמו בספריית התוכן המרכזית. אין מילון משותף שמשתרע על פני חברים בדרך שבלוק 7z מוצק יש. תן לכל worker את z_stream משלו על פני טווח הבייטים שלו עצמו והדבר היחיד שהם מתנגשים עליו הוא ה-seek. ההתנגשות הזו היא מה ש-TZipReadGate מסירה, וכל המחלקה קצרה מספיק לקרוא על מסך אחד
type
TZipReadGate = class
private
FBaseStream: TStream;
FLock: TRTLCriticalSection;
public
constructor Create(ABaseStream: TStream);
destructor Destroy; override;
function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
end;
function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
Count: Longint): Longint;
begin
if Count <= 0 then
begin
Result := 0;
Exit;
end;
EnterCriticalSection(FLock);
try
FBaseStream.Position := AOffset;
Result := FBaseStream.Read(Buffer, Count);
finally
LeaveCriticalSection(FLock);
end;
end;
מה TZipReadGate מגן עליו ומה הוא בכוונה לא
TZipReadGate.ReadAt שומר על פעולה בלתי-מתחלקת אחת, מיקום הזרם המשותף וקריאה ממנו, ושום דבר אחר. TZipArchive.OpenArchive בונה את השער מעל FInputStream ברגע שספריית התוכן המרכזית ננתחה בהצלחה, ו-TZipArchive.Close משחררת אותו. ארכיונים שנפתחו לכתיבה אף פעם לא מקבלים אחד. כל קריאה ש-worker מבצע על החבילה עוברת לכן דרך קטע קריטי יחיד שמוחזק למשך קריאה מאוגרת אחת
כל השאר נשאר מחוץ לנעילה כי הוא כבר פרטי או כבר בלתי-משתנה. TZipSubStream שומרת את FPosition שלה עצמה, כך שכל worker עוקב אחרי המקום שלו עצמו ברשומה שלו עצמו. TZLibStream ש-TZipEntry.GetStream בונה מעל אותו זרם-משנה הוא לכל-רשומה, נוצר עם windowBits של 15- עבור deflate גולמי, ואף פעם לא משותף. ספריית התוכן המרכזית מנותחת במלואה לפני שכל worker מתחיל, כולל כל כותרת מקומית, כך ש-GetEntryByName הוא חיפוש hash לקריאה-בלבד עד שהמקביליות מתחילה. הניתוב עצמו הוא שלוש שורות ב-TZipSubStream.Read, וענף חסר-השער הוא מה ששומר על כל קורא חד-threaded קיים על נתיב הקוד הישן
function TZipSubStream.Read(var buffer; Count: longint): longint;
var
rest: Int64;
rc: longint;
begin
rest := FSize - FPosition;
if (Count > rest) then
Count := rest;
if FReadGate <> nil then
rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
else
begin
FBaseStream.Position := FOffset + FPosition;
rc := FBaseStream.Read(buffer, Count);
end;
FPosition := FPosition + rc;
Result := rc;
end;
כמה עולה השער תחת תחרות?
פחות ממה שהביטוי "נעילה גלובלית על הארכיון" מרמז, בגלל הגרנולריות ש-TZLibStream במקרה משתמשת בה. מאגר הקלט שלה הוא BufferSize, מוגדר כ-$4000, כך ש-ReadInputBuffer שולפת 16 קילו-בייט של בייטים דחוסים לכל מילוי ומוסרת אותם ל-zng_inflate. רכישת נעילה אחת לכן מכסה 16 קילו-בייט של קלט deflate, שעבור XML של גיליון-עבודה מתרחב לסדר גודל של 100 קילו-בייט markup שה-worker אז מפענח ומנתח בלי להחזיק שום דבר. הנעילה מוחזקת עבור קריאה ממוקמת כנגד מטמון מערכת ההפעלה; העבודה שהיא שוערת נמדדת במילישניות
הגבול הכן הוא היכן שהיחס הזה מתהפך. רשומות מאוחסנות ולא דחוסות נקראות דרך השער אחד-לאחד עם אין עבודת inflate להסתיר את ה-latency, כך שחבילה מלאה ברשומות מאוחסנות הייתה מסדרת הרבה יותר בקשיחות. קובץ קר על מדיה איטית מרחיב את הקטע הקריטי, כי הקריאה בתוכו היא עכשיו העברת דיסק אמיתית ולא פגיעת מטמון. ומעבר לקומץ workers השער אינו מה שאתה פוגע בו קודם בכל מקרה: ניתוח גיליון-עבודה כבד-הקצאה, ומנהל הזיכרון של דלפי מסדר הקצאות על פני threads הרבה לפני שהשער קורא הופך לאילוץ. זו הסיבה ש-TXLSXWorkbook.ParallelParseThreads ברירת המחדל שלה תקרה אוטומטית במקום thread אחד לכל ליבה
גוף ה-worker, ולולאת הניקוז שקל לשכוח
עם השער במקום, HotXLS מחקה את הצבת שלב A לגמרי. ה-worker עכשיו פותח את זרם הרשומה שלו עצמו ומזין אותו ישירות למפענח. שני שדות חולפים נושאים את הקלטים: FParZip מחזיק את הארכיון למשך שלב המקביליות, FParSheetPartNames מחזיק את שמות החלקים, ושניהם מנוקים בבלוק ה-finally כך שאין מצביע מיושן ששורד פתיחה כושלת. הזרם שחוזר מ-TZipArchive.OpenFile הוא TZipVerifiedStream שעוטף TZLibStream שעוטף TZipSubStream, ושחרור החיצוני משחרר את השרשרת
procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
Stream: TStream;
DrainBuffer: array [0..32767] of Byte;
PartName: WideString;
begin
PartName := WideString(FParSheetPartNames[AIndex]);
Stream := FParZip.OpenFile(PartName);
if Stream = nil then
Exit;
try
ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
// Consume any trailing bytes so the ZIP entry size and CRC are verified.
while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
;
finally
Stream.Free;
end;
end;
לולאת הניקוז היא הפרט שהעברה ישירה של הקוד הישן הייתה משמיטה, וההשמטה שלה משביתה בשקט את בדיקת השלמות. TZipVerifiedStream צוברת CRC32 מתגלגל בעודם בייטים עוברים, וקוראת ל-VerifyComplete רק כשהמיקום שלה מגיע לגודל הלא-דחוס שנרשם בספריית התוכן המרכזית; שם מגיעות חריגות אי-ההתאמה בגודל ואי-ההתאמה ב-CRC32, בתוספת קריאת בדיקה בת-בייט-אחד שתופסת רשומה ארוכה יותר מהמוצהר. קורא XML עוצר באלמנט הסוגר ובדרך כלל משאיר שורה-חדשה או כמה בייטים של רווח-לבן זנב לא-נקרא, כך שבלי הניקוז המיקום אף פעם לא מגיע לגודל המוצהר והבדיקות אף פעם לא מופעלות. קריאת השארית לתוך מאגר גירוד עולה כלום ומשחזרת אותן. כשזרמי ההצבה היו קיימים, XlsxCopyStreamAll עשתה זאת במקרה
מה עדיין רץ סדרתית, והדגל שמכבה את הכול
שלב A שורד, פחות החילוץ. הוא עדיין יוצר כל גיליון-עבודה וקורא את היחסים שלו על ה-thread הקורא, וזה מה שמשאיר כל מפה משותפת בלתי-משתנה ברגע ש-workers מתחילים. שלב C עדיין הולך על הגיליונות סדרתית אחר כך עבור הערות, ציורים, תרשימים וטבלאות, וההגנה שלו השתנתה מבדיקת null על מערך ההצבה הישן ל-zip.Exists כנגד שם החלק. הקלטים המשותפים לקריאה-בלבד ש-workers נוגעים בהם, טבלת המחרוזות המשותפת ומפות ה-cellXf, שלמים לפני ששלב B מתחיל ואף פעם לא נכתבים במהלכו
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
Wb.ParallelParse := True; // default; False forces one sheet at a time
Wb.ParallelParseThreads := 4; // 0 selects the automatic cap
Wb.Open('quarterly-consolidation.xlsx');
// ... workbook is identical either way ...
finally
Wb.Free;
end;
end;
קביעת ParallelParse ל-False לפני Open שולחת את אותה שגרת עבודה עם ספירת threads של אחד, ו-RunParallelJobs ניוונת ללולאה פשוטה על ה-thread הקורא. זה שווה ידיעה משתי סיבות: זו התשובה בשורה-אחת אם עניין threading אי-פעם עולה בשטח, וזה אומר שהנתיבים הסדרתי והמקבילי חולקים גוף אחד של קוד ניתוח במקום להיסחף. חריגות worker נלכדות, האינדקס העבודה הנמוך ביותר מנצח, והשגיאה מועלית מחדש על ה-thread הקורא אחרי שכל worker מצטרף, כך שגיליון-עבודה פגום עדיין עולה על פני השטח כחריגה אחת במקום הצפוי. כוונון כללי של נתיב הפתיחה הסובב מכוסה בהמדריך לביצועי חוברת עבודה גדולה בדלפי
שער הקריאה, שלב הפתיחה המקבילי וגישת הרשומה הזורמת המתוארים כאן משתלבים כחלק מרכיב HotXLS Excel הסטנדרטי עבור דלפי ו-C++Builder, עם קוד מקור מלא; עמוד המוצר נושא את הפניית TXLSXWorkbook המלאה כולל תכונות הפתיחה המקבילית