The new structured concurrency system in Swift brings excitement into Swift development.  Structured concurrency promises safer and easier to understand concurrency models to the language.  As with anything new, structured concurrency also brings some new challenges.  In this series of articles, we examine some of the issues developers will encounter when adopting structured concurrency.

Protocol conformance and actors

Protocols are important tools in Swift development. A protocol defines a set of properties and functions that types can adopt, much like interfaces and headers in other languages. Types, either value or reference, can conform to those protocols and become interchangeable with other types that also conform to the same protocol. This approach is valuable in protocol-driven Swift, mocks in unit testing, and many other uses.

Actors are reference types that are designed to protect state in multi-threaded environments. That means direct access to state is limited to the actor alone.  Accessing state from outside the actor requires properties and functions to use structured concurrency.  Similarly, for an actor to conform to a protocol, the protocol itself must account for structured concurrency for properties and functions.

Properties

The following is a protocol with a single property:

protocol ExampleProtocol {
	var property: Double { get set }
}

This protocol states that any conforming class or struct will have the property accessor.  The ExampleProtocol is legal for structs and classes, but not for actors. The protocol implies that the property can be changed from outside the actor because of the "set" modifier. Since the main purpose of actors is protecting mutable state, actors disallow mutable state access from outside the actor as the ExampleProtocol defines.

Making the property immutable by removing "set" helps, but not completely:

protocol ExampleProtocol {
	var property: Double { get }
}

This updated protocol implies that the caller can get the value immediately from the actor, but that is not necessarily the case. In this case, the "get" does not specify mutability in the implementation, just that access to the property is immutable. However, the implementation in a conforming type could be mutable or immutable. However, an actor can only conform to the above using immutable approaches.

If the property on the actor is defined as immutable using "let", the actor can conform to the protocol. The property on the actor will never change so accessing that property is safe regardless of whether the caller is inside or outside the actor. For example:

actor MyActor: ExampleProtocol {
	let property: Double = 0
}

Alternatively, the property could be a computed property marked with "nonisolated" modifier, meaning the computed property is outside the actor and the getter never accesses internal mutable state. For example:

actor MyActor: ExampleProtocol {
	nonisolated var property: Double {
		get {
			// computation here
			// do not access state
			return 5
		}
	}
}

However, this protocol is still illegal for property implemented with mutability on the actor. To get around this limitation, it might be tempting to add an "async" to the protocol:

protocol ExampleProtocol {
	var property: Double { get async }
}

The "async" would allow for structured concurrency.  While it is legal to add "async" to a property getter in a protocol, it does not allow access to mutable state on the actor. However, adding async to a computed property getter now allows access mutable state. For example:

actor MyActor: ExampleProtocol {
	var _property: Double = 5
	var property: Double {
		get async {
			_property
		}
	}
}

Or if using a nonisolated computed property:

actor MyActor: ExampleProtocol {
	var _property: Double = 5
	nonisolated var property: Double {
		get async {
			await _property
		}
	}
}

The difference between the two approaches is subtle in that the second implementation adds two keywords. The "nonisolated" keyword sets the computed property outside of the actor, so it can't directly access mutable state on the actor. Thus, the second keyword is added: "await" in the getter. This allows accessing the mutable state on the actor, whereas the first implementation is already on the actor and does not have to "await". Although there is a difference as to whether these implementations are inside or outside the actor, they both conform to the protocol.

What if an actor must conform to a protocol with a mutable property? Unfortunately, an actor type cannot do this. In these situations, classes or structs are the only options. However, if you want some of the benefits of an actor for protocol-conforming class or struct, using a global actor is an option.

Swift provides the ability to create global actors. A global actors provides an umbrella to group a wide variety of types into an actor. For example, Swift provides a global actor called @MainActor representing the main thread of an application. Types or functions marked as belonging to a global actor can now communicate as if they are on the same actor. Since the code is now on the same actor, many of the protocol conformance limitations no longer exist. So, if a protocol and conforming class are on the same actor, the conformance is now legal:

@MainActor
protocol ExampleProtocol {
	var property: Double { get set }
}

@MainActor
class MyClass: ExampleProtocol {
	var property: Double = 5
}

With the above code, no async is required since the property is protected by the @MainActor. All of the calls are synchronous on the @MainActor. However, if you cannot modify the ExampleProtocol to exist on the same global actor as the conforming class, then the only solution may be to not use any actor and use a class or struct normally.

Functions

Actor protocol conformance with functions have similar limitations as properties. If there is any chance the function changes state, then extra work must be done to prevent race conditions. For example, the following is illegal:

protocol ImportantProtocol {
	func doTheThing()
}

actor NewActor: ImportantProtocol {
	func doTheThing() {
		print("I'm doing the thing!")
	}
}

While the actor is attempting to conform to the protocol, the doTheThing() function is on the actor and would require the ability to suspend. There are three ways to solve this problem. First, like with properties, the function can be designated as "nonisolated", meaning that it is outside the actor:

actor NewActor: ImportantProtocol {
	nonisolated func doTheThing() {
		print("I'm doing the thing!")
	}
}

Now the actor is conformant. However, observe that doTheThing() does not access or update state within the actor. If accessing or updating state is necessary, a Task could be used. A Task is an unstructured asynchronous call that allows structured "await" calls within it. Invoking a Task in this case increases complexity:

actor NewActor: ImportantProtocol {
	private var count = 0
	
	private func addOne() {
		count += 1
	}

	nonisolated func doTheThing() {
		Task {
			await addOne()
			print("I'm doing the thing!")
		}
	}
}

This version of the function can now update mutable state on the actor and conform to the protocol by using a Task.

Second, if the protocol can be changed, the function can be changed to add structured concurrency with "async":

protocol ImportantProtocol {
	func doTheThing() async
}

actor NewActor: ImportantProtocol {
	private var count = 0

	func doTheThing() async {
		count += 1
		print("I'm doing the thing!")
	}
}

If  "async" is added to the function in the protocol, then the actor can easily adopt the protocol. Since it is marked "async", the function is on the actor and can freely modify state and no longer needs the "nonisolated" keyword.

Third, using a global actor with a class or struct with a protocol has similar benefits for functions as for properties:

@MainActor
protocol ImportantProtocol {
	func doTheThing()
}

@MainActor
class NewClass: ImportantProtocol {
	private var count = 0

	func doTheThing() {
		count += 1
		print("I'm doing the thing!")
	}
}

Since the above example is all on the same actor, the function can modify the mutable state.

Thus far, the example functions defined in a protocol did not have return values. With a return value, more care is required for implementing the function in an actor.

If the "nonisolated" keyword is used in the implementation, then mutable state within the actor is unavailable to the function, although immutable properties are available:

protocol ImportantProtocol {
	func doTheThing() -> Int
}

actor NewActor: ImportantProtocol {
	private let maxCount = 5

	nonisolated func doTheThing() -> Int {
		maxCount
	}
}

However, if the function must touch mutable state, "async" is now required in the protocol and implementation:

protocol ImportantProtocol {
	func doTheThing() async -> Int
}

actor NewActor: ImportantProtocol {
	private var maxCount = 5

	nonisolated func doTheThing() async -> Int {
		await maxCount
	}
}

By using "async", the function can now touch mutable state, by reading, writing, or both, and return the value. Of course, this depends on the "async" in the protocol. If the protocol cannot be modified, it might be tempting to use a Task:

protocol ImportantProtocol {
	func doTheThing() async -> Int
}

actor NewActor: ImportantProtocol {
	private var count = 0

	private func addOne() {
		count += 1
	}

	nonisolated func doTheThing() -> Int {
		Task {
			await addOne()
			print("I'm doing the thing!")
		}
		return 0
	}
}

While the above code compiles, the return value cannot be a result of the Task. A Task can only return a value in a structured concurrency context, that is the Task must have a parent Task. Otherwise, a Task will not complete until after the parent function returns.

As with properties, if the protocol cannot be updated to structured concurrency and the implementation requires access to internal properties, an actor type cannot satisfy the requirements and you must use a class or struct instead. However, as with properties, you can use a global actor to get some of the benefits of an actor:

@MainActor
protocol ImportantProtocol {
	func doTheThing() -> Int
}

@MainActor
class NewClass: ImportantProtocol {
	private var count = 0

	func doTheThing() -> Int {
		count += 1
		return count
	}
}


Remember, the above will all be within the @MainActor and therefore the function is synchronous within the actor. 


Of course, protocols can be tailored to specific types. For example, it is possible to define protocols as reference-type only:

protocol ImportantProtocol: AnyObject {
	func doTheThing() async -> Int
}

the addition of the "AnyObject" in the inheritance allows only reference types to use the protocol. Before actors, adding "AnyObject" would make the protocol class only. However, actors are reference types too, but not exactly classes, and can equally conform to the protocol. Thus, the following is valid:

protocol ImportantProtocol: AnyObject {
	func doTheThing() async -> Int
}

actor NewActor: ImportantProtocol {
	private var maxCount = 5

	nonisolated func doTheThing() async -> Int {
		await maxCount
	}
}

Now, Swift allows declaring that a protocol is restricted to actors by using the "Actor" keyword instead of the "AnyObject" keyword:

protocol ImportantProtocol: Actor {
	func doTheThing() async -> Int
}

actor NewActor: ImportantProtocol {
	private var maxCount = 5

	nonisolated func doTheThing() async -> Int {
		await maxCount
	}
}

In the above case, only actors can conform to the protocol. However, restricting a protocol to classes but not actors is somewhat harder to define. Instead of AnyObject or Actor, the protocol must specify a class type, such as UIViewController:

protocol ImportantProtocol: UIViewController {
	func doTheThing() async -> Int
}

Since actors do not support inheritance, actors can never be a subclass of UIViewController and thus cannot conform to this protocol.

Lessons learned

Actors and structured concurrency are important new tools in the Swift toolbox. Actors are designed to reduce the risk of race conditions and, consequently, are harder to use than simple classes. Protocol conformance is one example of this difficulty. However, actor limits on protocol conformance expose some lessons in writing protocols.

The first lesson in writing protocols is to avoid defining mutable state. Actors have trouble conforming to mutable state for good reason: changing mutable state from the outside risks race conditions. Classes can still easily conform to mutable state in a protocol, but should they? Exposed mutable state in a class remains a potential race-condition threat. Care should be used whenever adding mutable state to protocols.

The second lesson is that in writing protocols, the design should carefully consider the context of the protocol. Is the protocol targeting an actor, class or struct? If a planned protocol could be used on an actor, then protocol design should consider all limitations of the actor

 

In Part 2, we will explore some of the difficulties using unstructured concurrency in Swift!

Technologies