并发执行代码中已捕获变量的突变。

12

我在 Swift 5.5 中遇到了问题,但并不真正理解解决方案。

import Foundation

func testAsync() async {

    var animal = "Dog"

    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        animal = "Cat"
        print(animal)
    }

    print(animal)
}

Task {
    await testAsync()
}

这段代码出错了。
Mutation of captured var 'animal' in concurrently-executing code

然而,如果将animal 变量从此异步函数的上下文中移开,

import Foundation

var animal = "Dog"

func testAsync() async {
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        animal = "Cat"
        print(animal)
    }

    print(animal)
}

Task {
    await testAsync()
}

代码将会编译通过。我知道这个错误是为了防止数据竞争,但是为什么移动变量可以让它变得安全呢?


我猜这只是编译器的一个特性。你会如何重写这段代码以保证并发安全? - Jere
3个回答

14
关于全局变量示例的行为,我可能会参考Rob Napier在此处所提到的与全局变量可发送性相关的错误/限制:
引用自Rob Napier的评论: 编译器在推理全局变量方面有许多限制。简短的答案是“不要创建全局可变变量”。这在论坛上已经讨论过,但没有得到任何讨论。https://forums.swift.org/t/sendability-checking-for-global-variables/56515 顺便提一下,如果您将其放入实际应用程序中并将“Strict Concurrency Checking”构建设置更改为“Complete”,则会收到全局示例中适当警告:
引用自警告消息: 对 var 'animal' 的引用不具有并发安全性,因为它涉及共享可变状态。
线程安全问题的编译时检测正在发展中,在Swift 6中承诺出现许多新错误(这就是为什么他们给我们这个新的“Strict Concurrency Checking”设置,以便我们可以开始使用不同级别的检查来审查我们的代码)。
无论如何,您可以使用actor以提供与该值的线程安全交互:
actor AnimalActor {
    var animal = "Dog"
    
    func setAnimal(newAnimal: String) {
        animal = newAnimal
    }
}

func testAsync() async {
    let animalActor = AnimalActor()
    
    Task {
        try await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC)
        await animalActor.setAnimal(newAnimal: "Cat")
        print(await animalActor.animal)
    }

    print(await animalActor.animal)
}

Task {
    await testAsync()
}

了解更多信息,请参阅WWDC 2021年的使用Swift Actors保护可变状态和2022年的使用Swift Concurrency消除数据竞争


请注意,在上述示例中我避免使用GCD API。 asyncAfter 是旧的 GCD 技术,用于延迟部分工作而不阻塞当前线程。但是,新的Task.sleep(与旧的Thread.sleep 不同)在并发系统内实现了相同的行为(并提供取消功能)。 在可能的情况下,我们应该避免在 Swift 并发代码库中使用 GCD API。


有趣的阅读,我一直认为全局变量默认情况下在主线程上运行(尽管我从未使用过它们)。 - Cheezzhead
1
没有这样的保证。可变全局变量未同步且不安全。 - Rob

6
首先,如果可以的话,请使用结构化并发,就像其他答案建议的那样。
我遇到了一种情况,即没有清晰的结构化并发API:需要返回非异步值的协议。
protocol Proto {
    func notAsync() -> Value
}

为了计算数值,需要使用异步方法调用。我选择了以下这个解决方案:

func someAsyncFunc() async -> Value {
    ...
}

class Impl: Proto {
    func notAsync() -> Value {
        return UnsafeTask {
            await someAsyncFunc()
        }.get()
    }
} 

class UnsafeTask<T> {
    let semaphore = DispatchSemaphore(value: 0)
    private var result: T?
    init(block: @escaping () async -> T) {
        Task {
            result = await block()
            semaphore.signal()
        }
    }

    func get() -> T {
        if let result = result { return result }
        semaphore.wait()
        return result!
    }
}

如果你遇到相同的情况,可以复制粘贴UnsafeTask类并在你的代码中使用它。

我认为这是一个相当丑陋的解决方案,例如:类型必须是类,因为结构体会进行并发检查,这意味着编译器会报并发访问semaphoreresult的错误。据我所知,信号量应该是线程安全的,并且结果仅由一个上下文写入并由其他上下文读取。如果T的大小不超过指针大小,则写入是原子化的,因此是“安全”的。在其他情况下,可能不安全。虽然我可能会忽略一些并发边缘情况。 欢迎提出建议。


跟进问题 - 因为我遇到了类似的情况并实施了类似的方法。你为什么说:“如果 T 的大小不超过指针大小,写操作是原子的,因此是‘安全’的。在其他情况下可能不安全。”我相信这是正确的,但我想要了解为什么。我感到困惑的原因是我们这里有一个信号量锁,所以对 result 的写操作应该是安全的,不是吗? - undefined

0

当你在async函数内声明变量时,它就成为了结构化并发的一部分。假设你的testAsync函数可以在任何上下文中运行。但是对animal的更改是在主线程上完成的,这引入了数据竞争。

在第二个例子中,变量是全局声明的并且在主线程上运行*。编译器不会严格检查全局变量的并发性。

*:实际上,不能保证在主线程上运行。正如@Rob所说,应避免使用全局变量。


我已经修改了我的答案,但请记住问题是“为什么编译器没有抛出错误”,而不是“我是否应该在并发中使用全局变量”。 - Cheezzhead
同意。但是针对“为什么编译器没有抛出错误”的问题,你说这是因为在全局场景中“没有数据竞争”。但实际上是有的,只是编译器没有检测到(除非你打开“完整”检查)。你的编辑很好! - Rob

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