Mengoptimalkan tugas yang berjalan lama

Anda telah diberi tahu "jangan memblokir thread utama" dan "membagi tugas yang lama", tetapi apa artinya melakukan hal-hal tersebut?

Dipublikasikan: 30 September 2022, Terakhir diperbarui: 19 Desember 2024

Saran umum untuk menjaga aplikasi JavaScript tetap cepat cenderung bermuara pada saran berikut:

  • "Jangan memblokir thread utama".
  • "Bagi tugas panjang Anda menjadi beberapa bagian."

Ini adalah saran yang bagus, tetapi apa yang harus dilakukan? Mengirimkan lebih sedikit JavaScript itu bagus, tetapi apakah hal itu otomatis sama dengan antarmuka pengguna yang lebih responsif? Mungkin ya, mungkin tidak.

Untuk memahami cara mengoptimalkan tugas di JavaScript, Anda harus mengetahui tugas itu sendiri dan cara browser menanganinya terlebih dahulu.

Apa yang dimaksud dengan tugas?

Tugas adalah bagian pekerjaan terpisah yang dilakukan browser. Pekerjaan tersebut mencakup rendering, mengurai HTML dan CSS, menjalankan JavaScript, dan jenis pekerjaan lainnya yang mungkin tidak Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis mungkin merupakan sumber tugas terbesar.

Visualisasi tugas seperti yang digambarkan dalam profiler performa DevTools Chrome. Tugas berada di bagian atas stack, dengan pengendali peristiwa klik, panggilan fungsi, dan item lainnya di bawahnya. Tugas ini juga mencakup beberapa pekerjaan rendering di sisi kanan.
Tugas yang dimulai oleh pengendali peristiwa click, yang ditampilkan di profiler performa Chrome DevTools.

Tugas yang terkait dengan JavaScript memengaruhi performa dengan beberapa cara:

  • Saat mendownload file JavaScript selama startup, browser akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut agar dapat dieksekusi nanti.
  • Pada waktu lain selama siklus proses halaman, tugas akan diantrekan saat JavaScript berfungsi, seperti merespons interaksi melalui pengendali peristiwa, animasi yang didorong JavaScript, dan aktivitas latar belakang seperti pengumpulan analisis.

Semua hal ini—kecuali web worker dan API serupa—terjadi di thread utama.

Apa yang dimaksud dengan thread utama?

Thread utama adalah tempat sebagian besar tugas berjalan di browser, dan tempat hampir semua JavaScript yang Anda tulis dieksekusi.

Thread utama hanya dapat memproses satu tugas dalam satu waktu. Setiap tugas yang memerlukan waktu lebih dari 50 milidetik adalah tugas panjang. Untuk tugas yang melebihi 50 milidetik, total waktu tugas dikurangi 50 milidetik dikenal sebagai periode pemblokiran tugas.

Browser memblokir interaksi agar tidak terjadi saat tugas berdurasi berapa pun sedang berjalan, tetapi hal ini tidak dapat dilihat oleh pengguna selama tugas tidak berjalan terlalu lama. Namun, saat pengguna mencoba berinteraksi dengan halaman saat ada banyak tugas yang lama, antarmuka pengguna akan terasa tidak responsif, dan mungkin bahkan rusak jika thread utama diblokir untuk jangka waktu yang sangat lama.

Tugas yang lama di profiler performa DevTools Chrome. Bagian pemblokiran tugas (lebih dari 50 milidetik) digambarkan dengan pola garis diagonal merah.
Tugas yang lama seperti yang digambarkan dalam profiler performa Chrome. Tugas yang lama ditunjukkan dengan segitiga merah di sudut tugas, dengan bagian pemblokiran tugas diisi dengan pola garis merah diagonal.

Untuk mencegah thread utama diblokir terlalu lama, Anda dapat membagi tugas yang panjang menjadi beberapa tugas yang lebih kecil.

Satu tugas panjang versus tugas yang sama yang dibagi menjadi tugas yang lebih singkat. Tugas panjang adalah satu persegi panjang besar, sedangkan tugas yang dikelompokkan adalah lima kotak kecil yang secara kolektif memiliki lebar yang sama dengan tugas panjang.
Visualisasi satu tugas panjang versus tugas yang sama yang dibagi menjadi lima tugas yang lebih pendek.

Hal ini penting karena saat tugas dipecah, browser dapat merespons pekerjaan dengan prioritas lebih tinggi jauh lebih cepat—termasuk interaksi pengguna. Setelah itu, tugas yang tersisa akan berjalan hingga selesai, sehingga memastikan pekerjaan yang awalnya Anda antrekan akan selesai.

Penggambaran tentang bagaimana membagi tugas dapat memfasilitasi interaksi pengguna. Di bagian atas, tugas yang lama akan memblokir pengendali peristiwa agar tidak berjalan hingga tugas selesai. Di bagian bawah, tugas yang dikelompokkan memungkinkan pengendali peristiwa berjalan lebih cepat dari biasanya.
Visualisasi tentang apa yang terjadi pada interaksi saat tugas terlalu lama dan browser tidak dapat merespons interaksi dengan cukup cepat, dibandingkan saat tugas yang lebih lama dibagi menjadi tugas yang lebih kecil.

Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas panjang sebelum dapat dimulai. Hal ini menunda interaksi. Dalam skenario ini, pengguna mungkin telah melihat jeda. Di bagian bawah, pengendali peristiwa dapat mulai berjalan lebih cepat, dan interaksi mungkin terasa instan.

Setelah mengetahui pentingnya membagi tugas, Anda dapat mempelajari cara melakukannya di JavaScript.

Strategi pengelolaan tugas

Saran umum dalam arsitektur software adalah membagi pekerjaan Anda menjadi beberapa fungsi yang lebih kecil:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

Dalam contoh ini, ada fungsi bernama saveSettings() yang memanggil lima fungsi untuk memvalidasi formulir, menampilkan indikator lingkaran berputar, mengirim data ke backend aplikasi, memperbarui antarmuka pengguna, dan mengirim analisis.

Secara konseptual, saveSettings() dirancang dengan baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat menjelajahi hierarki project untuk mengetahui fungsi setiap fungsi. Dengan membagi pekerjaan seperti ini, project akan lebih mudah dikelola dan dipelihara.

Namun, potensi masalah di sini adalah JavaScript tidak menjalankan setiap fungsi ini sebagai tugas terpisah karena fungsi tersebut dieksekusi dalam fungsi saveSettings(). Artinya, kelima fungsi tersebut akan berjalan sebagai satu tugas.

Fungsi saveSettings seperti yang digambarkan dalam profiler performa Chrome. Meskipun fungsi tingkat atas memanggil lima fungsi lainnya, semua pekerjaan dilakukan dalam satu tugas panjang yang membuat hasil yang terlihat oleh pengguna saat menjalankan fungsi tidak terlihat hingga semuanya selesai.
Satu fungsi saveSettings() yang memanggil lima fungsi. Pekerjaan dijalankan sebagai bagian dari satu tugas monolitik yang panjang, yang memblokir respons visual apa pun hingga kelima fungsi selesai.

Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut yang dapat berkontribusi 50 milidetik atau lebih pada total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat yang memiliki keterbatasan resource.

Dalam hal ini, saveSettings() dipicu oleh klik pengguna, dan karena browser tidak dapat menampilkan respons hingga seluruh fungsi selesai berjalan, hasil dari tugas yang lama ini adalah UI yang lambat dan tidak responsif, dan akan diukur sebagai Interaction to Next Paint (INP) yang buruk.

Menunda eksekusi kode secara manual

Untuk memastikan tugas penting yang ditampilkan kepada pengguna dan respons UI terjadi sebelum tugas prioritas rendah, Anda dapat menghasilkan thread utama dengan mengganggu pekerjaan Anda sebentar untuk memberi browser kesempatan menjalankan tugas yang lebih penting.

Salah satu metode yang digunakan developer untuk membagi tugas menjadi tugas yang lebih kecil melibatkan setTimeout(). Dengan teknik ini, Anda meneruskan fungsi ke setTimeout(). Tindakan ini menunda eksekusi callback ke tugas terpisah, meskipun Anda menentukan waktu tunggu 0.

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

Hal ini dikenal sebagai menghasilkan, dan berfungsi paling baik untuk serangkaian fungsi yang perlu dijalankan secara berurutan.

Namun, kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda dapat memiliki data dalam jumlah besar yang perlu diproses dalam loop, dan tugas tersebut dapat memerlukan waktu yang sangat lama jika ada banyak iterasi.

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

Penggunaan setTimeout() di sini bermasalah karena ergonomi developer, dan setelah lima putaran setTimeout() bertingkat, browser akan mulai menerapkan penundaan minimum 5 milidetik untuk setiap setTimeout() tambahan.

setTimeout juga memiliki kelemahan lain dalam hal pengunduran: saat Anda menyerahkan ke thread utama dengan menunda kode untuk dijalankan dalam tugas berikutnya menggunakan setTimeout, tugas tersebut akan ditambahkan ke akhir antrean. Jika ada tugas lain yang menunggu, tugas tersebut akan berjalan sebelum kode yang ditangguhkan.

API yang menghasilkan khusus: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not supported.
  • Safari: not supported.

Source

scheduler.yield() adalah API yang dirancang khusus untuk menghasilkan thread utama di browser.

Ini bukan sintaks tingkat bahasa atau konstruksi khusus; scheduler.yield() hanyalah fungsi yang menampilkan Promise yang akan diselesaikan dalam tugas mendatang. Setiap kode yang dirantai untuk dijalankan setelah Promise tersebut di-resolve (baik dalam rantai .then() eksplisit atau setelah await dalam fungsi asinkron) akan dijalankan dalam tugas mendatang tersebut.

Dalam praktiknya: masukkan await scheduler.yield() dan fungsi akan menjeda eksekusi pada titik tersebut dan menghasilkan thread utama. Eksekusi fungsi lainnya—disebut lanjutan fungsi—akan dijadwalkan untuk berjalan dalam tugas loop peristiwa baru. Saat tugas tersebut dimulai, promise yang ditunggu akan diselesaikan, dan fungsi akan terus dieksekusi dari tempat terakhir dieksekusi.

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Fungsi saveSettings seperti yang digambarkan dalam profiler performa Chrome, kini dibagi menjadi dua tugas. Tugas pertama memanggil dua fungsi, lalu menghasilkan, sehingga memungkinkan pekerjaan tata letak dan gambar terjadi serta memberi pengguna respons yang terlihat. Akibatnya, peristiwa klik selesai dalam waktu 64 milidetik yang jauh lebih cepat. Tugas kedua memanggil tiga fungsi terakhir.
Eksekusi fungsi saveSettings() kini dibagi menjadi dua tugas. Akibatnya, tata letak dan gambar dapat berjalan di antara tugas, sehingga memberi pengguna respons visual yang lebih cepat, seperti yang diukur dengan interaksi pointer yang kini jauh lebih singkat.

Namun, manfaat sebenarnya dari scheduler.yield() dibandingkan pendekatan yang menghasilkan lainnya adalah kelanjutannya diprioritaskan, yang berarti jika Anda menghasilkan di tengah tugas, kelanjutan tugas saat ini akan berjalan sebelum tugas serupa lainnya dimulai.

Hal ini menghindari kode dari sumber tugas lain agar tidak mengganggu urutan eksekusi kode Anda, seperti tugas dari skrip pihak ketiga.

Tiga diagram yang menggambarkan tugas tanpa menghasilkan, menghasilkan, dan dengan menghasilkan dan melanjutkan. Tanpa menghasilkan, ada tugas yang panjang. Dengan penghentian, ada lebih banyak tugas yang lebih singkat, tetapi dapat terganggu oleh tugas lain yang tidak terkait. Dengan penghentian dan kelanjutan, ada lebih banyak tugas yang lebih singkat, tetapi urutan eksekusinya dipertahankan.
Saat Anda menggunakan scheduler.yield(), kelanjutan akan melanjutkan dari tempat terakhir sebelum melanjutkan ke tugas lain.

Dukungan lintas browser

scheduler.yield() belum didukung di semua browser, sehingga diperlukan penggantian.

Salah satu solusinya adalah dengan menempatkan scheduler-polyfill ke dalam build, lalu scheduler.yield() dapat digunakan secara langsung; polyfill akan menangani kembali ke fungsi penjadwalan tugas lainnya sehingga berfungsi dengan cara yang sama di seluruh browser.

Atau, versi yang kurang canggih dapat ditulis dalam beberapa baris, hanya menggunakan setTimeout yang digabungkan dalam Promise sebagai penggantian jika scheduler.yield() tidak tersedia.

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Meskipun browser tanpa dukungan scheduler.yield() tidak akan mendapatkan kelanjutan yang diprioritaskan, browser tersebut tetap akan menghasilkan agar tetap responsif.

Terakhir, mungkin ada kasus saat kode Anda tidak dapat memberikan thread utama jika kelanjutannya tidak diprioritaskan (misalnya, halaman yang diketahui sibuk yang berisiko tidak menyelesaikan tugas selama beberapa waktu). Dalam hal ini, scheduler.yield() dapat diperlakukan sebagai jenis progressive enhancement: hasil di browser tempat scheduler.yield() tersedia, jika tidak, lanjutkan.

Hal ini dapat dilakukan dengan mendeteksi fitur dan kembali menunggu satu tugas mikro dalam satu baris yang praktis:

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

Membagi pekerjaan yang berjalan lama dengan scheduler.yield()

Manfaat menggunakan salah satu metode penggunaan scheduler.yield() ini adalah Anda dapat await-nya dalam fungsi async apa pun.

Misalnya, jika Anda memiliki array tugas yang akan dijalankan yang sering kali berakhir dengan tugas yang panjang, Anda dapat menyisipkan hasil untuk membagi tugas.

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

    // Yield to the main thread:
    await yieldToMain();
  }
}

Lanjutan runJobs() akan diprioritaskan, tetapi tetap memungkinkan pekerjaan dengan prioritas lebih tinggi seperti merespons input pengguna secara visual untuk dijalankan, tanpa harus menunggu daftar tugas yang berpotensi panjang selesai.

Namun, ini bukan penggunaan yang efisien dari penghasil. scheduler.yield() cepat dan efisien, tetapi memiliki beberapa overhead. Jika beberapa tugas di jobQueue sangat singkat, overhead dapat dengan cepat bertambah menjadi lebih banyak waktu yang dihabiskan untuk menghasilkan dan melanjutkan daripada menjalankan tugas yang sebenarnya.

Salah satu pendekatannya adalah dengan mengelompokkan tugas, hanya menghasilkan di antara tugas tersebut jika sudah cukup lama sejak hasil terakhir. Batas waktu umum adalah 50 milidetik untuk mencoba mencegah tugas menjadi tugas yang lama, tetapi dapat disesuaikan sebagai kompromi antara responsivitas dan waktu untuk menyelesaikan antrean tugas.

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

Hasilnya adalah tugas dipecah agar tidak pernah memerlukan waktu terlalu lama untuk dijalankan, tetapi runner hanya menghasilkan thread utama sekitar setiap 50 milidetik.

Serangkaian fungsi tugas, yang ditampilkan di panel performa Chrome DevTools, dengan eksekusi yang dibagi menjadi beberapa tugas
Tugas yang dikelompokkan menjadi beberapa tugas.

Jangan gunakan isInputPending()

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

isInputPending() API menyediakan cara untuk memeriksa apakah pengguna telah mencoba berinteraksi dengan halaman dan hanya menghasilkan jika input tertunda.

Hal ini memungkinkan JavaScript berlanjut jika tidak ada input yang tertunda, bukan menghasilkan dan berakhir di bagian belakang antrean tugas. Hal ini dapat menghasilkan peningkatan performa yang mengesankan, seperti yang dijelaskan dalam Intent to Ship, untuk situs yang mungkin tidak kembali ke thread utama.

Namun, sejak peluncuran API tersebut, pemahaman kami tentang penghasil telah meningkat, terutama dengan diperkenalkannya INP. Kami tidak lagi merekomendasikan penggunaan API ini, dan sebagai gantinya merekomendasikan untuk menghasilkan terlepas dari apakah input tertunda atau tidak karena sejumlah alasan:

  • isInputPending() mungkin salah menampilkan false meskipun pengguna telah berinteraksi dalam beberapa situasi.
  • Input bukanlah satu-satunya kasus saat tugas harus menghasilkan. Animasi dan pembaruan antarmuka pengguna reguler lainnya juga sama pentingnya untuk menyediakan halaman web yang responsif.
  • API yang lebih komprehensif telah diperkenalkan sejak saat itu yang mengatasi masalah yang menghasilkan, seperti scheduler.postTask() dan scheduler.yield().

Kesimpulan

Mengelola tugas memang sulit, tetapi melakukannya akan memastikan halaman Anda merespons interaksi pengguna dengan lebih cepat. Tidak ada satu saran untuk mengelola dan memprioritaskan tugas, tetapi ada sejumlah teknik yang berbeda. Untuk mengulang, berikut adalah hal-hal utama yang perlu dipertimbangkan saat mengelola tugas:

  • Menghasilkan thread utama untuk tugas penting yang ditampilkan kepada pengguna.
  • Menggunakan scheduler.yield() (dengan penggantian lintas browser) untuk menghasilkan dan mendapatkan kelanjutan yang diprioritaskan secara ergonomis
  • Terakhir, lakukan pekerjaan sesedikit mungkin dalam fungsi Anda.

Untuk mempelajari scheduler.yield(), scheduler.postTask() penjadwalan tugas relatif eksplisit, dan prioritas tugas lebih lanjut, lihat dokumen Prioritized Task Scheduling API.

Dengan satu atau beberapa alat ini, Anda akan dapat menyusun pekerjaan di aplikasi sehingga memprioritaskan kebutuhan pengguna, sekaligus memastikan bahwa pekerjaan yang kurang penting tetap dapat diselesaikan. Hal ini akan menciptakan pengalaman pengguna yang lebih baik, yang lebih responsif dan lebih menyenangkan untuk digunakan.

Terima kasih khusus kepada Philip Walton atas pemeriksaan teknisnya terhadap panduan ini.

Gambar thumbnail bersumber dari Unsplash, atas izin Amirali Mirhashemian.