Problem

When working with Swift concurrency, you might need to conform actor-isolated types to protocols that aren’t isolated.

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 }
}

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.

Displaying the error

I wanted to show the error of this crashing example.

import Foundation

@MainActor
final class Person: CustomStringConvertible {
    let name: PersonName
    init(name: String) {
        self.name = PersonName(value: name)
    }
    nonisolated var description: String {
        MainActor.assumeIsolated {
            name.value
        }
    }
}

class PersonName {
    let value: String
    init(value: String) {
        self.value = value
    }
}

await MainActor.run {
    let person = Person(name: "John Doe")
    
    // access from background
    Task.detached {
        // at next line: Terminated due to signal: TRACE/BPT TRAP (5)
        let description = person.description
        print("Description from background thread: \(description)")
    }
    
    RunLoop.current.run(until: Date(timeIntervalSinceNow: 1.0))
}

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.

log show --last 1m --predicate 'process == "MyProcess"'

You can even debug from the terminal and install it in a breakpoint.

lldb MyProcess
b _dispatch_assert_queue_fail
breakpoint command add
platform shell "/bin/sh" "-c" "sleep 1; /usr/bin/log show --last 2s --predicate 'process == \"MyProcess\"'"
DONE
run

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.

log stream --predicate 'process == "MyProcess"'
17:34:19.352391+0100 MyProcess: (libsystem_info.dylib) Retrieve User by ID

log show --last 1m --predicate 'process == "MyProcess"'
17:34:19.351508+0100 MyProcess: (libsystem_info.dylib) Retrieve User by ID
17:34:19.354057+0100 MyProcess: [com.apple.libsystem.libdispatch:] BUG IN CLIENT OF LIBDISPATCH: Assertion failed: Block was expected to execute on queue [com.apple.main-thread (0x200fbb3c0)]

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.

default	20:42:22.801863+0100	ReportCrash	recordCrashEvent; 
isBeta 0, log: '/Users/jano/Library/Logs/DiagnosticReports/
MyProcess-2024-12-16-204222.ips'

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.

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.

Invalidating observers from main

This is implemented in Swift 6.1:

@MainActor
class MyViewController: UIViewController {
    private var observer: NSObjectProtocol?

    init() {
        super.init(nibName: nil, bundle: nil)
        observer = NotificationCenter.default.addObserver(forName: ...
    }

    isolated deinit { // <-- add isolated here
        if let observer {
            NotificationCenter.default.removeObserver(observer)
        }
    }
}

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:

// non isolated
final class TaskHolder {
    var task: Task<Void, Never>?

    deinit {
        task?.cancel()
    }
}

// isolated
@MainActor
final class Foo {
    // var task: Task<Void, Never>?
    let taskHolder = TaskHolder()
    ...
}
    

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.