Categories
iOS Swift

Task with @MainActor gotcha in Swift

While working on a codebase with a mix of structured concurrency and good old DispatchQueues I stumbled on something. Most of the time this would not be a problem, but in my case, it was important that events would happen in order. Let’s jump to an example for illustrating what I am talking about.

Firstly, let’s add an extension to Task which adds a static function for running operations on the main actor. Similar to the existing Task.detached. This made sense since I needed to push the Task to main actor in multiple places.

extension Task {
@discardableResult static func main(
operation: @escaping () async throws -> Success
) -> Task<Success, Failure> where Failure == any Error {
Task { @MainActor in
print(Thread.isMainThread) // logging purposes
return try await operation()
}
}
}

This extension was used in a class which observes state using non-async await code. If the state changed, it would trigger a closure.

final class Observer {
private var didChange: ((Contact) async throws -> Void)?
func start(didChange: @escaping (Contact) async -> Void) {
self.didChange = didChange
}
func simulateChange() {
DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(1)) { [self] in
let changed = Contact(name: "Toomas \((1…5).randomElement()!)")
Task.main {
print(Thread.isMainThread) // logging purposes
try await self.didChange?(changed)
}
}
}
}
view raw Observer.swift hosted with ❤ by GitHub

The idea is that when the state changes, we jump to the main actor from the beginning, since it will always end up updating state in a @MainActor annotated class. In this example, a view model.

@MainActor final class ViewModel: ObservableObject {
@Published private(set) var contact: Contact?
let observer: Observer
init() {
observer = Observer()
observer.start(didChange: { [weak self] contact in
print(Thread.isMainThread) // logging purposes
self?.contact = contact
})
}
func refresh() {
observer.simulateChange()
}
}
view raw ViewModel.swift hosted with ❤ by GitHub

Looks great, we use Task.main which calls the operation on the main actor which in turn sets the updated state to a main actor guarded view model. If I run the code and observe isMainThread print statements, then it prints: true, false, true. Here is where the gotcha is. I expected it to be true, true, true since I made sure Task uses @MainActor and also the view model is @MainActor. Here is the change I needed to do for getting the expected true, true, true in console log.

extension Task {
@discardableResult static func main(
// note the @MainActor
operation: @escaping @MainActor () async throws -> Success
) -> Task<Success, Failure> where Failure == any Error {
Task { @MainActor in
print(Thread.isMainThread) // logging purposes
return try await operation()
}
}
}

I was missing the @MainActor from the closure’s definition. What happened was that task was forced to main actor, but the operation closure did not specify any isolation, and Swift happily switched to the global executor instead.

If I rethink about what happened, then most of the time this would not be a problem, but in my specific case the timing was important. It was important that when we jump to the main actor, then no other code path should not change the state in the view model.

All in all, this is just something to keep in mind when mixing structured concurrency with DispatchQueues.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift

Initializing @MainActor type from a non-isolated context in Swift

Recently I was in the middle of working on code where I wanted a type to require @MainActor since the type was an ObservaleObject and makes sense if it always publishes changes on the MainActor. The MainActor type needed to be created by another type which is not a MainActor. How to do it?

This does not work by default, since we are creating the MainActor type from a non-isolated context.

final class ViewPresenter {
init(dataObserver: DataObserver) {
// Call to main actor-isolated initializer 'init(dataObserver:)' in a synchronous nonisolated context
self.viewState = ViewState(dataObserver: dataObserver)
}
let viewState: ViewState
}
@MainActor final class ViewState: ObservableObject {
let dataObserver: DataObserver
init(dataObserver: DataObserver) {
self.dataObserver = dataObserver
}
@Published private(set) var status: Status = .loading
@Published private(set) var contacts: [Contact] = []
}
view raw ViewState.swift hosted with ❤ by GitHub

OK, this does not work. But since the ViewState has a simple init then why not slap nonisolated on the init and therefore not requiring the init to be called on a MainActor. This leads to a warning: “Main actor-isolated property ‘dataObserver’ can not be mutated from a non-isolated context; this is an error in Swift 6”. After digging in Swift forums to understand the error, I learned that as soon as init assigns the dataObserver instance to the MainActor guarded property, then compiler considers that the type is owned by the MainActor now. Since init is nonisolated, compiler can’t ensure that the assigned instance is not mutated by the non-isolated context.

final class ViewPresenter {
init(dataObserver: DataObserver) {
self.viewState = ViewState(dataObserver: dataObserver)
}
let viewState: ViewState
}
@MainActor final class ViewState: ObservableObject {
let dataObserver: DataObserver
nonisolated init(dataObserver: DataObserver) {
// Main actor-isolated property 'dataObserver' can not be mutated
// from a non-isolated context; this is an error in Swift 6
self.dataObserver = dataObserver
}
@Published private(set) var status: Status = .loading
@Published private(set) var contacts: [Contact] = []
}
view raw ViewState.swift hosted with ❤ by GitHub

This warning can be fixed by making the DataObserver type to conform to Sendable protocol which tells the compiler that it is OK, if the instance is mutated from different contexts (of course we need to ensure that the type really is thread-safe before adding the conformance). In this particular case, making the type Sendable was not possible, and I really did not want to go to the land of @unchecked Sendable, so I continued my research. Moreover, having nonisolated init looked like something what does not look right anyway.

Finally, I realized that since the ViewState is @MainActor, then I could make the viewState property @MainActor as well and delay creating the instance until the property is accessed. Makes sense since if I want to access the ViewState and interact with it then I need to be on the MainActor anyway. If the property is lazy var and created using a closure, then we achieve what we want: force the instance creation to MainActor. Probably, code speaks itself more clearly.

final class ViewPresenter {
private let viewStateBuilder: @MainActor () -> ViewState
init(dataObserver: DataObserver) {
self.viewStateBuilder = { ViewState(dataObserver: dataObserver) }
}
@MainActor lazy var viewState: ViewState = viewStateBuilder()
}
@MainActor final class ViewState: ObservableObject {
let dataObserver: DataObserver
init(dataObserver: DataObserver) {
self.dataObserver = dataObserver
}
@Published private(set) var status: Status = .loading
@Published private(set) var contacts: [Contact] = []
}
view raw ViewState.swift hosted with ❤ by GitHub

What I like is that I can keep one of the types fully @MainActor and still manage the creation from a non-isolated context. The downside is having lazy var and handling the closure.

If you want to try my apps, then grab one of the free offer codes for Silky Brew.

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.