Ballad


  • 首页

  • 归档

  • 片段

  • 关于

编写高性能的Swift代码

发表于 2022-01-16

本文根据官方文档的内容编写

通常我们作为一个api caller其实并不太注意Swift语言层的性能优化,因为常规的使用方式不会出现异常性能的情况,不过了解这些知识对于我们以后可能对于底层代码的编写或者了解一些编译器的相关知识会有所帮助。

Optimization

Swift提供了三种不同的level:

  • Onone 最少的优化,最多的debug信息
  • O 更激进的优化,它会改变代码的类型和数量,对于debug信息只会做部分保留
  • Osize 编译器会将二进制体积大小的优先程度设置高于性能

这些可以在Xcode的Optimization Level进行设置

Whole Module Optimizations

我们知道Swift的文件编译是相对独立的,这样可以进行并行编译提高编译效率。不过独立编译也会让一些优化无法展开,这个也比较容易想到原因。比如函数默认是internal的,但很多时候它并不需要为外界所知,如果独立编译的话方法调用就会是动态派发,性能会不如静态调用来的优秀。Swift提供了一种将整个module视为一个文件编译的模式,那就是Whole Module Optimization(以下简称WMO),这种模式编译速度会慢一些但是会得到更多的优化。

Generics

我们知道Swift的泛型是一种非常强大的设计,然而对于编译器来说,不确定的类型并不是它希望看到的。对于泛型函数的调用,编译器会试图确定调用的具体类型,并生成一个特定版本的函数,这个过程叫做specialization。

1
2
3
4
5
6
func myAlgorithm<T>(_ a: [T], length: Int) { ... }

var arrayOfInts: [Int]
// The compiler can emit a specialized version of 'myAlgorithm' targeted for
// [Int]' types.
myAlgorithm(arrayOfInts, arrayOfInts.length)

然而,只有当泛型声明的定义在当前module中可见时,才能执行specialization。

Dynamic Dispatch

默认情况下,Swift和Objective-C一样是非常动态的语言,对于函数调用,Swift提供了三种不同的方式:

类型 实现方式
静态调用 关键字final
动态派发 默认,使用虚函数表
消息发送 关键字dynamic

很显然动态派发和消息发送都会比静态调用要更慢,如果我们想要更优秀的性能表现,那么我们可以对内部函数加上final关键字。final意味着函数将不能被重写,因此可以直接被调用。另外,标记为private和fileprivate的变量和函数会在编译器阶段进行推断优化加上final关键字。

结合WMO,以及Swift的默认访问控制级别:internal,我们可以了解在开启WMO的情况下,就算我们不做任何其他操作,编译器也能推断出是否需要进行final标记,从而帮我们进行更智能的性能优化。

Container Type

说到性能就逃不过容器类型。我们知道Swift提供了值类型和引用类型,也更鼓励使用值类型,其中的理由之一是值类型不需要额外的retain release操作。对于数组来说,Array和NSArray也提供不一样的功能,当我们使用值类型的时候编译器可以帮我们去掉大部分桥接NSArray的开销。

而在一些情况下,对象是引用类型,但我们依然不想要桥接NSArray,那么就可以使用ContiguousArray。原因是ContiguousArray的实现中会少一些类型检查。

Copy on write(COW)

Swift标准库中的所有容器类型都是值类型,他们使用COW的策略而不是直接拷贝。这很好理解,因为拷贝并不是一个可以被随意使用的操作,当对象不发生变化时,多次拷贝是毫无意义的操作。然而,COW如果使用不慎也会带来一些副作用,COW的执行逻辑是当对象的引用计数大于1并且发生了改变时进行拷贝,我们看看以下场景:

1
2
3
4
5
6
7
8
func append_one(_ a: [Int]) -> [Int] {
var a = a
a.append(1)
return a
}

var a = [1, 2, 3]
a = append_one(a)

原始的数组a在赋值结束之后是没有任何意义的,然而由于a入参了引用计数会增加1,而在append操作的时候则会依据COW进行一次拷贝,这种拷贝是没有意义的(现实情况是编译器可能会进行优化)。所以这种场景应该使用inout来避免无意义的拷贝。

Value cost

值类型好处很多,但是如果是一个大型的值类型,那么拷贝的开销就是我们需要考虑的事情了。

这时候我们又会想到COW,如果一个复杂的值类型需要被拷贝,我们是否可以只拷贝其变化的部分?前面我们知道了Swift的容器类型都是COW策略,所以使用容器类型就可以享受到COW的好处。但有的时候,我们希望自己的数据结构也能支持COW,这是否可行?

答案是肯定的,Swift提供了isKnownUniquelyReferenced来实现自定义的COW数据结构,看下面的例子:

1
2
3
4
5
6
mutating func update(withValue value: T) {
if !isKnownUniquelyReferenced(&myStorage) {
myStorage = self.copiedStorage()
}
myStorage.update(withValue: value)
}

isKnownUniquelyReferenced在给定对象只有一个强引用时会返回true,例子里的代码实现了只有引用计数>1时才拷贝的策略。

Unsafe code

我们定义一个链表,通过class来实现。当我们遍历链表的时候,会执行node = node.next,而arc会在访问next的时候对next进行retain,并在结束对node的访问的时候对node进行release,这无疑是开销很大的操作。

如果我们希望避免这种引用计数的开销,可以使用Unmanaged<T>。当然,这个时候数据访问安全就需要我们自己来保证了。

Protocols

我们知道Protocols可以限定为类的协议。将协议标记为类的一个优点是,编译器可以基于只有类满足协议这一点来优化程序,因为编译器不再需要判断是否要通过arc来插入内存管理代码。

Closure

我们知道匿名闭包是非逃逸的,而绑定了变量的闭包是逃逸的。当一个变量被逃逸闭包捕获时,编译器必须分配堆上内存来存储该变量,这样闭包创建者和闭包都可以读写该值。而如果是常量被捕获,那么只有值会被捕获,这样就不会有额外的内存分配操作。

有的时候我们对闭包命名只是出于表达,并不想让它逃逸,那么就建议将需要捕获的局部变量加上inout标记,这样也不会有额外的内存分配和内存管理操作。

Structured concurrency

发表于 2020-11-15

介绍

async/await是用于编写自然、高效的异步代码的语言机制。异步函数(通过async引入)可以在任意挂起点上(标记为await)放弃执行它们的线程,这对于构建高并发系统是必要的。

然而,async/await提议本身并没有引入并发性:如果你忽略异步函数中的挂起点,它将以与同步函数基本相同的方式执行。这个提议引入了对Swift中结构化并发的支持,使异步代码的并发执行具有一个符合直觉、可预测和高效实现的模型。

动机

举一个做饭的例子,这里行为是异步的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func chopVegetables() async throws -> [Vegetable] { ... }
func marinateMeat() async -> Meat { ... }
func preheatOven(temperature: Double) async throws -> Oven { ... }

// ...

func makeDinner() async throws -> Meal {
let veggies = await try chopVegetables()
let meat = await marinateMeat()
let oven = await try preheatOven(temperature: 350)

let dish = Dish(ingredients: [veggies, meat])
return await try oven.cook(dish, duration: .hours(3))
}

在我们的晚餐准备过程中,每一步都是一个异步操作,所以会有很多的挂起点。在等待chopVegetables的过程中,makeDinner不会阻塞线程:它会挂起,直到chopVegetables返回,然后继续往下走。以此类推,晚餐的准备工作可能处于不同的阶段,大多数会挂起,直到目前的步骤完成。

然而,即使我们的准备晚餐是异步的,但仍然是顺序的。它会等到蔬菜被切碎后再开始腌肉,然后再次等到肉准备好了再预热烤箱。当晚餐准备好的时候,顾客会感到非常饥饿。

为了加快准备晚餐的速度,我们需要同时执行其中的一些步骤。为了做到这一点,我们可以将准备晚餐分解为可以并行发生的不同任务。蔬菜可以在肉类腌制和烤箱预热的同时切碎。有时任务之间会有依赖性:一旦蔬菜和肉准备好了,我们可以把它们放在一个盘子里,但直到烤箱热起来我们才能把那个盘子放进烤箱。所有这些任务都是做饭这一更大任务的一部分。当所有这些任务完成后,晚餐就开始了。

此提议旨在提供必要的工具,以将工作分解为可以并发运行的更小的任务,允许任务彼此等待完成,并有效地管理任务的总体进度。

解决方案

我们的方案遵循结构化并发的原则。所有异步函数都作为异步任务的一部分运行。任务可以方便地使子任务并发地执行工作。这就创建了任务的层次结构,信息可以方便地在层次结构中上下流动,从而方便了对事情的整体管理。

子任务

这个提议引入了一个简单的方法通过async let来创建子任务:

1
2
3
4
5
6
7
8
func makeDinner() async throws -> Meal {
async let veggies = try chopVegetables()
async let meat = marinateMeat()
async let oven = try preheatOven(temperature: 350)

let dish = Dish(ingredients: await [veggies, meat])
return await try oven.cook(dish, duration: .hours(3))
}

async let与let十分相似,它定义了一个局部常量,该常量由赋值操作右侧的表达式初始化。但是,不同之处在于前者的初始化表达式是在单独的、并发执行的子任务中求值的。在任务完成时,子任务将初始化变量并返回。

由于函数的主体与其子任务并发执行,因此makeDinner可能会在生成async let的值之前到达需要该值的位置。为了说清楚这一点,读取由async let定义的变量被视为一个挂起点,因此必须标记为await。该任务将暂停,直到子任务完成变量的初始化,然后继续执行。

可以将async let看作是引入了一个隐藏的future,它是在声明async let时创建的,它的值是在await时取回的。从这个意义上说,async let是future的语法糖。

然而,在提出的结构化并发模型中,子任务有意地比一般用途的future受到更多的限制。与标准的future实现不同,子任务不会存在于创建它的上下文之外。在上下文结束时,子任务必须已经完成,否则将隐式取消。这种结构既可以更容易地推断在给定范围内执行的并发任务,也可以为编译器和运行时提供大量的优化机会。

让我们回到我们的例子。注意,如果出现了一些意外,chopVegetables()函数可能会抛出一个错误。抛出的错误结束了切菜的子任务。然后,正如预期的那样,错误将被传播到makeDinner()函数之外。在退出makeDinner()函数体时,任何尚未完成的子任务(腌制肉或预热烤箱,可能两者都有)将被自动取消。

使用任务组组织子任务

使用async let的结构可以很容易地创建一系列子任务并将它们与变量关联起来。但是,这个构造很难满足动态化的工作需求,在动态化的工作中,我们不知道需要创建的子任务的数量,因为它取决于数据结构的规模。为此,我们需要一个更动态的结构:任务组。

任务组定义了一个范围,可以在其中以编程方式创建新的子任务。与所有子任务一样,任务组作用域内的子任务必须在作用域退出时完成。这相当于一个正常的函数作用域在所有async let变量退出作用域之前强制执行等待,但是对于动态添加的子任务,await在任务组作用域退出时变成隐式的了。对于未完成的非await任务,各种行为变体很可能源于调用方配置。任务组还提供了处理子任务结果的程序,例如,通过等待直到下一个子任务完成。

为了进一步扩展我们的示例,让我们考虑chopVegetables()操作,该操作生成一个Vegetable数组。如果我们有足够的厨师来把每种蔬菜分开,那就可以更快地切蔬菜。

让我们从chopVegetables()的顺序版本开始:

1
2
3
4
5
6
7
8
/// Sequentially chop the vegetables.
func chopVegetables() async throws -> [Vegetable] {
var veggies: [Vegetable] = gatherRawVeggies()
for i in veggies.indices {
veggies[i] = await try veggies[i].chopped()
}
return veggies
}

在循环中引入async let不会产生任何有意义的并发性,因为每个async let都需要在循环的下一个迭代开始之前完成。为了以编程方式创建子任务,我们通过Task.withGroup引入了一个新的任务组上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// Sequentially chop the vegetables.
func chopVegetables() async throws -> [Vegetable] {
// Create a task group where each task produces (Int, Vegetable).
await try Task.withGroup(resultType: (Int, Vegetable).self) { group in
var veggies: [Vegetable] = gatherRawVeggies()

// Create a new child task for each vegetable that needs to be
// chopped.
for i in rawVeggies.indices {
await try group.add {
(i, veggies[i].chopped())
}
}

// Wait for all of the chopping to complete, slotting each result
// into its place in the array as it becomes available.
while let (index, choppedVeggie) = await try group.next() {
veggies[index] = choppedVeggie
}

return veggies
}
}

Task.withGroup(resultType:body:)函数引入了一个新的上下文,可以在其中创建子任务(通过add(_:)函数)。next()函数会等待下一个子任务完成,并提供子任务的返回值。在我们上面的例子中,每个子任务都携带索引和切好的蔬菜。

就像通过async let创建的子任务一样,如果传给Task.withGroup的闭包在没有完成所有子任务前退出了,那么剩余的子任务都将自动取消。

分离任务

到目前为止,我们创建的每个任务都是子任务,其生命周期受到上下文的限制。这不允许创建超过当前上下文的新任务。

runDetached操作创建一个新任务。它接受一个闭包,该闭包将作为任务的主体执行。在这里,我们创建了一个新的分离任务来做晚餐:

1
2
3
let dinnerHandle = Task.runDetached {
await makeDinner()
}

runDetached的返回是一个handle,可以在操作完成时(通过get())返回操作的结果,或者在不再需要结果时(通过cancel()取消任务)使用它。与子任务不同,分离任务即使没有剩余的任务处理,也不会被取消,因此runDetached适用于不关心完成情况的操作。

详细设计

结构化的并发

任何并发系统都必须提供某些基本工具。必须有某种方法来创建一个与现有线程并发运行的新线程。还必须有某种方法让一个线程挂起,直到另一个线程发出继续执行的信号。这些都是强大的工具,您可以使用它们编写非常复杂的系统。但它们也是非常原始的工具:它们很少做情况假设,但反过来它们也不会提供太多的支持。

假设有一个函数在CPU上做大量的工作。我们希望通过将工作分到两个核中来优化;因此,现在该函数会创建一个新线程,在每个线程中执行一半的工作,然后让其原始线程等待新线程完成。(在更现代的系统中,该函数可能会向全局线程池添加一个任务,但基本概念是相同的。)这两个线程所做的工作之间存在联系,但系统并不知道它。这加大了解决系统性问题的难度。

例如,假设一个高优先级操作需要上面的函数加急完成。该操作可能会升级第一个线程的优先级,但实际上它应该同时升级两个线程。在最好的情况下,它不会升级第二个线程,直到第一个线程开始等待它的完成。狭义地解决这个问题相对容易,可以让函数注册第二个应该升级的线程。但这将会成为一个通解,因为每个想要使用并发性的函数都可能重复这个行为。

结构化并发通过要求程序员将并发组织成任务和子任务来解决这个问题。这些任务成为并发的主要单元,而不是像线程这样的底层概念。以这种方式构造并发允许信息自然地在任务层次结构中上下流动,否则在每个层级的抽象和每个线程的转换上都需要仔细编写代码。这反过来又让相对轻松地解决一些高级问题成为可能。

举一些例子:

  • 通常我们都希望限制在一个任务上花费的时间。一些api通过传入超时时间来支持这一点,但是要在每个抽象级别正确地传递超时时间需要做很多工作。这一点尤其重要,因为终端程序员通常希望将超时写成持续时间(比如20ms),但正确处理的库在内部中传递的会是截止时间(比如now + 20ms)。在结构化并发下,可以在任务上设置截止时间,并自然地传递到任意级别的API,包括子任务。
  • 类似地,有时我们希望能够取消正在执行的任务。支持此功能的异步接口一般来说会返回一个token,并提供某种cancel()方法来实现。这极大地复杂化了API的设计,因此通常我们不这么做。此外,传递token,或者组合token以取消所有正在执行的任务,会给程序带来重大的工程挑战。在结构化并发下,取消通过api自然地下发到子任务,而api可以简单地通过设置处理代码来响应取消。
  • 图形用户界面通常依赖于任务优先级来确保及时刷新和响应事件。在结构化并发下,子任务自然继承父任务的优先级。此外,当高优先级任务等待低优先级任务完成时,低优先级任务及其所有子任务的优先级都可以提升,而且即使任务被暂时挂起,这种优先级也会可靠地持续存在。
  • 许多系统希望为操作维护它们自己的上下文信息,而不需要将其传递到每个抽象级别,例如记录当前服务的连接信息的服务器。结构化并发允许这种特性作为一种“局部任务存储”自然地通过异步操作传播,这种存储可以被子任务获取。
  • 依赖队列的系统通常容易受到队列泛滥的影响,即队列接受的工作量超过了它实际能够处理的工作量。这通常可以通过引入“反压”来解决:队列停止接受新工作,试图将工作安排到队列中的系统会做出响应,停止接受新工作。Actor系统通常会破坏这种情况,因为在调度器级别上很难拒绝向Actor队列添加工作,因为这样做可能会资源遗漏或阻止操作完成,从而永久性地破坏系统的稳定。结构化并发提供了一种有限的、协作的解决方案,它允许系统在遇到困难时向任务层次结构的上层传递信息,潜在地允许父任务停止或减缓类似的新工作的创建。

这个提议并没有为所有这些问题提出解决方案,但是我们早期的研究可以给出一些期待。

任务

任务是系统中并发的基本单位。每个异步函数都在一个任务中执行。换句话说,任务对应异步函数,就像线程对应同步函数一样。那么:

  • 所有的异步函数都作为任务的一部分来运行
  • 一个任务一次只执行一个函数,所以单个任务是不并发的
  • 当函数执行async的调用,被调用的函数依旧是作为同一任务的一部分运行(调用方等待它的返回)
  • 同样,当一个函数从一个async调用返回时,调用方会在同一任务上继续执行

同步函数不一定作为任务的一部分运行。

Swift假设存在一个底层线程系统。任务由系统调度在这些系统线程上运行。任务不需要来自底层线程系统的特殊调度支持,尽管一个好的调度程序可以利用Swift任务调度的一些有趣特性。

任务可以处于以下三种状态之一:

  • 挂起状态,可能还有更多工作要做,但至少现在并没有在运行。
    • 它可能是可调度状态,表示它已经准备好运行,只是在等待系统指派线程开始执行它。
    • 也可能是等待状态,可能还需要一些外部事务来让它变成可调度状态
  • 运行中,它正运行在一个线程上。
    • 它将一直运行,直到它从初始函数返回或到达一个挂起点为止。在一个挂起点上时,如果它的执行只需要改变actor,那么它可能立即成为可调度状态。
  • 完成状态,没有工作需要做了,所以也不会进入别的状态了。
    • 代码可以通过各种方式等待任务完成,最明显的就是使用await。

我们讨论任务和异步函数执行的方式要比同步函数复杂得多。异步函数作为任务的一部分运行。如果任务正在运行,则它及其当前执行的函数也在一个线程上运行。

注意,当一个异步函数调用另一个异步函数时,我们说调用函数被挂起,但这并不意味着整个任务被挂起。从函数的角度来看,它被挂起,等待调用返回。从任务的角度来看,它可能在被调用方中继续运行,或者被挂起,以便转移到一个不同的上下文中。

任务服务于三个高层次目的:

  • 它们携带调度信息,比如任务的优先级。
  • 它们可以被当作一个操作,可以取消、查询或更改。
  • 它们可以携带用户提供的局部任务数据。

在底层层次上,该任务允许实现优化本地内存的分配,比如异步函数上下文。它还允许动态工具、崩溃报告器和调试器发现函数是如何使用的。

子任务

异步函数可以创建子任务。子任务继承父任务的一些结构,包括其优先级,且可以与父任务并发运行。但是,这种并发性是有限制的:创建子任务的函数必须等待子任务结束后才能返回。这种结构意味着函数可以推断出为当前任务所做的所有工作,预期取消当前任务的影响,等等。它还大大提高了生成子任务的效率。

当然,一个函数的任务本身可能是另一个任务的子任务,它的父任务可能有其他的子任务;函数无法对这些进行推理。但是应用于整个任务树的这种设计的特性,比如取消,只向下应用,而不会在任务层次结构中向上传播,因此子树仍然可以被静态推断。如果子任务没有限定的持续时长,可以任意地超过父任务,那么在这些特征下的任务行为就不容易理解。

部分任务

任务的执行可以看作是任务运行阶段的演替,每个阶段都在一个挂起点结束,或者最终在任务完成时结束。这些阶段称为部分任务。部分任务是系统中可调度工作的基本单元。它们也是异步函数与底层同步世界交互的原语。在大多数情况下,程序员不应该直接处理部分任务,除非他们正在实现自定义执行器。

执行程序

执行程序是一种服务,它接受部分任务的提交,并安排某个线程运行它们。系统假设执行程序是可靠的,并且永远不会失败地运行部分任务。

当前运行的异步函数总是知道运行它的执行程序。这允许函数在调用同一执行程序时避免不必要的挂起,并且允许函数在它开始执行的同一执行程序上继续执行。

如果提交给执行程序的部分任务永远不会并发运行,则称为独占执行程序。(具体来说,部分任务必须完全按照happens-before关系排序:给定任何两个已提交并运行的任务,其中一个必须在另一个开始之前结束。)执行程序不需要按照任务提交的顺序运行部分任务;实际上,它们通常应该尊重任务优先级而不是提交顺序。

Swift提供了一个默认的执行程序实现,但是actor类和全局actor都可以禁用这个实现,并提供它们自己的实现。

通常终端程序员不需要直接与执行程序交互,而是通过调用恰好使用执行程序的异步函数和actor和函数来隐式地使用它们。

任务优先级

一个任务与特定的Task.Priority相关联。

任务优先级可以告知执行程序如何以及何时调度提交给它的任务。执行程序可以利用优先级信息首先尝试运行优先级较高的任务,然后继续为优先级较低的任务提供服务。它还可以使用优先级信息来影响线程的优先级。

如何处理优先级的确切语义留给每个平台和特定的执行程序实现。

子任务自动继承父任务的优先级。分离任务不继承优先级(或任何其他信息),因为它们在语义上没有父任务。

1
2
3
4
5
6
7
8
extension Task {
public static func currentPriority() async -> Priority { ... }

public struct Priority: Comparable {
public static let `default`: Task.Priority
/* ... */
}
}

TODO:定义任务优先级的细节;它很可能是一个类似于Darwin Dispatch的QoS的概念;请记住,在其他平台上(比如服务器端的Linux系统),优先级并不是那么重要。

任务的优先级是在启动顶层任务时通过传递给Task.runDetached(priority:operation:)来设置的。任务的子任务将继承这个优先级。

任务的优先级不一定与其执行程序的优先级匹配。例如,苹果平台上的UI线程是高优先级执行程序;提交给它的任何任务都将在其在线程上的时间内以高优先级运行。这有助于确保UI线程在稍后提交高优先级的工作时可用。这并不影响任务的正式优先级。

优先级提升

在某些情况下,任务的优先级必须提升,以避免优先级反转问题:

  • 如果任务是作为actor运行,并且有更高优先级任务在actor上排队,则该任务可以临时以高优先级任务的优先级运行。这并不影响子任务或Task.currentPriority();它是运行任务的线程的属性,而不是任务本身。
  • 如果一个任务是通过Task.Handle创建的。当高优先级任务调用await try handle.get()时,该任务的优先级将永久性提升,以匹配高优先级任务。这确实会影响子任务和Task.currentPriority()。

取消机制

任何具有对任务或其父任务引用的上下文都可以异步取消任务。可以通过在任务上调用cancel()显式地触发取消。取消也可以自动触发,例如,当父任务在未等待的子任务的作用域之外抛出错误时(例如async let)。

取消在已取消任务中的效果是完全协作和同步的。也就是说,取消没有任何影响,除非有东西会依赖取消。通常,大多数依赖取消的函数通过抛出CancellationError()来报告取消;因此,它们必须是抛出函数,对它们的调用必须用某种形式的try进行修饰。因此,取消不会在异步函数中引入额外的控制流路径;您总是可以查看一个函数,并查看取消可以发生的地方。与其他抛出的错误一样,可以使用defer在取消后有效地进行资源清理。

因此,一般的预期是,异步函数应该尝试通过迅速抛出或返回来响应取消。在大多数函数中,依赖于可以等待很长时间的低层函数(例如,I/O函数或Task.Handle.get())来检查取消和提前终止应该足够了。执行大量同步计算的函数可能希望定期显式地检查是否取消。

取消会立即触发两个效果:

  • 会在任务中设置一个标志,标志该任务已被取消;一旦设置了这个标志,它就不会被清空了。作为任务的一部分同步运行的操作可以检查此标志,通常预期会抛出CancellationError的错误。
  • 注册在任务上的取消处理操作会立即执行。

我们可以用前面看到的chopVegetables()函数的一个版本来演示取消操作:

1
2
3
4
5
6
func chopVegetables() async throws -> [Vegetable] {
async let carrot = try chop(Carrot()) // (1) throws UnfortunateAccidentWithKnifeError()!
async let onion = try chop(Onion()) // (2)

return await try [carrot, onion] // (3)
}

在第一行,我们开启了一个子任务:切胡萝卜。假设这个对chop函数的调用抛出了一个错误。因为这是异步的,所以在chopVegetables中不会立即观察到错误,我们继续启动第二个子任务来切洋葱。在第三行中,我们await切胡萝卜和切洋葱的任务,这将导致我们抛出从chop中抛出的错误。因为我们不处理这个错误,所以我们在没有等待切洋葱任务完成的情况下就退出了作用域。这会导致自动取消该任务。由于取消是协作性的,而且结构化并发不允许子任务比父任务持续更久,所以在切断洋葱的任务实际完成之前,控制流实际上不会返回;它返回或抛出的任何值都将被丢弃。

如上所述,取消对任务的影响是同步和协作的。执行大量同步计算的函数可能希望显式地检查是否可以取消。它们可以通过检查任务的取消状态来实现:

1
2
3
4
5
6
7
8
9
10
11
func chop(_ vegetable: Vegetable) async throws -> Vegetable {
await try Task.checkCancellation() // automatically throws `CancellationError`
// chop chop chop ...
// ...

guard await !Task.isCancelled() else {
print("Canceled mid-way through chopping of \(vegetable)!")
throw CancellationError()
}
// chop some more, chop chop chop ...
}

还要注意,关于取消原因的信息没有传递给任务。一个任务可能由于许多原因被取消,并且在最初的取消之后可能会产生其他的原因(例如,如果任务不能立即退出,它可能会传递一个截止日期)。取消的目标是允许以轻量级的方式取消任务,而不是作为任务间通信的辅助方法。

通过截止日期取消

取消的一个非常常见的用例是当任务消耗了太长的时间以致任务被取消。这个提议引入了截止日期的概念,并使它们能够在超过截止日期时使任务自发取消。

我们有意使用截止日期(“时间点”)而不是超时(“持续时间”)。这是因为截止日期能被正确处理:使用超时很容易出现错误,即由于重用了超时,而不是根据已经过去的时间进行调整,从而意外地延长了截止日期。为了方便,我们允许代码在设置截止日期时使用相对超时;这将立即转化为一个绝对的截止日期。

为了进一步分析截止日期的语义,让我们用截止日期来扩展我们的晚餐准备例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func makeDinnerWithDeadline() async throws -> Meal {
await try Task.withDeadline(in: .hours(2)) {
// intentionally wait until the vegetables have been chopped before starting any child tasks
let veggies = await try chopVegetables()
async let meat = Task.withDeadline(in: .minutes(30)) {
marinateMeat()
}
async let oven = try preheatOven(temperature: 350)

let dish = Dish(ingredients: await [veggies, meat])
return await try oven.cook(dish, duration: .hours(3))
}
}

func cook(_ dish: Dish, duration: Duration) async throws -> Meal {
await try checkCancellation()
// ...
}

在上面的例子中,我们设置了两个截止日期。第一个截止日期是从开始的两个小时到整个晚餐准备任务。第二个截止日期是从我们开始腌制算起的30分钟,它只适用于任务的那一部分。

注意,我们等待切蔬菜,然后开始腌制。这是为了说明下面的观点:想象一下,不知怎么地,因为某种原因切蔬菜花了1小时40分钟。现在我们已经到了腌肉的步骤,离截止日期只有20分钟了,但是我们试图把截止日期设定在“30分钟后”。如果我们在这里设置了30分钟的截止日期,我们就会超过外部的最后期限。相反,任务会自动注意到now + 30 minutes的新截止日期实际上大于当前的截止日期,因此它被忽略;任务将在两个小时后适当地取消。

截止日期也可以以编程的方式进行交互。例如,cook函数确切地知道完成它需要多长时间。在cook()函数开始时检查是否取消只意味着还没有超过截止日期,但我们可以做得更好:我们可以检查是否还剩下三个小时。如果没有,我们可以立即抛出错误,告诉用户我们不会满足期限:

1
2
3
4
5
6
func cook(_ dish: Dish, duration: Duration) async throws -> Meal {
guard await Task.currentDeadline().remaining > duration else {
throw await NotEnoughTimeToPrepareMealError("Not enough time to prepare meal!")
}
// ...
}

正因为如此,那些拥有已知执行时间的函数可以在开始工作之前进行主动取消,因为我们知道这些工作最终会超过截止日期。

通过async let修饰的子任务

异步调用本身不会引入并发执行。然而,async函数可以方便地使用async let让一个子任务中运行的工作并发运行:

1
async let result = try fetchHTTPContent(of: url)

任何对在async let中声明的变量的引用都是一个挂起点,相当于对异步函数的调用,所以它必须出现在await表达式中。async let的初始化被认为是由隐式await表达式括起来的。

如果async let的初始化可以抛出错误,那么对该async let中声明的变量子句的每个引用都被认为可以抛出错误,因此也必须包含在一个try/try!/try?之中:

1
2
3
4
5
6
{
async let (yay, nay) = ("yay", throw Nay())

await try yay // must be marked with `try`; throws Nay()
// implicitly guarantees `nay` also be completed at this point
}

简单来说,赋值语句右边是由async let修饰的,在初始化时也是在一起的(就像是异步回调一样),这意味着如果右边的任何值在初始化时抛出错误,所有左边要初始化的变量必须被视为它他们也抛出了同样的错误。

还有一种情况可能需要简短解释一下。在一个async let中的多个子句可以这样写:

1
2
3
4
5
6
7
8
9
10
{
async
let
ok = "ok",
(yay, nay) = ("yay", throw Nay())

await ok
await try yay
// okay
}

在上面的例子中,我们可以把每个子句看作是它自己的异步初始化变量,即ok单独初始化,(yay, nay)一起初始化,正如前面讨论的那样。

通过async let修饰的子句的至少一个变量在超出作用域之前,必须在所有执行路径(不会抛出错误)中await至少一次。例如:

1
2
3
4
5
6
7
8
9
10
{
async let result = try fetchHTTPContent(of: url)
if condition {
let header = await try result.header
// okay, awaited `result`
} else {
// error: did not await 'result' along this path. Fix this with, e.g.,
// _ = await try result
}
}

如果async let的作用域通过抛出错误而退出,则隐式取消与async let对应的子任务。如果子任务已经完成,则丢弃其结果(或抛出错误)。

原理阐述:在所有(非抛出)路径中,每个async let都需要await一个变量,这可以确保在正常执行过程中不会创建和隐式取消子任务。这样的代码可能是不必要的低效的,应该重新构造代码以避免创建不必要的子任务。

带有Nursery的子任务

除了async let之外,这个建议还引入了显式的Nursery类型,它允许在这样的任务组中对任务进行细粒度的范围界定。

任务可以动态地被添加到任务组,这意味着可以为动态大小集合的每个元素添加任务到任务组,并将它们都绑定到任务组的生命周期。这与async let的声明相矛盾,后者只允许在编译时静态获取声明的任务数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
extension Task {

/// Starts a new task group which provides a scope in which a dynamic number of
/// tasks may be spawned.
///
/// Tasks added to the group by `group.add()` will automatically be awaited on
/// when the scope exits. If the group exits by throwing, all added tasks will
/// be cancelled and their results discarded.
///
/// ### Implicit awaiting
/// When results of tasks added to the group need to be collected, one can
/// gather their results using the following pattern:
///
/// while let result = await group.next() {
/// // some accumulation logic (e.g. sum += result)
/// }
///
/// ### Cancellation
/// If an error is thrown out of the task group, all of its remaining tasks
/// will be cancelled and the `withGroup` call will rethrow that error.
///
/// Individual tasks throwing results in their corresponding `try group.next()`
/// call throwing, giving a chance to handle individual errors or letting the
/// error be rethrown by the group.
///
/// Postcondition:
/// Once `withGroup` returns it is guaranteed that the `group` is *empty*.
///
/// This is achieved in the following way:
/// - if the body returns normally:
/// - the group will await any not yet complete tasks,
/// - if any of those tasks throws, the remaining tasks will be cancelled,
/// - once the `withGroup` returns the group is guaranteed to be empty.
/// - if the body throws:
/// - all tasks remaining in the group will be automatically cancelled.
public static func withGroup<TaskResult, BodyResult>(
resultType: TaskResult.Type,
returning returnType: BodyResult.Type = BodyResult.self,
body: (inout Task.Group<TaskResult>) async throws -> BodyResult
) async rethrows -> BodyResult { ... }
}

任务组可以从任何异步上下文中启动,最终返回单个值(BodyResult)。任务可以动态地被添加进去,任务组在返回最终结果时断言所有任务都是空的,从而强制在返回之前等待所有任务的完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
extension Task { 
/* @unmoveable */
public struct Nursery<TaskResult> {
// No public initializers

// Swift will statically prevent this type from being copied or moved.
// For now, that implies that it cannot be used with generics.

/// Add a child task.
public mutating func add(
overridingPriority: Priority? = nil,
operation: () async -> TaskResult
) { ... }

/// Add a child task and return a handle that can be used to manage it.
public mutating func addWithHandle(
overridingPriority: Priority? = nil,
operation: () async -> TaskResult
) -> Handle<TaskResult> { ... }

/// Wait for a child task to complete and return the result it returned,
/// or else return.
public mutating func next() async -> TaskResult? { ... }

/// Query whether the task group has any remaining tasks.
/// Nurseries are always empty upon entry to the Task.withGroup body.
public var isEmpty: Bool { ... }

/// Cancel all the remaining tasks in the task group.
/// Any results, including errors thrown, are discarded.
public mutating func cancelAll() { ... }
}
}

任务组保证它将在返回之前await所有被添加的任务的完成。

这种等待可以通过这样几种方式实现:

  • 任务组内部代码实现
  • 在返回时默认完成

在chopVegetables()示例中,我们不仅向任务组添加了切蔬菜任务,而且还获取了切蔬菜的结果。下面是对一般模式的简化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func chopVegetables(rawVeggies: [Vegetable]) async throws -> [ChoppedVegetable] {
await try Task.withGroup(resultType: ChoppedVegetable.self) { task group in
var choppedVeggies: [ChoppedVegetable] = []
choppedVeggies.reserveCapacity(veggies.count)

// add all chopping tasks and process them concurrently
for v in rawVeggies {
await try task group.add { // await the successful adding of the task
await v.chopped() // await the processing result of task
}
}

while let choppedVeggie = await try task group.next() {
choppedVeggies.append(choppedVeggie)
}

return choppedVeggies
}
}

Nurseries:错误与取消机制

这里值得指出的是,将任务添加到任务组可能会失败,因为当我们要向任务组添加更多任务时,任务组可能已经被取消了。为了形象化这一点,让我们考虑下面的例子:

默认情况下,任务组中的任务处理抛出错误的方式就像《三个火枪手》那句经典语录一样,即:“我为人人,人人为我!”换句话说,如果单个任务抛出一个错误,该错误将转义到任务组中,那么所有其他任务将被取消,而任务组将重新抛出该错误。

为了形象化,让我们再考虑一下切蔬菜的问题。洋葱是一种很难切的蔬菜,如果你不小心,它们会让你流泪。如果我们试图切这些蔬菜,onion将向task group抛出一个错误,导致所有其他任务被自动取消:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func chopOnionsAndCarrots(rawVeggies: [Vegetable]) async throws -> [Vegetable] {
await try Task.withGroup { task group in // (3) will re-throw the onion chopping error
// kick off asynchronous vegetable chopping:
for v in rawVeggies {
await try task group.add {
await try v.chopped() // (1) throws
}
}

// collect chopped up results:
while let choppedVeggie = await try task group.next() { // (2) will throw for the onion
choppedVeggies.append(choppedVeggie)
}
}
}

让我们把chopOnionsAndCarrots()函数分解成多个步骤来完全理解它的语义:

  1. 添加切蔬菜的子任务进入任务组
  2. 异步处理不同的子任务
  3. 最终会处理到切洋葱任务然后抛出错误

任务组:父任务的取消机制

到目前为止,我们还没有讨论取消任务组的问题。如果创建任务组的任务被取消,则可以取消任务组。取消任务组将取消其中的所有任务。试图在已取消的任务组中添加更多任务将抛出CancellationError。下面的例子说明了这些语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct WorkItem { 
func process() async throws {
await try Task.checkCancellation() // (4)
// ...
}
}

let handle = Task.runDetached {
await try Task.withGroup(resultType: Int.self) { task group in
var processed = 0
for w in workItems { // (3)
try await task group.add { await w.process() }
}

while let result = try await task group.next() {
processed += 1
}

return processed
}
}

handle.cancel() // (1)

try await handle.get() // will throw CancellationError // (2)

有多种方法可以取消任务,但是在本例中,让我们考虑显式取消一个分离的任务。此任务是任务组的父任务,因此,当父任务的handle.cancel()被调用时,取消将被传递给它。

任务组在创建新子任务或等待子任务完成时会自动检查父任务的取消。如果系统承受较大负载,添加新任务也可能挂起,这是对正在添加到系统的新任务“队列”的一种形式上的反压。考虑到这些因素,程序员可以写出直观、自然的代码,在默认情况下仍然可以做正确的事情。

任务组:隐式地等待任务

有时不需要收集异步函数的结果(例如,因为它们可能返回Void),在这种情况下,我们可以依赖任务组隐式地等待所有已启动的任务返回。

在下面的例子中,我们需要确认我们收到的每一个订单,但是该确认没有返回任何有用的值给我们(它要么是Void,要么我们只是选择忽略返回值):

1
2
3
4
5
6
7
func confirmOrders(orders: [Order]) async throws {
await try Task.withGroup { task group in
for order in orders {
await try task group.add { await order.confirm() }
}
}
}

confirmOrders()函数将只在所有确认完成时返回,因为任务组将等待任何未完成的任务。

分离任务

分离任务是本提供中提供的两个“救生圈”api之一(另一个是下一节讨论的UnsafeContinuation),用于结构化并发规则对于特定异步操作过于严格的情况。

看看前面提到的在分离任务中做晚餐的例子,我们填写一下缺失的类型和细节:

1
2
3
4
5
6
7
8
let dinnerHandle: Task.Handle<Dinner> = Task.runDetached {
await makeDinner()
}

// optionally, someone, somewhere may cancel the task:
// dinnerHandle.cancel()

let dinner = await try dinnerHandle.get()

runDetached返回了一个Task.Handle这提供了任务逃逸的引用,允许等待或取消任务。

get()函数总是throwing(即使任务的代码没有抛出)CancellationError,因此等待handle的get()总是throwing,即使被封装的操作本身没有抛出错误。

1
2
3
4
5
6
7
extension Task {
public final class Handle<Success> {
public func get() async throws -> Success { ... }

public func cancel() { ... }
}
}

底层代码和通过UnsafeContinuation集成遗留api

异步代码的底层执行有时需要避开异步函数和任务组的高级抽象。此外,使API能够与现有的非异步代码交互,同时仍然能够向此类API的用户提供一个令人愉快的使用基于异步函数的接口,这一点非常重要。

对于这种情况,本提议提出了Unsafe(Throwing)Continuation的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
extension Task {
public static func withUnsafeContinuation<T>(
operation: (UnsafeContinuation<T>) -> ()
) async -> T { ... }

public struct UnsafeContinuation<T> {
private init(...) { ... }
public func resume(returning: T) { ... }
}


public static func withUnsafeThrowingContinuation<T, E: Error>(
operation: (UnsafeThrowingContinuation<T, E>) -> ()
) async throws -> T { ... }

public struct UnsafeThrowingContinuation<T, E: Error> {
private init(...) { ... }
public func resume(returning: T) { ... }
public func resume(throwing: E) { ... }
}
}

UnsafeContinuation允许封装现有的基于回调的复杂api,并将它们呈现给调用者,就像它是一个普通的异步函数一样。

处理UnsafeContinuation的规则:

  • resume函数只能在操作可能采取的每个执行路径(包括任何错误处理路径)上精确调用一次,
  • resume函数必须恰好在operation函数执行结束时被调用,否则将无法为operation函数中的捕获定义有用的语义,即它将与continuation并行;不幸的是,这不可避免地给使用延续带来了一些开销。

使用这个API,我们可以包装这样的函数(为了展示continuationAPI的灵活性,这里故意写得复杂了一些):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func buyVegetables(
shoppingList: [String],
// a) if all veggies were in store, this is invoked *exactly-once*
onGotAllVegetables: ([Vegetable]) -> (),

// b) if not all veggies were in store, invoked one by one *one or more times*
onGotVegetable: (Vegetable) -> (),
// b) if at least one onGotVegetable was called *exactly-once*
// this is invoked once no more veggies will be emitted
onNoMoreVegetables: () -> (),

// c) if no veggies _at all_ were available, this is invoked *exactly once*
onNoVegetablesInStore: (Error) -> ()
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// returns 1 or more vegetables or throws an error
func buyVegetables(shoppingList: [String]) async throws -> [Vegetable] {
await try Task.withUnsafeThrowingContinuation { continuation in
var veggies: [Vegetable] = []

buyVegetables(
shoppingList: shoppingList,
onGotAllVegetables: { veggies in continuation.resume(returning: veggies) },
onGotVegetable: { v in veggies.append(v) },
onNoMoreVegetables: { continuation.resume(returning: veggies) },
onNoVegetablesInStore: { error in continuation.resume(throwing: error) },
)
}
}

let veggies = await try buyVegetables(shoppingList: ["onion", "bell pepper"])

多亏将正确的continuation resume调用编写到buyVegetables函数的复杂回调中,我们才能够提供这个函数的更好的重载,允许我们的用户依赖async/await来与这个函数交互。

挑战性:理论上,提供编译器诊断来帮助开发人员避免多次resume(或者根本不resume)的错误是可能的。

然而,由于这个API的主要用例通常是与复杂的回调风格的API集成(如上面所示的buyVegetables),编译器通常不可能获得关于每个回调语义的足够信息,从而产生关于正确使用这个不安全API的诊断指导。

开发人员必须谨慎地调用resume,以保证正确恢复语义,如果不考虑应该调用resume的情况,将导致任务永远挂起,这证明了这个API的不安全表示是正确的。

关于任务api的一些杂项

自愿暂停

对于某些任务的长时间运行操作,比如在一个小循环中执行大量任务,有时候可能会有利于让任务检查他们是否应该被暂停,并为其他任务提供一个继续进行的机会(例如,如果所有任务都在一个有限并行的共享池中执行)。对于这个例子,Task引入了一个yield()操作,这是一种显式挂起并让其他任务有机会运行一段时间的方法。

这并不是解决任务饥饿的完美方法——如果任务是系统中最高优先级的任务,它可能会立即返回执行任务——但是它可能是针对长时间运行任务的有用的特定模式。

1
2
3
extension Task {
public static func yield() async { ... }
}

也可以将任务挂起,直到任意的截止日期为止。这类似于同步函数中的“休眠线程”,但是不会导致任何阻塞线程的代价。Task.sleep(until:)函数是异步的,只在给定的时间点之前挂起任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
extension Task {

/// Suspend until a given point in time.
///
/// ### Cancellation
/// Does not check for cancellation and suspends the current context until the
/// given deadline.
///
/// - Parameter until: point in time until which to suspend.
public static func sleep(until: Deadline) async {
fatalError("\(#function) not implemented yet.")
}
}

该功能不会自动检查取消,因此如果想检查是否超过了期限,则需要在休眠任务之前手动检查。

源码兼容性

这个提议只是一些新内容的添加。在async let中额外使用关键字async接受新代码是格式良好的,不会破坏或改变现有代码的含义。

对ABI稳定性的影响

无

对API弹性的影响

无

Async/await

发表于 2020-11-09

原文链接:Async/await proposal

介绍

现代Swift开发会涉及到大量的异步编程,一般来说我们通过回调来完成,但是回调的api非常难以使用。当代码涉及了非常多的异步操作,还要进行错误处理,并且回调之间互相依赖时,这会带来非常大的麻烦。本提议描述了对语言的拓展,让这一切变得更自然更不易出错。

我们打算为Swift引入协程的模型。函数可以是异步的,这让程序员们能够使用正常的控制流来编写包含异步操作的复杂逻辑。编译器负责将异步函数转换为一组适当的闭包和状态机。

这个提议定义了异步函数的语义,但是不提供并发行,并发性相关建议会在另外一个提议中详细阐述。并发性的提议会将异步函数与并发执行的任务相关联,并提供创建、查询和取消任务的api。

本提议从Chris Lattner和Joe Groff之前写的一个提议中得到了很多启发。提议本身源于Oleg Andreev的提议。显然,很多内容都经过了重写,细节也发生了改变,但是核心思想仍然是一致的。

动机:回调不是最优解

在异步编程中使用显式回调(也称为完成回调)有许多问题,我们将在下面探讨这些问题。提议通过在语言中引入异步函数来解决这些问题。异步函数允许将异步代码写成单行的形式。它们还能实现直接推断代码的执行模式,从而让回调更有效地运行。

问题一:回调地狱

一系列简单的异步操作通常需要深度嵌套的闭包。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func processImageData1(completionBlock: (result: Image) -> Void) {
loadWebResource("dataprofile.txt") { dataResource in
loadWebResource("imagedata.dat") { imageResource in
decodeImage(dataResource, imageResource) { imageTmp in
dewarpAndCleanupImage(imageTmp) { imageResult in
completionBlock(imageResult)
}
}
}
}
}

processImageData1 { image in
display(image)
}

这种回调地狱使得阅读和跟踪代码运行的位置变得非常困难。此外,使用一堆闭包会导致许多二阶效应,我们将在下文中讨论。

问题二:错误处理

回调使错误处理变得非常困难和冗长。Swift 2为同步代码引入了一个错误处理模型,但是基于回调的接口并没有从中得到任何好处:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func processImageData2(completionBlock: (result: Image?, error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
completionBlock(nil, error)
return
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
completionBlock(nil, error)
return
}
decodeImage(dataResource, imageResource) { imageTmp, error in
guard let imageTmp = imageTmp else {
completionBlock(nil, error)
return
}
dewarpAndCleanupImage(imageTmp) { imageResult in
guard let imageResult = imageResult else {
completionBlock(nil, error)
return
}
completionBlock(imageResult)
}
}
}
}
}

processImageData2 { image, error in
guard let image = image else {
error("No image today")
return
}
display(image)
}

Result的加入让Swift的api在错误处理这一块得到了提升。异步api也是Result的引入的一个重要动机:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func processImageData2(completionBlock: (Result<Image>) -> Void) {
loadWebResource("dataprofile.txt") { dataResourceResult in
dataResourceResult.map { dataResource in
loadWebResource("imagedata.dat") { imageResourceResult in
imageResultResult.map { imageResource in
decodeImage(dataResource, imageResource) { imageTmpResult in
imageTmpResult.map { imageTmp in
dewarpAndCleanupImage(imageTmp) { imageResult in
completionBlock(imageResult)
}
}
}
}
}
}
}
}

processImageData2 { result in
switch result {
case .success(let image):
display(image)
case .failure(let error):
error("No image today")
}
}

在使用Result时,我们更容易正确地处理错误,从而缩短代码。但是,回调地狱问题仍然存在。

问题三:条件执行很困难并且容易出错

通过执行异步函数是一件非常痛苦的事情。举个例子,假设我们需要在获得图像后对其进行swizzle。我们可能不得不在swizzle之前执行一些异步代码。也许构造这个函数的最好的函数是把swizzle的代码组织成一个逃逸闭包,并在异步函数的完成回调中被捕获,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
func processImageData3(recipient: Person, completionBlock: (result: Image) -> Void) {
let swizzle: (contents: image) -> Void = {
// ... continuation closure that calls completionBlock eventually
}
if recipient.hasProfilePicture {
swizzle(recipient.profilePicture)
} else {
decodeImage { image in
swizzle(image)
}
}
}

此种模式颠倒了一个函数自上而下的组织方式:在函数的后半部分执行的代码出现在了前半部分。除了重新构造整个函数之外,我们现在还必须仔细考虑闭包的捕获,因为这个闭包是在完成回调中使用的。随着条件执行的异步函数数量的增加,问题会进一步恶化,从而产生实质上的回调地狱。

问题四:实在太容易出错了

只需简单地返回,不调用正确的完成回调,就可以很容易地提前退出异步操作。当你搞忘了的时候,这个问题是很难调试的:

1
2
3
4
5
6
7
8
9
10
11
12
13
func processImageData4(completionBlock: (result: Image?, error: Error?) -> Void) {
loadWebResource("dataprofile.txt") { dataResource, error in
guard let dataResource = dataResource else {
return // <- forgot to call the block
}
loadWebResource("imagedata.dat") { imageResource, error in
guard let imageResource = imageResource else {
return // <- forgot to call the block
}
...
}
}
}

当你记得调用回调时,你仍然可能会忘记在调用回调之后返回:

1
2
3
4
5
6
7
8
func processImageData5(recipient:Person, completionBlock: (result: Image?, error: Error?) -> Void) {
if recipient.hasProfilePicture {
if let image = recipient.profilePicture {
completionBlock(image) // <- forgot to return after calling the block
}
}
...
}

幸运的是,guard语法在某种程度上让你避免了忘记返回的情况,但它并不能给你永远的保护。

问题五:因为完成回调的尴尬处境,很多api被设计成同步的形式了

虽然很难量化,但笔者认为,使用回调的尴尬导致许多api被定义为同步行为,甚至在它们可能阻塞的时候。这可能会导致UI应用程序的性能和响应性问题,例如loading光标。它还会导致一些api无法在异步对规模至关重要的情况下使用,比如在服务器上。

解决方案:async/await

异步函数——通常称为async/await——允许异步代码像单行的同步代码一样编写。通过允许程序员充分使用同步代码可用的相同语言结构,它可以立即解决上面描述的许多问题。使用async/await也自然地保留了代码的语义结构,提供了至少三个对语言的改进所必需的信息:

  1. 更好的异步代码性能
  2. 更好的工具,在调试、剖析和探索代码时提供更一致的体验
  3. 为将来的并发特性(如任务的优先级和取消)奠定基础。

我们使用前面的例子演示一下async/await如何大大简化异步代码:

1
2
3
4
5
6
7
8
9
10
11
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

func processImageData2() async throws -> Image {
let dataResource = await try loadWebResource("dataprofile.txt")
let imageResource = await try loadWebResource("imagedata.dat")
let imageTmp = await try decodeImage(dataResource, imageResource)
let imageResult = await try dewarpAndCleanupImage(imageTmp)
return imageResult
}

许多关于async/await的实现描述都是一个通用的机制:将函数切割为多个部分的编译器传递。这在抽象的底层层次上理解机器是如何运行的是很重要的,但在更高层次上,我们希望您忽略它。相反,可以将异步函数看作具有放弃线程的特殊能力的普通函数。异步函数通常不会直接使用这种能力;相反,它们只是进行方法调用,有时这些调用将要求它们放弃线程并等待某些事情发生。当该操作完成时,函数将继续执行。

这与同步函数有很强的相似性。同步函数的调用会在调用完成前等待,在调用完成后返回。一旦调用完成,控制流会回到调用时的位置并从它停止的地方继续。异步函数也是如此:它可以像往常一样调用;当进行调用时,它通常会立即等待调用的完成。一旦调用完成,控制权就返回到函数,恢复到原来的位置。唯一的区别是同步函数可以充分利用部分它们的线程及其堆栈,而异步函数可以完全放弃堆栈而使用它们自己单独的存储区域。异步函数的这种额外功能有一些实现成本,但我们可以通过围绕它进行整体设计来降低成本。

因为异步函数必须能够放弃自己的线程,而同步函数不知道如何放弃一个线程,所以同步函数通常不能调用异步函数:异步函数只能放弃它占领的线程的一部分。如果进行一下这样的尝试,同步函数中调用一个异步函数,同步函数需要等待异步函数的返回。一般来说,实现这一功能的唯一方式是阻塞整个线程,直到异步函数恢复并完成,那这将完全违背异步函数的目的,并会造成恶劣的系统影响。

相反,异步函数既可以调用同步函数也可以调用异步函数。当然,在调用同步函数时,它不能放弃线程。实际上,异步函数不会直接放弃线程;它们只有在到达所谓的挂起点时才会放弃线程,挂起点就是await。挂起点可以直接发生在一个函数中,也可以发生在该函数调用的另一个异步函数中,但在任何一种情况下,该函数及其所有异步调用者都会同时放弃线程。(在实践中,异步函数在编译时不依赖于异步调用期间的线程,因此只有最内层的函数需要做一些额外的工作。)

当控制流返回到一个异步函数时,它会准确地恢复到原来的位置。这并不一定意味着它将在与之前完全相同的线程上运行,因为语言层不能保证在挂起之后会这样做。在这种设计中,线程主要是一种实现机制,而不是预期的并发接口的一部分。然而,许多异步函数并不仅仅是异步的:它们还与特定的actor相关联,并且它们总是作为actor的一部分运行。Swift保证这些函数实际上将返回到其actor以完成执行。因此,直接使用线程进行状态隔离的库——例如,通过创建自己的线程并在线程上顺序调度任务——通常应该在Swift中将这些线程转化为actor,以在Swift中保证正常运行。

挂起点

挂起点是执行异步函数时放弃线程的点。挂起点总是与函数中一些确定性的、语法上明确的事件相关联;从函数的角度来看,它们不会是被隐藏的或异步的。详细的语言设计将把几个不同的操作描述为挂起点,但最重要的一个操作是对与不同上下文的异步函数的调用。

重要的是,挂起点只与显式操作相关联。事实上,这个提议要求将可能暂停的调用封装在一个await表达式中,这是非常重要的。这沿袭了Swift使用try表达式来处理可能引发错误的函数调用的先例。标记挂起点特别重要,因为挂起中断了原子性。例如,如果一个异步函数在一个受串行队列保护的上下文中运行,达到一个挂起点意味着其他代码可以交错在同一串行队列上。原子性的重要性可以体现在一个经典但有点老旧的例子:如果存款被存入一个帐户,但操作在处理匹配的取款之前暂停,那么它将创建一个时间窗口,在该窗口中可以重复使用这些资金。对许多Swift程序员来说,一个更贴切的例子是UI线程:挂起点是UI可以显示给用户的点,因此程序构建部分UI然后挂起的风险是呈现一个卡住了的、部分构造的UI。(注意,代码中也使用显式回调显式地调用挂起点:挂起发生在外部函数返回点和回调开始运行点之间。)我们要求所有的挂起点都被标记,这使得程序员可以安全地假定没有挂起点的地方将按照原子方式运行,并且更容易识别出有问题的非原子模式。

由于挂起点只能出现在异步函数中被显式标记的点上,因此长时间的计算仍然会阻塞线程。当调用一个只做大量工作的同步函数时,或者遇到直接在异步函数中编写的特别密集的计算循环时,可能会发生这种情况。在任何一种情况下,线程都不能在这些计算运行时交错代码,这通常是正确的选择,但也可能成为延展性问题。需要进行密集计算的异步程序通常应该在单独的上下文中运行。当这不可行的时候,将会有一些工具来人为地挂起并允许其他操作进行交叉。

异步函数应该避免调用那些实际上会阻塞线程的函数,特别是当它们阻塞线程的目的是等待哪些不能保证当前正在运行的工作完成的时候。例如,获取互斥锁的行为只能被阻塞,直到当前运行的线程放弃该互斥锁;这有时是可以接受的,但必须谨慎使用,以避免引入死锁或人为的延展性问题。相反,等待一个条件变量可能会阻塞,直到某个工作被调度,并通知该变量;这种模式与推荐的方式是背道而驰的。需要做一些工作来让程序避免这些缺陷。

当异步函数在另一个上下文中等待操作时,此设计目前没有提供阻止当前上下文并切换的函数。这种省略是有意为之的:为了防止出现死锁。

异步调用

对async函数的调用看上去和实际运行上都很像对同步函数的调用。调用一个async函数的语义是:

  • 参数是使用普通规则计算的,包括对任何inout参数的初始访问。
  • 被调用者的执行者是确定的。该提议没有说明确定执行者的规则;参见关于actor的补充建议。
  • 如果被调用者的执行者与调用者的执行者不同,则会发生挂起,在被调用者中恢复执行的部分任务会安排到被调用者的执行程序上。
  • 被调用者在其执行者上使用给定的参数执行。
  • 在返回期间,如果被调用者的执行者与调用者的执行者不同,则会发生挂起,并且在调用者中恢复执行的部分任务会安排到调用者的执行程序中。
  • 最后,调用者在它的执行者上继续执行。如果被调用方正常返回,则调用表达式的结果为函数返回的值;否则,表达式将抛出被调用方抛出的错误。

从调用方的角度来看,异步调用的行为类似于同步调用,除了它们可能在不同的执行者上执行,需要任务被暂时挂起。还要注意的是,由于调用上的挂起,inout访问的持续时间可能要长得多,因此inout对共享的可变状态的引用没有充分隔离,更有可能产生动态排他性冲突。

详细设计

异步函数

函数类型可以显式标记为async,表示函数是异步的:

1
func collect(function: () async -> Int) { ... }

函数或初始化声明也可以显式声明为async:

1
2
3
4
5
6
7
8
9
class Teacher {
init(hiringFrom: College) async throws {
...
}

private func raiseHand() async -> Bool {
...
}
}

对声明为async的函数的引用以及初始化方法均是async函数类型 。如果引用是对实例方法的柯里化的静态引用,则内层的函数类型是异步的,这与此类引用的规则是一致的。

一些特殊的函数,比如deinit以及存储访问器不能标为async。

原理阐述:只有getter的属性可能是async的。但是,同样具有异步setter的属性意味着能够将属性作为inout传递并深入到该属性本身,这取决于setter是否有效地是一个同步操作。禁止异步属性比只允许get的异步属性更简单。

如果一个函数既是async又是throws,那么在声明时,async必须在throws之前。同样的规则也被应用在async和rethrows上。

原理阐述:这种顺序限制没有很特别的理由,但它没有坏处,而且它消除了对风格的潜在争论。

异步函数类型

异步函数类型不同于同步函数类型。不存在从同步函数类型到相应异步函数类型的隐式转换。但是,将非抛出异步函数类型的值隐式转换为相应的抛出异步函数类型是允许的。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct FunctionTypes {
var syncNonThrowing: () -> Void
var syncThrowing: () throws -> Void
var asyncNonThrowing: () async -> Void
var asyncThrowing: () async throws -> Void

mutable func demonstrateConversions() {
// Okay to convert to throwing form
syncThrowing = syncNonThrowing
asyncThrowing = asyncNonThrowing

// Error to convert between asynchronous and synchronous
asyncNonThrowing = syncNonThrowing // error
syncNonThrowing = asyncNonThrowing // error
asyncThrowing = syncThrowing // error
syncThrowing = asyncThrowing // error
}
}

我们可以手动创建一个调用同步函数的async闭包,因此缺少隐式转换不会有表达能力上的影响。有关定义async闭包的语法,请参阅“闭包”一节。

原理阐述:我们不建议使用从同步函数到异步函数的隐式转换,因为这会使类型检查复杂化,特别是在存在同一函数的同步和异步重载的情况。有关更多信息,请参阅“重载和重载解析”一节。

Await表达式

对async函数类型的值的调用(包括对async函数的直接调用)带来了一个挂起点。任何挂起点都必须发生在一个异步的上下文中(例如,一个async函数)。而且,它必须出现在await表达式的操作中。

看看下面的例子:

1
2
3
4
5
// func redirectURL(for url: URL) async -> URL { ... }
// func dataTask(with: URL) async throws -> URLSessionDataTask { ... }

let newURL = await server.redirectURL(for: url)
let (data, response) = await try session.dataTask(with: newURL)

在这个例子中,一个任务的挂起可能发生在redirectURL(for:)和dataTask(with:)中,因为它们都是异步函数。因此,两个调用表达式都必须包含在await表达式中,因为它们都包含了挂起点。尽管在await的操作中允许有多个挂起点,但await表达式的操作必须至少包含一个挂起点。例如,我们可以使用一个await来覆盖两个挂起点来重写上面的例子:

1
let (data, response) = await try session.dataTask(with: server.redirectURL(for: url))

await没有其他的语义;像try一样,它只是标记正在进行异步调用。await表达式的类型是它的操作者的类型,返回结果是其操作者的结果。

原理阐述:重要的是,异步调用必须在函数内被清晰地识别,因为它们引入了挂起点,这会破坏操作的原子性。挂起点可能是调用所固有的(因为异步调用必须在不同的执行者上执行),或者仅仅是被调用者实现的一部分,但是在任何一种情况下,它在语义上都是重要的,程序员需要有正确的认识。await表达式也是异步代码的指示符,它与闭包中的推断相关联;更多信息请参见“闭包”一节。

挂起点不能出现在非async函数类型的自动闭包中。

挂起点不能出现在defer的block中。

闭包

一个闭包可以具有async的函数类型。这样的闭包可以明确地标记为async,如下所示:

1
2
3
4
{ () async -> Int in
print("here")
return await getInt()
}

如果匿名闭包包含一个await表达式,则推断它具有async函数类型。

1
2
3
4
5
6
let closure = { await getInt() } // implicitly async

let closure2 = { () -> Int in // implicitly async
print("here")
return await getInt()
}

注意,对闭包的async推断不会延伸到它的封闭性、嵌套函数或闭包,因为这些上下文不论异步或同步都是可分离的。例如,只有closure6在这种情况下被推断为async:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// func getInt() async -> Int { ... }

let closure5 = { () -> Int in // not 'async'
let closure6 = { () -> Int in // implicitly async
if randomBool() {
print("there")
return await getInt()
} else {
let closure7 = { () -> Int in 7 } // not 'async'
return 0
}
}

print("here")
return 5
}

重载和重载解析

现有的包含一个操作同步和异步入口的Swift程序,可能会为每个操作使用两种命名相似的方法来设计:

1
2
func doSomething() -> String { ... }
func doSomething(completionHandler: (String) -> Void) { ... }

在调用方看来上,通过是否传入回调可以清楚地了解正在调用哪个方法。然而,随着第二种方法的api直接映射到一个async函数,这两种方法现在非常相似:

1
2
3
4
func doSomething() -> String { ... }
func doSomething() async -> String { ... }

doSomething() // synchronous or asynchronous?

如果我们用throws替换async,声明上面的两个方法会产生一个“invalid redeclaration”的编译错误。但是,我们建议允许async函数重载非async函数,因此上面的代码是没问题的。这允许现有的Swift程序发展现有同步函数的async版本,而不会产生虚假的重命名。

重载async和非async函数的能力与重载解析规则相对应,根据调用的上下文选择适当的函数。对于一个调用,重载解析倾向于在同步上下文中使用非async函数,因为这样的上下文中不能包含对异步函数的调用。此外,重载解析倾向于在异步上下文中使用async函数,因为当有替代方法时,这样的上下文应该避免同步、阻塞api。当重载解析选择一个async函数时,该调用必须发生在一个await表达式中。

自动闭包

除非函数本身是async,否则函数不能使用async函数类型的自动闭包参数。例如,下面的声明格式是不正确的:

1
2
// error: async autoclosure in a function that is not itself 'async'
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { }

这一限制的存在有几个原因。考虑下面的例子:

1
2
3
4
5
6
// func getIntSlowly() async -> Int { ... }

let closure = {
computeArgumentLater(await getIntSlowly())
print("hello")
}

乍一看,await表达式告诉程序员调用computeArgumentLater(_:)会有一个挂起点,实际上并不是这样:挂起点位于被computeArgumentLater(_:)使用和传递的自动闭包内部。这导致了一些问题。首先,await出现在调用上这一事实意味着我们将推断闭包具有async函数类型,然而这也是不正确的:闭包中的所有代码都是同步的。其次,因为一个await的操作只需要包含一个暂停点在它的某个位置,一个等效的重写的调用应该是:

1
await computeArgumentLater(getIntSlowly())

但是,因为参数是一个自动闭包,所以这种重写没有保留它语义。因此,对async自动闭包参数的限制确保async自动闭包参数只能在异步上下文中使用,从而避免了这些问题。

源码兼容性

这个提议基本上只是一些新的添加:现有代码不会使用任何新特性(例如,不创建async函数或闭包),因此不会受到影响。但是,它引入了两个新的上下文关键字:async和await。

语法中async的用法位置(函数声明、函数类型和作为let的前缀)允许我们将async作为上下文关键字处理,而不会破坏源代码的兼容性。用户定义的async不能出现在代码的相应语法位置中。

await 这个上下文相关的关键字有一些问题,因为它发生在表达式中。例如,我们可以在Swift中定义一个功能:

1
2
3
func await(_ x: Int, _ y: Int) -> Int { x + y }

let result = await(1, 2)

这是一段格式良好的代码,它是对await函数的调用。根据这个提议,这段代码变成了一个带有子表达式(1,2)的await表达式,这对于现有的Swift程序来说是一个编译时错误,因为等待只能在异步上下文中使用,而且现有的Swift程序没有这样的上下文。这样的函数看起来并不常见,所以我们认为这是引入async/await时可以接受的源码破坏。

对ABI稳定性的影响

无

对API弹性的影响

无

Swift并发路线图

发表于 2020-11-07

本文是SwiftConcurrencyRoadmap的中文翻译

我们的目标是使Swift在并发编程方面方便、高效和安全。

本文概述了对语言的一些添加和更改来实现asynchronous functions和actor的特性。这些提议将被分别提出,但在许多情况下它们将相互依赖。此文档用于对它们进行统一。宣言可能描述多个可能的方向,与此不同,本文描述了处理Swift并发性的单一计划。

这些即将产生的变化将会带来的是:

  • 异步编程更方便和清晰
  • 提供一套标准的工具和技术,让swift开发者能够获得指引
  • 优化编译期处理以提升异步代码的性能
  • 以和消除内存不安全性同样的方式消除竞态条件和死锁

这些特性的引入将跨越多个Swift版本。功能的引入大致分为两个阶段。第一个阶段引入异步语法和actor类型;这将允许用户以一种减少(而不是消除)数据竞争的方式来组织Actor的代码。第二阶段将强制执行完整的Actor Isolation,消除数据竞争,同时也将带来一些特性,实现高效和符合人体工程学的Actor交互操作,从而使隔离成为现实。

作为路线图,本文不像那些具体的proposal那样详细。本文还讨论了第二阶段的功能,但该阶段的详细建议将会在第一个阶段得到更好地定义之后再进行补充。

还有许多其他相关主题没有在本文档中介绍,比如异步流、并行for循环和分布式Actor。这些特性中的许多都是对路线图中所描述内容的补充,可以在任何时候引入。

现状

目前,我们提倡的处理并发的基本模式是良好的:我们告诉大家应该使用队列而不是锁去保护状态,通过异步回调而不是阻塞的方式返回慢速操作的结果。

但是实际情况是我们可能会写出糟糕和容易出错的代码。看看下面的代码,你就会明白:

1
2
3
4
5
6
7
8
internal func refreshPlayers(completion: (() -> Void)? = nil) {
refreshQueue.async {
self.gameSession.allPlayers { players in
self.players = players.map(\.nickname)
completion?()
}
}
}

有三点我们值得进行思考:

  • 我们写了非常多的公式代码,这个方法基本上只是进行一个简单调用,转换结果并赋值,但是处理线程和回调的代码太多了导致我们很难看清这个方法的本质
  • 这些公式代码让产生bug变得更容易。我们在方法的回调中直接对self.players进行了赋值,那么这个赋值操作是在哪个线程上执行的呢?我们不清楚。所以这会带来潜在的数据竞争:回调可能需要被派发到正确的线程上执行。可能这个工作是allPlayers完成的,但我们无法根据现有的条件推断这段代码是否是线程安全的。
  • 这段代码是低效的,尽管这是不必要的。首先,我们要分配几个方法对象的内存,对于像self这样的引用,我们必须拷贝到这些方法中,这带来了额外的引用计数的操作。这段代码可能会运行很多次,也可能根本不会运行,所以通常这使得编译器无法避免这些拷贝工作。

此外,这些问题的耦合是不可避免的。异步回调一般来说总是准确地只运行一次,这意味着它们不会产生循环引用。由于Swift无法获知这一点,它要求self在闭包中显式地声明,所以一些程序员会条件反射地使用[weak self]。由于不得不处理self为空的情况,运行时开销和公式代码的增加也将无法避免。通常,当self为空时,这些方法会立即返回,这使得我们很难判断代码的正确性,因为任意量的代码可能会被跳过。

所以在这里基本模式没有问题,但是用swift进行表达会失去重要的结构信息并产生问题。解决方案是将这些基本模式引入到语言中。这将减少公式代码,并通过语言支持让基本模式更加安全,消除bug,并让程序员有信心更广泛地使用并发。也许它还将为我们提供一个提高并发代码性能的机会。

下面是使用我们提出的新语法重写的代码:

1
2
3
internal func refreshPlayers() async {
players = await gameSession.allPlayers().map(\.nickname)
}

有几点值得注意:

  • refreshPlayers现在是async修饰的方法了
  • allPlayers也是async修饰的方法,并且方法会返回结果而不是通过回调传递
  • 我们可以使用表达式的组合去直接调用map方法
  • await关键字表明refreshPlayers这个方法将会在这一刻被挂起
  • await的表现与try类似,它只需要在表达式中出现一次就可以了
  • 显式声明的self.被消除了,因为已经没有闭包对self进行捕获了
  • allPlayers和players的访问不再会有数据竞争的情况了

为了理解最后一点是如何实现的,我们必须走出来看看队列应该如何使用来保护状态。

原始代码是一个使用refreshQueue保护其内部状态:

1
2
3
4
5
6
7
8
9
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession
var refreshQueue = DispatchQueue(label: "PlayerRefresh")

func refreshPlayers(completion: (() -> Void)? = nil) {
...
}
}

这是一种常见的模式:类具有私有队列和一些只能在队列上访问的属性。现在我们用一个actor来替换这种方式:

1
2
3
4
5
6
actor class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession

func refreshPlayers() async { ... }
}

你会注意到:

  • 我们将这个类用actor进行修饰,这与给类一个私有队列并在队列里维护内部状态的做法是类似的
  • 你还是需要使用队列来保护状态:编译器将确保类的方法在指定的队列上运行,并且它将阻止你在方法之外去访问这些状态。
  • 因为编译器负责执行此操作,所以它可以更聪明地进行优化,比如当方法的调用是在不同的actor上的时候。

上面我们展示了一个actor类,其中有一组经过严密封装的属性和代码。但我们现在的UI编程方式通常是将代码分散到大量的类中,而这些类本应在一个主线程中使用。所以主线程仍然是一种actor——我们称之为global actor。

您可以使用属性将类和方法绑定到actor上。编译器将允许您从任何地方引用这个类,但是要真正调用这个方法,您需要在UI Actor上进行调用。因此,如果PlayerRefreshController的所有动作都适合在全局UI actor上执行,我们将这样表示它:

1
2
3
4
5
6
7
@UIActor
class PlayerRefreshController {
var players: [String] = []
var gameSession: GameSession

func refreshPlayers() async { ... }
}

对于第一阶段的提议

我们会在未来数周提出以下提议,以开展第一阶段的工作:

  • async / await,引入基于协程的模型结构。方法可以声明为async,并且可以等待其他async方法的结果返回,这使得异步代码的单行表达成为可能。相关的讨论。
  • TaskAPI and Structured Concurrency,为标准库引入task的概念。api将提供包括detached tasks、用于动态创建child tasks的task "nurseries",以及task的取消及优先级机制。它还将引入基于范围的机制来等待多个子task的返回值,机制设计的原则基于Structured concurrency。相关的讨论。
  • Actors & Actor Isolation,这为并行编程提供了状态隔离,通过这种机制可以消除潜在的数据竞争。第一阶段的提议将引入部分Actor Isolation,将完全隔离留给下一阶段。相关的讨论。
  • Concurrency Interoperability with Objective-C,它将为swift的并行特性(比如async方法)和Objective-C的一些约定俗成的异步方法表达提供自动桥接。这将允许现有的异步Objective-C api在swift的并发模型中立即可用,做法是为swift的转换提供两个选择,一个是直接转换为async方法,另外一个向后兼容提供基于回调的版本。相关的讨论。
  • Async handlers,它提供了声明同步的actor方法为一个异步处理的能力。这些方法的表现非常像一个同步方法,但是在内部,他们会被异步处理。这将允许一些传统的通知概念(比如UITableViewDelegate)去异步地执行而无需繁琐的设置。

Actor Isolation以及第二阶段

我们的目标是在正常情况下防止可变状态上的数据竞争。实现此目的的系统称为Actor Isolation,这是因为actor是整套机制能够得以实现的核心概念,而且这套机制的重点将放在防止actor外部访问actor保护的状态。然而,在需要确保系统在并发状态下的正确性时,Actor Isolation机制同样会对代码做出限制,即使在actor没有直接参与的情况下。

我们打算分两个阶段介绍路线图中描述的特性:首先介绍创建异步方法和actor的能力;第二,实施完整的Actor Isolation。

Actor Isolation的基本思想与独占访存的思想类似,并以此为基础。Swift的并发设计旨在通过从actor的自然隔离开始,然后使用所有权作为补充工具,提供一种易于使用和可组合的安全并发方法。

Actor Isolation问题将被简化为确保所有普通的可变内存仅由特定的actor或task访问的问题。这进而简化为如何访问内存以及谁可以优先访问的分析。我们可以把情况分成这样几类:

  • actor的属性将会被被actor所保护
  • 不可变内存(比如let变量)、局部内存(比如完全不会被被捕获的本地变量),以及值类型内存(比如结构体的属性或者枚举),将会被保护,不会产生数据竞争
  • 不安全的内存(如UnsafeMutablePointer引用的任意内存)与不安全的抽象相关联。试图做到安全地使用这些抽象是不可取的,因为这些抽象的目的是在必要时可以用来绕过安全的语言规则。相反,我们寄希望于程序员会正确使用这些类型。
  • 全局内存(如全局或静态变量)原则上可以被任何地方的代码访问,所以会出现数据竞争。
  • 类相关的内存也可以从任何持有该类的引用的代码中访问。这意味着,虽然对类的引用可能受到actor的保护,但在actor之间传递该引用会使其属性暴露在数据竞争中。这也包括在值类型中持有的对类的引用,当这些引用在actor之间传递时。

完整的Actor Isolation的目标是确保这最后两种情况受到保护。

第一阶段:基本的Actor Isolation

第一阶段带来了安全性上的好处。人们将能够用全局actor保护全局变量,并通过将其转换为actor类来保护类成员。需要在特定队列上进行访问的框架可以定义一个全局行为体,并为其提供默认协议。

在这一阶段,一些重要的Actor Isolation将被强制执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
actor class MyActor {
let immutable: String = "42"
var mutableArray: [String] = []

func synchronousFunction() {
mutableArray += ["syncFunction called"]
}
}

extension MyActor {

func asyncFunction(other: MyActor) async {
// allowed: an actor can access its internal state, even in an extension
self.mutableArray += ["asyncFunction called"]

// allowed: immutable memory can be accessed from outside the actor
print(other.immutable)

// error: an actor cannot access another's mutable state
otherActor.mutableArray += ["not allowed"]

// error: either reading or writing
print(other.mutableArray.first)

// allowed: async functions can call async functions on other actors
await other.asyncFunction(otherActor: self)

// error: only asynchronous functions can be called from outside the actor
other.synchronousFunction()
}
}

这些改变不会对现有的swift代码带来改变,因为actor和async是一个新特性。

第二阶段:完整的Actor Isolation

即使在引入actor之后,全局变量和引用类型的值,仍然存在数据竞争的可能性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var racyGlobal: [String] = []

@MyGlobalActor
var safeGlobal: [String] = []

class PlainOldClass {
var unprotectedState: String = []
}

actor class RacyActor {
let immutableClassReference: PlainOldClass

func racyFunction(other: RacyActor) async {
// protected: global variable protected by a global actor
safeGlobal += ["Safe access"]

// unprotected: global variable not in an actor
racyGlobal += ["Racy access"]

// unprotected: racyProperty is immutable, but it is a reference type
// so it allows access to unprotected shared mutable type
other.takeClass(immutableClassReference)
}

func takeClass(_ plainClass: PlainOldClass) {
plainClass.unprotectedState += ["Racy access"]
}
}

在第一阶段,我们打算保留Swift目前的行为:全局变量和引用类型的内存不会受到保护。因此actor对于它们来说也不是安全的。因为这是swift目前的行为,所以这也不会对现有的swift代码带来改变。

在第二阶段,我们将引入一套工具链来处理完整的Actor Isolation。其中最重要的一点是将一个类型限制为local actor。当一个类型被标记为local actor时,编译器将阻止它在actor之间传递。引用需要通过某种方式进行拷贝或者以非共享的方式进行跨域传递。

这反过来又会对默认情况作出改变:

  • 全局变量将被要求由一个global actor保护,或标记为 unsafe actor。
  • 类(和包含类引用的类型)将从默认的 unsafe actor变为local actor。

这种改变将需要现有的Swift代码能够进行兼容,并且需要通过语言层来把关。触及可变的全局变量或跨actor边界共享的类引用,从根本上无法证明其不受数据竞争的影响,将需要改变以确保其免受数据竞争的影响(通过未来的代码)。希望这个兼容不会很繁琐:

  • 我们预期全局变量的使用应该是克制的,而且大多数全局变量可以由global actor保护;
  • 只要类没有跨actor边界进行共享,local actor就不应该影响actor内部的代码;
  • 在必须跨边界传递引用的地方,语言应该让这一点显现出来,解决方案也应该简单;
  • 通过进一步鼓励和简化值类型的使用,跨边界共享的需求将会减少;
  • 这两个阶段之间的时间将使用户有机会将他们的代码分解为actor和async方法,为完全隔离做好准备。

与第一阶段的讨论不同,第二阶段需要的语言特性将首先在swift论坛的演化讨论版提出讨论。采取两个阶段的主要驱动因素之一是希望在转移到完全隔离模型之前给swift用户以时间来适应async方法和actor。将代码移植到actor和async方法的经验将告诉我们执行完全Actor Isolation所需的功能。这个反馈应该会反哺第二阶段的特性讨论。

预计将在第二阶段讨论的功能包括:

  • 引入actorlocal的限制类型
  • 通过mutableIfUnique类类型,编译器能正确处理“写入拷贝”类型;
  • 属性可选择退出Actor Isolation,例如通过其他方法处理线程安全的情况。

基本概念表

这些是将在整个设计中使用的基本概念,在这里简要定义:

  • synchronous function是Swift程序员已经习以为常的同步方法:它在一个线程上运行到完成,除了它调用的任何同步方法外,没有交叉代码。
  • thread是指底层硬件的线程概念。平台各不相同,但往往具有相同的基本特征:真正的并发性需要创建线程,但创建和运行线程的成本很高。C方法调用和普通的同步Swift方法,都需要使用一个线程。
  • asynchronous function是一种新的方法,它不需要运行到完成一路畅通。中断会导致方法被挂起。挂起点是指异步方法中断其线程的点。
  • task是异步运行的操作。所有异步方法都作为某个任务的一部分运行。当一个异步方法调用另一个异步方法时,该调用仍然是同一任务的一部分,即使该调用必须更改actor。任务类似于异步方法的线程。
  • 异步方法可以创建child task。子任务继承父任务的一些结构,包括其优先级,但可以与父任务并发运行。但是,这种并发性是有限制的:创建子任务的方法必须等待子任务结束后才能返回。
  • 一个程序希望能开启独立的并发工作通过使用detached task,而不是一个有边界的child task来超越上下文边界。
  • partial task是可调度的工作单元。task中当前执行的方法被挂起时,就是部分任务的结束,并创建一个新的partial task来继续整个task的工作。
  • executor是一种服务,它接受partial task的提交,并安排某个线程运行它们。当前运行的异步方法总是知道它在哪个executor上运行。如果提交给执行程序的部分task永远不会并发运行,则称为exclusive executor。
  • actor是程序中可以运行代码的独立部分。它一次只能运行一段代码—也就是说,它充当exclusive executor—但是它运行的代码可以与其他actor运行的代码并发执行。
  • 一个actor的状态会受到保护,只有该actor才能访问。实现这一点所需的系统称为actor isolation。Swift的长期目标是在默认情况下保证actor isolation。
  • actor class是一个引用类型,它的每个实例都是一个单独的actor。它的受保护状态是它的实例属性,它的actor方法是它的实例方法。
  • global actor是全局对象。它的受保护状态和actor方法可以分布在许多不同的类型中。它们可以标记为一个特定于actor的属性,在许多情况下Swift可以推断出这个属性。

Why heic lost rotation?

发表于 2020-09-01

我们的项目中支持了heic的上传和展示。在iOS 14之前,一切都很美好,我们享受着heic带来的图片体积的减小。但是在iOS 14以及新的macOS中,事情发生了变化,非常多的heic图片的展示方向都和预想的不一样。这是怎么回事呢?

背景

我们的项目中在图片上传之前会对图片本身进行一些ImageI/O相关的处理。

首先,我们会将图片downsampling,对于图片的像素数量我们有一个最大的限制是15000000个像素。对于图片最短边的尺寸我们限制为1500个像素。也就是说,假设原图大小是30004000,我们会把图片缩放到15002000。实现原理是通过WWDC18 session 219介绍的方式,通过ImageI/O进行处理(详情见图片渲染优化)。

downsampling完成之后,我们会根据图片的类型进行compress。比如对于jpeg以及heic,我们会进行一个0.75 compress quality的压缩(经验值)。

问题出在哪?

首先拿到heic原图和经过我们处理后的图片,通过exiftool打印图片的元信息,这里仅对相关的属性进行一个diff(左边是原图,右边是处理过的图片):

我们发现这个Rotation信息和之前的不一样了,于是我们合理怀疑一下是不是这个Rotation导致图片展示方向出了问题?

然而根据我们之前的经验和相关的苹果的文档,图片的方向是根据Orientation来进行计算的,而两边图片元信息中的Orientation的值都是一样的:Rotate 90 CW即顺时针旋转90度。这又是为什么呢?

后来我们得知,这个Rotation是heic独有的属性,因此我们推测,在新的系统上,苹果使用了这个Rotation来进行图片方向的判断。

诊断

Fine,那为什么经过我们处理后的图片会丢失Rotation相关的信息呢?经过了几天各种各样乱七八糟的尝试以及各地各式的资料搜集,我们依旧没能找到答案,这个Rotation信息就这么没了。。。

于是我们作出了一个假设就是ImageI/O的处理就是会导致失去Rotation信息,那么我们解决的思路就分以下两种:

  • 不遗余力把这个Rotation信息找回来
  • 在downsampling的时候让ImageI/O把图片先转一下

第二个方案实现起来很简单,只需要在downsampling的时候把kCGImageSourceCreateThumbnailWithTransform设为true就好了,但是我们又会发现,转过来了之后在旧的iOS版本上,图片方向又是错的了,原因可能是在旧版本上系统是根据Orientation来判断图片方向的。。。

所以到目前为止,我们唯一稍微可行的方案是:在downsampling的时候把图片先转一下,在encode的阶段把meta data全丢掉,这样显示的就是对的,可这么做似乎并不优雅。

解决方案

在一筹莫展之际,我随意地翻了翻ImageI/O相关的接口和注释,发现一个有趣的东西:kCGImageDestinationImageMaxPixelSize,这是一个在encode的时候的一个配置项,但是和downsampling的一个配置项非常像:kCGImageSourceThumbnailMaxPixelSize。

于是我有了一个大胆的假设,是不是真正的最佳实践并不是手动downsampling+encode,而是通过encode的配置项实现downsampling,反正图片编码也会进行downsampling。

于是我动手改造原来的代码,将手动downsampling的过程去掉,通过CGImageDestinationAddImageFromSource的方法传入图片的image source进行encode,而不是之前直接传入CGImage。果然,经过处理后的图片没有丢掉Rotation,我们得到了一个可能是完美的解决方案。

总结

ImageI/O相关的文档和注释真的非常之少,而且缺乏系统指导性的最佳实践,甚至连苹果系统内部对于图片方向的判断逻辑都出现严重的前后不一。不过通过这个问题,我们推想出了一个最佳实践,那就是只有在显示渲染的时候,我们才去做手动downsampling,对于encode来说,downsampling的操作是可以传入参数自动完成的。

Debug Universal Link

发表于 2020-08-27

这片文章主要是记录一下几个月前发生的一件事,其中有些经验值得写一写。

背景

有一次我们的测试给我提了一个bug是说App间的跳转不生效了。我们的App是通过Universal Link进行跳转的,所以我第一时间先在我的手机检查了一下 Safari 的跳转和 apple-app-site-association 文件内容,发现都没有问题。当时就很困惑到底哪里出了问题,所以去社区看了看有没有类似的问题,发现了两个radar:

  • rdar://45201697: iOS 12 cannot fetch AASA file within 5 minutes after rebooting the phone
  • rdar://33893852: Certain users fail to download apple-app-site-association when downloading the app

看了一下基本都是下载 AASA 的问题,那问题来了,下载 AASA 文件的过程要怎么debug呢?

Debug

看了一下网上的方案,大致上分为两种:

Console

根据这个讨论 what is SWC Agent Database in keychain? 我们可以了解到 AASA 文件的下载是在 swcd 这个进程进行的。所以我们可以打开 Console,用手机重新安装App,选择查看 swcd 的log来进行debug。

我尝试了这种方法,发现问题是出在测试的手机上有代理,导致 AASA 下载失败了。

然而,后来我发现还有一种更神秘的方案可以进行debug。

Sysdiagnose

苹果的工程师在去年曾经介绍了一种 debug universal link 的方式:Twitter。结合 Sysdiagnose ,日志收集流程如下:

  1. 复现问题
  2. 同时按下两个音量键和侧边键1秒左右,如果成功触发 Sysdiagnose,你会感受到一个震动
  3. 等待10分钟
  4. 在手机的log中找到 sysdiagnose_YYYY.MM.DD_HH-MM-SS-XX… 这样匹配你的时间的文件并发到你的设备上
  5. 打开其中的 swcutil_show.txt 你就能看到非常详细的 universal link 信息。

好吧,真的是非常详细了。

总结

学到了一些很tricky的东西。

图片渲染优化

发表于 2020-08-27

图片渲染是iOS app中必不可少的一环,当我们在屏幕上展示一幅图片的时候,一个 workflow 应该是这样的:

  • 拿到图片的 Data buffer
  • decode 得到图片的 Image buffer
  • 将 Image buffer 渲染到屏幕上

这个流程看上去没有问题,但是有一点值得我们注意。假设我们要显示一张 2000 1000 的图片,在decode环节我们decode出来的 image buffer 的大小就会是 2000 1000 4 byte = 7.6MB,这里我们会发现 image buffer 的大小和图片的分辨率是正相关的,但有的时候,图片和最终展示在界面上的尺寸是不同的。比如这张 2000 1000 的图片,假如是展示在一个 200 * 100 的 view 上,那显然有很多像素信息是会被浪费掉的。

如何解决这个问题呢?

Downsampling

我们的思路可能是说建立一个新的 workflow :

  • 拿到图片的 Data buffer
  • 根据显示区域的大小建立一个 thumbnail 区域
  • decode 图片的 Data buffer 到这个 thumbnail 区域,得到一个小一些的 Image buffer
  • 将 Image buffer 渲染到屏幕上

WWDC18-session219 详细介绍了如何去进行这样的 downsampling,同事值得注意的是我们可以将 downsampling 的过程放在后台线程进行,再将结果回调到主线程进行展示,这也很大程度上能缓解 CPU 的压力。

Rasterization

别误会,这里的 rasterization 并不是 CALayer 的概念。我们在图片渲染上头疼的点在于图片像素尺寸和界面上展示的尺寸很可能是不同的。这里其实我们还会想到一种方案来解决:使用矢量图。

矢量图和位图不同,矢量图并不存储图片的像素信息,它是通过数学表达的方式通过点和曲线的连接来构成图形。图形层显示矢量图会进行 rasterization 来将矢量图信息转换成位图的信息并直接渲染到 frame buffer 上。

在 iOS 13 以前,我们使用矢量图的方案是通过在 image assets 添加PDF文件来实现。Xcode 会在编译期来进行 rasterization 将图片转换成位图(当然这样做也失去了矢量图的一些特性)。

在 iOS 13之后,我们可以通过私有库 CoreSVG 进行 SVG 的渲染,在未来,我们也有希望使用 SVG Native 来进行规范化的矢量图形渲染。

Rendering Speed

通过上面的做法,我们基本已经避免了在渲染层面上浪费过多的内存的情况,那么我们如何再进一步去优化渲染速度呢?

从 iOS 10 开始, UICollectionView 就支持了通过 UICollectionViewDataSourcePrefetching 来通过预加载来异步获取数据的方案。我们可以通过预加载来提前在后台线程执行图片的获取和解码,在用户视角上提高渲染的速度。

还有一种方案也是通过提前解码来实现加速渲染的,那就是很多第三方库所使用的预解码技术。

在这之前我们先了解一下 Core Animation 的渲染原理。

用人话来说就是对于 UIImageView 来说,渲染流程是这样的:

  • 拿到 UIImageView 的 layer.contents
  • 从拿到的 CGimage decode 获取 image buffer
  • 向GPU发送 Draw Calls
  • GPU进行渲染然后将结果展示在硬件上

那么我们的优化方向就是如何让 render server 提前拿到 image buffer,这样就能加快渲染速度了。这里我们就要说说 CGImage 这个东西了。

CGImage

我们先看看 CGImage 的初始化方法:

1
2
3
4
5
6
7
8
9
10
11
init?(width: Int, 
height: Int,
bitsPerComponent: Int,
bitsPerPixel: Int,
bytesPerRow: Int,
space: CGColorSpace,
bitmapInfo: CGBitmapInfo,
provider: CGDataProvider,
decode: UnsafePointer<CGFloat>?,
shouldInterpolate: Bool,
intent: CGColorRenderingIntent)

值得注意的是这个 provider,就是通过它来生成渲染所需要的 image buffer。经过研究,发现这里创建出 CGImage 但并没有进行 decode,而是在需要显示渲染的时候通过调用 CGDataProviderCopyData 来触发decode。所以我们这时列一下整个解码流程:

  • 获取 layer.contents
  • 获取 image.cgImage
  • CGImageGetDataProvider 获取data
  • CGDataProviderRetainBytePtr 触发解码
  • CGDataProviderDirectCallbacks 拿到数据
  • ImageIO进行解码拿到 image buffer

了解了整个流程我们就知道了 iOS 系统的方案在生成 CGImage 的时候并没有创建完整的 image buffer ,而是在渲染的时候进行处理。那我们优化的方向就是用空间换时间,如果能在生成 CGImage 生成的时候直接把 image buffer 创建好,那渲染就会快很多了。

主流图片库的做法也很简单,通过CGContextDrawImage 绘制一遍生成出来的 CGImage 就可以了。

总结

这篇文章主要讲述了三种图片渲染上优化的方向,希望可以和对图片渲染有兴趣的朋友多多交流。

Haskell

发表于 2018-10-30

Haskell

Haskell is a lazy, functional programming language created in the late 1980’s by a committee of academics.

  • functional: functions are first-class, centered around evaluating expressions rather than executing instructions.
  • pure: immutable, no side effects, calling the same function with the same arguments results in the same output every time.
  • lazy: call-by-need, expressions are not evaluated until their results are actually needed.Alone with Memoization
  • statically typed: as Swift.Every Haskell expression has a type, and types are all checked at compile-time.
  • abstraction: parametric polymorphism, higher-order functions
  • wholemeal programming:

“Functional languages excel at wholemeal programming, a term coined by Geraint Jones. Wholemeal programming means to think big: work with an entire list, rather than a sequence of elements; develop a solution space, rather than an individual solution; imagine a graph, rather than a single path. The wholemeal approach often offers new insights or provides new perspectives on a given problem. It is nicely complemented by the idea of projective programming: first solve a more general problem, then extract the interesting bits and pieces by transforming the general program into more specialised ones.” — Ralf Hinze

1
2
3
4
5
> int acc = 0;
for ( int i = 0; i < lst.length; i++ ) {
acc = acc + 3 * lst[i];
}
>

to

1
2
> sum (map (3*) lst)
>

Lazy evaluation

Strict evaluation

1
f (release_monkeys(), increment_counter())

If the releasing of monkeys and incrementing of the counter could independently happen, or not, in either order, depending on whether f happens to use their results, it would be extremely confusing. When such “side effects” are allowed, strict evaluation is really what you want.

Side effects and purity

By “side effect” we mean anything that causes evaluation of an expression to interact with something outside itself.The root issue is that such outside interactions are time-sensitive.

  • No global variable
  • No output to screen
  • No reading from a file or the network

WTF……

The solution is IO monad.

Consequences

  • Purity
  • Tricky space usage
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- Standard library function foldl, provided for reference
foldl :: (b -> a -> b) -> b -> [a] -> b
foldl _ z [] = z
foldl f z (x:xs) = foldl f (f z x) xs

foldl (+) 0 [1,2,3]
= foldl (+) (0+1) [2,3]
= foldl (+) ((0+1)+2) [3]
= foldl (+) (((0+1)+2)+3) []
= (((0+1)+2)+3)
= ((1+2)+3)
= (3+3)
= 6

foldl' (+) 0 [1,2,3]
= foldl' (+) (0+1) [2,3]
= foldl' (+) 1 [2,3]
= foldl' (+) (1+2) [3]
= foldl' (+) 3 [3]
= foldl' (+) (3+3) []
= foldl' (+) 6 []
= 6

Consider if there is a very long list, it leads to stack overflow.

  • Short-circuiting operators
1
2
3
(&&) :: Bool -> Bool -> Bool
True && x = x
False && _ = False
  • Infinite data structures
  • Pipelining/wholemeal programming
    • due to laziness, each stage of the pipeline can operate in lockstep, only generating each bit of the result as it is demanded by the next stage in the pipeline.
  • Thunk.
  • Confilct with parallel computing.

Functors

  • map :: (a -> b) -> [a] -> [b]
  • treeMap :: (a -> b) -> Tree a -> Tree b
  • maybeMap :: (a -> b) -> Maybe a -> Maybe b

why not

thingMap :: (a -> b) -> f a -> f b

Since f is type variable, we can make a type class, which is traditionally called Functor:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Functor f where
fmap :: (a -> b) -> f a -> f b

(<$>) :: Functor f => (a -> b) -> f a -> f b
(<$>) = fmap

instance Functor [] where
fmap _ [] = []
fmap f (x:xs) = f x : fmap f xs

instance Functor Maybe where
fmap _ Nothing = Nothing
fmap h (Just a) = Just (h a)

Applicative functors

1
2
3
4
type Name = String

data Employee = Employee { name :: Name
, phone :: String }

So Employee is

Employee :: Name -> String -> Employee

However, sometimes we want this:

(Name -> String -> Employee) -> Maybe Name -> Maybe String -> Maybe Employee

and why not provide this as well:

Name -> String -> Employee) -> [Name] -> [String] -> [Employee]

Seems wec can use fmap to do this job,

1
2
3
4
5
6
7
8
9
class Functor f where
fmap2 :: (a -> b -> c) -> f a -> f b -> f c

fmap2 :: Functor f => (a -> b -> c) -> (f a -> f b -> f c)
fmap2 h fa fb = ???

h :: a -> (b -> c)
fmap h :: fa -> f(b -> c)
fmap h fa :: f(b -> c)

The reason why we cannot implement fmap2 is fmap h fa :: f (b -> c), and we cannot handle f (b -> c) with f b

So it’s time to introduce Applicative

1
2
3
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
1
2
3
4
5
liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
liftA2 h fa fb = (h `fmap` fa) <*> fb

liftA3 :: Applicative f => (a -> b -> c -> d) -> f a -> f b -> f c -> f d
liftA3 h fa fb fc = ((h <$> fa) <*> fb) <*> fc

Law for Applicative:

1
2
> f `fmap` x === pure f <*> x
>

Monads

From functor and applicative, we can see computations with a fixed structure.

However, sometimes we want to be able to decide what to do based on some intermediate results.

1
newtype Parser a = Parser { runParser :: String -> Maybe (a, String) }

Suppose we are trying to parse a file containing a sequence of numbers, like this:

4 78 19 3 44 3 1 7 5 2 3 2

So the example above could be broken up into groups like this:

78 19 3 44 – first group
1 7 5 – second group
3 2 – third group

So what we need is some thing like this:
parseFile :: Parser [[Int]]

Applicative gives us no way to decide what to do next based on previous results: we must decide in advance what parsing operations we are going to run, before we see the results.

1
2
3
4
5
6
7
class Monad m where
return :: a -> m a

(>>=) :: m a -> (a -> m b) -> m b

(>>) :: m a -> m b -> m b
m1 >> m2 = m1 >>= \_ -> m2

m refer to monads and m a refer to mobits. A mobit of type m a represents a computation which results in a value (or several values, or no values) of type a.

A function which will choose the next computation to run based on the result(s) of the first computation.

So all (>>=) really does is put together two mobits to produce a larger one, which first runs one and then the other, returning the result of the second one.

Resource

CIS 194

Junjie Lu

8 日志
GitHub Twitter
© 2022 Junjie Lu
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4