Artykuł techniczny

HotXLS, CRC32 z zlib-ng i przepełnienie stosu w wątkach Delphi

HotXLS potrafi zawiesić wątek roboczy Delphi bez żadnego wyjątku możliwego do przechwycenia, gdy oblicza sumę kontrolną dużej części XML arkusza w jednym wywołaniu: zlib-ng przełącza się na swój algorytm Chorba powyżej mniej więcej 119 KB danych wejściowych, a ogólna wersja w czystym C tego algorytmu przydziela tablicę roboczą wystarczająco dużą, by przebić się przez domyślny 1 MB stos wątku. Delphi nigdy nie dostaje szansy na reakcję, ponieważ przepełnienie stosu nie jest tym rodzajem wyjątku, do przechwytywania którego zbudowano try/except

HotXLS to natywna biblioteka Delphi i C++Builder do odczytu i zapisu skoroszytów Excela, a awaria prowadziła z powrotem do jej modułu zapisu arkusza. Pierwszym sygnałem problemu było zgłoszenie do wsparcia technicznego: nocne zadanie eksportu zawieszało się mniej więcej dwa razy w tygodniu, zawsze w trakcie działania, bez okna dialogowego wyjątku Delphi i bez zalogowanego błędu, po prostu proces znikał, a wpis Windows Error Reporting nie prowadził donikąd użytecznego. Odtworzenie tego przy biurku było zupełnie inną sprawą. Małe skoroszyty zapisywały się bez problemu. Duże skoroszyty też zapisywały się bez problemu, dopóki zapis działał w wątku głównym z już podłączonym debuggerem. Potrzeba było prawdziwej partii plików o rozmiarze produkcyjnym, przechodzącej przez rzeczywistą wielowątkową ścieżkę eksportu, aby sprowadzić awarię do domu, a do tego momentu wejście/wyjście dysku, presja pamięci i podejrzany szablon zostały już wykluczone

Jak zapis arkusza zamienia się w jedno gigantyczne wywołanie CRC32

Pliki XLSX to kontenery ZIP, a format ZIP wymaga sumy kontrolnej CRC-32 dla każdego wpisu, zapisywanej zarówno w lokalnym nagłówku pliku, jak i w centralnym katalogu. HotXLS oblicza tę sumę kontrolną, wywołując mały wrapper o nazwie ZLibCRC32, który z kolei wywołuje własną procedurę crc32 z zlib-ng, gdy tylko SaveAs skończy składać XML arkusza w pamięci, i przez długi czas to wywołanie niosło cały niekompresowany bufor w jednym wywołaniu. To rozsądny projekt dla małego arkusza. Staje się jednym bardzo dużym wywołaniem w chwili, gdy arkusz jest tym rodzajem opisanym w naszym przewodniku po wydajności dużych skoroszytów w HotXLS, gdzie XML pojedynczego arkusza rutynowo przekracza kilkaset kilobajtów, zanim zostanie skompresowany

Dlaczego zlib-ng potrzebuje gigantycznego bufora stosu dla CRC32?

zlib-ng nie używa jednej implementacji CRC-32 do każdego wywołania. Poniżej pewnego progu rozmiaru przechodzi przez bufor za pomocą wyszukiwań tabelarycznych i sztuczek składania, które nie wymagają znaczącej dodatkowej pamięci, a powyżej tego progu, mniej więcej 119 KB, dokładnie 118 960 bajtów w wersji, z którą łączy się HotXLS, przełącza się na wyspecjalizowany, szybki algorytm o nazwie Chorba. Ogólna implementacja w czystym C tej ścieżki wymienia pamięć na szybkość: przydziela tablicę roboczą na stosie, a nie na stercie, o rozmiarze mającym uczynić wewnętrzną pętlę algorytmu szybką, a nie mającym wygodnie zmieścić się w jakimkolwiek budżecie stosu, który akurat niesie wywołujący wątek. Nic z tego nie jest widoczne od strony wywołującego. Funkcja sumy kontrolnej to zwykle wywołanie liścia, odczytaj kilka bajtów, zwróć liczbę, żadnej alokacji wartej wspomnienia, i to założenie utrzymuje się dla przytłaczającej większości wywołań do zlib-ng aż do momentu, gdy w jedno z nich wchodzi bufor wystarczająco duży, by przekroczyć próg Chorba

function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // One call over the whole worksheet XML buffer: fine for a small
  // sheet, but a large enough input pushes zlib-ng onto its Chorba
  // fast path and that path's stack-hungry scratch buffer
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

Dlaczego widziały to wątki robocze, a debugowanie interaktywne nigdy

Wywołanie tej awarii wymaga dwóch warunków jednocześnie: części XML arkusza wystarczająco dużej, by przekroczyć próg Chorba w zlib-ng, oraz wątku, który ma tylko zwykły domyślny stos, a nie coś bardziej przestronnego. Produkcyjne zadania eksportu spełniały oba. Działają jako zadania wsadowe po stronie serwera, które rozkładają zapisy HotXLS na pulę wątków roboczych, każdy niosący domyślny 1 MB stos, który Windows rezerwuje, chyba że wywołujący poprosi o więcej, i każdy przetwarzający skoroszyty klientów wystarczająco duże, by miało to znaczenie. Debugowanie przy biurku nie spełniało niezawodnie żadnego z tych warunków: przykładowe pliki były zwykle mniejsze niż próg, a przebiegi krok po kroku miały tendencję do odbywania się w wątku głównym, a nie wewnątrz świeżo utworzonego wątku roboczego, więc dwa warunki, które musiały się zbiec w produkcji, prawie nigdy nie zbiegały się przy biurku programisty

Pogoń za awarią, która obwiniała niewłaściwą funkcję

Raporty awarii, do których zespół miał dostęp, wskazywały na lokalizację wewnątrz funkcji deflate z zlib-ng, nie na żaden kod HotXLS, i też nie wprost na kod CRC-32. Ten pojedynczy szczegół skierował pierwszy etap dochodzenia w stronę ścieżki kompresji: rozmiary buforów przekazywane do deflate, bity okna, poziom kompresji, wszyscy zwykli podejrzani przy natywnej awarii pochodzącej z kodeka. Żaden z nich się nie potwierdził

Myląca górna ramka

Przepełnienie stosu to dziwny rodzaj awarii do zsymbolizowania, ponieważ zanim zostanie zgłoszona, wskaźnik stosu już przebiegł poza przestrzeń, która była dla niego zarezerwowana. Cokolwiek wygenerowało ten raport o awarii, najprawdopodobniej rozwiązało adres powodujący błąd do najbliższego symbolu, jaki jeszcze mogło znaleźć, a najbliższy eksportowany punkt wejścia leżący obok prawdziwego winowajcy okazał się być deflate. Rzeczywista awaria tkwiła w alokacji bufora roboczego Chorba wewnątrz ścieżki CRC-32, skompilowanej w tej samej bibliotece, wystarczająco blisko w binarce, by pomylić ją z funkcją, która faktycznie działała

Bisekcja za pomocą znaczników czasu zamiast debuggera

Awaria, która zabiera cały proces, nie zostawia nic do przechwycenia dla zwykłej sesji debuggera Delphi, więc zespół sięgnął po punkty kontrolne GetTickCount rozrzucone wokół każdego podejrzanego wywołania oraz ręczną bisekcję wzdłuż ścieżki zapisu, zawężając, jaka operacja była w toku w chwili śmierci procesu. Obok tego, znany dobry build bazowy uruchomił te same pliki produkcyjne równolegle z bieżącym buildem, konkretnie po to, by wykluczyć regresję we własnych zmianach tej rundy, zanim spojrzano dalej w górę potoku. Dopiero gdy oba sprawdzenia wypadły czysto, dochodzenie osiadło na zależności zewnętrznej robiącej coś nieoczekiwanego z całkowicie prawidłowymi danymi wejściowymi

Dlaczego try/except nie potrafi przechwycić przepełnienia stosu?

Przepełnienie stosu nie jest wyjątkiem, który kod Delphi kiedykolwiek zgłasza celowo, i nie jest też dostarczane w taki sposób, w jaki Windows dostarcza naruszenie dostępu czy dzielenie przez zero. Ujawnia się jako sprzętowy błąd strony ochronnej (guard-page fault), zgłaszany przez ten sam mechanizm strukturalnej obsługi wyjątków, na którym zbudowany jest try/except w Delphi, ale w dokładnym momencie, gdy się uruchamia, zwykle nie ma już przestrzeni stosu, by uruchomić handler, odwinąć kod porządkujący, czy nawet czysto zakończyć zgłaszanie błędu. W wątku roboczym niosącym tylko domyślną rezerwację 1 MB, z buforem roboczym tego rozmiaru, który już skonsumował większość tego, co zostało, nie ma nic, z czym środowisko uruchomieniowe mogłoby pracować

procedure TExportWorker.Execute;
var
  Workbook: TXLSXWorkbook;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    try
      BuildWorksheet(Workbook);
      Workbook.SaveAs(FTargetFile);  // crashes the process here on a
                                      // large enough sheet: try/except
                                      // never gets a chance to run
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

Ten blok except wygląda jak siatka bezpieczeństwa, i wobec większości awarii nią jest, ale tutaj nie robi nic. Zespół potwierdził to w praktyce: try/except nic nie przechwycił, blok finally też nigdy nie dostał niezawodnej szansy na uruchomienie, a operator widział martwy proces bez jakiegokolwiek wpisu logu na poziomie aplikacji, dokładnie tego, co opisywało oryginalne zgłoszenie do wsparcia

Rozwiązanie: podawanie CRC32 w porcjach po 64 KB zamiast jednego gigantycznego wywołania

Poprawka wydana przez HotXLS nie zmienia niczego w samym zlib-ng ani w poziomie kompresji używanym do zapisu skoroszytu. ZLibCRC32 teraz przechodzi przez dane wejściowe w stałych porcjach po 64 KB, po 65536 bajtów każda, wywołując crc32 z zlib-ng raz na porcję i przekazując bieżącą wartość sumy kontrolnej z jednego wywołania do następnego. CRC-32 jest z konstrukcji algorytmem przyrostowym, więc suma kontrolna zbudowana z kilku porcji jest bit w bit identyczna z tą obliczoną w jednym wywołaniu na tych samych bajtach: poprawka zmienia sposób podziału pracy, a nie to, co jest obliczane

function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB keeps every call comfortably under the Chorba threshold
  CrcChunkSize = 65536;
var
  Cursor: PByte;
  ThisChunk: Longint;
begin
  Result := crc;
  Cursor := PByte(@buffer);
  while count > 0 do
  begin
    ThisChunk := count;
    if ThisChunk > CrcChunkSize then
      ThisChunk := CrcChunkSize;
    Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
    Inc(Cursor, ThisChunk);
    Dec(count, ThisChunk);
  end;
end;

Nic w otaczającym wywołaniu SaveAs nie musiało się zmienić, żeby to zadziałało, i nic we wpisach ZIP zapisywanych przez HotXLS też się nie zmieniło: wartość CRC-32, która ląduje w lokalnym nagłówku pliku i w centralnym katalogu, jest dokładnie tą wartością, którą wygenerowałoby jedno gigantyczne wywołanie, tylko zebraną z mniejszych fragmentów. Obniżenie wersji zlib-ng lub przejście na wolniejszą implementację CRC-32 o niskim zużyciu alokacji też uniknęłoby awarii, ale kosztem realnym dla każdego pliku, który nigdy nawet nie zbliżył się do progu, i dlatego żadne z tych rozwiązań nie zostało wydane

Co to oznacza, jeśli wywołujesz zlib-ng z własnych wątków roboczych

Opisany tu tryb awarii przepełnienia stosu nie ma nic wspólnego konkretnie z arkuszami kalkulacyjnymi. Każda aplikacja, która przekazuje zlib-ng duży bufor, czy to do kompresji, dekompresji, czy sumy kontrolnej, z wątku niosącego tylko domyślny stos platformy, może trafić na tę samą ścianę, ponieważ biblioteka wybiera swój algorytm na podstawie rozmiaru danych wejściowych, a niektóre z tych algorytmów zakładają, że jest zapasowa przestrzeń stosu. Dwie linie obrony działają bez dotykania samego zlib-ng: podawanie dużych buforów do procedur wrażliwych na rozmiar w stałych fragmentach całkowicie usuwa warunek wyzwalający dla każdego algorytmu, który jest naturalnie przyrostowy, a tam, gdzie dzielenie na fragmenty nie wchodzi w grę, danie wywołującemu wątkowi stosu większego niż domyślny dla platformy to druga dźwignia. Każde z tych rozwiązań jest tańsze niż odkrywanie nieudokumentowanego progu rozmiaru z produkcyjnego raportu awarii, który obwinia niewłaściwą funkcję

Ten konkretny próg pozostawał niewidoczny, dopóki wystarczająco duży skoroszyt produkcyjny nie przekroczył go na niewłaściwym rodzaju wątku, co jest dokładnie tym rodzajem awarii, który ujawnia się dopiero, gdy kod działa na prawdziwych plikach zamiast na małych danych testowych. Ścieżka CRC-32 podzielona na fragmenty jest teraz częścią standardowego potoku zapisu w komponencie Excel HotXLS dla Delphi i C++Buildera, bez niczego do skonfigurowania przez wywołującego i bez żadnej właściwości, która by to włączała lub wyłączała