Sebuah batch render membeku di tengah jalan karena executor di PDFium Component tidak menganggap sebuah task selesai sampai reply-nya sudah didispatch. Di bawah padSynchronize, reply itu berjalan di main thread. Jika main thread memblokir tanpa memompa CheckSynchronize, worker-nya menunggu main thread sementara main thread-nya menunggu idle
Gambaran di debugger tidak bisa disalahartikan begitu Anda pernah melihatnya. Jeda proses yang membeku itu dan main thread-nya berada di dalam sebuah wait pada idle event, beberapa frame di bawah batch loop Anda sendiri. Pindah ke worker thread mana pun dan ia berada di dalam TThread.Synchronize, memegang sebuah hasil yang sudah selesai tapi tidak bisa diserahkan. Tidak ada yang berputar, tidak ada CPU yang terbakar, proses ini sekadar terparkir. Artikel ini membahas kenapa state itu bisa ada sama sekali, dan tentang tiga aturan tetangga yang menentukan apakah sebuah worker pool Delphi di atas PDFium berperilaku baik atau menggigit: apa yang sebenarnya dibatasi QueueCapacity, urutan apa yang harus diikuti shutdown saat membatalkan, dan apa yang tidak dibeli paralelisme ketika menyangkut kepemilikan objek PDFium
Kenapa WaitForIdle menggantung main thread?
Ia menggantung karena idle dalam TPdfAsyncExecutor didefinisikan untuk mencakup dispatch reply, bukan sekadar penyelesaian worker. Running count dinaikkan di DequeueTask ketika seorang worker mengambil sebuah task, dan diturunkan di TaskFinished, yang dipanggil worker hanya setelah TPdfAsyncTaskOperation.Execute kembali. Method itu menjalankan tubuh worker, mencatat hasilnya, lalu mendispatch reply sesuai TPdfAsyncDispatchMode. Dengan padSynchronize, dispatch-nya adalah sebuah pemanggilan TThread.Synchronize, sehingga Execute tidak kembali sampai main thread menjalankannya
Delphi menaruh separuh kedua dari kontrak itu di pundak Anda. TThread.Synchronize menambahkan method itu ke sebuah queue global dan memblokir thread pemanggil pada sebuah event; sesuatu pada main thread harus memanggil CheckSynchronize sebelum event itu pernah di-signal. Message loop VCL melakukan ini untuk Anda di antara pesan-pesan, dan itulah persisnya kenapa bug ini tidak terlihat selama pemakaian interaktif dan muncul begitu Anda menulis sebuah batch loop yang memblokir. Sebuah main thread yang memblokir adalah main thread yang sudah meninggalkan message loop, dan main thread di luar message loop tidak sedang mengalirkan apa pun untuk siapa pun
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.
Penyelesaian task dan executor idle adalah dua milestone berbeda
Keduanya dipisahkan dengan sengaja, dan mengetahui yang mana yang sedang Anda tunggu adalah keseluruhan perbaikannya. IPdfAsyncTask.WaitFor terpenuhi persis pada saat hasil worker diputuskan: Complete menulis TPdfAsyncTaskState final dan mengeset done event sebelum reply apa pun dipertimbangkan. TPdfAsyncExecutor.WaitForIdle terpenuhi belakangan, begitu queued count dan running count keduanya nol, dan running-nya tidak turun sampai reply-nya mendarat. Jadi sebuah task bisa saja patsSucceeded dan bisa diamati lewat Snapshot sementara executor-nya masih sah-sah saja sibuk
// 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;
Satu konsekuensi yang layak diinternalisasi: sebuah reply yang memunculkan exception tidak menulis ulang sejarah. DispatchReply menangkap exception itu dan menyimpannya di ReplyErrorMessage, membiarkan State, CancellationReason, dan ErrorMessage persis seperti yang ditentukan worker. Sebuah UI callback yang meledak saat melukis sebuah thumbnail karena itu tidak pernah mengubah sebuah render yang sukses menjadi yang gagal, dan telemetri Anda tetap melaporkan apa yang sebenarnya dilakukan render engine. Bila Anda menginginkan API berbentuk callback di sekitar satu operasi tunggal alih-alih sebuah pool, background rendering dengan cancellable future membahas jalur itu
Apakah QueueCapacity juga membatasi worker yang sedang berjalan?
Tidak. QueueCapacity di PDFium Component hanya menghitung task yang mengantre, tidak pernah yang sudah dieksekusi pada sebuah worker. Itu disengaja: kapasitas dimaksudkan untuk menyatakan backpressure sungguhan pada antrean tunggu, dan melipat slot konkurensi yang tetap ke dalam angka yang sama akan menghitungnya dua kali. Dengan empat worker dan kapasitas delapan, Anda bisa punya dua belas task yang sedang berjalan, dan GetStats melaporkan pemisahannya secara jujur lewat QueuedCount dan RunningCount
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;
Empat lane dari TPdfAsyncPriority bersifat strict, bukan berbobot. DequeueTask menelusuri dari papCritical turun ke papLow dan mengambil lane pertama yang tidak kosong, mempertahankan urutan FIFO di dalam masing-masing. Itu memberi sebuah request interaktif cara yang bersih untuk melompat ke depan batch yang belum dimulai, tapi ia tidak pernah menyela pekerjaan yang sudah berjalan, dan sebuah caller yang terus memberi makan papCritical bisa membuat papLow kelaparan tanpa batas. Sediakan dua lane teratas untuk hal-hal yang secara nyata ditunggu seorang manusia, dan biarkan bulk export berada di papNormal atau di bawahnya. Pakai Submit ketika sebuah queue yang penuh adalah sebuah error pemrograman yang layak mendapat EPdfAsyncQueueFull, dan TrySubmit ketika itu adalah kondisi normal yang memang Anda niatkan untuk ditangani
Kenapa Shutdown membatalkan di luar lock executor?
Karena membatalkan di dalamnya akan membalik urutan lock dan menggantungkan shutdown yang sedang Anda coba lakukan. Shutdown(True) mengambil lock executor, membalik flag shutdown, dan menambahkan setiap task yang pending ke sebuah array snapshot lokal lewat AppendSnapshot. Lalu ia melepas lock-nya dan baru sesudahnya menelusuri snapshot itu memanggil Cancel pada setiap entri. Membatalkan sebuah task memicu callback pengguna yang didaftarkan pada token source-nya, dan callback itu adalah kode aplikasi biasa: mereka mungkin melakukan query GetStats, mengirim pekerjaan kompensasi, atau menunggu idle. Setiap satu dari itu masuk kembali ke lock executor, dan sebuah callback yang dipanggil sementara lock itu sedang dipegang akan deadlock melawan dirinya sendiri
Token source mematuhi disiplin yang sama satu level lebih dalam. CancelWithReason mengambil lock source-nya, memutuskan satu canceller pemenang, menulis Reason, CancellationMessage, dan CancelledAtTick, dan baru setelah itu membalik flag cancelled secara atomik. Publish sebelum flip itulah yang membuat metadatanya aman dibaca: thread mana pun yang mengamati IsCancelled bernilai True dijamin akan menemukan sebuah alasan lengkap di baliknya, dan caller yang datang belakangan kalah dalam race, mengembalikan False, dan tidak bisa menimpa alasan yang pertama. Callback yang terdaftar di-snapshot dan dibersihkan di dalam lock tapi dipanggil di luarnya, masing-masing dibungkus sehingga satu handler yang gagal tidak bisa menekan yang lain. Task yang sudah berjalan tidak pernah dibunuh; mereka berakhir secara kooperatif ketika tubuh worker-nya berikutnya memanggil ThrowIfCancelled, dan itulah sebabnya Shutdown berakhir dengan sebuah WaitForIdle yang memompa sebelum menggabungkan (join) thread
Apakah worker paralel melonggarkan kepemilikan objek PDFium?
Tidak, dan inilah batasan yang paling mungkin disalahbaca. TPdfAsyncExecutor menjadwalkan pekerjaan; ia tidak membuat klaim apa pun soal thread affinity dari apa pun yang Anda sentuh di dalam pekerjaan itu. Sebuah instance TPdf yang hidup tidak menjadi bisa diakses secara konkuren hanya karena dua worker kebetulan memanggilnya, dan render lock internal adalah sebuah penjaga terhadap pemanggilan render yang tumpang tindih, bukan sebuah lisensi untuk berbagi sebuah dokumen lintas thread. Rendering atau export paralel berarti satu TPdf per worker, dibuat dan dihancurkan di dalam job-nya
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;
Biayanya nyata dan layak disebutkan: setiap worker membayar parse-nya sendiri dan page cache-nya sendiri, sehingga memori berskala dengan jumlah worker, bukan dengan jumlah dokumen. Itulah harga dari sebuah model tempat seorang worker bisa dibatalkan atau crash tanpa merusak yang lain. Bila worker Anda justru berbagi sebuah dokumen sisi-viewer, aturan locking di sekitarnya dibahas di render lock dan pemanggilan yang melewatkannya, dan jalur cancellable dokumen-tunggal ada di cancellable progressive rendering
Memperluas interface published tanpa merusak vtable
IPdfCancellationToken dan IPdfCancellationTokenSource adalah interface bergaya COM yang mungkin sudah dikonsumsi binary eksternal, sehingga menambahkan sebuah method ke salah satunya akan menggeser setiap slot berikutnya dalam vtable dan diam-diam salah merutekan pemanggilan yang dikompilasi terhadap layout lama. Kemampuan diagnostik, waiting, removable-callback, dan atomic-cancel karena itu berada di IPdfCancellationTokenEx dan IPdfCancellationTokenSourceEx, yang mewarisi alih-alih memodifikasi. New dan Run mempertahankan semantik aslinya untuk caller yang sudah ada; kode baru memakai NewEx, NewTimeout, dan RunEx ketika menginginkan CancelWithReason, WaitForCancellation, atau sebuah IPdfCancellationRegistration yang terkelola. Pewarisan adalah satu-satunya cara aman untuk menumbuhkan sebuah interface published, dan itu memakan biaya satu tipe ekstra per generasi
NewTimeout layak mendapat catatan yang jujur. Setiap timeout source memiliki sebuah thread ringan yang menunggu pada cancellation event atau deadline-nya, mana pun yang datang lebih dulu. Untuk segelintir atau beberapa lusin deadline, itu sederhana, latensi rendah, dan identik di Delphi, Lazarus, dan C++Builder. Untuk ribuan deadline pendek, itu bentuk yang salah, dan Anda seharusnya menggerakkan cancellation dari satu timer level-aplikasi, bukan menahan ribuan thread yang menunggu
Tidak satu pun dari aturan ini eksotis begitu ditulis, tapi setiap satu dari mereka adalah sebuah insiden produksi ketika tidak diikuti. Tunggu pada milestone yang benar dan biarkan sesuatu memompa synchronize queue, baca QueueCapacity hanya sebagai batas pada antrean tunggu, batalkan di luar lock Anda, dan beri setiap worker dokumennya sendiri. Lapisan async yang dijelaskan di sini hadir sebagai bagian dari Delphi PDFium Component, berdampingan dengan API rendering, teks, dan form yang dijadwalkannya