Mengoptimalkan tugas yang berjalan lama

Anda diberi tahu "jangan memblokir thread utama" dan "putuskan tugas yang panjang", tapi apa artinya melakukan hal-hal tersebut?

Jeremy Wagner
Jeremy Wagner

Jika Anda membaca banyak hal tentang performa web, saran untuk menjaga agar aplikasi JavaScript Anda tetap cepat cenderung melibatkan beberapa informasi berikut:

  • "Jangan memblokir thread utama."
  • "Pisahkan tugas yang berjalan lama."

Apa artinya? Mengirim JavaScript lebih sedikit memang bagus, tetapi apakah hal itu secara otomatis setara dengan antarmuka pengguna yang lebih cepat di seluruh siklus proses halaman? Mungkin ya, tapi mungkin tidak.

Untuk memahami pentingnya mengoptimalkan tugas di JavaScript, Anda perlu memahami peran tugas dan bagaimana browser menanganinya—dan itu dimulai dengan memahami apa itu tugas.

Apa itu tugas?

Tugas adalah setiap pekerjaan terpisah yang dilakukan browser. Tugas melibatkan pekerjaan seperti rendering, penguraian HTML dan CSS, menjalankan kode JavaScript yang Anda tulis, dan hal-hal lain yang mungkin tidak dapat Anda kontrol secara langsung. Dari semua ini, JavaScript yang Anda tulis dan deploy ke web adalah sumber utama tugas.

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

Tugas memengaruhi performa dalam beberapa cara. Misalnya, ketika browser mendownload file JavaScript saat startup, browser tersebut akan mengantrekan tugas untuk mengurai dan mengompilasi JavaScript tersebut agar dapat dieksekusi. Kemudian di dalam siklus proses halaman, tugas akan dimulai saat JavaScript Anda berfungsi seperti mendorong interaksi melalui pengendali peristiwa, animasi berbasis JavaScript, dan aktivitas latar belakang seperti pengumpulan analisis. Semua hal ini—kecuali pekerja web dan API serupa—terjadi di thread utama.

Apa yang dimaksud dengan thread utama?

Thread utama adalah tempat sebagian besar tugas dijalankan di browser. Fungsi ini disebut thread utama karena suatu alasan: ini adalah satu thread tempat hampir semua JavaScript yang Anda tulis melakukan tugasnya.

Thread utama hanya dapat memproses satu tugas dalam satu waktu. Jika tugas melampaui titik tertentu—tepat 50 milidetik—tugas tersebut akan diklasifikasikan sebagai tugas panjang. Jika pengguna mencoba berinteraksi dengan halaman saat tugas yang panjang berjalan—atau jika update rendering yang penting perlu dilakukan—browser akan tertunda dalam menangani pekerjaan tersebut. Hal ini menyebabkan latensi interaksi atau rendering.

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

Anda harus memecah tugas. Ini berarti mengambil satu tugas yang panjang dan membaginya menjadi tugas-tugas yang lebih kecil yang membutuhkan waktu lebih sedikit untuk dijalankan satu per satu.

Satu tugas panjang versus tugas yang sama dipecah menjadi tugas yang lebih pendek. Tugas panjang adalah satu persegi panjang besar, sedangkan tugas potongan adalah lima kotak yang lebih kecil yang secara kolektif 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 ketika tugas terbagi, browser memiliki lebih banyak peluang untuk merespons pekerjaan dengan prioritas lebih tinggi—termasuk interaksi pengguna.

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

Di bagian atas gambar sebelumnya, pengendali peristiwa yang diantrekan oleh interaksi pengguna harus menunggu satu tugas panjang sebelum dapat berjalan. Hal ini menunda berlangsungnya interaksi. Di bagian bawah, pengendali peristiwa memiliki kesempatan untuk berjalan lebih cepat. Karena memiliki peluang untuk berjalan di antara tugas-tugas yang lebih kecil, pengendali peristiwa akan berjalan lebih cepat daripada jika harus menunggu tugas yang panjang selesai. Dalam contoh atas, pengguna mungkin melihat adanya jeda; di bagian bawah, interaksi mungkin terasa langsung.

Namun, masalahnya adalah saran "bagi tugas yang panjang" dan "jangan blokir thread utama" tidak cukup spesifik kecuali jika Anda sudah mengetahui cara melakukan hal tersebut. Itulah yang akan dijelaskan dalam panduan ini.

Strategi manajemen tugas

Saran umum dalam arsitektur perangkat lunak adalah memecah pekerjaan menjadi beberapa fungsi yang lebih kecil. Ini memberi Anda manfaat berupa keterbacaan kode yang lebih baik, dan kemudahan pemeliharaan proyek. Hal ini juga membuat pengujian lebih mudah ditulis.

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

Dalam contoh ini, ada fungsi bernama saveSettings() yang memanggil lima fungsi di dalamnya untuk melakukan tugas, seperti memvalidasi formulir, menampilkan indikator lingkaran berputar, mengirim data, dan sebagainya. Secara konseptual, hal ini dirancang dengan baik. Jika perlu men-debug salah satu fungsi ini, Anda dapat melintasi pohon proyek untuk mencari tahu apa yang dilakukan setiap fungsi.

Namun, masalahnya adalah JavaScript tidak menjalankan setiap fungsi ini sebagai tugas terpisah karena dijalankan dalam fungsi saveSettings(). Ini berarti kelima fungsi tersebut berjalan sebagai satu tugas.

Fungsi SaveSettings seperti yang digambarkan dalam profiler performa Chrome. Meskipun fungsi tingkat atas memanggil lima fungsi lainnya, semua pekerjaan berlangsung dalam satu tugas panjang yang memblokir thread utama.
saveSettings() fungsi tunggal yang memanggil lima fungsi. Pekerjaan dijalankan sebagai bagian dari satu tugas monolitik yang panjang.

Dalam skenario kasus terbaik, bahkan hanya satu dari fungsi tersebut dapat berkontribusi 50 milidetik atau lebih untuk total durasi tugas. Dalam kasus terburuk, lebih banyak tugas tersebut dapat berjalan lebih lama—terutama di perangkat dengan resource yang terbatas. Berikut ini adalah seperangkat strategi yang dapat Anda gunakan untuk memecah dan memprioritaskan tugas.

Menunda eksekusi kode secara manual

Salah satu metode yang digunakan developer untuk memecah tugas menjadi lebih kecil adalah setTimeout(). Dengan teknik ini, Anda akan 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);
}

Ini akan berfungsi dengan baik jika Anda memiliki serangkaian fungsi yang perlu dijalankan secara berurutan, tetapi kode Anda mungkin tidak selalu diatur dengan cara ini. Misalnya, Anda bisa memiliki data dalam jumlah besar yang perlu diproses dalam satu loop, dan tugas tersebut dapat memakan waktu yang sangat lama jika Anda memiliki jutaan item.

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

Menggunakan setTimeout() di sini bermasalah, karena ergonomi membuatnya sulit untuk diterapkan, dan seluruh array data dapat memerlukan waktu yang sangat lama untuk diproses, meskipun setiap item dapat diproses dengan sangat cepat. Semuanya akan bertambah, dan setTimeout() bukanlah alat yang tepat untuk tugas tersebut—setidaknya tidak saat digunakan dengan cara ini.

Selain setTimeout(), ada beberapa API lain yang memungkinkan Anda menunda eksekusi kode ke tugas berikutnya. Salah satunya melibatkan penggunaan postMessage() untuk waktu tunggu yang lebih cepat. Anda juga dapat membagi tugas menggunakan requestIdleCallback()—tetapi hati-hati!—requestIdleCallback() menjadwalkan tugas pada prioritas serendah mungkin, dan hanya selama waktu tidak ada aktivitas browser. Jika thread utama padat, tugas yang dijadwalkan dengan requestIdleCallback() mungkin tidak dapat dijalankan.

Gunakan async/await untuk membuat poin hasil

Frasa yang akan Anda lihat di sepanjang panduan ini adalah "hasil untuk thread utama"—tapi apa artinya? Mengapa Anda harus melakukannya? Kapan Anda harus melakukannya?

Ketika tugas dipecah, tugas lain dapat diprioritaskan dengan lebih baik oleh skema prioritas internal browser. Salah satu cara untuk menghasilkan thread utama melibatkan penggunaan kombinasi Promise yang di-resolve dengan panggilan ke setTimeout():

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

Dalam fungsi saveSettings(), Anda dapat menghasilkan thread utama setelah setiap bit pekerjaan jika Anda melakukan await pada fungsi yieldToMain() setelah setiap panggilan fungsi:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

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

Hasilnya adalah tugas yang sebelumnya monolitik sekarang dipecah menjadi beberapa tugas terpisah.

Fungsi simpanSettings yang sama seperti yang digambarkan di profiler performa Chrome, hanya dengan hasil. Hasilnya adalah tugas yang dulunya monolitiknya sekarang dipecah menjadi lima tugas terpisah—satu untuk setiap fungsi.
Fungsi saveSettings() sekarang menjalankan fungsi turunannya sebagai tugas terpisah.

Manfaat menggunakan pendekatan berbasis promise untuk menghasilkan daripada penggunaan setTimeout() manual adalah ergonomi yang lebih baik. Titik hasil menjadi deklaratif, sehingga lebih mudah ditulis, dibaca, dan dipahami.

Menghasilkan hanya jika diperlukan

Bagaimana jika Anda memiliki banyak tugas, tetapi Anda hanya ingin menghasilkan jika pengguna mencoba berinteraksi dengan halaman? Itulah tujuan pembuatan isInputPending().

isInputPending() adalah fungsi yang dapat Anda jalankan kapan saja untuk menentukan apakah pengguna mencoba berinteraksi dengan elemen halaman: panggilan ke isInputPending() akan menampilkan true. Metode ini akan menampilkan false jika tidak cocok.

Misalnya Anda memiliki antrean tugas yang harus dijalankan, tetapi Anda tidak ingin menghalangi input apa pun. Kode ini—yang menggunakan isInputPending() dan fungsi yieldToMain() kustom—memastikan bahwa input tidak akan tertunda saat pengguna mencoba berinteraksi dengan halaman:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  while (tasks.length > 0) {
    // Yield to a pending user input:
    if (navigator.scheduling.isInputPending()) {
      // There's a pending user input. Yield here:
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

Saat berjalan, saveSettings() akan melakukan loop atas tugas dalam antrean. Jika isInputPending() menampilkan true selama loop, saveSettings() akan memanggil yieldToMain() sehingga input pengguna dapat ditangani. Jika tidak, ini akan menggeser tugas berikutnya dari depan antrean dan menjalankannya secara terus-menerus. Ini akan dilakukan sampai tidak ada lagi tugas yang tersisa.

Penggambaran fungsi SaveSettings yang berjalan di profiler performa Chrome. Tugas yang dihasilkan memblokir thread utama hingga isInputTertunda menampilkan nilai benar (true), yang pada saat itu tugas tersebut akan dihasilkan ke thread utama.
saveSettings() menjalankan task queue untuk lima tugas, tetapi pengguna telah mengklik untuk membuka menu saat item tugas kedua berjalan. isInputPending() menghasilkan thread utama untuk menangani interaksi, dan melanjutkan menjalankan tugas lainnya.

Menggunakan isInputPending() yang dikombinasikan dengan mekanisme hasil merupakan cara yang bagus untuk membuat browser menghentikan tugas apa pun yang sedang diproses sehingga dapat merespons interaksi penting yang ditampilkan kepada pengguna. Hal tersebut dapat membantu meningkatkan kemampuan halaman Anda untuk merespons pengguna dalam berbagai situasi saat banyak tugas yang sedang berlangsung.

Cara lain untuk menggunakan isInputPending()—terutama jika Anda khawatir menyediakan penggantian untuk browser yang tidak mendukungnya—adalah dengan menggunakan pendekatan berbasis waktu bersama dengan operator perantaian opsional:

async function saveSettings () {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];
  
  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline = performance.now() + 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

Dengan pendekatan ini, Anda akan mendapatkan penggantian untuk browser yang tidak mendukung isInputPending() dengan menggunakan pendekatan berbasis waktu yang menggunakan (dan menyesuaikan) batas waktu sehingga pekerjaan akan dipecah jika diperlukan, baik dengan memberikan input pengguna, atau pada titik waktu tertentu.

Kesenjangan dalam API saat ini

API yang telah disebutkan sejauh ini dapat membantu Anda memecah tugas, tetapi API tersebut memiliki sisi negatif yang signifikan: saat Anda menyerahkan thread utama dengan menunda kode untuk dijalankan di tugas berikutnya, kode tersebut akan ditambahkan ke akhir task queue.

Jika Anda mengontrol semua kode di halaman, Anda dapat membuat penjadwal sendiri dengan kemampuan untuk memprioritaskan tugas, tetapi skrip pihak ketiga tidak akan menggunakan penjadwal Anda. Akibatnya, Anda tidak dapat memprioritaskan pekerjaan di lingkungan tersebut. Anda hanya bisa memotongnya, atau secara eksplisit menghasilkan interaksi pengguna.

Untungnya, ada API penjadwal khusus yang saat ini sedang dalam pengembangan untuk mengatasi masalah ini.

API penjadwal khusus

API penjadwal saat ini menawarkan fungsi postTask() yang, pada saat penulisan, tersedia di browser Chromium, dan di Firefox di belakang tanda. postTask() memungkinkan penjadwalan tugas yang lebih mendetail, dan merupakan salah satu cara untuk membantu browser memprioritaskan pekerjaan sehingga tugas berprioritas rendah menghasilkan thread utama. postTask() menggunakan promise, dan menerima setelan priority.

postTask() API memiliki tiga prioritas yang dapat Anda gunakan:

  • 'background' untuk tugas prioritas terendah.
  • 'user-visible' untuk tugas prioritas sedang. Ini adalah default jika tidak ada priority yang ditetapkan.
  • 'user-blocking' untuk tugas penting yang perlu dijalankan dengan prioritas tinggi.

Ambil kode berikut sebagai contoh, dengan postTask() API digunakan untuk menjalankan tiga tugas dengan prioritas tertinggi, dan dua tugas lainnya pada prioritas serendah mungkin.

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

Di sini, prioritas tugas dijadwalkan sedemikian rupa sehingga tugas yang diprioritaskan browser—seperti interaksi pengguna—dapat berjalan sesuai keinginan.

Fungsi SaveSettings seperti yang digambarkan dalam profiler performa Chrome, tetapi menggunakan postTask. postTask membagi setiap fungsisaveSettings yang berjalan, dan memprioritaskannya sehingga interaksi pengguna memiliki kesempatan untuk berjalan tanpa diblokir.
Saat saveSettings() dijalankan, fungsi tersebut menjadwalkan setiap fungsi menggunakan postTask(). Pekerjaan penting yang dilihat pengguna dijadwalkan dengan prioritas tinggi, sementara pekerjaan yang tidak diketahui pengguna dijadwalkan untuk berjalan di latar belakang. Hal ini memungkinkan interaksi pengguna dieksekusi lebih cepat karena pekerjaan dibagi dan diprioritaskan dengan tepat.

Ini adalah contoh sederhana tentang cara penggunaan postTask(). Anda dapat membuat instance objek TaskController berbeda yang dapat berbagi prioritas antar-tugas, termasuk kemampuan mengubah prioritas untuk berbagai instance TaskController sesuai kebutuhan.

Hasil bawaan dengan kelanjutan melalui scheduler.yield

Salah satu bagian yang diusulkan dari API penjadwal adalah scheduler.yield, sebuah API yang dirancang khusus untuk menghasilkan thread utama di browser yang saat ini tersedia untuk dicoba sebagai uji coba origin. Penggunaannya menyerupai fungsi yieldToMain() yang ditunjukkan sebelumnya dalam artikel ini:

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

Anda akan melihat bahwa kode di atas sebagian besar sudah dikenal, tetapi alih-alih menggunakan yieldToMain(), Anda memanggil dan await scheduler.yield().

Tiga diagram yang menggambarkan tugas tanpa memberikan hasil, memberikan hasil, serta dengan pemberian dan kelanjutan. Tanpa memberi hasil, akan ada tugas yang berjalan lama. Dengan hasil, ada lebih banyak tugas yang lebih singkat, tetapi mungkin terganggu oleh tugas lain yang tidak terkait. Dengan hasil dan kelanjutan, ada lebih banyak tugas yang lebih pendek, tetapi urutan eksekusinya tetap dipertahankan.
Visualisasi eksekusi tugas tanpa memberikan hasil, dengan hasil, serta dengan hasil dan kelanjutan. Saat scheduler.yield() digunakan, eksekusi tugas akan melanjutkan pekerjaannya, bahkan setelah endpoint.

Manfaat scheduler.yield() adalah kelanjutan, yang berarti jika Anda menyerahkan tugas di tengah serangkaian tugas, tugas terjadwal lainnya akan dilanjutkan dalam urutan yang sama setelah titik hasil. Tindakan ini akan menghindari kode dari skrip pihak ketiga menggunakan urutan eksekusi kode Anda.

Kesimpulan

Mengelola tugas memang sulit, tetapi dengan melakukannya membantu halaman Anda merespons interaksi pengguna lebih cepat. Tidak ada satu pun saran untuk mengelola dan memprioritaskan tugas. Sebaliknya, ini adalah sejumlah teknik yang berbeda. Untuk menegaskan kembali, berikut ini adalah hal-hal utama yang perlu Anda pertimbangkan saat mengelola tugas:

  • Menghasilkan thread utama untuk tugas penting yang dihadapi pengguna.
  • Gunakan isInputPending() untuk membuka thread utama saat pengguna mencoba berinteraksi dengan halaman.
  • Prioritaskan tugas dengan postTask().
  • Terakhir, lakukan sesedikit mungkin pekerjaan di fungsi Anda.

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

Terima kasih banyak kepada Philip Walton atas pemeriksaan teknisnya atas artikel ini.

Gambar utama berasal dari Unsplash, atas izin Amirali Mirhashemian.