swiftmultithreadingconcurrencythread-safetyswift6

Swift 6 Migration: Handling Weak Delegates in a Sendable Context


I’m trying to wrap my head around Swift 6’s concurrency model while migrating some code that uses a weak delegate, and I want to minimize changes. Suppose I have a delegate defined like this:

protocol ServiceDelegate: AnyObject {
    func doSomething()
}

And a service interface:

protocol ServiceProvidable {
    var delegate: ServiceDelegate? { get set }
    func load()
}

The implementation looks something like this:

final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
    weak var delegate: ServiceDelegate?

    func load() {
        delegate?.doSomething()
    }
}

With Swift 6 enabled, I get the following error:

Stored property 'delegate' of 'Sendable'-conforming class 'ServiceProvider' is mutable

Possible Solutions

As I see it, I have a few options, but each comes with its own trade-offs:

  1. Isolate ServiceProvidable to an actor (e.g., @MainActor): This approach would involve bigger refactoring for other code

  2. Create a thread-safe wrapper around the delegate: I could implement a wrapper that ensures thread-safe access to the delegate and then mark the wrapper as @unchecked Sendable. Here’s what this could look like:

protocol ServiceDelegate: AnyObject {
    func doSomething()
}

protocol SafeServiceDelegateProvidable: Sendable {
    var delegate: ServiceDelegate? { get set }
}

protocol ServiceProvidable {
    var safeDelegate: SafeServiceDelegateProvidable { get }
    func load()
}

final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
    let safeDelegate: SafeServiceDelegateProvidable = SafeServiceDelegateProvider()

    func load() {
        safeDelegate.delegate?.doSomething()
    }
}

final class SafeServiceDelegateProvider: SafeServiceDelegateProvidable, @unchecked Sendable {
    private var queue = DispatchQueue(label: "queue")
    private weak var _delegate: ServiceDelegate?

    var delegate: ServiceDelegate? {
        get {
            queue.sync { _delegate }
        }
        set {
            queue.sync { _delegate = newValue }
        }
    }
}

However, there’s a complication: safeDelegate needs to be updated (e.g. service.safeDelegate.delegate = self) after initialization, and since it can’t be a let constant, changing it to a var results in error: Stored property 'safeDelegate' of 'Sendable'-conforming class 'ServiceProvider' is mutable

Question

What would be the easiest or most effective solution to this problem? Are there better ways to handle weak delegates in Swift 6, given the concurrency changes?


Solution

  • safeDelegate.delegate can be set if the compiler can determine that safeDelegate has reference semantics. You can constraint the protocol to AnyObject:

    protocol SafeServiceDelegateProvidable: AnyObject, Sendable { .. }
    

    Or, you can change the type of safeDelegate to the concrete class SafeServiceDelegateProvider instead. I don't see how SafeServiceDelegateProvidable is useful here.

    protocol ServiceProvidable {
        var safeDelegate: SafeServiceDelegateProvider { get }
        func load()
    }
    
    final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
        let safeDelegate = SafeServiceDelegateProvider()
    
        func load() {
            safeDelegate.delegate?.doSomething()
        }
    }
    

    As Rob says in the comments, to actually make SafeServiceDelegateProvider fulfil the requirements of Sendable, ServiceDelegate needs to be Sendable too.

    protocol ServiceDelegate: AnyObject, Sendable {
        func doSomething()
    }
    

    Otherwise, SafeServiceDelegateProvider is still not safe to send across threads. Consider:

    class NotSendable: ServiceDelegate {
        var someProperty = 0
        func doSomething() {
            someProperty += 1
        }
    }
    
    @MainActor
    class Foo {
        let notSendable = NotSendable()
    
        func doSomething() {
            let provider = SafeServiceDelegateProvider()
            provider.delegate = notSendable
            Task.detached {
                // here I sent 'provider' to some other thread
                // doSomething is going to increment someProperty
                // this is going to race with the line 'notSendable.someProperty += 1' just below
                provider.delegate?.doSomething()
            }
            notSendable.someProperty += 1
        }
    }