Swift5.5 并发初探

异步函数

Swift has built-in support for writing asynchronous and parallel code in a structured way. … the term concurrency to refer to this common combination of asynchronous and parallel code.

       Swift官方文档是这样描述Swift并发的,它指的就是异步和并行代码的组合。并行编程需要解决的主要问题:

  • 如何确保不同运算运行步骤间的交互或是通信按照正确的顺序执行
  • 如何确保运算资源在不同运算之间被安全地共享和访问

为了更容易和更优雅的解决上面两个问题,在Swift5.5中,引入了异步函数的概念。在函数声明的返回箭头前面,加上async关键字,就可以把一个函数声明为异步函数:

func loadSignature() async -> String {
    fatalError("暂未实现")
}

async关键字会帮助编译器做两件事情:

  • 它允许我们在函数体内部使用await关键字;
  • 它要求其他人在调用这个函数时,使用await关键字。

代码举例

需求:从服务器拉取100000条天气数据,求取这些数据的平均值,然后将平均值回传给服务器。

分析:请求服务器的操作都是异步的毋庸置疑,由于数据量过大,求取平均值是个耗时操作,也应该异步处理。

常规代码实现:

func fetchWeatherHistory(completion: @escaping ([Double]) -> Void) {
    // 用随机值来取代网络请求返回的数据
    DispatchQueue.global().async {
        let results = (1...100_000).map { _ in Double.random(in: -10...30) }
        completion(results)
    }
}

func calculateAverageTemperature(for records: [Double], completion: @escaping (Double) -> Void) {
    // 先求和再计算平均值
    DispatchQueue.global().async {
        let total = records.reduce(0, +)
        let average = total / Double(records.count)
        completion(average)
    }
}

func upload(result: Double, completion: @escaping (String) -> Void) {
    // 省略上传的网络请求代码,均返回"OK"
    DispatchQueue.global().async {
        completion("OK")
    }
}

调用实现

fetchWeatherHistory { [weak self] records in
    self?.calculateAverageTemperature(for: records) { average in
        self?.upload(result: average) { response in
            print("Server response: \(response)")
        }
    }
}

存在的问题:

  • 可能存在方法中多次调用或者忘记调用completion的情况;
  • 闭包参数@escaping (String) -> Void难以阅读;
  • 层层嵌套的回调代码看起来很晦涩(所谓的回调地狱);
  • 在swift5.0添加Result类型之前,使用completion handlers返回错误很困难;

async/await实现代码

func fetchWeatherHistory() async -> [Double] {
    (1...100_000).map { _ in Double.random(in: -10...30) }
}

func calculateAverageTemperature(for records: [Double]) async -> Double {
    let total = records.reduce(0, +)
    let average = total / Double(records.count)
    return average
}

func upload(result: Double) async -> String {
    "OK"
}

调用实现

func processWeather() async {
    let records = await fetchWeatherHistory()
    let average = await calculateAverageTemperature(for: records)
    let response = await upload(result: average)
    print("Server response: \(response)")
}

仅仅通过async关键字将函数标记为异步返回值,在调用函数前加上await关键字,让整个调用过程变得简单清晰,就像在编写同步代码一样。

调用流程对比

普通函数的调用流程:(如上图)

  • 调用函数;
  • 函数获取线程的控制权,并完全占有该线程;
  • 函数执行完成返回或者抛出错误,将控制权交还调用方;

这里普通函数放弃线程控制权的唯一方式就是执行完成

异步函数的调用流程:(如上图)

  • 调用函数;
  • 函数获得线程控制权;
  • 函数运行后,挂起,同时放弃对线程的控制,并将控制权交给系统,系统可自由支配该线程;
  • 系统确定何时恢复函数;
  • 函数恢复后重新获得控制权,并继续工作;
  • 函数执行完成或抛出异常后,返回调用方,将控制权交还给调用方;

这里需要注意几点:

  • 一个异步函数挂起时,也会挂起它的调用者,所以调用者也必须是异步的;
  • 异步函数可以多次挂起;
  • 异步函数挂起时,不会阻塞线程;
  • 异步函数可能会在一个完全不同的线程上恢复;
  • async 函数并不一定会挂起;

异步属性

       在Swift5.5中,升级了只读属性,以单独或一起支持asyncthrows关键字,使它们更灵活。

enum FileError: Error {
    case missing, unreadable
}

struct BundleFile {
    let filename: String

    var contents: String {
        get async throws {
            guard let url = Bundle.main.url(forResource: filename, withExtension: nil) else {
                throw FileError.missing
            }

            do {
                return try String(contentsOf: url)
            } catch {
                throw FileError.unreadable
            }
        }
    }
}

因为contents属性同时是asyncthrows,读取时必须使用try await:

func printHighScores() async throws {
    let file = BundleFile(filename: "highscores")
    try await print(file.contents)
}

注意点:

  • 异步属性必须是只读的,可写属性不能声明为异步属性;
  • 异步属性需要有一个明确的getterasync关键字位于get后;
  • 从Swift 5.5 开始,getter也可以抛出异常,如果同时是异步的,则async关键字位于 throws前面;
  • await可用于属性body中的表达式,以表明操作的异步性;

结构化并发

对于同步函数来说,线程决定了它的执行环境。而对于异步函数,则由任务(Task)决定执行环境。Swift提供了一系列Task相关API来让开发者创建、组织、检查和取消任务。这些API围绕着Task这一核心类型,为每一组并发任务构建出一棵结构化的任务树:

  • 一个任务具有它自己的优先级和取消标识,它可以拥有若干个子任务并在其中执行异步函数。
  • 当一个父任务被取消时,这个父任务的取消标识将被设置,并向下传递到所有的子任务中去。
  • 无论是正常完成还是抛出错误,子任务会将结果向上报告给父任务,在所有子任务正常完成之前或者有子任务抛出之前,父任务是不会被完成的。

这些特性看上去和Operation类有一些相似,不过Task直接利用异步函数的语法,可以用更简洁的方式进行表达。而Operation则需要依靠子类或者闭包。

在调用异步函数时,需要在它前面添加await关键字;而另一方面,只有在异步函数中,我们才能使用 await关键字。那么问题在于,第一个异步函数执行的上下文,或者说任务树的根节点,是怎么来的?

简单地使用Task.init就可以让我们获取一个任务执行的上下文环境,它接受一个async标记的闭包:

struct Task where Failure : Error {
    init(
        priority: TaskPriority? = nil, 
        operation: @escaping @Sendable () async throws -> Success
    )
}

它继承当前任务上下文的优先级等特性,创建一个新的任务树根节点,我们可以在其中使用异步函数:

var results: [String] = []

func someSyncMethod() {
    Task {
        try await processFromScratch()
        print("Done: \(results)")
    }
}

func processFromScratch() async throws {
    let strings = await loadFromDatabase()
    if let signature = try await loadSignature() {
        strings.forEach {
            results.append($0.appending(signature))
        }
    } else {
        //throw error
    }
}

processFromScratch中的处理依然是串行的:对loadFromDatabaseawait将使这个异步函数在此暂停,直到实际操作结束,接下来才会执行loadSignature

task-serial.png

我们当然会希望这两个操作可以同时进行,同时,只有当两者都准备好后,才能调用appending来实际将签名附加到数据上。这需要任务以结构化的方式进行组织。使用async let绑定可以做到这一点:

 func processFromScratchNew() async throws {//结构化并发
     async let loadStrings = loadFromDatabase()
     async let loadSignature = loadSignature()
        
     let strings = await loadStrings
     if let signature = try await loadSignature {
         strings.forEach {
             results.append($0.appending(signature))
         }
     } else {
         //throw error
     }
 }

async let被称为异步绑定,它在当前Task上下文中创建新的子任务,并将它用作被绑定的异步函数的运行环境。和Task.init新建一个任务根节点不同,async let所创建的子任务是任务树上的叶子节点,它是结构化的。被异步绑定的操作会立即开始执行,即使在await之前执行就已经完成,其结果依然可以等到 await语句时再进行求值。在上面的例子中,loadFromDatabaseloadSignature将被并发执行。

除了async let外,另一种创建结构化并发的方式,是使用任务组(Task group)。比如,我们希望在执行 loadResultRemotely的同时,让processFromScratch一起运行,可以将两个操作写在同一个task group中:

func someSyncMethod() {
    Task {
        await withThrowingTaskGroup(of: Void.self) { group in
            group.async {
                try await self.loadResultRemotely()
            }
            group.async {
                try await self.processFromScratch()
            }
        }          
        print("Done: \(results)")
    }
}

演员模型

Swift5.5引入了actor,在概念上类似于在并发环境中可以安全使用的类,即需要确保在任何时间只能由单个线程访问actor内的可变状态。

代码演示:创建一个RiskyCollector类,该类能够实现两个收集器对象之间交换牌组中的卡片。

class RiskyCollector {
    var deck: Set

    init(deck: Set) {
        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)
    }
}

在单线程中,代码是安全的,但是在多线程中就不安全了,如果我们同时调用send(card:to:)多次,可能会发生以下事件链:

  • 第一个线程检查卡片是否在牌组中,并且是这样继续。
  • 第二个线程还检查卡片是否在牌组中,并且是这样继续。
  • 第一个线程从牌组中取出卡片并将其转移给另一个人。
  • 第二个线程试图从牌组中取出这张牌,但实际上它已经消失了,所以什么也不会发生。但是,它仍然将卡转让给其他人。

在这种情况下,一个玩家失去1张牌,而另一个玩家得到2张牌,这显然是不合理的。
通过actor模型可以解决这个问题:除非异步执行,否则无法从Actor对象外部读取存储的属性和方法,并且根本无法从 Actor 对象外部写入存储的属性。异步行为不是为了性能;相反,这是因为Swift会自动将这些请求放入一个按顺序处理的队列中,以避免出现竞争条件。因此,我们可以将RiskyCollector类重写为SafeCollectoractor,如下所示:

actor SafeCollector {
    var deck: Set

    init(deck: Set) {
        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)
    }
}

注意点:

  • Actor是使用actor关键字创建的。这是Swift中一种新的具体名义类型,用于连接结构体、类和枚举。
  • send()方法标有async,因为它需要在等待传输完成时暂停其工作。
  • 虽然该transfer(card:)方法没有用标记async,但我们仍然需要用await来调用它,因为它会等到另一个SafeCollector actor能够处理请求。

需要明确的是,actor可以自由地、异步或以其他方式使用自己的属性和方法,但是当与不同的actor交互时,它必须始终异步完成。通过这些更改,Swift可以确保永远不会同时访问所有与actor隔离的状态,更重要的是,这是在编译时完成的,以保证安全。

Actor和类的对比,相同点:

  • 两者都是引用类型,因此它们可用于共享状态。
  • 它们可以有方法、属性、初始值设定项和下标。
  • 它们可以符合协议并且是通用的。
  • 任何静态属性和方法在这两种类型中的行为都相同,因为它们没有self的概念,因此不会被隔离。

区别:

  • Actors 目前不支持继承。
  • Actors 遵循新的Actor协议。

总结

Swift并发的概念很多,但是各种的模块边界是清晰的:

  • 异步函数:提供语法工具,使用更简洁和高效的方式,表达异步行为。
  • 结构化并发:提供并发的运行环境,负责高效的异步函数调度、取消和执行顺序。
  • 演员模型:提供封装良好的数据隔离,确保并发代码的安全。

熟悉这些边界,有助于我们清晰地理解 Swift 并发各个部分的设计意图,从而让我们手中的工具可以被运用在正确的地方。

你可能感兴趣的:(Swift5.5 并发初探)