Kết xuất một trang trong PDFium là đồng bộ. Bạn gọi vào thư viện, nó raster hóa vào một bitmap bạn đã cung cấp, và quyền điều khiển trả về khi các pixel đã được ghi xong. Với một trang kích thước màn hình ở một mức thu phóng duy nhất, thao tác đó mất vài mili giây và không ai để ý. Với việc xuất 300 dpi cho tài liệu 200 trang, hoặc dải hình thu nhỏ phải raster hóa mọi trang cùng lúc, cùng một lời gọi đó có thể tốn hàng giây. Nếu bạn thực hiện lời gọi đó từ luồng chính, vòng lặp thông điệp dừng lại, cửa sổ ngừng vẽ lại, và Windows vẽ dòng đáng sợ "Not Responding" lên thanh tiêu đề của bạn. Công việc là đúng đắn. Nơi bạn chạy nó là sai
Giải pháp là chuyển phần kết xuất dài sang luồng nền và đưa kết quả trở lại luồng chính, nơi bitmap có thể được bàn giao cho một control. Bản thân PDFium không ngăn bạn làm điều này, nhưng binding phải đảm bảo việc bàn giao an toàn, vì bề mặt lỗi xung quanh "chạy trên worker, trả lời trên UI" rất rộng và các lỗi là không liên tục. Unit FPdfAsync trong PDFiumPas tồn tại để cung cấp cho mẫu đó một cài đặt đúng duy nhất, với mô hình hủy phù hợp với cách kết xuất dài thực sự hoạt động
Hình dạng của công việc
Ba thao tác chiếm ưu thế trong các trường hợp kết xuất vượt quá một frame. Kết xuất hàng loạt duyệt một phạm vi trang và raster hóa từng trang, thường ra đĩa. Xuất nhiều trang thực hiện tương tự nhưng lắp ráp đầu ra thành một tệp duy nhất. Kết xuất trang nền là những gì một trình xem làm khi người dùng nhảy đến một trang chưa có trong bộ nhớ đệm, do đó bitmap được tạo ra ngoài luồng và hiển thị khi sẵn sàng. Cả ba đều có cùng ràng buộc. Chúng chạy đủ lâu để luồng UI không thể chứa chúng, chúng tạo ra kết quả mà luồng UI cuối cùng cần, và người dùng có thể từ bỏ chúng. Đóng tài liệu, cuộn qua trang, hoặc nhấn Cancel nên dừng công việc thay vì buộc người dùng chờ đầu ra họ không còn muốn nữa
Ràng buộc cuối cùng đó là thứ định hình thiết kế. Một kết xuất không thể hủy là một kết xuất giữ tài liệu mở và tiêu tốn CPU sau khi câu trả lời không còn quan trọng nữa. Vì vậy unit được xây dựng xung quanh hai nguyên tố kết hợp được: một future mang kết quả trở lại, và một token mang yêu cầu hủy tiến lên
Một future fire-and-forget
TPdfFuture<T>.Run nhận một worker, một reply, và một token hủy tùy chọn. Nó khởi động worker trên một luồng nền, và khi worker hoàn thành, nó giao reply trên luồng chính. Tham số generic T là bất cứ thứ gì kết xuất tạo ra, thường là một handle bitmap hoặc một bản ghi trạng thái. Worker chạy ngoài luồng; reply chạy ở nơi an toàn để chạm vào VCL
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
Sự thiếu vắng có chủ ý là bất kỳ loại Wait nào. Không có phương thức nào để block người gọi cho đến khi future hoàn thành, và đó không phải là sơ suất. Một Wait được gọi từ luồng chính là cách cổ điển để deadlock một UI: worker cần luồng chính chạy reply của nó qua Synchronize, luồng chính đang dừng bên trong Wait, và cả hai bên không thể tiếp tục. Bằng cách từ chối cung cấp nguyên tố đó, future loại trừ mẫu thường làm thất bại những người tự viết điều này. Code thực sự cần block nên dùng TThread thuần và tự chịu trách nhiệm. Future là dành cho trường hợp fire-and-forget, đó chính xác là những gì kết xuất nền thực sự là
Kết quả được bọc trong TPdfFutureResult<T>, một bản ghi cho reply biết điều gì trong ba điều đã xảy ra. IsSuccess có nghĩa là worker trả về bình thường và Value chứa kết xuất. IsCancelled có nghĩa là token đã kích hoạt và worker thoát ra tại một điểm hủy. IsFailure có nghĩa là worker đã raise, và ErrorMessage mang văn bản lỗi. Reply kiểm tra trạng thái một lần và phân nhánh, thay vì đoán từ một giá trị sentinel liệu bitmap được trả về có thực hay không
Race condition v1.61.0 đã thay đổi cách giao reply
Phần hướng dẫn nhất của unit này là một thay đổi một dòng mất khá lâu để hiểu. Qua các phiên bản đầu, luồng worker giao reply của nó bằng TThread.Queue. Queue đăng reply vào hàng đợi của luồng chính và trả về ngay lập tức, điều này có vẻ chính xác là những gì một future fire-and-forget muốn. Nhưng nó sai, và lý do đáng giải thích vì nó là loại lỗi vượt qua mọi bài kiểm tra bạn nghĩ đến
Luồng worker được tạo với FreeOnTerminate := True. Điều đó có nghĩa là ngay khi Execute trả về, luồng tự phá hủy, và TThread.Destroy gọi RemoveQueuedEvents(Self) như một phần dọn dẹp. RemoveQueuedEvents xóa bất kỳ phương thức đã xếp hàng nào có mục tiêu là luồng đang chết. Vì vậy chuỗi là: worker hoàn thành, nó xếp hàng reply chống lại chính nó, Execute trả về, luồng tự phá hủy, và RemoveQueuedEvents xóa reply mà luồng chính chưa chạy. Kết quả đơn giản là biến mất. Tệ hơn, trong khoảng thời gian hẹp mà luồng chính lấy reply đã xếp hàng ra và bắt đầu chạy nó cùng lúc luồng đang được giải phóng, reply chạm vào các trường của một đối tượng nửa bị phá hủy, đó là use-after-free
Bản sửa lỗi trong v1.61.0 là giao reply bằng Synchronize thay vì Queue. Synchronize block luồng worker cho đến khi luồng chính đã chạy reply đến hoàn thành. Worker vẫn còn sống trong khi reply của nó thực thi, vì vậy không có gì để giải phóng từ dưới nó, và luồng không trả về từ Execute (và do đó không bắt đầu phá hủy chính nó) cho đến khi reply đã được giao. Việc giao được đảm bảo, và cửa sổ use-after-free được đóng lại
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // already cancelled? skip the worker
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// Synchronize, not Queue: this thread is FreeOnTerminate, so a queued reply
// could be dropped by RemoveQueuedEvents before the main thread ran it.
Synchronize(DispatchReply);
end;
Bài học chung tồn tại lâu hơn bản sửa lỗi cụ thể. Callback bất đồng bộ fire-and-forget là mẫu đồng thời dễ sai một cách tinh tế nhất, vì đường vui vẻ hoạt động ngay lần đầu và lỗi nằm trong sự tương tác giữa thứ tự phá hủy luồng và hàng đợi. Nó không tái tạo theo yêu cầu. Nó phụ thuộc vào việc luồng chính có tình cờ xả hàng đợi trước khi worker tình cờ hoàn thành việc tự phá hủy hay không, đó là thời gian mà trình lập lịch quyết định khác nhau mỗi lần chạy. Một nguyên tố đúng một lần, trong binding, có giá trị hơn nhiều so với cùng một code được suy ra lại trong mọi ứng dụng cần kết xuất nền
Tại sao các callback là con trỏ phương thức
Worker và reply không phải là anonymous methods. Chúng là các kiểu procedure of object, TPdfFutureWorker<T> và TPdfFutureReply<T>, và lựa chọn đó bị buộc bởi ma trận trình biên dịch. PDFiumPas biên dịch trên Delphi XE5 và các phiên bản sau, và trên Free Pascal 3.2 ở chế độ Delphi, và FPC 3.2 ở chế độ đó không hỗ trợ anonymous methods. Một callback reference-to-procedure nắm bắt các biến cục bộ sẽ biên dịch trên Delphi và thất bại trên FPC, vì vậy unit dùng mẫu số chung thấp nhất mà cả hai trình biên dịch đều chấp nhận
Hệ quả thực tế là nơi trạng thái tồn tại. Một anonymous method đóng gói trên các biến cục bộ; một con trỏ phương thức thì không. Vì vậy bất kỳ trạng thái nào mà worker cần, chỉ số trang, mức thu phóng, đường dẫn đầu ra, và bất kỳ trạng thái nào mà reply cần cập nhật, control hình ảnh đích hay nhãn tiến trình, phải gắn vào đối tượng mà phương thức của nó đang được truyền. Trong một trình xem, đối tượng đó thường là form hoặc một bộ điều khiển kết xuất mà nó sở hữu. Đây không phải là cách giải quyết được áp đặt miễn cưỡng; nó giữ cho việc sở hữu trạng thái đó rõ ràng và hiển thị trên đối tượng nhận thay vì ẩn bên trong một closure
Hủy hợp tác, không phải kill cứng
Hủy ở đây là hợp tác. Không có API nào tiếp cận vào luồng worker và chấm dứt nó, vì việc chấm dứt một luồng giữa chừng kết xuất để PDFium giữ các khóa và bitmap được viết một phần, và trạng thái tiến trình sau một kill cưỡng bức không phải là thứ bạn có thể lý luận về. Thay vào đó, worker được trao một token chỉ đọc và được kỳ vọng kiểm tra nó, và vòng lặp kết xuất được viết để kiểm tra nó giữa các trang hoặc giữa các tile, nơi mà việc dừng lại là sạch sẽ
Token cung cấp ba cách để quan sát việc hủy. IsCancelled là một phép kiểm tra boolean rẻ cho một vòng lặp muốn kiểm tra và tự quyết định. ThrowIfCancelled là trường hợp thông thường: gọi nó tại một điểm hủy tự nhiên và, nếu việc hủy đã được yêu cầu, nó raise EPdfOperationCancelled, giải phóng stack worker thẳng trở lại future. RegisterCallback gắn một thông báo một lần kích hoạt khi nguồn bị hủy, hữu ích khi một worker bị chặn trong một thứ có thể ngắt thay vì ngồi trong một vòng lặp chặt
Exception là nơi ranh giới luồng quan trọng. Khi worker raise EPdfOperationCancelled, future bắt nó và biến nó thành trạng thái đã hủy, vì vậy reply thấy IsCancelled chứ không phải failure. Bản thân đối tượng exception không bao giờ được marshal sang luồng chính. Nó sống và chết trên luồng worker; chỉ chuỗi thông điệp của nó được sao chép vào ErrorMessage. Marshal một đối tượng exception trực tiếp qua các luồng sẽ có nghĩa là tiếp cận bộ nhớ do một luồng đang kết thúc sở hữu, đó là cùng loại lỗi mà bản sửa lỗi Synchronize tồn tại để ngăn chặn. Một mã trạng thái và một chuỗi vượt qua ranh giới sạch sẽ; một đối tượng thì không
Hai interface, để worker không thể tự hủy
Việc hủy được phân chia trên hai interface có mục đích. IPdfCancellationTokenSource là phía ghi: nó có Cancel, và chủ sở hữu tạo ra nó, thường là form, giữ nó và gọi Cancel khi người dùng nhấp nút hoặc form đóng. IPdfCancellationToken là phía đọc: nó có IsCancelled, ThrowIfCancelled, và RegisterCallback, và đó là tất cả những gì worker nhận được. Một đối tượng cụ thể cài đặt cả hai, nhưng worker chỉ được trao token, vì vậy nó không có cách nào để hủy thao tác nó đang chạy. Sự phân chia là một rào cản bảo vệ cấp API. Một worker có thể tiếp cận Cancel qua token của nó sẽ mời một đoạn code bị nhầm lẫn tự hủy, và hệ thống kiểu loại trừ khả năng đó
Có một chi tiết tương ứng cho trường hợp người gọi muốn một kết xuất nhưng không bao giờ có ý định hủy nó. Thay vì buộc một nguồn mới cho mỗi lần gọi, unit expose PdfNoCancellationToken, một singleton token luôn ở trạng thái chưa hủy. Run thay thế nó khi đối số token được để là nil. Singleton đó được khởi tạo eagerly trong quá trình khởi tạo unit thay vì lazily khi lần đầu sử dụng, và lý do là đồng thời một lần nữa. Nếu một số lời gọi Run trên các luồng worker khác nhau đều tiếp cận một singleton được tạo lazily cùng một lúc, chúng có thể race trên việc xây dựng của nó, rò rỉ một bản sao, hoặc quan sát ngắn gọn một instance chưa khởi tạo đầy đủ. Xây dựng nó trước khi bất kỳ worker nào có thể chạy loại bỏ race hoàn toàn
Chạy một kết xuất có thể hủy
Trong thực tế, bạn tạo một nguồn, giữ nó trên form, truyền Token của nó vào Run cùng với một phương thức worker và một phương thức reply, và kết nối nút Cancel với nguồn. Worker kiểm tra token trong khi nó kết xuất; reply cập nhật UI một khi kết quả trở lại. Vì các callback là con trỏ phương thức, worker và reply đọc bất cứ điều gì chúng cần từ các trường của form
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // field, lives on the form
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // worker observes this at its next cancel point
end;
// Runs on a background thread. Reads FPageRange / FOutputDir from the form.
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // clean stop between pages
RenderOnePage(PageIndex); // synchronous PDFium rasterisation
end;
Result := True;
end;
// Runs on the main thread. Safe to touch the VCL here.
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
Reply xử lý cả ba kết quả vì cả ba đều có thể đạt được. Một kết xuất hoàn thành báo cáo thành công, người dùng nhấn Cancel thấy nhánh đã hủy, và một tệp không thể ghi hoặc một trang không thể phân tích đến như một failure với thông điệp. Không có nhánh nào trong số đó block, không có nhánh nào chạm vào luồng worker, và bitmap hoặc trạng thái mà worker tạo ra chỉ được đọc sau khi future đã giao nó trên luồng sở hữu UI
Cùng kỷ luật threading được đền đáp ở những nơi khác trong một trình xem. Cách các bitmap đã kết xuất được giữ và tái sử dụng qua các thay đổi thu phóng được đề cập trong ghi chú của chúng tôi về bộ nhớ đệm kết xuất và hiệu suất thu phóng, và câu hỏi rộng hơn về việc giữ ranh giới PDFium an toàn dưới Delphi nằm trong hardening PDFium VCL ABI cho an toàn bộ nhớ. Cơ sở hạ tầng async được mô tả ở đây đi kèm như một phần của PDFium Component cho Delphi và C++Builder, cùng với các API kết xuất, văn bản và biểu mẫu được đề cập ở những nơi khác trên blog này