Mengoptimalkan tugas yang berjalan lama

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

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

Saran umum untuk menjaga kecepatan aplikasi JavaScript biasanya mengarah pada saran berikut:

  • "Jangan memblokir thread utama."
  • "Pecah tugas panjang Anda".

Ini adalah saran yang bagus, tetapi pekerjaan apa yang terlibat? Mengirimkan lebih sedikit JavaScript memang bagus, tetapi apakah hal itu otomatis menghasilkan antarmuka pengguna yang lebih responsif? Mungkin, tetapi mungkin tidak.

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

Apa itu tugas?

Tugas adalah setiap bagian pekerjaan diskrit yang dilakukan browser. Pekerjaan tersebut mencakup rendering, parsing HTML dan CSS, menjalankan JavaScript, dan jenis pekerjaan lain yang mungkin tidak dapat 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 tumpukan, dengan pengendali peristiwa klik, panggilan fungsi, dan item lainnya di bawahnya. Tugas ini juga mencakup beberapa tugas rendering di sisi kanan.
Tugas yang dimulai oleh handler peristiwa click di, ditampilkan di profiler performa Chrome DevTools.

Tugas yang terkait dengan JavaScript memengaruhi performa dalam beberapa cara:

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

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

Apa itu thread utama?

Thread utama adalah tempat sebagian besar tugas dijalankan 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 terjadinya interaksi saat tugas dengan durasi apa pun sedang berjalan, tetapi hal ini tidak dapat dirasakan oleh pengguna selama tugas tidak berjalan terlalu lama. Namun, saat pengguna mencoba berinteraksi dengan halaman saat ada banyak tugas panjang, antarmuka pengguna akan terasa tidak responsif, dan bahkan mungkin rusak jika thread utama diblokir dalam jangka waktu yang sangat lama.

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

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

Satu tugas panjang versus tugas yang sama yang dipecah menjadi tugas yang lebih pendek. Tugas panjang adalah satu persegi panjang besar, sedangkan tugas yang dibagi-bagi adalah lima kotak yang lebih kecil yang secara bersama-sama memiliki lebar yang sama dengan tugas panjang.
Visualisasi satu tugas panjang versus tugas yang sama yang dipecah menjadi lima tugas yang lebih pendek.

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

Penggambaran tentang bagaimana memecah tugas dapat memfasilitasi interaksi pengguna. Di bagian atas, tugas yang panjang akan memblokir handler peristiwa agar tidak berjalan hingga tugas selesai. Di bagian bawah, tugas yang dibagi-bagi memungkinkan pengendali peristiwa berjalan lebih cepat daripada yang seharusnya.
Visualisasi tentang apa yang terjadi pada interaksi saat tugas terlalu panjang dan browser tidak dapat merespons interaksi dengan cukup cepat, dibandingkan saat tugas yang lebih panjang dipecah menjadi tugas yang lebih kecil.

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

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

Strategi pengelolaan tugas

Saran umum dalam arsitektur software adalah memecah pekerjaan Anda menjadi fungsi-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 spinner, mengirim data ke backend aplikasi, memperbarui antarmuka pengguna, dan mengirim analisis.

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

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 akan berjalan sebagai satu tugas.

Fungsi saveSettings seperti yang digambarkan di profiler performa Chrome. Meskipun fungsi tingkat teratas memanggil lima fungsi lainnya, semua pekerjaan dilakukan dalam satu tugas panjang sehingga hasil yang terlihat oleh pengguna saat menjalankan fungsi tidak akan 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 terbaik, bahkan hanya satu fungsi tersebut dapat berkontribusi 50 milidetik atau lebih terhadap total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat dengan resource terbatas.

Dalam hal ini, saveSettings() dipicu oleh klik pengguna, dan karena browser tidak dapat menampilkan respons hingga seluruh fungsi selesai berjalan, hasil tugas yang panjang 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 dihadapi pengguna dan respons UI terjadi sebelum tugas berprioritas lebih rendah, Anda dapat menyerahkan ke thread utama dengan menghentikan pekerjaan Anda sejenak untuk memberi browser kesempatan menjalankan tugas yang lebih penting.

Salah satu metode yang digunakan developer untuk memecah tugas menjadi tugas yang lebih kecil melibatkan setTimeout(). Dengan teknik ini, Anda meneruskan fungsi ke setTimeout(). Hal ini menunda eksekusi callback ke dalam 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 yielding, dan paling cocok untuk serangkaian fungsi yang perlu dijalankan secara berurutan.

Namun, kode Anda mungkin tidak selalu disusun seperti ini. Misalnya, Anda dapat memiliki data dalam jumlah besar yang perlu diproses dalam loop, dan tugas tersebut dapat memakan 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 kekurangan lain dalam hal penundaan: saat Anda menunda 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 penyerahan khusus: scheduler.yield()

Browser Support

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

Source

scheduler.yield() adalah API yang dirancang khusus untuk memberikan hasil ke 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. Kode apa pun yang dirantai untuk dijalankan setelah Promise tersebut diselesaikan (baik dalam rantai .then() eksplisit atau setelah await di fungsi asinkron) akan dijalankan dalam tugas mendatang tersebut.

Dalam praktiknya: sisipkan await scheduler.yield() dan fungsi akan menjeda eksekusi pada saat itu dan menghasilkan ke thread utama. Eksekusi fungsi lainnya—yang disebut kelanjutan fungsi—akan dijadwalkan untuk dijalankan dalam tugas loop peristiwa baru. Saat tugas tersebut dimulai, promise yang ditunggu akan diselesaikan, dan fungsi akan terus dieksekusi dari tempat penghentiannya.

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 dan memberikan respons yang terlihat kepada pengguna. 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 memberikan respons visual yang lebih cepat kepada pengguna, sebagaimana diukur oleh interaksi pointer yang kini jauh lebih singkat.

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

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

Tiga diagram yang menggambarkan tugas tanpa yielding, dengan yielding, dan dengan yielding dan kelanjutan. Tanpa menghasilkan, ada tugas yang panjang. Dengan penundaan, ada lebih banyak tugas yang lebih singkat, tetapi dapat terganggu oleh tugas lain yang tidak terkait. Dengan penangguhan dan kelanjutan, ada lebih banyak tugas yang lebih pendek, tetapi urutan eksekusinya dipertahankan.
Saat Anda menggunakan scheduler.yield(), kelanjutan akan dilanjutkan 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 memasukkan scheduler-polyfill ke dalam build Anda, lalu scheduler.yield() dapat digunakan secara langsung; polyfill akan menangani penggantian ke fungsi penjadwalan tugas lainnya sehingga berfungsi serupa di seluruh browser.

Atau, versi yang kurang canggih dapat ditulis dalam beberapa baris, hanya menggunakan setTimeout yang di-wrap 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 akan tetap memberikan hasil agar browser tetap responsif.

Terakhir, mungkin ada kasus ketika kode Anda tidak dapat menyerahkan ke thread utama jika kelanjutannya tidak diprioritaskan (misalnya, halaman yang diketahui sibuk yang berisiko tidak menyelesaikan tugas untuk beberapa waktu). Dalam hal ini, scheduler.yield() dapat diperlakukan sebagai semacam peningkatan progresif: menghasilkan di browser tempat scheduler.yield() tersedia, atau melanjutkan.

Hal ini dapat dilakukan dengan mendeteksi fitur dan melakukan penggantian ke penantian satu microtask dalam satu baris kode 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 di fungsi async mana pun.

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

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

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

Kelanjutan runJobs() akan diprioritaskan, tetapi tetap memungkinkan pekerjaan berprioritas lebih tinggi seperti merespons input pengguna secara visual untuk dijalankan, tidak perlu menunggu daftar pekerjaan yang berpotensi panjang selesai.

Namun, ini bukan penggunaan yang efisien untuk menghasilkan. 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 melepaskan dan melanjutkan daripada menjalankan tugas sebenarnya.

Salah satu pendekatan adalah mengelompokkan tugas, hanya melakukan penyerahan di antara tugas jika sudah cukup lama sejak penyerahan terakhir. Batas waktu umum adalah 50 milidetik untuk mencoba mencegah tugas menjadi tugas panjang, tetapi batas waktu ini 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 terlalu lama dijalankan, tetapi pelari hanya memberikan hasil ke thread utama setiap 50 milidetik.

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

Jangan gunakan isInputPending()

Browser Support

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

Source

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

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

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

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

Kesimpulan

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

  • Berikan prioritas pada thread utama untuk tugas penting yang dihadapi pengguna.
  • Gunakan scheduler.yield() (dengan penggantian lintas browser) untuk menghasilkan secara ergonomis dan mendapatkan kelanjutan yang diprioritaskan
  • Terakhir, lakukan sesedikit mungkin pekerjaan dalam fungsi Anda.

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

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

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

Gambar thumbnail yang diambil dari Unsplash, atas izin Amirali Mirhashemian.