MainActor 调用后 asyncDetached 回退到主线程

asyncDetached falling back into main thread after MainActor call

我正在尝试新的 async/await 内容。我这里的目标是运行后台的test()方法,所以我用Task.detached;但是在 test() 期间我需要在主线程上进行调用,所以我正在使用 MainActor。

(我意识到单独来看这可能看起来很复杂,但它已经从一个更好的真实案例中缩减了。)

好的,测试代码如下所示(在视图控制器中):

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
}

第一个 print 说我不在主线程上。这就是我所期望的。

但是第二个 print 说我 在主线程上。 不是我所期望的。

感觉调用了一个MainActor函数就莫名其妙的回到了主线程。我以为我会等待主线程,然后在我已经在的后台线程中恢复。

这是一个错误,还是我的预期有误?如果是后者,我如何 do 我在 await 期间退出到主线程,然后又回到我所在的线程?我认为这正是 async/await 可以轻松实现的...?

(在某种程度上,我可以通过在调用 getBounds 之后再次调用 Task.detached 来“解决”问题;但那时我的代码看起来非常像嵌套的 GCD,以至于我不得不想知道我为什么要使用 async/await。)

也许我还为时过早,但我继续将此作为错误提交:https://bugs.swift.org/browse/SR-14756


更多注释:

我可以通过替换

来解决问题
    let bounds = await self.getBounds()

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

但这似乎没有必要详细说明,并且不能使我相信最初的现象不是错误。


我也可以通过使用 actors 来解决问题,这看起来像是最好的方法。但同样,这并不能使我相信我在这里注意到的现象不是错误。


我越来越确信这是一个错误。我刚刚遇到(并报告)了以下内容:

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

这会打印 howdy,然后第二个 print 会打印 true。但是如果我们注释掉第一个打印,剩下的(第二个)print 打印 false!

仅仅添加或删除打印语句怎么能改变我们所在的线程呢?这当然不是故意的。

据我了解,鉴于这一切都是全新的,无法保证 asyncDetached 必须安排在主线程之外。

Swift Concurrency: Behind the Scenes 会话中,讨论了调度程序将尝试将事情保持在同一线程上以避免上下文切换。鉴于此,我不知道您将如何专门避免主线程,但也许我们不应该关心,只要任务取得进展并且从不阻塞。

我找到了时间戳 (23:18),它解释了不能保证同一个线程在等待之后会继续执行。 https://developer.apple.com/videos/play/wwdc2021/10254/?time=1398

即使您通过测试找出等待 @MainActor 函数的确切线程行为,也不应依赖它。正如@fullsailor 的回答,该语言明确不保证在等待后将在同一线程上恢复工作,因此此行为可能会在任何 OS 更新中发生变化。将来,您可能可以通过使用自定义执行程序来请求特定的线程,但目前在该语言中还没有。有关详细信息,请参阅 https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md

此外,希望您在主线程上 运行 不会造成任何问题。有关 Task.yield() 的更多详细信息,请参阅 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

在您的示例中,很可能 Swift 决定不值得从主线程切换 back 的性能损失,因为它已经存在,但如果主线程更饱和,您可能会遇到不同的行为。

编辑:

您在 async let 中看到的行为是因为这会生成一个 child 任务,该任务与您正在执行的工作同时运行。因此,由于 child 在主线程上是 运行,所以您的其他代码不是。有关 child 项任务的更多详细信息,请参阅 https://forums.swift.org/t/concurrency-structured-concurrency/41622

下面的公式有效,并且非常优雅地解决了整个问题,虽然我有点不愿意 post 因为我不太明白它是如何工作的:

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
}

我测试了 await self.view.bounds 调用 wazoo,view 访问和 bounds 访问都在主线程上。 nonisolated 此处的指定对于确保这一点至关重要。对此的需求以及对 await 的伴随需求令我感到非常惊讶,但这似乎都与演员的性质以及 UIViewController 是 MainActor 这一事实有关。

我有一个函数有同样的问题,该函数 运行 是一项很长的任务,应该始终 运行 在后台线程上。最终使用新的 Swift async await 语法,我找不到基于 Task 或 TaskGroup 的简单方法来确保这一点。 Task.detached 调用将使用不同的线程,因为任务调用自身。如果这是从 MainThread 调用的,它将是另一个线程,否则它可以是主线程。 最终我找到了一个始终有效的解决方案 - 但看起来非常“hacky”。​​

  1. 通过成为 class 的一部分,确保您的后台函数不与主线程隔离,该 class 是一个隔离的 MainActor class(如视图控制器)

    nonisolated func iAllwaysRunOnBackground() {}
    
  2. 测试主线程,如果在主线程执行,在Task中再次调用函数,等待执行,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
    }