Pola konkurensi

Promise.all dan Promise.allSettled untuk fan-out I/O.

Daftar isi

Server Node sering harus menjalankan banyak operasi I/O paralel, misalnya mengambil beberapa sumber data sekaligus atau menulis banyak berkas dalam satu permintaan. Menjalankan operasi tersebut berurutan dengan await satu per satu menggandakan waktu total ke penjumlahan durasi, padahal I/O tidak saling bergantung. Kombinator promise seperti Promise.all dan Promise.allSettled menjadi alat untuk fan-out I/O dan menunggu hasilnya kembali dengan kebijakan penanganan kegagalan yang berbeda-beda. Topik ini menjelaskan kapan masing-masing kombinator dipakai dan bagaimana perilakunya terhadap promise yang rejected.

Promise.all untuk fan-out yang berhenti pada kegagalan pertama#

Promise.all menerima deret promise dan mengembalikan promise baru yang fulfilled setelah seluruh input fulfilled, dengan array hasil yang urutannya sama dengan urutan input. Apabila satu promise saja rejected, hasil keseluruhan langsung rejected dengan alasan yang sama, dan promise lain yang masih berjalan tidak dibatalkan meskipun hasilnya diabaikan. Berkas promise-all.js menjalankan dua skenario: semua sukses dan ada yang reject.

js promise-all.js
function ambil(nomor, delay, gagal = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (gagal) reject(new Error(`gagal #${nomor}`));
      else resolve(`data #${nomor}`);
    }, delay);
  });
}

async function sukses() {
  console.log("Promise.all (semua sukses):");
  try {
    const hasil = await Promise.all([
      ambil(1, 80),
      ambil(2, 40),
      ambil(3, 120),
    ]);
    console.log("  hasil:", hasil);
  } catch (err) {
    console.log("  tertangkap:", err.message);
  }
}

async function gagal() {
  console.log("Promise.all (ada yang reject):");
  try {
    const hasil = await Promise.all([
      ambil(1, 80),
      ambil(2, 40, true),
      ambil(3, 120),
    ]);
    console.log("  hasil:", hasil);
  } catch (err) {
    console.log("  tertangkap:", err.message);
  }
}

(async () => {
  await sukses();
  await gagal();
})();
text Keluaran
Promise.all (semua sukses):
  hasil: [ 'data #1', 'data #2', 'data #3' ]
Promise.all (ada yang reject):
  tertangkap: gagal #2

Pada kasus sukses, total waktu tunggu menyamai durasi terlama, yaitu 120 milidetik untuk ambil(3, 120), bukan penjumlahan 80 + 40 + 120 = 240 milidetik. Pada kasus gagal, reject dari ambil(2, 40) selesai lebih dahulu pada 40 milidetik dan langsung melempar pengecualian, sehingga hasil ambil(1, 80) maupun ambil(3, 120) yang masih berjalan tidak pernah dikumpulkan.

Perilaku short-circuit ini cocok ketika seluruh hasil bersifat wajib. Contoh nyata adalah pengambilan pengguna dan profil pengguna dalam satu permintaan HTTP: bila salah satu gagal, respons tidak dapat dirakit sehingga kegagalan harus diteruskan ke pemanggil.

Promise.allSettled untuk hasil parsial#

Promise.allSettled tidak pernah rejected. Kombinator ini menunggu seluruh promise settled, baik fulfilled maupun rejected, lalu mengembalikan array objek { status, value } atau { status, reason } sesuai keadaan tiap promise. Urutan hasil kembali sama dengan urutan input. Berkas promise-allsettled.js menjalankan tiga promise yang salah satunya reject.

js promise-allsettled.js
function ambil(nomor, delay, gagal = false) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (gagal) reject(new Error(`gagal #${nomor}`));
      else resolve(`data #${nomor}`);
    }, delay);
  });
}

async function jalankan() {
  const hasil = await Promise.allSettled([
    ambil(1, 80),
    ambil(2, 40, true),
    ambil(3, 120),
  ]);
  console.log("Promise.allSettled:");
  for (const r of hasil) {
    if (r.status === "fulfilled") console.log("  fulfilled:", r.value);
    else console.log("  rejected:", r.reason.message);
  }
}

jalankan();
text Keluaran
Promise.allSettled:
  fulfilled: data #1
  rejected: gagal #2
  fulfilled: data #3

Total waktu tunggu tetap menyamai durasi terlama yaitu 120 milidetik, tetapi kegagalan ambil(2, 40) tidak membatalkan pengumpulan hasil lain. Pola ini cocok untuk operasi best-effort seperti pengambilan banyak sumber data opsional, pembersihan beberapa sumber daya paralel, atau pemanggilan banyak layanan eksternal yang hasilnya akan dirangkum sebagian.

Perbandingan pola fan-out#

Pemilihan kombinator bergantung pada kebijakan penanganan kegagalan dan kebutuhan hasil parsial.

KombinatorHasil saat semua suksesHasil saat ada yang gagalCocok untuk
Promise.allArray nilai berurutanReject pertama, abaikan sisanyaOperasi wajib yang tidak boleh parsial
Promise.allSettledArray objek {status, value/reason}Semua hasil tetap dikumpulkanOperasi best-effort dengan hasil parsial
Promise.raceNilai promise pertama yang settledReject pertama yang settledTimeout atau respons tercepat
Promise.anyNilai fulfilled pertamaAggregateError bila semua rejectedRedundansi dengan minimal satu sukses

Pola timeout dengan Promise.race#

Promise.race mengembalikan promise yang settled mengikuti promise pertama yang selesai, baik fulfilled maupun rejected. Sifat ini menjadikannya kandidat untuk membatasi lama operasi I/O dengan cara mengombinasikan operasi utama dan timer dalam satu race. Bila timer lebih dahulu selesai, operasi utama dianggap gagal karena lewat batas waktu.

Pola klasik adalah membuat promise timer terpisah dan menggabungkannya dengan promise operasi: Promise.race([operasi, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 500))]). Pendekatan ini memang memberi sinyal timeout ke pemanggil, tetapi operasi yang sedang berjalan tidak dibatalkan, sehingga beban kerja tetap berlanjut di latar dan sumber daya terbuang. Sejak Node 15, AbortController dan AbortSignal menjadi mekanisme pembatalan yang dipahami modul inti seperti fetch, fs.readFile, dan setTimeout versi promise, sehingga pembatalan sungguhan dapat diteruskan ke operasi yang sedang berjalan.

Kesalahan umum#

Menjalankan banyak await secara berurutan pada operasi yang seharusnya paralel. Pola const a = await ambil(1, 80); const b = await ambil(2, 40); menghasilkan total waktu 120 milidetik, padahal Promise.all cukup 80 milidetik. Selalu periksa apakah dependensi data benar-benar berurutan sebelum memakai await beruntun.

Mengira Promise.all membatalkan promise lain saat reject. Promise yang sudah berjalan tetap berjalan sampai selesai, hanya hasilnya yang tidak diharapkan. Bila efek samping seperti penulisan berkas atau panggilan API tetap berlangsung, pertimbangkan pembungkus dengan sinyal AbortController untuk membatalkan operasi yang tidak lagi diperlukan.

Ringkasan#

  • Promise.all mengumpulkan hasil paralel dan berhenti pada kegagalan pertama, cocok untuk operasi wajib.
  • Promise.allSettled menunggu seluruh promise selesai dan mengembalikan status tiap-tiap promise, cocok untuk hasil parsial.
  • Waktu total kedua kombinator menyamai durasi terlama, bukan penjumlahan durasi.
  • Promise.race dan Promise.any melengkapi pola untuk kasus timeout dan redundansi.

Referensi#