Swift并发的结构化编程

并发(concurrency)

早期的计算机 CPU 都是单核的,操作系统为了达到同时完成多个任务的效果,会将 CPU 的执行时间分片,多个任务在同一个 CPU 核上按时间先后交替执行。由于 CPU 执行速度足够地快,给人的错觉就像在同时执行多个任务。这种通过不同任务的指令切换来实现多任务的技术,称为「concurrency」,中文术语为「并发」。

后来,CPU 发展到两核、多核,同一个时刻,在不同的核上可以执行不同的任务。理论上,有 N 个 CPU 核即可同时不受干扰地在 N 个核上都完全独立运行一个任务。这种通过在不同 CPU核 上运行多任务的技术称为「parallel」,中文术语为「并行」。

现代操作系统的进程和线程调度已经完全屏蔽了这两种多任务技术的差异。大部分情况下,开发者不需要关心两个任务到底是在不同的 CPU 核上执行还是在一个 CPU 核的不同时间分片上执行。因此,在很多技术文档中,也常常使用「concurrency」一词表示多个任务同时进行这种特性,用于充分利用 计算机 系统的多核处理器,提高程序的性能和效率。

结构化编程

在我们开始学习 C 语言时,尽量不用或少用 goto 语句。尽管 C 语言规范已经限制了 goto 必须在本函数内部跳转,但使用 goto 语句仍然有着很大的不确定性:它可以跳转到函数中的任意位置。想象一下,如果函数中大量使用 goto 语句会是什么样的景象。如果你曾经了解过汇编语言,那么一定对汇编语言的看似排列整齐实则包含各种跳转的逻辑深恶痛觉,开发者必须一个语句一个语句地分析,小心翼翼地探索才能理清其中关系。过度使用 goto 会和使用汇编面临一样的问题。

好在现代编程语言早就经过了早期的洪荒时代,几乎每一种现代编程语言都会包含函数、条件语句、循环等基本要素。这些我们早已习以为常的代码逻辑,恰恰正是体现「结构化编程」的良好范例:使用函数、条件语句、循环等的代码的控制流总是单一的,不会出现类似 goto 这样的无法预知跳转到哪里的「分叉」。

「结构化编程」的核心思想就是代码的抽象和封装,确保程序运行路径总是从单一入口进入,执行结束后在单一出口退出,不会有第二种情况。以函数为例,不管函数中实现了多么复杂的逻辑,调用方根本不需要关心函数内部是如何实现的,当调用发生时,执行控制权交给该函数,无论是否发生错误、是否存在未能准备好的资源,该函数一定会在未来的某一个时刻返回结果并将执行控制权交还给调用方。

我们一直在享受现代编程语言「结构化编程」提供的便利,在我们日常开发的同步代码中,随处可见「结构化编程」的影子。

非结构化并发

在单线程编程中,借助函数、条件判断等控制流使得「结构化编程」早已司空见惯;但在并发编程中,涉及到线程和并发任务的切换,就没有那么容易实现「结构化」了。事实上,在过去的很长一段时间,「非结构化并发」仍然是主流。

非结构化并发最明显的问题就是很多时候会浪费 CPU 算力。当线程进入到耗时 I/O 操作时就处于阻塞状态,必须等待 I/O 操作完成,当很多线程都出现这种状态时,CPU 实际上就处于低负载或空闲状态而造成算力的浪费 - 空闲的算力本可以用来执行其他计算任务。

另外,非结构化并发将会异步调用多出一个一个的执行分支,这些分支并没有像函数调用那样有一个统一的出口,也没有办法将并发任务的执行结果或错误信息在调用者的线程上下文中回传。来看一个使用「非结构化并发」的示例:

func task0() {
    print("in task0")
}
    
func task1() {
    DispatchQueue.global().async {
        self.task0()
    }
}
    
func main() {
    DispatchQueue.global().async {
        self.task1()
    }
}

上例中,调用 main、task1、task0 方法时,控制流会返回给调用着,但 main、task1 内部异步执行了并发任务,相当于执行了类似 goto 的跳转行为,这些并发任务将在其它线程完成处理并获取结果,其生命周期也和 main、task1 的作用域完全无关。

再来看一个简单却典型的例子:

load_conf { url in
    load_image(url) { data in
        resize_image(data) { data in
            show_image(data)
            //1
        }
        //2
    }
    //3
}

示例演示了一系列的任务:load_conf(读取配置) -> load_image(加载图片) -> resize_image(处理图片) -> show_image(显示),这种书写方式在之前的开发中普遍存在。很显然它有以下问题:

  • 任务界限不清晰

每个异步任务都在回调中反馈执行结果,一层回调嵌套一层回调,开发者需要通过代码缩进来判断任务边界,当代码量较多时分析起来会很痛苦。特别是当拷贝大段代码时,原始的缩进信息可能存在丢失或错乱的情况,处理起来更让人头疼,可维护性较差。

  • 任务终止时机不明确

每一个异步任务都具备一定的执行条件(上述示例中没有体现),比如 load_image 会要求参数 url 是一个合法的图片地址,否则会终止执行。当一个异步任务因为执行条件或中间过程失败时,就不应该继续执行其他任务,上述示例中,最终可能会在 1、2、3 处终止。而有些开发者仅仅会关注最常规的那个路径,也就是在 1 处终止,而没有考虑到 2、3 处终止时的异常处理。上述示例还比较简单,实际开发中比这复杂的比比皆是,开发者要考虑的异常路径则更多。

另外,请回忆一下在 Objective-C ,如果需要实现一个或多个代码块依赖另一个或多个代码块的逻辑,有哪些实现方式?

我们可以使用 NSOperationdispatch_groupdispatch_barrier 甚至是信号量等相关 API 完成需求,但这些方案需要开发者明确了解对应 API 的含义及使用陷阱。比如,在使用 GCD 时一个经典的死锁问题:

dispatch_queue_t queue = dispatch_queue_create("kanchuan.com", DISPATCH_QUEUE_SERIAL);
dispatch_sync(queue, ^{
    dispatch_sync(queue, ^{
        NSLog(@"thread => %@", [NSThread currentThread]);
    });
}); 

这种初学者一不小心就会犯错的例子不在少数。传统的非结构化并发方案在语法表达和使用上比较繁琐,使用不当会造成资源竞争、死锁等严重问题。

通过以上的分析,我们来总结下非结构化并发的缺点:

  1. 线程 I/O 阻塞时无法充分发挥 CPU 算力;
  2. 异步任务无法获知自己从那里来,调用者也不知道异步任务会在何时结束;
  3. 调用者无法找到一个合适的时机统一处理异步任务的返回结果;
  4. 调用者无法取消自己派发的异步任务;
  5. 异步任务可能也会触发自己内部的异步任务,这会让问题变得更加复杂;
  6. 开发者需要手动管理资源竞争问题。

以上问题在非结构化并发中广泛存在,那么,现在是时候考虑使用「结构化并发」了。

四、实现结构化并发的底层技术

结构化并发既然能解决非结构化并发的问题,那么为什么不一开始就采用结构化并发的设计呢?不要说古老的 Objective-C ,就是 Apple 全新设计的 Swift 语言也要到 5.5 才支持结构化并发且最开始还有一些问题。归根到底,是因为实现结构化并发所需要的底层技术栈更加复杂。这些技术包括:

1、作用域(Scope)

结构化是以 代码块(Code Block) 为执行范围,而结构化并发则是以 作用域(Scope) 为执行范围。在不同的编程语言中,对于结构化并发作用域的命名所有不同,如 Kotlin 称为 Scope,Swift 称为 Task。

在 Swift 中,结构化并发依赖异步函数,异步函数又必须运行在某个 Task 中,Swift 结构化并发是以 Task 为基本要素进行组织的。

2、协程(Coroutine)和 异步函数(Async function)

用户态和内核态线程的主要区别如下:

优点缺点
用户态线程轻量,避免了从用户态到内核态切换的开销。一个线程只能占用一个核;操作系统无法感知用户态线程,需要开发者管理用户态线程的调度。
内核态线程操作系统可充分利用多核优势,实现真正的并行。内核态线程调度时要进行寄存器切换、特权模式切换、内核检查等,开销较大;内核线程表支持的线程个数有限。

那么,协程又是什么呢?

协程(Coroutine)这个名词早在 1958 年被提出来了,但很长一段时间没有被广泛应用。在一些资料中,直接定义“协程就是用户态线程”。我一开始看到这个定义是一脸懵逼的,在不断的 Google 和 ChatGPT 之后,我的结论是:协程确实就是用户态线程,但它和传统意义上的用户态线程还是有区别的:

  • 协程是编程语言运行时支持和负责调度的

传统意义上的用户态线程是由如 POSIX threads 库实现并进行管理和调度的。虽然也有代码库实现的协程方案,但使用的更多的还是来自编程语言原生支持的协程方案。协程的调度在编程语言上最直观地表现就是简化了异步任务的书写方式,对应到 Swift 中,就是通过 async 声明异步函数,通过 await 挂起任务让出线程。

  • 协程的调度是非抢占式的

通过协程调度的代码在执行过程中可以主动让出执行权给其他协程,而不必像传统用户态线程那样必须阻塞。

  • 协程更加轻量

协程通常运行在单个线程上,而用户态线程需要由操作系统进行调度和管理,需要额外的线程控制块等数据结构来维护线程状态、切换线程上下文。

3、计算续体(Continuation)

协程需要解决的一个重要问题就是:await 异步函数之后的代码是怎么被调度在 异步函数 执行完成后在执行的?

Task.detached { [self] in
	let result = await async_func()
	print("result = \(result)") // print 函数总是会等待异步函数 async_func 执行完成后再执行。
}

这就要借助 计算续体(Continuation)了。

计算续体(Continuation)是一种在并发编程中用于管理异步操作的机制。它帮助开发者更加清晰地表达异步操作的逻辑,避免嵌套闭包和回调地狱。当一个计算过程在中间被打断,其后续部分的信息可以使用一个对象表示,这个对象就是计算续体(Continuation)。

选择哪些部分作为计算续体,需要开发者通过 asyncawait 等关键字明确地告诉编译器。当使用 await 调用一个异步函数时,编译器会将后续部分的代码转换成 Continuation,当异步任务执行完毕之后,再将其结果值传递至 Continuation 中继续执行。可以想见,多个 Continuation 可以嵌套,就好像常规异步编程中的多层级回调一样。

我们知道,普通函数在调用过程中,支持其运行的一个重要内容就是栈帧。栈帧中保存着每一层级函数调用所需要的局部变量、方法参数、返回地址等重要内容,栈帧中内容的增加和减少对应着就是函数的进入和退出。栈帧是由操作系统管理的。

Continuation 和 栈帧 非常相似,与 栈帧 不同的是,Continuation 由编程语言的运行时管理。实践中 Continuation 也会保存函数栈帧的信息以确保在恢复 Continuation 时能够正确地找到执行所需的环境信息。

在 Swift 中,可以通过 withCheckedContinuation API 创建 Continuation:

@frozen public struct UnsafeContinuation<T, E> where E : Error {
    public func resume(returning value: T) where E == Never
    public func resume(returning value: T)
    public func resume(throwing error: E)
}

public func withCheckedContinuation<T>(function: String = #function, _ body: (CheckedContinuation<T, Never>) -> Void) async -> T

// 异步函数抛出异常时使用 withCheckedThrowingContinuation
public func withCheckedThrowingContinuation<T>(function: String = #function, _ body: (CheckedContinuation<T, Error>) -> Void) async throws -> T

可以将传统异步调用包装为 Swift 的异步函数:

func chuanAsyncFunc() async throws -> Int {
    try await withCheckedThrowingContinuation { continuation in
        DispatchQueue.global().async {
            do {
                let result = try chuanFunc()
                continuation.resume(returning: result)//返回结果
            } catch {
                continuation.resume(throwing: error)//抛出异常
            }
        }
    }
}