Section 2/202 menit

2. Structured Concurrency — async let & TaskGroup

2. Structured Concurrency — async let & TaskGroup

Teori: Mengapa "Structured"?

Unstructured concurrency (GCD, Thread) memiliki problem: task yang dibuat bisa hidup lebih lama dari parent-nya, menyebabkan:

  • Resource leak (task berjalan meski tidak ada yang butuh hasilnya)
  • Sulit di-cancel
  • Error tidak bisa propagate ke caller

Structured concurrency meminjam konsep dari structured programming (if/for/while) — setiap task punya lifetime yang dibatasi oleh scope-nya:

swift
Tanpa struktur:                Dengan struktur:
──────────────                 ──────────────────
Parent selesai                 Parent tidak selesai
    │                          sampai SEMUA child selesai
    ↓                              │
Task anak masih berjalan!          ├── Task A
(memory leak, use-after-free)      ├── Task B
                                   └── Task C
                                   ↓
                                   Parent selesai
                                   (semua child sudah selesai)

Properti kunci Structured Concurrency:

  1. Lifetime guarantee: Child task tidak bisa hidup lebih lama dari parent
  2. Automatic cancellation: Jika parent di-cancel, semua child ikut di-cancel
  3. Error propagation: Error dari child bisa propagate ke parent
  4. Resource safety: Tidak ada orphaned tasks

async let — Concurrent Bindings

async let memulai task secara concurrent tapi di dalam scope yang sama:

swift
// Tanpa async let: sequential — total waktu = A + B + C
func loadDashboard() async throws -> Dashboard {
    let user = try await fetchUser()      // tunggu selesai
    let news = try await fetchNews()      // baru mulai
    let weather = try await fetchWeather() // baru mulai
    return Dashboard(user: user, news: news, weather: weather)
    // Total: ~3 detik jika masing-masing 1 detik
}

// Dengan async let: concurrent — total waktu = max(A, B, C)
func loadDashboard() async throws -> Dashboard {
    async let user = fetchUser()          // mulai concurrent
    async let news = fetchNews()          // mulai concurrent
    async let weather = fetchWeather()    // mulai concurrent
    
    // Semua mulai bersamaan, tunggu semua selesai di sini
    return try await Dashboard(
        user: user,
        news: news,
        weather: weather
    )
    // Total: ~1 detik (berjalan paralel)
}

Cancellation otomatis dengan async let:

swift
func loadWithTimeout() async throws -> Data {
    async let result = fetchLargeFile()         // mulai
    async let _ = Task.sleep(nanoseconds: 5_000_000_000)  // 5 detik timeout
    
    // Jika fungsi ini di-cancel dari luar:
    // Kedua task di atas otomatis di-cancel
    return try await result
}

TaskGroup — Concurrency Dinamis

Ketika jumlah task tidak diketahui saat compile time:

swift
// async let: jumlah task FIXED saat compile time
// TaskGroup: jumlah task DINAMIS saat runtime

func downloadAllImages(urls: [URL]) async throws -> [UIImage] {
    try await withThrowingTaskGroup(of: (Int, UIImage).self) { group in
        // Tambah task untuk setiap URL
        for (index, url) in urls.enumerated() {
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: url)
                guard let image = UIImage(data: data) else {
                    throw ImageError.invalidData
                }
                return (index, image)
            }
        }

        // Kumpulkan hasil — urutan tidak dijamin (selesai duluan = datang duluan)
        var results = [(Int, UIImage)]()
        for try await result in group {
            results.append(result)
        }

        // Sort berdasarkan index untuk menjaga urutan asli
        return results.sorted { $0.0 < $1.0 }.map { $0.1 }
    }
}

enum ImageError: Error { case invalidData }

Membatasi Concurrency

Tanpa batasan, TaskGroup bisa membuat terlalu banyak task concurrent — membebani server atau memori:

swift
func downloadWithConcurrencyLimit(
    urls: [URL],
    maxConcurrent: Int = 5
) async throws -> [Data] {
    var results = [Data?](repeating: nil, count: urls.count)

    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        var activeCount = 0
        var nextIndex = 0

        // Seed awal: isi hingga batas concurrency
        while nextIndex < urls.count && activeCount < maxConcurrent {
            let index = nextIndex
            group.addTask {
                let (data, _) = try await URLSession.shared.data(from: urls[index])
                return (index, data)
            }
            activeCount += 1
            nextIndex += 1
        }

        // Proses: setiap kali satu selesai, tambah satu baru
        for try await (index, data) in group {
            results[index] = data
            activeCount -= 1

            if nextIndex < urls.count {
                let idx = nextIndex
                group.addTask {
                    let (data, _) = try await URLSession.shared.data(from: urls[idx])
                    return (idx, data)
                }
                activeCount += 1
                nextIndex += 1
            }
        }
    }

    return results.compactMap { $0 }
}

Kapan Menggunakan

async let — gunakan ketika:

  • Jumlah operasi concurrent sudah diketahui saat compile time
  • Operasi saling independen (tidak butuh hasil satu untuk memulai lain)
  • 2–10 operasi concurrent (lebih dari itu, pertimbangkan TaskGroup)

TaskGroup — gunakan ketika:

  • Jumlah operasi bergantung pada data runtime (array, collection)
  • Perlu limit concurrency
  • Ingin collect results secara incremental
  • Error dari satu task tidak boleh cancel semua (gunakan withTaskGroup tanpa throws)

Jangan gunakan ketika:

  • Operasi sequential (gunakan biasa, tidak perlu group)
  • Hasil masing-masing task tidak diperlukan (gunakan Task {} biasa)
  • Kamu hanya ingin "fire and forget" — gunakan unstructured Task {}

Real Use Case: Batch API Processing

swift
actor BatchProcessor {
    private let apiClient: AuthenticatedAPIClient

    init(client: AuthenticatedAPIClient) { self.apiClient = client }

    // Proses banyak item dengan concurrency terkontrol
    func processItems<T: Sendable & Decodable>(
        ids: [String],
        endpoint: String,
        maxConcurrent: Int = 10
    ) async throws -> [String: T] {
        var results = [String: T]()
        var errors = [String: Error]()

        await withTaskGroup(of: (String, Result<T, Error>).self) { group in
            var running = 0
            var iterator = ids.makeIterator()

            // Seed
            while running < maxConcurrent, let id = iterator.next() {
                let capturedID = id
                group.addTask {
                    do {
                        let value: T = try await self.apiClient.authenticatedRequest(
                            endpoint: "\(endpoint)/\(capturedID)",
                            type: T.self
                        )
                        return (capturedID, .success(value))
                    } catch {
                        return (capturedID, .failure(error))
                    }
                }
                running += 1
            }

            for await (id, result) in group {
                running -= 1
                switch result {
                case .success(let value): results[id] = value
                case .failure(let error): errors[id] = error
                }

                if let nextID = iterator.next() {
                    let capturedID = nextID
                    group.addTask {
                        do {
                            let value: T = try await self.apiClient.authenticatedRequest(
                                endpoint: "\(endpoint)/\(capturedID)",
                                type: T.self
                            )
                            return (capturedID, .success(value))
                        } catch {
                            return (capturedID, .failure(error))
                        }
                    }
                    running += 1
                }
            }
        }

        if !errors.isEmpty {
            print("⚠️ \(errors.count) item gagal diproses")
        }
        return results
    }
}