美文网首页iOS学习
Swift5.5 并发初探

Swift5.5 并发初探

作者: Flum_X | 来源:发表于2021-07-05 12:37 被阅读0次

    异步函数

    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<Success, Failure> 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<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)
        }
    }
    

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

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

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

    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)
        }
    }
    

    注意点:

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

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

    Actor和类的对比,相同点:

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

    区别:

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

    总结

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

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

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

    相关文章

      网友评论

        本文标题:Swift5.5 并发初探

        本文链接:https://www.haomeiwen.com/subject/bmnbultx.html