Tasks in Swift are part of the concurrency framework introduced at WWDC 2021. A task allows us to create a concurrent environment from a non-concurrent method, calling methods using async/await.
When working with tasks for the first time, you might recognize familiarities between dispatch queues and tasks. Both allow dispatching work on a different thread with a specific priority. Yet, tasks are quite different and make our lives easier by taking away the verbosity from dispatch queues.
If you’re new to async/await, I recommend first reading my article Async await in Swift explained with code examples.
How to create and run a Task
Creating a basic task in Swift looks as follows:
let basicTask = Task {
return "This is the result of the task"
}
As you can see, we’re keeping a reference to our basicTask
which returns a string value. We can use the reference to read out the outcome value:
let basicTask = Task {
return "This is the result of the task"
}
print(await basicTask.value)
// Prints: This is the result of the task
This example returns a string but could also have been throwing an error:
let basicTask = Task {
// .. perform some work
throw ExampleError.somethingIsWrong
}
do {
print(try await basicTask.value)
} catch {
print("Basic task failed with error: \(error)")
}
// Prints: Basic task failed with error: somethingIsWrong
In other words, you can use a task to produce both a value and an error.
How do I run a task?
Well, the above examples already gave away the answer for this section. A task runs immediately after creation and does not require an explicit start. It’s important to understand that a job is executed directly after creation since it tells you only to create it when its work is allowed to start.
Performing async methods inside a task
Apart from returning a value or throwing an error synchronously, tasks also execute async methods. We need a task to perform any async methods within a function that does not support concurrency. The following error might be familiar to you already:
‘async’ call in a function that does not support concurrency
In this example, the executeTask
method is a simple wrapper around another task:
func executeTask() async {
let basicTask = Task {
return "This is the result of the task"
}
print(await basicTask.value)
}
We can solve the above error by calling the executeTask()
method within a new Task:
var body: some View {
Text("Hello, world!")
.padding()
.onAppear {
Task {
await executeTask()
}
}
}
func executeTask() async {
let basicTask = Task {
return "This is the result of the task"
}
print(await basicTask.value)
}
The task creates a concurrency supporting environment in which we can call the async method executeTask()
. Interestingly, our code executes even though we didn’t keep a reference to the created task within the on appear method, bringing us to the following section: cancellation.
Handling cancellation
When looking at cancellation, you might be surprised to see your task executing even though you didn’t keep a reference to it. Publisher subscriptions in Combine require us to maintain a strong reference to ensure values get emitted. Compared to Combine, you might expect a task to cancel as well once all references are released.
However, tasks work differently since they run regardless of whether you keep a reference. The only reason to keep a reference is to give yourself the ability to wait for a result or cancel the task.
Cancelling a task
To explain to you how cancellation works, we’re going to work with a new code example which is loading an image:
struct ContentView: View {
@State var image: UIImage?
var body: some View {
VStack {
if let image = image {
Image(uiImage: image)
} else {
Text("Loading...")
}
}.onAppear {
Task {
do {
image = try await fetchImage()
} catch {
print("Image loading failed: \(error)")
}
}
}
}
func fetchImage() async throws -> UIImage? {
let imageTask = Task { () -> UIImage? in
let imageURL = URL(string: "https://source.unsplash.com/random")!
print("Starting network request...")
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
return UIImage(data: imageData)
}
return try await imageTask.value
}
}
The above code example fetches a random image and displays it accordingly if the request succeeds.
For the sake of this demo, we could cancel the imageTask
right after its creation:
func fetchImage() async throws -> UIImage? {
let imageTask = Task { () -> UIImage? in
let imageURL = URL(string: "https://source.unsplash.com/random")!
print("Starting network request...")
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
return UIImage(data: imageData)
}
// Cancel the image request right away:
imageTask.cancel()
return try await imageTask.value
}
The cancellation call above is enough to stop the request from succeeding since the URLSession implementation performs cancellation checks before execution. Therefore, the above code example is printing out the following:
Starting network request...
Image loading failed: Error Domain=NSURLErrorDomain Code=-999 "cancelled"
As you can see, our print statement still executes. This print statement is a great way to demonstrate how to implement cancellation checks using one of the two static cancellation check methods. The first one stops executing the current task by throwing an error when a cancellation is detected:
let imageTask = Task { () -> UIImage? in
let imageURL = URL(string: "https://source.unsplash.com/random")!
/// Throw an error if the task was already cancelled.
try Task.checkCancellation()
print("Starting network request...")
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
return UIImage(data: imageData)
}
// Cancel the image request right away:
imageTask.cancel()
The above code results print:
Image loading failed: CancellationError()
As you can see, both our print statement and network requests don’t get called.
The second method we can use gives us a boolean cancellation status. By using this method, we allow ourselves to perform any additional cleanups on cancellation:
let imageTask = Task { () -> UIImage? in
let imageURL = URL(string: "https://source.unsplash.com/random")!
guard Task.isCancelled == false else {
// Perform clean up
print("Image request was cancelled")
return nil
}
print("Starting network request...")
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
return UIImage(data: imageData)
}
// Cancel the image request right away:
imageTask.cancel()
In this case, our code only prints out the cancellation statement.
Performing regular cancellation checks is essential to prevent your code from doing unnecessary work. Imagine an example in which we would transform the returned image; we should’ve probably added multiple checks throughout our code:
let imageTask = Task { () -> UIImage? in
let imageURL = URL(string: "https://source.unsplash.com/random")!
// Check for cancellation before the network request.
try Task.checkCancellation()
print("Starting network request...")
let (imageData, _) = try await URLSession.shared.data(from: imageURL)
// Check for cancellation after the network request
// to prevent starting our heavy image operations.
try Task.checkCancellation()
let image = UIImage(data: imageData)
// Perform image operations since the task is not cancelled.
return image
}
We are in control in regards to cancellation, making it easy to make mistakes and perform unnecessary work. Keep an eye sharp when implementing tasks to ensure your code regularly checks for cancellation states.
Setting the priority
Each task can have its priority. The values we can apply are similar to the quality of service levels we can configure when using dispatch queues. The low, medium, high priorities look similar to priorities set with operations.
Each priority has its purpose and can indicate that a job is more important than others. There is no guarantee your task indeed executes earlier. For example, a lower priority job could already be running.
Configuring a priority helps prevent a low-priority task from avoiding the execution of a higher priority task.
The thread used for execution
By default, a task executes on an automatically managed background thread. Through testing, I found out that the default priority is 25. Printing out the raw value of the high priority shows a match:
(lldb) p Task.currentPriority
(TaskPriority) $R0 = (rawValue = 25)
(lldb) po TaskPriority.high.rawValue
25
You can set a breakpoint to verify on which thread your method is running:
Continuing your journey into Swift Concurrency
The concurrency changes are more than just async-await and include many new features that you can benefit from in your code. Now that you’ve learned about the basics of tasks, it’s time to dive into other new concurrency features:
- MainActor usage in Swift explained to dispatch to the main thread
- How to Use URLSession with Async/Await for Network Requests in Swift
- Async await in Swift explained with code examples
- Swift 6: Incrementally migrate your Xcode projects and packages
- Concurrency-safe global variables to prevent data races
- Unit testing async/await Swift code
- Thread dispatching and Actors: understanding execution
- @preconcurrency: Incremental migration to concurrency checking
- Detached Tasks in Swift explained with code examples
- Task Groups in Swift explained with code examples
- Sendable and @Sendable closures explained with code examples
- AsyncSequence explained with Code Examples
- AsyncThrowingStream and AsyncStream explained with code examples
- Tasks in Swift explained with code examples
- Nonisolated and isolated keywords: Understanding Actor isolation
- Async let explained: call async functions in parallel
- Actors in Swift: how to use and prevent data races
Conclusion
Tasks in Swift allow us to create a concurrent environment to run async methods. Cancellation requires explicit checks to ensure we do not perform any unnecessary work. By configuring the priority of our tasks, we can manage the order of execution.
If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional suggestions or feedback.
Thanks!