Orkestrasi Async C#: Task WhenAll vs Task WhenAny

Foto oleh Daniil Komov via Pexels

Task WhenAll vs Task WhenAny di C#: Mengelola Operasi Asynchronous

Dalam pengembangan aplikasi modern, terutama yang melibatkan interaksi dengan banyak layanan eksternal, database, atau komputasi intensif, menjalankan operasi secara asynchronous dan paralel adalah kunci untuk menjaga responsivitas aplikasi. Namun, bagaimana cara kita mengelola banyak operasi asynchronous ini? Kapan kita perlu menunggu semua operasi selesai, dan kapan kita hanya perlu merespons yang tercepat?

C# menyediakan dua metode powerful di namespace System.Threading.Tasks untuk orkestrasi Task secara paralel: Task.WhenAll dan Task.WhenAny. Meskipun keduanya berfungsi untuk mengelola sekelompok Task, tujuan dan perilakunya sangat berbeda.

Task.WhenAll: Menunggu Hingga Semua Selesai

Bayangkan Anda perlu mengambil data dari tiga API yang berbeda untuk menampilkan satu halaman dashboard. Ketiga panggilan API ini tidak saling bergantung, dan Anda memerlukan data dari ketiganya sebelum bisa merender halaman. Dalam skenario seperti ini, Anda ingin menjalankan ketiga panggilan API tersebut secara bersamaan dan menunggu hingga semua selesai.

Task.WhenAll adalah metode yang sempurna untuk kasus ini. Ia menerima sejumlah Task (bisa Task kosong atau Task<TResult>) dan mengembalikan sebuah Task baru yang akan selesai hanya ketika semua Task yang diberikan kepadanya juga telah selesai. Jika semua Task berhasil, Task.WhenAll<TResult> akan mengembalikan sebuah array yang berisi hasil dari setiap Task<TResult>.

Contoh Penggunaan Task.WhenAll

Berikut adalah contoh sederhana di mana kita menjalankan tiga operasi asynchronous dan menunggu hingga semuanya selesai:

Contoh penggunaan Task.WhenAll untuk menunggu beberapa operasi asynchronous selesai

using System; using System.Threading.Tasks; public class WhenAllBasic { public static async Task Main(string[] args) { Console.WriteLine("Memulai operasi paralel..."); Task task1 = SimulasiOperasiAsync(2000, "Operasi 1"); Task task2 = SimulasiOperasiAsync(1000, "Operasi 2"); Task task3 = SimulasiOperasiAsync(3000, "Operasi 3"); await Task.WhenAll(task1, task2, task3); Console.WriteLine("Semua operasi telah selesai."); } static async Task SimulasiOperasiAsync(int delayMs, string name) { Console.WriteLine($"[{name}] Dimulai..."); await Task.Delay(delayMs); Console.WriteLine($"[{name}] Selesai dalam {delayMs}ms."); } }

Output dari kode di atas akan menunjukkan bahwa ketiga operasi dijalankan secara paralel dan kita menunggu hingga operasi terpanjang (SimulasiOperasiAsync(3000)) selesai sebelum melanjutkan.

Jika Anda perlu mengumpulkan hasil dari setiap tugas, Anda bisa menggunakan Task.WhenAll dengan Task<TResult>:

Menggunakan Task.WhenAll untuk mengumpulkan hasil dari Task<TResult>

using System; using System.Threading.Tasks; public class WhenAllResults { public static async Task Main(string[] args) { Console.WriteLine("Memulai operasi paralel dengan hasil..."); Task<int> task1 = SimulasiOperasiDenganHasilAsync(2000, "Data A"); Task<int> task2 = SimulasiOperasiDenganHasilAsync(1000, "Data B"); Task<int> task3 = SimulasiOperasiDenganHasilAsync(1500, "Data C"); int[] results = await Task.WhenAll(task1, task2, task3); Console.WriteLine("Semua operasi selesai. Hasil:"); foreach (int result in results) { Console.WriteLine($"Hasil: {result}"); } } static async Task<int> SimulasiOperasiDenganHasilAsync(int delayMs, string dataName) { Console.WriteLine($"[{dataName}] Mengambil data..."); await Task.Delay(delayMs); int result = dataName.Length + delayMs / 1000; Console.WriteLine($"[{dataName}] Selesai, nilai {result}."); return result; } }

Dalam contoh ini, Task.WhenAll akan mengembalikan Task<int[]> yang berisi semua hasil setelah semua operasi SimulasiOperasiDenganHasilAsync selesai.

Penanganan Exception dengan Task.WhenAll

Salah satu pertimbangan penting saat menggunakan Task.WhenAll adalah penanganan kesalahan. Jika salah satu dari Task yang diberikan kepada Task.WhenAll mengalami exception (menjadi Faulted), maka Task yang dikembalikan oleh Task.WhenAll juga akan menjadi Faulted. Uniknya, ia akan membungkus semua exception dari semua Task yang gagal ke dalam sebuah AggregateException.

Penanganan exception dengan Task.WhenAll menggunakan AggregateException

using System; using System.Threading.Tasks; public class WhenAllException { public static async Task Main(string[] args) { Console.WriteLine("Memulai operasi paralel, salah satu akan gagal..."); Task task1 = SimulasiOperasiAsync(2000, "Operasi Berhasil 1", false); Task task2 = SimulasiOperasiAsync(1000, "Operasi Gagal", true); Task task3 = SimulasiOperasiAsync(1500, "Operasi Berhasil 2", false); try { await Task.WhenAll(task1, task2, task3); Console.WriteLine("Semua operasi selesai dengan sukses (ini tidak akan tercapai)."); } catch (AggregateException ae) { Console.WriteLine("Terjadi beberapa kesalahan:"); foreach (var ex in ae.InnerExceptions) { Console.WriteLine($" - Exception: {ex.Message}"); } } } static async Task SimulasiOperasiAsync(int delayMs, string name, bool shouldFail) { Console.WriteLine($"[{name}] Dimulai..."); await Task.Delay(delayMs); if (shouldFail) { Console.WriteLine($"[{name}] GAGAL."); throw new InvalidOperationException($"Gagal melakukan {name}"); } Console.WriteLine($"[{name}] Selesai."); } }

Saat Anda menangkap AggregateException, Anda bisa mengakses semua exception individual melalui properti InnerExceptions. Ini memungkinkan Anda untuk melihat semua masalah yang terjadi, bukan hanya yang pertama.

Task.WhenAny: Siapa Cepat Dia Dapat

Berbeda dengan Task.WhenAll yang menunggu semua Task, Task.WhenAny dirancang untuk skenario di mana Anda hanya perlu merespons Task pertama yang selesai. Contohnya, Anda mungkin memiliki beberapa server cache yang menyimpan data yang sama, dan Anda hanya perlu mendapatkan respons dari server mana pun yang tercepat. Atau, Anda ingin mengimplementasikan sebuah timeout, di mana Anda menunggu operasi utama atau sebuah Task penanda timeout yang akan selesai duluan.

Task.WhenAny menerima sejumlah Task dan mengembalikan sebuah Task<Task> (atau Task<Task<TResult>> jika Task yang diberikan memiliki hasil) yang akan selesai segera setelah salah satu dari Task yang diberikan selesai. Task yang dikembalikan oleh Task.WhenAny ini adalah Task yang pertama kali selesai.

Contoh Penggunaan Task.WhenAny

Mari kita lihat skenario pemilihan respons tercepat:

Contoh penggunaan Task.WhenAny untuk merespons Task pertama yang selesai

using System; using System.Threading.Tasks; public class WhenAnyBasic { public static async Task Main(string[] args) { Console.WriteLine("Memulai kompetisi operasi..."); Task task1 = SimulasiOperasiAsync(3000, "Operasi Lambat"); Task task2 = SimulasiOperasiAsync(1000, "Operasi Cepat"); Task task3 = SimulasiOperasiAsync(2000, "Operasi Sedang"); Task completedTask = await Task.WhenAny(task1, task2, task3); Console.WriteLine($"Task pertama yang selesai memiliki Id: {completedTask.Id}"); // Untuk melihat pesan spesifik dari task yang selesai // Jika ingin mengetahui detail lebih lanjut, bisa cast ke Task<T> jika ada hasilnya // Atau gunakan Task.Wait() (jangan di production code utama) atau Task.IsCompleted, dll. Console.WriteLine("Operasi yang paling cepat telah diidentifikasi. Operasi lain mungkin masih berjalan."); } static async Task SimulasiOperasiAsync(int delayMs, string name) { Console.WriteLine($"[{name}] Dimulai..."); await Task.Delay(delayMs); Console.WriteLine($"[{name}] Selesai dalam {delayMs}ms."); } }

Dalam contoh ini, kita tidak tahu operasi mana yang akan selesai duluan, namun Task.WhenAny akan segera mengembalikan Task yang pertama kali selesai. Kita kemudian bisa memeriksa properti Id atau hasil dari completedTask.

Untuk mengumpulkan hasil dari Task<TResult> yang pertama kali selesai:

Menggunakan Task.WhenAny untuk mendapatkan hasil dari Task<TResult> yang pertama selesai

using System; using System.Threading.Tasks; public class WhenAnyResults { public static async Task Main(string[] args) { Console.WriteLine("Memulai pengambilan data tercepat..."); Task<string> serverA = GetDataFromServiceAsync(3000, "Server A"); Task<string> serverB = GetDataFromServiceAsync(1000, "Server B"); Task<string> serverC = GetDataFromServiceAsync(2000, "Server C"); Task<string> completedTask = await Task.WhenAny(serverA, serverB, serverC); string result = await completedTask; // Ambil hasil dari task yang selesai duluan Console.WriteLine($"Data tercepat diterima: {result}"); Console.WriteLine("Operasi lain mungkin masih berjalan di latar belakang."); } static async Task<string> GetDataFromServiceAsync(int delayMs, string serviceName) { Console.WriteLine($"[{serviceName}] Meminta data..."); await Task.Delay(delayMs); string data = $"Data dari {serviceName}"; Console.WriteLine($"[{serviceName}] Data siap: {data}"); return data; } }

Outputnya akan menampilkan hasil dari operasi yang selesai paling awal, misalnya 'Data dari Server B' jika server B lebih cepat.

Penanganan Exception dan Implikasi Tugas Lain dengan Task.WhenAny

Ketika Task.WhenAny selesai, Task yang dikembalikannya mungkin saja berstatus RanToCompletion (berhasil), Canceled, atau Faulted (mengalami exception). Anda perlu memeriksa status ini:

Penanganan exception dengan Task.WhenAny

using System; using System.Threading.Tasks; public class WhenAnyException { public static async Task Main(string[] args) { Console.WriteLine("Memulai kompetisi, salah satu bisa gagal..."); Task<string> task1 = GetDataOrFailAsync(3000, "Service Lambat", false); Task<string> task2 = GetDataOrFailAsync(1000, "Service Cepat Gagal", true); Task<string> task3 = GetDataOrFailAsync(2000, "Service Sedang", false); Task<string> completedTask = await Task.WhenAny(task1, task2, task3); if (completedTask.IsFaulted) { Console.WriteLine("Task pertama yang selesai GAGAL!"); foreach (var ex in completedTask.Exception.InnerExceptions) { Console.WriteLine($" - Exception: {ex.Message}"); } } else if (completedTask.IsCanceled) { Console.WriteLine("Task pertama yang selesai DIBATALKAN."); } else { string result = await completedTask; Console.WriteLine($"Task pertama yang selesai berhasil: {result}"); } Console.WriteLine("Operasi lain mungkin masih berjalan."); } static async Task<string> GetDataOrFailAsync(int delayMs, string serviceName, bool shouldFail) { Console.WriteLine($"[{serviceName}] Memulai..."); await Task.Delay(delayMs); if (shouldFail) { Console.WriteLine($"[{serviceName}] GAGAL."); throw new InvalidOperationException($"Gagal mengambil data dari {serviceName}"); } string data = $"Data dari {serviceName}"; Console.WriteLine($"[{serviceName}] Selesai: {data}"); return data; } }

Penting untuk diingat bahwa Task.WhenAny hanya mengembalikan satu Task yang selesai. Task lainnya yang belum selesai akan terus berjalan di latar belakang kecuali Anda secara eksplisit membatalkannya. Ini adalah kesalahan umum yang dapat menyebabkan penggunaan sumber daya yang tidak perlu atau perilaku yang tidak terduga.

Untuk mengatasi ini, Anda bisa mengintegrasikan CancellationToken (seperti yang sudah kita bahas di artikel sebelumnya tentang CancellationToken di C#: Membatalkan Proses Async dengan Aman) untuk membatalkan Task yang tidak lagi dibutuhkan:

Menggunakan CancellationToken untuk membatalkan Task yang tersisa setelah WhenAny

using System; using System.Threading; using System.Threading.Tasks; public class WhenAnyWithCancellation { public static async Task Main(string[] args) { Console.WriteLine("Memulai kompetisi dengan pembatalan..."); using var cts = new CancellationTokenSource(); Task<string> task1 = GetDataWithCancellationAsync(3000, "Service Lambat", cts.Token); Task<string> task2 = GetDataWithCancellationAsync(1000, "Service Cepat", cts.Token); Task<string> task3 = GetDataWithCancellationAsync(2000, "Service Sedang", cts.Token); var tasks = new[] { task1, task2, task3 }; Task<string> completedTask = await Task.WhenAny(tasks); // Batalkan task lainnya setelah task tercepat selesai cts.Cancel(); if (completedTask.IsFaulted) { Console.WriteLine("Task pertama yang selesai GAGAL."); // Penanganan exception... } else if (completedTask.IsCanceled) { Console.WriteLine("Task pertama yang selesai DIBATALKAN (ini seharusnya tidak terjadi jika berhasil)."); } else { string result = await completedTask; Console.WriteLine($"Task tercepat berhasil: {result}"); } // Tunggu sebentar untuk melihat efek pembatalan pada task lain await Task.Delay(500); Console.WriteLine("Status task lainnya setelah pembatalan:"); foreach (var task in tasks) { if (task != completedTask) { Console.WriteLine($" Task {task.Id} - Status: {task.Status}"); } } } static async Task<string> GetDataWithCancellationAsync(int delayMs, string serviceName, CancellationToken cancellationToken) { Console.WriteLine($"[{serviceName}] Memulai..."); try { await Task.Delay(delayMs, cancellationToken); string data = $"Data dari {serviceName}"; Console.WriteLine($"[{serviceName}] Selesai: {data}"); return data; } catch (OperationCanceledException) { Console.WriteLine($"[{serviceName}] Dibatalkan."); return $"[{serviceName}] Dibatalkan."; // Atau throw, tergantung kebutuhan } } }

Dalam skenario ini, setelah Task tercepat selesai dan hasilnya didapat, CancellationTokenSource akan membatalkan Task lainnya yang masih berjalan, memastikan tidak ada sumber daya yang terbuang.

Membandingkan Task.WhenAll dan Task.WhenAny: Kapan Menggunakan yang Mana?

Pemilihan antara Task.WhenAll dan Task.WhenAny sangat bergantung pada kebutuhan bisnis dan teknis Anda:

FiturTask.WhenAllTask.WhenAny
Tujuan UtamaMenunggu semua Task selesai dan mengumpulkan semua hasil.Merespons Task pertama yang selesai.
Return TypeTask (tanpa hasil) atau Task<TResult[]> (dengan hasil array).Task<Task> (tanpa hasil) atau Task<Task<TResult>> (dengan hasil Task tunggal).
Penanganan ExceptionMengembalikan AggregateException yang berisi semua exception dari Task yang gagal.Mengembalikan Task pertama yang selesai, yang mungkin berstatus Faulted. Anda harus memeriksa status Task yang dikembalikan.
Implikasi Task LainSemua Task akan dijalankan hingga selesai (atau gagal).Task lain akan terus berjalan di latar belakang kecuali dibatalkan secara eksplisit.
Skenario IdealParalelisasi operasi yang semuanya penting, agregasi data dari banyak sumber, memastikan semua pre-kondisi terpenuhi.Respon tercepat dari beberapa sumber, implementasi timeout, fallback mechanism, kompetisi antar operasi.

Kesalahan Umum dan Pertimbangan Penting

  • Mengabaikan Exception: Baik dengan WhenAll atau WhenAny, penting untuk selalu menangani potensi exception. Untuk WhenAll, gunakan try-catch pada await Task.WhenAll(...). Untuk WhenAny, pastikan untuk memeriksa properti IsFaulted pada Task yang dikembalikan.
  • Resource Leak dengan WhenAny: Seperti yang sudah disinggung, Task lain yang tidak dipilih oleh WhenAny akan terus berjalan. Selalu pertimbangkan untuk membatalkan Task tersebut jika mereka tidak lagi dibutuhkan, terutama jika melibatkan operasi I/O atau komputasi yang mahal. Gunakan CancellationTokenSource seperti contoh di atas.
  • Tidak Menggunakan await: Pastikan Anda menggunakan await pada hasil Task.WhenAll atau Task.WhenAny untuk memastikan eksekusi bersifat asynchronous dan exception tertangani dengan baik.
  • Menggunakan Synchronous Blocking: Hindari memanggil .Wait() atau .Result pada Task.WhenAll atau Task.WhenAny dari thread UI atau context yang bisa deadlocked. Selalu gunakan await.

Ringkasan & Kapan Harus Memilih

Memilih antara Task.WhenAll dan Task.WhenAny adalah tentang memahami kebutuhan spesifik aplikasi Anda:

  • Pilih Task.WhenAll ketika Anda perlu memastikan semua operasi asynchronous selesai, dan Anda tertarik pada hasil gabungan atau ingin mengetahui jika ada kegagalan dari salah satu operasi. Ini ideal untuk parallel processing yang memerlukan agregasi hasil.
  • Pilih Task.WhenAny ketika Anda hanya membutuhkan satu hasil tercepat dari sekelompok operasi, atau Anda ingin mengimplementasikan mekanisme timeout atau fallback. Ingatlah untuk selalu mempertimbangkan manajemen Task lainnya yang belum selesai.

Dengan pemahaman yang tepat tentang kedua metode ini, Anda dapat merancang aplikasi C# yang lebih responsif, efisien, dan tangguh dalam menghadapi berbagai skenario operasi asynchronous.

Seri Belajar C# Fundamental:

← Sebelumnya: CancellationToken di C#: Membatalkan Proses Async dengan Aman

→ Selanjutnya: IDisposable dan using di C#