Bài viết kỹ thuật

Lỗi vòng lặp for của FPC: nhấp Cancel không dừng in

Nhấp Cancel trong lúc một job in đang chạy trong một trình xem PDFium Component đôi khi không làm gì cả: vòng lặp vẫn tiếp tục render mọi trang và bản sao còn lại, và job vẫn đến được máy in. Nguyên nhân là vòng lặp for của Free Pascal, thứ cố định cận trên của nó đúng một lần lúc vào vòng lặp, nên việc đặt về không biến đứng sau CopyCount hay ToPage giữa vòng lặp không thay đổi bất cứ thứ gì đang chạy sẵn. Lỗi cận vòng lặp for là cạm bẫy thứ năm lộ ra từ cùng lượt kiểm toán tương thích Delphi/FPC của PDFiumPas đã tạo ra bốn cạm bẫy xuyên trình biên dịch khác, và khác với bốn cạm bẫy đó, nó hoàn toàn sống bên trong routine in của một trình xem — phải cần một tester giữ Cancel qua một job dài mới thực sự nhận ra máy in không bao giờ dừng lại

Vì sao job in vẫn tiếp tục sau khi Cancel được nhấp?

Job in vẫn tiếp tục vì kiểm tra hủy chỉ đặt lại các biến nạp cận vòng lặp, không phải bản thân các vòng lặp, thứ mà Free Pascal đã khóa sẵn khi mỗi vòng lặp bắt đầu. Handler SpeedButtonPrintClick đứng sau nút Print trong các demo PDFViewer và MultiPageViewer xây dựng mỗi job từ ba vòng lặp lồng nhau: một vòng lặp ngoài trên các tập bản sao đã sắp xếp, một vòng lặp giữa trên các trang, và một vòng lặp trong trên các bản sao chưa sắp xếp của cùng một trang. PrintDialog.Collate quyết định bộ đếm nào, CollateCopyCount hay CopyCount, thực sự giữ số lượng bản sao được yêu cầu, trong khi bộ đếm kia giữ ở một. Việc hủy phải vươn qua cả ba cấp cùng lúc, và phiên bản đầu tiên của code này cố làm điều đó bằng cách đặt lại chính các biến cận ngay khi Cancel chuyển thành true

for CollateCopy:= 1 to CollateCopyCount do
  for Page:= FromPage to ToPage do
    for Copy:= 1 to CopyCount do
    begin
      // ... render the page and send it to the printer ...
      Application.ProcessMessages;
      if Cancel then
      begin
        CollateCopyCount:= 0;
        ToPage:= 0;
        CopyCount:= 0;
      end;
    end;
Printer.EndDoc;  // runs whether or not Cancel fired

Ý định đọc khá rõ ràng: nếu các bộ đếm định nghĩa bao nhiêu lượt sắp xếp, trang, và bản sao còn lại đều rơi về không, các vòng lặp lẽ ra nên hết việc để làm và tự thoát ra. Printer.EndDoc sau đó chạy vô điều kiện sau vòng lặp bất kể nó kết thúc thế nào, nên ngay cả một job mà người dùng tin rằng họ đã dừng vẫn được gửi tới spooler với mọi trang đã render trước khi cú nhấp được nhận ra

Free Pascal khóa gì khi một vòng lặp for bắt đầu

Free Pascal đánh giá giá trị cuối của một vòng lặp for đúng một lần, tại thời điểm vòng lặp bắt đầu, và không bao giờ nữa trong suốt vòng đời của vòng lặp đó. for Page := FromPage to ToPage do đọc ToPage một lần duy nhất để tính số lượt lặp cần chạy, và sau đó vòng lặp không còn quan tâm gì thêm đến một biến tên ToPage, chỉ quan tâm đến số lượt lặp nó đã ghi lại sẵn. Đặt ToPage := 0 từ bên trong thân vòng lặp thay đổi một biến mà vòng lặp đang chạy không còn tham khảo nữa, chính xác là lý do vì sao Cancel có thể là true trong khi máy in vẫn tiếp tục nhận các trang qua thêm vài lượt lặp, đôi khi là tất cả

Khuôn mẫu đó là một thói quen hoàn toàn hợp lý mang theo từ C hay C++, và bản demo trình xem C++Builder của PDFium Component phản chiếu bản Pascal đủ gần để làm phép so sánh trực tiếp. for (Copy = 1; Copy <= CopyCount; Copy++) của nó test lại Copy <= CopyCount theo giá trị sống của CopyCount ở mỗi lượt, nên đặt bộ đếm về không ở đó thực sự chấm dứt vòng lặp ở lượt kiểm tra tiếp theo. Bản demo C++Builder mang cùng code đặt-bộ-đếm-về-không giống hệt và nó hoạt động, chính xác là điều khiến cùng ý tưởng trông an toàn để tái sử dụng trong bản build Pascal nằm ngay cạnh nó trong cùng repository

Vì sao bản build Delphi không gặp cùng lỗi?

Bản demo PDFViewer Delphi chưa bao giờ phụ thuộc vào việc một vòng lặp nhận ra một cận đã thay đổi, vì đường hủy của nó unwind qua một ngoại lệ thay vì một phép so sánh. Vòng lặp trong cùng của nó gọi thủ tục Abort của RTL ngay khi Cancel chuyển thành true, thứ ném ra một EAbort im lặng lan truyền thẳng qua cả ba vòng lặp for lồng nhau đến một handler bọc quanh toàn bộ khối in

Printer.BeginDoc;
try
  for CollateCopy:= 1 to CollateCopyCount do
    for Page:= FromPage to ToPage do
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Abort;  // raises EAbort, unwinds all three loops at once
      end;
  Printer.EndDoc;
except
  on E: EAbort do
    Printer.Abort;
else
  begin
    Printer.Abort;
    raise;
  end;
end;

Một ngoại lệ không quan tâm bao nhiêu vòng lặp for đứng giữa điểm nó được ném ra và handler bắt nó, chính xác là thuộc tính vấn đề này cần. Sự vững chắc đó không phải một biện pháp phòng thủ có chủ đích chống lại hành vi khóa-cận mô tả ở trên — tác giả của bản demo Delphi đơn giản chỉ tìm đến một công cụ khác. Lối thoát dựa trên ngoại lệ vẫn đáng được gọi tên như khuôn mẫu vững chắc hơn: nó sống sót qua việc thêm một cấp lồng thứ tư sau này, trong khi một chuỗi các câu lệnh Break đặt thủ công phải được nhớ và thêm lại mỗi lần độ sâu lồng vòng lặp thay đổi

Cách sửa: Break ở mọi cấp lồng, kiểm soát bởi một cờ PrintSucceeded

Bản sửa phát hành trong PDFiumPas v2.27.0 giữ nguyên cấu trúc ba vòng lặp trong các demo trình xem Lazarus và C++Builder nhưng làm cho việc hủy tường minh ở mọi cấp, và nó tách việc dừng vòng lặp khỏi việc job được nộp thành một cờ chỉ được đọc sau khi vòng lặp đã hoàn toàn kết thúc

Printer.BeginDoc;
try
  Cancel:= False;
  for CollateCopy:= 1 to CollateCopyCount do
  begin
    for Page:= FromPage to ToPage do
    begin
      for Copy:= 1 to CopyCount do
      begin
        // ... render the page and send it to the printer ...
        Application.ProcessMessages;
        if Cancel then
          Break;
      end;
      if Cancel then
        Break;
    end;
    if Cancel then
      Break;
  end;
  PrintSucceeded:= not Cancel;
finally
  if PrintSucceeded then
    Printer.EndDoc
  else
    Printer.Abort;
end;

PrintSucceeded được tính có chủ đích đúng một lần, ngay sau khi vòng lặp lồng ba cấp thoát ra, không hơn gì not Cancel. Không có gì bên trong thân vòng lặp được tự quyết định liệu job có thành công hay không — vòng lặp chỉ có thể kết thúc theo hai cách, hết lượt sắp xếp, trang, và bản sao, hoặc chạm vào chuỗi BreakCancel kích hoạt, và PrintSucceeded đọc kết quả sau thực tế thay vì theo dõi nó khi vòng lặp diễn ra. Việc tính nó theo cách đó là điều khiến lựa chọn giữa Printer.EndDocPrinter.Abort của khối finally đáng tin cậy: nó không bao giờ kích hoạt trước khi vòng lặp thực sự đã ổn định

Kiểm toán các vòng lặp in điều khiển bởi hủy của riêng bạn

Ba kiểm tra vươn xa hơn nhiều so với riêng routine in này. Không bao giờ giả định một vòng lặp for Pascal sẽ nhận ra một biến cận thay đổi sau khi nó đã bắt đầu; nếu một vòng lặp cần kết thúc sớm, hãy nói điều đó trực tiếp bằng Break, ở mọi cấp lồng mà việc hủy cần vượt qua, không chỉ cấp trong cùng. Ưu tiên một cơ chế thoát unwind theo cấu trúc, như một ngoại lệ, một khi độ sâu lồng đủ lớn để một Break bị bỏ sót là hợp lý — cặp Abort/EAbort của demo Delphi có được điều này miễn phí. Kiểm soát bất kỳ bước commit nào như EndDoc đằng sau một cờ được tính nghiêm ngặt sau vòng lặp, không bao giờ bên trong nó, để một job đã dừng sớm không bao giờ có thể bị nhầm với một job đã hoàn tất

Cùng lượt review đã sửa vòng lặp này cũng siết chặt cách chín demo trình xem xử lý bàn phím khi một nút cancel hiển thị: giờ chúng nuốt mọi phím ngoại trừ Esc, nên một Ctrl+P hay Ctrl+F lạc lõng trong lúc một lần in hay tìm kiếm đang hoạt động không còn có thể khởi động một lần thứ hai chồng lên nó. Bản sửa tái nhập (reentrancy) này là một lỗi khác với một cơ chế khác, nhưng nó lộ ra từ cùng lượt review về việc Cancel thực sự làm gì giữa thao tác. Thói quen đáng mang theo từ cả hai bản sửa là một thói quen test nhiều hơn là một thói quen viết code: thực hiện việc hủy trên mọi trình biên dịch mà một routine in dùng chung thực sự phát hành đến, vì một ý tưởng như đặt lại các bộ đếm và tin tưởng vòng lặp sẽ nhận ra có thể sống sót qua việc test dưới C++Builder, đến được một bản build Free Pascal chưa được xác minh, đọc giống hệt trong mã nguồn, và thất bại theo cách không ai thấy cho đến khi ai đó giữ Cancel đủ lâu để xem job hoàn tất dù sao đi nữa. Về thiết lập in mà logic hủy này đứng trên, bài hướng dẫn về in tài liệu PDF với component PDFium VCL nói đến phần còn lại

Bản sửa hủy-in này được phát hành như một phần của PDFium Component dành cho Delphi, C++Builder, và FPC/Lazarus, chạy cùng bộ demo trên cả ba trình biên dịch ở mỗi bản phát hành để các khoảng trống như thế này bị bắt bởi một ma trận build thay vì một ticket hỗ trợ