Explore structured concurrency in Swift
The idea is based on structured programming. The easiest example is if-else statement where it only execute some block of code conditionally while moving from top to bottom.
The problem is asynchronous code with completion handlers or callback are easier to understand and we should handle all edge cases and errors by ourself.
Task: provides new async context for executing code concurrently and because it’s integrated with Swift, the compiler can help us prevent some concurrency bugs.
Structured concurrency is balance between flexibility and simplicity.
When function throw an error, the other child task will be cancelled then await for it to finish before exiting the function. In fact, the task is not really cancelled but mark the result is no longer needed. When child tasks are cancelled, all subtasks are automatically cancelled too.
The code should check for task cancellation explicitly so we should design concurrent code with cancellation in mind.
A task group is form of structured concurrency that is designed to provide dynamic amount of concurrency at the same time.
We can still wrote unstructured task using async {}
. When we anotate a function that with @MainActor
Swift will make sure the task is created on the main thread and stay in that thread. Trade off with this flexibility is we should manage cancellations and errors manually.
There’s also detached task where we can construct an async task which is not bound from originating scope. They’re independent and not constrained with the same actor from where it called. the example given is how we can cache fetched thumbnails into disk with async task in the background.
Async let task
The simplest form of task, with this we can assign a variable an async function that returns value: async let image = downloadImage()
. This will create image
placeholder and then run the task. when we want to use image
we have to await it.
A parent task can spawn more than one child task and can only complete if all of its child task is complete. If error happens, parent task must immediately exit. Note that cancel the task doesn’t means it immediately stop the task. It means that the output is no longer needed and every task should handle cancellation.
We can check task cancellation with Task.checkCancellation()
Task is guaranteed to be finished in this state:
- Successfully execute the task
- Cancelled through cancellation
- Throwing and error (see: Throwing function and Error handling in Swift)
Group Task
This can be useful when we don’t know the amount of concurrency we need. For example if we want to fetch thumbnail from dictionary or array.
We can create task group using withThrowingTaskGroup
function. This will give us a group object which we can create child task and allow them to throw errors. Task cannot outlive the scope of block where the group is defined.
Inside group.async {}
we can use async-let and it will compose naturally.
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
try await withThrowingTaskGroup(of: Void.self) { group in
for id in ids {
group.async {
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
}
}
return thumbnails
}
In sample code above, Swift will give us error because dictionary is not able to handle one access at a time. With the increasing concurreny, data races is often happens.
For data race safety:
-
@Sendable
closure - Cannot capture mutable variables
- Should only capture value types, actors, or classes that implement their own synchronization
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
for id in ids {
group.async {
return (id, try await fetchOneThumbnail(withID: id))
}
}
// Obtain results from the child tasks, sequentially, in order of completion.
for try await (id, thumbnail) in group {
thumbnails[id] = thumbnail
}
}
return thumbnails
}
If type conform to AsyncSequence
protocol, we can use for-await to iterate through them
Unstructured Tasks
tasks that need to launch from non-async contexts must be manually cancelled or awaited
Detached tasks
- unscoped lifetime, we should manually cancelled and awaited
- do not inherti anything from originating context. by default not constrained to the same actor and don’t have to run at the same priority as where they launched
- run independently with generic defaults for things like priority.
Related sessions:
- Meet AsyncSequence
- Protect mutable state with Swift actors
- Meet async await in Swift
- Swift concurrency: Behind her scene