返回首页

Swift Concurrency 系列 06|Swift 并发里的常见问题:竞态、重复请求与状态错乱

真正麻烦的是这些问题在业务里通常表现成偶发错乱而不是明确崩溃

并发 bug 最折磨人的地方,是它经常不像 bug。

它在线上更常表现成这些模糊问题:

  • 用户说“有时候会闪一下”
  • 测试说“偶尔会出现旧数据”
  • 产品说“我刚切了筛选,怎么又跳回去了”
  • 日志里看不到明确崩溃,但页面状态就是不对

也就是说,很多并发问题的外观更像“偶发业务异常”,而不是“技术上明显坏了”。

所以这篇文章我不想只讲术语定义,而是直接围绕一个更真实的列表页场景,把最常见的三类问题拆开:

  • 竞态
  • 重复请求
  • 状态错乱

以及它们在真实代码里是怎么长出来的。

一、先看一个真实得不能再真实的页面

假设有一个文章列表页,支持这些操作:

  • 页面首次进入自动加载
  • 下拉刷新
  • 切换分类
  • 输入关键词搜索
  • 点击“重试”

很多项目一开始都是这么写出来的:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published var items: [Article] = []
  @Published var isLoading = false
  @Published var errorMessage: String?
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  let repository: ArticlesRepository

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func onAppear() {
    Task {
      await load()
    }
  }

  func refresh() {
    Task {
      await load()
    }
  }

  func retry() {
    Task {
      await load()
    }
  }

  func categoryChanged(to value: String) {
    selectedCategory = value
    Task {
      await load()
    }
  }

  func keywordChanged(to value: String) {
    keyword = value
    Task {
      await load()
    }
  }

  func load() async {
    isLoading = true
    errorMessage = nil

    do {
      items = try await repository.fetchArticles(
        category: selectedCategory,
        keyword: keyword
      )
    } catch {
      errorMessage = error.localizedDescription
    }

    isLoading = false
  }
}

这段代码刚写出来时,通常大家都会觉得“挺顺的”:

  • async/await
  • 代码很直
  • 每个入口都能工作

但只要页面真实使用起来,很快就会长出并发问题。

二、第一类问题:竞态是默认了一个不存在的顺序

还是这段代码。 它最核心的问题不在于开了很多 Task,而在于它默认这些事会按如果想的顺序发生:

  • 先发出的请求先回来
  • 旧请求回来时,当前筛选条件还没变
  • loading 的开始和结束总能一一对应

但异步系统并不会替团队保证这些顺序。

比如用户操作如下:

  1. 进入页面,请求 A 发出
  2. 立刻切到“iOS”分类,请求 B 发出
  3. 又输入关键词 swift,请求 C 发出

此时如果返回顺序是:

  1. C 先回来
  2. A 后回来
  3. B 最后回来

那按当前代码,三个结果都会改 items。 也就是说,最终页面展示什么,取决于谁最后回来,而不是谁对应当前用户意图

这就是最典型的竞态:

代码偷偷依赖了顺序,但顺序根本没被约束。

三、第二类问题:重复请求的根源通常是入口没有收口

看上面的 ViewModel,会触发 load() 的入口至少有五个:

  • onAppear
  • refresh
  • retry
  • categoryChanged
  • keywordChanged

每个入口都各自开一个 Task。 从语法角度这当然合法,但从工程角度看,它意味着:

  • 同类任务没有统一调度点
  • 没有人知道当前是不是已经有一个同类任务在跑
  • 新任务出现时,旧任务没有明确命运

于是“重复请求”就不再是偶发,而是结构上的自然产物。

所以在并发治理里,我很少问:

“为什么这里多发了一次请求?”

我更常问:

“同一类任务到底有几个入口,它们之间有没有替换关系?”

这两个问题答不出来,重复请求几乎是必然的。

四、第三类问题:状态错乱,往往是因为过期结果仍然有写入资格

常见的情况是觉得只要请求成功返回,结果就应该被接纳。

这在同步系统里通常没问题,在并发系统里却经常是错的。

因为并发场景里最关键的问题是:

这个结果现在还算不算当前页面的有效结果?

例如:

  • 当前页面已经切到 keyword = "swift"
  • 结果却来自旧请求 keyword = ""

这个结果是真的、成功的、格式也对,但它已经过期了。 如果仍然允许它写 UI,状态就一定会错。

所以并发系统里,“结果正确” 和 “结果有效” 是两回事。 很多页面问题表面上像是结果错了,实际更接近没判断它现在还有没有资格落地。

五、先别急着上复杂工具,第一步先把同类任务收口

上面这段代码最需要的,是先做一件很朴素的事:

给同类任务一个统一入口。

比如先把列表加载收口成这样:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published private(set) var state: ViewState = .idle
  @Published private(set) var items: [Article] = []
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  private let repository: ArticlesRepository
  private var loadTask: Task<Void, Never>?

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func reload() {
    let request = RequestContext(
      category: selectedCategory,
      keyword: keyword
    )

    loadTask?.cancel()
    loadTask = Task {
      await performLoad(request: request)
    }
  }

  private func performLoad(request: RequestContext) async {
    state = .loading

    do {
      let result = try await repository.fetchArticles(
        category: request.category,
        keyword: request.keyword
      )

      guard !Task.isCancelled else { return }
      guard request.category == selectedCategory,
         request.keyword == keyword else { return }

      items = result
      state = .loaded
    } catch is CancellationError {
      // 取消不更新页面
    } catch {
      guard !Task.isCancelled else { return }
      state = .failed(error.localizedDescription)
    }
  }
}

这段代码做了几件非常关键的事:

  • 同类加载任务只有一个持有点 loadTask
  • 新任务到来时,旧任务会先取消
  • 发请求时把“当前上下文”冻结成 RequestContext
  • 结果回来后会校验它是否仍然对应当前页面

注意,这里真正重要的是任务关系开始变清楚了。

六、“冻结请求上下文”这么关键

很多并发文章讲任务取消,却不够强调“上下文快照”这件事。 但在页面业务里,它非常重要。

比如请求时用的是:

  • selectedCategory = "ios"
  • keyword = "swift"

那这两个值就不应该在请求飞出去之后还动态读取当前 ViewModel 上的最新值。 否则往往会得到一种非常奇怪的状态:

  • 发请求时是一组参数
  • 校验结果时却用了另一组参数

所以一个很实用的原则是:

发起异步任务时,把任务真正依赖的业务上下文冻结下来。

这样后面判断“这个结果还算不算当前结果”时,才有清晰依据。

七、很多并发 bug,最后都是“状态写入口太多”

常见的情况是一遇到并发问题,就会立刻想到:

  • 要不要加锁
  • 要不要上 Actor
  • 要不要切换线程

这些有时当然重要,但在页面层场景里,更常见的问题其实是:

  • 太多地方都能写 items
  • 太多地方都能改 isLoading
  • 太多入口都能直接发请求

一旦状态写入口散掉,即使没有真正的数据竞争,也会出现“组合起来就是错的”现象。

所以我做这类排查时,通常先问的是:

  • 哪些代码有权改这份状态
  • 哪些任务有权结束当前 loading
  • 哪些结果有权覆盖当前列表

这些问题一旦没被收口,并发 bug 通常只是时间问题。

八、一个更接近真实项目的演进顺序

如果真的想把这类问题治住,我建议按这个顺序演进,而不是一上来就引入太多机制:

1. 收口同类任务入口

先让“列表加载”只有一个统一入口,而不是每个 UI 事件都自己发请求。

2. 明确任务替换关系

哪些任务应该并发,哪些应该取消旧任务,只保留最后一次。

3. 冻结请求上下文

把发请求时依赖的关键业务参数收成一个明确对象。

4. 给结果加有效性判断

不是所有成功返回的结果,都有资格改当前页面。

5. 最后再考虑更复杂的共享状态隔离

比如跨页面共享缓存、跨模块资源协调,这时再看 Actor、统一协调器等方案。

这个顺序更稳,因为它先解决的是业务并发关系,而不是先引入更复杂的技术词汇。

九、结论:大多数业务并发问题,本质都是“任务关系没建模”

竞态、重复请求、状态错乱看起来是三个问题,实际根源经常非常接近:

  • 谁和谁是同类任务,没有建模
  • 新任务来了,旧任务怎么办,没有建模
  • 结果是不是还有效,没有建模
  • 哪些地方能写状态,没有收口

所以换个更短的说法这篇文章,我会说:

大多数业务里的并发问题,表面上像是不会并发语法,实际更接近没有把任务关系、结果有效性和状态写权限明确建模出来。

一旦这三件事开始清楚,很多“偶发错乱”会比如果想的更容易消失。