Bounded Parallelism in Swift Concurrency: Mastering TaskGroup with Async/Await
When you need to run several async operations in parallel but keep the number of concurrent tasks under control, Swift’s TaskGroup is the tool for the job. This guide walks through why and how to use it, with a concrete example and key pitfalls.
28 Aug 2025, 11:26 UTC

When Parallelism Needs a Ceiling
Many apps hit a performance bottleneck when they fire off dozens of network requests at once. A single‐threaded HTTP client can choke, the device’s battery drains faster, and the OS may throttle the process. The problem is simple: you want parallel work, but you need a hard limit on how many tasks run simultaneously.
Swift 5.5’s structured concurrency gives you TaskGroup, a lightweight construct that lets you launch a group of child tasks, collect their results, and respect cancellation. However, TaskGroup itself doesn’t impose a limit on concurrency. That’s where a semaphore or a custom AsyncSequence comes in.
Why TaskGroup Is the Right Tool
Unlike DispatchGroup or manual asyncTask loops, TaskGroup is first‐class in the language:
- All child tasks share the same parent’s lifetime; if the parent is cancelled, every child is automatically cancelled.
- The compiler enforces that every task in a group returns the same type, preventing accidental type mismatches.
- Result aggregation is built‐in: you can await the group’s completion and get an array of results.
Because of these guarantees, TaskGroup is a safer foundation for bounded parallelism than ad‐hoc thread pools.
Building a Bounded Parallel Fetcher
Below is a minimal, self‐contained example that demonstrates how to run up to maxConcurrent network fetches in parallel, collect the data, and honour cancellation. The code runs on macOS/iOS 15+ (or any platform that ships with Swift 5.5’s concurrency runtime).
import Foundation
// A simple semaphore that limits the number of concurrent async operations.
final class AsyncSemaphore {
private let value: Int
private var waiting: [CheckedContinuation<Void, Never>] = []
private var count: Int
private let lock = NSLock()
init(value: Int) {
self.value = value
self.count = value
}
func wait() async {
await withCheckedContinuation { continuation in
lock.lock()
if count > 0 {
count -= 1
lock.unlock()
continuation.resume()
} else {
waiting.append(continuation)
lock.unlock()
}
}
}
func signal() {
lock.lock()
if let next = waiting.first {
waiting.removeFirst()
lock.unlock()
next.resume()
} else {
count += 1
lock.unlock()
}
}
}
func fetchData(from url: URL, semaphore: AsyncSemaphore) async throws -> Data {
// Acquire a slot before starting the network request.
try Task.checkCancellation()
await semaphore.wait()
defer { semaphore.signal() }
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
func fetchAll(urls: [URL], maxConcurrent: Int) async throws -> [Data] {
let semaphore = AsyncSemaphore(value: maxConcurrent)
return try await withThrowingTaskGroup(of: Data.self) { group in
for url in urls {
group.addTask {
try await fetchData(from: url, semaphore: semaphore)
}
}
var results: [Data] = []
for try await result in group {
results.append(result)
}
return results
}
}
// Example usage
let urls = [URL(string: "https://example.com/1")!, URL(string: "https://example.com/2")!]
Task {
do {
let dataArray = try await fetchAll(urls: urls, maxConcurrent: 2)
print("Fetched \(dataArray.count) items")
} catch {
print("Error: \(error)")
}
}
Key points in the snippet:
- We wrap the network call in
fetchDatathat first checks for cancellation and then acquires a semaphore slot. - The
AsyncSemaphoreis a lightweight, thread‐safe counter that blocks the caller until a slot is available. - Because
withThrowingTaskGroupis awaited, the parent task will not finish until all children have either succeeded or thrown. - If the parent
Taskis cancelled,Task.checkCancellation()will throwCancellationError, causing the child to exit early andsignal()to release the semaphore slot.
Trade‐Offs and Limitations
While the pattern above works, it has a few caveats you should weigh:
| Aspect | Consideration |
|---|---|
| Data Races | All child tasks write to the same array. The compiler warns if the array is not isolated; you must capture it inside the group or use an actor. |
| Granularity | Semaphore counts are static. If you need dynamic throttling (e.g., based on network quality), you’ll need a more sophisticated AsyncSequence. |
| Toolchain Strictness | Swift 5.9+ enables stricter Sendable checks. Code that compiled under 5.5 may now emit warnings about non‐Sendable state captured by child tasks. |
| Cancellation Propagation | Only the parent’s cancellation is automatically forwarded. If you cancel a child manually, the parent will still wait for all children unless you break the loop. |
Because TaskGroup does not limit parallelism by itself, you must supply a semaphore or similar mechanism. Failure to do so can lead to the same symptoms you’re trying to avoid.
How to Verify Your Implementation
- Build with the correct toolchain: Run
swift --versionto confirm you’re on Swift 5.5 or later. If you’re on a newer release, enable strict concurrency checks withswift build -Xfrontend -enable-experimental-concurrencyto surface Sendable issues. - Run the example: Use
swift runorswift build -c release && ./.build/release/YourTargetto execute the code. Observe that at mostmaxConcurrentrequests are in flight. - Test cancellation: Wrap the call to
fetchAllin a parentTaskand cancel it after a short delay. Verify that the child tasks throwCancellationErrorand that the semaphore is released. - Check for data races: Run with
swift run -Xfrontend -enable-actor-isolation-assertionsand look for runtime warnings about concurrent writes.
Common Pitfalls
- Forgetting to await the group: If you launch a
TaskGroupbut neverawaitit, the child tasks may continue running in the background or be prematurely cancelled. - Sharing mutable state across children: The compiler will allow you to capture a non‐Sendable variable, but it can lead to undefined behavior. Prefer actors or isolated state.
- Using a non‐Sendable semaphore: Ensure your semaphore is thread‐safe; the example uses
NSLockfor simplicity but you could also useDispatchSemaphoreif you prefer GCD primitives.
Actionable Takeaways
If you need bounded parallel work in Swift:
- Use
withThrowingTaskGroupto launch tasks that share a parent lifetime. - Wrap the heavy operation in a function that acquires a semaphore before proceeding.
- Ensure you
awaitthe group and handle cancellation explicitly. - Compile with the latest toolchain and enable strict concurrency checks to catch Sendable violations early.
With these steps, you’ll have a robust, maintainable pattern for controlled parallelism that leverages Swift’s modern concurrency model.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.