Swift Concurrency 系列 04|什么时候该用 Task,什么时候不该乱开 Task?
Task 不是异步代码的万能入口,真正关键的是谁创建它、谁取消它、谁对结果负责
很多团队第一次大规模接触 Swift Concurrency 时,最容易养成的坏习惯不是滥用 async/await,而是滥用 Task。
因为它真的太方便了。
你在按钮回调里不能直接 await,那就写一个 Task。你在 UIKit 代理方法里不能直接 await,那就写一个 Task。你在某个同步方法里想偷渡到异步世界,最顺手的还是写一个 Task。
久而久之,Task 就会从“桥接同步和异步的入口”,变成“哪儿不顺手就包一层的并发胶带”。
这篇文章真正想回答的不是“Task 能不能用”,而是三个更接近工程的问题:
- 它到底解决的是哪一类问题。
- 什么场景下它是自然选择,什么场景下只是把结构问题藏起来。
- 当你决定开一个
Task时,应该先问自己哪些问题。
一、先把定位讲清楚:Task 是异步任务的创建点,不是流程设计工具
很多人看见 Task {},脑子里想到的是“异步执行一段代码”。
这个理解不算错,但还不够。
更准确地说,Task 做的事情是:
- 创建一个新的并发任务
- 让一段代码进入异步上下文
- 把执行、取消、优先级、结果等责任绑定到这个任务上
所以 Task 从来不只是“把代码丢后台跑一下”。
你一旦写下它,其实同时做了几个决定:
- 这段工作现在开始独立存在
- 它可能晚于当前调用点结束
- 它可能被取消,也可能没被取消
- 它的结果要么被消费,要么被丢弃
- 它和当前对象、当前页面、当前用户动作之间建立了某种关系
这就是为什么 Task 不能只从语法上理解。
语法上它只是一个代码块,工程上它意味着“任务生命周期被创建出来了”。
二、Task 最适合的场景:你确实需要从同步世界进入异步世界
Task 最自然的使用场景,其实非常朴素:
当前上下文不是
async,但你就是需要触发一个异步流程。
比如这几类情况。
1. 用户交互回调
Button("保存") {
Task {
await viewModel.save()
}
}
这里用 Task 很合理,因为 Button 的 action 本身不是 async,但保存动作显然是异步流程。
2. UIKit / AppKit 的同步代理方法
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
代理回调签名是框架决定的,不是你能改成 async 的。要进入异步流程,就需要一个桥接点。
3. 应用生命周期或通知回调
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
这里 Task 的价值依然是一样的:把一个同步事件转成异步任务。
如果你把这些例子放在一起看,会发现一个共同点:
- 事件来自同步 API
- 业务处理希望是异步的
Task只是入口,不是主体
这时候 Task 是好工具。
三、真正危险的地方:把 Task 当成“哪里报错就补哪里”的修复方式
团队里最常见的问题不是“不会用 Task”,而是“用得太自然”。
最典型的几种错误姿势是这样的。
1. 编译器不让 await,就包一层 Task
func refresh() {
Task {
await loadData()
}
}
这段代码单独看未必错。问题在于,很多时候你其实完全可以把 refresh() 本身设计成 async,然后由上层决定什么时候调用。
一旦默认思路变成“不能 await 就开 Task”,你就会失去对任务边界的控制。
2. 已经在 async 函数里,还要再套一个 Task
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
这类代码的问题不是“能不能跑”,而是它把原本属于一个函数内部的控制流拆散了。
你马上会遇到几个问题:
- 谁保证这两个任务的结束顺序。
- 失败时怎么统一处理。
- 调用方怎么知道整个
loadPage()什么时候真正完成。 - 如果外层任务取消了,这两个子任务会不会一起停。
如果你的本意是并行执行,通常更清楚的写法是 async let 或 task group,而不是再额外创建两个不透明的 Task。
3. 遇到状态竞争,就想靠多开几个 Task 来“错峰”
有些代码会写成这样:
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
表面上看像是把事情拆开了,实际上是把状态一致性直接交给运气。
你不知道第三个任务会不会先于第二个完成,也不知道取消发生时界面会停在哪个状态。
这类问题的根源通常不是“任务开得不够多”,而是本来应该串起来的状态流被拆碎了。
四、判断该不该开一个 Task,先问这四个问题
这是我觉得最有用的一组工程检查表。比背语法有用得多。
1. 这个任务是谁创建的
是按钮点击创建的?页面出现时创建的?ViewModel 初始化时创建的?还是某个 service 层偷偷创建的?
如果你回答不清楚“谁创建了它”,后面就几乎不可能理清“谁应该取消它”。
2. 这个任务归谁持有
如果任务只是 fire-and-forget,那通常意味着没有人真正管理它。
但很多业务并不适合 fire-and-forget。
比如搜索、分页、保存、上传、轮询,这些任务往往都应该被某个对象明确持有:
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
这里真正有价值的不是“用了 Task”,而是:
- 同类任务只有一个入口
- 新任务出现时,旧任务会被取消
- 任务归属在
SearchViewModel
没有“归属”的 Task,通常后期都会变成幽灵任务。
3. 如果用户离开页面,它还应不应该继续跑
这个问题特别重要,因为它直接决定任务生命周期该绑定到哪里。
例如:
- 页面首屏请求:用户离开页面后通常不必继续
- 订单提交:即使页面关闭,也可能需要继续完成
- 图片预取:优先级可以很低,而且离开页面就该取消
不同类型的任务,对应的设计完全不一样。
如果你没有先回答这个问题,就很容易把所有任务都写成一个样子:
Task {
await doSomething()
}
表面统一,实际上语义全乱了。
4. 这个任务的结果由谁消费
有些任务的结果会回写 UI,有些任务的结果要更新缓存,有些任务只是打点上报。
如果结果没有明确去向,通常就会出现两类坏味道:
- 任务开了,但错误没人接
- 任务跑完了,但结果没人用
这也是为什么我不太喜欢无节制的 fire-and-forget。不是它绝对不能用,而是大多数业务任务都不是“发出去就算了”。
五、已经在 async 世界里时,优先考虑结构化并发,而不是额外创建 Task
这是很多文章讲得不够实的一点。
你已经在 async 函数里,就说明你已经拥有了异步控制流。此时再写额外的 Task,往往是在绕开结构化并发给你的约束。
看两个对比。
错误倾向:用多个 Task 硬拆并行
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
这段代码不是完全错,但它表达得不够直接。因为调用方看到的是“我主动创建了三个任务”,而不是“这里有三段并行依赖”。
更好的表达:async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
这类写法的优势是语义更清楚:
- 这些工作属于当前函数
- 它们和当前调用链在一个结构里
- 当前函数结束前会等待结果
- 外层取消时,内部并发也跟着取消
也就是说,Task 和结构化并发的差别,核心不是“都能并发”,而是谁对生命周期负责。
六、页面层最常见的灾难:每个入口都开一个 Task
拿一个很真实的列表页来说,通常会有这些触发点:
- 首次进入页面加载
- 下拉刷新
- 搜索关键字变化
- 切换筛选条件
- 点击“重试”
- 翻到下一页自动加载更多
如果每个入口都自己写:
Task {
await load()
}
那一两个迭代之后,页面大概率会出现这些现象:
- 多个请求同时飞出去
- 旧结果覆盖新结果
- 明明最新关键字是
swift,界面却显示了swi的结果 - 用户退出页面后,回调还在写状态
loading、isRefreshing、error之间互相打架
这个阶段很多人会误以为自己遇到的是“并发很复杂”。
其实问题更具体:任务入口太分散,状态变更没有统一收口。
更稳的做法通常是把“开什么任务”集中到一个状态对象里,而不是让视图层四处 new 任务。
例如:
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
这段代码真正解决的问题有三个:
- 同类任务有唯一入口
- 同类任务之间的替换关系明确
- 状态回写集中在一处
这里 Task 依然用了,但它已经不是“哪儿方便写哪儿”,而是“任务管理的一个受控入口”。
七、什么时候适合持有 Task 引用,什么时候不需要
这也是判断代码成熟度的一个信号。
适合持有引用的场景
- 搜索输入防抖
- 页面刷新任务
- 可以被重复触发且新任务应该替换旧任务的请求
- 轮询、监听、长时间运行的同步过程
因为这些场景都天然涉及取消或替换。
不一定要持有引用的场景
- 用户点击一次后只做一次的短任务
- 明确就是 fire-and-forget 的埋点、日志、缓存清理
- 生命周期已经被外层框架替你管理的任务
重点不在“持不持有一定更高级”,而在于任务有没有被正确治理。
如果一个任务可能被取消、可能被替换、可能影响用户可见状态,那大概率就不该当匿名烟花放出去。
八、Task.detached 不是 Task 的增强版,而是更强的隔离声明
虽然这篇主要讲 Task,但很多团队在乱开 Task 之后,很快又会进一步乱用 Task.detached。
这个要顺手提醒一句:
Task {}会继承一部分当前上下文Task.detached {}则更像“从当前上下文剥离出去单独跑”
所以如果你连普通 Task 的归属和取消都没理顺,就更不该用 detached 去放大自由度。
很多 Task.detached 最后都不是性能优化,而是责任逃逸。
九、一个实用判断标准:你是在创建任务,还是在逃避建模
这是我自己在 review 里最常问的一句话。
当你准备写:
Task {
...
}
先停两秒,问自己:
- 我是在创建一个有明确生命周期的任务?
- 还是只是因为这里改成
async太麻烦,所以临时包一层? - 我知道它什么时候结束、被谁取消、结果给谁吗?
如果这些问题都答不上来,那多数情况下不是 Task 这个 API 有问题,而是当前抽象层次还没理顺。
十、结论:Task 值得常用,但不值得随手用
Task 在 Swift Concurrency 里当然重要,而且你会经常用到它。
但它的正确价值不是“让我哪儿都能异步”,而是:
- 在同步入口处,安全地进入异步流程
- 在需要独立生命周期时,显式创建任务
- 在需要取消、替换、隔离时,提供明确的并发边界
所以我更愿意这样理解它:
Task不是异步代码的万能入口,而是任务生命周期的显式声明。
当你把它当声明来用,代码会越来越清楚。
当你把它当补丁来用,代码迟早会变成“每一层都能跑,但没人说得清为什么这样跑”。