Ako Delphi ili FPC funkcija vraća zapis, pri svakom pozivu ne dobija nov, nuliran Result. Skrivena promenljiva Result počinje nulirana tačno jednom, a između poziva se automatski više ne nulira, pa je za to zadužena sama funkcija. Ako to nuliranje uradite pomoću FillChar(Result, SizeOf(Result), 0), od drugog poziva nadalje rutina prepisuje aktivnu referencu na string ili dinamički niz umesto da je oslobodi, pa memorijski blok na koji je referenca pokazivala ostaje bez vlasnika
Situacija u kojoj se to ispoljava sasvim je svakodnevna. Grupna obrada otvara niz PDF-ova nezavisnih proizvođača i prolazi kroz svaku napomenu na svakoj stranici, preuzimajući tekst komentara u dnevnik revizije. Ništa u toj petlji ne deluje opasno: svaki poziv je obična funkcija koja vraća običan zapis, bez vidljivih pokazivača i bez ičega što bi ličilo na ručno upravljanje memorijom. Brojanje referenci unutar zapisa je osnovno pravilo Object Pascala, a ne posebnost neke biblioteke, pa je svaka Delphi ili FPC baza koda koja meša FillChar sa zapisima koji sadrže stringove ili dinamičke nizove izložena istom nedostatku
Zašto FillChar nad rezultatom zapisa dovodi do curenja stringova?
FillChar curi stringove zato što ne zna koju vrstu podataka prepisuje. FillChar(X, Count, Value) radi nad bilo kojom promenljivom: uzima neotkucani blok od Count bajtova i svaki bajt postavlja na Value, i to je ceo ugovor funkcije. Upravo zato je FillChar brz i opšte namene, jer nikada ne pregleda tip X niti odlučuje na osnovu značenja bajtova. Polje UnicodeString ili WideString unutar zapisa nisu sami znakovi; ono je pokazivač na memorijski blok koji pre podataka sa znakovima sadrži brojač referenci. FillChar vidi nekoliko bajtova koji slučajno sadrže vrednost pokazivača i prepisuje ih nulama, baš kao polje Integer ili Double. Pokazivač nestaje, brojač referenci koji je prethodno trebalo smanjiti ostaje netaknut, a blok na koji je pokazivao ostaje zauzet bez ijedne preostale reference
Kako kompajler prati stringove i dinamičke nizove unutar zapisa
Object Pascal tip naziva upravljanim kada kompajler mora da pokrene dodatni kôd kako bi ostao ispravan tokom dodeljivanja i izlaska iz opsega. Dugi stringovi kao AnsiString, UnicodeString i WideString spadaju u tu grupu, kao i dinamički nizovi, interfejsi i Variant tipovi, zajedno sa svakim zapisom ili statičkim nizom koji takvo polje sadrži. Za svako upravljano polje kompajler tiho generiše knjigovodstvo koje bi inače bilo zamorno i lako pogrešno ručno napisati: uvećava brojač referenci pri dodeli, smanjuje ga kada se promenljiva koja ga drži prepiše ili izađe iz opsega i oslobađa osnovni blok kada brojač dođe do nule. Ta mehanika je razlog zašto običan Pascal kôd nikada ručno ne alocira niti oslobađa string, kao i zašto je dodela jednog dinamičkog niza drugom jeftina i bezbedna umesto da zahteva ručnu petlju kopiranja. System.Default i Finalize su dva dokumentovana načina da se ista logika oslobađanja pozove na zahtev, i njih treba da pozove kôd za čišćenje zapisa umesto 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 poziv u petlji uvek je bezopasan, što upravo olakšava da se ovaj nedostatak previdi pri testiranju. Lokalna promenljiva upravljanog tipa zapisa počinje nulirana, a između prolaza petlje se automatski više ne nulira, pa su pri prvom upisivanju povratne vrednosti funkcije u tu promenljivu polja Description ili ContentsText još uvek nil. FillChar prepisuje nil nulom, što ne menja ništa u pogledu brojača referenci, i poziv se završava potpuno ispravnim rezultatom. Drugi poziv je drugačiji: ista lokalna promenljiva već sadrži ono što je upisao prvi poziv, a Result novog poziva upisuje se direktno u isti prostor, a ne u novu, praznu memoriju. FillChar na početku tog drugog poziva nulira polje koje više nije nil, pa je sve što se dalje oslanja na taj obrazac bajtova od tog trenutka neprimetno pogrešno. Test koji jednom pozove funkciju i pregleda rezultat nikada neće otkriti problem; otkriva ga tek petlja ili bilo koja putanja koja funkciju više puta pozove nad istim odredištem
Stvarno curenje: napomene, obeleživači i zapisi veza
PDFiumPas je pre verzije 1.56.4 zaista isporučivao ovaj nedostatak, u tri funkcije koje vraćaju zapis sa najmanje jednim upravljanim poljem: čitač napomena na nivou stranice vraća TPdfAnnotation sa stringovima ContentsText i AuthorText, čitač obeleživača vraća TBookmark sa stringom Title, a čitač napomena sa vezama vraća TLinkAnnotation sa stringom ActionPath i dinamičkim nizom Points. Sve tri funkcije počinjale su isto kao primer ispod: sirovim FillChar čistile su Result, a zatim jedno po jedno popunjavale polja podacima sa stranice. Kada se svaka napomena na stranici čita redom, što je uobičajen način za pravljenje revizijske liste ili panela za pregled, čitač napomena poziva se u petlji i pri svakom prolazu posle prvog curi tekst prethodne napomene; PDF napravljen sa neobično velikim brojem napomena koje sadrže tekst može povećavati memoriju dugotrajnog procesa sve dok taj proces radi. Ispravka je zahvatila po jedan red u svakoj funkciji: zamena FillChar(Result, SizeOf(Result), 0) sa Result := Default(TPdfAnnotation) bila je dovoljna, jer dodela Default upravljanom zapisu pokreće uobičajeni kompajlerski redosled oslobađanja pa čišćenja umesto 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 var parametra
Čitač obeleživača pokazuje suptilniju verziju istog problema, jer zapis koji se čisti pomoću FillChar nije sopstveni Result funkcije, već var parametar jedan poziv niže. SetBookmarkData prima izlaz kao var Data: TBookmark i ranije je na početku tela čistio Data pomoću FillChar; GetBookmark, javna funkcija koja zaista vraća TBookmark, poziva SetBookmarkData i prosleđuje sopstveni Result direktno kao taj var argument. var parametar prosleđuje se po referenci, pa su Data unutar SetBookmarkData i Result unutar GetBookmark isti prostor pod dva imena, a opasnost od aliasinga koja važi za sopstveni Result funkcije jednako direktno važi za svaku pomoćnu rutinu koja ga prima po referenci. Ako se pregledaju samo funkcije koje doslovno deklarišu povratni tip zapisa, ovaj oblik promiče; pretraga mora da obuhvati svaki var i out parametar u koji se prosleđuje 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 poziv?
FillChar je i dalje ispravan, a često i neznatno jeftiniji, za zapis sastavljen isključivo od celobrojnih tipova, polja sa pokretnim zarezom, statičkih nizova takvih polja ili drugih običnih zapisa sastavljenih na isti način, jer u njemu nema ničega što kompajler mora da finalizuje. Tip pravougaonika koji koristi PDFiumPas upravo je takav slučaj: TPdfRectangle sadrži četiri polja tipa Double i ništa drugo, pa čišćenje pomoću FillChar ništa ne oslobađa jer nema ničega sa brojanjem referenci što bi trebalo osloboditi. Provera koja razdvaja ta dva slučaja jednostavna je: da li bilo koje polje zapisa, na bilo kom nivou ugnježđivanja, ima tip string, AnsiString, WideString, dinamički niz, interfejs ili Variant? Zapis na najvišem nivou može izgledati potpuno brojčano, a ipak pasti na toj proveri ako je jedno njegovo polje zapis koji nekoliko nivoa dublje skriva string, zato treba pratiti ugnježđene zapise do kraja umesto stati kod spoljnog spiska polja. Revizija postojeće baze koda za ovaj obrazac mehanička je, a ne iscrpna: pronađite svaki poziv FillChar čija je meta promenljiva tipa zapis, pa spisak polja tog zapisa uporedite sa gornjim spiskom upravljanih tipova. Revizija PDFiumPas-a za v1.56.4 izvršila je upravo takvu pretragu kroz celu biblioteku i pronašla ovu izloženost u jednoj jedinici; na svim ostalim mestima FillChar je već čistio obične brojčane zapise, gde je bio i ostao pravi alat
Isto ponašanje kompajlera koje ovde čini ponovo korišćeni Result opasnim pokreće i srodnu grupu neslaganja između Delphi-ja i FPC-a na drugim mestima u ovoj bazi koda; prateći članak o zamkama između kompajlera obrađuje slučaj u kojem se FPC i Delphi ne slažu oko tačnog trenutka kada se privremeni rezultat zapisa finalizuje unutar jednog izraza, što je drugačiji simptom iste osnovne činjenice da Result zapisa funkcije nije uvek svež, privatni prostor kakvim izgleda. Petlja kroz napomene koja se koristi kao primer u ovom članku takođe nije hipotetička: to je isti prolaz stranicu po stranicu koji biste napisali dok pravite panel za pregled napomena, upravo oblik koda koji je od jednolinijskog FillChar napravio sporo curenje memorije
Ništa od ovoga ne zahteva promenu biblioteke niti traženje greške u tuđem kompajliranom kôdu: to je osobina samog jezika Object Pascal sa kojom svakodnevno rade svi Delphi i FPC programeri, a ispravka je jedan poziv funkcije kada znate šta treba tražiti. Ovde opisani API-ji za napomene, obeleživače i napomene sa vezama isporučuju se kao deo PDFium Component za Delphi, C++Builder i Lazarus/FPC, zajedno sa ostalim mogućnostima za čitanje, iscrtavanje i rad sa napomenama opisanim na ovom blogu