Техническая статья

HotXLS, zlib-ng CRC32 и переполнение стека в потоках Delphi

HotXLS может уронить рабочий поток Delphi без единого перехватываемого исключения, когда он вычисляет контрольную сумму большой XML-части листа за один вызов: zlib-ng переключается на свой алгоритм Chorba выше примерно 119 КБ входных данных, а обобщённый вариант этого алгоритма на C выделяет вспомогательный массив, достаточно большой, чтобы пробить стек потока по умолчанию в 1 МБ. У Delphi даже нет шанса среагировать, потому что переполнение стека — не то исключение, для перехвата которого создавался try/except

HotXLS — нативная библиотека для Delphi и C++Builder для чтения и записи книг Excel, и сбой был прослежен до её писателя листов. Первым признаком проблемы стал тикет поддержки: ночное задание экспорта падало примерно дважды в неделю, всегда посреди выполнения, без диалога исключения Delphi и без записи в лог — просто процесс исчезал, а запись в Windows Error Reporting указывала в никуда полезное. Воспроизведение за рабочим столом оказалось совсем другим делом. Небольшие книги сохранялись без проблем. Крупные книги тоже сохранялись без проблем, пока сохранение шло в главном потоке с уже подключённым отладчиком. Потребовалась реальная партия файлов производственного размера, проходящая через настоящий многопоточный путь экспорта, чтобы воспроизвести сбой у себя, и к тому моменту ввод-вывод диска, давление на память и подозрительный шаблон уже были исключены по отдельности

Как сохранение листа превращается в один гигантский вызов CRC32

Файлы XLSX — это ZIP-контейнеры, а формат ZIP требует контрольную сумму CRC-32 для каждой записи, фиксируемую и в локальном заголовке файла, и в центральном каталоге. HotXLS вычисляет эту контрольную сумму, вызывая небольшую обёртку по имени ZLibCRC32, которая, в свою очередь, вызывает собственную процедуру crc32 из zlib-ng после того, как SaveAs закончит сборку XML листа в памяти, и долгое время этот вызов нёс весь несжатый буфер в одном обращении. Это разумная конструкция для небольшого листа. Она становится одним очень большим вызовом в момент, когда лист относится к тому виду, что описан в нашем руководстве по производительности крупных книг в HotXLS, где XML одного листа регулярно перерастает несколько сотен килобайт ещё до сжатия

Почему zlib-ng нужен гигантский буфер стека для CRC32?

zlib-ng не использует одну реализацию CRC-32 для каждого вызова. Ниже порога размера он обходит буфер табличными подстановками и приёмами свёртки, не требующими сколько-нибудь заметной дополнительной памяти, а выше этого порога, примерно 119 КБ, точнее 118 960 байт в той сборке, с которой линкуется HotXLS, он переключается на специализированный быстрый алгоритм по имени Chorba. Обобщённая реализация этого пути на C меняет память на скорость: он выделяет вспомогательный массив в стеке, а не в куче, размером, рассчитанным сделать внутренний цикл алгоритма быстрым, а не удобно уместиться в тот бюджет стека, который случайно несёт вызывающий поток. Ничего из этого не видно со стороны вызывающего кода. Функция контрольной суммы обычно является листовым вызовом — прочитать несколько байтов, вернуть число, никакого выделения памяти, о котором стоило бы говорить, — и это предположение справедливо для подавляющего большинства вызовов в zlib-ng вплоть до момента, когда буфер, достаточно большой, чтобы пересечь порог 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;

Почему рабочие потоки видели это, а интерактивная отладка — никогда

Чтобы вызвать этот сбой, нужны два условия одновременно: XML-часть листа, достаточно большая, чтобы пересечь порог Chorba в zlib-ng, и поток, у которого только обычный стек по умолчанию, а не что-то более просторное. Продакшн-задания экспорта попадали в оба условия. Они выполняются как серверные пакетные задания, распределяющие записи HotXLS по пулу рабочих потоков, каждый из которых несёт стек по умолчанию в 1 МБ, зарезервированный Windows, если только вызывающий код не запросил больше, и каждый обрабатывает клиентские книги, достаточно крупные, чтобы это имело значение. Отладка за рабочим столом надёжно не попадала ни в одно из условий: тестовые файлы обычно были меньше порога, а пошаговое выполнение обычно происходило в главном потоке, а не внутри только что созданного рабочего, так что два условия, которым нужно было совпасть в продакшене, почти никогда не совпадали за столом разработчика

Погоня за сбоем, обвинившим не ту функцию

Отчёты о сбоях, которые команда смогла получить, указывали на место внутри функции deflate в zlib-ng — не на какой-либо код HotXLS и даже не явно на код CRC-32. Именно эта деталь направила первый проход расследования в сторону пути сжатия: размеры буферов, передаваемых в deflate, битность окна, уровень сжатия — все обычные подозреваемые для нативного сбоя, исходящего из кодека. Ни один из них не подтвердился

Обманчивый верхний кадр

Переполнение стека — странный вид сбоя для символизации, потому что к моменту, когда о нём сообщают, указатель стека уже пробежал мимо пространства, которое для него было зарезервировано. Что бы ни произвело этот отчёт о сбое, скорее всего, разрешило адрес сбоя до ближайшего символа, который ещё смогло найти, и ближайшей экспортированной точкой входа, оказавшейся рядом с настоящим виновником, оказался deflate. Настоящий сбой находился в выделении вспомогательного буфера Chorba внутри пути CRC-32, скомпилированного в ту же библиотеку, достаточно близко в бинарнике, чтобы его приняли за функцию, что реально выполнялась

Бисекция по временным меткам вместо отладчика

Сбой, роняющий весь процесс, не оставляет ничего, что могла бы поймать обычная сессия отладчика Delphi, так что команда прибегла к контрольным точкам GetTickCount, расставленным вокруг каждого подозрительного вызова, и ручной бисекции по пути сохранения, сужая, какая операция выполнялась в момент гибели процесса. Наряду с этим известная рабочая базовая сборка прогоняла те же продакшн-файлы бок о бок с текущей, специально чтобы исключить регрессию в изменениях этого раунда прежде, чем смотреть дальше вверх по цепочке зависимостей. Только после того, как обе проверки оказались чистыми, расследование остановилось на стороннем зависимости, делающей нечто неожиданное с совершенно корректным вводом

Почему try/except не может перехватить переполнение стека?

Переполнение стека — не то исключение, что код на Delphi когда-либо выбрасывает намеренно, и доставляется оно не так, как Windows доставляет нарушение доступа или деление на ноль. Оно проявляется как аппаратный сбой защитной страницы (guard-page fault), сообщаемый через тот же механизм структурированной обработки исключений, на котором построен try/except в Delphi, но в точный момент срабатывания обычно не остаётся места в стеке, чтобы выполнить обработчик, развернуть код очистки или даже чисто завершить сообщение о сбое. В рабочем потоке, несущем только резервирование по умолчанию в 1 МБ, где вспомогательный буфер такого размера уже поглотил почти всё, что оставалось, у среды выполнения просто нет ничего, с чем можно работать

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;

Этот блок except выглядит как страховочная сетка, и против большинства отказов он ею и является, но здесь он не делает ничего. Команда подтвердила это на практике: try/except ничего не перехватил, блоку finally тоже ни разу надёжно не удалось выполниться, и оператор увидел мёртвый процесс без единой записи в журнале на уровне приложения — ровно то, что описывал исходный тикет поддержки

Решение: подавать CRC32 срезами по 64 КБ вместо одного гигантского вызова

Исправление, выпущенное HotXLS, не меняет ничего в самом zlib-ng и ничего в уровне сжатия, используемом для записи книги. ZLibCRC32 теперь обходит вход фиксированными срезами по 64 КБ, по 65536 байт каждый, вызывая crc32 из zlib-ng один раз на срез и передавая текущее значение контрольной суммы от одного вызова к следующему. CRC-32 — инкрементный алгоритм по своей конструкции, так что контрольная сумма, накопленная за несколько срезов, побитово идентична вычисленной за один вызов над теми же байтами: исправление меняет, как разделена работа, но не то, что вычисляется

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;

Ничего в окружающем вызове SaveAs не пришлось менять, чтобы это заработало, и ничего в записях ZIP, которые пишет HotXLS, тоже не изменилось: значение CRC-32, что в итоге оказывается в локальном заголовке файла и центральном каталоге, — точно то же значение, что произвёл бы один гигантский вызов, просто собранное из более мелких частей. Понижение версии zlib-ng или откат к более медленной реализации CRC-32 с малым потреблением памяти тоже позволили бы избежать сбоя, но ценой реальных потерь для каждого файла, который изначально даже близко не подходил к порогу, и именно поэтому ни один из этих вариантов не вышел в релиз

Что это значит, если вы вызываете zlib-ng из собственных рабочих потоков

Описанный здесь режим отказа с переполнением стека не имеет ничего общего именно с электронными таблицами. Любое приложение, передающее zlib-ng большой буфер — для сжатия, распаковки или контрольной суммы — из потока, несущего только стек по умолчанию для платформы, может наткнуться на ту же стену, потому что библиотека выбирает алгоритм по размеру входных данных, а некоторые из этих алгоритмов предполагают, что стека достаточно с запасом. Две защиты работают, не трогая сам zlib-ng: подача больших буферов в чувствительные к размеру процедуры фиксированными фрагментами полностью убирает условие срабатывания для любого алгоритма, естественным образом являющегося инкрементным, а там, где разбиение на фрагменты не вариант, другой рычаг — выдать вызывающему потоку стек больше, чем стандартный для платформы. Любой из этих вариантов дешевле, чем узнать о недокументированном пороге размера из отчёта о сбое в продакшене, обвиняющего не ту функцию

Этот конкретный порог оставался невидимым, пока достаточно крупная продакшн-книга не пересекла его в неподходящем потоке, — именно такой сбой проявляется только тогда, когда код запускается на реальных файлах, а не на небольших тестовых образцах. Путь CRC-32 с разбиением на фрагменты теперь поставляется как часть стандартного конвейера записи в компоненте Excel HotXLS для Delphi и C++Builder, без чего-либо для настройки вызывающим кодом и без свойства, включающего или выключающего это поведение