如何在Swift 5.5中从同步函数等待异步函数?

32

当遵循协议或覆盖超类方法时,您可能无法将方法更改为async,但仍可能希望调用一些async代码。例如,当我重写一个程序以使用Swift的新结构化并发时,我想通过覆盖XCTestCase上定义的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并发:幕后中,运行时工程师Rokhini Prabhu指出:

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

她还包括了一个此类不安全代码模式的代码片段。

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

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

    semaphore.wait()

}

这明显是我想出来的准确模式(我觉得很有趣,因为我想出来的代码与重命名后的经典不正确代码完全一样)。

不幸的是,我没有找到任何其他方法可以从同步函数中等待异步代码完成。此外,我没有找到任何方法可以在同步函数中获取异步函数的返回值。我能在互联网上找到的唯一解决方案似乎和我的一样错误,例如这篇The Swift Dev文章说:

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

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

有没有一种正确、安全的方式可以等待一个async函数从同步函数中工作,以适应现有的同步类或协议要求,而不具体针对测试或XCTest?或者,我在哪里可以找到关于async/await在Swift中与现有同步原语如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 ,它就不会卡住。我的一个担忧是,即使函数本身没有标记 @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()
}

如果这对你来说没有卡死,请尝试添加更多的unsafeWaitFor。我的开发虚拟机有5个核心,而这里有6个unsafeWaitFor。对我来说,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 数量相同的unsafeWaitFor”,但如果是这样的话,我想在某个地方看到苹果明确说明。在更复杂的程序中,我真的可以确定我的代码可以访问机器上的所有内核吗?还是可能我的程序的其他部分正在使用其他内核,因此由 unsafeWaitFor 请求的工作将永远不会被调度?

当然,我问题中的示例是关于测试的,因此在那种情况下,很容易说“无论建议是什么都没关系:如果它有效,它就有效,如果不是,测试失败,你会解决它的”,但我的问题不仅仅是关于测试;那只是一个例子。

通过 GCD,我对自己能够使用信号量(在我自己控制的DispatchQueue上,而不是在主线程上)来同步异步代码而不耗尽总可用线程感到有信心。我希望能够使用 Swift 5.5 中的 async/await 同步从同步函数中的 async 代码。

如果这样做不可能,我也接受苹果的文档,在其中明确说明我可以在哪些情况下安全使用 unsafeWaitFor 或类似的同步技术。


在没有 async/await 的情况下,我们该如何做到这一点?我们无法做到。 没有 async/await,我们从未能够等待,现在仍然不能。 如果我们在 setUp 中进行异步工作,setUp 将会结束。 - matt
没错。我一直在使用async进行所有的测试,效果非常好。我相信将现有的方法切换为async会破坏ABI和源代码,所以我不知道苹果公司将如何修复setUp。希望很快就会有一个安全的解决方案。 - deaton.dg
为什么不继续做你一直在做的事情,不改变呢?虽然我不赞成这样做,但是嘿,如果你对此感到满意,那就好;没有法律要求你的所有代码都迁移到GCD等其他技术。 - matt
哈哈,没错!这其实是我的计划,我一直在尝试迁移代码时遇到了太多的障碍、边缘情况和错误等等(我相信你也能理解)。但“我真的想摆脱回调地狱”的原则激励我转向使用async/await - deaton.dg
好的,我猜我的GCD解决方案已经不安全了,不能从主线程使用。是的,我绝对不会在应用程序中这样做。感谢您的反馈。 :) - deaton.dg
显示剩余6条评论
4个回答

6
你可能会认为异步代码不应该出现在 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 并发中你……不能像我所知道的那样做任何这样的事情。

结构化并发提案允许顶层代码调用async函数,可以通过直接使用await关键字或将类标记为@main并定义一个static func main() async成员函数来实现。在这两种情况下,似乎都意味着运行时创建了一个“主”线程,将您的顶层代码作为工作者启动,然后调用某种join()函数等待其完成。
正如您的代码片段所示,Swift确实提供了一些标准库函数,允许同步代码创建Task。任务是Swift并发模型的构建块。您引用的WWDC演示说明,运行时旨在创建与CPU核心数量完全相同的工作者线程。但是,随后他们展示了下面的图像,并解释说每当主线程需要运行时都需要进行上下文切换。

enter image description here

据我所知,线程与CPU核心的映射仅适用于“协作线程池”,这意味着如果您的CPU有4个核心,则实际上将有5个线程。主线程应该保持大部分被阻塞,因此唯一的上下文切换是在主线程唤醒的罕见情况下发生。
重要的是要理解,在这种基于任务的模型下,控制“续延”开关的是运行时而不是操作系统(与上下文切换不同)。另一方面,信号量在操作系统级别上运行,并且对运行时不可见。如果您尝试使用信号量在两个任务之间进行通信,它可能会导致操作系统阻止其中一个线程。由于运行时无法跟踪此操作,因此它不会启动新线程来代替它,因此您最多会出现低效利用,最坏的情况下会死锁。

好的,最终,在Meet async/await in Swift中解释了XCTest库可以“开箱即用”地运行异步代码。但是,不清楚这是否适用于setUp()或仅适用于单个测试用例函数。如果它支持异步的setUp()函数,那么你的问题就完全没有意义了。另一方面,如果它不支持它,那么你就陷入了这样一个境地:无法直接等待你的async函数,但仅仅启动一个非结构化的Task(即一个你启动并忘记的任务)也不够好。

你的解决方案(我认为这是一个变通办法——正确的解决方案应该是让XCTest支持异步的setUp())仅阻止了主线程,因此使用起来应该是安全的。


不要混淆应用程序的主线程和测试代码的主线程。通常在应用程序的主线程上运行的代码将在测试的上下文中在工作线程上运行。由于对该信号量的唯一引用在setUp函数中,因此没有其他东西可以在错误的时间发出信号,也没有其他东西可以在不应该等待它时被卡住。它不用于同步多个任务,而是一次性使用,保证运行,唤醒调用。 - TallChuck
我刚刚看了你的编辑。我不明白@MainActor怎么可能会发挥作用,但如果这让你感到紧张,那我想我无法回答你。 - TallChuck
我刚刚再次更新了我的问题。很抱歉我讲得这么详细,但我想要清楚表达我的担忧。如果unsafeWaitFor请求的工作从未调用任何@MainActor方法,我是否真的知道它最终会被调度和终止,还是可能Swift的其他线程正在做其他事情,并且不会调度由unsafeWaitFor请求的工作,直到主线程空闲?使用GCD,我们有这个保证,但使用async/await,我不太确定。 - deaton.dg
这真的很荒谬。一旦你将unsafeWaitFor嵌套在它自己的回调函数中(这使得它在多个异步工作线程之间穿梭),你就进入了不安全的区域,这正是Rokhini说Swift并发中信号量不安全的原因。至于在测试设置中调用MainActor函数,对我来说仍然不清楚如何才会发生。我的直觉是,只有在存在设计缺陷时才会发生,并且挂起只会在测试中发生......这就是测试的目的,但我不理解得足够深刻,无法有强烈的看法。 - TallChuck
我并不认为嵌套使用 unsafeWaitFor 是荒谬的。如果 unsafeWaitFor 被用来实现递归或并发调用的类/协议要求,多个 unsafeWaitFor 可能同时处于运行状态,从而可能阻塞所有 Swift 线程。这样做的一个合理的例子是实现 EncodableDecodable(目前都是同步的),或类似的第三方协议。对于这些协议来说,很有可能是递归的,或者出于性能考虑从多个线程中进行编码或解码。我想知道确切的限制。 - deaton.dg
显示剩余5条评论

3

如果您想等待异步设置函数完成,可以在setUp方法中使用XCTestCase.expectation()XCTestCase.wait()

func doSettingUp() async {
    print("Start setting up... (wait 3 secs)")
    Thread.sleep(forTimeInterval: 3)
    print("Finish setting up")
}

class TestTests: XCTestCase {
    
    override func setUp() {
        print("setUp")
        
        let exp = expectation(description: "Test")
        Task {
            await doSettingUp()
            exp.fulfill()
        }
        wait(for: [exp], timeout: 10)
    }

    override func tearDown() {
        print("tearDown")
    }

    func test_Test() {
        print("test_Test")
    }
}

输出:

setUp
Start setting up... (wait 3 secs)
Finish setting up
test_Test
tearDown

0

我在XCTestCases中遇到了设置方面的问题,特别是与我的API登录有关。

我的解决方案是将我的登录过程拆分为单独的XCTestCases。它们按字母顺序运行。因此,我在上一个测试用例中设置了下一个测试用例的测试。

类似于这样:

  1. NetworkLogin(登录用户名/密码)
  2. NetworkLoginRefresh(刷新令牌)
  3. NetworkTests(除身份验证之外的所有API调用)
  4. NetworkXLogout

-2

你可以调用

_runAsyncMain { *这里是异步内容* }

以在顶层运行异步函数。


1
目前你的回答不够清晰,请编辑并添加更多细节,以帮助其他人理解它如何回答问题。你可以在帮助中心找到有关如何编写好答案的更多信息。 - Community

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接