This episode covers concurrency in Swift: Grand Central Dispatch with DispatchQueue, structured concurrency with async/await, actors and tasks with task groups, and how to prevent race conditions and guarantee thread safety through data isolation in Swift 6.

Responsive applications handle many jobs at once: fetching data, processing input, and updating the UI without blocking each other. Episode 9 covers concurrency and async programming in Swift — from Grand Central Dispatch, decades old, to modern structured concurrency with async/await, actors, and task groups.
Concurrency is a topic where mistakes don't always show up when you test, but explode in production. Understanding the execution model and how to isolate data will save you from the hardest-to-trace bugs: race conditions.
Grand Central Dispatch (GCD) is the concurrency foundation on Apple platforms. Its main work is organized through DispatchQueue — the queue on which blocks of code are executed:
import Dispatch
let queue = DispatchQueue.global(qos: .userInitiated)
queue.async {
let hasil = (1...1_000_000).reduce(0, +)
DispatchQueue.main.async {
print("Hasil: \(hasil)")
}
}queue.async { ... } schedules a block on a global queue, and DispatchQueue.main.async moves the result back to the main thread to update the UI. The main thread must be the only place UI is mutated — changing the UI from another thread is a classic bug.
GCD distinguishes two kinds of queues:
let serial = DispatchQueue(label: "com.example.worker")
serial.sync {
print("Tugas 1")
}
serial.sync {
print("Tugas 2")
}let serial = DispatchQueue(label: "com.example.worker") creates a serial queue that runs tasks in order. GCD is still widely used for callback-based work, but modern Swift points you toward safer structured concurrency.
Episode 8 introduced async/await for sequential code. To run several operations in parallel, Swift provides async let:
func ambilData() async throws {
async let pengguna = ambilPengguna(login: "arman")
async let repos = ambilRepositori(login: "arman")
let (u, r) = try await (pengguna, repos)
print(u.name, r.count)
}async let pengguna = ambilPengguna(login: "arman") starts the operation without waiting, and try await (pengguna, repos) waits for both to finish. The two requests run simultaneously — total time approaches the slowest request, not the sum of both.
When the number of tasks isn't known at compile time — for example downloading many files — use TaskGroup:
func unduhSemua(urls: [URL]) async -> [Data] {
await withTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
let (data, _) = try! await URLSession.shared.data(from: url)
return data
}
}
var hasil = [Data]()
for await data in group {
hasil.append(data)
}
return hasil
}
}withTaskGroup(of: Data.self) opens a scope where each addTask runs a work item, and for await data in group collects results as they become available. Task groups are the safest way to fan out work with a dynamic number of tasks.
An actor is a type that isolates mutable state: only one task may access its stored properties at a time. The compiler enforces this — access from outside an actor must await:
actor Penghitung {
private var nilai = 0
func increment() -> Int {
nilai += 1
return nilai
}
}
let counter = Penghitung()
Task {
let hasil = await counter.increment()
print(hasil)
}actor Penghitung guarantees no race condition when two tasks call increment at the same time — the compiler inserts synchronization automatically. A call from outside, await counter.increment(), waits its turn for access.
A Task represents a unit of async work. It can be created anywhere and runs on an executor managed by Swift:
Task {
do {
let pengguna = try await ambilPengguna(login: "arman")
print(pengguna.name)
} catch {
print("Gagal: \(error)")
}
}Task { ... } starts an independent unit of asynchronous work. In Swift 6, data isolation is enforced by the compiler: data shared between tasks must be explicitly safe — through actors, Sendable, or immutable values. Race condition bugs that were once only caught at runtime are now rejected at compile time.
Types allowed to move between tasks must conform to the Sendable protocol:
struct Pesan: Sendable {
let id: Int
let isi: String
}struct Pesan: Sendable declares that Pesan values are safe to send across task boundaries because all their properties are immutable. In Swift 6, the compiler checks this — capturing a mutable variable from outside inside a Task becomes a compile error.
Info
In Swift 5, concurrency mode issues warnings, while Swift 6 enforces them as errors. Enable Swift 6 mode gradually for your project and fix every warning before calling your codebase thread-safe.
Key takeaways:
async let runs several operations in parallel and waits for all of them.In the next episode, episode 10, we'll cover package management and modularization — the foundations of Swift Package Manager, creating packages with dependencies and libraries, multi-target structure with test targets, and integrating SPM into Xcode projects. Your code will become modular and reusable!