Tekninen artikkeli

Rinnakkainen PDF-sivujen renderointi Delphissä: Säikeistysturvallisuus

PDF-sivujen renderöinti rinnakkain Delphistä palautuu yhteen sääntöön: anna jokaiselle suoritinsäikeelle (worker thread) oma renderöijänsä. losLab PDF Library tarjoaa tähän tarkoitukseen funktion RenderPagesToFilesParallel, joka jakaa sivualueen TTask-poolille käyttäen yhtä TPDFlib-instanssia työntekijää kohden. Näin moniydinkone muuttaa eräajona tehtävän rasterointityön läpimenoksi, joka skaalautuu lähes ytimien määrän mukaan. Jos sen sijaan jaat yhden instanssin säikeiden kesken, ajo ei vain hidastu hallitusti, vaan se korruptoi muistin ja kaataa sovelluksen

Tämä on artikkeli, johon turvaudut, kun öisen taustatyön on muutettava 500-sivuinen PDF 500 PNG-kuvaksi, koneessa on 16 vapaata ydintä, ja ensimmäinen rehellinen yrityksesi säikeistää se kaatui GDI+:n sisällä. Lyhyt versio on, että säikeistysturvallisuus on tässä tapauksessa rakenteellinen ominaisuus, ei asetettava lippu, ja tämän artikkelin loppuosa käy läpi, miksi turvallinen rakenne näyttää siltä kuin se näyttää ja missä todellinen nopeuskatto todellisuudessa sijaitsee

Onko TPDFlib säikeistysturvallinen rinnakkaista renderöintiä varten?

Ei ole, ja syy on syytä ymmärtää ennen kuin suunnittelet sovelluksesi sen ympärille. Yksi TPDFlib-instanssi on suunniteltu vain yksisäikeiseen käyttöön, ja sen terävin kulma on TPDFPageTree.GetPage: se kirjoittaa jaetun FPagePointer-kentän instanssiin sivuvaikutuksena sivua valittaessa. Kaksi samaa instanssia kutsuvaa säiettä kilpailee tästä kentästä (race condition), joten suoritinsäie A voi olla puolivälissä sivua 3, kun suoritinsäie B osoittaa sivupuun sivulle 40. Mikään APIssa ei estä sinua kirjoittamasta alla olevaa koodia, ja se saattaa jopa toimia muutaman sivun ajan ennen kuin se kaatuu, mikä on pahin tapa tällaiselle virheelle käyttäytyä

// ÄLÄ tee näin: yksi jaettu instanssi, monta säiettä
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // jokainen säie kutsuu samaa instanssia -> data race FPagePointer-kentässä
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Virhe ei ole deterministinen, minkä vuoksi se selviää nopeasta perustestistä ja ilmenee vasta asiakkaan koneella, jossa on eri ytimien määrä ja raskaampi asiakirja. RenderPageToFile-kutsun ympärille ei myöskään voi lisätä lukkoa, joka korjaisi tämän tehokkaasti, sillä mutex-lukon pitäminen koko renderöintikutsun ajan sarjoittaa työn ja heittää menemään rinnakkaisuuden, jota tulit hakemaan

Miksi jokainen renderöintityöntekijä tarvitsee oman TPDFlib-instanssinsa?

Koska instanssi on eristyksen yksikkö. Kun jokainen suoritinsäie omistaa oman yksityisen TPDFlib-instanssinsa, joka on ladannut tiedoston itsenäisesti, jokaisella on oma sivupuunsa, oma FPagePointer-kenttänsä ja oma renderöintitilansa, jolloin mitään jaettua ei ole kilpailutilanteelle alttiina. Tällä turvallisuudella on hinta, joka on syytä tiedostaa etukäteen: jokainen työntekijä jäsentää koko asiakirjan muistiin, joten huippumuistinkulutus on karkeasti N-kertainen verrattuna yhteen instanssiin. Kahdeksan työntekijää 300 megatavun PDF:n parissa tarkoittaa kahdeksaa täyttä jäsennystä muistissa samanaikaisesti, ja erittäin suurilla syötteillä tämä on rajoite, joka päättää työntekijöiden määrän, ei suoritin. Kun asiakirja on valtava ja muisti on suurempi rajoite kuin suoritin, artikkelissa suurten PDF-tiedostojen käsittely ilman koko asiakirjan jäsentämistä esitelty suora pääsyreitti on usein parempi vaihtoehto kuin renderöintisäikeiden lisääminen

Yhden kutsun API: RenderPagesToFilesParallel

losLab PDF Library paketoi koko turvallisen mallin yhden metodin taakse, joten tavallisessa tapauksessa sinun ei tarvitse koodata sitä itse. RenderPagesToFilesParallel ottaa vastaan tiedostonimen ja salasanan, DPI:n, aloitus- ja lopetussivun, Options-arvon, joka välitetään suoraan sivukohtaiselle rasterointipolulle, tulostusmallin, jossa %p korvataan sivunumerolla, ja työntekijöiden enimmäismäärän (worker cap), jossa mikä tahansa arvo nollassa tai sen alapuolella tarkoittaa automaattista määritystä. Se palauttaa onnistuneesti renderöityjen sivujen määrän ja on vain Windowsille tarkoitettu polku, koska se tukeutuu CoInitialize- ja GDI+-kirjastoihin

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(sivujen määrä, CPU-ytimet)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Miksi CoInitialize jokaiselle suoritinsäikeelle?

GDI+ on sivun renderöinnin taustalla toimiva rasteroija, ja GDI+ on asuntotyyppisesti säikeistetty (apartment-threaded): se odottaa, että COM on alustettu siinä säikeessä, joka sitä kutsuu. VCL-sovelluksen pääsäikeessä tämä on yleensä jo valmiiksi määritetty, mutta juuri luodussa TTask-työntekijässä ei ole, ja renderöintipolun kutsuminen alustamattomasta säikeestä johtaa vääjäämättä kaatumiseen. Siksi jokainen työntekijä yhdistää CoInitialize(nil)-kutsun alussa CoUninitialize-kutsuun lopussa, sulkien sisäänsä koko elinkaarensa. Tämä on sama kurinalaisuus, jota mikä tahansa GDI+- tai COM-työ tarvitsee pääsäikeen ulkopuolella, ja se on toinen puolisko siitä, mikä tekee työntekijäkohtaisesta eristyksestä toimivan (ensimmäisen puoliskon ollessa yksityinen instanssi). Sama GDI+-rasterointipolku ohjaa yksisäikeisiä moottoreita, joita käsitellään artikkelissa renderöintimoottorin valitseminen PDF-tulostusta varten

Staattinen jakaminen vastaan dynaaminen sivujen lunastus

Ilmeinen tapa jakaa 500 sivua 8 työntekijälle on antaa jokaiselle kiinteä noin 62 sivun lohko. losLab PDF Library ei tee niin, ja syynä on kuormituksen tasapainottaminen. Sivujen käsittelykustannukset vaihtelevat suuresti: leipätekstisivu renderöityy millisekunneissa, kun taas tiheitä vektorikarttoja tai kokonaan skannattua kuvaa sisältävä sivu voi viedä viisikymmentä kertaa kauemmin. Jos työ jaetaan kiinteisiin lohkoihin, työntekijä, jolle osuu raskas lohko, jatkaa työtään pitkään sen jälkeen, kun muut ovat jo lopettaneet. Tällöin kokonaisaikasi määräytyy epäonnekkaimman lohkon, ei keskiarvon mukaan. Sen sijaan jokainen työntekijä pyytää seuraavan sivun jaetusta laskurista lyhyen kriittisen osion (critical section) sisällä, renderöi sen ja palaa hakemaan uutta, mikä pitää jokaisen ytimen varattuna, kunnes koko alue on käsitelty

// Mitä kukin työntekijä tekee poolin sisällä (yksinkertaistettu)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ on apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // yksi yksityinen instanssi työntekijää kohden
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // lunasta seuraava sivu atomisesti
          try
            PageNum := NextPage;
            Inc(NextPage);
          finally
            IdxLock.Leave;
          end;
          if PageNum > EndPage then Break;
          LocalLib.RenderPageToFile(DPI, PageNum, 0,
            Format('page_%d.png', [PageNum]));
        end;
      finally
        LocalLib.Free;
      end;
    finally
      CoUninitialize;
    end;
  end;

Rakenteinen lokitus suoritinsäikeiden välillä

Sellaisten eräajojen virheenkorjaus, jotka kuolevat sivulla 213/500, on surkeaa ilman lokia, ja yksinkertainen loki on itsessään rinnakkaisuusongelma. losLab PDF Library toimittaa TPDFlibLogger-luokan, joka kytketään TPDFlib.Logger-ominaisuuden kautta ja on oletuksena nil, jotta lokittoman polun kustannus pysyy nollana. Se perustuu takaisinkutsuihin: asetat OnLog-tapahtuman ja reitität tietueet sinne, minne isäntä haluaa, suodatettuna tasoilla llDebug / llInfo / llWarn / llError. PDFlibErrorMessage muuntaa raa'at numeeriset koodit ihmisen luettavaksi tekstiksi, jotta Error-tietue on muutakin kuin pelkkä luku. Valinnainen tiedostolokitus on ainoa jaettu resurssi, ja sitä suojataan TCriticalSection-lukolla juuri siksi, että useat työntekijät voivat lisätä tietoa samaan lokitiedostoon turvallisesti. Huomaa rehellinen raja: vain kyseinen tiedostolokitus on synkronoitu, joten jos jaat yhden lokityökalun itse rakennetussa poolissa ja OnLog koskee käyttöliittymää, sinun on silti itse ohjattava (marshal) se takaisin pääsäikeeseen

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // valinnainen jaettu nielu (lukolla suojattu)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // ohjaa pääsäikeeseen itse; OnLog suoritetaan työsäikeissä
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // oletuksena nil; nollakustannus, kun sitä ei ole asetettu
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // Error sisältää nyt tekstiä, esim. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Kuinka paljon nopeutusta pitäisi todella odottaa?

Ole rehellinen itsellesi siitä, mihin aika kuluu, sillä rinnakkainen renderöinti kannattaa vain silloin, kun työ on todella CPU-sidonnaista. Korkean DPI:n tulostus ja monimutkaiset vektori- tai varjostetut sivut ovat laskennallisesti raskaita, ja ne skaalautuvat lähes lineaarisesti ytimien määrän mukaan, kunnes CPU kuormittuu täyteen. Yksinkertaiset sivut ovat toinen tarina: niissä työntekijäkohtainen LoadFromFile-ylirasitus ja tulostustiedostojen kirjoittamisen levynopeuskustannukset voivat hukuttaa itse renderöinnin, ja kahdeksan työntekijää hitaalla levyllä voi valmistua hitaammin kuin puhdas sarjamuotoinen silmukka. Aseta MaxWorkers vastaamaan fyysisten ytimiesi määrää jotakin utopistista suuremman sijaan, tarkkaile muistia, kun lähde-PDF on suuri, ja jos eräajo osoustuu IO-sidonnaiseksi, korjaus on nopeampi tallennustila tai vähemmän työntekijöitä, ei säikeiden lisääminen. Käytettynä töissä, joita varten se on rakennettu, tässä esitetty erärenderöintipolku on osa standardia Delphille ja C++Builderille tarkoitettua losLab PDF Library -kirjastoa, ja se muuttaa vapaat ytimet valmiiksi sivuiksi ilman säikeistysturvallisuuden sudenkuoppia, jotka sinun pitäisi muuten ratkaista itse