Swift并发编程:async/await实战踩坑记
大家好,我是刚从英国读完计算机硕士回国的“海龟”(不是海归,是真·海龟,毕竟回国后天天加班写代码,感觉自己快缩进壳里了)。坐标成都,每天骑共享单车上下班,偶尔在玉林路小酒馆附近找个咖啡店 coding。喜欢用 Mac 写代码——准确地说,我的 MacBook Pro 是主战场,Windows 仅用来测试兼容性问题(别问,问就是甲方爸爸还在用 IE11 的亲戚系统 😭)。
最近在疯狂折腾 Rust,觉得这门语言简直酷到没朋友。但现实很骨感:我目前在一家做数字藏品(NFT)的 startup 打工,主力语言还是 Swift,项目 deadline 压得人喘不过气。上周五晚上,产品经理突然甩过来一个需求:“咱们的区块链交易状态同步太慢了,用户点完‘购买’要等5秒才更新,能不能优化到1秒内?”
我当时心里一万个草泥马奔腾而过——这不就是典型的并发 IO 阻塞问题吗?老代码全是回调地狱(callback hell),嵌套三层都算轻的。更离谱的是,有些地方还混着 DispatchQueue.main.async 和 OperationQueue,连我自己看都头晕。
于是,我咬咬牙,决定把整个交易同步模块重构为 Swift 的 async/await 模式。本文就是这次重构过程中的血泪踩坑实录,希望能帮到正在被回调折磨的 iOS 同行们。
为什么非要用 async/await?
先说背景。我们的 App 需要频繁和链上交互:查余额、监听交易、验证签名……每个操作都要调用第三方节点 API(类似 Infura 或 Alchemy),而这些请求天然就是异步的。
以前是怎么写的?大概是这样:
NetworkService.fetchBalance { balance, error in
guard let balance = balance else {
print("Error: \(error?.localizedDescription ?? "unknown")")
return
}
DispatchQueue.main.async {
self.balanceLabel.text = "\(balance)"
NetworkService.fetchTransactionHistory { txs, err in
// 又一层回调……
}
}
}
看到没?这就是经典的 金字塔 of doom。而且一旦涉及错误处理、重试逻辑、多个并行请求,代码复杂度指数级上升。更要命的是,在区块链场景下,我们经常需要 等待多个交易确认(比如 ERC-721 转移 + 支付完成),传统方式只能靠计数器 or 信号量硬扛,极易出 bug。
去年双11期间,我们就因为一个未处理的 race condition,导致用户重复支付——还好金额不大,不然我可能现在就在派出所写检讨了 🥲。
所以,当我听说 Swift 5.5 引入了 async/await(Apple Silicon 发布那会儿的事了),我就知道:这玩意儿能救我狗命。
第一个坑:以为 async/await 是万能胶水
刚开始重构时,我天真地以为只要把函数改成 async throws,再加个 await 就完事了。结果运行直接 crash:
Main Thread Checker: UI updates must be on main thread!
啊对,差点忘了!在 async 函数里,虽然你可以用 await 等待网络请求,但返回后不一定在主线程。而 UIKit/SwiftUI 的 UI 更新必须在主线程。
解决方案其实很简单:用 Task { @MainActor in ... } 包裹 UI 更新代码,或者直接给 ViewModel 标记 @MainActor。
@MainActor
class TransactionViewModel: ObservableObject {
@Published var statusText: String = "Pending"
func syncTransaction() async {
do {
let tx = try await BlockchainAPI.fetchLatestTx()
// ✅ 这里自动在主线程,因为整个 class 被 @MainActor 修饰
self.statusText = tx.confirmed ? "Success" : "Processing"
} catch {
self.statusText = "Failed"
}
}
}
教训:async/await 不等于自动回到主线程。别被 JavaScript 的 Promise 给带偏了——JS 的 event loop 天然在单线程跑,但 Swift 的并发模型完全不同!
说到 JS,我之前做前端时就觉得 Promise + async/await 写起来贼爽。但 Swift 的实现更严格(也更安全),比如你不能在 non-isolated 上下文中直接访问 actor 的属性,编译器会直接报错。这点我反而觉得挺好,至少不会写出那种“只在周五下午 3 点崩溃”的玄学 bug。
第二个坑:老代码怎么平滑迁移?
我们项目里有大量基于 completionHandler 的旧接口。不可能一次性全改掉(老板:deadline 在即,求稳!)。所以得想办法桥接。
Swift 提供了 withCheckedThrowingContinuation,可以把回调风格转成 async/await:
extension NetworkService {
func fetchBalanceAsync() async throws -> Decimal {
return try await withCheckedThrowingContinuation { continuation in
fetchBalance { balance, error in
if let error = error {
continuation.resume(throwing: error)
} else if let balance = balance {
continuation.resume(returning: balance)
}
}
}
}
}
看起来完美?但实际用的时候,我发现一个问题:如果原回调在子线程触发,continuation 也会在那里 resume。虽然 Swift 并发模型会自动切换上下文,但如果后续逻辑依赖特定队列(比如某些 SDK 要求在 serial queue 调用),就容易翻车。
最佳实践:在 resume 前显式 dispatch 到预期队列,或者确保原回调总是在同一队列执行。
另外,千万别在 continuation 里做耗时操作!否则会阻塞底层任务调度器。我就因为在一个 continuation 里解析大 JSON,导致整个 App 卡顿——性能 profiler 一拉,发现 swift_task_resume 占了 80% CPU,当场石化。
第三个坑:并发任务怎么控制?
区块链场景有个典型需求:同时查询多个 NFT 的元数据。比如用户打开收藏夹,要加载 20 个 NFT 的图片和描述。
用传统方式,可能会开 20 个 URLSessionDataTask,然后靠计数器等全部完成。但这样无法限制并发数,容易打爆服务器 or 触发限流。
Swift 的 async/await 配合 TaskGroup 就特别香:
func fetchNFTMetadata(for ids: [String]) async throws -> [NFT] {
return try await withThrowingTaskGroup(of: NFT.self) { group in
// 控制最大并发数为 5
let semaphore = AsyncSemaphore(value: 5)
for id in ids {
group.addTask {
await semaphore.acquire()
defer { semaphore.release() }
return try await NFTAPI.fetchMetadata(id: id)
}
}
var results: [NFT] = []
for try await nft in group {
results.append(nft)
}
return results
}
}
这里我自定义了一个 AsyncSemaphore(基于 AsyncStream 实现),用来限制并发。虽然 Apple 官方还没提供内置的并发控制工具,但社区方案已经很成熟了。
对比一下不同方案的性能(本地模拟 20 个 API 调用,延迟 300ms):
| 方案 | 总耗时 | CPU 占用 | 代码可读性 |
|---|---|---|---|
| 串行回调 | ~6s | 低 | ❌ 地狱 |
| 并发回调(无限制) | ~300ms | 高 | ⚠️ 易出错 |
| TaskGroup + Semaphore | ~1.2s | 中 | ✅ 清晰 |
可以看到,合理控制并发既能保证速度,又避免资源耗尽。这在移动端尤其重要——用户可不想因为你的 App 耗光电量而在 App Store 给你一星。
第四个坑:测试怎么写?
以前用 XCTest 测试异步代码,得靠 XCTestExpectation:
func testFetchBalance() {
let expectation = XCTestExpectation()
NetworkService.fetchBalance { balance, _ in
XCTAssertEqual(balance, 100)
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
现在有了 async/await,XCTest 也支持 async 测试方法了!
func testFetchBalanceAsync() async throws {
let balance = try await NetworkService.fetchBalanceAsync()
XCTAssertEqual(balance, 100)
}
简洁到哭!但注意:测试 target 的 Deployment Target 必须 >= iOS 15,否则编译不过。我们团队就因为有人用 Xcode 13.2 + iOS 14 模拟器,导致 CI 全红,最后发现是测试配置问题……运维小哥差点把我挂墙上。
最后:上线后的效果 & 心得
重构完成后,交易状态同步时间从平均 4.8 秒降到 0.9 秒,App Store 收到一堆“终于不卡了”的好评(虽然可能只是心理作用 😅)。更重要的是,代码可维护性大幅提升——新来的实习生都能看懂数据流,不用再问我“这个 completion block 到底在哪一层”。
几点开发心得总结:
- 不要为了用新特性而用:async/await 适合 IO 密集型任务(网络、文件),但如果是纯计算,还是用 GCD 更合适。
- 善用结构化并发:
TaskGroup、async let能让你的并发逻辑更清晰,避免手动管理任务生命周期。 - 警惕线程陷阱:即使用了 async/await,也要时刻记住“谁在哪个线程执行”。Swift 的 actor 模型是利器,但理解成本不低。
- 区块链 ≠ 高并发借口:很多开发者以为链上操作天然慢,就放任代码烂下去。其实通过合理并发设计,体验完全可以接近 Web2 应用。
顺便吐槽一句:产品经理上周又提了个需求,说要集成钱包连接(WalletConnect)。我看了看文档,好家伙,又是 callback 嵌套……不过这次我不慌了,因为我有 async/await 护体!
如果你也在成都搞 iOS 开发,或者对 Rust + 区块链感兴趣,欢迎来玉林路找我喝杯咖啡(我请,只要别让我再写回调了🙏)。
Happy coding, and may your tasks never deadlock!

评论 0