Swift并发编程:async/await实战 —— 一个运维老狗的iOS并发踩坑记
上周五晚上十一点,我正窝在工位上一边啃着冷掉的黄焖鸡,一边盯着 Jenkins 上跑飞的 CI 流水线——又是某个 iOS 团队提了个“微小改动”,结果把整个 App 的网络层搞崩了。产品经理在 Slack 里疯狂@我:“线上用户打不开首页!是不是你们 DevOps 又改了什么配置?” 我翻了个白眼:兄弟,这回真不是我们锅,是你们自己写的异步回调嵌套八层,连 Swift 编译器都看不下去了。
作为在这家公司待了三年多的 DevOps 工程师(没错,就是那个天天跟 Ansible、K8s、Prometheus 打交道,Vim 快捷键比吃饭还熟的老运维),最近却被迫跨界研究起了 Swift 并发模型。原因?很简单——想跳槽。最近投了几家大厂,面试官张口就问:“说说 Swift 的 async/await 和 GCD 的区别?”、“你们项目里怎么处理 Task 取消和资源泄漏?”、“如果用 SwiftUI + async/await 做一个实时数据流页面,你会怎么设计?”
我当时就懵了:我可是写 Shell 脚本出身的啊!但转念一想,现在 DevOps 和 SRE 都要懂应用层逻辑了,尤其 Apple 生态越来越封闭,不懂点 iOS 开发,连日志都看不懂。于是,我硬着头皮啃文档、翻 WWDC 视频,还在公司内部拉了个“Swift 并发攻坚小组”(其实就是我和 iOS 组两个实习生一起 debug 到凌晨三点)。
今天这篇文章,就是我从一个运维视角,结合真实项目踩坑经验,手把手带你用 Swift 的 async/await 重构一个高并发、低延迟的 App 功能。别担心,我不是 iOS 专家,但正因为“外行”,我才更清楚哪些地方容易栽跟头。
为什么不用 GCD?因为回调地狱真的会死人
先说背景。我们 App 有个“个人中心”页面,要同时加载:
- 用户基本信息(调
/user/profile) - 最近订单列表(调
/orders/recent) - 推荐商品(调
/recommendations)
旧代码是这么写的(简化版):
func loadUserData() {
NetworkService.fetchProfile { profile in
DispatchQueue.main.async {
self.profile = profile
NetworkService.fetchOrders { orders in
DispatchQueue.main.async {
self.orders = orders
NetworkService.fetchRecommendations { recs in
DispatchQueue.main.async {
self.recommendations = recs
self.isLoading = false
}
}
}
}
}
}
}
看到没?三重回调嵌套,缩进都快贴到屏幕右边了。更惨的是,如果任何一个接口失败,整个链路就断了,而且没法并行请求——明明三个 API 互不依赖,却要串行等 3 秒。产品经理说“首屏要 1 秒内打开”,我心想:你行你上。
这时候,async/await 就成了救命稻草。它不是魔法,但能让你用同步的写法,写出异步的逻辑,而且天然支持结构化并发(Structured Concurrency)——这是 Apple 在 Swift 5.5 引入的核心特性。
重构第一步:把回调函数改成 async 函数
首先,得把底层网络层改造一下。假设你用的是 URLSession(没用 Alamofire,因为我们团队信奉“少引入一个依赖就少一个坑”),原来的回调式方法:
static func fetchProfile(completion: @escaping (Profile?) -> Void) {
let url = URL(string: "https://api.example.com/user/profile")!
URLSession.shared.dataTask(with: url) { data, _, _ in
let profile = try? JSONDecoder().decode(Profile.self, from: data!)
completion(profile)
}.resume()
}
改成 async throws 版本:
static func fetchProfile() async throws -> Profile {
let url = URL(string: "https://api.example.com/user/profile")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(Profile.self, from: data)
}
关键点:
async表示这个函数会挂起(suspend),不会阻塞主线程throws表示可能抛异常,调用方必须用try处理URLSession.shared.data(from:)是系统提供的 async 版本,不用自己包装
📌 运维视角吐槽:这种改造其实和我们在后端用 Springboot 写
CompletableFuture或 Kotlin 协程很像——都是把“回调地狱”变成“线性流程”。只不过 Swift 把并发模型直接集成到语言层面,不用像 Java 那样依赖框架。
实战:用 TaskGroup 实现真正的并行请求
现在,主逻辑可以重写了:
@MainActor
class UserProfileViewModel: ObservableObject {
@Published var profile: Profile?
@Published var orders: [Order] = []
@Published var recommendations: [Product] = []
@Published var isLoading = false
func loadData() async {
isLoading = true
defer { isLoading = false }
do {
// 三个请求并行执行!
async let profileTask = NetworkService.fetchProfile()
async let ordersTask = NetworkService.fetchOrders()
async let recsTask = NetworkService.fetchRecommendations()
// 等待所有结果
let profile = try await profileTask
let orders = try await ordersTask
let recs = try await recsTask
self.profile = profile
self.orders = orders
self.recommendations = recs
} catch {
print("Load failed: $error)")
// 这里可以加 Sentry 上报,运维最爱
}
}
}
注意几个细节:
@MainActor确保所有 UI 更新都在主线程(SwiftUI 要求)async let是 Swift 并发的黑科技——声明即启动,并行执行- 所有
await放在一起,逻辑清晰,还能自动处理错误传播
性能提升立竿见影:原本 3 秒的串行请求,现在 1.2 秒搞定(取决于最慢的那个 API)。App Store 审核时,苹果特别看重“启动速度”和“响应流畅度”,这种优化直接加分。
避坑指南:那些让我想砸键盘的并发 Bug
坑 1:忘记取消 Task,导致内存泄漏
有一次,用户快速切换页面,旧的 loadData() 还在后台跑,新页面又启动了新的请求。结果内存飙升,Xcode Instruments 一查,NetworkService 实例堆积如山。
解决方案:用 Task 显式管理生命周期。
class UserProfileView: View {
@StateObject private var viewModel = UserProfileViewModel()
@State private var loadDataTask: Task<Void, Never>? = nil
var body: some View {
// ...
.onAppear {
loadDataTask?.cancel() // 先取消旧任务
loadDataTask = Task {
await viewModel.loadData()
}
}
.onDisappear {
loadDataTask?.cancel()
}
}
}
💡 运维经验:这就像我们在 K8s 里用
preStophook 优雅终止 Pod 一样——资源必须显式释放,否则迟早 OOM。
坑 2:在非 MainActor 里更新 @Published 变量
Swift 5.7 开始严格检查 Actor 隔离。如果你在后台 Task 里直接写 self.profile = xxx,编译器会报错:
Mutation of captured var 'profile' in concurrently-executing code
正确做法:要么用 @MainActor 标注整个 ViewModel,要么在赋值前切回主线程:
await MainActor.run {
self.profile = profile
}
坑 3:测试时卡死——别在 XCTestCase 里直接 await
单元测试里这样写会 hang 住:
func testLoadData() async {
await viewModel.loadData() // ❌ 可能死锁
}
正确姿势:用 XCTestExpectation + Task 包裹:
func testLoadData() {
let expectation = XCTestExpectation(description: "Load complete")
Task {
await viewModel.loadData()
expectation.fulfill()
}
wait(for: [expectation], timeout: 5)
}
和 Springboot 对比?其实思路一脉相承
虽然我是搞后端运维的,但发现 Swift 并发和 Java 生态很像:
| 场景 | Swift | Springboot (Java) |
|---|---|---|
| 异步函数 | func foo() async throws -> T |
CompletableFuture<T> foo() |
| 并行执行 | async let a = ..., b = ... |
CompletableFuture.allOf(a, b) |
| 错误处理 | do-catch |
.exceptionally() |
| 主线程安全 | @MainActor |
@Async + 主线程回调 |
所以如果你熟悉 Springboot 的异步编程,学 Swift 并发会快很多。反过来,面试官问“Swift 并发模型和 Java 虚拟线程有什么异同?”,你也能侃两句——这可是高级工程师加分项。
上架 App Store 时的并发注意事项
Apple 对后台任务审核很严。如果你在 SceneDelegate 或 AppDelegate 里启动了长期运行的 Task,记得:
- 不要滥用
Task.detached:它脱离当前上下文,容易导致资源泄漏 - 网络请求必须可取消:审核指南明确要求“用户离开页面后应停止加载”
- 避免在后台刷新中做 heavy work:
BGProcessingTask有严格时间限制
我们之前有个版本因为没处理 Task 取消,被拒了两次。理由是:“App 在后台持续消耗 CPU”。后来加上 task.isCancelled 检查才过审。
func backgroundSync() async {
while !Task.isCancelled {
await fetchData()
try? await Task.sleep(nanoseconds: 5_000_000_000) // 5秒
}
}
总结:async/await 不是银弹,但值得拥抱
经过这次重构,我们 App 的首屏加载时间从 2.8s 降到 1.1s,Crash 率下降 40%(主要是因为不再乱 dispatch 到主线程了)。更重要的是,代码可读性爆炸提升——现在实习生都能看懂数据加载逻辑,再也不用问我“这个 completion block 是第几层了?”
作为一个 Vim 党、Shell 脚本狂魔,我曾经觉得 iOS 开发花里胡哨。但深入 Swift 并发后才发现,Apple 这次真的把工程化做到了极致:结构化并发 + Actor 模型 + 严格的编译期检查,几乎从根源上杜绝了竞态条件和回调地狱。
如果你也在准备跳槽,或者被产品经理逼着优化性能,async/await 绝对是必学技能。网上教程很多(推荐 WWDC21 的《Meet async/await in Swift》),但光看不行,一定要在真实项目里踩坑。就像我们 DevOps 说的:“没在生产环境炸过,不算真学会。”
最后送大家一句运维老狗的忠告:并发不是炫技,而是为了写出更稳定、更可维护的代码。毕竟,半夜三点被 PagerDuty 叫醒修 Bug 的,可是你自己。
(完)
P.S. 如果你正在找 DevOps 或 SRE 岗,且团队愿意让我偶尔写写 Swift,欢迎私信!我已经受够了每天解释“为什么 iOS 发版要等 QA 三天” 😅

评论 0