Swift并发编程:async/await实战踩坑记

清新的学者
2025-12-15 16:34
阅读 544

大家好,我是刚从英国读完计算机硕士回国的“海龟”(不是海归,是真·海龟,毕竟回国后天天加班写代码,感觉自己快缩进壳里了)。坐标成都,每天骑共享单车上下班,偶尔在玉林路小酒馆附近找个咖啡店 coding。喜欢用 Mac 写代码——准确地说,我的 MacBook Pro 是主战场,Windows 仅用来测试兼容性问题(别问,问就是甲方爸爸还在用 IE11 的亲戚系统 😭)。

最近在疯狂折腾 Rust,觉得这门语言简直酷到没朋友。但现实很骨感:我目前在一家做数字藏品(NFT)的 startup 打工,主力语言还是 Swift,项目 deadline 压得人喘不过气。上周五晚上,产品经理突然甩过来一个需求:“咱们的区块链交易状态同步太慢了,用户点完‘购买’要等5秒才更新,能不能优化到1秒内?”

我当时心里一万个草泥马奔腾而过——这不就是典型的并发 IO 阻塞问题吗?老代码全是回调地狱(callback hell),嵌套三层都算轻的。更离谱的是,有些地方还混着 DispatchQueue.main.asyncOperationQueue,连我自己看都头晕。

于是,我咬咬牙,决定把整个交易同步模块重构为 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 到底在哪一层”。

几点开发心得总结:

  1. 不要为了用新特性而用:async/await 适合 IO 密集型任务(网络、文件),但如果是纯计算,还是用 GCD 更合适。
  2. 善用结构化并发TaskGroupasync let 能让你的并发逻辑更清晰,避免手动管理任务生命周期。
  3. 警惕线程陷阱:即使用了 async/await,也要时刻记住“谁在哪个线程执行”。Swift 的 actor 模型是利器,但理解成本不低。
  4. 区块链 ≠ 高并发借口:很多开发者以为链上操作天然慢,就放任代码烂下去。其实通过合理并发设计,体验完全可以接近 Web2 应用。

顺便吐槽一句:产品经理上周又提了个需求,说要集成钱包连接(WalletConnect)。我看了看文档,好家伙,又是 callback 嵌套……不过这次我不慌了,因为我有 async/await 护体!


如果你也在成都搞 iOS 开发,或者对 Rust + 区块链感兴趣,欢迎来玉林路找我喝杯咖啡(我请,只要别让我再写回调了🙏)。

Happy coding, and may your tasks never deadlock!

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝