`func == ` vs MainActor

Getting this concurrency error with equatable:

@MainActor final class C: Equatable {
    var value = [1:1]
    static func == (lhs: C, rhs: C) -> Bool {
        // Error: Main actor-isolated operator function '==' cannot be used to satisfy nonisolated requirement from protocol 'Equatable'
        // 1. Add 'nonisolated' to '==' to make this operator function not isolated to the actor
        // 2. Add '@preconcurrency' to the 'Equatable' conformance to defer isolation checking to run time
        lhs.value == rhs.value
    }
}

I guess option 2 is the way to go here? Why would I ever want using option 1? Is that for non global actors?

1 Like

Option 1 is for when you don't need to access any isolated state inside the actor to implement ==. For example, if you defined == in terms of the object identity of the class rather than its data:

@MainActor final class C: Equatable {
  nonisolated static func == (lhs: C, rhs: C) -> Bool { 
    lhs === rhs 
  }
}

#2 is the more dangerous option because it will crash at runtime if you ever invoke == while not on the main actor. And with Swift 6.2 there's an option #3 where you can use C: @MainActor Equatable and then you will only be allowed to invoke == on C if you have first proven you are in a main actor context.

(As an aside, it's not correct to implement == for a class with mutable state using its data. Object identity is pretty much the only correct implementation for == on classes.)

8 Likes

Thank you!

Could you give an example how that could happen? The class above is not sendable, so I assume it could only be created, used (and destroyed) on the main actor, is that not the case?

BTW, I'm getting the same error with a struct instead of a class.

It is sendable since all actors are implicitly sendable.

The problem arises when you pass the @MainActor value through a function that only requires Equatable. That loses the main actor isolation, and so then it will be possible to invoke == in a non-main actor context. Here is an example:

@MainActor final class C: @preconcurrency Equatable {
  var value = 0
  static func == (lhs: C, rhs: C) -> Bool {
    lhs.value == rhs.value
  }
}
@Test func foo() {
  Task.detached {
    let lhs = C()
    bar(lhs)
  }
}
func bar(_ value: some Equatable) {
  _ = value == value  // đź’Ą
}

Yeah that is to be expected.

1 Like

Thank you!

Hmm. BTW, if I add an otherwise do nothing init() {} - that acts as a "marker" and gives the "desired" behaviour - can no longer create that class outside of MainActor.

That is also to be expected. The implicitly synthesized initializer is not isolated, but as soon as you add an explicit one, it is isolated to the @MainActor. That also does not change the results of the code I shared above. It will still lead to a crash because it is possible to erase the "main actor"-ness by passing the value through some Equatable.

1 Like

This looks like an easy way to cheat the concurrency checker. Is it supposed to be like this when concurrency is fully matured?

But it's only possible to cheat if you do @preconcurrency Equatable, and so at that point it shouldn't be too surprising. If you do @MainActor Equatable, then you aren't able to cheat, and if you do regular Equatable, then it means your == was implemented without accessing any isolated state and so that is also ok.

3 Likes

I mean that I could merely send a non sendable... Simpler example:

protocol SomeProtocol {}

@MainActor struct S: SomeProtocol, CustomStringConvertible { // not sendable!
    init() {} // to make this init isolated
    public nonisolated var description: String {
        dispatchPrecondition(condition: .onQueue(.main))
        return ""
    }
}

@MainActor func foo() {
    let s = S()
    let e: some SomeProtocol = s
    
    Task.detached {
        let this = e as! S
        // job done, sent it
        print(this) // đź’Ą
    }
}

foo()

I'm sorry, I don't understand what you are trying to show with that code snippet. The S type is sendable because it is @MainActor:

func isSendable(_: some Sendable) {}
@MainActor func checkSendable() {
  isSendable(S())  // 👍
}

And further, because description is nonisolated, it can be called from any thread. There should be no expectation that it would be called on the main thread. The code is working as I would expect, including the crash.

Perhaps it was confusing when I shared this code snippet that implemented a nonisolated func ==:

@MainActor final class C: Equatable {
  nonisolated static func == (lhs: C, rhs: C) -> Bool { 
    lhs === rhs 
  }
}

This function can be called from any thread, and that is ok because the object identity of a class/actor does not require isolation. It can be accessed from anywhere.

1 Like

Say I want to construct a thing (class/struct, doesn't matter) that could only be created, used and destroyed on the main actor, and for that it would be impossible to sneak it somehow out of the main actor, e.g. using the trick the above example illustrates.

That is possible. That is what @MainActor does to a type, though you do need to explicitly use isolated deinit to have it destroyed on the main actor.

But your example doesn't sneak anything anywhere. You specifically implemented a nonisolated property, and so it is allowed to be called from any thread. And so it is not surprising that it was called on a non-main thread and then crashed. If you did not mark that property as nonisolated, then you wouldn't have been able to invoke it on a non-main thread, but at the cost of making your conformance @MainActor CustomStringConvertible.

1 Like

I see. So while I could pass the value via that type cast hack I won't be able doing anything with it unless it has non isolated members. Neat, no loophole.

Off Topic Question

Do you mind explaining why?

I think the easiest way to explain this is to illustrate that it can be used to break the promises of Set:

class Foo: Hashable {
  var x: Int

  init(x: Int) { self.x = x }

  static func == (lhs: Foo, rhs: Foo) -> Bool { lhs.x == rhs.x }
  func hash(into hasher: inout Hasher) { hasher.combine(x) }
}

let a = Foo(x: 1)
let b = Foo(x: 2)
let s: Set<Foo> = [a, b] // both are added, since they're not equal
b.x = 1 // set now contains { x: 1 } twice!
3 Likes

Global-actor-isolated types (@MainActor struct Foo {}) are always Sendable. This is because Swift knows that the information inside the type can only be accessed on a particular global actor. It’s ok to hold onto a value of that type inside any actor because that does not give you the ability to actually do anything with it synchronously. If you want a type that can only be created and used on the main actor, just not making that type Sendable is a great way to do that. It doesn’t guarantee that you are never, for example, transferring ownership of the value from the background to the main actor, but that is often something that is useful to do at the beginning of a value’s lifetime.

New in Swift 6.2, you can now alternatively mark your conformance to a protocol like Equatable as isolated to the main actor. This will cause the compiler to ensure your type’s conformance to Equatable is never used off the main actor even though the type is Sendable due to being actor isolated.

1 Like

BTW, is it not the case for protocols?

I have to mark a protocol Sendable in addition to marking it with @MainActor, otherwise getting an error:

@MainActor protocol P // : Sendable fixes the error
{ /* ... */ }

struct S: Sendable {
    let x: P //  "Error: Stored property 'x' of 'Sendable'-conforming struct 'S' has non-sendable type 'any P'"
}
1 Like

You need to do @MainActor struct S. You can't have a non-main actor type conform to a main actor protocol.

Sorry, was typing in the browser, of course I had that.
I am getting the error when "the protocol" is not marked Sendable (even if it is marked with MainActor)

oops, you are right... the struct was just "Sendable".

I'm a bit confused thought:

You can't have a non-main actor type conform to a main actor protocol.

I am not conforming the "S" itself to MainActor.... it's just one of it fields!

Oh sorry, I misread. I thought you were conforming S to P. In this situation I am not sure. I would assume P is implicitly Sendable, but it seems not.