@MainActor not called on main, no warning?

I've got a method that updates a @Published property that's consumed by SwiftUI. The method is decorated with @MainActor, and called in two different places inside a Task {} block. One of those is inside a Timer callback, which is called on the main thread, but the task inside is executed off the main thread, and I get runtime errors about publishing off the main thread.

The other is called in response to a SwiftUI button click, and the Task body seems to execute on the main thread (no runtime warnings).

If I decorate the timer Task with @MainActor, all is well (e.g. Task { @MainActor in … }.

I do not get warnings or errors at compile time (Xcode, 15b6, macOS 13.5). I'm supposed to get an error, right? Should I report this as a bug to Apple?

1 Like

You'll need to be more specific or provide more code context? Which code is called where? In the Task example you provide, the synchronous code inside the @MainActor closure should start on the main actor. If it's not, that's probably a bug. However, if that synchronous work then calls into other async systems like Combine or Dispatch, there are no guarantees, other the the cross isolation mutation checking you get by default. Also, have you enabled strict concurrency checking?

3 Likes

Now I have, and I get many more warnings, making it clear I need to be more careful with my code. I got around the previous issue by removing the @MainActor decoration on the method I was calling, and wrapping changes to @Published properties inside it in MainActor.run{}. I hope that's the right way to do that.

But it brings up a different issue. I have a class called Checker that makes a couple of network requests. One is to check the result of a REST API, the other is done to authenticate the first if it fails. There is shared state between the two calls, an auth token. I use a shared instance of that class, and the compiler warned me about that. So I made it an actor, and now it gets warnings within itself because the calls modify a @Published property checking on the main thread. I tried applying nonisolated to that, but that’s not allowed. I'm not sure how to solve that particular problem, but I want my UI to throw up a spinner while a network operation is in progress. I guess I can put that state outside the Checker.

Actually, while that works for this application, I don't think it would work for my typical REST API wrappers. I have a bit of shared state (usually auth tokens), but then I want individual methods to be callable concurrently. The auth state is rarely changed but frequently read. So making an API wrapper an actor is probably not what I want, right? All accesses are serialized, right? I need to think this through more.

Back to the original question, which might boil down to, how does Task decide what thread to run on? I know it inherits some state from the context in which it’s invoked, but it’s not always clear to me how that affects the actual thread used. If a Timer I set up fires, its block seems to be called on the main thread. If I invoke a Task {} block within it, that is not, unless I decorate that block with @MainActor in. But if I invoke a Task block from a SwiftUI Button action (e.g. Button(action: { Task { do something } }), its block is always invoked on the main thread (as far as I have observed, anyway. Maybe that’s just chance?). I don’t actually want this behavior, I think, because it’s usually invoking a long-running task (like a network request).

It's generally not a good idea to combine concurrency constructs like actors and non-concurrency APIs like Combine unless you're explicitly bridging between using one of the provided bridges. Unless properly marked up, the compiler can't track the safety of the other concurrency API. Even when properly marked up (which I don't believe Combine is in the first place), Swift concurrency can't express some of the patterns already used, like the fact that executing mutations on the same DispatchQueue or within a locking context are safe.

More specifically, the proper way to ensure subscriptions are received or values emitted on particular queues in Combine is to use the subscribe(on:) or receive(on:) methods on Publisher. However, since those methods don't (or perhaps can't) express their safety to Swift concurrency, they can't actually fix the warnings or errors you see. This is why I say combing the types of concurrency API isn't a great idea. And why you're seeing so many issues with @Published properties.

Moving to Swift concurrency, the fundamental rule you need to remember about the question "where is this called?" is that, unlike Dispatch or Combine, in Swift concurrency the callee, not the caller, determines where it is run. And you must remember that this rule only applies to calls you're required to await. This is a bit complicated by the intersection of hidden attributes like @_inheritsActorContext and concurrency features like actor captures in closures. For instance, let's take Task { }. It is defined in the standard library like this:

@discardableResult
@_alwaysEmitIntoClient
public init(
  priority: TaskPriority? = nil,
  @_inheritActorContext 
  @_implicitSelfCapture 
  operation: __owned @Sendable @escaping () async -> Success
)

For this example there are two main things to look at: the use of @_inheritActorContext and the async nature of the operation closure itself.

To start with the second point, the fact that the closure is async means it controls where the sync code it contains is executed (remember that any async calls that are awaited determine their own executing context). How does it do that? By being marked with a particular actor (isolation) context, such as using @MainActor, or, in this case, by using @_inheritActorContext, which allows the closure to inherit the actor isolation from the calling context. For example, the Task will execute in the same way in both these examples:

final class Printer {
  @MainActor
  func print() {
    Task {
      print("printing")
    }
  }
}
@MainActor
final class Printer {
  func print() {
    Task {
      print("printing")
    }
  }
}

In both of these examples print, being a synchronous API, executes on the main actor. This is due to the actor isolation inheritance enabled by the attribute. If there is no actor isolation it can inherit, it executes on the default executor, which, as its name implies, is the default context for any async work that doesn't have an isolation context provided. An actor context can be provided or overridden by capturing one into the closure itself (this only works with global actors), such as Task { @MainActor in }. This provides effectively the execution as if you had a surround @MainActor context in the first place.

As for SwiftUI's behavior here, that's due to how the View protocol defines the body property: @ViewBuilder @MainActor var body: Self.Body { get }. As you can see, this provides an isolation context to body which ensures it's always accessed and executes on the main actor.

Now, ultimately your general problem is that you're trying to use multiple async patterns without explicitly mapping between them to ensure their safety constructs are properly used. Without seeing your code it's hard to tell exactly what you'd need to do, but my general recommendation would be stay within Swift concurrency until you're ready to break out, or vice versa. Do not use @Published within actors, or if you want @Published, don't use an actor (you'd have to provide thread safety manually in that case, which @Published already does for the property itself). For instance, provide only specific holes in an actor to connect to Combine using the nonisolated keyword.

actor Checker {
  nonisolated
  func somePublisher() -> some Publisher<String, Error> {
    Future { promise in
      Task {
        await someInternalState()
        promise(.success(value))
      }
    }
  }
}

nonisolated lets you inform the compiler the method will execute outside the actor's context while guaranteeing safety by requiring to await calls back into the actor. This is what I mean by "explicitly bridging". If you provide examples we can help find safer ways to express what you want to do.

6 Likes

Shouldn’t marking the @Published properties as @MainActor be enough to prevent this?

Honestly I'm not sure how actor attributes and property wrappers are supposed to interact or whether their intended interaction is implemented, but even if the actor attribute properly constrains the underlying property access to the main actor, that doesn't guarantee anything about other Combine interactions with it or how the concurrency system views Combine's APIs. I'd have to see more detailed code examples to determine or speculate what was going wrong in the first post.

2 Likes

My understanding is, if a property is marked as main actor, it must be mutated/accessed from the main actor. Or awaited if off of the main actor

Whereas if a function is marked as main actor, it simply must be called from the main actor. What it does inside is up to it. As you can create detached tasks or call other async functions.

So I think for the case of the OP, he probably just needs to mark his published property as main actor, and he will start seeing error messages from places that aren’t main actor isolated.

1 Like

Unfortunately, this is a fact of life when using SwiftUI in an app that has a network component (as most do these days).

My point was, while, yes, you can directly connect async paradigms, doing so may not always be correct. For example, as mentioned earlier, Combine's ability to control subscription and emission using subscribe(on:) and receive(on:). Simply combining @MainActor and @Published doesn't result in the same thing. So when I said "unless you're explicitly bridging", I meant manually calling into API like receive(on:) or wrapping a Task in Future to ensure both sides have their safety constructs properly observed.

2 Likes

Gotcha. Yeah, there’s no nice way to bridge, because there’s no place to add receive(on:) in SwiftUI. I wish I could say @Published(on:) or something like that. Or maybe SwiftUI should've just ensured that when it subscribes to changes, it always adds an implicit receive(on:<main>). It would make using it a lot cleaner.

Are you trying to update an @Published property from a swift concurrency context?

Can you not just do:

@MainActor @Published var myVariable: Int

Task {
    await myVariable += 1
}

By marking your property as @MainActor you are guaranteeing that the property gets accessed/mutated on the main thread.

I've been using concurrency for some time with swiftui and I have no issues with it.

Yes, that probably works. A little unwieldy to mark every property as @MainActor, but probably gives me what I want.

OTOH, when I subscribe to something in Combine, I can add a receive(on:) operator, and the code mutating the property in the first place doesn’t have to care about how it’s used. I wish SwiftUI just did that when subscribing to changes.

Generally I mark my observable objects themselves as MainActor (since they drive UI). That way you don’t have to mark every property as main actor. This is what Apple has done for ALL of UIKit.

And if you absolutely must run some code on another thread, you can force it with a detached task.

Or make yourself a global actor for running code in the background. That will let you mark functions as @BackgroundActor (or whatever your actors name is) and make your code run on another thread.

This is when the new concurrency system starts to shine. The compiler will start telling you when you need to await something running on other actors. Rather than having to remember to run code with DispatchQueue.main.async

another thing you can do with combine is add a receive(on: DispatchQueue.main) to the end of your publisher chain before you mutate your property. That should also solve your problem.

1 Like