[翻译] Swift 6 的常见编译错误

Dec 27, 2024 • 预计阅读时间 12 分钟

常见编译器错误

识别、理解并解决使用 Swift 并发时可能遇到的常见问题。

编译器提供的数据隔离保证影响所有 Swift 代码。这意味着完整的并发检查可能会暴露潜在的问题,即使是在没有直接使用任何并发语言特性的 Swift 5 代码中也是如此。在启用 Swift 6 语言模式后,这些潜在问题中的一些也可能变成错误。

启用完整检查后,许多项目可能会包含大量警告和错误。不要感到不知所措!这些问题大多可以追溯到一小部分根本原因。而这些原因,通常是常见模式的结果,这些模式不仅容易修复,而且在学习 Swift 的并发系统时也非常有启发性。

不安全的全局和静态变量

全局状态(包括静态变量)可以从程序的任何位置访问。这种可见性使它们特别容易受到并发访问的影响。在数据竞争安全之前,全局变量模式依赖于程序员在没有编译器帮助的情况下,小心地以避免数据竞争的方式访问全局状态。

实验

这些代码示例以包的形式提供。您可以在 Globals.swift 中自行尝试。

Sendable 类型

var supportedStyleCount = 42

这里,我们定义了一个全局变量。该全局变量在任何隔离域中都是非隔离的且可变的。在 Swift 6 模式下编译上述代码会产生错误信息:

1 | var supportedStyleCount = 42
  |              |- error: global variable 'supportedStyleCount' is not concurrency-safe because it is non-isolated global shared mutable state
  |              |- note: convert 'supportedStyleCount' to a 'let' constant to make the shared state immutable
  |              |- note: restrict 'supportedStyleCount' to the main actor if it will only be accessed from the main thread
  |              |- note: unsafely mark 'supportedStyleCount' as concurrency-safe if all accesses are protected by an external synchronization mechanism
2 |

具有不同隔离域的两个函数访问此变量会有数据竞争的风险。在以下代码中,printSupportedStyles() 可能在主 actor 上运行,而同时从另一个隔离域调用 addNewStyle()

@MainActor
func printSupportedStyles() {
    print("Supported styles: ", supportedStyleCount)
}

func addNewStyle() {
    let style = Style()

    supportedStyleCount += 1

    storeStyle(style)
}

解决问题的一种方法是更改变量的隔离性。

@MainActor
var supportedStyleCount = 42

变量仍然可变,但已被隔离到全局 actor。现在所有访问只能在一个隔离域中进行,而在编译时 addNewStyle 中的同步访问将是无效的。

如果变量是常量且永远不会被修改,一个直接的解决方案是向编译器表达这一点。通过将 var 改为 let,编译器可以静态地禁止修改,保证安全的只读访问。

let supportedStyleCount = 42

全局值也可以用计算属性来表达。如果这样的属性始终返回相同的常量值,那么就语义而言,这与编译器的 let 常量等效:

var supportedStyleCount: Int {
    42
}

如果有保护此变量的同步机制,但编译器无法识别,您可以使用 nonisolated(unsafe) 禁用 supportedStyleCount 的所有隔离检查。

/// 此值仅在持有 `styleLock` 时访问。
nonisolated(unsafe) var supportedStyleCount = 42

只有当您使用锁或调度队列等外部同步机制仔细保护对变量的所有访问时,才使用 nonisolated(unsafe)

非 Sendable 类型

在上述示例中,变量是 Int 类型,这是一个本质上是 Sendable 的值类型。全局引用类型带来了额外的挑战,因为它们通常不是 Sendable。

class WindowStyler {
    var background: ColorComponents

    static let defaultStyler = WindowStyler()
}

这个静态 let 声明的问题与变量的可变性无关。问题在于 WindowStyler 是一个非 Sendable 类型,使其内部状态在跨隔离域共享时不安全。

func resetDefaultStyle() {
    WindowStyler.defaultStyler.background = ColorComponents(red: 1.0, green: 1.0, blue: 1.0)
}

@MainActor
class StyleStore {
    var stylers: [WindowStyler]

    func hasDefaultBackground() -> Bool {
        stylers.contains { $0.background == WindowStyler.defaultStyler.background }
    }
}

这里,我们看到两个可能同时访问 WindowStyler.defaultStyler 内部状态的函数。编译器只允许使用 Sendable 类型进行这种跨隔离访问。一个选项是使用全局 actor 将变量隔离到单个域。或者,直接添加 Sendable 一致性可能更有意义。

协议一致性隔离不匹配

协议定义了符合类型必须满足的要求,包括静态隔离。这可能导致协议声明和符合类型之间的隔离不匹配。

这类问题有许多可能的解决方案,但它们通常涉及权衡。选择合适的方法首先需要理解为什么会出现不匹配。

实验

这些代码示例以包的形式提供。您可以在 ConformanceMismatches.swift 中自行尝试。

未充分指定的协议

这个问题最常见的形式发生在协议没有显式隔离时。在这种情况下,与所有其他声明一样,这意味着非隔离。非隔离协议要求可以从任何隔离域的泛型代码中调用。如果要求是同步的,那么符合类型的实现访问 actor 隔离状态是无效的:

protocol Styler {
    func applyStyle()
}

@MainActor
class WindowStyler: Styler {
    func applyStyle() {
        // 访问主 actor 隔离状态
    }
}

上述代码在 Swift 6 模式下会产生以下错误:

 5 | @MainActor
 6 | class WindowStyler: Styler {
 7 |     func applyStyle() {
   |          |- error: main actor-isolated instance method 'applyStyle()' cannot be used to satisfy nonisolated protocol requirement
   |          `- note: add 'nonisolated' to 'applyStyle()' to make this instance method not isolated to the actor
 8 |         // access main-actor-isolated state
 9 |     }

协议可能实际上应该是隔离的,但尚未更新为并发。如果首先迁移符合类型以添加正确的隔离,将会出现不匹配。

// 这实际上只适合从 MainActor 类型使用,
// 但尚未更新以反映这一点。
protocol Styler {
    func applyStyle()
}

// 现在已正确隔离的符合类型,
// 暴露了不匹配。
@MainActor
class WindowStyler: Styler {
}

添加隔离

如果协议要求总是从主 actor 调用,添加 @MainActor 是最好的解决方案。

有两种方法可以将协议要求隔离到主 actor:

// 整个协议
@MainActor
protocol Styler {
    func applyStyle()
}

// 每个要求
protocol Styler {
    @MainActor
    func applyStyle()
}

用全局 actor 属性标记协议将为整个一致性范围推断隔离。如果协议一致性不是在扩展中声明的,这可以应用于整个符合类型。

每个要求的隔离对 actor 隔离推断的影响更窄,因为它只适用于该特定要求的实现。它不影响协议扩展或符合类型的其他方法的推断隔离。如果符合类型不一定也绑定到相同的全局 actor,应该优先使用这种方法。

无论如何,更改协议的隔离都会影响符合类型的隔离,并且可能对使用该协议的泛型代码施加限制。

您可以使用 @preconcurrency 来分阶段引入在协议上添加全局 actor 隔离导致的诊断。这将保持与尚未开始采用并发的客户端的源代码兼容性。

@preconcurrency @MainActor
protocol Styler {
    func applyStyle()
}

异步要求

对于实现同步协议要求的方法,实现的隔离必须完全匹配。使要求异步可为符合类型提供更大的灵活性。

protocol Styler {
    func applyStyle() async
}

可以用隔离方法满足非隔离的异步协议要求。

@MainActor
class WindowStyler: Styler {
    // 匹配,即使它是同步的且 actor 隔离的
    func applyStyle() {
    }
}

上述代码是安全的,因为泛型代码必须始终异步调用 applyStyle(),允许隔离实现在访问 actor 隔离状态之前切换 actor。

然而,这种灵活性是有代价的。将方法更改为异步可能会对每个调用站点产生重大影响。除了异步上下文之外,参数和返回值可能需要跨越隔离边界。这些可能需要进行重大的结构性更改来解决。即使只涉及少量类型,这可能仍然是正确的解决方案,但应该首先仔细考虑副作用。

预并发一致性

Swift 有许多机制可以帮助您逐步采用并发,并与尚未开始使用并发的代码进行互操作。这些工具对于您不拥有的代码以及您拥有但无法轻易更改的代码都很有帮助。

用 @preconcurrency 注释协议一致性可以抑制有关任何隔离不匹配的错误。

@MainActor
class WindowStyler: @preconcurrency Styler {
    func applyStyle() {
        // 实现主体
    }
}

这会插入运行时检查以确保始终强制执行符合类的静态隔离。

注意

要了解更多关于增量采用和动态隔离的信息,请参阅动态隔离

隔离符合类型

到目前为止,提出的解决方案假设隔离不匹配的原因最终根源于协议定义。但是,协议的静态隔离可能是适当的,问题仅仅是由符合类型引起的。

非隔离

即使是完全非隔离的函数也可能有用。

@MainActor
class WindowStyler: Styler {
    nonisolated func applyStyle() {
        // 也许这个实现不涉及
        // 其他 MainActor 隔离状态
    }
}

此实现的约束是隔离状态和函数变得不可用。如果函数用作实例独立配置的来源,这仍然可能是一个合适的解决方案。

通过代理实现一致性

可以使用中间类型来帮助解决静态隔离差异。如果协议要求其符合类型继承,这可能特别有效。

class UIStyler {
}

protocol Styler: UIStyler {
    func applyStyle()
}

// actors 不能有基于类的继承
actor WindowStyler: Styler {
}

引入新类型以间接实现一致性可以使这种情况工作。但是,此解决方案将需要对 WindowStyler 进行一些结构性更改,这也可能影响依赖代码。

// 具有必要超类的类
class CustomWindowStyle: UIStyler {
}

// 现在,一致性是可能的
extension CustomWindowStyle: Styler {
    func applyStyle() {
    }
}

这里创建了一个可以满足所需继承的新类型。如果一致性仅由 WindowStyler 内部使用,那么合并将最容易。

跨越隔离边界

编译器只允许值在它能证明不会引入数据竞争的情况下从一个隔离域移动到另一个隔离域。在可以跨越隔离边界的上下文中尝试使用不满足此要求的值是一个非常常见的问题。由于库和框架可能会更新以使用 Swift 的并发功能,即使您的代码没有更改,这些问题也可能出现。

实验

这些代码示例以包的形式提供。您可以在 Boundaries.swift 中自行尝试。

隐式 Sendable 类型

许多值类型完全由 Sendable 属性组成。编译器将把这样的类型视为隐式 Sendable,但仅当它们是非公开的时候。

public struct ColorComponents {
    public let red: Float
    public let green: Float
    public let blue: Float
}

@MainActor
func applyBackground(_ color: ColorComponents) {
}

func updateStyle(backgroundColor: ColorComponents) async {
    await applyBackground(backgroundColor)
}

Sendable 一致性是类型公共 API 契约的一部分,这由您来声明。因为 ColorComponents 被标记为 public,它不会隐式遵循 Sendable。这将导致以下错误:

 6 | 
 7 | func updateStyle(backgroundColor: ColorComponents) async {
 8 |     await applyBackground(backgroundColor)
   |           |- error: sending 'backgroundColor' risks causing data races
   |           `- note: sending task-isolated 'backgroundColor' to main actor-isolated global function 'applyBackground' risks causing data races between main actor-isolated and task-isolated uses
 9 | }
10 | 

一个直接的解决方案是使类型的 Sendable 一致性显式化:

public struct ColorComponents: Sendable {
    // ...
}

即使在简单的情况下,添加 Sendable 一致性也应该始终谨慎进行。请记住,Sendable 是对线程安全的保证,移除一致性是一个破坏 API 的更改。

预并发导入

即使另一个模块中的类型实际上是 Sendable,也并不总是可能修改其定义。在这种情况下,您可以使用 @preconcurrency 导入来降级诊断,直到库更新。

// ColorComponents 在此定义
@preconcurrency import UnmigratedModule

func updateStyle(backgroundColor: ColorComponents) async {
    // 在这里跨越隔离域
    await applyBackground(backgroundColor)
}

通过添加这个 @preconcurrency 导入,ColorComponents 仍然是非 Sendable 的。但是,编译器的行为将被改变。在使用 Swift 6 语言模式时,这里产生的错误将被降级为警告。Swift 5 语言模式将不会产生任何诊断。

潜在隔离

有时候,对 Sendable 类型的明显需求实际上可能是更基本的隔离问题的症状。类型需要是 Sendable 的唯一原因是要跨越隔离边界。如果您可以完全避免跨越边界,结果通常既更简单,又能更好地反映系统的真实本质。

@MainActor
func applyBackground(_ color: ColorComponents) {
}

func updateStyle(backgroundColor: ColorComponents) async {
    await applyBackground(backgroundColor)
}

updateStyle(backgroundColor:) 函数是非隔离的。这意味着其非 Sendable 参数也是非隔离的。当调用 applyBackground(_:) 时,实现立即从这个非隔离域跨越到 MainActor。

由于 updateStyle(backgroundColor:) 直接与 MainActor 隔离的函数和非 Sendable 类型一起工作,仅仅应用 MainActor 隔离可能更合适。

@MainActor
func updateStyle(backgroundColor: ColorComponents) async {
    applyBackground(backgroundColor)
}

现在,不再有非 Sendable 类型需要跨越的隔离边界。而且在这种情况下,这不仅解决了问题,还消除了对异步调用的需求。修复潜在的隔离问题也可能使进一步的 API 简化成为可能。

这种缺乏 MainActor 隔离的情况是迄今为止最常见的潜在隔离形式。开发者对使用这种解决方案犹豫也很常见。具有用户界面的程序有大量 MainActor 隔离状态是完全正常的。关于长时间运行的同步工作的担忧通常可以通过几个有针对性的 nonisolated 函数来解决。

计算值

与其尝试将非 Sendable 类型跨越边界传递,不如可以使用创建所需值的 Sendable 函数。

func updateStyle(backgroundColorProvider: @Sendable () -> ColorComponents) async {
    await applyBackground(using: backgroundColorProvider)
}

这里,ColorComponents 不是 Sendable 并不重要。通过使用可以计算值的 @Sendable 函数,完全避开了可发送性的缺乏。

发送参数

如果编译器可以证明可以安全地进行,它将允许非 Sendable 值跨越隔离边界。明确声明需要这样做的函数可以在其实现中以更少的限制使用这些值。

func updateStyle(backgroundColor: sending ColorComponents) async {
    // 这个边界跨越现在可以在所有情况下被证明是安全的
    await applyBackground(backgroundColor)
}

sending 参数确实在调用站点施加了一些限制。但是,这仍然可能比添加 Sendable 一致性更容易或更合适。这种技术也适用于您无法控制的类型。

Sendable 一致性

在遇到与跨越隔离域相关的问题时,尝试添加 Sendable 一致性是一个很自然的反应。您可以通过四种方式使类型成为 Sendable。

全局隔离

向任何类型添加全局隔离都将使其隐式成为 Sendable。

@MainActor
public struct ColorComponents {
    // ...
}

通过将此类型隔离到 MainActor,从其他隔离域的任何访问都必须异步完成。这使得可以安全地在域之间传递实例。

Actors

Actors 具有隐式 Sendable 一致性,因为它们的属性受 actor 隔离保护。

actor Style {
    private var background: ColorComponents
}

除了获得 Sendable 一致性外,actors 还接收自己的隔离域。这允许它们在内部自由地使用其他非 Sendable 类型。这可能是一个重要优势,但也有权衡。

因为 actor 的隔离方法都必须是异步的,访问该类型的站点可能需要异步上下文。仅这一点就是谨慎进行此类更改的原因。但更进一步,传入或传出 actor 的数据本身可能需要跨越隔离边界。这可能导致需要更多的 Sendable 类型。

actor Style {
    private var background: ColorComponents

    func applyBackground(_ color: ColorComponents) {
        // 在这里使用非 Sendable 数据
    }
}

通过将非 Sendable 数据和对该数据的操作移入 actor,不需要跨越任何隔离边界。这为这些操作提供了一个 Sendable 接口,可以从任何异步上下文自由访问。

手动同步

如果您有一个已经在做手动同步的类型,您可以通过将 Sendable 一致性标记为 unchecked 来向编译器表达这一点。

class Style: @unchecked Sendable {
    private var background: ColorComponents
    private let queue: DispatchQueue
}

您不应该感到必须移除队列、锁或其他形式的手动同步来与 Swift 的并发系统集成。然而,大多数类型本质上不是线程安全的。作为一般规则,如果一个类型还不是线程安全的,尝试使其成为 Sendable 不应该是您的第一种方法。通常先尝试其他技术更容易,只有在真正必要时才回到手动同步。

追溯性 Sendable 一致性

您的依赖项也可能暴露使用手动同步的类型。这通常只通过文档可见。在这种情况下也可以添加 @unchecked Sendable 一致性。

extension ColorComponents: @retroactive @unchecked Sendable {
}

因为 Sendable 是一个标记协议,追溯性一致性没有直接的二进制兼容性问题。但是,仍然应该非常谨慎地使用它。使用手动同步的类型可能带有条件或例外,这些条件或例外可能与 Sendable 的语义不完全匹配。此外,对于作为系统公共 API 一部分的类型,使用这种技术时应该特别小心。

注意

要了解更多关于追溯性一致性的信息,请参阅相关的 Swift 演进提案

Sendable 引用类型

引用类型可以在没有 unchecked 限定符的情况下被验证为 Sendable,但这仅在非常特定的情况下完成。

要允许检查的 Sendable 一致性,一个类:

  • 必须是 final
  • 除了 NSObject 之外不能继承自另一个类
  • 不能有任何非隔离的可变属性
public struct ColorComponents: Sendable {
    // ...
}

final class Style: Sendable {
    private let background: ColorComponents
}

符合 Sendable 的引用类型有时是值类型可能更可取的标志。但是在需要保持引用语义的情况下,或者在需要与混合 Swift/Objective-C 代码库兼容的情况下,也有其必要性。

使用组合

您不需要选择单一技术来使引用类型成为 Sendable。一个类型可以在内部使用多种技术。

final class Style: Sendable {
    private nonisolated(unsafe) var background: ColorComponents
    private let queue: DispatchQueue

    @MainActor
    private var foreground: ColorComponents
}

background 属性由手动同步保护,而 foreground 属性使用 actor 隔离。结合这两种技术可以得到一个更好地描述其内部语义的类型。通过这样做,该类型继续利用编译器的自动隔离检查。

非隔离初始化

当在非隔离上下文中初始化时,actor 隔离类型可能会出现问题。这经常发生在类型用作默认值表达式或作为属性初始化器时。

注意

这些问题也可能是潜在隔离未充分指定协议的症状。

这里,非隔离的 Stylers 类型正在调用 MainActor 隔离的初始化器。

@MainActor
class WindowStyler {
    init() {
    }
}

struct Stylers {
    static let window = WindowStyler()
}

这段代码会产生以下错误:

 7 | 
 8 | struct Stylers {
 9 |     static let window = WindowStyler()
   |                `- error: main actor-isolated default value in a nonisolated context
10 | }
11 | 

全局隔离的类型有时实际上不需要在其初始化器中引用任何全局 actor 状态。通过使 init 方法成为非隔离的,它可以自由地从任何隔离域调用。这仍然是安全的,因为编译器仍然保证任何隔离的状态只能从 MainActor 访问。

@MainActor
class WindowStyler {
    private var viewStyler = ViewStyler()
    private var primaryStyleName: String

    nonisolated init(name: String) {
        self.primaryStyleName = name
        // 类型在这里完全初始化
    }
}

所有 Sendable 属性仍然可以在此 init 方法中安全访问。虽然任何非 Sendable 属性都不能访问,但它们仍然可以通过使用默认表达式来初始化。

非隔离析构

即使类型具有 actor 隔离,析构器始终是非隔离的。

actor BackgroundStyler {
    // 另一个 actor 隔离类型
    private let store = StyleStore()

    deinit {
        // 这是非隔离的
        store.stopNotifications()
    }
}

这段代码产生错误:

error: call to actor-isolated instance method 'stopNotifications()' in a synchronous nonisolated context
 5 |     deinit {
 6 |         // this is non-isolated
 7 |         store.stopNotifications()
   |               `- error: call to actor-isolated instance method 'stopNotifications()' in a synchronous nonisolated context
 8 |     }
 9 | }

虽然这可能感觉令人惊讶,考虑到这个类型是一个 actor,但这不是一个新的约束。执行析构器的线程从未得到保证,Swift 的数据隔离现在只是暴露了这个事实。

通常,在 deinit 中完成的工作不需要是同步的。解决方案是使用非结构化 Task 首先捕获然后操作隔离值。使用这种技术时,确保不捕获 self(即使是隐式的)至关重要。

actor BackgroundStyler {
    // 另一个 actor 隔离类型
    private let store = StyleStore()

    deinit {
        // 这里没有 actor 隔离,所以任务也不会继承隔离性
        Task { [store] in
            await store.stopNotifications()
        }
    }
}

重要提示

永远不要从 deinit 内部延长 self 的生命周期。这样做会在运行时崩溃。

原文链接

https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems/

Swift
版权声明:如果转发请带上本文链接和注明来源。

lvv.me

iOS/macOS Developer

永久关闭 Firefox 浏览器的自动更新以及提示

在 macOS 上编译 stun 服务器和客户端