4、任务(Task)基础:Task类型、创建异步任务、任务优先级、任务取消机制

好,咱们今天来聊聊 Swift 并发里最基础、也最核心的东西——Task

说实话,我刚接触 Swift 并发那会儿,第一反应是:这不就是 GCD 的升级版吗?用着用着才发现,差远了。Task 不仅仅是「开个后台线程干活」,它背后有一套完整的任务管理机制。你想想看,一个异步任务从创建到取消,中间涉及的东西其实挺多的。

我个人习惯把 Task 理解成「Swift 并发世界里的基本执行单元」。就像函数是同步世界的基本单元一样,Task 就是异步世界里的那个「最小工作包」。

Task 类型:结构化 vs 非结构化

Swift 里的 Task 分两种:结构化任务非结构化任务。这俩概念刚接触时容易懵,我当年也绕了一阵子。

结构化任务,说白了就是有爹有妈的任务。它必须在一个已有的异步上下文中创建,并且它的生命周期和父任务绑定在一起。父任务取消了,子任务也跟着取消。父任务等子任务执行完,自己才能结束。

举个例子:

func fetchUserData() async -> User {
    // 这里是在异步上下文中
    let profile = await fetchProfile()   // 子任务
    let settings = await fetchSettings() // 子任务
    return User(profile: profile, settings: settings)
}

这种写法就是结构化并发。fetchProfile 和 fetchSettings 的生命周期完全由 fetchUserData 管理。父任务没结束,子任务跑不完?那就等着。

非结构化任务就不一样了。它用 Task.init 或 Task.detached 创建,不依赖任何父任务。说白了就是「我自己想跑就跑,谁也管不着」。

Task {
    // 非结构化任务
    let result = await someAsyncFunction()
    print(result)
}

我在项目中遇到过一个问题:用 Task.detached 创建了一个后台下载任务,结果用户退出页面后任务还在跑,白白浪费流量。后来我改成结构化任务,配合父任务的取消机制,问题就解决了。

注意:非结构化任务不会自动继承父任务的优先级和 Actor 上下文。如果你需要任务和当前上下文保持关联,用 Task.init 而不是 Task.detached。

创建异步任务:几种常见姿势

创建 Task 的方式其实就那么几种,但每种都有自己的适用场景。

创建方式 说明 典型场景
Task { } 创建非结构化任务,继承当前 Actor 的上下文 在同步函数中启动异步操作
Task.detached { } 创建独立任务,不继承上下文 后台计算、不关心调用者状态的任务
async let 创建结构化子任务 并发执行多个独立异步操作
TaskGroup 动态创建一组结构化子任务 不确定数量的并发任务

我个人最常用的是 async let 和 TaskGroup。为什么呢?因为它们自带结构化特性,生命周期清晰,不容易出幺蛾子。

// async let 的典型用法
async let user = fetchUser()
async let posts = fetchPosts()
let (loadedUser, loadedPosts) = await (user, posts)

你看,两行代码就搞定了并发请求。而且如果父任务被取消了,这两个子任务也会自动取消。省心。

小技巧:如果你需要并发执行的任务数量是固定的,用 async let。如果数量是动态的(比如从数组里遍历出来的),用 TaskGroup。

任务优先级:别什么都往高优先级塞

任务优先级这个东西,说白了就是告诉系统:「这个任务比较重要,你先处理它」。

Swift 提供了几个优先级等级:

  • high:用户交互相关的任务,比如 UI 更新
  • default:默认优先级,大部分任务用这个就行
  • low:后台预加载、日志上传等不紧急的任务
  • background:最低优先级,比如数据同步、备份
Task(priority: .high) {
    await updateUI()
}

Task(priority: .background) {
    await syncDataToServer()
}

我曾经犯过一个错误:把所有网络请求都设成 high 优先级。结果呢?用户滑动列表时卡得要命,因为 UI 更新和网络请求在抢资源。后来我学乖了,只有用户「正在看」的内容才用 high,预加载的数据一律 low 或 background。

注意:优先级继承在结构化任务中会自动发生。子任务默认继承父任务的优先级。但 Task.detached 不会继承,你需要手动指定。

任务取消机制:优雅地停下来

任务取消,嗯,这是个容易踩坑的地方。

Swift 的 Task 取消是协作式的。什么意思呢?就是说系统不会强行杀掉你的任务,而是给你发一个「取消信号」,你自己决定要不要响应。

func fetchData() async throws -> Data {
    // 检查是否被取消
    try Task.checkCancellation()
    
    // 或者用更温和的方式
    if Task.isCancelled {
        // 做一些清理工作
        return defaultData
    }
    
    // 继续执行...
}

我个人习惯在长时间运行的任务里,每隔一段逻辑就检查一下取消状态。比如下载大文件时,每下载 1MB 就检查一次:

func downloadLargeFile() async throws {
    for chunk in fileChunks {
        // 每次处理一个块之前检查
        try Task.checkCancellation()
        
        // 下载这个块
        await downloadChunk(chunk)
    }
}

如果不检查取消状态,任务会一直跑到完。即使用户取消了操作,后台还在傻乎乎地干活。浪费资源不说,还可能引发奇怪的问题。

核心要点:Task 取消不是强制的。你必须主动检查 Task.isCancelled 或调用 Task.checkCancellation(),才能让取消生效。

还有一个细节:withTaskCancellationHandler。这个函数可以让你注册一个取消时的回调,适合做一些清理工作:

func performTask() async {
    await withTaskCancellationHandler {
        // 主要任务逻辑
        for i in 0..<100 {
            try? await Task.sleep(nanoseconds: 1_000_000_000)
            print("Working: \(i)")
        }
    } onCancel: {
        // 取消时的清理工作
        print("Task was cancelled, cleaning up...")
    }
}

这个我在项目里用过一次:用户取消图片上传时,需要删除已经上传到服务器的部分文件。onCancel 回调里正好做这个事。

建议:如果你的任务涉及资源管理(文件句柄、网络连接、数据库事务),一定要处理取消时的清理。否则容易造成资源泄漏。

好了,Task 的基础知识就这些。总结一下:

  • 结构化任务有父任务管理,生命周期清晰
  • 非结构化任务自由度高,但要自己负责生命周期
  • 优先级别滥用,高优先级留给用户交互
  • 取消是协作式的,记得主动检查

下一章咱们聊聊 Actor,那玩意儿才是 Swift 并发里真正有意思的东西。