为什么一个MainActor隔离的可变存储属性会导致sendable错误?

5

我尝试将一个类符合 Sendable 协议。但是,我的一些可变存储属性会导致问题。然而,我无法理解的是,一个 MainActor 隔离属性不允许我的类符合 Sendable 协议。然而,如果我将整个类标记为 @MainActor,那么就可以了。但是,我并不想将整个类都符合 @MainActor

以这段代码为例:

final class Article: Sendable {
  @MainActor var text: String = "test"
}

它发出这个警告: 'Article'类的可发送属性'sendable'是可变的

有人能解释一下为什么吗? 我以为被隔离到actor中会没问题。

2个回答

5
错误提示您的类具有可变属性。该可变属性可以从Swift并发之外访问,因此不安全。
考虑以下内容:
final class Foo: Sendable {
    @MainActor var counter = 0   // Stored property 'counter' of 'Sendable'-conforming class 'Foo' is mutable
}

我们现在可以考虑一个视图控制器与counter直接交互的以下属性和方法:
let foo = Foo()

func incrementFooManyTimes() {
    DispatchQueue.global().async { [self] in
        DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
            foo.counter += 1
        }
        print(foo.counter)   // 6146264 !!!
    }
}

注意:如果您已将“Swift并发检查”构建设置设置为“Minimal”或“Targeted”,则上述代码只会编译出现上述警告。(如果更改为“Complete”,它将成为一个严重错误。)
简而言之,您已经将其标记为@MainActor,但没有阻止其他线程直接与此类的属性交互。要使类型为Sendable,它必须是以下之一:
- 不可变的; - 手动同步其属性;或者 - 是actor 如果您要使用具有可变属性的非actor作为Sendable,则必须自己实现线程安全性。例如:
final class Foo: @unchecked Sendable {
    private var _counter = 0
    private let queue: DispatchQueue = .main    // I would use `DispatchQueue(label: "Foo.sync")`, but just illustrating the idea

    var counter: Int { queue.sync { _counter } }

    func increment() {
        queue.sync { _counter += 1 }
    }
}

And

func incrementFooManyTimes() {
    DispatchQueue.global().async { [self] in
        DispatchQueue.concurrentPerform(iterations: 10_000_000) { _ in
            foo.increment()
        }
        print(foo.counter)   // 10000000
    }
}

显然,你也可以限制自己只使用不可变属性,这样就不需要同步了。但我假设你需要可变性。

在这种可变的情况下,你可以使用任何同步机制,但希望这说明了这个概念。简而言之,如果你要允许它在Swift并发之外进行突变,你必须自己实现同步。并且由于我们正在实现自己的同步,我们告诉编译器它是@unchecked,这意味着你不会让编译器检查其正确性,而是这个负担落在你的肩上。


显然,如果你使用一个actor并留在Swift并发的世界中,生活会轻松得多。例如:

actor Bar {
    var counter = 0

    func increment() {
        counter += 1
    }
}

同时:

let bar = Bar()

func incrementBarManyTimes() {
    Task.detached {
        await withTaskGroup(of: Void.self) { group in
            for _ in 0 ..< 10_000_000 {
                group.addTask { await self.bar.increment() }
            }
            await print(self.bar.counter)
        }
    }
}

第一个使用 concurrentPerform 的代码片段在我的电脑上无法编译。这里的 self 是什么?这个函数应该在一个类中吗?当 counter 被隔离到 @MainActor 时,如何访问它? - Sweeper
self是一个视图控制器,“incrementFooManyTimes”只是一个随机的方法。关于“如何访问计数器”的问题,那就是我的全部观点。如果你在一个随机类内有一个@MainActor属性,非隔离代码(比如我在视图控制器中的随机方法)仍然可以访问和与其交互。当然,如果你从Swift并发性访问它,编译器将执行严格的演员隔离规则,但如果没有这样做,@MainActor就不提供任何功能上的好处。但如果它是一个演员(像我的Bar示例),那么编译器将强制执行所有必要的检查。 - Rob
好的,现在我把它放在了一个 UIViewController 中,它可以编译通过了。虽然我完全没有想到会是这样。如果它是在一个没有超类或被标记为 @MainActor 的类中,它就不会起作用。我想知道 UIKit 类型有什么特别之处...无论如何,总的来说,我同意你的观点。 - Sweeper

1

阅读 Swift Evolution 提案,似乎复杂的 Sendable 符合性检查尚未设计/实现。

根据 全局Actor提案,被标记为全局Actor的类型隐式符合 Sendable

一个非协议类型被注释为全局Actor时,会隐式符合 Sendable。这些类型的实例是安全的,可以在并发域之间共享,因为它们的状态访问受全局Actor的保护。

所以如果你用 @MainActor 标记你的最终类,甚至不需要 : Sendable

另一方面,Sendable 的提案提到:

Sendable conformance checking for classes

[...] a class may conform to Sendable and be checked for memory safety by the compiler in a specific limited case: when the class is a final class containing only immutable stored properties of types that conform to Sendable:

final class MyClass : Sendable {
    let state: String
}

基本上,如果您没有将最终类标记为全局actor,则类中的每个存储属性都需要是let

我在这两个文档或其他Swift Evolution提案中都没有找到其他相关内容。

因此,当前的设计甚至不关心您是否向属性添加@MainActor。一个最终类符合Sendable的两个充分条件:

Sendable提案还提到:

未来有几种泛化方法,但有一些非常明显的情况需要解决。因此,该提案故意将对类的安全检查限制在一定程度上,以确保我们在并发设计的其他方面取得进展。


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