Techninis straipsnis

Lygiagretus PDF puslapių atvaizdavimas Delphi aplinkoje: gijų saugumas

Lygiagretus PDF puslapių atvaizdavimas Delphi aplinkoje susiveda į vieną taisyklę: kiekvienai darbiniai gijai suteikite atskirą atvaizduotoją. losLab PDF biblioteka pateikia RenderPagesToFilesParallel būtent šiam darbui, išsklaidydama puslapių diapazoną per TTask telkinį su vienu TPDFlib egzemplioriumi kiekvienam darbininkui, todėl daugiabruožis kompiuteris paketinį rastravimo darbą paverčia našumu, kuris skaluojasi beveik proporcyanai procesoriaus branduolių skaičiui. Jei vietoj to naudosite vieną bendrą egzempliorių keliose gijose, programa ne šiaip sulėtės, o sugadins atmintį ir užlūš

Tai yra straipsnis, kurio jums prireiks, kai naktinis darbas turi paversti 500 puslapių PDF failą į 500 PNG paveikslėlių, kompiuteryje yra 16 nenaudojamų branduolių, o jūsų pirmasis nuoširdus bandymas gijų pagalba tai atlikti suklupo GDI+ viduje. Trumpa versija yra ta, kad gijų saugumas čia yra struktūrinė savybė, o ne jūsų nustatoma vėliavėlė, o likusioje šio straipsnio dalyje apžvelgiama, kodėl saugi struktūra profesiniu požiūriu atrodo būtent taip ir kur iš tikrųjų yra realaus paspartėjimo riba

Ar TPDFlib yra saugi gijoms (thread-safe) lygiagrečiam atvaizdavimui?

Ne, ir priežastį verta suprasti prieš pradedant kurti sprendimą aplink jį. Vienas TPDFlib egzempliorius yra deklaruojamas naudoti vienoje gijoje, o aštriausia vieta yra TPDFPageTree.GetPage: pasirinkus puslapį, jis kaip šalutinį poveikį įrašo bendrinamą FPagePointer lauką egzemplioriuje. Dvi gijos, besikreipiančios į tą patį egzempliorių, lenktyniauja dėl šio lauko, todėl darbininkas A gali būti įpusėjęs 3 puslapį, kai darbininkas B perorientuoja puslapių medį į 40 puslapį. Niekas API draudimuose netrukdo parašyti žemiau pateikto kodo, ir jis netgi veiks kelis puslapius prieš sugesdamas, o tai yra blogiausias tokios klaidos elgsenos būdas

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

Gedimas nėra deterministinis, todėl jis puikiai praeina greitą patikrinimą ir vėliau iškyla kliento kompiuteryje, kuriame yra kitoks branduolių skaičius ir sunkesnis dokumentas. Taip pat nėra jokio užrakto (lock), kuriuo galėtumėte apgaubti RenderPageToFile, kad tai pigiai išspręstumėte, nes ekskliuzyvinis užraktas visam atvaizdavimo iškvietimo laikui tiesiog nuosekliai išrikiuoja darbą ir panaikina lygiagretumą, kurio siekėte

Kodėl kiekvienam darbininkui reikalingas atskiras TPDFlib egzempliorius?

Todėl, kad egzempliorius yra izoliacijos vienetas. Kai kiekvienas darbininkas turi privatų TPDFlib, kuris įkėlė failą nepriklausomai, kiekvienas turi savo puslapių medį, savo FPagePointer ir savo atvaizdavimo būseną, todėl nėra jokių bendrai naudojamų išteklių lenktynėms. Saugumas turi savo kainą, kurią turėtumėte įvertinti iš anksto: kiekvienas darbininkas analizuoja visą dokumentą atmintyje, todėl maksimalus atminties sunaudojimas yra maždaug N kartų didesnis nei vieno egzemplioriaus kaina. Aštuoni darbininkai apdorojantys 300 MB PDF reiškia aštuonias pilnas analizes vienu metu, ir esant labai didelėms įvestims tai yra apribojimas, kuris nusprendžia jūsų darbininkų skaičių, o ne CPU. Kai dokumentas yra didžiulis ir jus riboja atmintis, o ne CPU, tiesioginės prieigos kelias, aprašytas skyriuje didelių PDF apdorojimas be viso dokumento analizavimo, dažnai yra geresnė priemonė nei papildomos atvaizdavimo gijos

Vieno iškvietimo API: RenderPagesToFilesParallel

losLab PDF biblioteka supakuoja visą saugų šabloną į vieną metodą, todėl įprastu atveju jums nereikia nieko kurti rankiniu būdu. RenderPagesToFilesParallel priima failo pavadinimą ir slaptažodį, DPI, pradinį bei galinį puslapius imtinai, Options reikšmę, perduodamą tiesiai į puslapio rastravimo kelią, išvesties šabloną, kuriame %p yra pakeičiamas puslapio numeriu, ir darbininkų ribą, kur bet kokia reikšmė lygi arba mažesnė už nulį reiškia automatinį nustatymą. Metodas grąžina sėkmingai atvaizduotų puslapių skaičių ir yra skirtas tik „Windows“ operacinei sistemai, nes remiasi CoInitialize bei GDI+

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(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

Kodėl reikalinga CoInitialize kiekvienoje darbinėje gijoje?

GDI+ yra rastravimo variklis po puslapio atvaizdavimu, o GDI+ yra „apartment-threaded“: jis tikisi, kad COM bus inicializuotas bet kurioje gijoje, kuri kreipiasi į jį. Pagrindinė VCL programos gija paprastai tai jau turi sukonfigūravusi, tačiau naujai sukurta TTask darbinė gija to neturi, o atvaizdavimo kelio iškvietimas iš neinicializuotos gijos yra patikimas būdas užlūžti. Taigi kiekvienas darbininkas suderina CoInitialize(nil) įėjime su CoUninitialize išėjime, apgaubdamas visą savo gyvavimo laikotarpį. Tai yra ta pati disciplina, kurios reikia bet kokiam GDI+ ar COM darbui už pagrindinės gijos ribų, ir tai yra antroji pusė to, kas iš tikrųjų užtikrina darbininkų izoliaciją, o pirmoji yra privatus egzempliorius. Tas pats GDI+ rastravimo kelias valdo vienagijus variklius, aptartus straipsnyje atvaizdavimo variklio pasirinkimas PDF išvesčiai

Statinis skaidymas prieš dinaminį puslapių paėmimą

Akivaizdus būdas padalyti 500 puslapių tarp 8 darbininkų yra kiekvienam atiduoti fiksuotą dalį po maždaug 62 puslapius. losLab PDF biblioteka to nedaro, ir to priežastis yra apkrovos subalansavimas. Puslapio kaina labai skiriasi: pagrindinio teksto puslapis atvaizduojamas per kelias milisekundes, o tankių vektorinių žemėlapių puslapis arba pilnai nuskaitytas vaizdas gali užtrukti penkiasdešimt kartų ilgiau. Padalykite darbą į fiksuotas dalis, ir darbininkas, kuriam teko sunkioji dalis, veiks dar ilgai po to, kai kiti jau bus baigę darbą, todėl jūsų bendras laikas bus nulemtas sunkiausios dalies, o ne vidurkio. Vietoj to kiekvienas darbininkas paima kitą puslapį iš bendro skaitiklio trumpos kritinės sekcijos metu, jį atvaizduoja ir grįžta kito, todėl visi branduoliai lieka užimti, kol bus išnaudotas visas diapazonas

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          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;

Struktūrizuotas žurnalo rašymas keliose darbinėse gijose

Derinti paketinį darbą, kuris nutrūksta ties 213 puslapiu iš 500, yra sunku be žurnalo failo, tačiau paprastas žurnalo rašymas gali būti gijų konkurencijos klaida. losLab PDF biblioteka siūlo TPDFlibLogger, priskiriamą per savybę TPDFlib.Logger, kuri pagal nutylėjimą yra nil, todėl darbas be žurnalo lieka nemokamas. Jis veikia atgalinio iškvietimo principu: nustatote OnLog ir nukreipiate įrašus ten, kur nori šeimininkas, filtruodami pagal llDebug / llInfo / llWarn / llError lygius, o PDFlibErrorMessage paverčia skaitinius kodus į žmonėms suprantamą tekstą, kad Error įrašas būtų aiškesnis nei tiesiog skaičius. Pasirinktinis failo gavėjas yra vienintelis bendrinamas išteklius ir jis yra saugomas naudojant TCriticalSection tam, kad keli darbininkai galėtų saugiai rašyti į vieną žurnalo failą. Atkreipkite dėmesį į aiškią ribą: sinchronizuojamas tik šis failo gavėjas, todėl jei bendrinate vieną registratorių rankiniu būdu sukurtame telkinyje ir jūsų OnLog sąveikauja su vartotojo sąsaja, turite patys nukreipti šį veiksmą atgal į pagrindinę giją

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

Kokio paspartėjimo iš tikrųjų turėtumėte tikėtis?

Būkite sąžiningi su savimi dėl to, kur dingsta laikas, nes lygiagretus atvaizdavimas atsiperka tik tada, kai darbas yra ribojamas CPU. Didelės raiškos išvestis ir sudėtingi vektoriniai arba šešėliais padengti puslapiai reikalauja daug skaičiavimų, ir jie skaluojasi beveik tiesiškai su branduolių skaičiumi, kol neapkraunamas CPU. Paprasti puslapiai yra kita istorija: ten kiekvieno darbininko LoadFromFile viršvalandžiai ir failų įrašymo į diską sąnaudos gali užgožti patį atvaizdavimą, o aštuoni darbininkai, besigrumiantys dėl vieno lėto disko, gali baigti darbą lėčiau nei tvarkingas nuoseklus ciklas. Nustatykite MaxWorkers pagal savo fizinių branduolių skaičių, o ne pagal kažką siekiamo, stebėkite atmintį, kai šaltinio PDF yra didelis, ir jei pasirodys, kad paketas ribojamas IO, sprendimas yra greitesnė saugykla arba mažiau darbininkų, o ne daugiau gijų. Naudojamas darbams, kuriems buvo sukurtas, čia parodytas paketinis atvaizdavimo kelias yra standartinės losLab PDF Library, skirtos Delphi ir C++Builder, dalis, ir jis paverčia nenaudojamus branduolius baigtais puslapiais be jokių gijų saugumo spąstų, kuriuos kitu atveju turėtumėte spręsti patys