Блокировка рендеринга в PDFiumPas — это критическая секция на документ: EnterRenderLock и LeaveRenderLock, опирающиеся на поле TRTLCriticalSection в TPdf, — предназначена для оборачивания каждого вызова в растеризатор PDFium, чтобы страницу нельзя было выгрузить или перезагрузить из-под выполняющегося рендеринга. Шесть методов, поровну разделённых между TPdf и TPdfView, вызывали API извлечения растровых изображений и миниатюр PDFium напрямую и полностью пропускали эту блокировку — пробел, который PDFiumPas v2.26.0 закрыл, обернув все шесть в ту же пару блокировок, что уже использовала любая другая точка входа рендеринга
Пробел, описываемый здесь, — не тот проход усиления ABI, что описан в другом месте этого блога и разбирал несовпадение соглашения о вызовах cdecl и усечение ширины указателя FPC на Win64 в той же привязке PDFium. То, что следует ниже, у́же и более механично: контрольный список покрытия блокировкой для шести точек вызова, каждая из которых обращается в путь рендеринга PDFium, почему каждую из них было легко пропустить, и почему гонка, следующая из отсутствия блокировки, — один из наиболее трудных для воспроизведения по требованию дефектов в этой кодовой базе
Что на самом деле защищает блокировка рендеринга
PDFiumPas сериализует рендеринг потому, что загруженную страницу PDFium небезопасно читать из одного потока, пока другой поток волен её освободить. TPdf владеет TRTLCriticalSection в FRenderLock, инициализированной в конструкторе и защищённой флагом FRenderLockReady, так что вызов, поступивший после разрушения объекта, становится тихой операцией-пустышкой вместо входа в удалённую критическую секцию. EnterRenderLock и LeaveRenderLock — единственный санкционированный вход и выход из этой секции
procedure TPdf.EnterRenderLock;
begin
if FRenderLockReady then
EnterCriticalSection(FRenderLock);
end;
procedure TPdf.LeaveRenderLock;
begin
if FRenderLockReady then
LeaveCriticalSection(FRenderLock);
end;
TPdf.RenderPage, RenderTile и RenderPageProgressive уже следовали этой дисциплине ещё до того, как начался этот конкретный аудит, — каждый из них захватывал блокировку перед вызовом в PDFium и освобождал её в блоке finally, так что фоновый предварительный рендеринг и передний план UnloadPage на одном и том же экземпляре TPdf не могут пересечься. Пробел, найденный PDFiumPas v2.26.0, был не в этих очевидных точках входа — он обнаружился в шести методах, которые читаются как обычные методы доступа, а не как рендеринг, хотя каждый из них просит PDFium растрировать пиксели прежде, чем сможет что-либо вернуть
Какие шесть вызовов пропустили блокировку рендеринга?
TPdf.GetObjectBitmap, TPdf.GetBitmap и TPdf.GetThumbnail составили половину списка, а TPdfView.GetObjectBitmap, TPdfView.GetBitmap и TPdfView.GetThumbnail — вторую половину: те же три операции, продублированные между двумя классами компонентов, что предоставляют одну и ту же лежащую в основе страницу. Все шесть в конечном счёте вызывают либо FPDFImageObj_GetBitmap, либо FPDFPage_GetThumbnailAsBitmap, и обе эти точки входа PDFium растрируют на месте, а не возвращают ссылку на что-то уже отрисованное. Ничто в названии ни одного из шести методов не говорит «рендер», что является разумным объяснением того, почему они не были написаны по тому же контрольному списку, что RenderPage и RenderTile, с первого раза
function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
Bitmap: FPDF_BITMAP;
begin
Result:= nil;
EnterRenderLock;
try
Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
finally
LeaveRenderLock;
end;
if Bitmap<> nil then
try
Result:= ToBitmap(Bitmap);
finally
FPDFBitmap_Destroy(Bitmap);
end;
end;
Почему TPdfView защищает свой вызов блокировки проверкой на nil
TPdfView не владеет собственной критической секцией — каждый из его шести вызовов блокировки перенаправляется в FPdf.EnterRenderLock и FPdf.LeaveRenderLock, обёрнутые сначала в проверку, что связанная ссылка TPdf не равна nil. Эта проверка существует потому, что TPdfView может находиться на форме на этапе проектирования или кратковременно между закрытием одного документа и открытием следующего, ещё не имея TPdf, назначенного в FPdf. Пропуск этой проверки обменял бы один сбой на другой, поскольку вызов блокировки на nil-ссылке проваливается не более изящно, чем гонка, для предотвращения которой существует блокировка
function TPdfView.GetThumbnail: TBitmap;
var
PdfBitmap: FPDF_BITMAP;
begin
CheckActive;
Result:= nil;
if FPdf<> nil then
FPdf.EnterRenderLock;
try
PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
finally
if FPdf<> nil then
FPdf.LeaveRenderLock;
end;
if PdfBitmap<> nil then
try
Result:= ToBitmap(PdfBitmap);
finally
FPDFBitmap_Destroy(PdfBitmap);
end;
end;
Почему RenderPage(HDC) относится к тому же аудиту?
TPdfView.RenderPage относительно контекста устройства — не один из этих шести; он обнаружился релизом раньше, в PDFiumPas v2.25.0, и заслуживает места в этом контрольном списке потому, что это тот же дефект под другой личиной. Эта перегрузка вызывала FPDF_RenderPage напрямую без ни EnterRenderLock, ни вызова SetArithmeticMask, защищающего от исключений FPU на более старых компиляторах Delphi, тогда как перегрузка для TBitmap, находящаяся на несколько строк ниже в том же классе, уже несла оба. То, что два прохода аудита ловили один и тот же режим отказа с интервалом в релиз, говорит меньше о каком-то одном методе и больше о форме самой ошибки: она прячется в той перегрузке, которую никто не перечитывает заново, как только её сосед выглядит корректным
procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
ArithmeticMask: TArithmeticMask;
begin
CheckActive;
if FPdf<> nil then
FPdf.EnterRenderLock;
ArithmeticMask:= SetArithmeticMask;
try
FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
Ord(Rotation), EncodeRenderOptions(Options));
finally
RestoreArithmeticMask(ArithmeticMask);
if FPdf<> nil then
FPdf.LeaveRenderLock;
end;
end;
Почему эту гонку почти невозможно воспроизвести?
Пробел в блокировке рендеринга PDFiumPas не проявляется при каждом запуске и даже не при большинстве запусков, потому что ему нужны две конкретные вещи, совпавшие на одном и том же экземпляре TPdf одновременно: уже выполняющийся вызов растрирования и одновременный UnloadPage или ReloadPage, поступивший внутри того же окна. Однопоточное тестирование вообще никогда не задействует этот путь, и даже по-настоящему многопоточные нагрузки провоцируют его только тогда, когда фоновый рендеринг и событие жизненного цикла документа случайно пересекаются в пределах жизни одной страницы. Наиболее реалистичный триггер — фоновая предварительная отрисовка PDF, построенная на отменяемых futures, где рабочий поток растрирует следующую страницу, пока UI-поток перезагружает или выгружает текущую по действию пользователя
FPDFImageObj_GetBitmap и FPDFPage_GetThumbnailAsBitmap обходят структуры объектов страницы, которые UnloadPage волен освободить посреди обхода, так что гонка, реально сработавшая, не всегда производит немедленное нарушение доступа. Структура, прочитанная на мгновение слишком поздно, с тем же успехом может вернуть мусорные пиксели или повредить метаданные кучи, которые обрушат совершенно не связанные выделения памяти много позже, в функции, никогда не касавшейся страницы PDF. Это честная причина, по которой такой класс ошибок способен пережить несколько циклов релиза в кодовой базе: трассировка стека в точке отказа редко указывает куда-либо рядом с теми шестью строками, которым реально не хватало блокировки
Что меняется для вызывающего кода
GetBitmap, GetObjectBitmap, GetThumbnail и перегрузка HDC для RenderPage сохраняют свои публичные сигнатуры в точности такими, какими были, поскольку исправление — это внутренняя блокировка, добавленная вокруг существующих вызовов, а не миграция. Стоит помнить, что блокировка рендеринга ограничена рамками экземпляра TPdf, а не глобальна для процесса, так что два потока, отрисовывающие два отдельно загруженных документа, всё равно выполняются полностью параллельно, — блокировка сериализует только операции над тем одним документом, который оба потока случайно разделяют. Если ваша блокировка уже корректна, а рендеринг всё равно кажется медленным при масштабировании или прокрутке, это другой вопрос, на который отвечает статья о кэше рендеринга PDFium и приёмах производительности при масштабировании, — корректность и скорость здесь разные оси, и это исправление затрагивает только первую
Шесть методов и одна родственная перегрузка — небольшая доля поверхности PDFium, которую предоставляет PDFiumPas, но именно эта доля вела себя неправильно только под нагрузкой, которую никто случайно не запускал под отладчиком. Сама блокировка рендеринга и полный набор точек входа рендеринга, которые она теперь покрывает, поставляются как часть компонента PDFium для Delphi, C++Builder и Lazarus/FPC