21

当遵守协议或重写超类方法时,您可能无法将方法更改为 be async,但您可能仍想调用一些async代码。例如,当我正在重写一个要根据 Swift 的新结构化并发编写的程序时,我想通过覆盖定义的 on 来在async测试套件的开头调用一些设置代码。我希望我的设置代码在任何测试运行之前完成,因此使用or是不合适的。class func setUp()XCTestCaseTask.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 并发中使用是不安全的。这是因为它们对 Swift 运行时隐藏了依赖关系信息,但在代码的执行中引入了依赖关系……这违反了线程向前推进的运行时契约。

她还包括了这样一个不安全的代码模式的代码片段

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

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

    semaphore.wait()

}

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

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

为了在同步方法中调用异步方法,您必须使用新的分离函数,并且您仍然必须使用调度 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 个核心,这是 6unsafeWaitFor秒。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 更多的线程。因此,也许建议是“只使用与unsafeWaitForCPU 一样多的 s”,但如果是这种情况,我希望在某个地方看到 Apple 明确说明了这一点。在一个更复杂的程序中,我是否真的可以确定我的代码可以访问机器上的所有内核,或者我的程序的其他部分是否可能正在使用其他内核,因此请求的工作unsafeWaitFor将永远不会预定?

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

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

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

4

4 回答 4

2

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

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

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

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

    return socket.read()
}

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

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

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

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

在此处输入图像描述

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

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

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

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

于 2021-06-12T20:13:04.570 回答
0

你可以打电话

_runAsyncMain { *async stuff here* }

在顶层运行异步函数。

于 2021-12-21T19:10:44.253 回答
0

我在 XCTestCases 中也遇到了这个问题。专门为我的 API 登录。

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

像这样的东西:

  1. NetworkLogin(登录用户名/密码)
  2. NetworkLoginRefresh(刷新令牌)
  3. NetworkTests(除了身份验证的所有 API 调用)
  4. 网络X注销
于 2022-01-23T08:16:28.877 回答
0

要等待您的异步设置功能,您可以简单地在您的方法中使用XCTestCase.expectation()with :XCTestCase.wait()setUp

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
于 2022-02-03T10:26:37.560 回答