Swift wait/async - 如何同步等待异步任务完成?

gds*_*gds 47 swift swift-concurrency

我正在 Swift 中桥接同步/异步世界,并逐步采用异步/等待。我正在尝试调用一个从非异步函数返回值的异步函数。我知道显式使用Task是执行此操作的方法,例如,如此处所述

该示例并不真正适合,因为该任务不返回值。

经过多次搜索,我无法找到任何我认为很常见的问题的描述:异步任务的同步调用(是的,我知道这可能会冻结主线程)。

理论上我想在同步函数中编写的是:

let x = Task {
  return await someAsyncFunction()
}.result
Run Code Online (Sandbox Code Playgroud)

但是,当我尝试这样做时,由于尝试访问而出现此编译器错误result

'async' property access in a function that does not support concurrency

我发现的一种替代方案是这样的:

Task.init {
  self.myResult = await someAsyncFunction()
}
Run Code Online (Sandbox Code Playgroud)

其中myResult必须归属为@State成员变量。

然而,这并没有按照我想要的方式工作,因为不能保证在Task.init()完成并进入下一个语句之前完成该任务。那么我怎样才能同步等待该任务完成呢?

Cos*_*syn 30

您不应该同步等待异步任务。

人们可能会想出一种与此类似的解决方案:

func updateDatabase(_ asyncUpdateDatabase: @Sendable @escaping () async -> Void) {
    let semaphore = DispatchSemaphore(value: 0)

    Task {
        await asyncUpdateDatabase()
        semaphore.signal()
    }

    semaphore.wait()
}
Run Code Online (Sandbox Code Playgroud)

尽管它可以在一些简单的条件下工作,但根据WWDC 2021 Swift Concurrency: Behind the faces,这是不安全的。原因是系统希望您遵守运行时合同。合同要求

线程总是能够前进。

这意味着线程永远不会阻塞。当异步函数到达挂起点(例如await表达式)时,该函数可以挂起,但线程不会阻塞,它可以做其他工作。基于此契约,新的协作线程池只能生成与 CPU 核心数量相同的线程,从而避免过多的线程上下文切换。这份契约也是演员不会造成僵局的关键原因。

上述信号量模式违反了此约定。该semaphore.wait()函数阻塞线程。这可能会导致问题。例如

func testGroup() {
    Task {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0 ..< 100 {
                group.addTask {
                    syncFunc()
                }
            }
        }
        NSLog("Complete")
    }
}

func syncFunc() {
    let semaphore = DispatchSemaphore(value: 0)
    Task {
        try? await Task.sleep(nanoseconds: 1_000_000_000)
        semaphore.signal()
    }
    semaphore.wait()
}
Run Code Online (Sandbox Code Playgroud)

这里我们在函数中添加了 100 个并发子任务testGroup,不幸的是任务组永远不会完成。在我的 Mac 中,系统生成 4 个协作线程,仅添加 4 个子任务就足以无限期地阻止所有 4 个线程。因为在所有 4 个线程都被该wait函数阻塞之后,就没有更多的线程可用于执行向信号量发出信号的内部任务。

不安全使用的另一个例子是 actor 死锁:

func testActor() {
    Task {
        let d = Database()
        await d.updateSettings()
        NSLog("Complete")
    }
}

func updateDatabase(_ asyncUpdateDatabase: @Sendable @escaping () async -> Void) {
    let semaphore = DispatchSemaphore(value: 0)

    Task {
        await asyncUpdateDatabase()
        semaphore.signal()
    }

    semaphore.wait()
}

actor Database {
    func updateSettings() {
        updateDatabase {
            await self.updateUser()
        }
    }

    func updateUser() {

    }
}
Run Code Online (Sandbox Code Playgroud)

这里调用该updateSettings函数会死锁。因为它同步等待updateUser函数,而updateUser函数与同一个 actor 隔离,所以它updateSettings先等待完成。

上面两个例子都使用了DispatchSemaphore. NSCondition出于同样的原因,以类似的方式使用也是不安全的。基本上同步等待意味着阻塞当前线程。除非您只想要临时解决方案并且完全了解风险,否则请避免这种模式。

  • 您写道这很危险,因为“系统希望您遵守运行时合同”。有趣的是,我正在使用旧的同步 API 转换旧代码,这些旧代码以前称为同步 Apple API,并使用 Apple 现在拥有的更新方法。在这种情况下我别无选择,根据你的说法,我实际上正在履行合同。我的旧代码被广泛使用,没有错误,在过渡到新架构的过程中,我们需要准确地使用这个:以阻塞方式调用 async。 (4认同)
  • 运行时契约适用于快速并发内的异步任务。如果您从外部快速并发调用(如您的示例所示) - 那么这不是问题。(有很多潜在的陷阱,但“系统希望您遵守运行时合同”不是其中之一) (4认同)
  • 我使用此信号量策略来维护旧 API,该 API 现在在内部使用新的基于异步的 API。在我的单元测试中,任务从未触发,信号量最终永远等待。但是通过将优先级设置为 .default 或 .medium(它们是同一件事)以外的任何内容,单元测试成功了。我唯一的解释是,在测试环境中,.medium 优先级尝试使用与测试运行相同的线程,该线程正在等待信号量,但其他优先级使用不同的线程。这在不同的环境中可能会有所不同,这表明这是多么不安全。 (2认同)

Sou*_*unt 7

除了使用信号量之外,您还可以将异步任务包装在如下所示操作中。您可以在底层异步任务完成后发出操作完成信号,并使用以下命令等待操作完成:waitUntilFinished()

let op = TaskOperation {
   try await Task.sleep(nanoseconds: 1_000_000_000)
}
op.waitUntilFinished()
Run Code Online (Sandbox Code Playgroud)

请注意,使用semaphore.wait()op.waitUntilFinished()阻止当前线程并阻止线程可能会导致现代并发中未定义的运行时行为,因为现代并发假设所有线程始终向前推进。如果您计划仅在不使用现代并发的上下文中使用此方法,则使用 Swift 5.7,您可以提供在异步上下文中不可用的属性标记方法:

@available(*, noasync, message: "this method blocks thread use the async version instead")
func yourBlockingFunc() {
    // do work that can block thread
}
Run Code Online (Sandbox Code Playgroud)

通过使用此属性,您只能从非异步上下文调用此方法。但需要注意的是,如果该方法未指定可用性,您可以调用从异步上下文调用此方法的非异步方法noasync

  • 我相当确定这个链接库正在做 Apple/Swift 团队所说不做的事情,尽管是以一种更间接的方式。尽管您似乎意识到了这一点,但我会非常谨慎地使用这种试图改变新的异步/等待范例以适应更传统的异步范例的代码。 (2认同)

小智 -9

我也一直好奇这个问题。例如,如何启动一个(或多个)任务并等待它们在主线程中完成?这可能类似于 C++ 的思维,但也必须有一种方法可以在 Swift 中做到这一点。无论好坏,我想出了使用全局变量来检查工作是否完成:

import Foundation

var isDone = false

func printIt() async {
    try! await Task.sleep(nanoseconds: 200000000)
    print("hello world")
    isDone = true
}

Task {
    await printIt()
}

while !isDone {
    Thread.sleep(forTimeInterval: 0.1)
}
Run Code Online (Sandbox Code Playgroud)

  • 不要这样做。像这样忙碌的等待毫无意义,并且会消耗电池和性能。无论如何,同步等待异步任务都是一个坏主意。但如果你真的需要这样做,而且你可能不需要,你至少可以使用信号量或调度组,其中等待是由内核完成的,而没有无意义的循环燃烧能量 (5认同)
  • *无论好坏......*。更糟糕,甚至**最糟糕**。永远不要那样做。 (4认同)