Swift结构化并发

592 阅读10分钟

Swift结构化并发编程

Swift 并发模型的设计理念,是为了保证在运行时控制线程的数量,在理想状态下使线程数量不超过 CPU 核心数量,而 Swift 引入的结构化并发模型,例如async/await、Task Group、Actors 等特性,都可以帮助我们完成此目标。

一些基本概念 
  • 同步和异步

    - 线程的执行方式:同步和异步是最 基本概念。同步操作意味着在操作完成之前,运行这个操作的线程都将被占用,直到函 数最终被抛出或者返回

    - swift5.5异步函数之前所有函数都是同步函数

    - 异步操作:把耗时任务放到子线程,任务完成时回到主线程回调,其本质仍是同步函数,耗时任务的方法会占据主线程等待回调

    - 同步函数的调用方式

        - 每个线程中都有其各自的函数栈

        - 当在某个线程调用一个函数时,会将此函数帧压入栈中,函数帧保存着函数的必要信息和局部变量等

        - 当函数调用完成并返回时,此函数帧会从栈中弹出

    - 异步函数调用

       - 同时在栈和堆中各增加一个函数帧

       - 栈中保存只在函数内部使用的局部变量等内容

       - 堆中保存异步相关的内容

  • 串行和并行

    - 串行:在同一线程中按严格的先后顺序发生

    - 并行:在不同的线程中同时执行

  • 并发:通过一些方式组织程序,可以分成多个模块去独立执行,并行必然是需要多核,单核是无法并行的,但是程序基于单核是可以并发
异步函数

异步函数Async: 在函数声明的返回箭头前面,加上async 关键字

    1.函数体内部使用 await 关键字

    2.外部函数调用时,使用 await 关键字。

 - await关键字: 代表了函数在此处可能 会放弃当前线程,它是程序的潜在暂停点(称为挂起点 suspension point)

 - 和同步函数最大的不同在于,异步函数可以放弃自己当前占有的线程。会把异步函数的运行理解为:编译器用await关键字把异步函数切割成多个部分,每个部分拥有自己分离的存储空间,并可以由运行环境进行调度。我们可以把每个这种被切割后剩余的执行单元称 作续体(continuation),而一个异步函数,在执行时,就是多个续体依次运行的结果

 -  续体(continuation)来保证线程的可持续使用,避免了由于线程阻塞导致的线程数量膨胀和线程上下文切换带来的额外开销,从而达到 Swift 并发模型的极致目标:线程数量等同于 CPU 核心数量

  • 下面是一个频道channel列表的简化流程,发起网络请求,获取chanels,更新到本地数据库,更新主线程UI
 func loadChannels() async throws {

    let channels = try await fetchChannels()

    // 获取频道列表更新主线程UI

 }

 

 fucn fetchChannels() async throws -> \[Channel]{

     let (data, respoonse) = try await URLSession.shared.data(from: channel.url)

    let channels = dataformat(from: data)

    await saveDataBase(with: channles)

    return channels

 }

 

  func saveDataBase(with channels: \[Channel]) async throws {

    await database.save(channels);

    // 其他同步操作

  }

结构化并发

 结构化并发:可以理解为即使都进行并发操作, 也保证控制流路径的单一入口和单一出口。 程序可以产生多个控制流来实现并发,但是所有的并发路径在出口时都应该处于完成 (或取消) 状态,并合并到一起

 对于同步函数来说,线程决定了它的执行环境。而对于异步函数,则由任务 (Task) 决定执行环境。

Task可以让我们获取一个任务执行的上下文环境,它接受一个 async 标记的闭包


    // CashoutShareSchemeOperator瓜分红包(新年红包) class 同步方法
    override func operation(with url: URL, current webViewController: WebPage) {
        if let image = cashoutPictureCache {
            showCashoutShareSheet(image: image, inViewController: webViewController)
        } else {
            let imageURL = UserProfileUpdateLogic.shared.serverConfig.qrcodeUrl
            Task {
                let image = try await donwloadImage(with: imageURL)
               // 后续操作
            }
        }
    }    

    // ShareTool class
    static func donwloadImage(url: URL) async throws -> UIImage {
        let image = try await ShareTool.loadImage(with: url)
        return image
    }
  
    // 异步函数下载网络图片

    @available(iOS 15.0.0, *)
    static func loadImage(with url: URL?) async throws -> UIImage {
        guard let url = url else { fatalError("URL不能为空") }
        let (data, response) = try await URLSession.shared.data(from: url)
        guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
            fatalError("网络错误")
        }
        guard let image = UIImage(data: data) else {
            fatalError("错误的图片")
        }
        return image
    }

Swift创建结构化并发有2种方式

1.任务组(TaskGroup):在某个异步函数中,我们可以通过 withTaskGroup和抛出版本withThrowingTaskGroup为当前的任务添加一组结构化的并发子任务

    static func donwloadImages(urls: [URL]) async throws  -> [URL: UIImage?] {
        var images: [URL: UIImage?] = [:]
        if urls.isEmpty {return images}
        try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in
            for url in urls {
                group.addTask {
                    return (url, try await ShareTool.loadImage(with: url))
                }
            }
            for try await (url, image) in group {
                images[url] = image
            }
        }
        return images
    } 

2.异步绑定(async let):在当前Task上下文种创建新的子任务,并将它用作被绑定的异步函数 (也就是 async let 右侧的表达式) 的运行环境。和 Task.init 新建一个任务根节点不同, async let所创建的子任务是任务树上的叶子节点

    static func donwloadImages(avatarURL: URL?, otherURL: URL?) async throws ->(UIImage?, UIImage?) {
        async let avatarImage = await loadImage(with: avatarURL)
        async let otherImage = await loadImage(with: otherURL)
        return try await (avatarImage, otherImage)
    }

用法区别:当在运行时才知道任务数量时或是我们需要为不同的子任务设置不同优先级时, 我们将只能选择使用 Task Group。在其他大部分情况下,async let 和 task group 可以混用甚 至互相替代:

 Swift 提供了一系列 Task 相关 API 来让开发者创建、组织、检查和取消任务。这些 API 围绕着 Task 这一核心类型,为每一组并发任务构建出一棵结构化的任务树

  • 一个任务具有它自己的优先级和取消标识,它可以拥有若干个子任务并在其中执行异步函数。

  • 当一个父任务被取消时,这个父任务的取消标识将被设置,并向下传递到所有的子任务中去。

  • 无论是正常完成还是抛出错误,子任务会将结果向上报告给父任务,在所有子任务完成之前 (不论是正常结束还是抛出),父任务是不会完成的。

Actors 

actor 是 Swift 并发模型中新增的语言特性,与Class 一样,Actor 也是基本类型,并且为引用类型。actor 最重要的一个特性是,任何actors类型中的可变状态,在同一时间只运行一个任务访问

访问actor数据类型时, 内部会提供一个隔离域:

  • actor内部对属性或者其他方法方位, 不加任何限制, 处于隔离域内部

  • actor外部对属性或方法访问时,会要求切换到actor的隔离域,保证数据安全,使用await换为异步函数

原理:内部有个串行执行器(executor),保护内部状态

actor User {
    var name: String = ""
    /// 关注数
    var followingCount = 0 
    func follow() {
        followingCount += 1
        return followingCount
    }
}

class UserViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let user  = User()
        // 关注数增加
        let followingCount = await user.follow()
        let name = await user.name
    }
}
协同式线程池(cooperative thread pool)

Swift 并发在底层使用的是一种新实现的协同式线程池的调度方式:由一个cooperative串行队列负责调度工作,它将函数中剩余的运行内容被抽象为更轻量的续体,来进行调度。实际的工作运行由一个全局的并行队列负责,具体的工作可能会被分配到至多为 CPU 核心数量的线程中

采用的是非阻塞队列,为了保证队列对应的线程不被阻塞。运行在这个线程上的异步函数需要具有放弃线程的能力,让线程去执行其他操作

协同式队列的调度需要具有额外的能力,把还未执行的函数部分和必要的变量包装起来,作为续体暂存到其他地方 (比如堆上),然后等待空闲的线程去 执行它。Swift并发的调度器会组织这些续体,让它们在线程上运行

非阻塞队列和线程的保证解决了如何有效进行异步和并发调度的问题

执行器(executor)

执行器实际负责创建线程并保证接受协同式调度的线程不多于CPU 核心数

 Swift 并发提供了两种类型的执行:

 1. 全局的并发执行器:负责寻找合适的并发 队列来为并发操作提供线程

 2. 串行执行器:主要被用在 actor 中。每个actor会持有一个串行执行器,它负责保证 actor 隔离域的方法在串行队列中执行

    public protocol Executor: AnyObject, Sendablefunc enqueue(\_ job: UnownedJob)
    }

    public protocol SerialExecutor: Executor {
        func enqueue(\_ job: UnownedJob)
        func asUnownedSerialExecutor() -> UnownedSerialExecutor
    }

    public protocol Actor: AnyObject, Sendable {
        nonisolated var unownedExecutor: UnownedSerialExecutor { get }
    }

 每次在actor隔离域外对actor的调用,会被转换为一次执行器的 enqueue 方法,执行器负责通过协同式线程池以串行方式为这些工作分配合适的线程

图解异步线程流程

 1.当执行 operation 时,Task.init 首先被入栈,是一个普通的同步方法,在执行完毕后立即出栈,operation 函数随之结束。通过 Task.init 参数闭包传入的await donwloadImage(),被提交给协同式线程池,如果协同式调度队列正在执行其他工作,那么它被存放在堆上,等待空闲线程处理

1639543885085.jpg

2.当空闲线程可以运行协同式调度队列中的工作时,执行器读取 donwloadImage 并将它推入这个线程的栈上,开始执行

(注意点:空闲线程和operation所在线程可以不是同一个线程)

1639545792071.jpg

3.当执行到 await loadImage 方法调用时,它是一个异步函数调用。为了不阻塞当前线程,异步函数 donwloadImage 可能会在此处暂停并放弃线程, 形成挂起点,当前的执行环境(loadImage方法)会被记录到堆中,以便之后它在调度栈上继续运行。执行器此时会在堆中查找优先级高的任务执行,有更高优先级任务此时需要被挂起等待,形成一个续体(Contiuation); 当堆中没有比 loadImage 任务更高的优先级,就会执行 loadImage方法

WX20211215-132814@2x.png

4.当 loadImage 方法执行时,就会被放到空闲线程的函数栈栈顶,内部函数 await data:from的方法重复上面第三步操作

WX20211215-133327@2x.png

WX20211215-133523@2x.png

5.当 data:from 方法执行完成,就会从堆中移除时,空闲线程的函数栈栈顶就会替换成loadImage,执行后面的同步函数

WX20211215-133327@2x.png

6.当 loadImage 方法执行完成时,会从堆中移除时,空闲线程的函数栈栈顶就会替换成donwloadImage,执行后面的同步函数

1639545792071.jpg

至此一个 async/await 的调用过程结束,我们可以从其调用过程中的堆栈情况看到,调度队列将续体暂存在堆上,并在需要的时候用来替换掉调度队列线程的运行栈,是异步函数拥有放弃线程能力的基础。在调度线程空闲时 (比如 await 后),执行器会为它寻找接下来需要 处理的指令,这个指定可能是 await 所需要执行的部分,也可能是和之前完全不相关的其他任 务。和传统 GCD 调度的资源抢占式不同,这种调度方式通过协作的方式,由执行器、需要处理 的工作和调度队列一同,来保证线程向前运行,来保证线程的可持续使用,避免了由于线程阻塞导致的线程数量膨胀和线程上下文切换带来的额外开销,从而达到 Swift 并发模型的极致目标:线程数量等同于 CPU 核心数量, 这也是我们把它叫做协同式线程池的原因