【问题标题】:How can I wait for an async function from synchronous function in Swift 5.5?如何在 Swift 5.5 中等待来自同步函数的异步函数?
【发布时间】:2021-08-28 18:17:11
【问题描述】:

当符合协议或重写超类方法时,您可能无法将方法更改为async,但您可能仍想调用一些async 代码。例如,当我正在重写要根据 Swift 的新结构化并发编写的程序时,我想通过覆盖在 @987654328 上定义的 class func setUp() 在我的测试套件的开头调用一些 async 设置代码@。我希望我的设置代码在任何测试运行之前完成,因此使用Task.detachedasync { ... } 是不合适的。

最初,我写了一个这样的解决方案:

final class MyTests: XCTestCase {
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            try! await doSomeSetup()
        }
    }
}

func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

这似乎运作良好。然而,在Swift concurrency: Behind the scenes 中,运行时工程师 Rokhini Prabhu 指出

信号量和条件变量等原语在 Swift 并发中使用是不安全的。这是因为它们对 Swift 运行时隐藏了依赖信息,但在代码的执行中引入了依赖...这违反了线程向前推进的运行时契约。

她还包含了这种不安全代码模式的代码 sn-p

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

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

    semaphore.wait()

}

这显然是我想出的确切模式(我发现我想出的代码正是规范的不正确代码模重命名非常有趣)。

不幸的是,我无法找到任何其他方法来等待异步代码从同步函数完成。此外,我还没有找到任何方法来获取同步函数中异步函数的返回值。我在互联网上找到的唯一解决方案似乎和我的一样不正确,例如 The Swift Dev article

为了在同步方法中调用异步方法,您必须使用新的分离函数,并且您仍然需要使用调度 API 等待异步函数完成。

我认为这是不正确的或至少是不安全的。

什么是等待来自同步函数的async 函数以处理现有同步类或协议要求的正确、安全的方法,而不是特定于测试或 XCTest?或者,我在哪里可以找到说明 Swift 中 async/await 与现有同步原语(如 DispatchSemaphore)之间交互的文档?它们永远不安全,还是我可以在特殊情况下使用它们?

更新:

根据@TallChuck 的回答,注意到setUp() 总是在主线程上运行,我发现我可以通过调用任何@MainActor 函数来故意使程序死锁。这是应尽快更换我的解决方法的绝佳证据。

明确地说,这是一个挂起的测试。

import XCTest
@testable import Test

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            try! await doSomeSetup()
        }
    }
}

func doSomeSetup() async throws {
    print("Starting setup...")
    await doSomeSubWork()
    print("Finished setup!")
}

@MainActor
func doSomeSubWork() {
    print("Doing work...")
}

func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

但是,如果 @MainActor 被注释掉,它不会挂起。我担心的一个问题是,如果我调用库代码(Apple 或其他),即使函数本身没有标记为 @MainActor,也无法知道它是否最终会调用 @MainActor 函数。

我的第二个担心是,即使没有@MainActor,我仍然不知道我能保证这是安全的。在我的电脑上,这会挂起。

import XCTest
@testable import Test

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        unsafeWaitFor {
            unsafeWaitFor {
                unsafeWaitFor {
                    unsafeWaitFor {
                        unsafeWaitFor {
                            unsafeWaitFor {
                                print("Hello")
                            }
                        }
                    }
                }
            }
        }
    }
}
func unsafeWaitFor(_ f: @escaping () async -> ()) {
    let sema = DispatchSemaphore(value: 0)
    async {
        await f()
        sema.signal()
    }
    sema.wait()
}

如果这不适合您,请尝试添加更多 unsafeWaitFors。我的开发虚拟机有 5 个内核,这是 6 个unsafeWaitFors。 5对我来说很好。这与 GCD 明显不同。这是 GCD 中的等价物,它不会挂在我的机器上。

final class TestTests: XCTestCase {
    func testExample() throws {}
    
    override class func setUp() {
        super.setUp()
        safeWaitFor { callback in
            safeWaitFor { callback in
                safeWaitFor { callback in
                    safeWaitFor { callback in
                        safeWaitFor { callback in
                            safeWaitFor { callback in
                                print("Hello")
                                callback()
                            }
                            callback()
                        }
                        callback()
                    }
                    callback()
                }
                callback()
            }
            callback()
        }
    }
}
func safeWaitFor(_ f: @escaping (() -> ()) -> ()) {
    let sema = DispatchSemaphore(value: 0)
    DispatchQueue(label: UUID().uuidString).async {
        f({ sema.signal() })
    }
    sema.wait()
}

这很好,因为 GCD 很乐意生成比 CPU 更多的线程。所以也许建议是“只使用与 CPU 一样多的unsafeWaitFors”,但如果是这样的话,我想看看苹果在哪里明确说明了这一点。在一个更复杂的程序中,我是否可以确定我的代码可以访问机器上的所有内核,或者我的程序的其他部分是否可能正在使用其他内核,因此 unsafeWaitFor 要求的工作永远不会被安排?

当然,我的问题中的示例是关于测试的,因此在这种情况下,很容易说“建议是什么并不重要:如果有效,则有效,如果无效” t,测试失败,你会修复它,”但我的问题不仅仅是关于测试;这只是一个例子。

使用 GCD,我对自己能够在不耗尽可用线程总数的情况下将异步代码与信号量(在我自己控制的 DispatchQueues,而不是主线程上)同步的能力充满信心。我希望能够在 Swift 5.5 中将来自同步函数的 async 代码与 async/await 同步。

如果这样的事情是不可能的,我也会接受来自 Apple 的文档,说明在哪些情况下我可以安全地使用 unsafeWaitFor 或类似的同步技术。

【问题讨论】:

  • 我们在之前async/await会怎么做呢?我们不能。没有async/await,我们永远都等不及了,现在也等不及了。如果我们在setUp 期间进行异步工作,setUp 将结束。
  • @matt 我们(或至少我)使用上面的DispatchSemaphore 方法,但使用的是回调函数而不是async 函数。使用基于DispatchQueue 的并发性,这没关系,因为如果队列阻塞,GCD 可以产生更多线程来完成工作,这样阻塞的线程将来可能能够恢复。 Swift 的内置执行器不会产生新线程(至少不是因为这个),所以DispatchSemaphore 方法很容易与async 函数发生死锁,至少在理论上是这样。我的设置代码很简单,我还没有遇到死锁。
  • 在“在 Swift 中遇见 async/await”session 他们指出“XCTest 支持开箱即用的异步”(时间戳 21:20),但它看起来并不包括setUp().
  • 是的。我一直在使用async 进行所有测试,效果很好。我很确定将现有方法切换到 async 是 ABI 和破坏源,所以我真的不知道 Apple 将如何修复 setUp。希望很快会有一个安全的解决方法。
  • 为什么不继续做你正在做的事情,保持不变?我不赞成,但是,嘿,如果您对此感到满意,那很好;没有法律要求您的所有代码都从 GCD 等迁移出去。

标签: swift async-await grand-central-dispatch swift5.5


【解决方案1】:

您可能会争辩说异步代码不属于setUp(),但在我看来,这样做会将同步性顺序...性混为一谈? setUp() 的重点是在其他任何东西开始运行之前运行,但这并不意味着它必须同步写入,只是其他所有东西都需要将其视为依赖项。

幸运的是,Swift 5.5 引入了一种处理代码块之间依赖关系的新方法。它被称为await 关键字(也许你听说过)。关于async/await(在我看来)最令人困惑的事情是它造成的双面鸡和蛋问题,在我能找到的任何材料中都没有得到很好的解决。一方面,您只能在已经异步的代码中运行异步代码(即使用await),另一方面,异步代码似乎被定义为任何使用await(即运行其他异步代码)。

在最低级别,最终必须有一个async 函数实际上执行异步操作。从概念上讲,它可能看起来像这样(请注意,虽然以 Swift 代码的形式编写,但这是严格的伪代码):

func read(from socket: NonBlockingSocket) async -> Data {
    while !socket.readable {
        yieldToScheduler()
    }

    return socket.read()
}

换句话说,与先有鸡还是先有蛋的定义相反,这个异步函数不是通过使用await 语句来定义的。它将循环直到数据可用,但它允许自己在等待时被抢占。

在最高级别,我们需要能够启动异步代码而无需等待它终止。每个系统都以单个线程开始,并且必须通过某种引导过程来产生任何必要的工作线程。在大多数应用程序中,无论是在台式机、智能手机、Web 服务器还是您拥有的其他应用程序中,主线程然后进入某种“无限”循环,它可能在其中处理用户事件或侦听传入的网络连接,然后以适当的方式与工人互动。然而,在某些情况下,程序要运行到完成,这意味着主线程需要监督每个工作人员的成功完成。对于传统线程,例如 POSIX pthread 库,主线程为某个线程调用pthread_join(),直到该线程终止后才会返回。使用 Swift 并发,你......不能做这样的事情(据我所知)。

structured concurrency 提议允许顶级代码调用async 函数,方法是直接使用await 关键字,或者通过使用@main 标记一个类,并定义一个static func main() async 成员函数。在这两种情况下,这似乎都意味着运行时会创建一个“主”线程,将您的顶级代码作为工作线程启动,然后调用某种join() 函数来等待它完成。

正如您的代码 sn-p 所示,Swift 确实提供了一些标准库函数,允许同步代码创建 Tasks。任务是 Swift 并发模型的构建块。您引用的 WWDC 演示文稿解释说,运行时旨在创建与 CPU 内核一样多的工作线程。然而,后来,他们展示了下图,并解释了任何时候主线程需要运行时都需要进行上下文切换。

据我了解,线程到 CPU 内核的映射仅适用于“协作线程池”,这意味着如果您的 CPU 有 4 个内核,那么实际上总共会有 5 个线程。主线程意味着大部分时间都处于阻塞状态,因此唯一的上下文切换将是主线程唤醒的极少数情况。

重要的是要了解,在这种基于任务的模型下,控制“继续”切换(与上下文切换不同)的是运行时,而不是操作系统。另一方面,信号量在操作系统级别运行,并且对运行时不可见。如果您尝试使用信号量在两个任务之间进行通信,可能会导致操作系统阻塞您的线程之一。由于运行时无法跟踪这一点,它不会启动一个新线程来代替它,所以你最终会得到充分利用,最坏的情况是死锁。

好的,终于,在Meet async/await in Swift 中解释了XCTest 库可以“开箱即用”运行异步代码。但是,尚不清楚这是否适用于setUp(),还是仅适用于单个测试用例功能。如果事实证明它确实支持异步setUp() 函数,那么您的问题突然变得完全无趣。另一方面,如果它确实支持它,那么您将陷入无法直接等待async 函数的位置,但仅仅启动一个也不够好非结构化的Task(即你触发并忘记的任务)。

您的解决方案(我认为这是一种解决方法——正确的解决方案是让XCTest 支持async setUp()),只阻塞主线程,因此应该可以安全使用。

【讨论】:

  • 这是一篇很好的关于async/await 在 Swift 中的实用性的文章。这对以后可能会发现这个问题的人很有用。不过我还是不满意。你怎么知道我的解决方法是安全的,因为它只是阻塞了主线程?事实上,这不是特别使它相反吗?如果我的设置代码决定调用任何@MainActor 方法,它就会死锁。而且在编译时无法知道您调用的 async 方法是否最终会调用某些 @MainActor 方法,因此我无法在编译时知道这是否安全。
  • 不要将应用程序的主线程与测试代码的主线程混淆。通常在应用程序主线程上运行的代码将在测试上下文中的工作线程上运行。由于对该信号量的唯一引用是在 setUp 函数中,因此没有其他东西可以在错误的时间发出信号,并且没有其他东西可以在不应该等待的时候卡住它。不是用来同步多个任务的,是一次性使用,保证运行,叫醒
  • 我刚刚阅读了您的编辑。我不明白@MainActor 将如何发挥作用,但如果这让你感到紧张,那么我想我无法回答你
  • 我刚刚再次更新了我的问题。很抱歉这么冗长,但我想清楚我关心的是什么。我真的知道如果unsafeWaitFor 请求的工作没有调用任何@MainActor 方法,它最终会被调度并终止,或者Swift 的其他线程可能正在做其他事情并且不会调度请求的工作unsafeWaitFor 直到主线程空闲?对于 GCD,我们有这个保证,但对于 async/await,我不太确定。
  • 这很快就变得可笑了。一旦你将unsafeWaitFor 嵌套在它自己的回调中(这使它在多个异步工作线程之间交叉),你就进入了不安全区域,这正是 Rokhini 说信号量在 Swift 并发中不安全的原因。至于在您的测试设置中调用 MainActor 函数,我仍然不清楚这将如何发生。我的直觉是,只有在存在设计缺陷的情况下才会发生这种情况,而挂断只会发生在测试中……这是测试的重点,但我理解的程度不足以产生强烈的意见
【解决方案2】:

你可以打电话

_runAsyncMain { *async stuff here* }

在顶层运行异步函数。

【讨论】:

  • 正如目前所写,您的答案尚不清楚。请edit 添加其他详细信息,以帮助其他人了解这如何解决所提出的问题。你可以找到更多关于如何写好答案的信息in the help center
猜你喜欢
  • 1970-01-01
  • 2022-07-06
  • 1970-01-01
  • 2020-10-20
  • 2018-09-29
  • 2017-04-06
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多