Asyncdetached Falling Back into Main Thread After Mainactor Call

asyncDetached falling back into main thread after MainActor call

The following formulation works, and solves the entire problem very elegantly, though I'm a little reluctant to post it because I don't really understand how it works:

override func viewDidLoad() {
super.viewDidLoad()
Task {
await self.test2()
}
}
nonisolated func test2() async {
print("test 1", Thread.isMainThread) // false
let bounds = await self.view.bounds // access on main thread!
print("test 2", bounds, Thread.isMainThread) // false
}

I've tested the await self.view.bounds call up the wazoo, and both the view access and the bounds access are on the main thread. The nonisolated designation here is essential to ensuring this. The need for this and the concomitant need for await are very surprising to me, but it all seems to have to do with the nature of actors and the fact that a UIViewController is a MainActor.

How to resume a continuation ensuring that the result is delivered on the MainActor?

No, there is no way to do this.

If you await some function, you can decide on which thread will it return.
But being an await-able function, you can not make sure that your result will be delivered to the caller on a particular and/or main thread.

How to use URLSession in MainActor


Does it also run on main thread

No. That's the whole point of saying await. At that moment, the system can switch contexts to a background thread without you knowing or caring. Moreover, at await your code pauses without blocking — meaning that while you're waiting for the download, the main thread is free to do other work, the user can interact with your app, etc. Don't worry be happy.

SwiftUI @MainActor loses global actor

See my comment on the question about whether or not the @MainActor strategy is the right way to address an underlying issue, but to directly address your compilation error, you can use this syntax, which compiles fine:

.onChange(of: viewModel.username) { viewModel.editingChanged($0) }

Can I use actors in Swift to always call a function on the main thread?


Actors in Swift 5.5 ‍♀️

Actor isolation and re-entrancy are now implemented in the Swift stdlib. So, Apple recommends using the model for concurrent logic with many new concurrency features to avoid data races. Instead of lock-based synchronisation (lots of boilerplate), we now have a much cleaner alternative.

Some UIKit classes, including UIViewController and UILabel, now have out of the box support for @MainActor. So we only need to use the annotation in custom UI-related classes. For example, in the code above, myImageView.image would automatically be dispatched on the main queue. However, the UIImage.init(named:) call is not automatically dispatched on the main thread outside of a view controller.

In the general case, @MainActor is useful for concurrent access to UI-related state, and is the easiest to do even though we can manually dispatch too. I've outlined potential solutions below:

Solution 1

The simplest possible. This attribute could be useful in UI-Related classes. Apple have made the process much cleaner using the @MainActor method annotation:

@MainActor func setImage(thumbnailName: String) {
myImageView.image = UIImage(image: thumbnailName)
}

This code is equivalent to wrapping in DispatchQueue.main.async, but the call site is now:

await setImage(thumbnailName: "thumbnail")

Solution 2

If you have Custom UI-related classes, we can consider applying @MainActor to the type itself. This ensures that all methods and properties are dispatched on the main DispatchQueue.

We can then manually opt out from the main thread using the nonisolated keyword for non-UI logic.

@MainActor class ListViewModel: ObservableObject {
func onButtonTap(...) { ... }

nonisolated func fetchLatestAndDisplay() async { ... }
}

We don't need to specify await explicitly when we call onButtonTap within an actor.

Solution 3 (Works for blocks, as well as functions)

We can also call functions on the main thread outside an actor with:

func onButtonTap(...) async {
await MainActor.run {
....
}
}

Inside a different actor:

func onButtonTap(...) {
await MainActor.run {
....
}
}

If we want to return from within a MainActor.run, simply specify that in the signature:

func onButtonTap(...) async -> Int {
let result = await MainActor.run { () -> Int in
return 3012
}
return result
}

This solution is slightly less cleaner than the above two solutions which are most suited for wrapping an entire function on the MainActor. However, actor.run also allows for inter threaded code between actors in one func (thx @Bill for the suggestion).

Solution 4 (Block solution that works within non-async functions)

An alternative way to schedule a block on the @MainActor to Solution 3:

func onButtonTap(...) {
Task { @MainActor in
....
}
}

The advantage here over Solution 3 is that the enclosing func doesn't need to be marked as async. Do note however that this dispatches the block later rather than immediately as in Solution 3.

Summary

Actors make Swift code safer, cleaner and easier to write. Don't overuse them, but dispatching UI code to the main thread is a great use case. Note that since the feature is still in beta, the framework may change/improve further in the future.

Since we can easily use the actor keyword interchangeably with class or struct, I want to advise limiting the keyword only to instances where concurrency is strictly needed. Using the keyword adds extra overhead to instance creation and so doesn't make sense when there is no shared state to manage.

If you don't need a shared state, then don't create it unnecessarily. struct instance creation is so lightweight that it's better to create a new instance most of the time. e.g. SwiftUI.

start() for BlockOperation on the main thread

As Andreas pointed out, the documentation warns us:

Blocks added to a block operation are dispatched with default priority to an appropriate work queue. The blocks themselves should not make any assumptions about the configuration of their execution environment.

The thread on which we start the operation, as well as the maxConcurrentOperationCount behavior of the queue, is managed at the operation level, not at the individual execution blocks within an operation. Adding a block to an existing operation is not the same as adding a new operation to the queue. The operation queue governs the relationship between operations, not between the blocks within an operation.

The problem can be laid bare by making these blocks do something that takes a little time. Consider a task that waits one second (you would generally never sleep, but we're doing this simply to simulate a slow task and to manifest the behavior in question). I've also added the necessary “points of interest” code so we can watch this in Instruments, which makes it easier to visualize what’s going on:

import os.log
let pointsOfInterest = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: .pointsOfInterest)

func someTask(_ message: String) {
let id = OSSignpostID(log: pointsOfInterest)
os_signpost(.begin, log: pointsOfInterest, name: "Block", signpostID: id, "Starting %{public}@", message)
Thread.sleep(forTimeInterval: 1)
os_signpost(.end, log: pointsOfInterest, name: "Block", signpostID: id, "Finishing %{public}@", message)
}

Then use addExecutionBlock:

let queue = OperationQueue()          // you get same behavior if you replace these two lines with `let queue = OperationQueue.main`
queue.maxConcurrentOperationCount = 1

let operation = BlockOperation {
self.someTask("main block")
}
operation.addExecutionBlock {
self.someTask("add block 1")
}
operation.addExecutionBlock {
self.someTask("add block 2")
}
queue.addOperation(operation)

Now, I'm adding this to a serial operation queue (because you’d never add a blocking operation to the main queue ... we need to keep that queue free and responsive), but you see the same behavior if you manually start this on the OperationQueue.main. So, bottom line, while start will run the operation “immediately in the current thread”, any blocks you add with addExecutionBlock will just run, in parallel, on “an appropriate work queue”, not necessary the current thread.

If we watch this in Instruments, we can see that not only does addExecutionBlock not necessarily honor the thread on which the operation was started, but it doesn’t honor the serial nature of the queue, either, with the blocks running in parallel:

Parallel

Obviously, if you add these blocks as individual operations, then everything is fine:

for i in 1 ... 3 {
let operation = BlockOperation {
self.someTask("main block\(i)")
}
queue.addOperation(operation)
}

Yielding:

Sample Image



Related Topics



Leave a reply



Submit