• GitHub
  • Bluesky
  • Mastodon
  • Twitter
  • LinkedIn
  • Stack Overflow
  • RSS
  • Exploring Actors and Protocol Extensions

    Can Swift Keep Its Compile-Safe Thread Management Promise?

    Author:

    A box representing an Actor, with a bundle of literal threads sticking out of it

    Introduction

    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:

    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:

    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.

    That didn't work out well, we have 53 errors

    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 for currencySymbol, while Thread 11 uses $:

    1. Thread 4 sets the currencySymbol to
    2. 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 the format function
    3. Thread 4 executes the format function, wrongly applying the currencySymbol Thread 11 just set, which is $ instead of
    4. Thread 11 also executes the format function, correctly applying the $ it just set

    A code example showing how two threads access the same function in parallel

    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.

    That didn't solve our issue either, another 32 errors

    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:

    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.

    An image of the author
    is an Apple platforms developer that likes to write and discuss about technical subjects.

    Do you:

    Drop me a line!

    Contact Me