Swift Evolution 提案学习笔记:async/await

背景

在过往的 iOS 开发中,异步执行任务需要用到回调和闭包来实现。基于回调的异步编程有很多弊端,例如:

  • 如果需要连续执行多个异步任务,很容易产生如下的多层嵌套,影响可读性。

    swift 复制代码
    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)
                    }
                }
            }
        }
    }
  • 处理代码抛出的异常 / 错误进行非常麻烦。如下图所示,每一层都需要用 guard 来检查错误,并调用完成回调。

    swift 复制代码
    func processImageData2a(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, error in
                        guard let imageResult = imageResult else {
                            completionBlock(nil, error)
                            return
                        }
                        completionBlock(imageResult)
                    }
                }
            }
        }
    }
  • 假设我们需要根据某些条件改变回调的传参,为了避免大量重复的代码,我们就不得不先定义 swizzle 这个闭包。这会导致代码被「倒转」过来:swizzle 的代码原本是在后面执行,但却先于执行它的代码出现,提高了理解成本。此外,我们还必须非常小心地关注回调里持有了哪些变量。

    swift 复制代码
    func processImageData3(recipient: Person, completionBlock: (_ result: Image) -> Void) {
        let swizzle: (_ contents: Image) -> Void = {
          // ...
        }
        if recipient.hasProfilePicture {
            swizzle(recipient.profilePicture)
        } else {
            decodeImage { image in
                swizzle(image)
            }
        }
    }
  • 容易踩坑:忘记调用完成回调,或者是忘记 return

    swift 复制代码
    func processImageData4a(completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
        loadWebResource("dataprofile.txt") { dataResource, error in
            guard let dataResource = dataResource else {
                return // <- 忘了调用 completionBlock
            }
            loadWebResource("imagedata.dat") { imageResource, error in
                guard let imageResource = imageResource else {
                    return // <- 忘了调用 completionBlock
                }
                ...
            }
        }
    }
    
    func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
        if recipient.hasProfilePicture {
            if let image = recipient.profilePicture {
                completionBlock(image) // <- 忘了 return
            }
        }
        ...
    }

考虑到以上原因,回调的异步编程太容易出错。这导致许多程序员在编程时潜意识地不愿意编写异步代码,错失了很多性能优化的机会。

async/await

基于协程(coroutine)模型,Swift 的 async/await 机制让我们能像编写同步代码一样编写异步代码。这不仅能极大的提高代码可读性和 debug 的便利性,还能为未来推出的 Swift 并发特性提供基石。前文提到的、基于回调的异步代码用 async/await 重写之后会变得简洁明了:

swift 复制代码
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

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

可以这么理解 Swift 的 async 异步函数:

  • 异步函数本质上依然是普通的函数,但它们还被赋予了「暂时让出当前线程」的能力;相比之下,同步函数不具有此项能力,它们只懂得阻塞当前线程,直到其中的所有代码执行完毕。
  • 由于同步函数并不知道如何「让出」当前线程,一个同步函数不能够直接调用另一个异步函数。
  • 异步函数可以依然可以调用阻塞当前线程的其它同步函数。当一个异步函数执行到它自身或是它所调用的另一个异步函数的挂起点时,这条链路上的所有函数都会同时「挂起」,并让出当前线程给其它任务使用。
  • 当挂起的异步函数恢复执行时,它并不一定会回到被挂起前的线程。在编写 Swift 的并发逻辑时,我们应该避免考虑线程 ------ 这是底层的实现细节。在 Swift 的并发模型中,所有的方法都在某一个 actor 中执行。Swift 只保证异步函数在恢复后会回到原本的 actor 中执行。
  • Actor 的细节在本文中暂不讨论,它的作用类似于 GCD 中的串行队列。不同的是,actor 并不保证先进先出。

挂起点 Suspension points

在一个异步函数的执行过程中,其必须暂停执行并「让出」当前线程的时刻被称之为「挂起点」。在 Swift 中,挂起点总是显式、明确的 ------ 一个函数中所有可能的挂起点总会被明确地用await 标注出来。(注意,标注了await 的函数调用反过来并不一定就是一个挂起点 ------ 当前函数在编译时并不知道其所调用的函数是否真的会「挂起」。)

为何对「挂起点」的标注如此重要?因为挂起操作会破坏代码执行的「原子性」------ 从函数被挂起到恢复之间,当前的 actor 上可能已经运行了其它代码。举个例子:假设同一笔银行交易 A 包含了「存款 + 取款」两步,但在「存款」之后 A 交易的处理被暂时挂起了,那么不怀好意的人就有机会在挂起期间再度发起一笔「取款」B 交易。在 A 交易执行被恢复后,A 交易中的取款被继续执行 ------ 这导致一笔存款被取出了两次。

设计细节

以下是 async/await 的一些语法设计细节。

异步方法

swift 复制代码
// 将一个函数标注为异步
func collect(function: () async -> Int) { ... }

// 构造方法也可以是异步的
class Teacher {
  init(hiringFrom: College) async throws {
    ...
  }
  
  private func raiseHand() async -> Bool {
    ...
  }
}

在 async/await 的提案中,包括deinit 和属性的访问器(getter、setter、下标)在内的特殊方法不能是异步的。

在后来的SE-0310提案中 Swift 增加了对异步只读属性的支持,但包含 setter 的属性仍然不能是异步的。async/await 原提案中对此的解释是:由于一个可写属性可能被作为inout参数传递,该属性中包含的子属性可能在别处被访问或修改,而这意味着该可写属性的 setter 必须是同步且不会抛出任何异常的。SE-0310提案中则进一步提到,考虑到inout_modifydidSetwillSet、属性包装器、下标等等特性的存在,要支持异步可写属性工程量实在是十分巨大,所以暂不考虑支持。

对于没有调用父类构造方法的子类异步构造方法,编译器仅会在父类包含一个无参数、同步、被指定的构造方法时,隐式添加super.init()。这是因为如果父类构造器也是异步的,那么对父类构造方法的调用构成了一个「挂起点」,而这必须被显式地标注出来。

异步方法类型转换

可以将同步方法隐式转换为异步方法,但反过来则不行:

swift 复制代码
struct FunctionTypes {
  var syncNonThrowing: () -> Void
  var syncThrowing: () throws -> Void
  var asyncNonThrowing: () async -> Void
  var asyncThrowing: () async throws -> Void
  
  mutating func demonstrateConversions() {
    // OK    
    asyncNonThrowing = syncNonThrowing
    asyncThrowing = syncThrowing
    syncThrowing = syncNonThrowing
    asyncThrowing = asyncNonThrowing
    
    // Error
    syncNonThrowing = asyncNonThrowing 
    syncThrowing = asyncThrowing      
    syncNonThrowing = syncThrowing 
    asyncNonThrowing = syncThrowing
  }
}

Await 语句

所有可能的「挂起点」仅能出现在异步上下文中(例如在一个异步方法中),且必须用await进行标注:

swift 复制代码
// func redirectURL(for url: URL) async -> URL { ... }
// func dataTask(with: URL) async throws -> (Data, URLResponse) { ... }

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

一个await可以包含多个潜在的挂起点:

swift 复制代码
let (data, response) = try await session.dataTask(with: server.redirectURL(for: url))

await本身并不具备任何功能,它仅仅起到标注潜在 挂起点的作用。一个被标注了await的对象也可能并不包含任何挂起点:

swift 复制代码
let x = await synchronous() // 警告:标注为 await 但并没有调用任何异步方法

await不能用在defer的闭包中。

闭包

闭包也可以是异步的:

swift 复制代码
{ () async -> Int in
  print("here")
  return await getInt()
}

包含await的匿名闭包会被隐式标注为异步:

swift 复制代码
let closure = { await getInt() } // 隐式异步

let closure2 = { () -> Int in     // 隐式异步
  print("here")
  return await getInt()
}

一个嵌套闭包被隐式标注为异步,并不会导致其上下级闭包被一同标注为异步:

swift 复制代码
// func getInt() async -> Int { ... }

let closure5 = { () -> Int in       // 非异步
  let closure6 = { () -> Int in     // 隐式异步
    if randomBool() {
      print("there")
      return await getInt()
    } else {
      let closure7 = { () -> Int in 7 }  // 非异步
      return 0
    }
  }
  
  print("here")
  return 5
}

过载函数时的冲突解决策略

现状

以下两个函数虽然表面上具有不同的签名,但调用时的代码却可能出现重叠:

swift 复制代码
func doSomething(completionHandler: ((String) -> Void)? = nil) { ... }
func doSomething() async -> String { ... }

doSomething() // 调用哪个?

按照现有的 Swift 过载函数策略,参数较少的那个函数(也就是第二个)会被调用。这导致开发者如果新增函数 2,所有原先的doSomething()代码都会立刻报错 ------ 它们都会被改为调用函数 2,但函数 2 作为一个async异步方法,不能够在同步方法里被执行。这会给开发者造成巨大的迁移成本,因为他们将不得不在「改名方法 1,所有原来的的doSomething() 代码立刻报错」和「给所有的新增async方法想一个新的名字」之间二选一。

继续考虑如下情况:

swift 复制代码
// 现有的同步接口
func doSomethingElse() { ... }
// 新的异步接口
func doSomethingElse() async { ... }

// 错误: 重复定义 `doSomethingElse()`.

由于现有的 Swift 过载规则不允许两个方法签名完全一致、仅在「最终效果」上产生区别,这样的方法定义是不合法的。这也导致了迁移成本,因为开发者没办法在保留现有同步接口的情况下,新增同名的异步接口。

改进的过载策略

随着 async/await 一同推出的新过载策略会根据上下文来判断要执行哪个方法:

  • 在一个同步的上下文中,Swift 会优先选择两个重名方法中的同步版本。这是因为在同步的上下文中,调用异步版本是不合法的。
  • 在一个异步的上下文中,Swift 会优先选择异步版本,因为异步代码应该尽可能避免调用会导致阻塞的同步代码。此时,await关键字自然也是必不可少的。

明白了这些新的策略,我们再来看看上一节提到的 doSomething 方法:

swift 复制代码
func f() async {
  // 因为处于异步上下文中,异步版本的 doSomething 被选择了:
  await doSomething()
  // 错误:因为选择了异步的 doSomething,必须添加 await
  doSomething()
}

func f() async {
  let f2 = {
    // 处于同步上下文中,同步版本的 doSomething 被选择了:
    doSomething()
  }
  f2()
}

自动闭包

一个方法自身必须是async,它所接受的自动闭包参数才能够是async的。

swift 复制代码
// 错误:方法不是 async,参数中却有 async 自动闭包
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { } 

为什么会有这种限制?考虑以下代码:

swift 复制代码
// func getIntSlowly() async -> Int { ... }

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

这种写法有几个问题:

  1. await关键字出现在了传参里,这会导致开发者误认为挂起点是在computeArgumentLater(_:)被执行之前,但实际上并非如此 ------ getIntSlowly()是在computeArgumentLater(_:)内部被调用的。
  2. 基于第 1 点,由于await关键字的特殊位置,closure会被认为是异步的,但实际上closure里全都是同步代码。
  3. 似乎应该写成await computeArgumentLater(getIntSlowly()),但考虑到参数是个自动闭包,这样又破坏了语义的完整性。

通过把async自动闭包限定为仅可在异步上下文中使用,我们即可避免这些问题。

协议

async的协议方法要求可以被同步或异步的方法所实现,但协议中所规定的同步方法不得被async实现所满足。这和throw的协议要求类似。

swift 复制代码
protocol Asynchronous {
  func f() async
}

protocol Synchronous {
  func g()
}

struct S1: Asynchronous {
  func f() async { } // OK
}

struct S2: Asynchronous {
  func f() { } // OK,同步方法可满足协议的异步方法要求
}

struct S3: Synchronous {
  func g() { } // OK
}

struct S4: Synchronous {
  func g() async { } // 错误:异步方法无法满足协议的同步方法要求
}
相关推荐
iFlyCai12 小时前
Xcode 16 pod init失败的解决方案
ios·xcode·swift
Hamm21 小时前
先别急着喷,没好用的iOS-Ollama客户端那就自己写个然后开源吧
人工智能·llm·swift
hxx2212 天前
iOS swift开发--- 加载PDF文件并显示内容
ios·pdf·swift
今天也想MK代码3 天前
基于ModelScope打造本地AI模型加速下载方案
ai·语言模型·swift·model·language model
袁代码3 天前
Swift 开发教程系列 - 第11章:内存管理和 ARC(Automatic Reference Counting)
开发语言·ios·swift·ios开发
袁代码4 天前
Swift 开发教程系列 - 第8章:协议与扩展
开发语言·ios·swift·ios开发
袁代码4 天前
Swift 开发教程系列 - 第9章:错误处理
开发语言·ios·swift·ios开发
iFlyCai4 天前
Swift中的Combine
开发语言·ios·swift·combine·swift combine
一丝晨光4 天前
Objective-C 1.0和2.0有什么区别?
java·开发语言·macos·c#·objective-c·swift·apple
新中地GIS开发老师5 天前
【GIS开发小课堂】高德地图+Three.js实现飞线、运动边界和炫酷标牌
开发语言·javascript·arcgis·前端框架·swift