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 的开始和结束总能一一对应
但异步系统并不会替你保证这些顺序。
比如用户操作如下:
- 进入页面,请求 A 发出
- 立刻切到“iOS”分类,请求 B 发出
- 又输入关键词
swift,请求 C 发出
此时如果返回顺序是:
- C 先回来
- A 后回来
- B 最后回来
那按当前代码,三个结果都会改 items。
也就是说,最终页面展示什么,取决于谁最后回来,而不是谁对应当前用户意图。
这就是最典型的竞态:
代码偷偷依赖了顺序,但顺序根本没被约束。
三、第二类问题:重复请求的根源通常不是“手滑点了两次”,而是入口没有收口
看上面的 ViewModel,会触发 load() 的入口至少有五个:
onAppearrefreshretrycategoryChangedkeywordChanged
每个入口都各自开一个 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、统一协调器等方案。
这个顺序更稳,因为它先解决的是业务并发关系,而不是先引入更复杂的技术词汇。
九、结论:大多数业务并发问题,本质都是“任务关系没建模”
竞态、重复请求、状态错乱看起来是三个问题,实际根源经常非常接近:
- 谁和谁是同类任务,没有建模
- 新任务来了,旧任务怎么办,没有建模
- 结果是不是还有效,没有建模
- 哪些地方能写状态,没有收口
所以如果只用一句话总结这篇文章,我会说:
大多数业务里的并发问题,不是因为你不会并发语法,而是因为你没有把任务关系、结果有效性和状态写权限明确建模出来。
一旦这三件事开始清楚,很多“偶发错乱”会比你想的更容易消失。