Skip to content

Instantly share code, notes, and snippets.

@jakob
Last active October 20, 2022 08:56
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jakob/22c9725caac5125c1273ece93cc2e1e7 to your computer and use it in GitHub Desktop.
Save jakob/22c9725caac5125c1273ece93cc2e1e7 to your computer and use it in GitHub Desktop.
Swift Async/Await: A thorough example

I love the async/await proposal, but some of the examples are too simplified. In this message I'd like to explore what a more thorough example could look like.

Let's start with this example from the proposal:

@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    let image = await processImage()
    imageView.image = image
  }
}

Here are some problems that you would run into with this code:

  1. There is no guarantee that you are on the main thread after await processImage()
  2. There is no way to cancel processing
  3. If you click the button a second time before processImage() is done, two copies will run simultaneously and you don't know which image will "win".

Let's look at how we could fix these issues one by one:

1. Guarantee main thread

There are multiple ways to fix issue number 1.

At the language level

We could ensure that await always returns on the queue it was called on. This would have the advantage of making the concept easier to understand, but it would also strongly restrict the possible use cases of async/await and pretty much tie the feature to GCD.

Have async procedures return on same queue (as a convention)

We could ensure that coroutines like processImage() always returns on the main queue, or on the queue that they were originally called on.

However, this would require a lot of boilerplate code, and there is no way to verify this at the call site. We'd have to rely on the documentation of processImage(), or hope that it does the right thing.

Just use GCD

The easiest way!

@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    let image = await processImage()
    DispatchQueue.main.async {
      imageView.image = image
    }
  }
}

Disadvantage: we are now back in callback hell.

Async helper on dispatch queue

We could use a helper method that hops on a specific dispatch queue, like asyncCoroutine() from the proposal.

@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    let image = await processImage()
    await DispatchQueue.main.asyncCoroutine()
    imageView.image = image
  }
}

I think this is the most elegant solution. It doesn't lead to deeply nested blocks, and it lets you verify that you are on the correct queue at the call site.

Even better: use a helper method named hop() that checks if you are already on the requested queue, and only uses dispatch_async if you are not. Less overhead and easier to read!

@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    let image = await processImage()
    await DispatchQueue.main.hop()
    imageView.image = image
  }
}

Or use a global helper:

@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    let image = await processImage()
    await jumpToMainQueue()
    imageView.image = image
  }
}

2. Cancelling async operations

Async functions only return once they are completed. But sometimes we need to cancel them beforehand, which requires a way to communicate with the async function. A straightforward extension would be to refactor the processing code into a class, similar to the following:

class ImageProcessingTask {
  var cancelled = false
  func process() async -> Image? { … }
}

Then process() would periodically check the cancelled variable to see if it should continue, and could just return nil if it was cancelled.

Our example could then look similar to this:

var currentTask: ImageProcessingTask?
@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    currentTask = ImageProcessingTask()
    guard let image = await currentTask!.process() else { return }
    await jumpToMainQueue()
    imageView.image = image
  }
}

Now we can cancel the processing task anytime by setting currentTask.cancelled = true.

3. Fix Race Conditions

Finally, the code above still has some race conditions. When the user clicks the button twice, we don't know what will happen. To fix this, we need some extra synchronisation. The simplest way would be to (a) cancel previous task before starting a new one and (b) ensure we don't update the UI after the task was cancelled.

var currentTask: ImageProcessingTask?
@IBAction func buttonDidClick(sender:AnyObject) {
  beginAsync {
    currentTask?.cancelled = true
    let task = ImageProcessingTask()
    currentTask = task
    guard let image = await task.process() else { return }
    await jumpToMainQueue()
    guard task.cancelled == false else { return }
    imageView.image = image
  }
}

Now we have a guarantee that results of old tasks are ignored if we click the button again. The reason it works is that the synchronisation happens on the main queue: we set the cancelled flag on the main queue, and later we check it on the main queue before applying results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment