Swift 中的 Sendable 和 @Sendable 闭包

510 阅读11分钟

原文:What are Sendable and @Sendable closures in Swift? – Donny Wals

Swift 团队对 Swift 并发功能的目标之一是提供一个模型,使开发人员能够在默认情况下编写安全代码。这意味着投入了大量的时间和精力来确保 Swift 编译器能够帮助开发者检测并完全防止整个类型错误和并发问题。

帮助你防止数据竞赛(一种常见的并发问题)的功能之一是我以前写过的 Actors 的形式。

虽然当你想同步访问一些易变的状态时,Actors 是很好的,但它们并不能解决你在并发代码中可能遇到的所有问题。

在这篇文章中,我们将仔细研究 Sendable 协议,以及闭包的 @Sendable 注解。在这篇文章的最后,你应该对 Sendable(和 @Sendable)旨在解决的问题有一个很好的理解,它们是如何工作的,以及你如何在你的代码中使用它们。

理解 Sendable 所解决的问题

并发程序中最棘手的方面之一是确保数据的一致性。或者换句话说,就是线程安全。当我们在一个不做太多并发工作的应用程序中传递类或结构的实例、枚举 case,甚至是闭包时,我们不需要经常担心线程安全问题。在不真正执行并发工作的应用程序中,两个任务试图在完全相同的时间访问和 / 或改变一块状态的可能性不大。(但也不是不可能)

例如,你可能会从网络上抓取数据,然后将获得的数据传递给主线程上的几个函数。

由于主线程的性质,你可以安全地假设你所有的代码都是按顺序运行的,而且你的应用程序中没有两个进程会同时在同一个引用 a 上工作,可能会产生数据竞赛。

简要定义一下数据竞赛,它是指你的**两个或多个部分代码试图访问内存中的相同数据,并且这些访问中至少有一个是写操作。**当这种情况发生时,你永远无法确定读和写发生的顺序,你甚至会因为糟糕的内存访问而遇到崩溃。总而言之,数据竞赛是不好玩的。

虽然 Actors 是构建正确隔离和同步访问其可变状态的对象的绝佳方式,但它们不能解决我们所有的数据竞赛。更重要的是,对你来说,使用 Actors 重写所有的代码可能并不合理。

考虑一下像下面这样的代码:

class FormatterCache {
    var formatters = [String: DateFormatter]()

    func formatter(for format: String) -> DateFormatter {
        if let formatter = formatters[format] {
            return formatter
        }

        let formatter = DateFormatter()
        formatter.dateFormat = format
        formatters[format] = formatter

        return formatter
    }
}

func performWork() async {
    let cache = FormatterCache()
    let possibleFormatters = ["YYYYMMDD", "YYYY", "YYYY-MM-DD"]

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<10 {
            group.addTask {
                let format = possibleFormatters.randomElement()!
                let formatter = cache.formatter(for: format)
            }
        }
    }
}

乍一看,这段代码可能看起来并不太坏。我们有一个类,作为一个简单的日期格式化器的缓存,我们有一个任务组,将并行地运行一堆代码。每个任务将从可能的格式列表中随机抓取一个日期格式,并向缓存索取一个日期格式。

理想情况下,我们希望格式化缓存只为每种日期格式创建一个日期格式,并在创建一个格式后返回一个缓存的格式。

然而,由于我们的任务是并行运行的,所以这里有可能出现数据竞赛。一个快速的解决方法是使我们的 FormatterCache 成为一个 actor,这将解决我们潜在的数据竞赛。虽然这将是一个很好的解决方案(如果你问我,实际上是最好的解决方案),但当我们试图编译上面的代码时,编译器告诉我们一些别的东西。

Capture of 'cache' with non-sendable type 'FormatterCache' in a @Sendable closure. 在一个 @Sendable 闭包中使用 non-sendable 类型 "FormatterCache" 捕获 "cache"。

这个警告试图告诉我们,我们正在做一些潜在的危险。我们正在捕获一个无法安全通过并发边界的值,而这个闭包应该是安全通过并发边界的。

💡 If the example above does not produce a warning for you, you'll want to enable strict concurrency checking in your project's build settings for stricter Sendable checks (amongst other concurrency checks). You can enable strict concurrecy settings in your target's build settings. Take a look at this page if you're not sure how to do this. 如果上面的例子没有向你发出警告,你会想在你项目的构建设置中启用严格的并发性检查,以进行更严格的 Sendable 检查(在其他并发性检查中)。你可以在你的目标的构建设置中启用严格的并发性设置。如果你不确定如何做到这一点,请看看这个页面

能够安全地通过并发边界,本质上意味着一个值可以安全地从多个任务中并发访问和突变,而不会引起数据竞赛。Swift 使用 Sendable 协议和 @Sendable 注解来向编译器传达这种线程安全要求,然后编译器可以通过满足 Sendable 要求来检查一个对象是否确实是 Sendable

这些要求到底是什么,将根据你所处理的对象的类型而有一些不同。例如,actor 对象默认是 Sendable 的,因为他们有内置的数据安全。

让我们看一下其他类型的对象,看看它们的 Sendable 要求到底是什么。

Sendable 类型和值类型

在 Swift 中,值类型提供了大量的线程安全,开箱即用。当你把一个值类型从一个地方传到下一个地方时,会创建一个副本,这意味着每个持有你的值类型副本的地方都可以自由地突变它的副本而不影响代码的其他部分。

这是结构体相对于类的一个巨大的好处,因为它们允许我们对代码进行局部推理,而不必考虑我们代码的其他部分是否有对我们对象的同一实例的引用。

由于这种行为,像结构体和枚举这样的值类型,只要其所有成员也是 Sendable 的,就默认为 Sendable 的。

让我们看一个例子:

// This struct is not sendable
struct Movie {
    let formatterCache = FormatterCache()
    let releaseDate = Date()
    var formattedReleaseDate: String {
        let formatter = formatterCache.formatter(for: "YYYY")
        return formatter.string(from: releaseDate)
    }
}

// This struct is sendable
struct Movie {
    var formattedReleaseDate = "2022"
}

我知道这个例子有点奇怪,它们的功能并不完全相同,但这不是重点。

重点是,第一个结构并没有真正持有可变的状态;它的所有属性要么是常量,要么是计算的属性。然而,FormatterCache 不是一个 Sendable 的类。由于我们的 Movie 结构并不持有 FormatterCache 的副本,而是一个引用,Movie 的所有副本都会查看 FormatterCache 的相同实例,这意味着如果多个 Movie 副本试图与 FormatterCache 交互,我们可能会看到数据竞赛。

第二个结构只持有 Sendable 状态。StringSendable 的,由于它是 Movie 上定义的唯一属性,所以 movie 也是 Sendable 的。

这里的规则是,只要其成员也是 Sendable 的,所有值类型都是 Sendable 的。

一般来说,编译器会在需要时推断出你的结构是 Sendable 的。然而,如果你愿意,你可以手动添加 Sendable 一致性。

struct Movie: Sendable {
    let formatterCache = FormatterCache()
    let releaseDate = Date()
    var formattedReleaseDate: String {
        let formatter = formatterCache.formatter(for: "YYYY")
        return formatter.string(from: releaseDate)
    }
}

Sendable 和类

虽然结构体和角色都是隐式可发送的,但类不是。这是因为类在本质上是不太安全的;每个收到类的实例的人实际上都会收到对该实例的一个引用。这意味着你的代码中的多个地方都持有对完全相同的内存位置的引用,而且你对一个类的实例所做的所有突变都会在持有对该类实例的引用的所有者之间共享。

这并不意味着我们不能使我们的类成为 Sendable 的,这只是意味着我们需要手动添加一致性,并手动确保我们的类确实是 Sendable

我们可以通过添加与 Sendable 协议的一致性来使我们的类成为 Sendable 的:

final class Movie: Sendable {
    let formattedReleaseDate = "2022"
}

对类的 Sendable 要求与对结构的要求相似。

例如,只有当一个类的所有成员都是 Sendable 的,它才是 Sendable。这意味着它们必须是 Sendable 的类、值类型或角色。这个要求与对 Sendable 结构的要求相同。

除了这个要求之外,**你的类必须是 final 的。**如果一个子类增加了不兼容的重写或功能,继承可能会破坏你的 Sendable 一致性。出于这个原因,只有 **final** 类可以被做成 Sendable 类。

**最后,你的 Sendable 类不应该持有任何可变的状态。**可变的状态将意味着多个任务可以尝试突变你的状态,从而导致数据竞赛。

然而,在有些情况下,我们可能知道一个类或结构是安全的,可以跨并发边界传递,即使编译器不能证明这一点。

在这些情况下,我们可以退回到未勾选的 Sendable

未检查的 Sendable 一致性

当你在使用 Swift 并发性之前的代码库时,你有可能在你的应用程序中慢慢地引入并发性功能。这意味着你的一些对象需要在你的异步代码以及同步代码中工作。这意味着使用 actor 来隔离引用类型中的易变状态可能行不通,所以你只能用一个不能符合 Sendable 的类。例如,你可能有类似下面的代码:

class FormatterCache {
    private var formatters = [String: DateFormatter]()
    private let queue = DispatchQueue(label: "com.dw.FormatterCache.\(UUID().uuidString)")

    func formatter(for format: String) -> DateFormatter {
        return queue.sync {
            if let formatter = formatters[format] {
                return formatter
            }

            let formatter = DateFormatter()
            formatter.dateFormat = format
            formatters[format] = formatter

            return formatter
        }
    }
}

这个格式化缓存使用一个串行队列来确保对其格式化字典的同步访问。虽然这个实现并不理想(我们可以使用一个屏障,甚至可以使用一个普通的锁来代替),但它是有效的。然而,我们不能为我们的类添加 Sendable 的一致性,因为 formatters 不是 Sendable

为了解决这个问题,我们可以在 FormatterCache 中添加 @unchecked Sendable 一致性:

class FormatterCache: @unchecked Sendable {
    // implementation unchanged
}

通过添加这个 @unchecked Sendable,我们指示编译器假设我们的 FormatterCacheSendable 的,即使它不符合所有的要求。

在我们的工具箱里有这个功能是非常有用的,当你慢慢地把 Swift 并发放到一个现有的项目中时,但当你伸手去拿 @unchecked Sendable 时,你要三思,甚至三思。只有当你真的确定你的代码可以在并发环境中安全使用时,你才应该使用这个功能。

在闭包上使用 @Sendable

还有一个地方 Sendable 会发挥作用,那就是在函数和闭包上。

Swift 并发中的很多闭包都用 @Sendable 注解进行了注释。例如,这里是 TaskGroupaddTask 的声明,看起来是这样的:

public mutating func addTask(priority: TaskPriority? = nil, operation: @escaping **@Sendable** () async -> ChildTaskResult)

传递给 addTask 的操作闭包被标记为 @Sendable。这意味着该闭包捕获的任何状态都必须是 Sendable 的,因为该闭包可能会被跨并发边界传递。

换句话说,这个闭包将以并发的方式运行,所以我们要确保我们不会意外地引入数据竞赛。如果闭包捕获的所有状态都是 Sendable 的,那么我们就可以确定闭包本身是 Sendable 的。或者换句话说,我们知道闭包可以在并发环境中安全地被传递。

💡 提示:要想了解更多关于 Swift 中的闭包,请看我的文章,里面详细解释了闭包。

总结

在这篇文章中,你已经了解了 Swift 并发的 Sendable 和 @Sendable 功能。你了解了为什么并发程序需要围绕可变状态和跨并发边界传递的状态的额外安全,以避免数据竞赛。

你了解到,如果结构的所有成员都是 Sendable 的,那么结构就是隐含了 Sendable。你还了解到,只要类是 final 的,并且其所有成员也是 Sendable 的,就可以成为 Sendable

最后,你了解到闭包的 @Sendable 注解可以帮助编译器确保闭包中捕获的所有状态都是 Sendable 的,并确保在并发环境中调用该闭包是安全的。

我希望你喜欢这篇文章。如果你有任何问题、反馈或建议来帮助我改进参考资料,请随时在 Twitter 上与我联系。