Actor Conformance to Non-Isolated Protocols
Problem
When working with Swift concurrency, you might need to conform actor-isolated types to protocols that aren’t isolated.
The problem is that synchronous protocols are called freely from outside the actor. However, the actor is expected to prevent parallel execution, which requires calls from the outside to be asynchronous. This is so they can be suspended and wait for exclusive access. So the protocol should be instead:
But what if you are not at liberty to change the protocol?
Solution: Add nonisolated to the protocol implementation
Marking the property as nonisolated
effectively lifts it out of the actor’s domain, allowing synchronous access without runtime suspension. This is safe only if the returned values are Sendable
and don’t introduce data races. Such is the case with String
, a Sendable
value type.
As explained in SE-0434 Usability of global-actor-isolated types, when accessing a Sendable
property in a value type, any potential data race would have to be on the memory storing that property. Swift's concurrency system ensures that each thread gets exclusive access to a value instance before accessing one of its properties. Then the thread gets its own copy of the property, so effectively there is no data shared, which eliminates the possibility of a data race.
However, this won’t work with non Sendable
reference types.
Solution: Use MainActor.assumeIsolated
Problem: non-actor isolated reference types are not safe to use from nonisolated contexts.
This message says the compiler can’t guarantee safety. To which we can reply assume this will always be isolated to the main thread:
The compiler will trust but verify, enforcing the main-thread requirement at runtime with a _dispatch_assert_queue_fail
assertion. If we violate this promise Xcode stops at a frame where _dispatch_log
presents this message.
I tried unsuccessfully to output the message to the Xcode console to avoid opening the frame. Apparently you have to manually click there to see it.
Displaying the error
I wanted to show the error of this crashing example.
It’s not on stderr because libdispatch uses Unified Logging. Can’t look into the OSLogStore
from a signal handler either as they only allow brief synchronous work.
The following works if you execute the program from the terminal.
You can even debug from the terminal and install it in a breakpoint.
log show
works properly, while log stream
misses messages and shows the wrong timestamp. Probably related to its particular implementation and the process exiting abruptly.
I tried adding the breakpoint in Xcode but it didn’t show me the message.
Console.app doesn’t presents the message either. Instead you’ll find a line pointing to a file that contains some JSON with the stack trace.
So that’s it, no idea how to show this in the Xcode console. You have to manually read it from the decompiled assembler.
Solution: Use a Data Transfer Object
Instead of directly conforming our actor-isolated Person
to Codable
, we can create an intermediate non-isolated type (a DTO) that handles the serialization. The DTO can safely implement Codable
since it’s not actor-isolated, while our Person
class maintains its actor isolation guarantees. Less ergonomic than a Codable Person
but safer since it can be used with non main actors.
Conclusion
If these solutions feel unsatisfying, you’re not alone. Conformance to non-isolated protocols is one of the pain points mentioned in Improving the approachability of data-race safety.
Writing a single-threaded program is surprisingly difficult under the Swift 6 language mode.
- global and static variables,
- conformances of main-actor-isolated types to non-isolated protocols,
- class deinitializers,
- overrides of non-isolated superclass methods in a main-actor-isolated subclass, and
- calls to main-actor-isolated functions from the platform SDK.
Do they sound familiar?
Class deinitializers are solved in Swift 6.1 SE-0371 Isolated synchronous deinit. The rest are problematic and there is no language-level solution yet.
Invalidating observers from main
This is implemented in Swift 6.1:
The isolated
keyword for deinit
will inherit the actor isolation of the containing class (in this case @MainActor
), ensuring the cleanup happens on the main thread.
Unfortunately, as of december 2024 the compiler in the toolchain 6.1 crashes emitting silgen for isolated deinit
. Here is a workaround:
The TaskHolder
's deinit is implicitly isolated to @MainActor
since it's created in a main actor context. When LoadingViewModel
is deallocated, deinit
will be called on the main thread, safely cancelling the task.
The vision for Global and static is to opt-in a whole module to assume single-thread mode. An alternative is to make them @MainActor
(similar thing), or Sendable
, possibly @unchecked
with internal synchronization.
The vision for conformances of main-actor types to non-isolated protocols is for the protocol to inherit the actor’s isolation domain. This means that even if CustomStringConvertible
has no isolation requirements itself, implementing it from a @MainActor
type would restrict access to protocol methods (like description
) to the main actor only.
While these are current directions for the future, I wanted to share the workarounds that I'm using today.