Funkcija Delphi ili FPC-a koja vraća zapis ne dobiva novi, nulirani Result pri svakom pozivu. Ta skrivena varijabla Result počinje kao nula samo jednom i ništa je automatski ponovno ne nulira između poziva, pa je brisanje pri ulasku zadaća same funkcije. Ako to brisanje obavite pomoću FillChar(Result, SizeOf(Result), 0), rutina od drugog poziva nadalje prepisuje živu referencu stringa ili dinamičkog polja umjesto da je oslobodi, pa blok memorije na koji je referenca pokazivala ostaje bez vlasnika
Scenarij u kojem se to događa sasvim je svakodnevan. Skupni proces otvara niz PDF-ova trećih strana i prolazi kroz svaku bilješku na svakoj stranici, preuzimajući tekst komentara u dnevnik revizije. Ništa u toj petlji ne izgleda opasno: svaki je poziv obična funkcija koja vraća običan zapis, nema pokazivača na vidiku i ničega što bi nalikovalo ručnom upravljanju memorijom. Brojanje referenci unutar zapisa obično je pravilo računanja u Object Pascalu, a ne posebnost neke biblioteke, pa je ista greška moguća u svakoj bazi koda Delphi ili FPC-a koja kombinira FillChar s vrstama zapisa koje sadrže stringove ili dinamička polja
Zašto FillChar nad rezultatom zapisa uzrokuje curenje stringova?
FillChar uzrokuje curenje stringova jer ne zna kakvu vrstu podataka prepisuje. FillChar(X, Count, Value) radi nad bilo kojom varijablom: uzima netipizirani blok od Count bajtova i svaki od njih ispunjava vrijednošću Value, a to je cijeli ugovor. Upravo zato je FillChar brz i opće namjene, jer nikada ne pregledava vrstu X i ne odlučuje na temelju značenja temeljnih bajtova. Polje UnicodeString ili WideString unutar zapisa nisu sami znakovi, nego pokazivač na blok memorije na hrpi koji prije znakovnih podataka nosi broj referenci. FillChar vidi nekoliko bajtova koji slučajno sadrže vrijednost pokazivača i prepisuje ih nulom, baš kao što bi prepisao polje Integer ili Double. Pokazivač nestaje, broj referenci koji je prije toga trebalo smanjiti nikada se ne dotakne, a blok na koji je pokazivao ostaje zauzet bez ijedne preostale reference
Kako prevoditelj prati stringove i dinamička polja unutar zapisa
Object Pascal vrstu naziva upravljanom kada prevoditelj mora pokrenuti dodatni kod kako bi ona ostala ispravna tijekom pridruživanja i izlaska iz opsega. Duge vrste stringova kao što su AnsiString, UnicodeString i WideString pripadaju toj skupini, kao i dinamička polja, sučelja i Variant, zajedno sa svakim zapisom ili fiksnim poljem koje jedno od njih sadrži kao polje. Za svako upravljano polje prevoditelj tiho generira računovodstvo koje bi ručno bilo zamorno i sklono pogreškama: povećava broj referenci pri pridruživanju, smanjuje ga kada se varijabla koja ga drži prepiše ili izađe iz opsega te oslobađa temeljni blok kada broj dosegne nulu. Taj mehanizam objašnjava zašto običan Pascalov kod nikada ručno ne alocira ni ne oslobađa string i zašto je pridruživanje jednog dinamičkog polja drugome jeftina i sigurna radnja, a ne ručna petlja kopiranja. System.Default i Finalize dva su dokumentirana načina da se ta ista logika oslobađanja pozove na zahtjev i njih treba pozvati u kodu za čišćenje zapisa umjesto sirovog popunjavanja memorije
type
TLineItem = record
Description: string; // managed: reference-counted
Quantity: Integer; // unmanaged: plain ordinal
end;
function GetLineItem(Index: Integer): TLineItem;
begin
FillChar(Result, SizeOf(Result), 0); // clears bytes, not the reference
Result.Quantity := Source[Index].Qty;
Result.Description := Source[Index].Text;
end;
var
Item: TLineItem;
I: Integer;
begin
for I := 0 to High(Source) do
begin
Item := GetLineItem(I); // second pass onward: leaks the prior Description
Log.Add(Item.Description);
end;
end;
Zašto curenje počinje tek pri drugom pozivu?
Prvi je poziv u petlji uvijek bezopasan, upravo zbog čega se ta greška lako previdi pri testiranju. Lokalna varijabla upravljanog zapisa počinje kao nula i ništa je automatski ponovno ne nulira između jednog prolaza petlje i sljedećeg, pa je pri prvom pridruživanju povratne vrijednosti funkcije toj varijabli polje Description ili ContentsText još uvijek nil. FillChar prepisuje nil nulom, što ne mijenja ništa u pogledu broja referenci, pa poziv završava potpuno ispravno. Drugi je poziv drukčiji: ista lokalna varijabla već sadrži ono što je zapisao prvi poziv, a Result novog poziva zapisuje se izravno u isti prostor umjesto u novu, praznu memoriju. FillChar na početku drugog poziva nulira polje koje više nije nil i sve što slijedi iz tog uzorka bajtova od tog je trenutka neprimjetno pogrešno. Test koji funkciju pozove jednom i pregleda rezultat nikada neće otkriti problem; otkriva ga tek petlja ili bilo koji put koda koji funkciju ponavlja nad istim odredištem
Stvarno curenje: zapisi bilješki, knjižnih oznaka i poveznica
PDFiumPas je prije verzije 1.56.4 sadržavao upravo tu grešku u trima funkcijama koje vraćaju zapis s barem jednim upravljanim poljem: čitač bilješki na razini stranice vraća TPdfAnnotation sa stringovima ContentsText i AuthorText, čitač knjižnih oznaka vraća TBookmark sa stringom Title, a čitač bilješki poveznica vraća TLinkAnnotation sa stringom ActionPath i dinamičkim poljem Points. Sve tri funkcije počinjale su istim obrascem prikazanim u nastavku: očisti Result sirovim pozivom FillChar, zatim jedno po jedno popuni polja podacima temeljne stranice. Prolazak kroz svaku bilješku na stranici, uobičajen način izrade popisa za reviziju ili ploče za pregled, pozivao je čitač bilješki u petlji i nakon prvog prolaza curio je tekst prethodne bilješke; PDF s neuobičajeno velikim brojem bilješki koje sadrže tekst mogao je povećavati memoriju dugotrajnog procesa sve dok je proces radio. Popravak je zahvatio po jedan redak u svakoj funkciji: zamjena FillChar(Result, SizeOf(Result), 0) s Result := Default(TPdfAnnotation) bila je dovoljna jer pridruživanje Default upravljanom zapisu pokreće uobičajeni prevoditeljev slijed oslobađanja pa čišćenja umjesto sirovog popunjavanja memorije
function GetPageAnnotation(Page: FPDF_PAGE; Index: Integer): TPdfAnnotation;
var
Annotation: FPDF_ANNOTATION;
ContentLength: LongWord;
begin
Annotation := FPDFPage_GetAnnot(Page, Index);
FillChar(Result, SizeOf(Result), 0); // clears bytes, not a live reference
Result.Subtype := DecodeAnnotationSubtype(FPDFAnnot_GetSubtype(Annotation));
ContentLength := FPDFAnnot_GetStringValue(Annotation,
FPDFANNOT_TEXTTYPE_Contents, nil, 0);
if ContentLength >= 4 then
begin
SetLength(Result.ContentsText, ContentLength div 2 - 1);
FPDFAnnot_GetStringValue(Annotation, FPDFANNOT_TEXTTYPE_Contents,
Pointer(Result.ContentsText), ContentLength);
end;
end;
Ista opasnost iza parametra var
Čitač knjižnih oznaka pokazuje suptilniju inačicu istog problema jer zapis koji se čisti pomoću FillChar nije vlastiti Result funkcije, nego parametar var jedan poziv niže. SetBookmarkData prima izlaz kao var Data: TBookmark i ranije je na početku tijela čistio Data pomoću FillChar; GetBookmark, javna funkcija koja stvarno vraća TBookmark, poziva SetBookmarkData i vlastiti Result prosljeđuje izravno kao taj argument var. Parametar var prosljeđuje se referencom, pa su Data unutar SetBookmarkData i Result unutar GetBookmark isti prostor pod dvama imenima, a rizik aliasa koji vrijedi za vlastiti Result funkcije jednako izravno vrijedi za pomoćnu rutinu koja ga primi referencom. Pregled samo funkcija koje izričito deklariraju povratnu vrstu zapisa propušta ovaj obrazac; pretraga mora pratiti svaki parametar var i out u koji se prosljeđuje i Result
procedure TPdf.SetBookmarkData(Bookmark: FPDF_BOOKMARK; var Data: TBookmark);
var
BufferSize: LongWord;
begin
Data := Default(TBookmark); // fixed: was FillChar(Data, SizeOf(Data), 0)
Data.Handle := Bookmark;
if Bookmark <> nil then
begin
BufferSize := FPDFBookmark_GetTitle(Bookmark, nil, 0);
if BufferSize >= 4 then
begin
SetLength(Data.Title, BufferSize div 2 - 1);
FPDFBookmark_GetTitle(Bookmark, PWideChar(Data.Title), BufferSize);
end;
end;
end;
function TPdf.GetBookmark(const Title: WString): TBookmark;
begin
CheckActive;
SetBookmarkData(FPDFBookmark_Find(FDocument, PWideChar(Title)), Result);
end;
Kada je FillChar i dalje pravi izbor?
FillChar je i dalje ispravan, a često i nešto jeftiniji za zapis sastavljen isključivo od cijelih i rednih vrijednosti, polja s pomičnim zarezom, fiksnih polja takvih vrijednosti ili drugih običnih zapisa iz istih elemenata jer u njemu nema ničega što bi prevoditelj morao finalizirati. Vlastita vrsta pravokutnika u PDFiumPasu upravo je takav slučaj: TPdfRectangle sadrži četiri polja Double i ništa drugo, a čišćenje pomoću FillChar ne oslobađa ništa jer nema ničega čiji bi se broj referenci morao smanjiti. Provjera koja razlikuje ta dva slučaja jednostavna je: ima li ijedno polje zapisa, na bilo kojoj dubini ugniježđenosti, vrstu string, AnsiString, WideString, dinamičko polje, sučelje ili Variant? Zapis na najvišoj razini može izgledati potpuno numerički, a ipak pasti na toj provjeri ako je jedno od njegovih polja zapis koji nekoliko razina dublje skriva string, pa provjera mora pratiti ugniježđene zapise do kraja, a ne stati na popisu vanjskih polja. Revizija postojeće baze koda prema tom obrascu mehanička je, a ne iscrpna: pronađite svaki poziv FillChar čije je odredište varijabla zapisa, zatim usporedite popis polja tog zapisa s gornjim popisom upravljanih vrsta. Vlastita revizija PDFiumPasa za v1.56.4 provela je upravo takvu pretragu kroz cijelu biblioteku i pronašla ovu izloženost u jednoj jedinici; svako je drugo mjesto poziva FillChar već čistilo običan brojčani zapis, gdje je FillChar bio i ostao pravi alat
Isto ponašanje prevoditelja koje ovdje ponovno korišteni Result čini opasnim pokreće i srodnu skupinu neslaganja između Delphi-ja i FPC-a drugdje u ovoj bazi koda; popratni članak o zamkama među prevoditeljima obrađuje slučaj u kojem se FPC i Delphi ne slažu oko točnog trenutka finalizacije privremenog rezultata zapisa unutar jednog izraza, što je drugačiji simptom iste temeljne činjenice da Result zapisa funkcije nije uvijek svježi privatni prostor kakvim se čini. Petlja bilješki koja se kroz ovaj članak koristi kao primjer također nije hipotetska: riječ je o istom prolasku stranicu po stranicu koji biste napisali pri izradi ploče za pregled bilješki, upravo onom obliku koda koji je jednoredni FillChar pretvorio u sporo curenje memorije
Ništa od ovoga ne zahtijeva promjenu biblioteke ni traženje greške u tuđem prevedenom kodu: to je svojstvo samog jezika Object Pascal s kojim svaki razvojni programer u Delphiju ili FPC-u svakodnevno radi, a popravak je jedan poziv funkcije kada znate što tražiti. Ovdje opisani API-ji za bilješke, knjižne oznake i bilješke poveznica isporučuju se u sklopu komponente PDFium Component za Delphi, C++Builder i Lazarus/FPC, uz ostatak površine za čitanje, iscrtavanje i bilješke u PDF-u obrađene drugdje na ovom blogu