asyncDetached falling back into main thread after MainActor call
Asked Answered
A

6

14

I'm trying out the new async/await stuff. My goal here is to run the test() method in the background, so I use Task.detached; but during test() I need to make a call on the main thread, so I'm using MainActor.

(I realize that this may look convoluted in isolation, but it's pared down from a much better real-world case.)

Okay, so test code looks like this (in a view controller):

override func viewDidLoad() {
    super.viewDidLoad()
    Task.detached(priority: .userInitiated) {
        await self.test()
    }
}
@MainActor func getBounds() async -> CGRect {
    let bounds = self.view.bounds
    return bounds
}
func test() async {
    print("test 1", Thread.isMainThread) // false
    let bounds = await self.getBounds()
    print("test 2", Thread.isMainThread) // true
}

The first print says I'm not on the main thread. That's what I expect.

But the second print says I am on the main thread. That isn't what I expect.

It feels as if I've mysteriously fallen back into the main thread just because I called a MainActor function. I thought I would be waiting for the main thread and then resuming in the background thread I was already on.

Is this a bug, or are my expectations mistaken? If the latter, how do I step out to the main thread during await but then come back to the thread I was on? I thought this was exactly what async/await would make easy...?

(I can "solve" the problem, in a way, by calling Task.detached again after the call to getBounds; but at that point my code looks so much like nested GCD that I have to wonder why I'm using async/await at all.)

Maybe I'm being premature but I went ahead and filed this as a bug: https://bugs.swift.org/browse/SR-14756.


More notes:

I can solve the problem by replacing

    let bounds = await self.getBounds()

with

    async let bounds = self.getBounds()
    let thebounds = await bounds

But that seems unnecessarily elaborate, and doesn't convince me that the original phenomenon is not a bug.


I can also solve the problem by using actors, and this is starting to look like the best approach. But again, that doesn't persuade me that the phenomenon I'm noting here is not a bug.


I'm more and more convinced that this is a bug. I just encountered (and reported) the following:

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.
    async {
        print("howdy")
        await doSomeNetworking()
    }
}
func doSomeNetworking() async {
    print(Thread.isMainThread)
}

This prints howdy and then the second print prints true. But if we comment out the first print, the remaining (second) print prints false!

How can merely adding or removing a print statement change what thread we're on? Surely that's not intended.

Ably answered 10/6, 2021 at 2:17 Comment(6)
Just in case you think maybe Thread.isMainThread is mistaken somehow, it isn't. I know because if I speak of self.view.bounds within test before the await, I crash in the main thread checker, but if I speak of it after the await, I don't crash. We really are context switching here, and I don't know why.Ably
What happens if you call another async method that is not a MainActor?Intellection
@Intellection What happens if you do?Ably
One thing I am noticing here is the .userInitiated priority, which is strangely high for expensive work. This could be influencing some of the scheduling decisions. Maybe you should do another asyncDetached with lower priority before doing expensive work?Brownley
Like the detachedAsync vs. async, the priority is irrelevant to the result I'm describing. Try it yourself.Ably
I agree that my code is running on the main thread like yours is in places such as these, but I haven't seen any of the main-thread blocking that you are describing as my work-loads are probably structured differently than yours (more suspension points?). I am wondering if once the main-thread is saturated like yours is, lower-priority work doesn't end up on the main-thread as often. Just a guess, which is why it isn't an answer.Brownley
A
4

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.

Ably answered 13/6, 2021 at 21:48 Comment(1)
If an async function is isolated to an actor, it runs on that actor. Otherwise, it runs on the default executor which executes on the background thread. Therefore, making your async function test2() as nonisolated means it will not be run on the main thread.Protochordate
L
7

As I understand it, given that this is all very new, there is no guarantee that asyncDetached must schedule off the main thread.

In the Swift Concurrency: Behind the Scenes session, it's discussed that the scheduler will try to keep things on the same thread to avoid context switches. Given that though, I don't know how you would specifically avoid the main thread, but maybe we're not supposed to care as long as the task makes progress and never blocks.

I found the timestamp (23:18) that explains that there is no guarantee that the same thread will pick up a continuation after an await. https://developer.apple.com/videos/play/wwdc2021/10254/?time=1398

Leprosy answered 11/6, 2021 at 16:21 Comment(1)
The use of asyncDetached is not crucial here; async will do. But the task (in the real world) does block because of this leakage of the main thread into the task. I don't care if another thread picks up the work; who cares? But that it should be the main thread, preventing user interaction for 30 seconds while my calculation proceeds, is heinous.Ably
B
5

Even if you do the testing to figure out the exact threading behavior of awaiting on @MainActor functions, you should not rely on it. As in @fullsailor's answer, the language explicitly does not guarantee that work will be resumed on the same thread after an await, so this behavior could change in any OS update. In the future, you may be able to request a specific thread by using a custom executor, but this is not currently in the language. See https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md for more details.

Further, it hopefully should not cause any problems that you are running on the main thread. See https://developer.apple.com/videos/play/wwdc2021/10254/?time=2074 for details about how scheduling works. You should not be afraid of blocking the main thread by calling a @MainActor function and then doing expensive work afterwards: if there is more important UI work available, this will be scheduled before your work, or your work will be run on another thread. If you are particularly worried, you can use Task.yield() before your expensive work to give Swift another opportunity to move your work off the main thread. See Voluntary Suspension here for more details on Task.yield().

In your example, it is likely that Swift decided that it is not worth the performance hit of context switching back from the main thread since it was already there, but if the main thread were more saturated, you might experience different behavior.

Edit:

The behavior you're seeing with async let is because this spawns a child task which runs concurrently with the work you are doing. Thus, since that child is running on the main thread, your other code isn't. See https://forums.swift.org/t/concurrency-structured-concurrency/41622 for more details on child tasks.

Brownley answered 11/6, 2021 at 20:39 Comment(4)
I don't buy this. What I'm showing here is heavily reduced from a real world problem where I have a time-consuming calculation to perform. It absolutely must not run on the main thread; if it does, it blocks the interface. That is exactly what is happening because of this unexpected leakage or context switch of the main thread back onto my background thread. It's one thing to say another thread might pick up the work after suspension; that's fine. It's another to say that that thread will suddenly be the main thread.Ably
I have found two workarounds for the issue so far, but that doesn't make what I'm showing less of an issue, in my opinion.Ably
Interesting! I'd be excited to see your solutions: mine would be to put Task.yield() around expensive work to let other main thread work come in. Or maybe even an if Thread.isMainThread { Task.yield() } to avoid splitting up your work unless you're on the main thread. Just to be clear, context switching is probably not the language we should be using to discuss async functions in swift as continuations are passed between threads on the heap, which doesn't involve a full context-switch including thread-locals and such. Either way, you might have a valid use case for custom executors!Brownley
This would defeat the purpose of Tasks entirely, since moving stuff OFF the main thread is what you want to do in the first place.Overt
A
4

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.

Ably answered 13/6, 2021 at 21:48 Comment(1)
If an async function is isolated to an actor, it runs on that actor. Otherwise, it runs on the default executor which executes on the background thread. Therefore, making your async function test2() as nonisolated means it will not be run on the main thread.Protochordate
T
2

A brief note that since the question was asked, the UIKit classes got marked with @MainActor, so the code in discussion would print true on both occasions. But the problem can still be reproducible with a "regular" class.

Now, getting back to the dicussed behaviour, it's expected, and as others have said its also logical:

  • premature optimization is the root of all evil, thread context switches are expensive, so the runtime doesn't easily jump at doing them
  • the test function is not entirely in a concurrent context, because the code hops between the MainActor and your class, thus, the Swift runtime doesn't know that it has to get back to the cooperative thread pool.

If you convert your class to an actor, you'll see the behaviour you expect. Here's a tweaked actor based on the code from the question:

actor ThreadTester {
    func viewDidLoad() {
        Task.detached(priority: .userInitiated) {
            await self.test()
        }
    }

    @MainActor func getBounds() async -> CGRect {
        .zero
    }

    func test() async {
        print("test 1", Thread.isMainThread) // false
        let bounds = await self.getBounds()
        print("test 2", Thread.isMainThread) // true
    }
}

Task {
    await ThreadTester().viewDidLoad()
}

You can toggle between actor and class, leaving the other code untouched, and you'll consistently see the two behaviours.

Swift's structured concurrency works best if all entities involved in concurrent operations are already part of the structured concurrency family, as in this case the compiler has all the necessary information to make informed decisions.

Tumpline answered 30/8, 2022 at 4:50 Comment(0)
U
1

Checking on which thread task is running is unreliable since swift concurrency may use same thread across multiple tasks to avoid context switches. You have to use actor isolation to make sure your tasks aren't executed on actor (this applies to any actor along with MainActor).

First of all, actors in swift are reentrant. This means whenever you are making an async call actor suspends current task until the method returns and proceeds to execute other tasks submitted. This makes sure actor is never blocked due to a long-running task. So if you are calling any async call inside test method and fear that the method will be executed on main thread then you have nothing to worry about. Since your ViewController class will be MainActor isolated your code becomes:

override func viewDidLoad() {
    super.viewDidLoad()
    Task {
        await self.test()
    }
}

func getBounds() -> CGRect {
    let bounds = self.view.bounds
    return bounds
}

func test() async {
    // long running async calls
    let bounds = self.getBounds()
    // long running async calls
}

Now if some of your long running calls are synchronous then you have to remove test method from MainActor's isolation by applying nonisolated attribute. Also, creating Task with Task.init inherits the current actor context which is then executed on actor, to prevent this you have to use Task.detached to execute the test method:

override func viewDidLoad() {
    super.viewDidLoad()
    Task.detached {
        await self.test()
    }
}

func getBounds() -> CGRect {
    let bounds = self.view.bounds
    return bounds
}

nonisolated func test() async {
    // long running async calls
    // long running sync calls
    let bounds = await self.getBounds()
    // long running async calls
    // long running sync calls
}
Unpile answered 27/8, 2022 at 10:27 Comment(0)
G
0

I had the same problem with a function which runs a long task and should always be run on a background thread. Ultimately using the new Swift async await syntax I could not find a easy way based on Task or TaskGroup to ensure this. A Task.detached call will use a different thread as will the Task call itself. If this is called from the MainThread it will be another thread, if not it can be the main thread. Ultimately I found a solution which always works - but looks very "hacky".

  1. Make sure your background function is not isolated to the main thread by being part of a class that is a MainActor isolated class (like view controllers)

    nonisolated func iAllwaysRunOnBackground() {}
    
  2. Test for main thread and if executed on the main thread call the function again in a Task, wait for execution and return

    nonisolated func iAllwaysRunOnBackground() async throws {
      if Thread.isMainThread {
         async let newTask = Task {
           try await self.iAllwaysRunOnBackground()
         }
        let _ = try await newTask.value
        return 
      }
    
      function body
    }
    
Gameto answered 22/2, 2022 at 13:3 Comment(3)
Does putting isAllwaysRunInTheBackground inside an actor solve you problem?Habergeon
It might. Different methods executed in an Actor will be executed on a single thread. I see no guarantee that it is not the main thread. But it might never be the main thread because this thread is reserved to MainActor.Gameto
Well, the actors use the cooperative thread pool and the main thread is not part of that pool. That means that actor isolated methods should never be executed on the main thread.Habergeon

© 2022 - 2024 — McMap. All rights reserved.