Section 13/211 menit

13. Swift Concurrency: TaskGroup — Bulk Avatar Prefetch

13. Swift Concurrency: TaskGroup — Bulk Avatar Prefetch

Problem

Setelah user list tampil, kita ingin pre-load semua avatar agar cell langsung menampilkan gambar tanpa delay. Jumlah user tidak diketahui saat compile-time — bisa 5, bisa 100. async let tidak bisa menangani jumlah dinamis.

Konsep

withTaskGroup / withThrowingTaskGroup menjalankan sejumlah task secara concurrent dengan jumlah yang ditentukan saat runtime. Task ditambahkan dengan group.addTask {} dan hasilnya dikumpulkan dengan for await. Ada juga concurrency limiting untuk mencegah terlalu banyak request sekaligus.

Implementasi AvatarCache

swift
// Cache untuk menyimpan data avatar agar tidak di-download ulang.
// Dijadikan actor untuk thread-safe read/write dari banyak concurrent task.
actor AvatarCache {
    static let shared = AvatarCache()
    private init() {}

    private var cache: [String: Data] = [:]

    func data(for userID: String) -> Data? {
        cache[userID]
    }

    func store(_ data: Data, for userID: String) {
        cache[userID] = data
    }

    func clear() {
        cache.removeAll()
    }
}

Implementasi TaskGroup di Worker

swift
actor UserWorker {
    // ...existing methods...

    // Prefetch avatar untuk semua user secara concurrent tapi terbatas.
    // 'maxConcurrent' mencegah kita membuka terlalu banyak koneksi sekaligus
    // yang bisa menyebabkan throttling dari server atau exhausting thread pool.
    func prefetchAvatars(
        for users: [User],
        maxConcurrent: Int = 5
    ) async -> [String: Data] {

        // Kita pakai withTaskGroup (bukan throwing) karena avatar adalah
        // operasi best-effort — jika satu gagal, lanjutkan yang lain.
        await withTaskGroup(of: (String, Data?).self) { group in
            var results: [String: Data] = [:]

            // Chunking manual untuk membatasi concurrency.
            // Tanpa ini, jika ada 100 user, kita membuka 100 koneksi sekaligus.
            let chunks = users.chunked(into: maxConcurrent)

            for chunk in chunks {
                // Mulai fetch untuk satu chunk — max 'maxConcurrent' task bersamaan.
                for user in chunk {
                    guard let url = user.avatarURL else { continue }
                    group.addTask {
                        // Cek cache dulu sebelum download.
                        if let cached = await AvatarCache.shared.data(for: user.id) {
                            return (user.id, cached)
                        }
                        let data = try? await URLSession.shared.data(from: url).0
                        return (user.id, data)
                    }
                }

                // Kumpulkan hasil chunk ini sebelum memulai chunk berikutnya.
                // Ini adalah cara manual membatasi concurrency — tunggu satu batch
                // selesai sebelum mulai batch berikutnya.
                for await (id, data) in group {
                    if let data {
                        results[id] = data
                        // Simpan ke cache untuk request berikutnya.
                        await AvatarCache.shared.store(data, for: id)
                    }
                }
            }

            return results
        }
    }
}

// Helper extension untuk chunking array
private extension Array {
    func chunked(into size: Int) -> [[Element]] {
        stride(from: 0, to: count, by: size).map {
            Array(self[$0..<Swift.min($0 + size, count)])
        }
    }
}

Alternatif: TaskGroup dengan Partial Results

Scenario: tampilkan user segera, lalu update cell satu per satu ketika avatar-nya siap.

swift
actor UserListInteractor: UserListBusinessLogic, UserListDataStore {
    private let avatarCache = AvatarCache.shared

    // Trigger prefetch di background — tidak blocking, tidak await.
    // Interactor mendelegasikan ke worker dan hasilnya di-update ke presenter
    // satu per satu ketika tiap avatar selesai diload.
    func prefetchAvatars(for users: [User]) {
        // Task.detached karena kita tidak ingin propagate cancellation dari caller.
        // Prefetch adalah operasi opsional — jika view hilang, tetap simpan ke cache
        // agar berguna jika user buka layar ini lagi.
        Task.detached(priority: .utility) { [weak self] in
            guard let self else { return }

            await withTaskGroup(of: (String, Data?).self) { group in
                for user in users {
                    guard let url = user.avatarURL else { continue }
                    group.addTask {
                        let data = try? await URLSession.shared.data(from: url).0
                        return (user.id, data)
                    }
                }

                // for await di sini memberikan partial results —
                // setiap avatar yang selesai langsung dikirim ke presenter.
                for await (id, data) in group {
                    guard let data else { continue }
                    await AvatarCache.shared.store(data, for: id)
                    // Notify presenter bahwa avatar untuk user ini sudah siap.
                    // Presenter bisa update cell individual tanpa reload seluruh table.
                    await self.presenter?.presentAvatarReady(userID: id, data: data)
                }
            }
        }
    }
}

Kapan Pakai TaskGroup:

  • Jumlah task dinamis (tidak diketahui compile-time)
  • Ingin partial results (tampilkan hasil begitu tersedia, tidak perlu semua selesai)
  • Operasi bulk yang perlu dibatasi concurrency-nya

Kapan Tidak Pakai TaskGroup:

  • Jumlah task kecil dan tetap (2-5) → async let lebih ringkas
  • Semua task harus sukses agar hasilnya berguna → async let dengan throwing semantics lebih jelas