Understanding Structured Concurrency with Swift

The introduction of Swift 5.5 comes a new model for managing asynchronous programming. Although many anticipated new hardware announcements at Apple's WWDC21, instead, we received a redux in how many of us will write code for Swift-based apps, including iOS and beyond. As we will see, these changes focus on implementing more structured code when it comes to asynchronous operations

A Primer - Structured Variables

When Swift was first introduced in 2014, a challenging language feature to grasp was optional variables. Not being able to assign nil to non-optional values or do other seemingly straightforward tasks proved confusing and nuanced: 

	var item = "first"
   	item = nil  //nil cannot be assigned to type String


Back then, no other c-based language applied the concept of unwrapping variables. However, the main goal of optionals was to provide better tools to express one's ideas without falling prone to runtime errors like memory leaks or null pointer exceptions.  

Optionals have improved the entire App ecosystem by providing basic rules developers can adhere to at compile-time. Fast forward to today and the concept of structured coding has made it's to another essential aspect of iOS development - multi-threaded operations. Known as structured concurrency, many are just referring to this new paradigm as async / await. Let's review the highlights of how this works.


Actors

Swift 5.5 debuts a new type that provides synchronization for shared mutable state along with structs and classes. Called actors, these reference types isolate their state from the rest of the program. In Objective-c terminology, this is atomic behavior. Having a model that provides this type of (Actor) isolation is critical if designing a model to safeguard against race conditions often found in concurrent operations:

Like this article? Get more content like this when you sign-up for the weekly iOS Computer Science Lab.

	
Actor Counter {  //atomic!
    var value = 1
        
    func increment() -> Int {
        value += 1
        return value
    }
    
    //there's no need to mark internal methods or properties
    //as async since they are all running within the same (protected scope)
    
    func increase() -> Int {
        return self.increment()
    }
    
    
    func multiply(_ sum: Int) -> Int {
        
        let m = Multiplier()
        let product = m.double(using: sum)
        return product
        
    }
    
}

extension Counter {
    func decrement() -> Int {
        value -= 1
        return value
    }
}

Their atomic behavior provides a certain level of conformance when accessing their methods or properties. At first glance, the code appears to be standard Swift syntax. However, we should note that Counter has full permission to mutate the state of its internal property (value). This design is seen by implementing the increase() method and decrement() method implemented on the Actor type extension. Supporting the design principle of encapsulation, data mutation for Actors can only occur from within the type. This is known as actor-isolation. To get a clearer picture of how this works, let's create a unit test to exercise the Counter functionality. 

 

Working with Actors

Similar to Optionals, interacting with the new concurrency model does take some practice but quickly makes sense once we understand the basics. Consider the following:

func testWithActors()  {
        
        let c = Counter()
                
        async {
            let sum = await c.increment()
            let product = await c.multiply(sum)
            await printResults(using: product)
        }        
                
        print("called before async..")
    }
    
    
    //present content results on the main thread..
   @MainActor func printResults(using item: Int) 
        
    

To start, note that testWithActors() is invoked as a regular synchronous function. When developing Apps, the general rule is that interactions for the main user interface always occur on the main thread. A typical scenario is having an App start its main execution on the main thread, then have a segment of code retrieve or process some data on a secondary background task. This could be something simple like retrieving images from a REST-based service or starting a background job.

After declaring a new Counter instance, we attempt to retrieve information from the Actor by calling c.increment() and assigning that return value to sum. As shown, this code is wrapped in a within a new async{} closure known as an unstructured task. As we know, closures work like functions that can be used in other processes. Another helpful feature is that closures run within their own (separate) scope and capture values from their surrounding context.

As part of our model, note how we cannot just call c.increment() but must prefix this statement with await. New with Swift 5.5, the await keyword indicates the secondary / background thread may have to pause its execution to satisfy actor exclusivity requirements. As a result, the compiler checks for the required conforming behavior at compile time. To illustrate, let's attempt to refactor the code so that it works outside of the async{} task.

 func testWithActors()  {
        
        let c = Counter()

        //actor-isolated instance can only be accessed from inside the actor..
        let sum = await c.increment() //compliation error

        ...
 }

The Main Actor 

The final step in our async{} task is to print the results we obtained from the Actor. However, let's assume we want to present this content on the main thread. Typically, this is done by wrapping our code with Dispatch.queue.main() closure. Although flexible and convenient, this is a runtime design pattern that could easily cause a bug or crash if not implemented correctly. New with Swift 5.5, we have the concept of the Main Actor.


Added as an attribute at the function or class level, this keyword guarantees the code is executed on the main thread. Similar to working with Actors, Main Actor compliance is also checked at compile time. In our case, this design pattern works to our advantage. Note how the invocation of printResults() will occur on the main thread (e.g., Thread 1) even though the line of execution exists within the async{} task:

     ...
	//background thread (detached) task
        async {
            let sum = await c.increment()
            let product = await c.multiply(sum)
            await printResults(using: product)
        }        
                
        print("called before async..")
    }
    
    
    //present content results on the main thread..
   @MainActor func printResults(using item: Int) 
        
    

What's interesting about the new async / await format is that these keywords can have different meanings depending on where they are applied in code. To revisit our Counter code example, notice the placement of await used when returning the value from c.increment versus the call to printResults. Although printResults is not an asynchronous function, it is unknown when we'll receive the product variable. As a result, this line of execution must await its results. 

What's Next

While we've reviewed the basics, there are some additional aspects to the new structured concurrency model, including async-let, tasks and task groups, asyncSequence, exception handling, and the sendable protocol. With Xcode 13 currently in beta with its anticipated release this fall, there's undoubtedly much more to explore, but it's fabulous to see these welcome changes to the iOS ecosystem. Happy coding!