返回文章列表

Swift Concurrency 系列 06|如何避免常见并发问题:竞态、重复请求、状态错乱

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

并发 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,状态就一定会错。

所以并发系统里,“结果正确” 和 “结果有效” 是两回事。
很多页面问题不是因为结果错了,而是因为你没判断它现在还有没有资格落地。

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

上面这段代码最需要的,不是立刻加一堆锁或 Actor,而是先做一件很朴素的事:

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

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

@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、统一协调器等方案。

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

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

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

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

所以如果只用一句话总结这篇文章,我会说:

大多数业务里的并发问题,不是因为你不会并发语法,而是因为你没有把任务关系、结果有效性和状态写权限明确建模出来。

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