يفكّ HotXLS، مكتبة مكوّن Excel الأصلية لـ Delphi وC++Builder، ضغط عدة أوراق عمل XLSX في آن واحد من حزمة ZIP مفتوحة واحدة. الآلية هي TZipReadGate، فئة صغيرة في lxZipArchive.pas تحمل دفق الحزمة بالإضافة إلى قسم حرج واحد وتعرض طريقة واحدة بالضبط. تُسلسل زوج البحث والقراءة. كل شيء فوق ذلك الزوج يعمل بالتزامن
المشكلة التي أجبرت هذا التصميم واحدة يواجهها كل مطور Delphi فتح مصنّفًا كبيرًا. ملف xlsx بحجم 80 ميجابايت هو 80 ميجابايت من XML مضغوط بـdeflate، وأجزاء ورقة العمل بداخله تتوسع بمقدار خمسة إلى عشرة أضعاف تقريبًا. إذا كان مسار فتحك يستخرج كل ورقة عمل إلى دفق ذاكرة قبل تحليلها، تدفع ثمن البايتات المُتوسِّعة فوق المصنّف الذي تبنيه، وتصل الذروة قبل إنشاء خلية واحدة. هذا المقال عن التزامن على مستوى الحزمة الذي يُزيل خطوة التجهيز تلك. سقف المُخصِّص الذي يقع فوقه مشروح في مقال تحليل XLSX المتوازي ومدير الذاكرة، والواجهة البرمجية للقراءة مرة واحدة دون تجسيد أبدًا مشروحة في شرح القارئ المباشر المتدفق
لماذا كان مسار الفتح القديم يُجهّز كل ورقة عمل في الذاكرة
الفتح المتوازي الأصلي في HotXLS كان خط أنابيب من ثلاث مراحل، والمرحلة الوسطى كانت الوحيدة التي عملت على عمال. المرحلة أ مشت في قائمة الأوراق تسلسليًا، وأنشأت كل ورقة عمل، وقرأت جزء علاقتها، ونسخت XML ورقة العمل المفكوكة الضغط بأكملها إلى TMemoryStream خاص. المرحلة ب وزّعت ParseWorksheetXml عبر المجمّع. المرحلة ج عادت إلى الأرشيف على الخيط المستدعي للأجزاء التابعة الصغيرة: التعليقات، والتعليقات المُترابطة، والرسومات، والمخططات، والجداول. اختير ذلك الشكل لسبب معلن. تعليق الترويسة على lxParallelParse.pas كان يقول، بكلمات عديدة، إن أرشيف zip وحالة فك ضغطه ليسا آمنين للخيوط، وذهبت الملاحظات الداخلية أبعد: لا تكلّف نفسك عناء قفل الأرشيف، لأنه بمجرد تسلسل حالة فك الضغط لكل مدخل، لا يشتري القفل شيئًا. المرحلة أ وُجدت للإبقاء على كل لمسة أرشيف على خيط واحد. التكلفة كانت أن مصنّفًا بثماني أوراق نشطة يحمل ثماني مخازن مؤقتة XML لورقة عمل مفكوكة الضغط بالكامل في الذاكرة في آن واحد، وتلك المخازن المؤقتة هي أكبر الكائنات العابرة في مسار الفتح بأكمله
هل يستطيع خيطان فك الضغط من دفق ZIP واحد؟
نعم، وكان الحكم القديم خاطئًا بطريقة محددة يمكن تحديد موقعها: طوى قطعتين مختلفتين من الحالة في جملة واحدة. حالة فك الضغط ليست قابلة للمشاركة حقًا. يحمل z_stream من zlib النافذة المنزلقة، وجداول هوفمان، وموضع البت لعضو مضغوط واحد، وخيطان يدفعان بايتات عبر نفس الحالة يُنتجان هراءً. مصدر البايتات الأساسي سؤال مختلف تمامًا، والإجابة هناك أن دفق ملف له بالضبط قطعة واحدة من حالة مشتركة قابلة للتغيير تستحق الحماية، مؤشر موضعه
حاوية ZIP تجعل الفصل شرعيًا. كل عضو في أرشيف ZIP مضغوط بشكل مستقل: ترويسة ملفه المحلية الخاصة، ودفق بت deflate الخاص به عند DataOffset الخاص به، وCRC32 وأحجامه الخاصة في الفهرس المركزي. لا يوجد قاموس مشترك يمتد عبر الأعضاء بالطريقة التي تفعلها كتلة 7z الصلبة، لذا يمكن فك ضغط المدخل N دون لمس المدخل M. أعطِ كل عامل z_stream خاصًا به فوق نطاق بايت خاص به والشيء الوحيد الذي يتصادمان عليه هو البحث. ذلك التصادم هو ما تُزيله 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. الأرشيفات المفتوحة للكتابة لا تحصل على واحدة أبدًا. كل قراءة يُنفّذها عامل على الحزمة تمر إذن عبر قسم حرج واحد مُمسَك طوال مدة قراءة مُخزَّنة واحدة
كل شيء آخر يبقى خارج القفل لأنه خاص بالفعل أو غير قابل للتغيير بالفعل. تحتفظ TZipSubStream بـFPosition الخاص بها، لذا يتتبع كل عامل مكانه الخاص في مدخله الخاص. TZLibStream التي تبنيها TZipEntry.GetStream فوق ذلك الدفق الفرعي خاصة بكل مدخل، تُنشأ بـwindowBits يساوي -15 لـdeflate الخام، ولا تُشارك أبدًا. الفهرس المركزي يُحلَّل بالكامل قبل أن يبدأ أي عامل، بما في ذلك كل ترويسة محلية، لذا GetEntryByName بحث تجزئة للقراءة فقط بحلول وقت بدء التزامن. التوجيه نفسه ثلاثة أسطر في TZipSubStream.Read، والفرع بلا بوابة هو ما يُبقي كل مستدعٍ موجود أحادي الخيط على مسار الكود القديم
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 كيلوبايت من الترميز يفكّه العامل بعدها ويحلّله دون حمل أي شيء. القفل مُمسَك لقراءة مُعيَّنة الموضع مقابل ذاكرة التخزين المؤقت لنظام التشغيل؛ العمل الذي يُبوّبه يُقاس بالميلي ثانية
الحد الصادق هو حيث تنعكس تلك النسبة. المدخلات المُخزَّنة بدلًا من المضغوطة بـdeflate تُقرأ عبر البوابة واحدًا لواحد بلا عمل فك ضغط يُخفي زمن الوصول، لذا حزمة مليئة بأعضاء مُخزَّنين ستُسلسل بشدة أكبر. ملف بارد على وسائط بطيئة يوسّع القسم الحرج، لأن القراءة داخله الآن نقل قرص حقيقي لا إصابة ذاكرة تخزين مؤقت. وبعد حفنة من العمال ليست البوابة ما تصطدم به أولًا على أي حال: تحليل ورقة العمل كثيف التخصيص، ويُسلسل مدير ذاكرة Delphi التخصيصات عبر الخيوط قبل أن تصبح بوابة القراءة القيد بوقت طويل. لهذا السبب يفترض TXLSXWorkbook.ParallelParseThreads سقفًا تلقائيًا بدلًا من خيط واحد لكل نواة
متن العامل، وحلقة الاستنزاف السهلة النسيان
مع وجود البوابة، حذف HotXLS تجهيز المرحلة أ تمامًا. العامل الآن يفتح دفق مدخله الخاص ويُغذّيه مباشرةً إلى المُحلِّل. حقلان عابران يحملان المدخلات: يحمل 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 تفعل هذا صدفةً
ما الذي لا يزال يعمل تسلسليًا، والعلامة التي تُعطّل كل شيء
المرحلة أ تبقى، ناقصًا الاستخراج. لا تزال تُنشئ كل ورقة عمل وتقرأ علاقاتها على الخيط المستدعي، وهذا ما يترك كل خريطة مشتركة غير قابلة للتغيير بمجرد بدء العمال. المرحلة ج لا تزال تمشي في الأوراق تسلسليًا بعد ذلك للتعليقات، والرسومات، والمخططات، والجداول، وتغيّر حارسها من فحص فارغ على مصفوفة التجهيز القديمة إلى zip.Exists مقابل اسم الجزء. المدخلات المشتركة للقراءة فقط التي يلمسها العمال، جدول السلسلة المشترك وخرائط cellXf، مكتملة قبل بدء المرحلة ب ولا تُكتب أبدًا أثناءها
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 يُرسل نفس إجراء المهمة بعدد خيوط واحد، وتنحدر RunParallelJobs إلى حلقة عادية على الخيط المستدعي. هذا يستحق المعرفة لسببين: إنه الإجابة السطرية الواحدة إذا ظهر مخاوف تعدد الخيوط في الميدان، ويعني أن المسارين التسلسلي والمتوازي يتشاركان متن تحليل واحد بدلًا من الانحراف. استثناءات العمال تُلتقط، وأدنى فهرس مهمة يفوز، ويُعاد إثارة الخطأ على الخيط المستدعي بعد انضمام كل عامل، لذا لا تزال ورقة عمل تالفة تظهر كاستثناء واحد في المكان المتوقع. الضبط العام لمسار الفتح المحيط مشروح في دليل أداء المصنّفات الكبيرة في Delphi
بوابة القراءة، ومرحلة الفتح المتوازي، والوصول المتدفق للمدخل الموصوف هنا تُشحن كجزء من مكوّن HotXLS Excel القياسي لـ Delphi وC++Builder، مع الشيفرة المصدرية الكاملة؛ صفحة المنتج تحمل المرجع الكامل لـTXLSXWorkbook بما في ذلك خصائص الفتح المتوازي