Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Tasks in Swift explained with code examples

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

'async' call in a function that does not support concurrency is a common error in Swift.
‘async’ call in a function that does not support concurrency is a common error in Swift.

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.

How do you stay current as a Swift developer?

Let me do the hard work and join 19,138 developers that stay up to date using my weekly newsletter:

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.

We can manage the order of tasks execution by setting the priority
We can manage the order of tasks execution by setting the priority

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:

By using a breakpoint you can check the thread on which the task is running.
By using a breakpoint, you can check the thread on which the task 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:

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!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.