مقاله فنی

Inflate همزمان ZIP در Delphi: Read Gate در HotXLS

HotXLS، کتابخانه بومی کامپوننت Excel برای Delphi و C++Builder، چند کاربرگ XLSX را همزمان از یک بسته ZIP باز واحد inflate می‌کند. مکانیزم آن TZipReadGate است، یک کلاس کوچک در lxZipArchive.pas که stream بسته به‌علاوه یک critical section را نگه می‌دارد و دقیقاً یک متد ارائه می‌دهد. آن جفت seek-and-read را سریال‌سازی می‌کند. هرچیز بالای آن جفت همزمان اجرا می‌شود

مشکلی که این طراحی را اجباری کرد چیزی است که هر توسعه‌دهنده دلفی که یک workbook بزرگ باز کرده با آن روبرو شده. یک xlsx ۸۰ مگابایتی ۸۰ مگابایت XML deflate‌شده است، و بخش‌های کاربرگ داخل آن تقریباً پنج تا ده برابر گسترش می‌یابند. اگر مسیر باز شما هر کاربرگ را پیش از parse کردن در یک memory stream استخراج کند، برای بایت‌های باد‌کرده روی سرِ workbookی که می‌سازید هزینه می‌دهید، و اوج پیش از ساخت یک سلول واحد می‌رسد. این مقاله درباره همزمانی سطح-بسته است که آن گام staging را حذف می‌کند. سقف allocator که بالای آن نشسته در مقاله parse موازی XLSX و مدیر حافظه پوشش داده شده، و API خواندن-یک‌بار و هرگز-تحقق‌نبخشیدن در بررسی reader مستقیم streaming پوشش داده شده

چرا مسیر باز قدیمی هر کاربرگ را در RAM staging می‌کرد

باز موازی اصلی در HotXLS یک pipeline سه‌فازی بود، و فاز میانی تنها فازی بود که روی workerها اجرا می‌شد. فاز A فهرست sheet را به‌صورت سریال طی می‌کرد، هر کاربرگ را می‌ساخت، بخش relationship آن را می‌خواند، و کل XML کاربرگ inflate‌شده را در یک TMemoryStream خصوصی کپی می‌کرد. فاز B ParseWorksheetXml را روی pool پخش می‌کرد. فاز C برای بخش‌های ماهواره‌ای کوچک به آرشیو روی thread فراخواننده برمی‌گشت: commentها، commentهای threaded، drawingها، chartها، جدول‌ها. آن شکل به دلیلی اعلام‌شده انتخاب شده بود. کامنت هدر روی lxParallelParse.pas قبلاً به وضوح می‌گفت آرشیو zip و وضعیت inflate آن thread-safe نیستند، و یادداشت‌های داخلی فراتر می‌رفتند: زحمت قفل کردن آرشیو را نکشید، چون به‌محض اینکه وضعیت machine inflate به‌ازای هر entry سریال شود، قفل هیچ چیزی نمی‌خرد. فاز A وجود داشت تا هر لمس آرشیو را روی یک thread نگه دارد. هزینه این بود که یک workbook با هشت sheet شلوغ همزمان هشت بافر XML کاربرگ کاملاً inflate‌شده در حافظه نگه می‌داشت، و آن بافرها بزرگ‌ترین اشیاء گذرا در کل مسیر باز هستند

آیا دو thread می‌توانند از یک stream ZIP inflate کنند؟

بله، و قضاوت قدیمی به روشی خاص و قابل‌مکان‌یابی اشتباه بود: دو تکه وضعیت متفاوت را در یک جمله فرو ریخت. وضعیت inflate واقعاً غیرقابل‌اشتراک است. یک z_stream در zlib پنجره لغزان، جداول Huffman و موقعیت بیت را برای یک عضو فشرده حمل می‌کند، و دو thread که بایت‌ها را از میان همان یکی می‌رانند مزخرف تولید می‌کنند. منبع بایت زیرین یک سؤال کاملاً متفاوت است، و پاسخ اینجا این است که یک file stream دقیقاً یک تکه وضعیت مشترک قابل‌تغییر دارد که ارزش محافظت دارد، cursor موقعیت آن

container ZIP آن جدایی را قانونی می‌کند. هر عضو در یک آرشیو ZIP مستقلاً فشرده می‌شود: هدر فایل محلی خودش، جریان بیت deflate خودش در DataOffset خودش، CRC32 و اندازه‌های خودش در central directory. هیچ دیکشنری مشترکی که در سراسر اعضا گسترده باشد آن‌طور که یک بلوک solid 7z دارد وجود ندارد، پس entry N می‌تواند بدون لمس entry M inflate شود. به هر 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 یک عملیات تجزیه‌ناپذیر را نگهبانی می‌کند، موقعیت‌دهی stream مشترک و خواندن از آن، و هیچ‌چیز دیگر. TZipArchive.OpenArchive gate را روی FInputStream پس از parse موفق central directory می‌سازد، و TZipArchive.Close آن را آزاد می‌کند. آرشیوهای باز‌شده برای نوشتن هرگز یکی نمی‌گیرند. هر خواندنی که یک worker روی بسته انجام می‌دهد بنابراین از یک critical section واحد که برای مدت یک خواندن بافرشده نگه داشته شده عبور می‌کند

هرچیز دیگر بیرون از قفل می‌ماند چون از قبل خصوصی یا از قبل تغییرناپذیر است. TZipSubStream FPosition خودش را نگه می‌دارد، پس هر worker جای خودش را در entry خودش ردیابی می‌کند. TZLibStreamای که TZipEntry.GetStream روی آن sub-stream می‌سازد به‌ازای هر entry است، با windowBits برابر -15 برای deflate خام ساخته می‌شود، و هرگز به اشتراک گذاشته نمی‌شود. central directory پیش از شروع هر workerی کاملاً parse شده، شامل هر هدر محلی، پس GetEntryByName تا زمانی که همزمانی شروع می‌شود یک جستجوی hash فقط-خواندنی است. مسیردهی خودش سه خط در TZipSubStream.Read است، و شاخه بدون-gate چیزی است که هر فراخواننده تک-threadای موجود را روی مسیر کد قدیمی نگه می‌دارد

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;

gate تحت تنازع چقدر هزینه دارد؟

کمتر از چیزی که عبارت "قفل سراسری روی آرشیو" پیشنهاد می‌دهد، به‌خاطر دانه‌بندی‌ای که TZLibStream اتفاقاً استفاده می‌کند. بافر ورودی آن BufferSize است، تعریف‌شده به‌عنوان $4000، پس ReadInputBuffer ۱۶ کیلوبایت بایت فشرده به‌ازای هر refill می‌کشد و آن‌ها را به zng_inflate می‌دهد. یک تصرف قفل بنابراین ۱۶ کیلوبایت ورودی deflate را پوشش می‌دهد، که برای XML کاربرگ به چیزی در حدود ۱۰۰ کیلوبایت markup گسترش می‌یابد که worker سپس بدون نگه داشتن هیچ‌چیزی decode و parse می‌کند. قفل برای یک خواندن موقعیت‌دهی‌شده در برابر cache سیستم‌عامل نگه داشته می‌شود؛ کاری که gate می‌کند به میلی‌ثانیه اندازه‌گیری می‌شود

مرز صادقانه جایی است که آن نسبت معکوس می‌شود. entryهای ذخیره‌شده به‌جای deflate‌شده یک‌به‌یک از gate بدون هیچ کار inflateای برای پنهان کردن تأخیر عبور می‌کنند، پس یک بسته پر از اعضای ذخیره‌شده بسیار سخت‌تر سریال می‌شد. یک فایل سرد روی رسانه کند critical section را پهن‌تر می‌کند، چون خواندن داخل آن اکنون یک transfer دیسک واقعی است نه یک برخورد cache. و فراتر از یک مشت worker، gate اصلاً چیزی نیست که ابتدا به آن برخورد می‌کنید: parse کردن کاربرگ سنگین-تخصیص است، و مدیر حافظه دلفی تخصیصات را در سراسر threadها خیلی پیش از آنکه read gate به محدودیت تبدیل شود سریال می‌کند. به همین دلیل TXLSXWorkbook.ParallelParseThreads به‌طور پیش‌فرض یک سقف خودکار است به‌جای یک thread به‌ازای هر هسته

بدنه worker، و حلقه drain که فراموش کردنش آسان است

با gate در جای خودش، HotXLS staging فاز A را کاملاً حذف کرد. worker اکنون stream entry خودش را باز می‌کند و آن را مستقیماً به parser می‌دهد. دو فیلد گذرا ورودی‌ها را حمل می‌کنند: FParZip آرشیو را برای مدت فاز موازی نگه می‌دارد، FParSheetPartNames نام‌های بخش را نگه می‌دارد، و هر دو در بلوک finally پاک می‌شوند پس هیچ pointer کهنه‌ای از یک باز‌شدن ناموفق جان سالم به در نمی‌برد. streamای که از 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;

حلقه drain جزئیاتی است که یک port مستقیم کد قدیمی حذفش می‌کرد، و حذف خاموش آن بررسی صحت را غیرفعال می‌کند. TZipVerifiedStream یک CRC32 در حال اجرا را همان‌طور که بایت‌ها عبور می‌کنند انباشته می‌کند و VerifyComplete را فقط زمانی فراخوانی می‌کند که موقعیت آن به اندازه فشرده‌نشده ثبت‌شده در central directory برسد؛ آنجاست که exceptionهای عدم‌تطابق اندازه و عدم‌تطابق CRC32 می‌آیند، به‌علاوه یک خواندن کاوش یک‌بایتی که یک entry طولانی‌تر از اعلام‌شده را می‌گیرد. یک XML reader در عنصر بسته‌شدن متوقف می‌شود و معمولاً یک خط جدید یا چند بایت فضای خالی پیرو را نخوانده رها می‌کند، پس بدون drain موقعیت هرگز به اندازه اعلام‌شده نمی‌رسد و بررسی‌ها هرگز ماشه نمی‌شوند. خواندن باقیمانده در یک بافر scratch هیچ هزینه‌ای ندارد و آن‌ها را بازمی‌گرداند. وقتی streamهای staging وجود داشتند، XlsxCopyStreamAll این کار را اتفاقی انجام می‌داد

چه چیزی همچنان سریال اجرا می‌شود، و flagی که همه‌چیز را خاموش می‌کند

فاز A جان سالم به در می‌برد، منهای استخراج. همچنان هر کاربرگ را می‌سازد و relationshipهای آن را روی thread فراخواننده می‌خواند، که همان چیزی است که هر map مشترک را به‌محض شروع workerها تغییرناپذیر می‌گذارد. فاز C همچنان sheetها را پس از آن به‌صورت سریال برای commentها، drawingها، chartها و جدول‌ها طی می‌کند، و نگهبان آن از یک بررسی null روی آرایه staging قدیمی به zip.Exists در برابر نام بخش تغییر کرد. ورودی‌های فقط-خواندنی مشترکی که workerها لمس می‌کنند، جدول رشته مشترک و mapهای 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 همان رویه job را با تعداد thread یک اعزام می‌کند، و RunParallelJobs به یک حلقه ساده روی thread فراخواننده تنزل می‌یابد. این ارزش دانستن به دو دلیل دارد: پاسخ یک‌خطی است اگر هرگز یک دغدغه threading در میدان ظاهر شود، و یعنی مسیرهای سریال و موازی یک بدنه واحد کد parsing را به اشتراک می‌گذارند به‌جای واگرا شدن. exceptionهای worker گرفته می‌شوند، پایین‌ترین اندیس job برنده می‌شود، و خطا پس از join شدن هر worker روی thread فراخواننده دوباره بلند می‌شود، پس یک کاربرگ خراب همچنان به‌عنوان یک exception در مکان مورد انتظار ظاهر می‌شود. تنظیم عمومی مسیر باز اطراف در راهنمای عملکرد workbook بزرگ در Delphi پوشش داده شده

read gate، فاز باز موازی و دسترسی entry streaming که اینجا شرح داده شد به‌عنوان بخشی از نسخه استاندارد کامپوننت HotXLS Excel برای Delphi و C++Builder، با source کامل، عرضه می‌شوند؛ صفحه محصول مرجع کامل TXLSXWorkbook شامل ویژگی‌های باز موازی را حمل می‌کند