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中,升级了只读属性,以单独或一起支持async
和throws
关键字,使它们更灵活。
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
属性同时是async
和throws
,读取时必须使用try await
:
func printHighScores() async throws {
let file = BundleFile(filename: "highscores")
try await print(file.contents)
}
注意点:
- 异步属性必须是只读的,可写属性不能声明为异步属性;
- 异步属性需要有一个明确的
getter
,async
关键字位于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
中的处理依然是串行的:对loadFromDatabase
的await
将使这个异步函数在此暂停,直到实际操作结束,接下来才会执行loadSignature
:
我们当然会希望这两个操作可以同时进行,同时,只有当两者都准备好后,才能调用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
语句时再进行求值。在上面的例子中,loadFromDatabase
和loadSignature
将被并发执行。
除了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
类重写为SafeCollector
actor,如下所示:
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 并发各个部分的设计意图,从而让我们手中的工具可以被运用在正确的地方。