Async/await

2022-01-20 10:27:00 浏览数 (1)

  • 提议:SE-0296
  • 作者:John McCall, Doug Grego
  • 审核主管:Ben Cohen
  • 状态: 在 Swift 5.5 已实现
  • 决策记录:基本原理, 允许在async上重载

介绍

当前 Swift 开发中使用 closures 和 completion handlers 处理大量异步编程,但是这些 API 很难用。特别是当我们需要调用多个异步操作,进行多个错误处理(error handling), 或者需要在异步回调完成时处理控制流,这些情况下代码会变得很难阅读。本篇提案描述了一种语言扩展,使上述问题处理更自然,更不容易出错。

本篇设计将 协同程序模型 引入到 Swift。函数可以选择使用 async , 它允许编程人员使用常规的控制流机制来组合复杂的异步操作。编译器会把异步函数转化成一组合适的 closure 和状态机。

本篇提案定义了异步函数的语义。另外,本篇中并不涉及并发编程。并发在结构化并发提案中单独介绍。在结构化并发提案中将异步函数和并发执行任务相关联,并提供创建、查询和取消任务的 API。

Swift-evolution 关键点时间线:关键节点1, 关键节点2

动机:Completion handlers 非最佳方案

使用显式回调(也就是 Completion handlers)的异步编程存在许多问题,这点在下文讨论。本篇中我们建议在语言中引入异步函数来解决这些问题。这些异步函数允许以同步的方式来写异步代码。它们还允许实现直接对代码的执行模式进行推理,从而使回调能够更高效的运行。

问题一:金字塔厄运

一系列简单的异步操作通常需要深度嵌套闭包。下面例子可以说明这一点:

代码语言: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)
                }
            }
        }
    }
}

processImageData1 { image in
    display(image)
}

这种深度浅套的代码一般被称为“金字塔厄运”,代码阅读困难,追踪代码运行也困难。除此之外,使用一堆闭包也会导致一些其他影响,这点我们在下面讨论。

问题二:Error handling

回调会让错误处理变得复杂。Swift 2.0 为同步代码引入了错误处理模型,但是基于异步回调的接口并没有获取任何好处:

代码语言:txt复制
// (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](https://github.com/apple/swift-evolution/blob/main/proposals/0235-add-result.md) 加到标准库里改进了 Swift APIs 的错误处理。异步函数是促成 Result 产生的主要原因之一:

代码语言:txt复制
// (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)
    }
}
代码语言:txt复制
// (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来处理错误更容易,但是闭包嵌套的问题还是存在。

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

按条件来执行异步函数一直是个痛点。比如,假设我们要在获取图像后做旋转,但有时候在进行旋转操作前,必须要调用一个异步函数来解码该图片。构建该函数最好的方式是在中间助手闭包(一般称为 continuation closure)中编写旋转图片的代码,这个闭包在 completion handler 中按条件执行。比如这个例子伪代码可以这样写:

代码语言:txt复制
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)
        }
    }
}

这种模式跟普通从上到下组织的函数是相反的:在函数后半部分执行的代码必须出现在函数前半部分执行之前。为了重构这个函数,你必须仔细考虑辅助闭包(continuation closure)中的捕获, 因为闭包是在 completion handler 中使用。随着条件执行的异步函数数量增多,这个问题会变得越复杂,最后产生了一种倒置“末日金字塔”现象(问题1中提到的“金字塔厄运”)。

问题四:容易出错

开发者很容易在异步操作中忘记调用正确的 completion handler block 就直接返回,过早的跳出异步操作。当我们忘记了这个操作,程序有时候会变得很难调试,也会出现一些奇怪的问题:

代码语言:txt复制
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
            }
            ...
        }
    }
}

当你记得调用 block 时,你依然可能忘记在调用 block 之后执行 return 操作:

代码语言:txt复制
func processImageData4b(recipient:Person, completionBlock: (_ result: Image?, _ error: Error?) -> Void) {
    if recipient.hasProfilePicture {
        if let image = recipient.profilePicture {
            completionBlock(image) // <- 调用 block 之后,忘记执行 return 操作
        }
    }
}

这里其实要感谢guard语法,它从某种程度上防止编码时忘记执行 return 操作,但是不能保证全部。

问题五: 因为 completion handlers 很难用,许多的 API 使用同步来完成定义

这点确实很难去衡量, 但是作者认为,定义和使用异步 APIs (使用 completion handler) 的尴尬已经导致许多 APIs 被定义为明显的同步行为,即使它们可以像异步一样阻塞。这种会在 UI 中造成不确定表现和响应流畅度问题。例如加载符。并且当异步对于实现规模至关重要时,它还会导致不能使用这些 api。例如服务器端。

提议的解决方案: async/await

异步函数,经常被称为 async/await, 允许异步代码像线性且同步的代码一样被编写。它通过允许程序员充分利用同步代码可用的相同语言结构,直接解决上面描述的大多数问题。async/await 的使用还自然地保留了代码的语义结构,提供至少3种跨领域语言改进所需的信息:(1) 异步代码更好的性能;(2) 更好的工具性,在调试,评测(profiling) 和研究代码时提供一致的体验;(3) 为后续的并发特性例如任务优先级和取消任务提供基础。上一节的示例演示了 async/await 如何极大的简化异步代码:

代码语言:txt复制
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 的描述都通过一种通用的实现机制来讨论它:将函数分成多个部分的编译过程。为了理解机器是如何运作的,在较低的语言抽象级别上这一点很重要,但是在较高的语言抽象级别上,我们鼓励你忽略这个机制。相反,把异步函数看成具有放弃其线程这种特殊能力的普通函数。异步函数不会直接使用这个能力,而是在他们调用时,有些调用需要他们放弃所在的线程,然后等待执行结果。当执行完成时,函数继续从等待的点往下执行。

异步函数和同步函数看起来很像。同步函数可以进行调用,当发起函数调用,同步函数直接等待调用完成。一旦调用完成,控制返回到函数并且从它停止的地方继续。这对于异步函数也是同样的:异步函数可以进行函数调用,当发起函数调用,异步函数通常直接等待调用完成。一旦调用完成,控制返回到函数并且从它停止的地方继续。唯一区别是,同步函数可以充分利用其线程及其堆栈的(部分),而异步函数可以完全放弃该堆栈,并使用它们自己的存储。这种为异步函数提供的额外功能具有一定的实现成本,但是我们可以通过对其进行整体设计来降低这一成本。

由于异步函数一定可以放弃其所在的线程,同步函数不知道怎么放弃线程,所以一般情况下,同步函数不能调用异步函数。如果这样做,异步函数会放弃它带来的部分线程,调用异步函数的同步函数会把把它当作返回并继续从停止的地方执行,只是这时候没有返回值。最常用的办法是阻塞整个线程,直到该异步函数已经恢复并且完成。这完全违背了异步函数的目的,并产生恶劣的系统性影响。

相反,异步函数可以调用同步函数和异步函数。当异步函数调用同步函数时,首先该异步函数不会放弃所在线程。实际上,异步函数从不自主地放弃所在线程,他们只在异步函数到一个挂起点(suspension point)时才会放弃线程。挂起点可以发生在函数内部,或者发生在当前函数调用的另一个异步函数内部,但是无论哪种 case, 异步函数和它的调用者都会同时抛弃所在的线程。(实际上,异步函数被编译为在异步调用期间不依赖于线程,因此,只有最里面的函数需要做其他额外的工作。)

当控制流返回异步函数时,它会准确地恢复到原来的位置。这并不意味着它将在与之前完全相同的线程上运行,因为 swift 语言不保证在挂起之后运行。这种设计中,线程几乎更像是一种实现机制,而不是并发接口的一部分。然而,许多的异步函数不单单是异步的:它们和指定的 actors(用作隔离) 关联在一起,并总是被认为是 actor 的一部分。Swift 会保证这些函数实际会返回到它们所在的 actor 来完成函数执行。因此,直接使用线程来做状态隔离的库(例如, 通过创建自己的线程并在其上按顺序调度任务),通常应该将这些线程模型构建为 Swift 中的 actors, 以便于这些基本语言保证正常运行。

挂起点 (Suspension points)

挂起点是异步函数执行过程中必须要放弃线程的点。挂起点经常和确定且语法明确的事件相关联。从函数的角度看,它们从不会隐藏或者在发生的地方是异步行为(在该点是同步行为)。挂起点的原型是调用一个对不同执行上下文关联的异步函数。

挂起点只与明确的操作行为相关联,这点是至关重要的。事实上,本提案要求将可能发生挂起的调用都包含在await表达式中。这些调用被称为潜在挂起点,因为它们并不知道它们是否会被挂起:这取决于调用处上不可见的代码(比如,被调用方可能依赖异步 I/O)以及动态条件(例如,异步 I/O 是否必须等待完成)。

在潜在挂起点上对await的要求遵循了 Swift 的先例,即要求try表达式覆盖对可能抛出错误的函数的调用。标记潜在挂起点非常重要,因为挂起会中断原子性(suspensions interrupt atomicity)。比如,如果异步函数运行在同步队列保护的上下文中,如果此次到达某个挂起点,意味着可以在相同的同步队列中交叉执行其他代码。原子性问题的一个经典但有点陈腐的例子是对银行建模:如果一笔存款存入一个账户,但在处理匹配的提款之前,该操作暂停,并且它创建了另一个窗口,在该窗口中,这些资金可以双倍使用。对于许多 Swift 程序员来说,一个更相似的例子是 UI 线程:挂起点是可以向用户显示 UI 的点,因此,构建部分 UI 然后挂起的程序有可能呈现一个闪烁的、部分构建的 UI(比如请求后台服务的过程中,UI 展示转动菊花,在等待后台数据返回并渲染完成的过程中,这就是一个挂起点)。(请注意,挂起点也在使用显式回调的代码中显式调用:挂起发生在外部函数返回点和回调开始运行点之间。)要求标记所有潜在的挂起点,允许程序员安全地假设没有潜在挂起点的位置将以原子方式运行,并且更容易识别有问题的非原子模式。

因为潜在挂起点只能显式出现在异步函数内部标记的点,所以长时间的计算仍然会阻塞线程。这种可能发生在当调用一个仅用来做大量计算的同步函数,或者在异步函数中遇到一个特别大的计算循环。在上面两种场景中,在这些计算运行时,线程都不可能插入代码,通常情况下没有代码干扰是正确的,但是这也可能变成一个扩展性问题。一个需要进行大量计算的异步程序通常应该放到独立的上下文运行。当这不可行时,基础库会有一些工具来暂停并允许其他操作插入运行。

异步函数应该避免调用会阻塞线程的函数,特别是如果他们可以阻止它等待不能保证当前正在运行的工作。比如,获取互斥锁能阻塞,直到当前运行的线程释放了互斥锁。有时这种是可以接受的,但是一定要谨慎使用避免引入死锁问题或假扩展性问题。相反,等待一个条件变量可能会阻塞,直到某些任意的其他任务被调度,并向该变量发出信号;这种模式与推荐的做法大相径庭。

设计详情

异步函数

函数类型可以使用async标记,表示该函数是异步的:

代码语言:txt复制
func collect(function: () async -> Int) { ... } 

函数和初始化声明函数(init)也可以使用async标记:

代码语言:txt复制
class Teacher {
  init(hiringFrom: College) async throws {
    ...
  }

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

原因:async 跟在函数形参列表之后,因为它是函数类型及其声明的一部分。这跟throws例子是一样的。

使用async声明的函数或者初始化的引用类型是async函数类型。如果引用是对实例方法的静态引用,则是异步的 “内部”函数类型,与此类引用的常规规则一致。

有些特殊的函数例如deinit和存取访问器(例如属性和下标的 getters 和 setters) 不能使用async

原因:属性和下标只有 getter 方法可以声明为async. 同时具有async setter 方法的属性和下标意味着能够将引用作为inout传递,并且深入到该属性本身的属性,这取决于 setter 实际上是一个瞬间的 (同步的,非抛出的)操作。 相比只允许只读async属性和下标来说,禁止async属性更简单。

如果函数同时存在asyncthrows, 声明时async关键字必须在throws前面。该规则适用于asyncrethrows.

原因:这种顺序限制是任意的,但它没有害处,它消除了潜在的格式争论。

如果某个类的async初始化函数没有调用父类的初始化函数,当父类的初始化函数有参数,同步且是指定的初始化函数(designated initializer),那么该类的初始化函数会隐式地调用super.init()

原因:如果父类初始化函数是异步的,对异步的初始化函数调用是一个潜在挂起点,因此,调用(要求 await)必须在调用的地方是可见的。

异步函数类型

异步函数类型不同于同步函数类型。但是,同步函数类型到它对应的异步函数类型可以隐式转换。这跟 non-throwing 函数到它对应的 throwing 函数隐式转换相似,可以和异步函数转换组合。例如:

代码语言:swift复制
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函数)。而且,它必须出现在 await 表达式内。

看这个例子:

代码语言:txt复制
// 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:)调用之后,因为他们是异步函数。因此,两个调用表达式必须包含在await表达式内,他们每个都包含一个潜在挂起点。一个await可能包含多个潜在挂起点。例如,我们可以这样写来使用一个await来涵盖上例中的2个挂起点:

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

await没有其他语义,跟try很像。它仅仅标记正在进行异步调用。await表达式的类型是其运算对象的类型,其结果是运算对象的结果。await可能没有潜在挂起点,这种情况下编译器会给警告,跟try表达式规律一样:

代码语言:txt复制
let x = await synchronous() // warning: no calls to 'async' functions occur within 'await' expression

原因:在函数内部,异步函数调用能被清晰的辨别至关重要,因为异步函数可能会引入挂起点,打破操作的原子性。挂起点可能是调用所固有的(因为异步调用必须在不同执行程序上执行)也可能只是被调用方实现的一部分。但无论哪种情况,它在语义上都很重要,程序员需要承认这一点。await表达式同样也是异步代码的代表,异步代码与闭包中的推理交互。这点可以看 Closures 章节查看更多信息。

不是async函数的 autoClosure,一定不能出现挂起点。

deferblock 内一定不能出现潜在挂起点。

如果在子表达式中同时存在awaittry的变种(try!try?),await必须跟在try/try!/try?之后:

代码语言:txt复制
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

closure 可以拥有async函数类型。这样的 closure 可以使用async标记:

代码语言:txt复制
{ () async -> Int in
  print("here")
  return await getInt()
}

匿名 closure 如果包含await表达式,它将会被推断为具有async函数类型.

代码语言:txt复制
let closure = { await getInt() }

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

请注意,对闭包的async推理不会传到闭包中的封闭函数,嵌套函数或者闭包内,因为这些内容是可分离的异步或者同步的。例如,在下面例子中,只有closure6会被推断为async:

代码语言:txt复制
// 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 一般通过 callback 接口来支持异步函数,如:

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

许多像这类的 API 可以通过添加async来更新:

代码语言:txt复制
func doSomething() async ->String { ... }

这两个函数名字和签名都不同,尽管他两共享同一个基础函数名。但是,他两都可以进行无参调用(上面 completion handler 提供了默认值),这会为现有的代码带来问题:

代码语言:txt复制
doSomething() // problem: can call either, unmodified Swift rules prefer the `async` version

类似的问题同样存在于那些同时提供同步或者异步版本的函数,并具有相同签名的 api 中。这种情况下允许 API 提供异步函数更适合 Swift 异步场景,也不会破坏向后兼容性。新的异步函数也可以支持取消操作(在 Structured Concurrency 中可以看异步函数取消的定义)。

代码语言:txt复制
// Existing synchronous API
func doSomethingElse() { ... }

// New and enhanced asynchronous API
func doSomethingElse() async { ... }

在第一个场景中,Swift 的重载规则会优先调用有默认参数的函数,所以添加async函数将会破坏调用原始doSomething(completionHandler:)的现有代码,这会导致以下错误:

代码语言:txt复制
error: `async` function cannot be called from non-asynchronous context

这给代码演化带来了问题,因为现有异步库的开发者要么有一个强制兼容性中断(比如,到一个新的主版本),要么所有新的async版本都需要有不同的名称。后者可能会产生一种方案,比如 C#'s pervasive Async suffix.

在第二个场景中,两个函数都有相同的签名且只有async关键字不同,这种情况一般会被现有的 Swift 重载规则拒绝。这里确实不允许两个函数通过他们的效果词来做区分,比如不能定义两个仅仅只有throws不同的函数:

代码语言:txt复制
// error: redeclaration of function `doSomethingElse()`.

这同样给代码演化带来了问题,因为现有库的开发者不能保留他们现有的同步 API, 去支持新的异步特性。

相反,我们提出一个重载解析规则给予调用的上下文来选择合适的函数。对于给定的调用,重载解析会优先选择同步上下文中的非 async函数(因为这样的上下文不能包含对异步函数的调用)。而且,重载解析会优先选择异步上下文中的async函数(因为这样的上下文中应该避免跳出异步模型进入阻塞 API)。当重载解析选择了async函数时,给定的调用依然受 “异步函数调用必须发生在await表达式内”的限制。

重载解析规则取决于同步或者异步上下文环境,在对应的环境中,编译器只选择一个函数重载。选择异步函数重载需要await表达式,作为所有潜在挂起点的引入:

代码语言:txt复制
func f() async {
  // In an asynchronous context, the async overload is preferred:
  await doSomething()
  // Compiler error: Expression is 'async' but is not marked with 'await'
  doSomething()
}

在非async函数和不带任何await表达式的闭包中,编译器选择非async重载:

代码语言:txt复制
func f() async {
  let f2 = {
    // In a synchronous context, the non-async overload is preferred:
    doSomething()
  }
  f2()
}

Autoclosures

函数可能不会带async函数类型的 autoClosure 参数,除非函数本身是异步的。例如,下面这种声明不规范:

代码语言:txt复制
// error: async autoclosure in a function that is not itself 'async'
func computeArgumentLater<T>(_ fn: @escaping @autoclosure () async -> T) { } 

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

代码语言:txt复制
// func getIntSlowly() async -> Int { ... }

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

乍一看,await表达式好像暗示程序员,在调用computeArgumentLater(_:)之前有个潜在挂起点,但这不是实际的场景:潜在挂起点是在被传入和被使用在computeArgumentLater(_:)函数内部的 autoclosuse 中。这也直接带来一些问题。首先,await出现先于调用的事实意味着closure会被推断含有async函数类型,这点不正确:所有的在closure中的代码是同步的。其次,由于await操作只需要在他内部某包含一个潜在挂起点,所以对调用的一个等价重写应该可以这样:

代码语言:txt复制
await computeArgumentLater(getIntSlowly())

但是,因为参数是个 autoclosure,这种重写不再保持原有语义。因此,通过确保asyncautoclosure 参数只能在异步上下文中使用,对asyncautoclosure 参数的限制避免了上面这些问题。

协议一致性

协议也可以被声明为async。这样的协议可以通过async或者同步函数来实现。然而,同步函数需求不能同步async函数实现。例如:

代码语言:swift复制
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函数和闭包)并且不会收到影响。但是,带入了2个新的语境关键字,asyncawait.

async在语法里(函数声明和函数类型)使用的位置允许我们在不用破坏源代码兼容性的前提下把async作为语境关键字对待。在格式良好的代码中,用户定义的async不能出现在这些语法位置。

await语境关键字更容易造成疑惑,因为他出现在表达式内部。例如,我们可以在 Swift 中定义名为await的函数:

代码语言:txt复制
func await(_ x: Int, _ y: Int) -> Int { x   y }

let result = await(1, 2)

在当前 Swift 语言中,对await函数的调用是一段结构良好的代码。但随着本篇提议的产生,这段代码变成了一个带有子表达式(1, 2)await表达式。这段代码在现有的程序中会显示为编译错误,因为await仅仅能用在异步上下文中,不是存在像这样一个语境中。这些函数似乎并不常见,所以我们相信作为引入 async/await 的一部分,这是一个可以接受源代码破坏。

对 ABI 稳定性的影响

异步函数和函数类型可以添加到 ABI 中,不影响 ABI 的稳定性,因为现有的函数(同步的)和函数类型不会受到影响。

对 API 扩展性的影响

async函数的 ABI 与同步函数的 ABI 完全不同(例如,他们拥有完全不兼容的调用规定),所以从函数或者类型中添加或移除async, 不会影响扩展性。

未来的方向

reasync

Swift 中rethrows是同一种机制,用于表示特定函数仅在传递给它的一个参数是自身 throw 的函数时才做 throw 操作。例如Sequence.map利用rethrows因为该操作唯一能 throw 的方式是 transform 方法本身能否进行 throws:

代码语言:swift复制
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:

代码语言:txt复制
_ = [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参数带有reasync时,map函数也成为了异步函数:

代码语言:swift复制
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
  }
}

从概念上说,这是好的:当map的入参是async函数时,map会被认为是async(结果需要使用await),因此如果入参不是async函数,map会被认为是同步的(也就不会使用await来接受结果)。

实际上,存在几个问题:

  • 这可能不是序列异步map一个非常好的实现。更有可能的情况是,我们希望并发实现(比方说)并发处理最多核元素的数量。
  • throw 函数的 ABI 有意设计为让rethrows函数有可能去充当非 throw 函数,因此,一个 ABI 入口就足以同时处理 throw 调用和 non-throw 函数调用。异步函数并非如此,异步函数具有完全不同的 ABI, 其效率必然低于同步函数的 ABI。

Sequence.map这些可能会变成并发的函数,reasync更像个错误的工具:对async闭包重载提供单独(并发的)实现看起来更好。所以,与rethrows相比,reasync 可能更不适用。

毫无疑问,reasync有一些用途。比如用于可选的??操作符,async实现此时跟同步实现很接近:

代码语言:txt复制
func ??<T>(
    _ optValue: T?, _ defaultValue: @autoclosure () async throws -> T
) reasync rethrows -> T {
  if let value = optValue {
    return value
  }
  return try await defaultValue()
}

对于这种情况,可以通过两个入口点来解决上述 ABI 问题:一个是当参数是async,另一个是参数不是async。然而由于实现太复杂,作者到现在还没提交这个设计方案。

备选方案

await上实现try

许多异步 API 涉及文件 I/O, 网络,或者其他可能失败的操作,因此这些操作同时应该是asyncthrows。这也意味在在调用的地方,try await会被重复调用多次。为了减少这种重复模版调用,await可以实现try, 那么下面两行代码实现效果应该是一样的:

代码语言:txt复制
let dataResource  = await loadWebResource("dataprofile.txt")
let dataResource  = try await loadWebResource("dataprofile.txt")

最终没有在await实现try是因为它们表达的是基于不同的考量: await表示一个潜在挂起点,在你调用和它返回之间其他的代码可能会执行,而try是关于 block 外的控制流。

另外一个想在await上实现try的动机与任务取消有关。如果将任务取消构建为抛出错误,并且每个潜在挂起点隐式检查任务是否已经取消,然后每个潜在挂起点可以做抛出操作:这种 case 下await可以实现try因为每个await能够以错误退出。Structured Concurrency 提议中包含任务取消介绍,它不会将取消任务建模为抛出错误,也不会在每个潜在挂起点引入隐式取消检查。

启动 async 任务

由于只有async代码能够调用其他async代码,本篇提议并没有提供初始化异步代码的方法。这是有意这么做的:所有异步代码运行在 "task" 上下文中。"task" 是 Structured Concurrency 提议中定义的概念。这篇提议提供了通过@main来定义程序异步入口点的能力,比如:

代码语言:txt复制
@main
struct MyProgram {
  static func main() async { ... }
}

另外,本提案中,顶层(top-level)代码不能被视为上下文,所以下面程序格式不正确:

代码语言:txt复制
func f() async -> String { "hello, asynchronously" }

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

这一点也将在后续的提议中得到解决,该提议将适当地考虑顶级顶层变量(top-level variables)。

为顶层代码的考量不会影响本提案中定义的 async/await 基本机制。

把 await 作为语法糖

该提议把async函数作为 Swift 类型系统的核心部分,区分同步函数。另一种设计是保持类型系统不变,而是在某些Future<T, Error>类型上使用asyncawait语法。例如:

代码语言:txt复制
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 类型(例如,标准库中已经有一种),类似上面语法糖的方式将出现在现有代码中。如果没有这样一种类型,人们将不得不尝试用某种未来的协议来抽象所有不同类型的未来类型。这对于将来的某些类型可能是可能的,但会放弃对异步代码的行为或性能的任何保证。
  • throws的设计不一致。在这种模型下,异步函数的结果类型是 future 类型(或者是其他Futurable类型),而不是实际的返回值。它们必须始终是 awaited(因此是后缀语法),否则,当您真正关心异步操作的结果时,您将使用futures。当异步设计的许多其他方面有意避开对future的思考时,这就变成了一个具有future模型的编程,而不是一个异步编程模型。
  • async从类型系统中删除将消除基于async进行重载的能力。请看上一节,了解async上重载的原因。
  • Future 是相对较重的类型,而且为每个异步操作生成一个在代码大小和性能上都有不小的代价。相反,与系统类型高度集成允许async函数专门构建和优化异步功能,以实现高效的挂起操作。Swift 编译器和运行时的所有级别都可以以一种 future 返回函数无法实现的方式优化async函数。

历史版本

  • 审查变化:
    • 使用try await代替await try
    • 添加语法糖备选设计。
    • 修改提议允许 在async上重载。
  • 其他变化:
    • 不能再直接重载异步和非异步函数,然而,重载解决方案的支持仍有其他理由。
    • 为同步函数到异步函数增加隐式转换。
    • 增加await try次序限制来匹配async throws限制。
    • 增加async初始化的支持。
    • 增加了对满足async协议要求的同步函数的支持。
    • 增加reasync的讨论。
    • 增加await不含有try的理由。
    • 增加async跟在函数参数列表的理由。
  • 初稿( 文档 和 社区讨论节点)

其他相关提议

除了本篇提议,还有不少相关提议包含 Swift 并发模型其他方面:

  • 与Objective-C的并发互操作:描述与 Objective-C 的交互,特别是在接受 completion handler 的异步 Objective-C 方法和@objc asyncSwift 方法之间的关系。
  • 结构化并发: 描述在异步调用使用的任务结构,子任务和分离任务的创建、取消、优先级划分和其他任务管理 API。
  • Actors: 描述参与者模型,为并发编程提供了状态隔离。

0 人点赞