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. This is the most common case:
protocol CustomStringConvertible {
var description: String // synchronous access
}
@MainActor
struct Person: CustomStringConvertible {
let name: String
// đź’Ą Main actor-isolated property 'description' cannot
// be used to satisfy nonisolated protocol requirement
var description: String { name }
}
A key realization to understand this is that a protocol defines not only methods and properties but also the isolation context, or lack of in this case, where it is called.
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:
protocol IsolableCustomStringConvertible {
var description: String { get async } // asynchronous access
}
But what if you are not at liberty to change the protocol?
Solution: Add nonisolated to the protocol implementation
@MainActor
final class Person: CustomStringConvertible {
let name: String
init(name: String) {
self.name = name
}
nonisolated var description: String {
name
}
}
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.
// same example, but replaced String with this reference type
class PersonName {
let value: String
init(value: String) {
self.value = value
}
}
@MainActor
final class Person: CustomStringConvertible {
let name: PersonName
init(name: String) {
self.name = PersonName(value: name)
}
nonisolated var description: String {
// đź’Ą Main actor-isolated property 'name' can not
// be referenced from a nonisolated context
name.value
}
}
This message says the compiler can’t guarantee safety. To which we can reply assume this will always be isolated to the main thread:
nonisolated var description: String {
MainActor.assumeIsolated {
name.value
}
}
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.
"BUG IN CLIENT OF LIBDISPATCH: Assertion failed: "
"%sBlock was %sexpected to execute on queue [%s (%p)]"
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.
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.
struct PersonDTO: Codable {
let name: String
}
extension Person {
func encode() throws -> Data {
let dto = PersonDTO(name: name.value)
let encoder = JSONEncoder()
return try encoder.encode(dto)
}
static func decode(from data: Data) throws -> Person {
let decoder = JSONDecoder()
let dto = try decoder.decode(PersonDTO.self, from: data)
return Person(name: dto.name)
}
}
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.
The vision for Global and static variables 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.