【译】SE-0296 Async/await

原文链接:SE-0296 async/await

简介

现代 Swift 开发涉及大量使用闭包和回调的异步编程,但这些 API 很难使用。当使用了许多异步操作,需要错误处理,或者异步调用之间的控制流变得复杂时,这就变得特别麻烦。这个提案描述了一种语言扩展,使之更自然,更不容易出错。

这份设计将 coroutine模型引入 Swift。函数可以选择成为 async,允许程序员使用正常的控制流机制来编写涉及异步操作的复杂逻辑。编译器负责将一个异步函数翻译成一套适当的闭包和状态机。

这个提案定义了异步函数的语义。然而,它并没有提供并发性:这在另一个引入结构化并发的提案里讨论,该提案将异步函数与并发执行的任务联系起来,并提供用于创建、查询和取消任务的 API。

Swift-evolution thread: Pitch #1, Pitch #2

动机:闭包不是最理想的解决方案

使用显式回调的异步编程有很多问题,我们将在下面探讨这些问题。我们建议通过在语言中引入 async 函数来解决这些问题。async 函数允许将异步代码写成线性代码。它们还允许直接推导代码的执行模式,使回调的运行效率大大提高。

问题 1:回调地狱

想要把简单的异步操作串联起来往往需要嵌套多层闭包。下面举一个例子:

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)
}

这种“回调地狱”使得我们很难读取和跟踪代码运行的位置。此外,不得不使用闭包嵌套会导致许多副作用,我们将在接下来讨论。

问题 2:错误处理

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

// (2a) Using a `guard` statement for each callback:
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)
                }
            }
        }
    }
}

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

Result 加入标准库主要是为了改善 Swift API 的错误处理,而异步 API 是 Result 提案想要优化的其中一部分:

// (2b) Using a `do-catch` statement for each callback:
func processImageData2b(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        do {
            let dataResource = try dataResourceResult.get()
            loadWebResource("imagedata.dat") { imageResourceResult in
                do {
                    let imageResource = try imageResourceResult.get()
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        do {
                            let imageTmp = try imageTmpResult.get()
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        } catch {
                            completionBlock(.failure(error))
                        }
                    }
                } catch {
                    completionBlock(.failure(error))
                }
            }
        } catch {
            completionBlock(.failure(error))
        }
    }
}

processImageData2b { result in
    do {
        let image = try result.get()
        display(image)
    } catch {
        display("No image today", error)
    }
}
// (2c) Using a `switch` statement for each callback:
func processImageData2c(completionBlock: (Result<Image, Error>) -> Void) {
    loadWebResource("dataprofile.txt") { dataResourceResult in
        switch dataResourceResult {
        case .success(let dataResource):
            loadWebResource("imagedata.dat") { imageResourceResult in
                switch imageResourceResult {
                case .success(let imageResource):
                    decodeImage(dataResource, imageResource) { imageTmpResult in
                        switch imageTmpResult {
                        case .success(let imageTmp):
                            dewarpAndCleanupImage(imageTmp) { imageResult in
                                completionBlock(imageResult)
                            }
                        case .failure(let error):
                            completionBlock(.failure(error))
                        }
                    }
                case .failure(let error):
                    completionBlock(.failure(error))
                }
            }
        case .failure(let error):
            completionBlock(.failure(error))
        }
    }
}

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

使用 Result 可以简化错误处理,但闭包嵌套的问题依然存在。

问题3:选择性执行很难并且容易出错

选择性执行一个异步函数是一件非常痛苦的事情。例如,假设我们需要在获取到图像后进行 swizzle,但是,我们有时候不得不在 swizzle 之前触发异步调用去解码图片。也许这个函数最好的结构是使用一个 helper “continuation” 闭包,在回调的闭包里捕获它,就像这样:

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)
        }
    }
}

这种模式颠倒了自上而下的自然函数结构:函数下半部才会执行的代码必须在上半部执行之前出现。为了重构这整个函数,我们必须小心地思考闭包里捕获的东西,因为这个闭包会在回调里被捕获。这个问题会随着选择性执行的异步函数越来越多变得越来越严重,最终会成为一个反转的回调地狱。

问题4:很容易造成很多错误

异步操作提前退出时很容易忘记调用回调。忘记这件事的时候,问题就会很难 debug:

func processImageData4a(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
            }
            ...
        }
    }
}

当你记得调用闭包时,你还有可能忘记在这之后 return:

func processImageData4b(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 语法会在一定程度上防止你忘记 return 的事情,但它不能解决所有问题。

问题 5:因为回调很难用,很多 API 会设计成同步阻塞的形式

这很难量化,但作者认为,定义和使用异步 API(使用完成处理程序)的尴尬导致许多 API 被定义为明显的同步行为,即使它们会产生阻塞。这可能会导致 UI 应用程序的性能和响应性问题,例如旋转的光标。它还可能导致定义的 API 在异步对水平拓展至关重要时无法使用,例如在服务器上。

解决方案:async/await

异步函数 - 通常被称为 async/await - 允许将异步代码当作线性同步代码来编写。这就立马解决了上述的许多问题,因为它允许程序员充分利用与同步代码相同的语言结构。使用 async/await 还自然地保留了代码的语义结构,提供了必要的信息让语言可以做至少三个方向的改进。(1) 为异步代码提供更好的性能;(2) 更好的工具,在调试、剖析和探索代码时提供更一致的体验;(3) 为未来的并发特性(如任务优先级和取消)奠定基础。上一节的例子可以展示 async/await 如何大幅简化异步代码:

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
}

许多关于 async/await 的描述讨论了一个通用的实现机制:一个将函数拆分为多个组件的编译器 pass。这对于底层抽象来说很重要,为了理解机器如何执行,但在更高的层次,我们更鼓励你忽略它。相反,把异步函数看成一个普通函数,只是具有放弃其线程的特殊权利。异步函数通常不会直接使用这个权力;相反,它们产生调用,有时这些调用会要求它们放弃自己的线程,等待某件事情的发生。当这个事情完成后,函数会再次恢复执行。

与同步函数对比非常明显。一个同步函数可以进行调用;当它调用时,函数立即等待调用完成。一旦调用完成,控制权就会返回到函数,并从它离开的地方开始。异步函数也是如此:它可以像往常一样进行调用;当它进行调用时,它(通常)立即等待调用完成。一旦调用完成,控制权就会回到函数,它就会回到原来的位置。唯一的区别是,同步函数可以充分利用(部分)它的线程和它的栈,而异步函数则可以完全放弃这个栈,使用自己的、独立的存储。这种赋予异步函数的额外权力有一定的实现成本,但我们可以通过围绕它进行整体设计来降低不少成本。

因为异步函数必须能够放弃自己的线程,而同步函数不知道如何放弃一个线程,所以一个同步函数通常不能调用一个异步函数:异步函数只能放弃它所占用的那部分线程,如果它试图放弃,它的同步调用者就会把它当作一个返回,并试图拾起原来的位置,只是没有返回值。在一般情况下,唯一的办法就是阻塞整个线程,直到异步函数被恢复并完成,但这将完全违背异步函数的目的,同时也会产生恶劣的系统影响。

而异步函数则可以调用同步函数或异步函数。当然,当它在调用同步函数时,它不能放弃自己的线程。事实上,异步函数从来不会自发地放弃自己的线程,只有当它达到所谓的 suspension point 时才会放弃自己的线程。suspension point 可以直接发生在一个函数中,也可以发生在该函数调用的另一个异步函数中,但无论哪种情况,该函数及其所有异步调用者都会同时放弃该线程。(实际上,异步函数在异步调用过程中都会被编译成不依赖于线程,因此只有最内部的函数需要做任何额外的工作)。

当控制返回到一个异步函数时,它就会从原来的地方拾起。这并不意味着它一定会在和之前完全相同的线程上运行,因为语言并不能保证在 suspension 后会这样。在这种设计中,线程主要是一种实现机制,而不是并发的预设接口的一部分。然而,许多异步函数并不仅仅是异步的:它们还与特定的 actor 相关联(这是一个单独提案的主题),而且它们总是应该作为该 actor 的一部分运行。Swift 确实保证这类函数事实上会返回到它们的 actor 中去完成执行。相应地,直接使用线程进行状态隔离的库–例如,通过创建自己的线程并将任务按顺序调度到线程上–一般应该将这些线程建模为 Swift 里的 actor,以便这些基本的语言保证能够正常运行。

Suspension point

Suspension point 是异步函数执行过程中不得不放弃其线程的一个点。Suspension point 总是与函数中的一些确定性的、语法上显式的事件相关联;从函数的角度来看,它们从来都不是隐藏的或异步的。Suspension point 的主要形式是调用一个与不同执行上下文相关联的异步函数。

重要的是,suspension point 只与显式操作相关联。事实上,这一点非常重要,以至于该提案要求将可能 suspend 的调用用 await 表达式修饰。这些调用被称为潜在的 suspension point,因为静态分析时并不知道它们是否真的会 suspend:这既取决于调用方不可见的代码(例如,被调用者可能依赖于异步 I/O),也取决于动态条件(例如,该异步 I/O是否需要等待才能完成)。

对潜在的 suspension point 的 await 要求延续 Swift 的之前的做法,要求 try 表达式涵盖对可能抛出错误的函数的调用。标记潜在的 suspension point 特别重要,因为 suspension 会中断原子性。例如,如果一个异步函数在一个给定的上下文中运行,而这个上下文是由一个串行队列保护的,那么达到一个suspension point 就意味着其他代码可以在同一个串行队列上插入其它代码。一个经典但有点老套的例子,这种原子性很重要,那就是对银行账户进行建模:如果一笔存款被记入一个账户,但在处理匹配的取款之前,操作 suspend,就会产生一个窗口期,在这个窗口期中,这些资金可以被重复使用。对于很多 Swift 程序员来说,一个更贴切的例子是 UI 线程:suspension point 是 UI 可以展示给用户的点,所以程序如果构建了部分 UI,然后 suspend,就有可能呈现出一个闪烁的、构建了一半的 UI。(请注意,在使用显式回调的代码中,suspension point也是显式调用的:suspend 会发生在外部函数返回和回调开始运行的点之间)。要求对所有潜在的 suspension point 进行标记,可以让程序员安全地假设没有潜在 suspension point 的地方将表现为原子模式,以及更容易识别有问题的非原子模式。

因为潜在的 suspension point 只能出现在异步函数中明确标记的位置,所以长时间的计算仍然会阻塞线程。这种情况可能发生在调用一个只是做了很多工作的同步函数时,或者遇到直接写在异步函数中的特别密集的计算循环时。无论是哪种情况,在这些计算运行的时候,线程都不能在中间插入其它运算逻辑,这通常是正确性的正确选择,但也可能成为一个扩展性问题。需要进行高强度计算的异步程序一般应该在一个单独的上下文中运行。当这不可行时,会有第三方库的设施人为地 suspend 并允许其他操作插入执行。

异步函数应该避免调用实际上可以阻塞线程的函数,特别是当它们可以阻塞线程,等待那些不能保证当前正在运行的工作时。例如,获取一个互斥锁时只能阻塞,直到某个当前正在运行的线程放弃互斥锁;这有时是可以接受的,但必须谨慎使用,以避免引入死锁或人为的可扩展性问题。相反,在一个条件变量上的等待可以阻塞,直到一些任意的其他工作得到安排,给变量发出信号;这种模式与建议强烈相悖。

设计细节

异步函数

函数类型可以明确标记为 async,表示该函数是异步的:

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

一个函数或初始化器声明也可以显式声明为 async

class Teacher {
  init(hiringFrom: College) async throws {
    ...
  }
  
  private func raiseHand() async -> Bool {
    ...
  }
}

理由async 跟在参数列表后面,因为它既是函数类型的一部分,也是函数声明的一部分。这遵循了 throws 的先例。

声明为 async 的函数或构造器的引用,类型是 async 函数类型。如果该引用是对实例方法的 “curried” 静态引用,则按照此类引用的通常规则,”内部”函数类型为async

deinit 和存储访问器这样的特殊函数(例如,属性的 getter/setter 和 subscript)不能为 async

理由:只有 getter 的属性和 subscript 有可能成为 async 的。然而,async setter 的属性和下标意味着能够将引用作为 inout 传递,并深入到该属性本身的属性,这取决于 setter 实际上是否为一个”瞬时”(同步、non-throwing)操作。比起只允许 async get-only 的属性和 subscript,禁止 async 属性是更简单的规则。

译者注:让 getter 和 subscript 能够标记为 async 的功能,已经包含在 SE-0310 Effectful Read-only Properties 提案里,并且已经通过了 review,在 Swift 5.5 完成了实现。

如果一个函数既是 async 又是 throws 的,那么在类型声明中,async 关键字就必须放在 throws 前面。这个规则同样适用于 asyncrethrows

理由:这个顺序限制是随性的,但并不会带来害处,而且以后也不需要去争辩该用哪种代码风格。

一个有父类但没有调用父类构造器的 async 构造器,只有当超类有一个零参数的、同步的、指定的初始化器时,才会得到对super.init()的隐式调用。

理由:如果超类初始化器是 async,对异步构造器的调用就是一个潜在的 suspension point,因此,调用(和所需的 await)必须在代码里可见。

异步函数类型

异步函数的类型明显与同步函数的类型不同。然而,从同步函数的类型到其对应的异步函数的类型会有一个自动隐式转换。这类似于从一个 non-throwing 函数到其 throwing 对应函数的隐式转换,也可以与异步函数转换组合到一起。例如:

struct FunctionTypes {
  var syncNonThrowing: () -> Void
  var syncThrowing: () throws -> Void
  var asyncNonThrowing: () async -> Void
  var asyncThrowing: () async throws -> Void
  
  mutating func demonstrateConversions() {
    // Okay to add 'async' and/or 'throws'    
    asyncNonThrowing = syncNonThrowing
    asyncThrowing = syncThrowing
    syncThrowing = syncNonThrowing
    asyncThrowing = asyncNonThrowing
    
    // Error to remove 'async' or 'throws'
    syncNonThrowing = asyncNonThrowing // error
    syncThrowing = asyncThrowing       // error
    syncNonThrowing = syncThrowing     // error
    asyncNonThrowing = syncThrowing    // error
  }
}

await 表达式

async 函数的调用(包括对 async 函数的直接调用)会引入一个潜在的 suspension point。任何潜在的 suspension point 必须发生在异步上下文中(例如,一个 async 函数)。此外,它必须发生在 await 表达式的对象内。

请看下面的例子:

// 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)

在这个代码示例中,在调用 redirectURL(for:)dataTask(with:) 期间可能会发生任务suspension,因为它们是异步函数。因此,这两个调用表达式必须包含在某个 await 表达式中,因为每个调用都包含一个潜在的 suspension point。一个 await 表达式可以包含一个以上的潜在 suspension point。例如,我们可以使用一个 await 来覆盖上面例子中的两个潜在的 suspension point,将其改写为:

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

await 没有额外的语义;与 try 一样,它只是标志着一个异步调用正在进行。await 表达式的类型是其操作对象的类型,其结果是其操作对象的结果。一个 await 操作数也可能没有潜在的 suspension point,这将导致 Swift 编译器发出警告,这是跟随 try 表达式的先例:

let x = await synchronous() // warning: no calls to 'async' functions occur within 'await' expression

理由:重要的是,异步调用在函数中应该具有明确的标识,因为它们可能会引入 suspension point,从而打破操作的原子性。suspension point 可能是调用所固有的(因为异步调用必须在不同的 executor 上执行),或者仅仅是被调用者实现的一部分,但无论哪种情况,它在语义上都是很重要的,工程师都需要意识到它。await 表达式也是异步代码的一个标识,它与闭包中的推导交互;更多信息请参见 Closure 一节。

潜在的 suspension point 不可以发生在非 async 函数的 @autoclosure 里。

潜在的 suspension point 不可以发生在 defer 里。

如果 awaittry 的变体(包括 try!try?)被应用于同一个子表达式,await 必须跟在 try/try!/try? 后面。

let (data, response) = await try session.dataTask(with: server.redirectURL(for: url)) // error: must be `try await`
let (data, response) = await (try session.dataTask(with: server.redirectURL(for: url))) // okay due to parentheses

理由:这一限制也是任意的,但延续了对 async throws 顺序的限制,以防止代码风格上的争论。

Closures

一个闭包也可以是 async 类型的。这类闭包可以明确标记为 async ,就像这样:

{ () async -> Int in
  print("here")
  return await getInt()
}

一个匿名闭包如果包含一个 await 表达式,就会被推导为 async 类型:

let closure = { await getInt() } // implicitly async

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

请注意,闭包的 async 推导不会影响到它外层或嵌套的函数或闭包,因为这些上下文是独立的,异步或同步的。例如,在这种情况下,只有 closure6 被推导为 async

// 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 API 一般通过回调接口支持异步函数,例如:

func doSomething(completionHandler: ((String) -> Void)? = nil) { ... }

许多这样的 API 很可能会增加一个 async 形式的函数:

func doSomething() async -> String { ... }

这两个函数的名称和签名是不同的,尽管它们的名字一样。然而,它们中的任何一个函数都可能会在没有参数的情况下被调用(由于默认的代码补齐),这将给现有的代码带来一个问题:

doSomething() // problem: can call either, unmodified Swift rules prefer the `async` version

Swift 的重载规则更倾向于调用缺省参数较少的函数,所以增加 async 函数会破坏现有的代码,这些代码调用了原来的 doSomething(completionHandler:),只是没传入 completionHandler。这将得到一个类似这样的编译错误:

error: `async` function cannot be called from non-asynchronous context

这会给代码演进带来了问题,因为现有异步库的作者要么硬性打破兼容性(例如,一个新的大版本),要么需要为所有新的 async 版本取不同的名字。后者很可能会演变类似于 C# 普遍的 Async 后缀

相反,我们提出了一个重载解决规则,根据调用的上下文选择适当的函数。给定一个调用,在同步的上下文中,重载解析会优先选择非 async 函数(因为这种上下文不能包含对 async 函数的调用)。此外,在异步上下文中,重载解析会优先选择 async 函数(因为这种上下文应该避免从异步模型中跳出而进入阻塞的 API)。当重载解析选择 async 函数时,该调用仍然需要加上 await

需要注意的是,我们延续了 throws 的设计,不允许只有 async 不同的重载:

func doSomething() -> String { /* ... */ }       // synchronous, blocking
func doSomething() async -> String { /* ... */ } // asynchronous

// error: redeclaration of function `doSomething()`.

autoclosure

除非函数本身是 async 函数类型,否则函数不能接受 async 函数类型的 @autoclosure 参数。例如,下面的声明就是不规范的:

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

这种限制的存在有几个原因。请看下面的例子:

// func getIntSlowly() async -> Int { ... }

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

乍一看,这里的 await 表达式在向工程师暗示,调用 computeArgumentLater(_:) 之前有一个潜在的 suspension point,但实际情况并非如此:潜在的 suspension point 是在 computeArgumentLater(_:) 主体传递和使用的闭包中。这导致了一些问题,首先 await 出现在调用之前意味着 closure 会被推导为具有 async 函数类型,这也是不正确的:closure 中的所有代码都是同步的。其次,由于 await 的操作对象只需要在其中某个地方包含一个潜在的 suspension point,因此调用的等效代码应该是:

await computeArgumentLater(getIntSlowly())

但是,由于参数是 autoclosure 的,这种重写不再保留之前的语义。因此,对 async autoclosure 参数的限制可以避免这些问题,只要确保 async autoclosure 参数只能在异步上下文中使用。

Protocol conformance

协议要求可声明为 async。这种要求可由 async 或同步函数来满足。但是,同步函数的要求不能由async 函数来满足。例如:

protocol Asynchronous {
  func f() async
}

protocol Synchronous {
  func g()
}

struct S1: Asynchronous {
  func f() async { } // okay, exactly matches
}

struct S2: Asynchronous {
  func f() { } // okay, synchronous function satisfying async requirement
}

struct S3: Synchronous {
  func g() { } // okay, exactly matches
}

struct S4: Synchronous {
  func g() async { } // error: cannot satisfy synchronous requirement with an async function
}

这种行为遵循了异步函数的子类型/隐式转换规则,正如 throws 以前的行为。

代码兼容性

这个提案总体上是补充性的:现有代码不会使用任何新的功能(例如,不会创建 async 函数或闭包),不会受到影响。但是,它引入了两个新的上下文关键字 asyncawait

async 在语法中的位置(函数声明和函数类型)使我们能够在不破坏源码兼容性的情况下将 async 作为上下文关键字来处理。在格式良好的代码中,用户定义的 async 不能出现在这些语法位置上。

await 上下文关键字比较麻烦,因为它发生在一个表达式中。例如,今天可以在 Swift 中定义一个函数 await

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

let result = await(1, 2)

目前这段代码格式没有任何问题,它是对 await 函数的调用。但在这个提案里,这段代码变成了一个带有子表达式 (1, 2)await 表达式。这对于现有的 Swift 程序来说,将表现为编译时错误,因为 await 只能在异步上下文中使用,而现有的 Swift 程序都没有这样的上下文。这样的函数似乎并不常见,所以我们认为这是一个可以接受的 source break,作为引入 async/await 的一部分。

对于 ABI 的影响

异步函数和函数类型对 ABI 是补充性的,所以对 ABI 的稳定性没有影响,因为现有的(同步)函数和函数类型是不变的。

对于 API 的影响

async 函数的 ABI 与同步函数的 ABI 完全不同(例如,它们有不兼容的调用惯例),所以从函数或类型中添加或删除 async 并不是一个兼容的修改。

未来方向

reasync

Swift 的 rethrows 是一种机制,用于表明只有当传递给它的参数之一是一个本身就会 throws 的函数时,某个函数才会抛出。例如,Sequence.map 就使用了 rethrows,因为只有当 transform 本身是 throws 时,map 操作才会 throws

extension Sequence {
  func map<Transformed>(transform: (Element) throws -> Transformed) rethrows -> [Transformed] {
    var result = [Transformed]()
    var iterator = self.makeIterator()
    while let element = iterator.next() {
      result.append(try transform(element))   // note: this is the only `try`!
    }
    return result
  }
}

这是实际代码中 map 的使用:

_ = [1, 2, 3].map { String($0) }  // okay: map does not throw because the closure does not throw
_ = try ["1", "2", "3"].map { (string: String) -> Int in
  guard let result = Int(string) else { throw IntParseError(string) }
  return result
} // okay: map can throw because the closure can throw

同样的概念可以应用于 async 函数。例如,我们可以想象当 map 传入的闭包是异步的函数时,map 也将成为异步函数:

extension Sequence {
  func map<Transformed>(transform: (Element) async throws -> Transformed) reasync rethrows -> [Transformed] {
    var result = [Transformed]()
    var iterator = self.makeIterator()
    while let element = iterator.next() {
      result.append(try await transform(element))   // note: this is the only `try` and only `await`!
    }
    return result
  }
}

理论上,这是没问题的:当提供一个 async 函数时,map 将被视为 async(你需要 await 结果),而提供一个非 async 函数时,map 将被视为同步的(不需要 await)。

在实践中,这里会有几个问题:

  • 对于 Sequence 来说这可能不是一个很好的 map 实现。更有可能的是,我们想要一个并发的实现,(比如)并发处理多个元素。
  • throws 函数的 ABI 被有意设计为使 rethrows 函数可以作为一个非抛出函数,因此一个 ABI 入口点就足以满足 throws 和 non-throws 的调用。而 async 函数则不同,它的 ABI 完全不同,其效率必然低于同步函数的 ABI。

对于像 Sequence.map 这种可能并发的东西,reasync 可能是错误的工具:为 async 闭包重载以提供一个单独的(并发)实现可能是更好的答案。因此,reasync 可能没有 rethrows 那么普适。

毋庸置疑,reasync 有它的用途,比如 Optional?? 操作符,async 的实现可以很好地降级为同步实现:

func ??<T>(
    _ optValue: T?, _ defaultValue: @autoclosure () async throws -> T
) reasync rethrows -> T {
  if let value = optValue {
    return value
  }

  return try await defaultValue()
}

对于这种情况,可以通过发出两个入口点来解决上文所述的 ABI 问题:一个是当参数是 async 时,另一个是当参数不是时。然而,由于实现方式非常复杂,作者还没有准备好采用这种设计。

替代方案

await 隐含 try

许多异步 API 都涉及文件 I/O、网络请求或其它可能失败的操作,因此会同时出现 asyncthrows。在调用方,这意味着 try await 将被重复多次。为了减少模板,可以让 await 隐含 try,所以下面两行将是等价的:

let dataResource  = await loadWebResource("dataprofile.txt")
let dataResource  = try await loadWebResource("dataprofile.txt")

我们选择不让 await 隐含 try,因为它们表达的是不同的含义。await 是关于一个潜在的 suspension point,即在你进行调用和它返回之间可能会有其他代码执行,而 try 则是关于 block 外的控制流。

使 await 隐含 try 的另一个原因与任务取消有关。如果任务取消被建模为一个抛出的错误,并且每个潜在的 suspension point 都隐式地检查任务是否被取消,那么每个潜在的 suspension point 都可能抛出:在这种情况下,await 也可能意味着 try,因为每个 await 都可能带着错误退出。任务取消在结构化并发提案中有所涉及,并没有仅将取消建模为抛出的错误,也没有在每个潜在的 suspension point 引入隐式的取消检查。

启动 async 任务

因为只有 async 代码才能调用其他 async 代码,所以本提案没有提供启动异步代码的方法。这是有意的:所有异步代码都在 “task” 的上下文中运行,这个概念在结构化并发提案中得到了定义。该提案提供了通过 @main 来定义程序的异步入口的能力,例如:

@main
struct MyProgram {
  static func main() async { ... }
}

此外,在本提案中,顶层代码不被视为异步上下文,所以下面的程序是不合法的:

func f() async -> String { "hello, asynchronously" }

print(await f()) // error: cannot call asynchronous function in top-level code

这一点也将在随后的提案中得到解决,该提案将适当考虑到顶层变量。

对顶层代码的处理并不会影响本提案中定义的 async/await 基本机制。

await 作为语法糖

这个建议使 async 函数成为 Swift 类型系统的核心部分,与同步函数不同。另一种设计是不改变类型系统,而是将 asyncawait 的语法糖化在一些 Future<T, Error> 类型上,例如:

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

这种方法与这里提出的方法相比,有许多缺点:

  • 在 Swift 生态系统中,没有通用的 Future 类型可供使用。如果 Swift 生态系统已经基本确定了一个单一的 Future 类型(例如,如果标准库中已经有了一个 Future 类型),那么像上面这样的语法糖方法就会改变现有的实践。如果缺乏这样的类型,就必须尝试用某种 Futurable 协议来抽象所有不同种类的 Future 类型。对于某些 Future 类型来说,这也许是可能的,但会放弃对异步代码行为或性能的任何保证。
  • 这与 throws 的设计不一致。在这个模型中,异步函数的结果类型是 Future 类型(或 “任何Futurable 类型”),而不是实际返回值。它们必须总是立即 await,否则当你关心异步操作真正的结果时,你最终还是会到用 Future 的接口。这就变成了一个使用 Future 的编程模型,而不是异步编程模型,并且 async 设计的许多方面都有意不去考虑 Future
  • async 从类型系统中拿出来,就会限制基于 async 进行重载的能力。参见前文关于在 async 上进行重载的原因。
  • Future 是比较重量级的类型,为每一个 async 操作生成一个类型实例在代码大小和性能上都有不小的代价。相比之下,与类型系统的深度集成,使得 async 函数可以有针对性地构建和优化,从而实现高效 suspension。Swift 编译器和运行时的每一层都可以对 async 函数进行优化,而这种优化方式在基于 Future 的模型里是几乎不可行的。

修订历史

  • Post-review changes:

    • Replaced await try with try await.
    • Added syntactic-sugar alternative design.
  • Changes in the second pitch:

    • One can no longer directly overload async and non-async functions. Overload resolution support remains, however, with additional justification.
    • Added an implicit conversion from a synchronous function to an asynchronous function.
    • Added await try ordering restriction to match the async throws restriction.
    • Added support for async initializers.
    • Added support for synchronous functions satisfying an async protocol requirement.
    • Added discussion of reasync.
    • Added justification for await not implying try.
    • Added justification for async following the function parameter list.
  • Original pitch (document and forum thread).

相关提案

除本提案外,还有一些相关提案,涵盖了 Swift 并发模型的不同方面。

  • Concurrency Interoperability with Objective-C: 描述与 Objective-C 的交互,特别是接收回调的异步 Objective-C 方法与 @objc async Swift 方法之间的关系。
  • Structured Concurrency:描述了异步调用所使用的任务结构、子任务和分离任务的创建、取消、优先级和其他任务管理 API。
  • Actors: 描述了为并发程序提供状态隔离的 actor 模型。

鸣谢

在 Swift 中实现 async/await 的想法由来已久。这个提案从 Chris LattnerJoe Groff 撰写的早期提案中获得了一些灵感(以及动机部分的大部分内容),可以在这里找到。该提案本身源自 Oleg Andreev 撰写的提案,可在这里查阅。它经过了重大的改写(再次),许多细节都发生了变化,但异步函数的核心思想没有改变。

高效的实现对于异步函数的引入,以及整个 Swift 并发来说都是至关重要的。Nate Chandler、Erik Eckstein、Kavon Farvardin、Joe Groff、Chris Lattner、Slava Pestov 和 Arnold Schwaighofer 都为这个提案的实现做出了重要贡献。