Exploring Actors and Protocol Extensions
Can Swift Keep Its Compile-Safe Thread Management Promise?
Author: Lucas van DongenIntroduction
When writing a blogpost, it's easy to keep falling deeper into a rabbit hole when validating your writing. If you're publishing towards an audience of people that are at least as technical and critical as you are, you want to be sure that everything that supports your narrative is true. While making a tiny mistake doesn't hurt, you need to be sure that the statements that carry the core narrative of your story remain valid under scrutiny.
When I was writing a yet-to-be-published blogpost I was using the bleeding edge of Swift versions as opposed to the "current and previous version" iOS version support I used in my day job, often extending to "two versions back, whenever this code was written". Thus, I had an opportunity to delve into all new features in the language and frameworks that I was not able to play a lot with yet.
When I was reviewing the content of this blogpost I started to experiment more closely with Actors to figure out how they should be used in a correct and idiomatic way. And when I did so I found way more interesting things than I could (and should, for brevity's sake) include in that original blogpost, so I had to move some of my learnings to separate posts.
It takes some courage to say "this is the story, it's done now, let's take it out into the world".
This post will go over the following topics:
- How to prove your multi-threaded code breaks with Unit Tests
- What can go wrong when you use protocol extensions on Actors
- How to safely use protocol extension on Actors
The Promise of Actors and Compile-Safe Isolation
The initial version of Swift was intended as a compile-time type-safe language, where common issues in Objective-C like unexpected nil
values and lenient rules on type safety often resulted in run-time crashes.
Swift delivered on this promise from the get-go. But after the initial implementation of Swift was ABI stabilized in version 5.0, the Swift developers started to look into thread-safety as the next target. The first phase, now essentially delivered, would focus on the following features:
async/await
- Task API and Structured Concurrency
- Actors & Actor Isolation
- Concurrency Interoperability with Objective-C
- Async handlers
Today, I'll focus on some peculiarities I discovered when really pushing Actors to their limits using my Threads Torture Testsβ’.
The Original Test Set-Up
The test set-up I created was originally intended to prove concurrency issues existed first, if they could happen in certain circumstances. With a consistently failing test, I could prove Actors solved concurrency issues by making the tests pass again. The idea was to have a NumberFormatter
for formatting currencies, using a private static
instance for performance reasons. A very common scenario.
This was my first naive implementation for the currency formatter:
class NonActorFormatter: FormatterBuilding {
static let staticFormatter = buildFormatter()
func format(
money: Decimal,
currencySymbol: String
) -> String {
Self.staticFormatter.currencySymbol = currencySymbol
let result = Self.staticFormatter.string(from: NSDecimalNumber(decimal: money))!
return result
}
}
// Since we're going to build a formatter in each implementation
// let's reuse through a `protocol` `extension` pair
protocol FormatterBuilding {
static func buildFormatter() -> NumberFormatter
}
extension FormatterBuilding {
static func buildFormatter() -> NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
return formatter
}
}
Then, I would put it under a stress test using a unit test that would create a thousand iterations that would call this format
function from different threads, that would force concurrency issues to happen if not handled properly, called the Tread Torture Tests:
private func torture(
formatter: Formatting,
file: StaticString = #filePath,
line: UInt = #line
) {
let exp = expectation(description: "Wait until all are ran")
(0...lastIteration).forEach { iteration in
Task {
let currencySymbol = self.currencySymbol(for: iteration)
let amount = self.amount(for: iteration)
let money = Decimal(amount + iteration)
let expected = "\(currencySymbol)\u{00a0}\(amount + iteration),00"
let result = formatter.format(money: money, currencySymbol: currencySymbol)
Task { @MainActor in
XCTAssertEqual(
expected,
result,
"Your banking license got revoked because of a compliance issue",
file: file,
line: line
)
if iteration == self.lastIteration {
exp.fulfill()
}
}
}
}
wait(for: [exp])
}
If I would not run many Tasks in parallel the test wouldn't recreate a data race issue. All of these tests would pass if they were singular tests.
Predictably, this didn't hold up under pressure. Although Formatter
classes have been thread-safe since iOS 7 / macOS 10.9 (64-bit), as noted in the documentation, this doesn't guarantee that changes to the currencySymbol
property will consistently precede the string(from:)
method call in every scenario.
When the function is called from many different threads at roughly the same time, one thread can actually flip the currencySymbol
to a different one before we call string(from:)
in another thread. This results in an unexpected currency symbol about one out of ten times.
Who's going to explain this to the auditor?
A quick explanation of data races:
In our test we call the same function rapidly from different threads. In this example we show Thread 4 and Thread 11 going into the same function. Thread 4 calls it using the
€
as value forcurrencySymbol
, while Thread 11 uses$
:
- Thread 4 sets the
currencySymbol
to€
- Roughly around the same time, Thread 11 accesses the same function in parallel and sets the
currencySymbol
to$
, before Thread 4 has a chance to execute theformat
function- Thread 4 executes the
format
function, wrongly applying thecurrencySymbol
Thread 11 just set, which is$
instead of€
- Thread 11 also executes the
format
function, correctly applying the$
it just set
Let's try to fix this with an Actor-based implementation
According to the documentation, Actors are designed to prevent concurrency-related issues, such as data races, by ensuring all operations occur within the Actor's own context. This concept is akin to each Actor operating on a private DispatchQueue
. However, it's important to note that Actors are not strictly bound to a single thread to achieve this isolation.
So I wrote an Actor to prove how it would fix my concurrency-related issue:
actor ExtensionActorFormatter: Formatting {
static let staticFormatter = buildFormatter()
}
protocol Formatting: FormatterBuilding {
static var staticFormatter: NumberFormatter { get }
func format(
money: Decimal,
currencySymbol: String
) -> String
}
extension Formatting {
func format(
money: Decimal,
currencySymbol: String
) -> String {
Self.staticFormatter.currencySymbol = currencySymbol
let result = Self.staticFormatter.string(from: NSDecimalNumber(decimal: money))!
return result
}
}
To prevent that I would need to write the torture
helper-function over and over for every implementation I wanted to test, I wrapped everything in a protocol
/extension
pair as dictated by Protocol-Oriented Programming.
This is when I started experiencing some unexpected behavior I initially couldn't explain.
Protocol Implementations in Extensions are not Context Safe by Default
Instead of having a passing test by coralling all of the function calls into the same context, I got roughly the same amount of test errors as my non-Actor implementation.
Apparently, even when applied to Actors any protocol / extension
implementation will behave as if it was executed outside of the Actor's context. I've witnessed this behavior both on explicit Actors as well was classes that were decorated with a custom global Actor.
When running the tests and using print(Thread.current)
(note this is deprecated inside async contexts) inside of the function, it will print dozens of different thread numbers when it fails to isolate it's context within the Actor, but only handful when it is successfully isolated.
Initially, I assumed that Actors would be thread-safe in the classic sense that everything would happen on the same
DispatchQueue
, but Actors happily manage to retain context safety without forcing everything to one thread. It's an implementation you don't need to worry about, unless you are using a mix of Actors and classic thread-safe code. Actor isolated code will not happen on the same thread every time.
In my opinion either the protocol / extension
should automatically apply the Actor context of the Actor it extends, or throw a build error. Note that not even the SWIFT_STRICT_CONCURRENCY
flag was triggering any warning, as it would do for many other issues. Can you think of any reason why the compiler should allow this to happen? It doesn't make sense to me.
This is dangerous behavior as any context related issue you expect to be covered by an Actor all of a sudden becomes unchecked. Many concurrency issues only manifest themselves in production when large numbers of users trigger edge cases that you cannot trigger in regular unit tests, nor by manually testing. These are tough issues to debug, especially since you will believe the Actor-isolated code can't be the issue.
Is there a safe way to use Protocol Extensions in Actors?
While not enforced by the compiler, it's definitely possible to use protocol extensions in a safe manner with Actors. There are two techniques, one for Actors and one for implementations using a global custom Actor.
Actor implementation
The Actor implementation requires us to inherit from the Actor
protocol. Note that this code will compile and run without any warning, but will fail the test when the Actor
inheritance is removed from the protocol
definition!
protocol Formatting: Actor, FormatterBuilding {
static var staticFormatter: NumberFormatter { get }
func format(
money: Decimal,
currencySymbol: String
) -> String
}
extension Formatting {
func format(
money: Decimal,
currencySymbol: String
) -> String {
Self.staticFormatter.currencySymbol = currencySymbol
let result = Self.staticFormatter.string(from: NSDecimalNumber(decimal: money))!
return result
}
}
actor ExtensionActorFormatter: Formatting {
static let staticFormatter = buildFormatter()
}
Despite contradicting documentation at it's definition, using the AnyActor
protocol (as I initially tried to use) did not work:
/// The `AnyActor` marker protocol generalizes over all actor types, including
/// distributed ones. In practice, this protocol can be used to restrict
/// protocols, or generic parameters to only be usable with actors, which
/// provides the guarantee that calls may be safely made on instances of given
/// type without worrying about the thread-safety of it -- as they are
/// guaranteed to follow the actor-style isolation semantics.
Global Custom Actor implementation
The Global Custom Actor follows the same pattern, but it requires the @CustomActor
decorator instead of inheriting from the Actor
protocol:
@CustomActor
protocol CustomActorFormatting: FormatterBuilding {
static var staticFormatter: NumberFormatter { get }
func format(
money: Decimal,
currencySymbol: String
) -> String
}
extension CustomActorFormatting {
func format(
money: Decimal,
currencySymbol: String
) -> String {
Self.staticFormatter.currencySymbol = currencySymbol
let result = Self.staticFormatter.string(from: NSDecimalNumber(decimal: money))!
return result
}
}
@CustomActor
class CustomActorClassFormatter: CustomActorFormatting {
static let staticFormatter = buildFormatter()
}
When the @CustomActor
is removed from the CustomActorFormatting
definition, the code again will compile without warning and run, but fail the test.
You cannot use protocol Formatting: Actor
on @CustomActor
and vice versa. Specifically type-ing your protocol towards a particular Global Custom Actor takes away a lot of flexibility, which kind of negates the advantages you get from protocol extensions. If you specify Actor
in your protocol declaration, it works in an extension for all Actors.
Overview
After a lot has been said and shown about this subject, let me give you a handy table of what implementations are safe and what implementations aren't:
Implementation | Declaration | Protocol | Compiles | Thread-Safe |
---|---|---|---|---|
No Actor | class NonActorFormatter |
- | β | π« |
Actor | class ActorFormatter |
- | β | β |
Global Custom Actor | @CustomActor class CustomActorFormatter |
- | β | β |
Actor with extension | class ExtensionActorFormatter |
protocol Formatting |
β | π« |
Global Actor with extension | @CustomActor class ExtensionCustomActorFormatter: Formatting |
protocol Formatting |
β | π« |
Actor-specific extension | class FixedExtensionActorFormatter: Formatting |
protocol Formatting: Actor |
β | β |
Global Actor-specific extension | @CustomActor class FixedExtensionCustomActorFormatter: Formatting |
@CustomActor protocol Formatting |
β | β |
Actor using Global Actor extension | actor ErrorExtensionActorFormatter: Formatting |
@CustomActor protocol Formatting |
π« | - |
Global Actor using Actor extension | @CustomActor class ErrorExtensionCustomActorFormatter: Formatting |
protocol Formatting: Actor |
π« | - |
All of the examples that Compile are also consultable in the Threads Torture Testsβ’, including Unit Tests that prove or disprove they work.
Conclusions
While it took me quite a lot of time getting to the bottom of this, finding just a few threads on the swift.org forums about these issues and how to resolve them, I feel I now have a much better understanding about what are some of the pitfalls around Actors, protocols and extensions.
Sadly, Swift does not deliver (yet) upon the idea that a developer can have context-safe code with compile-time guarantees. While the Strict Concurrency flag helps a lot finding some more common concurrency issues together with existing compiler errors, I would not take the safety of Actors at face value if my business would depend on it.
I hope these edge cases will be handled in future versions of Xcode and the Swift compiler as the idea of having context-safe code without doing all of the thread management manually is extremely appealing.
I would leave you with the following advice:
- If you suspect possible issues in your code around concurrency, use unit tests like the Threads Torture Tests to prove it can break
- Once you managed to break it, you can apply a fix that will mitigate the issue and keep it under test
- Always ensure that you add the proper actor inheritance when creating
protocol / extension
pairs for Actor code - Be aware when you refactor existing code using
protocol / extension
pairs, you either need to create an extra implementation for the Actor or inline the extension class
Let me know what your experiences are with the new concurrency API's in general and Actors specifically when working on your applications.
The Future
I'm really curious about what Swift 6 will bring. We have been enjoying pretty stable updates in Swift since version 4 or so. But with SWIFT_STRICT_CONCURRENCY
I'm seeing so many new warnings (that are supposed to become errors in Swift 6) that I would have to put serious work into my older projects that are still heavily block and Result
-based. I hope this post is outdated by then as they patched this hole in their concurrency model.