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 await
ed 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.