Swift 引入 async/await 后,错误处理代码从嵌套闭包中解放出来,但 try 关键字家族在并发挂起点前后的行为仍有一些容易忽视的差别。try? 会把错误压成 nil,try! 会在遇到错误时直接触发 fatalError,而 rethrows 则控制高阶函数中错误是否向上游传递。三者放在 Task、async let、TaskGroup 等并发结构里,如果只按同步代码的习惯理解,往往会出现静默失败、延迟崩溃或错误类型被吞掉的问题。

一、async/await 下三种 try 的基础语义
同步代码中,try 要求调用者要么用 do-catch 捕获,要么继续把错误向上抛。async throws 函数的调用点必须同时出现 try 和 await,顺序上 try await 是不可拆分的语法组合。try? 会把错误扁平化为可选值:成功时返回 .some,失败时返回 nil,不再保留具体 Error 类型。try! 则放弃所有错误传播,一旦抛出错误就触发运行时 fatalError。这些基本规则并不会因为并发而改变,但并发会放大它们的副作用。
以网络请求为例,下面的 fetchUser 函数会因状态码、解码或网络问题抛出不同类型错误。使用 try? await 调用后,URLSession 的 URLError、自定义 APIError 以及任务取消产生的 CancellationError 全部被压成一个 nil。调用方无法区分究竟是数据不存在、网络断开还是整个任务已经被取消。
import Foundation
enum APIError: Error {
case invalidEncoding
case badStatus(Int)
}
func fetchUser() async throws -> String {
let url = URL(string: "https://ipipp.com/user")!
let (data, _) = try await URLSession.shared.data(from: url)
guard let name = String(data: data, encoding: .utf8) else {
throw APIError.invalidEncoding
}
return name
}
func downloadAvatar() async throws -> Data {
let url = URL(string: "https://ipipp.com/avatar")!
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
这个例子中,try? await fetchUser() 返回 String?。如果只是想做预加载缓存,nil 可以接受;但若是页面关键数据,nil 会导致界面卡在空状态,而且问题难以复盘。
二、try? 在并发场景中的静默失败风险
并发任务取消是 Swift 结构化并发的重要机制。Task 被取消时,正在执行的异步函数通常会在下一个挂起点抛出 CancellationError。如果调用链上充斥着 try?,这个取消错误就会被吞掉,任务看起来正常返回了 nil,但实际上协作取消流程被打断。例如用户离开页面后,后台任务本应提前结束,却可能继续执行耗时操作。
更典型的场景是 async let 并行加载多个资源。下面代码同时请求用户名和头像,两个子任务都使用 try? 包装。即使网络请求全部失败,userName 和 imageData 也只是 nil,外层逻辑没有任何错误提示。这种写法会让业务层把请求失败和正常空值混为一谈。
func loadUserData() async {
async let name = try? fetchUser()
async let avatar = try? downloadAvatar()
let userName = await name
let imageData = await avatar
// userName 与 imageData 都可能为 nil
}
在 TaskGroup 中问题会更隐蔽。多个子任务并发执行,只要有一个失败,整体结果可能应该标记为失败,但如果每个子任务都返回 Optional,父任务就只能拿到一个装满 nil 的数组,连哪个键对应的操作失败都不知道。此时更推荐使用 withThrowingTaskGroup 保留错误抛出能力,在子任务内部不吞错,而由父任务统一捕获。
三、try! 的延迟崩溃与任务隔离问题
try! 表示调用者断言这个操作绝对不会失败。同步代码中如果断言错误,崩溃会立即发生在 try! 所在行,调试相对直接。但在 async/await 中,try! await 可能在一个独立 Task 或 actor 隔离域中执行。实际抛出错误的调用栈与业务代码创建任务的位置相距很远,崩溃信息里常常只显示 Swift 运行时的 fatalError,而不是最初触发网络请求的函数名。
并发任务具有隔离边界,try! 造成的进程崩溃不能像同步异常那样被上层 do-catch 拦截。即使你在 Task 外层写了错误处理,也无法捕获任务内部的 fatalError。这意味着一个偶发的服务端错误、一次网络抖动或返回数据格式变化,都可能直接杀死整个 App。
func loadProfile() {
Task {
let name = try! await fetchUser()
print(name)
}
}
这段代码看起来简洁,但在生产环境中极其危险。只要 fetchUser 抛出一个错误,Task 所在的线程就会触发 fatalError,而且这个错误不会变成 Swift 错误返回给 loadProfile。更好的做法是让 Task 继承外层作用域的错误处理,或者用 do-catch 记录错误后降级显示占位内容。
四、rethrows 在异步高阶函数中的传播边界
rethrows 的作用是让函数具备条件抛出能力:函数本身不主动 throw,但当传入的闭包参数抛出错误时,函数也会抛出该错误。这个关键字在同步高阶函数中很常见,进入 async/await 之后,其规则向异步闭包自然延伸。异步 rethrows 函数要求闭包参数是 async throws,函数体内部使用 try await 调用闭包,并且不能额外抛出闭包错误之外的新错误。
下面实现一个 mapAsync 函数,对数组元素逐个执行异步变换。由于 transform 可能抛错,函数不能写成普通 async;但又不想让所有调用者都被迫处理错误,因此 rethrows 是最合适的签名。
func mapAsync<T, U>(_ items: [T], transform: (T) async throws -> U) async rethrows -> [U] {
var output: [U] = []
for item in items {
output.append(try await transform(item))
}
return output
}
如果调用 mapAsync 时传入的 transform 是 async 非 throws 闭包,那么整个 mapAsync 调用不需要 try;如果 transform 是 async throws,则调用端必须使用 try await。这种条件传播能极大提高 API 的易用性,避免无意义的 do-catch 包裹。
Swift 标准库中的 withThrowingTaskGroup 也遵循类似思路。它的闭包参数是 async throws,函数自身标记 rethrows,因此当闭包内部没有错误抛出时,调用方无需处理错误;一旦某个子任务失败,错误会沿任务组边界重新抛给调用者。自定义并发工具函数时,优先考虑 rethrows 可以避免错误语义被过早抹平。
五、并发错误处理策略建议
在实际并发代码中,三种 try 写法的选择应该遵循一个简单原则:错误如果不影响主流程,才能用 try?;确定不可能失败,才用 try!;而凡是高阶函数需要转发闭包错误,优先 rethrows。对于网络请求、数据库访问、文件 I/O 这类典型会失败的异步操作,try? 基本不应作为默认选项,因为它把错误信息完全丢弃。
更稳妥的方式是使用 do-catch 捕获 try await,并在 catch 中优先判断 Task.isCancelled 或错误类型是否为 CancellationError。这样既能响应结构化并发取消,也能区分真正的业务失败。对于需要继续执行的部分,可以采用 Result 类型或自定义错误枚举把失败信息显式保留,再由上层统一处理。
最后要注意任务组的错误边界。withThrowingTaskGroup 会等待所有子任务完成后统一重抛,适合整组操作需要原子性的场景;如果某个子任务失败后允许其他任务继续,则应在子任务内部捕获错误并记录日志,但要避免使用 try? 静默吞掉 CancellationError。并发错误处理的核心不是在每一层都消除错误,而是让错误在正确的边界出现。
Swift async/await错误处理rethrows修改时间:2026-10-02 23:21:31