Swift 5.5 新特性(下)

1,592 阅读10分钟

本文接《Swift 5.5 新特性(上)》,继续介绍 Swift 并发相关的新特性

TaskGroup

对于更复杂的并发任务,我们可以创建 TaskGroup,这是一组协同工作的任务集合。

为了减少错误使用任务组的风险,Swift 没有为任务组提供 public 构造器,任务组是通过像 withTaskGroup() 这样的函数创建的。我们把要执行的任务放在这个函数的尾随闭包中,闭包则为我们提供了 TaskGroup 实例。借助该实例的 async() 方法,我们可以立即启动一个任务。

示例代码如下:

func printMessage() async {
    let string = await withTaskGroup(of: String.self) { group -> String in
        group.async { "Hello" }
        group.async { "From" }
        group.async { "A" }
        group.async { "Task" }
        group.async { "Group" }

        var collected = [String]()

        for await value in group {
            collected.append(value)
        }

        return collected.joined(separator: " ")
    }

    print(string)
}

提示async 调用中可以使用任何函数,只要确保任务组中的所有任务返回相同类型的数据。对于某些复杂的工作,我们可能需要返回带关联值的枚举。这种情况我们也可以引入 async let 绑定来解决。

如果任务组中的代码可能抛出错误,我们可以在组中直接处理错误,也可以让这些错误向上抛到组外处理。或者,使用 withThrowingTaskGroup() 函数。如果没有捕获所有可能的错误,则调用这个函数需要使用 try

例如,下面的代码的演示了在一个组内读取多个地点的天气指数,最后返回平均值:

func printAllWeatherReadings() async {
    do {
        print("Calculating average weather…")

        let result = try await withThrowingTaskGroup(of: [Double].self) { group -> String in
            group.async {
                try await getWeatherReadings(for: "London")
            }

            group.async {
                try await getWeatherReadings(for: "Rome")
            }

            group.async {
                try await getWeatherReadings(for: "San Francisco")
            }

            // 将所有的数组展平为单个数组
            let allValues = try await group.reduce([], +)

            // 计算平均值
            let average = allValues.reduce(0, +) / Double(allValues.count)
            return "平均温度为 \(average)"
        }

        print("完成! \(result)")
    } catch {
        print("计算数据时出错")
    }
}

任务组提供了 cancelAll() 方法,可以取消一个组内现有的全部任务,但后续的 async() 调用仍会继续往组内添加任务。我们可以使用 asyncUnlessCancelled() 来确保任务只有在任务组没有取消的情况下才会被添加。

async let 绑定

SE-0317 引入了通过 async let 简单语法来创建子任务的特性。当你要处理多个异构的异步返回类型时,它相对于任务组是一种更方便的选项。

演示代码如下,我们创建一个结构体,它有三个不同类型的属性,通过三个异步函数来获取:

struct UserData {
    let username: String
    let friends: [String]
    let highScores: [Int]
}

func getUser() async -> String {
    "Taylor Swift"
}

func getHighScores() async -> [Int] {
    [42, 23, 16, 15, 8, 4]
}

func getFriends() async -> [String] {
    ["Eric", "Maeve", "Otis"]
}

async let 的使用方式很简单 —— 它以异步方式运行。我们可以令每个属性的获取都采用 async let,然后等待三个属性都返回,最后用于创建对象。

func printUserDetails() async {
    async let username = getUser()
    async let scores = getHighScores()
    async let friends = getFriends()

    let user = await UserData(name: username, friends: friends, highScores: scores)
    print("我的名字是 \(user.name), 我有 \(user.friends.count) 个朋友!")
}

重要: async let 必须在异步上下文中使用,如果你没有显式地等待 async let 的结果,Swift 会隐式地等待它。

对于可能抛出错误的函数,我们并不需要在 async let 之前使用 try,因为捕获是推迟到 await 结果的时刻才需要的。同理,await 关键字也是隐式的。所以,当我们在使用 async let 时,不必书写成 async let result = try await someFunction() 这样的形式,只需要书写 async let result = someFunction()

演示代码如下:

enum NumberError: Error {
    case outOfRange
}

func fibonacci(of number: Int) async throws -> Int {
    if number < 0 || number > 22 {
        throw NumberError.outOfRange
    }

    if number < 2 { return number }
    async let first = fibonacci(of: number - 2)
    async let second = fibonacci(of: number - 1)
    return try await first + second
}

在上面的代码中,fibonacci(of:) 的调用隐式等价于 try await fibonacci(of:) 调用,但我们将其延后到之后的 try await 处理。

用同步代码继续异步任务

SE-0300 引入了一个新的函数用于帮助我们适配旧有的,completion handler风格的API到现代异步风格的代码。

例如,下面的函数通过一个 completion handler 返回数值:

func fetchLatestNews(completion: @escaping ([String]) -> Void) {
    DispatchQueue.main.async {
        completion(["Swift 5.5 release", "Apple acquires Apollo"])
    }
}

假如我们希望使用 async/await,那我们可能需要重写该函数。但现实中我们可能因为各种原因不具备重写的条件 —— 比如这个函数来自外部的库,等等。

Continuations 使得我们可以桥接 completion handler 和异步函数,用更现代的 API 风格包装旧有的代码。比如,withCheckedContinuation() 函数可以创建一个 continuation,供你在其中运行任意代码,continuationresume(returning:) 方法作为中继接收 completion 中的数据,进而转换为 async 风格的函数返回值。 我们包装的 async 版本的函数就可以使用 await 语法以同步风格来使用了:

func printNews() async {
    let items = await fetchLatestNews()

    for item in items {
        print(item)
    }
}

术语 checked 含义是 Swift 会在运行时检查我们的行为:我们是调用了一次 resume 还是多次调用 resume?这一点很重要,因为假如我们永远不继续 continuation 会泄漏资源,而多次调用则会遇到问题。

这里明确说明一下,continuation 必须且只能 resume 一次。

除了提供运行时检查的 continuation, Swift 还提供一个 withUnsafeContinuation 函数,其工作机制与 withCheckedContinuation 函数仅有是否提供运行时检查的区别。

当然,前者存在一定是有原因的。当你对性能有要求时,可以在代码正式实装的时候切换到无运行时检查但是性能更好的 withUnsafeContinuation 版本。

Actors

SE-0306 引入了 actor,这是一个概念上与类十分相似但在并行环境下可以安全使用的新类型。之所以能实现并行环境的安全使用,是因为 Swift 确保 actor 中的可变状态在任意时刻都只能被一个线程访问,这个机制可以在编译器层面消除多种严重的 Bug。

为了说明 actor 解决的问题,我们以下面的代码为例。想象有一个叫 RiskyCollector 的类,它可以用来和其他 collector 交易卡牌。

class RiskyCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

    func send(card selected: String, to person: RiskyCollector) -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

在单线程环境下上面的代码是安全的:我们检查套牌中是否包含选择的卡牌,移除它,转移给其他 collector 的套牌。但是,在多线程环境下我们的代码有潜在的竞争条件。

如果我们在同一时间调用了 send(card:to:) 多于一次,那么下面的情况就可能发生:

  1. 第一个线程检查发现卡牌在套牌中,继续执行。

  2. 第二个线程也检查发现卡牌在套牌中,也继续执行。

  3. 第一个线程从套牌中移除目标卡牌并转移给其他人。

  4. 第二个线程也试图从套牌中移除卡牌,但卡牌实际上已经不在了,但它又一次被转移给了其他人。

如上所述,出现了一个玩家少了一张卡牌而另外的玩家获得两张卡牌的情况。

Actor 通过引入隔离机制解决这个问题:actor 中的存储属性和方法在 actor 对象外部是无法读取的,除非读取的代码是异步的;而写入则是完全限制在 actor 对象内部。同时读取的异步限制并非处于性能的考虑,而是因为 Swift 会自动地将读取请求放入队列中串行处理,从而避免竞争条件。

因此,我们可以将 RiskyCollector 类重写为一个 SafeCollector actor,像下面这样:

actor SafeCollector {
    var deck: Set<String>

    init(deck: Set<String>) {
        self.deck = deck
    }

    func send(card selected: String, to person: SafeCollector) async -> Bool {
        guard deck.contains(selected) else { return false }

        deck.remove(selected)
        await person.transfer(card: selected)
        return true
    }

    func transfer(card: String) {
        deck.insert(card)
    }
}

​ 有几点需要我们注意:

  1. Actors 是通过新的 actor 关键字创建的。这是一个 Swift 5.5 引入的新的类型,和 struct, class, eunm 并存。
  2. send() 方法之所以被标记为 async,这是因为它会被挂起,直到转移动作完成。
  3. 尽管 transfer(card:) 方法并没有被标记为 async,我们仍然需要用 await 来调用它,这是因为这个过程需要等待其他 SafeCollector actor 处理请求。(因为方法的 receiver 已经不是自己,而是另一个 actor)

再明确一下,一个 actor 只能自由地使用它自己的数学和方法,无论异步与否,而与其他 actor 交互则总是异步完成的。基于这些设定,Swift 能够确保所有的 actor 隔离的状态永远不会被同时访问。重要的是,这些都是在编译期完成的,所以能保证并发的安全性。

Actor 和 Class 有以下相似点:

  • 两者都是引用类型,因此可以用于共享状态,
  • 两者都可以有方法,属性,构造器和下标。
  • 两者都可以遵循协议,可以是泛型。
  • 两者的静态属性和静态方法的行为完全一致,因为静态属性和静态方法没有 self 的概念,所以不涉及数据的隔离。

Actor 和 Class 的重要区别:

  • Actor 当前还不支持继承,这使得它们的构造器更简单 —— 不需要便利构造器,重写和 final 关键字。这一点未来版本的 Swift 可能会改变。
  • 所有的 actor 都隐式遵循了 Actor 协议,其他的具体类型都不能使用这个协议。

对于解释 actor 和 class 的区别,我听过的最好的一种描述是:“actor 传递消息,而不是内存”。因此,不同于直接操作其他人的属性,调用它们的方法,我们传递消息来请求其他人改变数据,并且让 Swift 运行时来保驾护航。

全局 actor

SE-0316 使得全局状态可以借助 actor 实现隔离从而避免竞争条件。

虽然理论上我们应用很多全局 actor,不过目前我们主要可以用 @MainActor 全局 actor 来标记只允许在主线程中访问的属性和方法。

例如,我们可能有一个类专门处理 app 中的数据存储。出于安全考虑,如果不在主线程,所有持久化存储的写入操作都会被拒绝。

class OldDataController {
    func save() -> Bool {
        guard Thread.isMainThread else {
            return false
        }

        print("Saving data…")
        return true
    }
}

上面的代码当然可以工作,但是有了 @MainActor,我们可以确保 save() 方法总是在主线程上执行,如同我们显式使用 DispatchQueue.main 来运行它:

class NewDataController {
    @MainActor func save() {
        print("Saving data…")
    }
}

Swift 会确保 save() 方法总是是在主线程上执行。

注意:因为我们是通过 actor 来推进工作的,所以必须使用 await, async let 或者类似的异步手段来调用 save()

@MainActor 实际上是 MainActor 结构体的全局 actor 包装。

Sendable@Sendable 闭包

SE-0302 对“可发送的”数据提供了支持,所谓“可发送的”数据指的是可以被安全地转移到另 一个线程的数据。这个新特性是通过 Sendable 协议和 @Sendable 函数属性来实现的。

有很多类型,跨线程发送时天然就是安全的:

  • 所有的 Swift 值类型,包括 Bool, Int, `String 等
  • 包装数据是值类型的可选型
  • 包含值类型的标准库集合,例如 Array<String> 或者Dictionary<Int, String>`
  • 元素全是值类型的元组
  • 元类型,比如 String.self

所有这些都已经升级,遵循了 Sendable 协议。

对于自定义类型,分几种情况:

  • Actors 自动遵循 Sendable ,因为它们的同步是在类型内部实现的。
  • 如果只包含遵循 Sendable 的属性,那么自定义结构体和枚举也会自动遵循 Sendable ,这与 Codable 的工作方式相似。
  • 自定义类只有满足这些条件才能遵循 Sendable:继承自 NSObject 或者没有继承其他类;所有属性都是常量且遵循 Sendable ,类以 final 标记阻断自身被继承。

我们用 @Sendable 属性来标记函数在并发环境中工作,这能够强制施加多种规则,从而确保我们不会犯错。例如,我们传给 Task 构造器的的操作是被标记为 @Sendable 的,这意味着只有昂 Task 捕获的数据是常量时操作才是被允许的。

func printScore() async { 
    let score = 1

    Task { print(score) }
    Task { print(score) }
}

当上面的 score 是一个变量时,Task 的代码块是不被允许的,因为可能出现一个任务正在访问它的值而其他任务在修改它的值。

你也可以用 @Sendable 标记你自己的函数和闭包,这会强制施加相似的规则给捕获的值:

func runLater(_ function: @escaping @Sendable () -> Void) -> Void {
    DispatchQueue.global().asyncAfter(deadline: .now() + 3, execute: function)
}

更多文章,欢迎关注微信公众号:Swift花园