Using @MainActor to ensure execution on the main thread

Hi,

I'm right now trying to build up a mental model of the new concurrency mechanisms, namely async await, actors and the MainActor. In the example code below I've annotated a class with @MainActor that bridges some Combine code over to an instance variable. In my real app this is a @Published variable that is directly accessed in my SwiftUI views (a prime example to annotate this class with @MainActor, right?).

@MainActor
class Test {
    var state: Bool = false {
        didSet {
            print(Thread.isMainThread) // false
        }
    }
    var subscriptions: Set<AnyCancellable> = []
    
    init() {
        Just(true)
            .receive(on: DispatchQueue.global(qos: .background))
            .sink {[weak self] _ in
                self?.updateState()
            }.store(in: &subscriptions)
    }
    
    func updateState() {
        print(Thread.isMainThread)  // false
        state.toggle()
    }
}

I would have expected that this would a cause a combination of:

  1. A compiler error that updateState is accessed outside of a concurrent context (because in the sink block I'm outside of the classes scope, so I would expect that the method becomes async to synchronise access to the main dispatch queue/actor)
  2. A general error by the compile time concurrency checks

Nothing of this is happening, though, I'm just getting a respective warning by the thread sanitiser during runtime:

Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

I assumed that a @MainActor annotated class would basically be an actor when it comes to interacting with its methods and properties. I guess that mental model is wrong. Would appreciate if you help me to clear this up and advise me on the appropriate pattern to let the compiler help me ensure safe code in these cases.

I've also tried to make this class an actor itself. But (a) is that probably not what I want because then it still isn't ensured to publish on the main thread and (b) did I run into other issues with using a weak self reference (which probably should be a separate post).

Thanks in advance!

Environment:

  • macOS 13.0 Beta (22A5365d)
  • Xcode 14.0 (14A309)
  • Swift version 5.7 (swiftlang-5.7.0.127.4 clang-1400.0.29.50)
  • SWIFT_STRICT_CONCURRENCY = complete

@MainActor works when using swift concurrency, not dispatch queues.

The issue is that the closure parameter of sink should be inferred to be @Sendable but it is not. Thus, because it's not @Sendable, the closure is instead being granted @MainActor isolation (inherited from its context). Not sure why there isn't then a warning about losing that actor isolation when passing it to sink, but the warning I expected does show up if I mark it explicitly as @MainActor like so:

test.swift:17:19: warning: converting function value of type '@MainActor (Bool) -> Void' to '(Bool) -> Void' loses global actor 'MainActor'
            .sink {@MainActor [weak self] _ in
                  ^

Anyway. If you add @Sendable to the closure instead of @MainActor, then you'll get the diagnostics I would have expected:

test.swift:18:23: error: call to main actor-isolated instance method 'updateState()' in a synchronous nonisolated context
                self?.updateState()
                      ^
test.swift:22:10: note: calls to instance method 'updateState()' from outside of its actor context are implicitly asynchronous
    func updateState() {
         ^
4 Likes

This is basically all you need to know. The entire actor-isolation is for Swift concurrency and ensures that code isolated to a certain actor is not accessed from another (or non-isolated context). If you use dispatch queues (or locks or any other form of synchronization) the compiler will/can not help you. Since your example wants to bridge some Combine code you should just receive the changes on the main queue.

If you want to handle the main thread hop in the sink you could use

Task { @MainActor [weak self] in
	self?.updateState()
}

or

Task {
	await MainActor.run { [weak self] in
		self?.updateState()
	}
}
1 Like

Thank you all for the replies and sorry for the late response!

@MainActor works when using swift concurrency, not dispatch queues.

I understand that the compiler cannot reason about my code using dispatch queues. But I would expect that when leaving or entering the Swift concurrency "world" unsafely, I get warned about that fact. In particular when using Combine it is easy to forget that queue switches can happen. If this is a gap that is not (or cannot be) covered that would be a real bummer to the goal of Swift concurrency from my perspective.


The issue is that the closure parameter of sink should be inferred to be @Sendable but it is not. Thus, because it's not @Sendable, the closure is instead being granted @MainActor isolation (inherited from its context). Not sure why there isn't then a warning about losing that actor isolation when passing it to sink.

Got it. Can you tell if this is a Combine or a Swift issue? Is Swift supposed to automatically infer @Sendable in this case since it cannot ensure that it is executed in the same context again? Should Combine explicitly add the annotation?

Does it make sense to write a bug report for this or is anyone of you aware if this is intentional somehow (e.g. to avoid excessive warnings during the transition)?

Anyway. If you add @Sendable to the closure instead of @MainActor , then you'll get the diagnostics I would have expected:

Great! Happy that this matches my mental model :slight_smile:

Got a follow up question on this, though. The following code fails with the inline error:

.sink {@Sendable [weak self] _ in
    Task {
        // error: Reference to captured var 'self' in concurrently-executing code
        await self?.updateState() 
    }
}

By unwrapping self, though, I'm able to use it in the Task:

.sink {@Sendable [weak self] _ in
    guard let self else { return } // <----
    Task {
        await self.updateState()
    }
}

That solution is fine (it avoids a retain cycle with the Combine subscription) but I'm wondering why the explicit unwrapping is needed. My naive assumption would be that Optional<Wrapped> becomes Sendable when Wrapped is. And that the @MainActor annotation implicitly makes my type Sendable (explicitly marking the type Sendable doesn't make a difference either). Is this even the problem? Do I miss something here?

The other part to that is the error message. It doesn't really help to understand the underlying issue. Do you agree?


This is basically all you need to know. The entire actor-isolation is for Swift concurrency and ensures that code isolated to a certain actor is not accessed from another (or non-isolated context)

Exactly the "(or non-isolated context)" part is happening in my example, though, isn't it?

If you want to handle the main thread hop in the sink you could use

Task { @MainActor [weak self] in
	self?.updateState()
}

That's neat. I guess it achieves the same as my code above (with the guard let). With your code the whole block is executed in the context of the MainActor and with my code above only the updateState() call. Correct? I guess no benefits for one or the other?


Thanks again everyone! This is helping me ton :)

1 Like