Tehnički članak

FillChar nad rezultatom funkcije izaziva curenje stringova u Object Pascalu

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