Műszaki cikk

Hat PDFium-hívás, amely elfelejtette a renderelési zárat Delphiben

A PDFiumPas renderelési zárja egy dokumentumonkénti kritikus szakasz — EnterRenderLock és LeaveRenderLock, egy TRTLCriticalSection mezővel alátámasztva a TPdf-en —, amelynek célja, hogy körbevegyen minden hívást a PDFium raszterizálójába, így egy oldal nem törölhető ki vagy tölthető be újra egy folyamatban lévő renderelés alól. Hat metódus, egyenlő arányban megosztva a TPdf és TPdfView között, közvetlenül hívta a PDFium bitkép- és miniatűr-kinyerő API-jait, és teljesen kihagyta ezt a zárat, egy rést, amit a PDFiumPas v2.26.0 zárt be, mind a hatot ugyanabba a zárpárba csomagolva, amit minden más renderelési belépési pont már használt

Az itt tárgyalt rés nem az ABI-megerősítő átfutás, amit ez a blog máshol tárgyal, amely egy cdecl hívási-konvenció-eltérésen és egy FPC Win64 pointer-szélesség-csonkoláson ment végig ugyanabban a PDFium-kötésben. Ami következik, szűkebb és mechanikusabb: egy zár-lefedettségi ellenőrzőlista hat híváshelyhez, amelyek mind belenyúlnak a PDFium renderelési útvonalába, miért volt könnyű mindegyiket elmulasztani, és miért az egyik nehezebben, igény szerint reprodukálható hiba ebben a kódbázisban a zár hiányából következő versenyhelyzet

Mit véd meg ténylegesen a renderelési zár

A PDFiumPas azért szerializálja a renderelést, mert a PDFium betöltött oldala nem biztonságos olvasni az egyik szálról, miközben egy másik szál szabadon felszabadíthatja azt. A TPdf birtokol egy TRTLCriticalSection-t az FRenderLock-ban, inicializálva a konstruktorban, és egy FRenderLockReady jelzővel őrizve, így egy hívás, amely leszerelés után érkezik, csendes no-op-pá válik ahelyett hogy egy törölt kritikus szakaszba lépne. Az EnterRenderLock és LeaveRenderLock az egyetlen szentesített be- és kilépési mód abból a szakaszból

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

A TPdf.RenderPage, a RenderTile, és a RenderPageProgressive már ezt a fegyelmet követte, mielőtt ez a konkrét audit egyáltalán elkezdődött, mindegyik a zárat véve, mielőtt belehívna a PDFiumba, és felszabadítva azt egy finally blokkban, így egy háttérbeli előre-renderelés és egy előtér UnloadPage ugyanazon a TPdf-példányon nem fedhetik át egymást. A rés, amit a PDFiumPas v2.26.0 talált, nem ezekben a nyilvánvaló belépési pontokban volt — hat metódusban bukkant fel, amelyek inkább hozzáférőknek olvasnak, nem renderelőknek, pedig mindegyikük pixelek raszterizálását kéri a PDFiumtól, mielőtt bármit is visszaadhatna

Melyik hat hívás hagyta ki a renderelési zárat?

A TPdf.GetObjectBitmap, a TPdf.GetBitmap, és a TPdf.GetThumbnail tette ki a lista felét, és a TPdfView.GetObjectBitmap, a TPdfView.GetBitmap, és a TPdfView.GetThumbnail a másik felét — ugyanaz a három művelet, duplikálva a két komponensosztály között, amelyek ugyanazt az alapul szolgáló oldalt teszik elérhetővé. Mind a hat végül vagy az FPDFImageObj_GetBitmap-et, vagy az FPDFPage_GetThumbnailAsBitmap-et hívja meg, és a PDFium mindkét belépési pontja helyben raszterizál, ahelyett hogy visszaadna egy hivatkozást valamire, ami már renderelve van. Egyik metódusnév sem mondja azt, hogy render, ami ésszerű magyarázat arra, miért nem írták őket ugyanazon ellenőrzőlista ellen, mint a RenderPage-et és a RenderTile-t elsőre

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;

Miért védi a TPdfView a zárhívását egy nil-ellenőrzéssel?

A TPdfView nem birtokol saját kritikus szakaszt — hat zárhívása mindegyike továbbít az FPdf.EnterRenderLock-hoz és az FPdf.LeaveRenderLock-hoz, egy ellenőrzésbe csomagolva, hogy a hozzá tartozó TPdf hivatkozás előbb ne legyen nil. Ez az őr azért létezik, mert egy TPdfView ülhet egy formon tervezési időben, vagy röviden egy dokumentum bezárása és a következő megnyitása között, anélkül hogy bármely TPdf hozzá lenne rendelve az FPdf-hez még. Az őr kihagyása egy összeomlást cserélne egy másikra, mivel egy zárolási hívás egy nil hivatkozás ellen nem bukik el kevésbé kecsesen, mint az a versenyhelyzet, amelynek megelőzésére a zár létezik

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;

Miért tartozik a RenderPage(HDC) ugyanabba az auditba?

A TPdfView.RenderPage egy eszközkontextus ellen nem egyike a hatnak — egy kiadással korábban bukkant fel, a PDFiumPas v2.25.0-jában, és azért érdemel helyet ebben az ellenőrzőlistában, mert ugyanaz a hiba, más aláírásban. Az az overload egyenesen az FPDF_RenderPage-et hívta, sem az EnterRenderLock, sem a SetArithmeticMask hívás nélkül, amely az FPU-kivételek ellen véd régebbi Delphi-fordítókon, míg a TBitmap overload, amely néhány sorral lejjebb ül ugyanabban az osztályban, már mindkettőt hordozta. Két audit-átfutás, amely ugyanazt a hibamódot kapja el egy kiadással eltérve, kevesebbet mond bármelyik önálló metódusról, és többet a hiba alakjáról: bármelyik overloadban elrejtőzik, amit senki nem olvas újra, amint testvére helyesnek tűnik

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;

Miért majdnem lehetetlen reprodukálni ezt a versenyhelyzetet?

A PDFiumPas renderelésizár-rése nem bukik el minden futásnál, még a legtöbb futásnál sem, mert két konkrét dolognak kell egyszerre ugyanarra a TPdf-példányra landolnia: egy raszterizációs hívásnak, amely már folyamatban van, és egy egyidejű UnloadPage-nek vagy ReloadPage-nek, amely megérkezik azon az ablakon belül. Az egyszálas tesztelés soha nem gyakorolja ezt az útvonalat, és még a valóban több szálú munkaterhelések is csak akkor váltják ki, amikor egy háttér-renderelés és egy dokumentum-életciklus-esemény véletlenül átfedésbe kerül egy oldal élettartamán belül. A legrealisztikusabb kiváltó a a megszakítható jövőkre épített háttérbeli PDF-előre-renderelés, ahol egy munkásszál raszterizálja a következő oldalt, miközben a UI-szál újratölti vagy törli az aktuálisat felhasználói bemenetre

Az FPDFImageObj_GetBitmap és az FPDFPage_GetThumbnailAsBitmap olyan oldal-objektum-struktúrákat jár be, amelyeket az UnloadPage szabadon felszabadíthat bejárás közben, így egy versenyhelyzet, amely ténylegesen kiváltódik, nem mindig eredményez azonnali hozzáférési hibát. Egy struktúra, amelyet egy pillanattal túl későn olvasnak, ugyanolyan könnyen adhat vissza szemét-pixeleket, vagy sérült kupac-metaadatot, ami csak néhány, teljesen független allokációval később omlik össze, egy olyan függvényben, amely soha nem érintett egy PDF-oldalt. Ez az őszinte oka annak, hogy ez a hibaosztály hogyan tudott túlélni egy kódbázisban több kiadási cikluson át: az összeomlás pillanatában lévő stack trace ritkán mutat bárhová a hat sor közelébe, ahol valóban hiányzott egy zár

Mi változik a hívók számára

A GetBitmap, a GetObjectBitmap, a GetThumbnail, és a RenderPage HDC-overloadja pontosan úgy tartja meg nyilvános szignatúráit, ahogyan voltak, mivel a javítás belső zárolás, amit meglévő hívások köré adtak, nem pedig egy migráció. Érdemes emlékezni, hogy a renderelési zár TPdf-példányonként van hatókörben, nem a folyamathoz globálisan, így két szál, amely két külön betöltött dokumentumot renderel, még mindig teljesen párhuzamosan fut — a zár csak azokat a műveleteket szerializálja, amelyek ellen mindkét szál véletlenül ugyanazt az egy dokumentumot osztja meg. Ha a te zárolásod már megbízható, és a renderelések mégis lassúnak érződnek nagyítás vagy görgetés közben, az egy másik kérdés, amire a PDFium renderelési gyorsítótár és nagyítási teljesítmény taktikákról szóló cikk válaszol — a helyesség és a sebesség itt külön tengelyek, és ez a javítás csak az elsőt érinti

Hat metódus és egy testvér-overload a PDFium felület kis töredéke, amit a PDFiumPas elérhetővé tesz, de ez volt az a töredék, amely csak olyan terhelés alatt viselkedett rosszul, amit véletlenül senki nem futtatott debuggerben. Maga a renderelési zár, és a renderelési belépési pontok teljes halmaza, amelyet most lefed, a Delphihez, C++Builderhez, és Lazarus/FPC-hez készült PDFium Komponens részeként érkezik