返回文章列表

Swift Concurrency 系列 04|什么时候该用 Task,什么时候不该乱开 Task?

Task 不是异步代码的万能入口,真正关键的是谁创建它、谁取消它、谁对结果负责

很多团队第一次大规模接触 Swift Concurrency 时,最容易养成的坏习惯不是滥用 async/await,而是滥用 Task

因为它真的太方便了。

你在按钮回调里不能直接 await,那就写一个 Task。你在 UIKit 代理方法里不能直接 await,那就写一个 Task。你在某个同步方法里想偷渡到异步世界,最顺手的还是写一个 Task

久而久之,Task 就会从“桥接同步和异步的入口”,变成“哪儿不顺手就包一层的并发胶带”。

这篇文章真正想回答的不是“Task 能不能用”,而是三个更接近工程的问题:

  1. 它到底解决的是哪一类问题。
  2. 什么场景下它是自然选择,什么场景下只是把结构问题藏起来。
  3. 当你决定开一个 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 的结果
  • 用户退出页面后,回调还在写状态
  • loadingisRefreshingerror 之间互相打架

这个阶段很多人会误以为自己遇到的是“并发很复杂”。

其实问题更具体:任务入口太分散,状态变更没有统一收口。

更稳的做法通常是把“开什么任务”集中到一个状态对象里,而不是让视图层四处 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 不是异步代码的万能入口,而是任务生命周期的显式声明。

当你把它当声明来用,代码会越来越清楚。
当你把它当补丁来用,代码迟早会变成“每一层都能跑,但没人说得清为什么这样跑”。