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:
- Lifetime guarantee: Child task tidak bisa hidup lebih lama dari parent
- Automatic cancellation: Jika parent di-cancel, semua child ikut di-cancel
- Error propagation: Error dari child bisa propagate ke parent
- 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
withTaskGrouptanpa 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
}
}