Getting to know Swift’s new Actor and Async/Await features

Getting to know Swift’s new Actor and Async/Await features

During last week’s WWDC, Apple announced that Swift 5.5 has added support for the async/await pattern for calling code asynchronously. Although this is a fairly common pattern, seen in languages such as C#, Javascript and Python, I personally had never used it and had only really seen it being used recently in the Python Quart framework.

In addition, Apple also announced support for a new Actor datatype, which acts essentially like a class whose properties are inherently thread-safe. This seems like a great feature because implementing mutexes and atomic operations manually leaves a lot of chance for error and harder-to-read code.

Considering that I am in the process of learning Swift, I thought working through some experiments with this new functionality would be a great way of learning the language and the async/await pattern at the same time. After watching the WWDC videos on Swift’s new asynchronous capabilities, I thought I had a pretty good intuition of how it would all behave and as usual, there was still plenty I had to (and have to) learn.

A Brief Note on Getting Started

There seem to be a number of ways to get Swift 5.5 working on your computer. I would like to explain how I got it working for me but there may be alternatives out there that work better for your environment.

  1. Download and Install the Swift 5.5 Development Toolchain under the Snapshot section: here
  2. Open Xcode and select the toolchain via the menu Xcode > Toolchains > Swift 5.5 Development Snapshot
  3. Start a new macOS Command Line Tool Project
  4. Under your project’s Signing & Capabilities section, check the box to “Disable Library Validation”
  5. Under your project’s Build Settings, add the following flags to the”Other Swift Flags” section: -Xfrontend -enable-experimental-concurrency -parse-as-library

Hopefully after that, you will be able to compile the code examples below without a problem. And if you’d like to see the example code below all in one place, you can find it here.

The Actor

Designing for concurrency and asynchronous updates is common when your code has to make requests on a network or interface with its users outside of the command line. In order to simplify as much as possible, we’re starting with an actor whose only property is an array of characters and an index. Its only methods return the character that the current index points to and then increments the index. Once the index gets to the end, it’s reset back to 0 so that we can traverse the array infinitely. In order to add the element of unpredictability seen in the real world, the methods both sleep for a random number of milliseconds before returning.

// Helper function for printing timestamped messages as they are executed.
func stepPrint(_ i: Int, msg: String) {
    print("\(Date()): Step \(String(format: "%2d", i)): \(msg)")
}

actor CircularAlphabet {
    var idx = 0
    let letters = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    
    func getLetterAsync(step: Int) async -> Character {
        return getLetterSync(step: step)
    }
    func getLetterSync(step: Int) -> Character {
        let waitTime = Int.random(in: 0..<1000)
        stepPrint(step, msg: "Waiting \(waitTime)ms")
        usleep(UInt32(waitTime * 1000))
        defer { idx = (idx+1 < letters.count) ? idx+1 : 0 }
        return letters[idx]
    }
}

var alpha = CircularAlphabet()

As you can see, both methods should do exactly the same thing; the only difference being that getLetterAsync() is defined as asynchronous.

My personal assumption here is that I should be able to call either method in parallel and safely get a letter returned without having to worry about race conditions upon incrementing the idx property.

Synchronously using async/await

To start with, let’s loop through the alphabet, calling getLetterAsync() and awaiting each result.

func oneAtATime(count: Int) async {
    for i in 0..<count {
        stepPrint(i, msg: "Enter")
        let result = await alpha.getLetterAsync(step: i)
        stepPrint(i, msg: "Exit")
        stepPrint(i, msg: "Returned \(result)")
    }
}

await oneAtATime(count: 30)

This usage is not a great example of when to use asynchronous calls but it might be useful in cases where there are other asynchronous processes elsewhere in the program. We’re not doing anything in parallel here but we are freeing up the CPU to be used more efficiently.

My surprise in this case was that I needed to declare oneAtATime() as async and use the await keyword when calling it. As we’ve already noted, there’s nothing asynchronous about this method from the caller’s point of view. The reason is that, in my current environment, async can only be called from functions that are declared as async. This is all bootstrapped from the very top level because we’ve declared our main function with async.

static func main() async { ... }

Moving closer to parallelism

The next thing to try as we explore is the async let functionality used for declaring a constant whose value is initialized by an async function or method.

func oneAtATimeWithAsync(count: Int) async {
    for i in 0..<count {
        stepPrint(i, msg: "Enter")
        async let result = alpha.getLetterAsync(step: i)
        stepPrint(i, msg: "Exit")
        stepPrint(i, msg: "Returned \(await result)")
    }
}

await oneAtATimeWithAsync(count: 30)

By taking note of the timestamps printed, you’ll see that the “Exit” printed before or while the getLetterAsync method is sleeping, which is what we expected also.

Possibly worth noting however, because I got tripped up on it when first writing oneAtATimeWithAsync is using await on the same line as the async let, as shown below.

async let result = await alpha.getLetterAsync(step: i)

If we changed our function to this, the behavior of oneAtATimeWithAsync would not change and the await inside the later print statement would still be required. I had assumed the await would be evaluated first but that is not the case and Xcode does not warn about this being an ineffective use of the keyword.

But can I has parallel?

We’ve got the async and await usage down so let’s get started on building some concurrency. Rather than get straight to the solution, I’d like to first document a few of my false starts. For me, it’s almost just as useful know what you cannot do with a language as what you can. This is especially true, as you’ll see, when the compiler doesn’t catch your problem either.

My first thought was to use the same approach as before but append the results to an Array and we can use await when we want to access each item in the array. Unfortunately, something like the code below does not work. Swift does not allow us to declare a fixed-size array without simultaneously initializing its elements.

func concurrent(count: Int) {
    var results = Array(count: count)  // this doesn't exist in Swift
    // initialize
    for i in 0..<count {
        let async results[i] = getValueAsync()
    }
    // await on print
    for i in 0..<count {
        let val = await results[i]
        print(val)
    }
}

My second thought was, what about a callback?! Then I’m not relying on async let to set the elements in my array. Unfortunately the compiler immediately reminded me that an actor can only access its own properties. This makes sense since we can’t expect the actor to protect the results array from race conditions.

func concurrent(count: Int) {
    var results = Array(repeating: Character(""), count:count)
    for i in 0.. () in 
            alpha.getLetterSyncWithCallback(step: i) {(result: Character) in 
                results[i] = result
            }
        }
    }
}

So why not move the results into the CircularAlphabet actor and have it manage the concurrent access? It’s a significant design change but worth trying to get something working.

actor CircularAlphabet {
    var idx = 0
    let letters = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
    var results: [Character] = []
    func updateResults() async {
        let waitTime = Int.random(in: 0..<1000)
        usleep(UInt32(waitTime * 1000))
        defer {idx = (idx+1 < letters.count) ? idx+1: 0}
        results.append(letters[idx])
    }
}

Again, this isn’t going to give us the parallelism we’re looking for because we’ll have to use await on each call to alpha.updateResults. It seems that storing the results of a variable number of concurrent processes is not supported yet (at least cleanly, safely, easily).

Well Defined Concurrency

This doesn’t mean the async/await pattern cannot be used at all for concurrent processing. We just need to declare each async/await call by hand and therefore cannot handle the variable number of steps that we were trying to support above.

For brevity’s sake, we’ll handle two steps below:

func concurrentStaticAsync() async {
    stepPrint(0, msg: "Enter")
    async let firstResult = alpha.getLetterAsync(step: 0)
    stepPrint(0, msg: "Exit")
    stepPrint(1, msg: "Enter")
    async let secondResult = alpha.getLetterAsync(step: 1)
    stepPrint(1, msg: "Exit")
    
    stepPrint(0, msg: "Returned \(await firstResult)")
    stepPrint(1, msg: "Returned \(await secondResult")
}

await concurrentStaticSync()

But wait, what happened? A deadlock as soon as the firstResult finished its await. There must be something else going on with concurrent, asynchronous access to an Actor’s properties. Now you see why CircularAlphabet has the getLetterSync method.

Below, using getLetterSync, you’ll see that we can concurrently call to get letters.

func concurrentStaticSync() async {
    stepPrint(0, msg: "Enter")
    async let firstResult = alpha.getLetterSync(step: 0)
    stepPrint(0, msg: "Exit")
    stepPrint(1, msg: "Enter")
    async let secondResult = alpha.getLetterSync(step: 1)
    stepPrint(1, msg: "Exit")
    
    stepPrint(0, msg: "Returned \(await firstResult)")
    stepPrint(1, msg: "Returned \(await secondResult)")
}

This works great and will definitely have its use cases but it is unfortunate that the order is statically defined in code. If firstResult takes a long time, there’s nothing actionable we can do with the secondResult.

Achieving Effective Concurrency

The features discussed so far are only available within the development toolchain for Swift and are not officially production-ready. They are, however, freely available to anyone who wants to experiment and learn from them. In contrast, the next solution is currently only supported on the upcoming Apple operating systems (macOS 12+, iOS 15+, etc) which means it’s only available to members of Apple’s developer program who have access to their beta releases.

Enter the Task Group, using withTaskGroup(of:returning:body:), we’re able to create a context where we can dynamically create asynchronous tasks and safely wait for them to finish. This pattern almost exactly matches our initial intuition of what we’d like to see, except wrapped within a closure, which forms the context.

func parallel(count: Int) async {
    await withTaskGroup(of: (Int, Character).self) { group in
        for i in 0..<count {
            group.async {
                stepPrint(i, msg: "Enter")
                async let result = alpha.getLetterAsync(step: i)
                stepPrint(i, msg: "Exit")
                return (i, await result)
            }
        }
        
        for await finishedStep in group {
            stepPrint(finishedStep.0, msg: "Returned \(finishedStep.1)")
        }
    }
}

await parallel(count: 30)

This is great! We now have a dynamically defined number of tasks working in parallel. Also, the advantage of the above versus my initial attempt is that it forces you to handle each parallel task all in one place, the closure. This makes it easier to read, understand and debug.

Conclusion

Hopefully this review will help you get started experimenting with Swift 5.5 and its new concurrency features. It should also highlight some of the quirks and limits that currently exist. With its emphasis on safety and readability, it seems like a really powerful set of tools for building concurrent applications.

And finally, I’m compelled to reiterate that I’m fairly new to Swift and there could be approaches or features to the language that solve the problems above that I’m wholly unaware of. If so, please let us know and that way, we can all learn from one another!

Written by
Jason Aylward
Join the discussion

Recent Comments