async let 也能调度同步函数?——Swift 并发隐藏小技巧详解

312 阅读4分钟

什么是 async let

  • async let 是 Swift 5.5 引入的「结构化并发」语法糖之一
  • 它允许你把「多个异步操作」并行地扔给后台,然后在需要结果时用 await 一次性收回来
  • 写起来比 TaskGroup 简洁,比「回调金字塔」优雅

最小可运行模板:

// 异步函数示例
func fetchA() async -> Int { 
    try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 s
    return 1 
}
func fetchB() async -> Int { 
    try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 s
    return 2 
}

// 并发调用
func demo() async {
    async let a = fetchA()   // 立即开始,不阻塞
    async let b = fetchB()   // 同上
    let sum = await a + b    // 这里才阻塞,直到两者都完成
    print("sum = \(sum)")    // 3
}

隐藏特性:async let 也能接「同步」函数

大多数教程只告诉你:"右侧必须是一个 async 返回的表达式"

但真相是:只要右侧表达式最终能产生一个 async 值,编译器就放行。

最简单的办法:把「同步函数调用」直接塞进 async let

// 1️⃣ 一个再普通不过的同步函数
func heavySyncJob(id: Int) -> Int {
    // 故意耗时
    for _ in 0..<1_000_000 { _ = id & 1 }
    return id * 2
}

// 2️⃣ 以前我们要么
//    - 把 heavySyncJob 改成 async
//    - 或者在 Task { ... } 里手动 .await
//
// 3️⃣ 现在一行代码就能把它扔后台:
func runConcurrent() async {
    async let x = heavySyncJob(id: 1)  // ✅ 编译通过!
    async let y = heavySyncJob(id: 2)  // ✅ 同样有效
    let result = await x + y           // 3 000 000 次空转后返回
    print("result = \(result)")
}

运行现象:

  • Xcode 控制台先空白几秒,然后一次性打印 result = 6
  • 期间主线程完全不被卡住(可在界面上拖拽按钮验证)

原理剖析:为什么能这样写?

  1. async let 右侧的表达式会被 隐式包装成 Task {}
  2. Taskoperation 闭包会在 全局并发线程池 上调度
  3. 因为闭包与调用上下文脱离,所以即使内部是「同步」代码,也不会阻塞原线程
  4. 返回值通过 Task.result 转回 async let 所生成的 Future,最终由 await 解开

用伪代码还原编译器行为:

// 你写的
async let x = heavySyncJob(id: 1)

// 编译器大概帮你展开成
let __task_x = Task { heavySyncJob(id: 1) }   // 扔到后台
let x = await __task_x.value                  // 需要时等待

注意:这属于「语法糖」而非魔法,性能与你自己手写 Task {} 几乎一致;但代码量更少、结构化更强。

别忘了错误处理

如果同步函数会 throws,同样适用:

enum SomeError: Error { case zero }

func riskySync(_ n: Int) throws -> Int {
    guard n != 0 else { throw SomeError.zero }
    return n * 10
}

func testThrows() async {
    async let a = riskySync(5)   // 不会抛在此处
    async let b = riskySync(0)   // 同样不会立即抛
    do {
        let sum = try await a + b   // ❗️b 会在这里抛
        print("sum = \(sum)")
    } catch {
        print("捕获到错误:\(error)")
    }
}

实战建议与踩坑提醒

场景建议
大量 CPU 密集任务async let 可以并行,但别一次性开几百个;控制并发量可用 TaskGroup 或信号量
需要取消async let 会随作用域退出自动取消,但同步函数本身「不感知」取消。需要手动检查 Task.isCancelled 并提前返回
访问主线程资源同步函数里若操作 UI / CoreData 主队列对象,要先 await MainActor.run 切回来
单元测试@MainActor 测试方法里,async let 依旧会把闭包放到后台,注意线程断言

小结

  • async let 并非只能绑定 async 函数;任何表达式只要能最终产出 async 值就能用
  • 把「同步函数」直接塞进 async let,编译器会隐式生成 Task 并调度到全局线程池
  • 写法极简、结构化、易读,是「一行代码后台并行」的利器
  • 但仍要关注「取消、错误、线程安全」等传统并发问题

我的观点

  1. async let 的「同步函数调度」能力,让老项目渐进迁移到 Swift 并发变得异常丝滑:

    旧模块不改代码,就能被新并发代码调用,先享受「并行」红利,再逐步把函数标成 async

  2. 它本质上仍是「Task 语法糖」,所以不要滥用:

    • 长时运算应检查取消
    • 大量任务请用 TaskGroup 限制并发度
  3. 结合 actor / MainActor 可以做「线程安全+并行」的优雅架构;未来再配合 distributed actor 写后端也会更顺手