Artikel Teknis

Rendering Latar Belakang PDF di Delphi dengan Cancellable Futures

Merender halaman di PDFium bersifat sinkronus. Anda melakukan panggilan ke library, library merasterisasinya menjadi bitmap yang Anda berikan kepadanya, lalu kendali akan kembali setelah piksel-piksel tersebut selesai ditulis. Untuk satu halaman seukuran layar dengan satu tingkat perbesaran (zoom), hal itu membutuhkan beberapa milidetik dan tidak ada yang menyadarinya. Namun untuk ekspor 300 dpi dari dokumen 200 halaman, atau untuk deretan gambar kecil (thumbnail strip) yang harus merasterisasi seluruh halaman sekaligus, panggilan yang sama membutuhkan waktu berdetik-detik. Jika Anda melakukan panggilan itu dari thread utama (main thread), message loop akan berhenti, window berhenti melakukan pengecatan ulang (repainting), dan Windows akan menampilkan tulisan "Not Responding" (Tidak Merespons) yang sangat ditakuti di atas bilah judul (title bar) Anda. Pekerjaannya benar, tetapi tempat Anda menjalankannya yang salah

Perbaikannya adalah memindahkan rendering berdurasi panjang tersebut ke thread latar belakang (background thread) dan mengembalikan hasilnya ke thread utama, tempat bitmap itu kemudian dapat diserahkan ke sebuah kontrol. PDFium sendiri tidak mencegah Anda melakukan hal ini, namun binding tersebutlah yang harus membuat proses serah terimanya menjadi aman, karena area bug seputar "jalankan di worker, balas di UI" sangatlah luas dan kegagalannya bersifat sesekali (intermittent). Unit FPdfAsync di PDFiumPas hadir untuk memberikan satu implementasi yang benar pada pola tersebut, disertai sebuah model pembatalan (cancellation model) yang sesuai dengan bagaimana rendering panjang benar-benar berperilaku

Bentuk dari pekerjaan

Ada tiga operasi yang mendominasi kasus-kasus di mana proses render melampaui waktu satu bingkai (frame). Rendering batch menyusuri rentang halaman lalu merasterisasi tiap-tiap halamannya, biasanya untuk disimpan ke dalam disk. Ekspor multi-halaman melakukan hal yang sama tetapi menyatukan output ke dalam sebuah file. Rendering halaman latar belakang adalah apa yang dikerjakan oleh sebuah penampil (viewer) ketika pengguna melompat ke halaman yang belum berada di dalam cache, sehingga bitmap diproduksi di luar-thread dan ditampilkan saat sudah siap. Ketiganya berbagi batasan yang sama. Pekerjaan-pekerjaan itu berjalan cukup lama sehingga thread UI tidak mampu menampungnya, mereka memproduksi hasil yang pada akhirnya dibutuhkan oleh thread UI, dan pengguna mungkin saja akan membatalkannya di tengah jalan. Menutup dokumen, menggulir melewati halaman tersebut, atau menekan tombol Batal seharusnya menghentikan pekerjaan alih-alih memaksa pengguna untuk menunggu hasil output yang tidak lagi mereka perlukan

Batasan terakhir itulah yang membentuk desain tersebut. Sebuah render yang tidak dapat dibatalkan adalah sebuah render yang menahan dokumen tetap terbuka lalu terus membakar daya CPU bahkan setelah jawabannya sudah tidak penting lagi. Jadi, unit ini dibangun di seputar dua fungsi primitif yang menyatu (compose): sebuah future yang membawa hasilnya kembali, dan sebuah token yang meneruskan permintaan pembatalan tersebut ke depan

Sebuah future jalankan-dan-lupakan (fire-and-forget)

TPdfFuture<T>.Run mengambil sebuah pekerja (worker), balasan (reply), dan sebuah token pembatalan (cancellation token) opsional. Metode ini memulai pekerja pada sebuah thread latar belakang, dan ketika pekerjanya selesai, metode ini akan mengirimkan balasan tersebut ke thread utama. Parameter generik T adalah apa pun yang diproduksi oleh render tersebut, seringkali sebuah handle bitmap atau record status. Pekerja berjalan di luar-thread; balasan berjalan di tempat yang aman untuk menyentuh VCL

class procedure TPdfFuture<T>.Run(
  const AWorker: TPdfFutureWorker<T>;
  const AReply: TPdfFutureReply<T>;
  const AToken: IPdfCancellationToken = nil); static;

Penghilangan yang disengaja di sini adalah ketiadaan Wait dalam bentuk apa pun. Tidak ada metode untuk memblokir pemanggil hingga future itu tuntas, dan ini bukanlah sebuah kelalaian. Sebuah Wait yang dipanggil dari thread utama adalah cara paling klasik untuk membuat kebuntuan (deadlock) pada antarmuka pengguna: si pekerja membutuhkan thread utama untuk bisa menjalankan balasannya melalui Synchronize, sedangkan thread utamanya sendiri sedang diparkir di dalam Wait, dan kedua belah pihak akhirnya tidak bisa melanjutkan apa-apa. Dengan menolak menawarkan fungsi primitif tersebut, future ini telah meniadakan pola yang paling sering menggagalkan orang-orang yang mencoba menulis kode seperti ini sendiri. Kode yang benar-benar butuh diblokir harusnya menggunakan TThread polos dan menanggung sendiri konsekuensinya. Future diperuntukkan bagi kasus jalankan-dan-lupakan, yang mana memang itulah hakikat rendering latar belakang yang sebenarnya

Hasilnya dibungkus ke dalam TPdfFutureResult<T>, sebuah record yang memberitahu balasan perihal mana dari tiga kemungkinan hal ini yang telah terjadi. IsSuccess berarti si pekerja telah kembali secara normal dan Value menampung hasil render-nya. IsCancelled berarti token tersebut menyala (fired) dan si pekerja telah membatalkan aksinya (bailed out) di suatu titik pembatalan. IsFailure berarti pekerjanya membangkitkan eksepsi (raised), di mana ErrorMessage membawa teks pesannya. Balasan tersebut cukup menginspeksi status ini sekali lalu mencabang, alih-alih harus menebak-nebak dari nilai sentinel apakah bitmap yang dikembalikan itu merupakan bitmap yang sesungguhnya

Perlombaan (race) di v1.61.0 yang mengubah cara pengiriman balasan

Bagian yang paling instruktif dari unit ini adalah perubahan satu baris yang memerlukan sedikit waktu untuk bisa memahaminya. Melalui versi-versi awal, thread pekerja mengirimkan balasannya menggunakan TThread.Queue. Queue (antrean) mengirimkan balasan tersebut ke dalam antrean thread utama lalu kembali dengan seketika, dan ini terbaca persis sama dengan yang diinginkan oleh model future jalankan-dan-lupakan. Ternyata itu keliru, dan alasannya layak untuk diuraikan lebih rinci sebab ini merupakan tipe bug yang mampu lolos dari setiap pengujian yang terpikir untuk Anda tulis

Thread pekerja dibuat dengan nilai FreeOnTerminate := True. Hal itu berarti pada saat fungsi Execute mengembalikan nilainya, thread tersebut membongkar dirinya sendiri (tears itself down), lalu TThread.Destroy memanggil RemoveQueuedEvents(Self) sebagai bagian dari proses pembersihannya. RemoveQueuedEvents membersihkan metode apa pun di dalam antrean yang targetnya adalah sebuah thread yang sedang mati. Maka beginilah rangkaiannya: pekerja selesai bertugas, lalu dia mengantrekan balasan terhadap dirinya sendiri, fungsi Execute kembali, si thread pun menghancurkan dirinya, selanjutnya RemoveQueuedEvents menghapus balasan yang mana bahkan belum sempat dijalankan oleh thread utama. Alhasilnya pun sirna. Lebih parah lagi, di celah sempit di saat thread utama mulai menarik antrean balasan tersebut lalu mulai menjalankannya tepat pada waktu yang sama dengan saat si thread sedang dibebaskan (freed), sang balasan akan menyentuh kolom-kolom (fields) dari suatu objek yang setengah hancur, dan ini merupakan perlakuan gunakan-setelah-dibebaskan (use-after-free)

Perbaikan di v1.61.0 tersebut adalah mengirimkan balasannya dengan menggunakan Synchronize alih-alih Queue. Synchronize memblokir thread pekerja sampai thread utama telah menjalankan balasannya itu hingga tuntas. Pekerjanya masih dalam kondisi hidup tatkala balasannya sedang dieksekusi, dengan begitu tidak ada ruang pembebasan yang dilakukan di bawahnya, dan thread tidak akan kembali dari metode Execute (oleh karenanya tidak bisa mulai menghancurkan dirinya sendiri) sampai balasan tersebut telah diselesaikan. Pengiriman dijamin tuntas, dan celah bahaya gunakan-setelah-dibebaskan pun ditutup

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;

Pelajaran umumnya jauh melampaui sekadar perbaikan spesifik ini. Callback asinkronus jalankan-dan-lupakan adalah pola konkurensi yang paling mudah memiliki bug yang samar, karena alur bahagia (happy path) langsung bekerja pada percobaan pertama dan bug tersebut menetap pada interaksi antara urutan pembongkaran thread dengan antrean. Bug ini tidak dapat direproduksi sesuai keinginan. Hal ini bergantung pada apakah thread utama secara kebetulan telah menguras antrean tersebut sebelum sang pekerja bertepatan selesai menghancurkan dirinya sendiri, di mana hal itu merupakan masalah waktu (timing) yang diputuskan secara berbeda oleh penjadwal (scheduler) pada setiap iterasi. Sebuah primitif yang sudah tertulis benar di dalam binding bernilai jauh lebih tinggi ketimbang reka ulang kode yang sama di setiap aplikasi yang membutuhkan rendering latar belakang

Mengapa metode callback menggunakan method pointer

Pekerja (worker) dan balasan (reply) bukanlah metode anonim (anonymous methods). Keduanya adalah tipe procedure of object, TPdfFutureWorker<T> dan TPdfFutureReply<T>, dan pilihan tersebut dipaksa oleh matriks kompilator. PDFiumPas dapat dikompilasi di Delphi XE5 dan versi yang lebih baru serta pada Free Pascal 3.2 dalam mode Delphi, dan FPC 3.2 dalam mode tersebut tidak mendukung metode anonim. Sebuah referensi-ke-prosedur callback yang menangkap variabel lokal (local variables) mungkin dapat dikompilasi di Delphi namun akan gagal di FPC, karenanya unit ini memanfaatkan penyebut (denominator) paling dasar yang dapat diterima oleh kedua kompilator tersebut

Konsekuensi praktisnya terletak pada penyimpanan state. Metode anonim membungkus variabel lokal (closes over locals); sebuah method pointer tidak. Jadi, state apa pun yang dibutuhkan oleh pekerja, seperti indeks halaman, tingkat zoom, jalur output, serta state apa pun yang butuh diperbarui oleh balasan, seperti kontrol gambar atau label progres, harus melekat (hang off) pada objek yang metodenya digunakan. Di dalam sebuah penampil (viewer) objek tersebut biasanya berupa form atau render controller miliknya. Ini bukanlah perbaikan sementara (workaround) yang dilakukan secara terpaksa; ini menjaga agar kepemilikan atas state tersebut tetap eksplisit dan terlihat jelas pada objek penerima alih-alih disembunyikan di dalam sebuah closure

Pembatalan kooperatif, bukan penghentian paksa

Pembatalan di sini bersifat kooperatif. Tidak ada API yang mencampuri thread pekerja dan menghentikannya, karena mematikan thread di tengah-tengah proses render akan membiarkan PDFium menahan kunci (locks) dan bitmap yang baru tertulis sebagian, serta state proses setelah sebuah penghentian paksa (hard kill) tidak bisa diandalkan. Sebaliknya, pekerja diberikan sebuah token read-only dan diharapkan untuk memeriksanya, lalu siklus render diprogram untuk mengeceknya di antara satu halaman dengan halaman lain atau di antara ubin gambar (tiles), yang mana penghentian tersebut berlangsung bersih

Token menawarkan tiga cara untuk mendeteksi pembatalan. IsCancelled merupakan polling boolean ringan bagi perulangan yang ingin menguji dan mengambil keputusannya sendiri. ThrowIfCancelled adalah kasus paling umum: panggil saja pada titik pembatalan yang alami dan, bilamana terdapat permintaan pembatalan, itu akan membangkitkan (raises) eksepsi EPdfOperationCancelled, yang mengembalikan (unwinds) pekerja tersebut ke bentuk future-nya. RegisterCallback melampirkan sebuah notifikasi tangkapan-sekali (one-shot) yang memicu (fires) sekali ketika sumber (source) dibatalkan, ini sangat berguna ketika seorang pekerja sedang tertahan (blocked) pada sesuatu yang dapat diinterupsinya ketimbang menunggu iterasi secara ketat (tight loop)

Eksepsi (exception) adalah letak batasan thread (thread boundary) menjadi hal yang penting. Ketika seorang pekerja membangkitkan EPdfOperationCancelled, sang future menangkapnya dan mengubahnya menjadi status dibatalkan, sehingga balasan mendeteksinya sebagai IsCancelled ketimbang suatu kegagalan. Objek eksepsi itu sendiri tidak pernah di-marshaled ke thread utama. Eksepsi tersebut hidup dan mati di thread pekerja; hanya string pesannya yang disalin ke dalam ErrorMessage. Mem-marshal objek eksepsi yang masih hidup melewati batas-batas antar thread itu berarti menjangkau memori yang dimiliki oleh thread yang sudah mau berakhir (finishing), dan ini adalah kelompok kesalahan yang persis sama dengan perbaikan yang Synchronize cegah. Status beserta pesan teksnya saja sudah bisa melintasi batas secara bersih; objek utuh justru akan menimbulkan kekacauan

Dua antarmuka, agar pekerja tidak bisa membatalkan dirinya sendiri

Pembatalan sengaja dibagi ke dalam dua interface. IPdfCancellationTokenSource merupakan sisi penulisannya: ia memiliki metode Cancel, dan pemilik yang menciptakannya (biasanya sebuah form) menyimpannya lalu memanggil metode Cancel ketika pengguna mengeklik tombol atau menutup form tersebut. IPdfCancellationToken merupakan sisi pembacanya: ia memiliki elemen IsCancelled, ThrowIfCancelled, dan RegisterCallback, dan itulah hal-hal yang diterima oleh pekerja. Ada satu objek konkret yang mengimplementasikan keduanya, namun si pekerja sebatas menerima token pembaca, sehingga tidak memiliki kontrol pembatalan sama sekali ke dalam proses jalannya pekerjaan. Pemisahan fungsi tersebut merupakan batasan pengaman (guard rail) di dalam level API. Pekerja yang dapat menyentuh panggilan Cancel dari token yang dititipkan kepadanya bisa secara tak terduga memanggil program itu guna membatalkan operasinya, dan untungnya ini dihilangkan/dicegah berkat tipe sistem yang digunakan

Terdapat detail serupa (matching detail) untuk sebuah panggilan (caller) yang membutuhkan render tapi sama sekali tidak punya rencana membatalkannya di kemudian hari. Ketimbang terus memanggil pembaruan form objek dari semula, unit library ini menyediakan sepotong unit PdfNoCancellationToken, yakni fungsi token (singleton token) statis pada posisi tidak-dibatalkan (not-cancelled). Metode Run secara otomatis meletakkannya apabila letak argument dari token itu kosong atau nil. Unit parameter mandiri ini sengaja selalu dibuat-matang (eagerly constructed) sewaktu sesi penginisiasian ketimbang dirakit waktu pertama pemakaian berlangsung, dan pemicu utamanya dikarenakan masalah konkurensi (concurrency). Andai beberapa metode call (panggilan Run) berlomba menjalankan thread lalu menemukan metode singleton setengah telanjang itu, dipastikan balapan (race) saat pembuatannya tak bisa dihidari dan hal itu akan bocor dengan adanya duplikasi. Menghidupkan unit ini terlebih dahulu menghapus setiap probabilitas bentrok yang tak diharapkan

Menjalankan fungsi rendering yang bisa dibatalkan

Dalam penerapannya, Anda membuat sebuah sumber (source), menjaganya di dalam form, melewatkan Token-nya ke dalam metode Run berdampingan dengan metode pekerja (worker) dan balasan (reply), serta mengaitkan tombol Batal (Cancel) ke sumber (source) tersebut. Si pekerja kemudian memeriksa tokennya kala ia sedang merender; si balasan membarui status di UI (antarmuka pengguna) begitu hasilnya kembali. Oleh karena callback merupakan metode penunjuk (method pointers), maka kedua fungsi kerja dapat menelusuri atau membaca setiap input yang sekiranya dibutuhkan dari form di hadapan

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;

Balasan (reply) menangani semua hasil yang mungkin karena ketiganya memang mungkin terjadi. Proses rendering yang usai secara penuh akan melaporkan kesuksesan, pengguna yang memilih opsi Batal akan mendapat hasil rute pemberhentian/pembatalan (cancelled), dan saat memproses dokumen rusak maka fungsi membaca bakal memberikan status keterangan pesan gagal (failure with a message). Tiada satu di antara titik cabangnya memblokir aplikasi Anda, tidak satupun titik mencaplok urusan kerja dalam ranah pekerjanya tersebut, dan perihal bentuk maupun isian bitmap-nya akan dikonfirmasikan setelah perutusannya dibawa secara berantai di waktu thread kembali memasuki UI form pengguna (main thread)

Disiplin tata pengelolaan utas (threading discipline) yang sejenis memberi manfaat (pays off) pula di area lain dalam fungsi penampil (viewer). Cara di mana sebuah potongan render bitmap dapat terus disimpan dan dipakai kembali selama peralihan (zoom changes) tertuang dalam wujud penjelasan materi catatan performa resolusi rendering dari kecepatan fungsi cache dan perbesarannya, kemudian bila pertanyaan yang hendak dibahas meluas mengenai proteksi/pengamanan garis batasan pada PDFium (PDFium boundary) yang dikhususkan perihal aplikasi di dalam Delphi, dijumpai uraian pembatas perlindungannya ke tingkat PDFium VCL ABI yang secara lengkap dituangkan membedah fungsi batasan di artikel memberi jaminan ketahanan pada pembatasan batas keamanan memori Delphi. Infrastruktur Async (tidak selaras) seperti yang dituangkan pada penjelasan tutorial kita saat ini, didistribusikan selayaknya keping produk Komponen PDFium untuk fungsi pengembang di Delphi beserta C++Builder, serumpun sejalan menemani ragam dari serpihan fasilitas perihal perihal seputar rendering, manipulasi teks pada dokumen maupun isian dalam formulir (form) porsi API-API tersebut sejatinya telah pun direkam tersusun di bagian yang bersinggungan di seluruh isi kanal-kanal seputaran blog kita bersama saat ini