Bài viết kỹ thuật

FillChar trên kết quả hàm làm rò rỉ chuỗi trong Object Pascal

Một hàm Delphi hay FPC trả về một bản ghi không nhận được một Result mới, đặt về không ở mỗi lệnh gọi. Biến Result ẩn đó bắt đầu bằng không đúng một lần duy nhất, và không có gì tự động đặt lại nó về không giữa các lệnh gọi, nên việc xóa nó lúc vào là công việc riêng của hàm. Làm việc xóa đó bằng FillChar(Result, SizeOf(Result), 0) và, từ lệnh gọi thứ hai trở đi, routine ghi đè lên một tham chiếu chuỗi hay mảng động đang sống thay vì giải phóng nó, để lại mồ côi bất cứ khối heap nào mà tham chiếu đó từng trỏ tới

Kịch bản nơi điều này cắn là bình thường. Một tiến trình hàng loạt mở một chồng PDF bên thứ ba và duyệt qua mọi chú thích trên mọi trang, kéo văn bản comment vào một log kiểm toán. Không có gì về vòng lặp đó trông nguy hiểm: mỗi lệnh gọi là một hàm thuần túy trả về một bản ghi thuần túy, không con trỏ nào trong tầm mắt, không gì giống quản lý bộ nhớ thủ công cả. Đếm tham chiếu bên trong một bản ghi là một quy tắc kế toán Object Pascal thuần túy, không phải một điểm kỳ quặc đặc thù của bất kỳ thư viện nào, và bất kỳ codebase Delphi hay FPC nào trộn FillChar với các kiểu bản ghi mang chuỗi hay mảng động đều phơi bày trước cùng lỗi này

Vì sao FillChar trên một kết quả bản ghi làm rò rỉ chuỗi?

FillChar làm rò rỉ chuỗi vì nó không có ý niệm gì về loại dữ liệu nó đang ghi đè. FillChar(X, Count, Value) hoạt động trên bất kỳ biến nào: nó lấy một khối không định kiểu gồm Count byte và đóng dấu mỗi byte bằng Value, và đó là toàn bộ hợp đồng. Chính điều này khiến FillChar nhanh và đa dụng, vì nó không bao giờ kiểm tra kiểu của X và không bao giờ rẽ nhánh theo ý nghĩa của các byte bên dưới. Một trường UnicodeString hay WideString bên trong một bản ghi không phải chính các ký tự; nó là một con trỏ đến một khối heap mang một bộ đếm tham chiếu đứng trước dữ liệu ký tự. FillChar thấy một số byte tình cờ giữ một giá trị con trỏ và ghi đè chúng bằng không đúng như nó sẽ ghi đè một trường Integer hay Double. Con trỏ biến mất, bộ đếm tham chiếu lẽ ra phải giảm trước không bao giờ bị đụng tới, và khối nó trỏ tới nằm đó được cấp phát mà không còn gì tham chiếu đến nó nữa

Trình biên dịch theo dõi chuỗi và mảng động bên trong một bản ghi như thế nào

Object Pascal gọi một kiểu là "có quản lý" khi trình biên dịch phải chạy code thêm để giữ nó đúng đắn qua phép gán và khi ra khỏi phạm vi. Các kiểu chuỗi dài như AnsiString, UnicodeString, và WideString đủ tiêu chuẩn, và các mảng động, interface, và Variant cũng vậy, cùng với bất kỳ bản ghi hay mảng kích thước cố định nào chứa một trong số đó làm trường. Với mỗi trường có quản lý, trình biên dịch âm thầm phát ra công việc kế toán mà nếu không sẽ tẻ nhạt và dễ làm sai bằng tay: tăng một bộ đếm tham chiếu khi gán, giảm nó khi biến giữ bị ghi đè hay ra khỏi phạm vi, và giải phóng khối bên dưới một khi bộ đếm đó chạm không. Cỗ máy đó là lý do vì sao code Pascal thông thường không bao giờ tự cấp phát hay giải phóng một string bằng tay, và vì sao việc gán một mảng động này cho một mảng động khác là một thao tác rẻ, an toàn thay vì một vòng lặp copy thủ công. System.DefaultFinalize là hai cách được ghi chép để kích hoạt cùng logic giải phóng đó theo yêu cầu, và đó là những gì code xóa của một bản ghi nên gọi thay vì một phép fill bộ nhớ thô

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;

Vì sao rò rỉ chỉ bắt đầu ở lệnh gọi thứ hai?

Lệnh gọi đầu tiên trong một vòng lặp luôn vô hại, chính điều đó khiến lỗi này dễ bị bỏ sót trong lúc test. Một biến cục bộ thuộc kiểu bản ghi có quản lý bắt đầu bằng không, và không có gì tự động đặt lại nó về không giữa một lượt vòng lặp và lượt tiếp theo, nên lần đầu tiên một vòng lặp gán giá trị trả về của một hàm vào biến đó, trường Description hay ContentsText của nó vẫn là nil. FillChar ghi đè nil bằng không, điều này không thay đổi gì xét về bộ đếm tham chiếu, và lệnh gọi trả về trông hoàn toàn đúng đắn. Lệnh gọi thứ hai thì khác: cùng biến cục bộ đó đã giữ bất cứ thứ gì lệnh gọi đầu tiên đã ghi vào nó, và Result của lệnh gọi mới được ghi trực tiếp vào chính storage đó thay vì vào bộ nhớ mới, rỗng. FillChar ở đầu lệnh gọi thứ hai đó đặt về không một trường không còn là nil, và mọi thứ ở hạ nguồn của mẫu byte đó đều sai âm thầm từ đó trở đi. Một test gọi hàm một lần và kiểm tra kết quả sẽ không bao giờ thấy vấn đề; chỉ một vòng lặp, hay bất kỳ đường code nào gọi hàm lặp lại trên cùng đích, mới phơi bày nó

Một rò rỉ thật: chú thích, bookmark, và bản ghi liên kết

PDFiumPas từng phát hành đúng lỗi này trước phiên bản 1.56.4, trong ba hàm mỗi hàm trả về một bản ghi mang ít nhất một trường có quản lý: bộ đọc chú thích cấp trang trả về một TPdfAnnotation mang các chuỗi ContentsTextAuthorText, bộ đọc bookmark trả về một TBookmark mang một chuỗi Title, và bộ đọc chú thích liên kết trả về một TLinkAnnotation mang một chuỗi ActionPath và một mảng động Points. Cả ba đều mở đầu với cùng hình dạng hiển thị dưới đây: xóa Result bằng một FillChar thô, sau đó điền các trường từng cái một từ dữ liệu trang bên dưới. Việc duyệt qua mọi chú thích trên một trang từng cái một, cách thông thường để xây dựng một danh sách kiểm toán hay một bảng review, gọi bộ đọc chú thích trong một vòng lặp và làm rò rỉ văn bản của chú thích trước đó ở mỗi lượt sau lượt đầu tiên; một PDF được tạo với một số lượng bất thường lớn các chú thích mang văn bản có thể làm bộ nhớ của một tiến trình chạy dài phình to trong suốt thời gian tiến trình đó tiếp tục chạy. Bản sửa chạm vào một dòng trong mỗi hàm: thay FillChar(Result, SizeOf(Result), 0) bằng Result := Default(TPdfAnnotation) là đủ, vì gán Default cho một bản ghi có quản lý chạy chuỗi giải-phóng-rồi-xóa thông thường của trình biên dịch thay vì một phép fill bộ nhớ thô

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;

Cùng mối nguy hiểm đứng sau một tham số var

Bộ đọc bookmark thể hiện một phiên bản tinh vi hơn của cùng vấn đề, vì bản ghi được xóa bằng FillChar không phải Result riêng của hàm mà là một tham số var ở một lệnh gọi bên dưới. SetBookmarkData nhận đầu ra của nó dưới dạng var Data: TBookmark và từng xóa Data ở đầu thân hàm bằng FillChar; GetBookmark, hàm công khai thực sự trả về một TBookmark, gọi SetBookmarkData và truyền chính Result của nó thẳng qua như tham số var đó. Một tham số var được truyền theo tham chiếu, nên Data bên trong SetBookmarkDataResult bên trong GetBookmark là cùng storage dưới hai cái tên, và bất kỳ rủi ro aliasing nào áp dụng cho Result riêng của một hàm cũng áp dụng trực tiếp không kém cho bất kỳ routine hỗ trợ nào nhận nó theo tham chiếu. Chỉ review các hàm khai báo tường minh một kiểu trả về là bản ghi sẽ bỏ sót hình dạng này; việc tìm kiếm phải theo mọi tham số varout mà một Result được chuyển tiếp vào đó nữa

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;

Khi nào FillChar vẫn là lựa chọn đúng?

FillChar vẫn đúng, và thường rẻ hơn một chút, cho một bản ghi hoàn toàn được xây dựng từ ordinal, trường dấu chấm động, mảng kích thước cố định của những kiểu đó, hay các bản ghi thuần túy khác được tạo từ cùng chất liệu, vì không có gì trong đó để trình biên dịch phải finalize. Kiểu hình chữ nhật riêng của PDFiumPas chính xác là trường hợp đó: TPdfRectangle giữ bốn trường Double và không gì khác, và việc xóa một trường bằng FillChar không giải phóng gì vì không có gì đếm tham chiếu để giải phóng. Kiểm tra tách biệt hai trường hợp này đơn giản để phát biểu: liệu bất kỳ trường nào của bản ghi, ở bất kỳ độ sâu lồng nào, có kiểu string, AnsiString, WideString, một mảng động, một interface, hay một Variant hay không? Một bản ghi có thể trông hoàn toàn dạng số ở cấp cao nhất và vẫn thất bại phép kiểm tra đó nếu một trong các trường của nó tự thân là một bản ghi chôn giấu một chuỗi vài lớp sâu hơn, nên việc kiểm tra phải theo các bản ghi lồng nhau xuyên suốt thay vì dừng lại ở danh sách trường ngoài cùng. Kiểm toán một codebase hiện có cho khuôn mẫu này mang tính máy móc thay vì cạn kiệt: tìm mọi lệnh gọi FillChar mà mục tiêu là một biến bản ghi, sau đó kiểm tra danh sách trường của bản ghi đó theo danh sách kiểu-có-quản-lý ở trên. Lượt kiểm toán v1.56.4 riêng của PDFiumPas đã chạy đúng lượt tìm kiếm đó trên toàn bộ thư viện và tìm thấy sự phơi nhiễm này trong một unit; mọi điểm gọi FillChar khác đã sẵn xóa một bản ghi số thuần túy, nơi FillChar là, và vẫn là, công cụ đúng

Cùng hành vi trình biên dịch khiến một Result bị tái sử dụng nguy hiểm ở đây cũng thúc đẩy một họ bất đồng Delphi-so-với-FPC liên quan ở nơi khác trong codebase này; một bài viết đồng hành về cạm bẫy xuyên trình biên dịch nói đến một trường hợp mà FPC và Delphi bất đồng về chính xác khi nào một biến tạm kết-quả-bản-ghi được finalize bên trong một biểu thức đơn lẻ, một triệu chứng khác của cùng thực tế nền tảng rằng Result bản ghi của một hàm không phải lúc nào cũng là storage mới, riêng tư như vẻ ngoài của nó. Vòng lặp chú thích được dùng làm ví dụ xuyên suốt bài viết này cũng không phải giả thuyết: nó chính là lượt duyệt từng-trang-một mà bạn sẽ viết trong khi xây dựng một bảng review chú thích, chính xác là hình dạng code đã biến một FillChar một dòng thành một rò rỉ bộ nhớ chậm rãi ngay từ đầu

Không điều nào trong số này đòi hỏi chuyển thư viện hay truy tìm một lỗi trong code đã biên dịch của người khác: đó là một thuộc tính của chính ngôn ngữ Object Pascal, một thuộc tính mọi lập trình viên Delphi và FPC làm việc cùng hàng ngày, và bản sửa là một lệnh gọi hàm duy nhất một khi bạn biết cần tìm gì. Các API chú thích, bookmark, và chú thích liên kết được mô tả ở đây đi kèm sẵn trong PDFium Component dành cho Delphi, C++Builder, và Lazarus/FPC, cùng với phần còn lại của bề mặt đọc, render, và chú thích PDF được nói đến ở nơi khác trên blog này