A Swift concurrency threading surprise with protocol-typed object

Bear with me. Start with this example, which works as expected:

protocol MyProtocol {
    func doSomething()
}

class A {
    func test() {
        Task {
            let b = B()
            await b.doSomething()
        }
    }
}

class B: MyProtocol {
    @MainActor func doSomething() {
        print(Thread.isMainThread)
    }
}

// and then, in main thread code, say `A().test()`

When we print Thread.isMainThread, sure enough, we're on the main thread. Now watch this little move (I have starred the changed line):

protocol MyProtocol {
    func doSomething()
}

class A {
    func test() {
        Task {
            let b: MyProtocol = B() // *
            await b.doSomething()
        }
    }
}

class B: MyProtocol {
    @MainActor func doSomething() {
        print(Thread.isMainThread)
    }
}

// and then, in main thread code, say `A().test()`

The outcome is that the Thread.isMainThread call reveals we are not on the main thread now! That seems like a very big change just because b is typed as a MyProtocol rather than as a B. The "solution" is to type the protocol's declaration of this method as MainActor:

protocol MyProtocol {
    @MainActor func doSomething()
}

class A {
    func test() {
        Task {
            let b: MyProtocol = B()
            await b.doSomething()
        }
    }
}

class B: MyProtocol {
    func doSomething() {
        print(Thread.isMainThread)
    }
}

// and then, in main thread code, say `A().test()`

As you can see, I've actually moved the MainActor marking from the implementation of the function to the protocol declaration of the function. And now Thread.isMainThread is on the main thread again.

I'm not sure what my question is. Our team got caught out by this when something that was supposed to run on the main actor didn't. It seems like a kind of trap: in the second example, you naturally do expect the @MainActor designation to be obeyed, no matter how a reference to an instance of B happens to be typed. I can imagine that this might be

  • a bug
  • a design decision, where you really do expect developers to know about this rule
  • a sort of way-station edge case on the road to full concurrency in Swift 6
7 Likes

I believe the bug here is that @MainActor func doSomething() should not be allowed to fulfil MyProtocol’s requirement. I’m not sure when this will be fixed.

5 Likes

I don't think it is a bug but it's intended behaviour. There was no actor isolation in the protocol declaration so when you initialized the variable to type MyProtocol, thereby this made the compiler see the method as

(B) -> () -> () // this is B.doSomething

instead of this which you had in mind

(B) -> @MainActor () -> () // this is @MainActor B.doSomething
1 Like

Right, but the compiler shouldn't have allowed that. Effectively the @MainActor attribute is being erroneously ignored by the compiler. It should be issuing an error to the effect that the MyProtocol has no valid conformance implemented in B because @MainActor func doSomething() is not applicable.

Compiling the example above with -strict-concurrency=complete produces the following warning:

test.swift:17:21: warning: main actor-isolated instance method 'doSomething()' cannot be used to satisfy nonisolated protocol requirement
    @MainActor func doSomething() {
                    ^
test.swift:4:10: note: mark the protocol requirement 'doSomething()' 'async' to allow actor-isolated conformances
    func doSomething()

If you want to be sure you have the most-protective concurrency warnings you should enable that option, though this may result in warnings on some patterns that you as the developer 'know' are safe but which the Swift compiler cannot (yet) recognize.

3 Likes

Ah, excellent - thanks for checking that.

This seems to indicate that indeed this example code is invalid, it's just not correctly recognised as such in Swift 5 (turning on strict concurrency checking is essentially opting in to an intrinsic behaviour of Swift 6, which is focused on closing gaps in the compiler's safety checks).

It's a pity that this isn't also a warning / error in Swift 5 mode - it's hard for me to imagine a scenario in which this is intended behaviour. But then that's true of most of the Swift 6 mode concurrency checks, and for whatever reason it was decided those fixes wouldn't be applied retroactively.

1 Like

I agree! I recently made this diagnostic more discoverable in Swift 5 mode so that it’s not buried in complete concurrency checking [Concurrency] Make actor-isolated witness diagnostics more discoverable. by hborla · Pull Request #70153 · apple/swift · GitHub

6 Likes