Bài viết kỹ thuật

Deadlock WaitForIdle trong render bất đồng bộ PDFium Delphi

Một lượt render hàng loạt đóng băng giữa chừng vì bộ thực thi (executor) trong PDFium Component không coi một tác vụ là đã xong cho tới khi phản hồi của nó đã được điều phối. Dưới padSynchronize, phản hồi đó chạy trên luồng chính. Nếu luồng chính chặn lại mà không bơm CheckSynchronize, worker sẽ chờ luồng chính trong khi luồng chính chờ trạng thái rảnh (idle)

Hình ảnh trong debugger không thể nhầm lẫn được một khi bạn đã thấy nó. Tạm dừng tiến trình đang đóng băng và luồng chính nằm bên trong một lượt chờ trên sự kiện idle, vài khung bên dưới chính vòng lặp batch của bạn. Chuyển sang bất kỳ luồng worker nào và nó nằm bên trong TThread.Synchronize, giữ một kết quả đã hoàn tất mà nó không thể trao đi. Không có gì đang xoay vòng, không có CPU nào cháy, tiến trình đơn giản là bị đỗ lại. Bài viết này nói về vì sao trạng thái đó tồn tại, và về ba quy tắc lân cận quyết định liệu một pool worker Delphi trên nền PDFium hoạt động ngoan ngoãn hay cắn người: QueueCapacity thực sự giới hạn cái gì, thứ tự tắt phải hủy theo trình tự nào, và tính song song không mua được điều gì khi nói tới quyền sở hữu đối tượng PDFium

Vì sao WaitForIdle treo luồng chính?

Nó treo vì trạng thái idle trong TPdfAsyncExecutor được định nghĩa bao gồm cả việc điều phối phản hồi, không chỉ việc worker hoàn tất. Số đếm đang chạy được tăng lên trong DequeueTask khi một worker nhặt một tác vụ, và nó được giảm xuống trong TaskFinished, hàm mà worker chỉ gọi sau khi TPdfAsyncTaskOperation.Execute đã trả về. Hàm đó chạy phần thân worker, ghi lại kết quả, rồi mới điều phối phản hồi theo TPdfAsyncDispatchMode. Với padSynchronize, việc điều phối là một lệnh gọi TThread.Synchronize, nên Execute không trả về cho tới khi luồng chính đã chạy nó

Delphi đặt nửa còn lại của hợp đồng đó lên vai bạn. TThread.Synchronize nối thêm phương thức vào một hàng đợi toàn cục và chặn luồng gọi trên một sự kiện; một thứ gì đó trên luồng chính phải gọi CheckSynchronize trước khi sự kiện đó bao giờ được báo hiệu. Vòng lặp thông điệp VCL làm điều này cho bạn giữa các thông điệp, đó chính xác là lý do lỗi này vô hình trong quá trình dùng tương tác và xuất hiện ngay khi bạn viết một vòng lặp batch mang tính chặn. Một luồng chính bị chặn là một luồng chính đã rời khỏi vòng lặp thông điệp, và một luồng chính ở ngoài vòng lặp thông điệp thì không tháo cạn cho ai cả

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

Hoàn tất tác vụ và trạng thái idle của executor là hai cột mốc khác nhau

Chúng được tách biệt có chủ đích, và biết bạn đang chờ cái nào chính là toàn bộ cách sửa. IPdfAsyncTask.WaitFor được thỏa mãn ngay khoảnh khắc kết quả worker được quyết định: Complete ghi TPdfAsyncTaskState cuối cùng và đặt sự kiện xong trước khi bất kỳ phản hồi nào được xem xét. TPdfAsyncExecutor.WaitForIdle được thỏa mãn muộn hơn, một khi cả số đếm đang xếp hàng lẫn đang chạy đều bằng không, và số đang chạy không giảm cho tới khi phản hồi đã được xử lý xong. Vì vậy một tác vụ có thể là patsSucceeded và quan sát được qua Snapshot trong khi executor vẫn đang bận một cách chính đáng

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

Một hệ quả đáng ghi nhớ: một phản hồi ném ra lỗi không viết lại lịch sử. DispatchReply bắt lấy ngoại lệ và lưu nó trong ReplyErrorMessage, để nguyên State, CancellationReasonErrorMessage đúng như worker đã xác định chúng. Vì vậy một callback UI phát nổ trong khi vẽ một thumbnail không bao giờ biến một lần render thành công thành thất bại, và telemetry của bạn vẫn tiếp tục báo cáo đúng những gì engine render thực sự đã làm. Nếu bạn muốn một API dạng callback quanh một thao tác đơn lẻ thay vì một pool, render nền với future có thể hủy trình bày đường đó

QueueCapacity có giới hạn cả các worker đang chạy không?

Không. QueueCapacity trong PDFium Component chỉ đếm các tác vụ đang xếp hàng, không bao giờ đếm những tác vụ đã đang thực thi trên một worker. Đó là có chủ đích: dung lượng có nghĩa là diễn đạt áp lực ngược thực sự trên hàng chờ, và gộp các khe song song cố định vào cùng con số đó sẽ đếm chúng hai lần. Với bốn worker và một dung lượng tám, bạn có thể có mười hai tác vụ đang bay, và GetStats báo cáo sự phân tách đó một cách trung thực qua QueuedCountRunningCount

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

Bốn làn của TPdfAsyncPriority mang tính nghiêm ngặt, không phải trọng số. DequeueTask duyệt từ papCritical xuống papLow và lấy làn không rỗng đầu tiên, giữ nguyên thứ tự FIFO trong mỗi làn. Điều đó cho một yêu cầu tương tác một cách sạch sẽ để vượt lên trước một lô batch chưa bắt đầu, nhưng nó không bao giờ ngắt công việc đang chạy sẵn, và một bên gọi liên tục nạp papCritical có thể làm đói papLow vô thời hạn. Hãy dành hai làn cao nhất cho những thứ mà một con người đang hiển nhiên chờ đợi, và để việc xuất hàng loạt ở papNormal hoặc thấp hơn. Dùng Submit khi một hàng đợi đầy là một lỗi lập trình đáng một EPdfAsyncQueueFull, và TrySubmit khi đó là một điều kiện bình thường mà bạn định xử lý

Vì sao Shutdown hủy bên ngoài khóa của executor?

Vì hủy bên trong nó sẽ đảo ngược thứ tự khóa và treo chính việc tắt bạn đang cố thực hiện. Shutdown(True) lấy khóa của executor, bật cờ shutdown, và nối thêm mọi tác vụ đang chờ vào một mảng snapshot cục bộ qua AppendSnapshot. Rồi nó giải phóng khóa và chỉ sau đó mới duyệt qua snapshot gọi Cancel trên từng mục. Hủy một tác vụ kích hoạt các callback người dùng đã đăng ký trên nguồn token của nó, và các callback đó là mã ứng dụng thông thường: chúng có thể truy vấn GetStats, gửi thêm công việc bù, hay chờ idle. Mỗi điều đó tái nhập khóa của executor, và một callback được gọi trong khi khóa đó đang bị giữ sẽ deadlock với chính nó

Nguồn token tuân theo cùng kỷ luật đó ở một tầng sâu hơn. CancelWithReason lấy khóa của nguồn, quyết định người hủy chiến thắng duy nhất, ghi Reason, CancellationMessageCancelledAtTick, và chỉ sau đó mới bật cờ đã-hủy một cách nguyên tử. Công bố trước rồi mới bật cờ chính là điều khiến metadata an toàn để đọc: bất kỳ luồng nào quan sát IsCancelled là True được bảo đảm sẽ tìm thấy một lý do đầy đủ đứng sau nó, và các bên gọi tới sau thua cuộc đua, trả về False, và không thể ghi đè lý do đầu tiên. Các callback đã đăng ký được chụp nhanh và xóa bên trong khóa nhưng được gọi bên ngoài nó, mỗi cái được bọc để một handler thất bại không thể chặn đứng các handler còn lại. Các tác vụ đã đang chạy không bao giờ bị giết chết; chúng kết thúc theo kiểu hợp tác khi phần thân worker của chúng gọi ThrowIfCancelled lần tiếp theo, đó là lý do Shutdown kết thúc bằng một WaitForIdle có bơm trước khi join các luồng

Các worker song song có nới lỏng quyền sở hữu đối tượng PDFium không?

Không, và đây là ranh giới dễ bị hiểu sai nhất. TPdfAsyncExecutor lập lịch công việc; nó không đưa ra tuyên bố nào về mối liên kết luồng của bất cứ thứ gì bạn chạm vào bên trong công việc đó. Một thực thể TPdf đang sống không trở nên truy cập đồng thời được chỉ vì hai worker tình cờ gọi vào nó, và khóa render nội bộ là một lớp bảo vệ chống các lệnh gọi render chồng lấn, không phải một giấy phép để chia sẻ một tài liệu qua nhiều luồng. Render hay xuất song song nghĩa là mỗi worker một TPdf, được tạo ra và hủy đi bên trong công việc đó

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

Cái giá là có thật và đáng nêu tên: mỗi worker tự trả tiền cho lượt phân tích của riêng nó và cache trang của riêng nó, nên bộ nhớ mở rộng theo số lượng worker chứ không phải theo số lượng tài liệu. Đó là cái giá của một mô hình mà một worker có thể bị hủy hay crash mà không làm hỏng ai khác. Nếu các worker của bạn thực sự chia sẻ chung một tài liệu phía viewer, các quy tắc khóa xung quanh điều đó được trình bày trong khóa render và những lệnh gọi bỏ lỡ nó, và đường xử lý có thể hủy trên một tài liệu duy nhất nằm trong render tiến triển có thể hủy

Mở rộng một interface đã công bố mà không làm hỏng vtable

IPdfCancellationTokenIPdfCancellationTokenSource là các interface kiểu COM mà các binary bên ngoài có thể đã tiêu thụ, nên nối thêm một phương thức vào bất kỳ cái nào cũng sẽ dịch chuyển mọi khe sau đó trong vtable và âm thầm định tuyến sai các lệnh gọi được biên dịch dựa trên bố cục cũ. Vì vậy các khả năng chẩn đoán, chờ đợi, callback có thể gỡ bỏ và hủy nguyên tử sống trong IPdfCancellationTokenExIPdfCancellationTokenSourceEx, những cái kế thừa chứ không sửa đổi. NewRun giữ nguyên ngữ nghĩa gốc của chúng cho các bên gọi hiện có; mã mới tìm tới NewEx, NewTimeoutRunEx khi nó muốn CancelWithReason, WaitForCancellation hay một IPdfCancellationRegistration được quản lý. Kế thừa là cách duy nhất an toàn để mở rộng một interface đã công bố, và nó tốn thêm một kiểu mỗi thế hệ

NewTimeout đáng một ghi chú trung thực. Mỗi nguồn timeout sở hữu một luồng nhẹ chờ trên sự kiện hủy hoặc thời hạn, cái nào tới trước. Với vài chục thời hạn thì điều đó đơn giản, độ trễ thấp và giống hệt nhau trên Delphi, Lazarus và C++Builder. Với hàng nghìn thời hạn ngắn thì đó là hình dạng sai, và bạn nên điều khiển việc hủy từ một bộ đếm giờ ở mức ứng dụng duy nhất thay vì giữ hàng nghìn luồng đang chờ

Không quy tắc nào trong số này kỳ lạ một khi đã được viết ra, nhưng mỗi quy tắc đều là một sự cố sản xuất khi nó không được tuân theo. Hãy chờ đúng cột mốc và để thứ gì đó bơm hàng đợi synchronize, đọc QueueCapacity chỉ như một giới hạn trên hàng chờ, hủy bên ngoài các khóa của bạn, và trao cho mỗi worker tài liệu của riêng nó. Lớp bất đồng bộ được mô tả ở đây được cung cấp như một phần của PDFium Component cho Delphi, cùng với các API render, văn bản và form mà nó lập lịch