← 返回全部文章

Swift 并发中的取消:让 Task 真正停下来

Task.cancel() 只是发出协作式取消信号。本文从检查点、错误传播和底层请求清理三个层面,整理一套可维护的取消方案。

本文目录
  1. cancel() 实际做了什么
  2. 把检查点放在有意义的位置
  3. 清理底层资源
  4. 避免吞掉取消错误
  5. 与界面生命周期配合
  6. 一份实用检查清单

在 Swift Concurrency 中,取消不是强制终止。调用 task.cancel() 后,任务仍然可能继续执行,直到代码主动检查取消状态、某个异步 API 响应取消,或者任务自然结束。

这是一种协作式模型。它避免任务在任意一条指令上被突然中断,却也意味着取消逻辑必须作为正常控制流来设计。

cancel() 实际做了什么

下面的调用只会把任务标记为已取消:

let task = Task {
    await loadTimeline()
}

task.cancel()

如果 loadTimeline() 内部没有取消检查,而且调用的异步函数也不响应取消,这段工作依然会继续。可以把取消信号理解为一个由并发运行时维护的状态,而不是线程中断。

常用的检查方式有两个:

// 只读取状态,不抛出错误
if Task.isCancelled {
    return
}

// 已取消时抛出 CancellationError
try Task.checkCancellation()

当函数本身已经是 throws 时,优先使用 Task.checkCancellation(),这样取消可以沿着调用链自然传播。

把检查点放在有意义的位置

不需要在每一行代码前检查取消。合适的位置通常是:

  • 即将开始一段昂贵计算之前;
  • 循环处理一批数据的边界处;
  • 一个异步阶段完成、准备进入下一阶段时;
  • 准备提交 UI 或缓存结果之前。
func buildThumbnails(for images: [UIImage]) async throws -> [UIImage] {
    var thumbnails: [UIImage] = []
    thumbnails.reserveCapacity(images.count)

    for image in images {
        try Task.checkCancellation()
        thumbnails.append(resize(image))
        await Task.yield()
    }

    return thumbnails
}

Task.yield() 给其他任务一次调度机会,但它不是取消检查的替代品。真正决定函数是否结束的,仍然是显式检查或会响应取消的异步调用。

清理底层资源

有些旧式 API 通过回调工作,并不知道外层 Swift Task 已经取消。此时可以用取消处理器,把外层信号转交给底层对象。

func fetchData(using request: NetworkRequest) async throws -> Data {
    try await withTaskCancellationHandler {
        try await request.value()
    } onCancel: {
        request.cancel()
    }
}

onCancel 可能和操作并发发生,因此里面应只调用线程安全、可重复执行的清理方法。不要在这里更新 UIKit,也不要假设操作闭包已经走到某一个固定阶段。

避免吞掉取消错误

一个常见问题是把所有错误都转成空结果:

do {
    return try await loadContent()
} catch {
    return []
}

这样会把取消伪装成“加载成功但没有内容”。上层既无法区分状态,也可能继续执行缓存或界面更新。更合适的做法是保留取消语义:

do {
    return try await loadContent()
} catch is CancellationError {
    throw CancellationError()
} catch {
    logger.error("Load failed: \(error)")
    throw error
}

与界面生命周期配合

界面层通常持有一个可取消任务,并在新请求开始或页面离开时取消旧任务。

@MainActor
final class TimelineViewModel: ObservableObject {
    private var loadTask: Task<Void, Never>?

    func reload() {
        loadTask?.cancel()
        loadTask = Task { [weak self] in
            do {
                let value = try await repository.load()
                try Task.checkCancellation()
                self?.items = value
            } catch is CancellationError {
                // 预期控制流,不展示错误提示
            } catch {
                self?.error = error
            }
        }
    }

    deinit {
        loadTask?.cancel()
    }
}

最后一次取消检查很重要:网络请求结束到更新状态之间,任务仍然可能被取消。没有这一步,旧请求就可能覆盖新页面的数据。

一份实用检查清单

  1. 明确谁创建任务,谁负责取消任务;
  2. 在耗时阶段之间加入取消检查;
  3. 将取消信号传递到底层网络、文件或数据库操作;
  4. 不把 CancellationError 当成普通失败展示给用户;
  5. 提交界面与缓存结果前再次检查;
  6. 为快速连续刷新、页面退出等场景写测试。

取消不是异常收尾,而是并发任务的一条正常结束路径。把它纳入 API 设计后,页面切换、搜索联想和重复刷新都会更可预测。