Introduction to Unstructured Concurrency in Swift

This article is part of my Modern Concurrency in Swift Article Series.

This article was originally written creating examples using Xcode 13 beta 1. The article, code samples, and provided sample project have been updated for Xcode 13 beta 3.

Table of Contents
  1. Modern Concurrency in Swift: Introduction
  2. Understanding async/await in Swift
  3. Converting closure-based code into async/await in Swift
  4. Structured Concurrency in Swift: Using async let
  5. Structured Concurrency With Task Groups in Swift
  6. Introduction to Unstructured Concurrency in Swift
  7. Unstructured Concurrency With Detached Tasks in Swift
  8. Understanding Actors in the New Concurrency Model in Swift
  9. @MainActor and Global Actors in Swift
  10. Sharing Data Across Tasks with the @TaskLocal property wrapper in the new Swift Concurrency Model
  11. Using AsyncSequence in Swift
  12. Modern Swift Concurrency Summary, Cheatsheet, and Thanks

Understanding Structured Concurrency in Swift is a pre-requisite to read this article. If you aren’t familiar with that concept, feel free to read the Beginning Concurrency in Swift: Structured Concurrency and async-let and Structured Concurrency With Group Tasks in Swift articles of this series.

So far we have focused in exploring Structured Concurrency with the new APIs introduced in Swift 5.5. Structured Concurrency is great to keep a linear flow in our programs, keeping a hierarchy of tasks that is easy to follow. Structured Concurrency helps a lot with keeping task cancellation on track and making error handling as obvious as it would be with no concurrency. Structured concurrency is a great tool to execute various tasks at once, without making our code more difficult to read.

Introducing unstructured concurrency.

Despite the fact that structured concurrency is really useful, there will be times (although hopefully a minority) in which your tasks will have no structured pattern of any kind at all. For these cases, we can leverage unstructured concurrency which will give us more control over the asks, in exchange for some simplicity. The great news is that Swift 5.5 gives us the tools to do this without having to sacrifice a lot of the simplicity. One example of this is giving users the ability to download images, but also giving them the option to cancel the downloads.

There are some situations in which you will feel the need to use unstructured concurrency:

  • Launching tasks from non-async contexts. They can outlive their scopes.
  • Detached tasks, for tasks that won’t inherit any information about their parent task.

In this article, we will focus on the former.

Launching tasks from non-async contexts

We have actually done this before, and this time we will explain Task {} blocks in depth. Recall when we began talking about async/await, we mentioned that when you need to await on a task, you need to be within an async context. If you are inside a function that has async in the signature, then you are fine, and you can await without doing anything special.

The problem is that Apple’s SDKs were not designed to support concurrency from the beginning. Take UIKit as an example. None of the methods that are part of the lifecycle of a view controller are marked as async, such as viewDidAppear. If you need to perform concurrency or simply await on an async task, you can’t, unless you use a Task block.

We actually did this when we talked about Understanding async/await in Swift. In case you didn’t read the original article, by the end of it we ended up with code like this:

// MARK: - Definitions

struct ImageMetadata: Codable {
    let name: String
    let firstAppearance: String
    let year: Int
}

struct DetailedImage {
    let image: UIImage
    let metadata: ImageMetadata
}

enum ImageDownloadError: Error {
    case badImage
    case invalidMetadata
}

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
    let image = try await downloadImage(imageNumber: imageNumber)
    let metadata = try await downloadMetadata(for: imageNumber)
    return DetailedImage(image: image, metadata: metadata)
}

func downloadImage(imageNumber: Int) async throws -> UIImage {
    let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).png")!
    let imageRequest = URLRequest(url: imageUrl)
    let (data, imageResponse) = try await URLSession.shared.data(for: imageRequest)
    guard let image = UIImage(data: data), (imageResponse as? HTTPURLResponse)?.statusCode == 200 else {
        throw ImageDownloadError.badImage
    }
    return image
}

func downloadMetadata(for id: Int) async throws -> ImageMetadata {
    let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(id).json")!
    let metadataRequest = URLRequest(url: metadataUrl)
    let (data, metadataResponse) = try await URLSession.shared.data(for: metadataRequest)
    guard (metadataResponse as? HTTPURLResponse)?.statusCode == 200 else {
        throw ImageDownloadError.invalidMetadata
    }

    return try JSONDecoder().decode(ImageMetadata.self, from: data)
}

//...

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    Task {
        if let imageDetail = try? await downloadImageAndMetadata(imageNumber: 1) {
            self.imageView.image = imageDetail.image
            self.metadata.text = "\(imageDetail.metadata.name) (\(imageDetail.metadata.firstAppearance) - \(imageDetail.metadata.year))"
        }
    }
}

In the code above, we have a few methods that can be awaited. We want to call them from viewDidAppear, but because viewDidAppear does not have async as part of the function signature, we can’t do so directly. Instead, we need to create an async context using async, and we can await inside of it.

The implications of doing this are interesting. First, Task {} actually creates an explicit task. Second, because this launches a new task, anything underneath the Task {} block will continue executing alongside anything inside the Task {} block.

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        async {
            if let imageDetail = try? await downloadImageAndMetadata(imageNumber: 1) {
                print("Image downloaded")
            }
        }

        print("Continue execution alongside the async block")
    }
}

If you run this, you will notice that the output is:

Continue execution alongside the async block
Image downloaded

Executing linear code is much faster than downloading anything from the network, so you are pretty much guaranteed to receive this output every time you run it. It follows to say that, if you have multiple Task {} blocks, you are launching an async task on each.

Finally (and this is the most interesting part), using Task this way will actually return you a handle of type Task<T, Error>. You can later store this handle somewhere and use it to explicitly cancel the task explicitly, await its result, and more.

This is where the “unstructured” part comes into play. We can begin a task somewhere, and then we can cancel it from a completely unrelated place.

For example, we can begin the download from the tap of a button:

// You can get the full code at the end of the article.
class ViewController: UIViewController {
// ...
var downloadAndShowTask: Task<Void, Never>? {
    didSet {
        if downloadAndShowTask == nil {
            triggerButton.setTitle("Download", for: .normal)
        } else {
            triggerButton.setTitle("Cancel", for: .normal)
        }
    }
}

func downloadAndShowRandomImage() {
    let imageNumber = Int.random(in: 1...3)
    downloadAndShowTask = async {
        do {
            let imageMetadata = try await downloadImageAndMetadata(imageNumber: imageNumber)
            imageView.image = imageMetadata.image
            let metadata = imageMetadata.metadata
            metadataLabel.text = "\(metadata.name) (\(metadata.firstAppearance) - \(metadata.year))"
        } catch {
            showErrorAlert(for: error)
        }
        downloadAndShowTask = nil
    }
}

// Inside ViewController
@IBAction func triggerButtonTouchUpInside(_ sender: Any) {
    if downloadTask == nil {
        // If we have no task going, we have now running task. Therefore, download.
        Task {
            await downloadAndShowRandomImage()
        }
    } else {
        // We have a task, let's cancel it.
        cancelDownload()
    }
}

// ...
}

And cancel the download when the user wishes to:

func cancelDownload() {
    downloadAndShowTask?.cancel()
}

The full program contains a triggerButton whose label changes when downloadAndShowTask's value changes. When it is nil, there’s no task going on, so we will use the button to download an image. Otherwise, we will use the button to cancel the action.

downloadAndShowTask is of type Task<Void, Never> because the task itself doesn’t return anything and it doesn’t throw an error. Our button will download the image and set the labels.

If you needed to download the images but not process them directly, you may want to define your tasks in such way that they return specific values.

The following example is more involved, but it shows you the flexibility you have with Task {} unstructured tasks.

First, we will add @MainActor to the declaration of the view controller. It’s possible that other threads other than the main one will want to access the values of the view controller.

@MainActor
class ViewController: UIViewController //...

Next, we will change downloadAndShowTask to downloadTask and we will change the signature to Task<DetailedImage, Error>. This will allow us to await on the DetailedImage, or to throw an error from within the task if necessary.

var downloadTask: Task<DetailedImage, Error>? {
    didSet {
        if downloadTask == nil {
            triggerButton.setTitle("Download", for: .normal)
        } else {
            triggerButton.setTitle("Cancel", for: .normal)
        }
    }
}

Next, we will create a new method, beginDownloadingRandomImage, which will start an image download and store it in the downloadTask handle. We will create the code that updates the outlets accordingly while we are on it.

func beginDownloadingRandomImage() {
    let imageNumber = Int.random(in: 1...3)
    downloadTask = Task {
        return try await downloadImageAndMetadata(imageNumber: imageNumber)
    }
}

func showImageInfo(imageMetadata: DetailedImage) {
    imageView.image = imageMetadata.image
    let metadata = imageMetadata.metadata
    metadataLabel.text = "\(metadata.name) (\(metadata.firstAppearance) - \(metadata.year))"
}

We will update the implementation of downloadAndShowRandomImage so it makes use of the two new functions.

func downloadAndShowRandomImage() async {
    beginDownloadingRandomImage()
    do {
        if let image = try await downloadTask?.value {
            showImageInfo(imageMetadata: image)
        }
    } catch {
        showErrorAlert(for: error)
    }
    downloadTask = nil
}

This method will now call beginDownloadingImage, which will assign a value to downloadTask within. Then, we call downloadTask?.value. .value will return us the image when it’s done downloading, which is why it’s awaited.

cancelDownload is the same as always. We can cancel (and start) the download at any point.

Tasks created this way also inherit the priority, local values, and the actor. They can outlive their scope and therefore give you more control over their lifetimes.

Summary

We have explored what async actually does. We have used it to create an explicit task that we can later cancel manually and explicitly. We can use Task {} blocks to work with concurrency that doesn’t have any kind of structure. This is useful when we want to have more control over the tasks. Being able to cancel tasks when deemed necessary can help improve the user experience, especially if very long-running tasks are involved. These tasks can outlive the original scope they are defined in, enforcing the idea that they create unstructured concurrency.

There is a a small project with the last pieces of code that you can explore to better understand Task {}. The program has a Download button that cancels into a Cancel button when a download is in progress. Rapidly tapping the button will show you an alert that says “cancelled” as you cancelled the task explicitly without giving a change to the image to download.

Download image

Cancel image download