Swift 隔离式并发实践:actor、Global Actor 与 Sendable

1. 为什么 async/await 不能解决并发问题

Swift Concurrency 带来的第一个直观变化,是 async/await 让异步调用读起来像同步代码。之前一长串回调嵌套,现在可以按顺序写清楚:先请求网络,再解析,再更新状态。但在真实项目里,异步“写起来舒服”只是表面,很多时候真正的痛点不在于“怎么写异步逻辑”,而在于“多个任务同时访问同一份状态时发生的问题”。线程变多、任务变多、入口变多之后,只要可变状态没有明确的限制,就可能带来问题。

获取服务端配置就是一个非常典型的并发场景:有本地缓存,有后台刷新,有多处调用,有UI依赖结果,还有可能叠加服务端推送。表面上只是“拉一份配置下来”,但内部状态在多个任务之间来回穿插,如果不做隔离,async/await 并不能有效处理这些逻辑。

1.1 只有 async/await,并不能保证配置状态安全

我们从最原始的方式出发,常见写法往往是先声明一份全局缓存:

1
2
3
4
5
6
7
8
9
10
11
var config: [String: Any] = [:]

// 后台任务在某个时机写入
Task {
config["featureA"] = true
}

// UI在显示时读取
Task {
let value = config["featureA"]
}

表面上已经完全进入 Swift Concurrency 语境,没有回调地狱,全部换成了 Task 和 await,看上去都是“现代写法”。但共享状态本身是裸奔的。这两个 Task 可能在任何时间并发运行:一边写入,一边读取,甚至在写入过程中的某个中间状态被读取。轻则逻辑错乱,重则触发崩溃。

而且从模型角度看,async/await 解决的是“控制流的表达方式”,并没有解决“谁在什么时刻可以访问这份状态”。异步链路变线性了,但状态访问的顺序仍然没有约束。

1.2 获取服务端配置特别容易踩并发坑的原因

获取服务端配置这件事,看起来只是“拉一份 JSON 然后更新缓存”,实际步骤还是不少的:

  • 本地缓存需要保存当前配置字典
  • 后台可能有定时任务轮询配置
  • 部分业务接口调用前会校验版本是否最新
  • UI根据配置决定是否显示功能开关
  • 后续如果接入服务端推送时,配置还可能被动更新

如果这些动作统统围绕同一块 [String: Any] 展开,风险包括:

  • 某次刷新还在更新部分字段,另一条任务已经开始读
  • 版本判断刚做完,另一个任务用旧数据覆盖了新配置
  • UI渲染时拿到的是“半更新”的快照
  • 日志里的版本号和真实生效版本对不上

这类问题不是语法错误,而是时序错误。代码一行一行看都讲得通,只要执行顺序有一点点抖动,结果立刻改变。靠 async/await 把“单条调用链”的顺序写明白,只解决了局部问题,无法约束“多条链同时跑时的访问关系”。

1.3 根本问题:可变状态没有“归属”

如果把问题点压缩成一句话,就是:一份可变状态被很多地方同时操作,没有任何一个组件为它负最终责任。 最原始的写法里,配置缓存就是那一行 var config: [String: Any]。但是后台能改,网络层能改,业务层也能改,UI某些地方也可能顺手改一部分。访问既没有中介,也没有边界。

在并发模型下,这种状态迟早会出问题。无论是通过 async/await 调用,还是用 GCD、OperationQueue,只要状态处于“谁都能摸一下”的状态,复杂度上升之后风险就会扩大。Swift 并发真正想改变的,不是“回调怎么写”,而是“状态怎么被看待”:可变状态不再直接暴露给整个系统,而是被封进一个隔离域,所有访问必须通过排队进入这个隔离域。

1.4 引入 ConfigStore:配置缓存只通过一个 actor 访问

在完整示例里,配置缓存不再是一个散落在全局的字典,而是收进了一个专门的 actor:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
actor ConfigStore {
private var storage: [String: Any] = [:]
func set(key: String, value: Any) {
storage[key] = value
}
func setAll(_ dict: [String: Any]) {
storage = dict
}
func get(key: String) -> Any? {
storage[key]
}
func currentVersion() -> Int {
storage["version"] as? Int ?? 0
}
func currentSnapshot() async -> ConfigSnapshot {
ConfigSnapshot(storage: storage)
}
}

这里有几个关键变化点:

  • 可变状态 storage 不再到处可见,而是被牢牢关在 ConfigStore 里面。外部代码无法直接操作字典,只能通过方法访问。
  • 所有访问 storage 的操作,都必须通过 await store.xxx() 进入。Swift 在这里可以自动保证:同一时刻只有一个任务在 ConfigStore 内执行,所有读写都会被排成队列顺序完成。
  • 从其他隔离域直接访问 storage 的尝试会被编译器阻止,只能通过 ConfigStore 暴露的接口来读写。这相当于给配置缓存加了门禁。

换个说法:配置这块状态,从“谁都能碰”的公共区域,变成了“只属于 ConfigStore”这一小块隔离空间,想进来必须排队。
光是这一层改造,就比单纯引入 async/await 安全得多。

1.5 再往外一层:刷新流程本身也需要独立

缓存归属 ConfigStore 之后,还有一个问题没有解决:刷新这条业务链路,谁来负责顺序? 获取服务端配置的完整流程大致是这样一条链:

  1. 读取本地版本号
  2. 请求服务端获取最新配置
  3. 比对版本,判断是否需要更新
  4. 写入本地缓存
  5. 生成 ConfigSnapshot 返回给上层

如果这条链路允许多条任务同时执行,即使底层 storage 的读写是串行化的,整个刷新流程依然可能被时序打乱。典型情况是两条刷新逻辑前后脚进来:

  • 两条任务在同一时刻读取到相同的本地版本,
  • 各自判断“需要刷新”,
  • 各自发起网络请求,
  • 各自写入结果,
  • 各自返回快照。

问题不在于 storage 是否串行,而在于版本判断、网络请求、写回缓存这几个步骤本质上是一条需要保持顺序的业务链路。一旦并发执行,网络返回顺序、调度顺序、写入时机都可能不同步,最终生效的配置可能来自任意一条任务。即便有一条链路拿到了更新的版本,也很可能被另一条链路的旧数据覆盖。

换句话说,在没有明确“业务隔离”的前提下,刷新流程的逻辑顺序并没有被保护,最终状态可能不是业务上的“最新配置”。这种不确定性会在高并发或弱网场景下被放大,很难预期,也难排查。为此示例里引入了一个全局 actor:

1
2
3
4
5
@globalActor
struct ConfigFetcher {
actor ActorType {}
static let shared = ActorType()
}

刷新服务还是用普通 class,只在方法上挂上 ConfigFetcher:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
final class ConfigRefreshService {
private let api: ConfigAPIProtocol
private let store: ConfigStore
init(api: ConfigAPIProtocol, store: ConfigStore) {
self.api = api
self.store = store
}
@ConfigFetcher
func refreshIfNeeded() async throws -> ConfigSnapshot {
let localVersion = await store.currentVersion()
let remote = try await api.fetchConfig()
guard remote.version > localVersion else {
return await store.currentSnapshot()
}
await store.setAll(remote.asDictionary())
return await store.currentSnapshot()
}
@ConfigFetcher
func forceRefresh() async throws -> ConfigSnapshot {
let remote = try await api.fetchConfig()
await store.setAll(remote.asDictionary())
return await store.currentSnapshot()
}
}

这里有两个细节:

  • 类本身没有标记 @ConfigFetcher,只有方法标注。这样构造 ConfigRefreshService 时不必进入全局 actor,避免额外的隔离开销,把隔离精确应用到真正需要顺序保障的刷新流程上。
  • 只要是刷新相关的方法,入口都被统一挂在 ConfigFetcher 上。无论多少任务同时调用 refreshIfNeeded 或 forceRefresh,真正执行这些方法时都会排队进入 ConfigFetcher 的隔离域。

这样的结果就是刷新逻辑从“可能并发交错的一堆任务”,收束成“同一时间只有一条刷新任务在跑的串行队列”。 业务对“配置更新”就变成了一次只处理一个版本切换——在语言层面被牢牢限制。

1.6 状态更新在 MainActor

最外层是UI状态,这部分也需要独立归属。示例中的 ViewModel 写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@MainActor
final class ConfigViewModel {
private let refreshService: ConfigRefreshService
private(set) var snapshot: ConfigSnapshot = .empty
private(set) var isLoading = false
private(set) var lastError: Error?
init(refreshService: ConfigRefreshService) {
self.refreshService = refreshService
}
func loadOnLaunch() {
Task {
await loadConfig()
}
}
func manualRefresh() {
Task {
await loadConfig(force: true)
}
}
private func loadConfig(force: Bool = false) async {
isLoading = true
lastError = nil
do {
let newSnapshot: ConfigSnapshot
if force {
newSnapshot = try await refreshService.forceRefresh()
} else {
newSnapshot = try await refreshService.refreshIfNeeded()
}
snapshot = newSnapshot
} catch {
lastError = error
}
isLoading = false
}
var isFeatureAEnabled: Bool {
snapshot.featureAEnabled
}
var isFeatureBEnabled: Bool {
snapshot.featureBEnabled
}
}

这里处理了几件事情:

  • 所有UI相关状态(snapshot、isLoading、lastError)都被放在 MainActor 下,自动遵循“只能在主线程读写 UI 状态”的要求,不需要显式写 DispatchQueue.main.async。
  • UI并不直接接触 ConfigStore,ViewModel 拿到的是 ConfigSnapshot 这种只读值类型,刷新完成后一次性替换,UI 层不参与任何缓存读写逻辑。
  • UI也不处理版本判断、请求细节、缓存写入顺序。所有这些业务细节都被收敛在 ConfigRefreshService 和 ConfigFetcher 里,ViewModel 只负责触发刷新和展示结果。

到这里,获取服务端配置这条链路已经形成了三个清晰的归属:

  • 配置缓存交给 ConfigStore
  • 刷新流程交给 ConfigFetcher / ConfigRefreshService
  • 界面状态交给 MainActor 下的 ConfigViewModel

数据在这些隔离域之间流转时,交由 ConfigSnapshot 这种值类型承载,不再直接暴露底层字典。

本章小结:async/await 负责“流程”,隔离负责“结构”
回到开头的问题:为何 async/await 不能单独解决并发安全?原因可以归纳成一句话:
async/await 让“单条异步链路”更易读,但获取服务端配置这种场景真正敏感的是“多条链路同时运行时,状态更新有没有限制”。如果配置缓存只是一个全局变量,谁都能读写,即便全部换成 async/await,底层结构仍然是不安全的。只有做到三点,代码在并发场景下才有机会保持稳定:

  • 配置缓存归属 ConfigStore 这样的 actor,所有读写排队进入
  • 刷新流程归属 ConfigFetcher 这样的全局 actor,保证串行执行
  • 界面状态归属 MainActor,使用 ConfigSnapshot 这样的 Sendable 值在隔离域之间传递

后面的章节,会在这条示例链路之上继续展开:

  • actor、Global Actor、MainActor 在隔离行为上有哪些细微差异
  • Sendable 如何约束 ConfigSnapshot 等模型在隔离域间移动
  • Task 与 await 在中途暂停时,会如何影响执行顺序和可重入行为
  • 接入服务端推送与 AsyncSequence 后,这套结构如何扩展而不失控

整个讨论都围绕这一个案例展开,争取从一个具体场景里,把 Swift 并发的结构性思路拆开讲清楚。

2. Swift Actor 隔离体系:获取服务端配置的三层结构

上一章从整体视角说明了 async/await 为什么无法单独保证并发安全,也展示了获取服务端配置这条链路里三块状态的归属问题:配置缓存、刷新流程、界面状态。

这一章还是以这段代码为核心,详细拆解 Swift 提供的三种隔离方式:普通 actor、全局 actor、MainActor。它们如何分别承担三类状态的独占管理职责,又是如何共同构成一个稳定的架构层次。

在这套结构中,actor 并不是“新的语法糖”,而是有非常明确的业务含义:谁可以访问这份状态、访问顺序如何决定、跨域移动数据时会发生什么,每一项都有严格的语言语义束缚着。理解这三种隔离的角色关系,基本等于理解 Swift 并发机制的核心。

2.1 隔离域的本质:不是线程,而是访问顺序的“唯一入口”

“隔离域”这个概念听起来很抽象,但在工程语境下非常好理解。它描述的不是线程,而是访问某种状态的唯一入口。获取服务端配置这个业务里,有三块状态彼此独立:

  • 本地缓存字典
  • 刷新流程的版本判断与写入步骤
  • UI层的快照状态及加载标志

如果这三块状态都散落在各处,由不同代码同时读取或修改,那么并发行为很难控制;即便每条逻辑单独看都完全正确,一旦多条任务同时运行,状态顺序马上变得不可预测。Swift 的做法是让这些状态分别属于不同的隔离域:

  • 本地存储属于 ConfigStore(普通 actor)
  • 刷新流程属于 ConfigFetcher(全局 actor)
  • UI 状态属于 MainActor

这三个隔离域都在不同的“执行环境”工作,各自拥有明确的职责——谁可以访问状态、访问顺序如何保证、跨域访问时需要使用 await。这样结构化的划分让系统更可推断,也让编译器能够帮助约束访问路径。

2.2 ConfigStore:配置缓存的独占隔离域

配置缓存是整个业务里最核心的一份可变状态——如果它被多个任务同时访问时出错,所有流程都会受影响。现在的写法将这份状态完全托管给 ConfigStore:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
actor ConfigStore {
private var storage: [String: Any] = [:]
func set(key: String, value: Any) {
storage[key] = value
}
func setAll(_ dict: [String: Any]) {
storage = dict
}
func get(key: String) -> Any? {
storage[key]
}
func currentVersion() -> Int {
storage["version"] as? Int ?? 0
}
func currentSnapshot() async -> ConfigSnapshot {
ConfigSnapshot(storage: storage)
}
}

这一段代码实现的效果非常直接:谁要读写这份 storage,都必须经过 ConfigStore,外界没有直接操作权限。内部执行也严格按照顺序排队,不会出现多个任务同时执行 actor 内部的任何语句。Swift 并发在这里提供的是“语言级别的排他访问”:不需要锁,不需要队列,也不需要额外辅助结构。更重要的是,ConfigStore 不负责业务判断,只负责存储一致性。这种“最小化职责”的设计,使得它既稳定又容易维护。

2.3 ConfigFetcher:刷新流程的业务级隔离域

缓存虽然被保护好了,但刷新流程本身也有强烈的顺序要求。一个典型的刷新需要:

  • 检查当前版本
  • 拉取最新配置
  • 比对决定是否更新
  • 写入缓存
  • 生成快照给上层

如果这条流程同时被多条任务触发,就算底层 store 是串行访问,也会出现逻辑上的互相覆盖、错序或震荡。刷新流程的解决方式,是把它放进一个全局 actor:

1
2
3
4
5
@globalActor
struct ConfigFetcher {
actor ActorType {}
static let shared = ActorType()
}

再让刷新方法显式标注:

1
2
@ConfigFetcher
func refreshIfNeeded() async throws -> ConfigSnapshot

调用行为因此被改变:无论有多少任务同时触发刷新,真正执行刷新逻辑时,都必须排队进入 ConfigFetcher 的隔离环境。刷新流程从“可能并发”变成“严格串行”,业务顺序完全可控。

此外,全局 actor 与普通 actor 最大的区别在于:全局范围内只有一份“业务隔离域”。不论在哪个模块发起刷新,都归入这一条业务通路。这在大型应用中可以避免大量细碎的锁与队列管理。

2.4 ConfigViewModel:界面状态的主线程隔离域

SwiftUI 和 UIKit 都要求 UI 更新必须在主线程执行,因此UI相关状态天然有自己的隔离域。示例中通过:

1
2
@MainActor
final class ConfigViewModel { ... }

明确表达UI状态只能在 MainActor 内访问。这带来了以下效果:

  • snapshot、isLoading、lastError 等 UI 状态不会在后台线程被错误更新
  • 刷新流程返回快照时,会自动切回主线程更新 UI
  • 不再需要显式写 DispatchQueue.main.async

界面层仅关心 ConfigSnapshot,完全不需要知道 ConfigStore 或 ConfigFetcher 的存在;三层之间通过值传递形成天然的解耦。这样设计后,UI 层不会触碰任何后台可变状态,后台逻辑也不会直接影响 UI 的线程上下文,职责边界彻底分离。

2.5 三层隔离的状态流动结构

从 UI 发起请求开始,到配置返回给 UI 更新,就有了一条稳定、可预测的路径:

1
2
3
4
5
6
7
8
9
10
MainActor(UI)
→ 请求刷新(进入 ConfigFetcher)
ConfigFetcher(刷新流程)
→ 读取 / 写入缓存(进入 ConfigStore)
ConfigStore(存储层)
→ 构造快照(跨域返回)
ConfigFetcher
→ 返回结果
MainActor(UI)
→ 更新界面状态

每一步都有明确的职责,每一个状态都有属于自己的隔离域。没有地方能绕开这些隔离域,开发者不需要时刻思考“这个地方是不是需要加锁”或“是不是会被后台线程修改”。更关键的是,这种结构天然具备扩展性:如果要接入服务端推送时,只需要让推送更新也进入 ConfigFetcher,不需要改动缓存层或 UI。

本章小结:三种 actor 构成三类状态的天然分层
可以将这一章的核心思想总结为一句话:Swift 并发不是让异步写法变漂亮,而是让可变状态在系统中有“归属”、有“边界”、有“秩序”。在“获取服务端配置”这一个例子里,三类状态的分层关系非常明确:

  • 配置缓存归 ConfigStore
  • 刷新流程归 ConfigFetcher
  • UI 状态归 MainActor

每一层都有自己的隔离域,彼此不越界。任务在 await 间穿梭,但状态访问始终稳定。下一章会深入 Sendable,解释为什么 ConfigSnapshot 必须设计成值类型,为什么不能直接返回一个 class,以及跨隔离域传递数据时编译器如何帮助保证安全。

3. Sendable:跨隔离域传递的数据必须有清晰边界

前一章讲了获取服务端配置这套结构中三类状态分别有属于各自的隔离域:ConfigStore、ConfigFetcher 与 MainActor。隔离域负责“状态在何处被访问”这个问题,但还缺一块同样重要的拼图——状态在隔离域之间如何流动?

从 ConfigStore 创建快照,到 ConfigFetcher 处理结果,再到 UI 更新状态,都会涉及跨隔离域的数据传递。一旦传递的数据里夹带可变引用类型,或包含某块后台共享的状态,整个隔离系统都会被绕开,从而重新回到“谁都能改”的混乱状态。

Swift 提供的 Sendable 协议就是为了解决这类问题。它不是语法糖,而是一条类型级别的安全边界:哪些数据能跨隔离域移动、哪些数据不能、哪些必须被约束为不可变值类型,都由编译器在编译期间检查。这一章的核心,就是解释为什么 ConfigSnapshot 必须是值类型、必须符合 Sendable,以及为什么 raw 字段不能直接使用 [String: Any]

3.1 数据是否需要跨隔离域传递

在当前的架构中,一次刷新过程会让快照按照这样一条路径流动:

1
2
3
4
ConfigStore(actor)
→ 构造 ConfigSnapshot
→ 返回给 ConfigFetcher(全局 actor)
→ 最终返回给 MainActor(UI)

这条路径跨越三个隔离域。只要路径上出现引用类型,或者包含共享可变的对象,隔离域就无法保证状态不会被其他任务改写。Sendable 就是为了提前阻止这类情况。快照本身是 “后台产出 → 主线程使用” 的典型类型,毫无疑问需要跨域。要让这条数据流可靠,必须设计成完全不可变的结构体。

3.2 为什么 ConfigSnapshot 必须是 struct

如果快照以 class 表达:

1
2
3
4
5
final class ConfigSnapshotRef {
var version: Int
var featureAEnabled: Bool
var featureBEnabled: Bool
}

并从 actor 返回它:

1
2
3
func currentSnapshot() async -> ConfigSnapshotRef {
ConfigSnapshotRef(...)
}

就会出现问题,因为class 是引用类型,多个隔离域拿到的是同一个实例,任何地方修改版本号或开关状态,其他隔离域都会“随之改变”。这直接破坏 ConfigStore 的隔离语义,也让 UI 拿到的快照不再是“某一刻的结果”,而成了“某个共享对象”。

Swift 会阻止这种行为。可变引用类型默认不符合 Sendable,一旦试图从 actor 返回,编译器会直接报错。这是 Swift 并发的设计思路之一:跨隔离域的数据不能依赖共享引用。而 struct 只复制,不共享内部状态,只要字段本身也符合 Sendable,就可以保证快照是独立的,后续不会被任何后台任务修改。

3.3 为什么 raw 字段不能使用 [String: Any]

业务上往往希望把服务端返回的所有字段原样保存下来,因此 ConfigSnapshot 会包含一个 “raw 字段”:

1
2
3
4
let raw: [String: AnySendable]

// 而不是
let raw: [String: Any]

原因很简单:Any 不是 Sendable。Any 可以装任意东西,包括闭包、class 实例、可变引用等。把它放进跨域传递的数据结构里,编译器无法判断是否安全,只能编译报错。为了允许 raw 字段跨域流动,又不破坏类型安全,需要一个受控的包装类型:

1
2
3
4
5
enum AnySendable: Sendable {
case string(String)
case bool(Bool)
case int(Int)
}

设计它的目的是为了确保:

  • raw 内部所有值都符合 Sendable
  • 不允许出现可变引用
  • 整体结构可以跨隔离域传递

这是 Swift 并发的一个思想体现:跨域数据必须经过显式表达,不能留有模糊空间。

3.4 为什么 ConfigSnapshot 不能被标记为 @MainActor

很多人在一开始会尝试这样写:

1
2
@MainActor
struct ConfigSnapshot { ... }

然后在 ConfigStore 里创建 snapshot:

1
2
3
func currentSnapshot() async -> ConfigSnapshot {
ConfigSnapshot(storage: storage)
}

编译器立即报错:

1
Call to main actor-isolated initializer in a synchronous actor-isolated context

错误内容其实非常直接,一个标记为 @MainActor 的类型,其初始化必须在主线程执行。但当前代码在后台 actor 内构造它,这是一个同步构造过程,无法跨隔离运行,Swift 只能拒绝。快照的正确使用方式应该是不使用任何 actor,这样才可以在任意隔离域之间自由移动。

换句话说,快照本质上是“数据”,不属于 UI、后台或业务调度者。它是流动在各层之间的载体,不应绑定线程,不应绑定 actor。

3.5 Sendable:跨隔离域的类型边界

当 ConfigStore 返回快照时:

1
func currentSnapshot() async -> ConfigSnapshot

Swift 会检查ConfigSnapshot 是否 Sendable、内部字段是否存在引用类型、是否存在跨域无法预测的可变状态。跨域传递只会在满足 Sendable 条件时被允许,否则就会报错。

Sendable 让跨域数据流的安全成为编译期规则,快照之所以能在 ConfigStore → ConfigFetcher → MainActor 之间走得那么自然,就是因为 Sendable 的约束确保它在所有隔离域里都是独立、安全、不可变的值。

3.6 ConfigSnapshot:每一条约束都为跨域服务

快照的特征如下:struct 值类型、完全不可变、不依赖 UI 或后台资源、内部字段都是值类型、符合 Sendable、不属于任何 actor。这些特征共同达成一种结构:

  • 在后台构造完全安全
  • 在流程层传递不共享引用
  • 在主线程使用不需额外切换
  • 在任何任务中都不会被修改

这一点非常重要:快照不是共享对象,而是一个“时间点的定格”。 UI 永远看到的是当时完整的配置,不会因后台后续写入而改变。

本章小结:Sendable 规定的是“能不能跨域传”,不是“怎么传”
隔离域决定谁可以访问状态;Sendable 决定哪些状态可以离开隔离域。在配置系统中,三层隔离域相互独立,但通过值类型快照形成了稳定的跨域数据流:

1
ConfigStore → ConfigSnapshot → ConfigFetcher → MainActor

这一条数据链路之所以稳定可靠,是因为:

  • 快照完全不可变
  • raw 字段受控
  • 不存在共享引用
  • 编译器提前阻止风险

Sendable 是 Swift 并发中经常被忽略但最关键的一环,它把跨域数据的安全从开发行为约束变成了强制编译规则。没有 Sendable,这套结构的安全性将完全不存在。

下一章会继续以这个示例为背景,从 Task 与 await 的角度分析刷新流程中发生的暂停点、任务切换、可重入行为,以及它们与隔离域之间的关系。

4. Task 与 await:异步流程如何在隔离域之间穿梭

获取服务端配置这条链路在运行时,会经历多个异步阶段:界面发起刷新、流程层判断版本、后台拉取配置、写入本地缓存、构造快照、更新界面状态。整个过程并不是一条单向直跑的流程,而是不断跨越隔离域、不断暂停与继续执行的协作流程。

Swift 并发里,Task 和 await 是所有动作的载体,它们不是线程的包装,而是完全由语言调度的异步协作单元。理解 Task 和 await 如何在隔离域间流转,决定了能否看懂整个“刷新配置”在运行时的真实流程。下面结合实际代码,把任务模型的核心机制从表象到本质展开,使整个架构的运行行为变得可观察、可推断、可验证。

4.1 Task 不是线程,而是一段可随时暂停和继续的异步逻辑

UI层启动配置加载的方式很直接:

1
2
3
4
5
func loadOnLaunch() {
Task {
await loadConfig()
}
}

这一行代码不会创建新的线程,而是把 loadConfig() 包装成一个任务交给调度器,让调度器决定它具体在哪个线程执行。

在 Swift Concurrency 中,Task 并不是传统意义上的线程,而是“协程”这一更高层次的抽象。协程最大的特征是可以在执行过程中暂停,然后在未来某个时刻恢复,而且恢复时不要求继续运行在同一个线程上。暂停点由 await 决定,而不是由线程决定。

当代码运行到 await 时,任务会把当前执行状态保存下来,线程随即被释放,用于执行其他任务。这意味着系统可以在极低成本下同时调度大量异步任务,而不需要像传统线程那样长期占用系统资源。当等待的异步操作完成后(例如网络响应到达),任务再被调度器恢复,继续从暂停的位置往下执行。

这种模型让 Swift 能够用同步写法表达异步流程,同时具备协程式的高吞吐能力,不会因为某个耗时操作而阻塞线程,也不会因为线程数量不足而限制并发规模。简而言之,Swift 的异步任务更像“可暂停的逻辑片段”,而不是“一条长久占用资源的执行轨道”。

在这一点上,Swift 并发与 GCD 的理念完全不同:GCD 分派的是“要执行的事情”,Swift 分派的是“能够暂停、恢复的执行结构”

4.2 await 的真正含义:让出执行权,而不是等待线程完成某件事

刷新配置的流程里,有许多 await:

1
2
3
4
let localVersion = await store.currentVersion()
let remote = try await api.fetchConfig()
await store.setAll(remote.asDictionary())
return await store.currentSnapshot()

await 的行为不只是“等待结果”,而是两个动作:

  1. 暂停当前任务
  2. 把执行权交还给调度器

这个暂停是协作式的。线程不会被锁住,而是继续执行其他任务。当 await 的内容完成后(例如网络请求返回),任务再继续往下执行。刷新流程的每一段 await 都是潜在的挂起点:

  • 版本读取可能挂起
  • 网络请求一定挂起
  • 写入缓存可能挂起
  • 读取快照可能挂起

这些暂停点让任务具有极强的弹性,系统能在高并发情况下保持稳定运行。

4.3 actor 内部的 await 会触发可重入:暂停时允许其他任务进入

Swift 的 actor 不是传统意义上的“锁住一段代码直到结束”。actor 保证的是执行片段的串行化,而不是整个方法的串行化。例如 ConfigStore:

1
2
3
4
5
actor ConfigStore {
func currentSnapshot() async -> ConfigSnapshot {
await ConfigSnapshot(storage: storage)
}
}

虽然构造快照是同步的,但方法本身是 async,因此对外界来说一定需要 await:

1
let snap = await store.currentSnapshot()

这一 await 让调用者暂停,ConfigStore 也有机会处理其他任务。

actor 的行为总结为一句话:只要遇到 await,当前任务会退出 actor,让其他任务进入。这种行为叫可重入,是提升吞吐量的关键机制。在获取服务端配置的案例中,这种可重入不会破坏存储一致性,因为写操作是整体替换(setAll),读操作也是一次性读取(snapshot),不存在“部分更新状态被读取”的风险。

4.4 为什么 ConfigFetcher 中的 await 不会造成刷新流程被打断

刷新流程的核心位置:

1
2
3
4
5
6
7
@ConfigFetcher
func refreshIfNeeded() async throws -> ConfigSnapshot {
let localVersion = await store.currentVersion()
let remote = try await api.fetchConfig()
await store.setAll(remote.asDictionary())
return await store.currentSnapshot()
}

即便其中包括长时间暂停(fetchConfig),整个方法也不会被其他刷新流程打断。原因在于 ConfigFetcher 是一个全局 actor。全局 actor(包括 MainActor)与普通 actor 最大的差异是:在 await 时不会让出执行域,其他任务无法“插队”。执行线程可以让给系统,但执行权不会让给同一全局 actor 的其他任务。这使得业务流程的顺序绝对稳定,不会出现刷新逻辑交错执行的问题。这种“不可重入性”正是刷新流程所需的语义:刷新必须顺序执行,不能被其他刷新流程抢占

4.5 store 的 setAll 和 currentSnapshot 的 await 执行流程

刷新流程内部跨越了多个隔离域,写入缓存:

1
2
3
4
5
// 写入缓存
await store.setAll(...)

// 构造快照
return await store.currentSnapshot()

从 ConfigFetcher(全局 actor)跨越到 ConfigStore(普通 actor),然后再回到 ConfigFetcher,最后返回给MainActor。

这条路径体现了 Swift 并发的真正运行方式:任务不断在隔离域之间切换执行,状态的访问顺序由隔离域保证,而流程的执行顺序由任务逻辑保证。线程如何切换、在哪个物理 CPU 上运行,与整个逻辑没有关系。所有隔离行为由类型结构而不是线程结构来定义。

4.6 await 暂停点对业务正确性的影响

在 Swift 并发中,最常见的误区是只是把 await 当作“等待”。但其实 await 是业务结构中的关键节点:

  • await 会打断当前逻辑,让后续操作延迟执行
  • await 会让流程处于“未完成状态”
  • await 允许隔离域切换
  • await 是插队的窗口(对普通 actor)
  • await 是任务取消的机会点

在示例中之所以有效,是因为整个结构针对暂停点做了对应的匹配:

  • ConfigFetcher 保证暂停不会破坏流程顺序
  • ConfigStore 保证暂停不会导致存储中间态泄漏
  • ConfigSnapshot 保证暂停不会暴露部分构造结果
  • MainActor 保证暂停不会让 UI 落到后台线程

这些匹配构成一个完整的系统,让 await 在任意位置出现都不会损坏业务逻辑。

4.7 任务如何穿越隔离域

以下是一次完整刷新动作的实际执行轨迹:

  • MainActor 发起任务:loadOnLaunch()
  • 任务进入 ConfigFetcher:调用 refreshIfNeeded
  • 跨域进入 ConfigStore:读取版本号
  • 返回 ConfigFetcher:继续流程
  • 遇到 fetchConfig:任务挂起,执行器释放
  • 网络返回后恢复任务
  • 跨域进入 ConfigStore:写入缓存
  • 返回 ConfigFetcher:准备生成快照
  • 跨域进入 ConfigStore:读取最终快照
  • 返回 ConfigFetcher
  • 返回 MainActor
  • UI 更新 snapshot

这条链路说明一件事,整个系统的逻辑顺序是可推断的,隔离域的结构让状态安全,await 的协作式模型让并发变得轻量且可维护。异步变得直观的原因不是 async/await 本身,而是 async/await 在隔离域规则下具有明确行为。

本章小结:Task 决定流程,await 决定节奏,隔离域决定结构
获取服务端配置示例展示的不是“如何用 async/await 写异步代码”,而是:

  • Task 承载业务流程
  • await 让任务以协作方式运行
  • ConfigStore 保护后台状态
  • ConfigFetcher 保证刷新顺序
  • MainActor 保证界面更新
  • ConfigSnapshot 让数据在隔离域之间安全流动

这套结构使得系统在复杂的并发环境中保持稳定、可预测和可维护。下一章我们将从更多工程细节角度切入——可重入的实际影响、什么时候会导致严重业务错乱、哪些逻辑必须避免在 actor 内 await,以及如何正确处理关键步骤的连续性。

5. actor 的可重入性:保证业务不被打乱

Swift actor 的行为与传统锁存在本质差异。传统锁强调“进入后一直持有到退出”,而 Swift 的 actor 更像一个“门卫”:一次只允许一个任务进入执行,但如果执行过程中遇到 await,任务必须退出,让其他等待中的调用进入。这种机制称为可重入(Reentrancy)。

可重入提高了并发吞吐能力,却也可能让业务逻辑在暂停与恢复之间出现顺序错乱。获取服务端配置中同时使用了普通 actor 和全局 actor,正是为了让“哪些逻辑可以被可重入”与“哪些逻辑绝不能被可重入”形成稳定的边界。本章从示例代码出发,说明可重入机制的本质、影响、风险点,以及如何保证在这些行为下仍然保证严格的业务顺序。

5.1 可重入的本质:遇到 await 时必须退出 actor,让其他调用进来

下面是一个具有典型可重入行为的示例:

1
2
3
4
5
6
7
actor Sample {
func run() async {
doSomeLocalWork() // 在 actor 内执行
await Task.sleep(...) // 暂停点
doMoreWork() // 恢复后继续
}
}

执行流会这样展开:

  1. 第一次进入 actor,执行前半部分
  2. 遇到 await,任务暂时离开 actor
  3. actor 可以处理其他任务
  4. await 完成后,原任务重新进入 actor,继续执行下半部分

换句话说,actor 内部只有“不包含 await 的连续片段”是绝对串行的。一旦出现 await,该片段就结束了,执行权让给其他调用。可重入是 Swift 为提高性能所做的设计,使得某个 actor 在面对大量调用时不会被长任务完全阻塞。但这带来另一个问题:如果代码逻辑依赖“前半段状态必须与后半段状态连续一致”,await 就会带来问题。

5.2 ConfigStore 是可重入的,但不会破坏存储一致性

现在来看示例里的 ConfigStore:

1
2
3
4
5
6
7
8
9
actor ConfigStore {
private var storage: [String: Any] = [:]
func setAll(_ dict: [String: Any]) {
storage = dict
}
func currentSnapshot() async -> ConfigSnapshot {
await ConfigSnapshot(storage: storage)
}
}

ConfigStore 的方法包含两类行为:

  1. setAll 是同步写入
  2. currentSnapshot 是 async,但构造快照是同步读取

可重入在这里不会造成影响,原因有两点:

  1. 所有写入都是“整体替换”。
    不会出现半写入状态,不会在 await 中间暴露未完成数据,storage 始终保持完整一致。
  2. 读取快照也是一次性读取。
    它不会在中途 await,也不会依赖内部多步更新,因此即使可重入,也不会打断连续性。

也就是说,ConfigStore 的设计本身就避免了可重入带来的典型风险。

5.3 刷新流程属于业务关键路径,因此不能被可重入

可重入真正会造成隐患的是业务流程,而不是资源层。看一下 ConfigRefreshService:

1
2
@ConfigFetcher
func refreshIfNeeded() async throws -> ConfigSnapshot { ... }

这一方法包含多个 await:

  • 读取版本号
  • 发出网络请求
  • 写入缓存
  • 获取快照

如果这是普通 actor 方法,那么在网络请求的等待期间,可能有其他刷新任务进入,在写入缓存前,其他任务可能更新 storage,版本判断可能在逻辑恢复时变得不再准确,最终导致刷新流程交错执行,顺序错乱。

但示例中采用的是全局 actor ConfigFetcher,它具有一个关键特性,全局 actor 在 await 时不会允许其他相同 actor 的方法插队进入。也就是说,刷新流程从进入到退出始终是独占、一条路跑到底的,不会因为 await 而让其他刷新流程抢占执行权。刷新流程的不可重入,是整个配置系统稳定性的核心基础。任何版本判断、写入缓存、快照生成,只能在保证流程连续性的前提下进行。

5.4 为什么 ConfigFetcher 不可重入,而 ConfigStore 可以

这是架构层的设计选择,ConfigStore 负责“存储的正确性”。可重入不会破坏存储,因为写是原子替换、读是完整快照。
ConfigFetcher 负责“流程的顺序性”。流程顺序不能被中断,否则多个刷新可能交叉执行,导致版本倒退或状态覆盖。

两者的角色完全不同:

  • ConfigStore 是状态仓库,适合高吞吐,需要可重入。
  • ConfigFetcher 是流程调度者,需要绝对顺序,不允许可重入。

这种组合形成了一种稳定结构,可重入发生在对业务安全无害的地方,不可重入发生在业务必须保持完整性的地方。这种设计能在保证性能的同时,维持流程级别的正确性。

5.5 可重入真正可能造成问题的场景

为了理解为何 ConfigFetcher 必须不可重入,可以参考下面的对比示例:

1
2
3
4
5
6
7
8
actor UserSession {
var token: String = ""
func refreshToken() async {
token = ""
await Task.sleep(1_000_000_000) // 暂停点
token = "NEW_TOKEN"
}
}

假设两个任务同时调用 refreshToken:

  1. 第一个任务清空 token
  2. 任务暂停
  3. 第二个任务进入,清空 token
  4. 第二个任务恢复后写入自己的 token
  5. 第一个任务恢复后写入自己的 token

最终 token 的值不确定,完全取决于时序,可重入使得状态被多次覆盖,而方法本身无法表达这种风险。因此凡是状态分阶段更新的逻辑,必须避免可重入:

  • 如多步数据库操作
  • 如令牌刷新
  • 如本地配置合并
  • 如资源初始化
  • 如后台同步任务

这些流程不能被插队,也不能依赖时序偶然性。

再看示例代码,ConfigStore 内部不会产生部分状态,ConfigFetcher 使流程不可重入,而快照是一次性构建的值类型,版本判断与写入都是在同一流程中执行,UI 不会在后台线程更新,推送也走同一业务隔离域。在这样的结构下,即使 actor 具有可重入能力,业务结果依旧稳定一致。暂停点不会破坏状态,恢复点不会让流程被插队,读取数据也不会读到半更新状态。这种设计可以自动规避可重入风险。

本章小结:可重入本身不是问题,问题是结构是否能接住它
Swift actor 的可重入是一把双刃剑。

  • 在资源层面,它提高性能,使 actor 多任务下不阻塞。
  • 在流程层面,它可能破坏顺序,必须通过全局 actor 加以约束。
  • 在数据层面,Sendable 防止跨域共享引用导致的数据竞争。

获取服务端配置的示例展示了一条可参考的并发路径:

  • 资源层可重入但不会破坏一致性
  • 流程层不可重入,保持严格顺序
  • UI 层固定在主线程,状态安全
  • 快照作为值在隔离域之间安全流动

可重入在其中不是风险,而是性能优化手段。真正的重要点在于结构本身:哪些能被插队,哪些不能,哪些必须保持原子性,都由隔离域清晰定义。下一章将从架构角度总结这种分层模式如何推广到更大的业务领域,例如多源配置、后台同步、用户会话管理、服务端推送整合等。

6. Swift 并发在架构中的落地方式

前几章分别从隔离域、Sendable、Task、可重入这四个角度拆解了 Swift 并发的底层机制。机制背后的目的并不是让语法更漂亮,而是让系统的结构变得清晰、稳定、可推断。

当业务逐渐复杂、访问路径增多、并发量提升时,痛点往往不在于“怎么写异步”,而在于“可变状态是否仍然安全”“流程是否仍然顺序正确”“数据是否能跨域可靠传播”。获取服务端配置这个示例很小,却展示了一个适用于中大型系统的架构模式。这一章将重点说明这套架构如何落地、如何扩展,以及为何它能承托更复杂的业务场景。

6.1 第一层:资源层(ConfigStore)是状态的唯一入口

资源层的职责非常单纯:守护可变状态,并明确它的访问方式。在示例中,这个角色由 ConfigStore 承担:

1
2
3
4
5
6
7
8
9
actor ConfigStore {
private var storage: [String: Any] = [:]
func setAll(_ dict: [String: Any]) {
storage = dict
}
func currentSnapshot() async -> ConfigSnapshot {
ConfigSnapshot(storage: storage)
}
}

在资源层有几个关键原则:

  • 可变状态只能在一个地方修改。
  • 外部只能通过 await 调用受控方法访问内部存储。
  • 内部不暴露部分状态,不允许跨 actor 的共享引用指针流出。
  • 资源层不关心业务顺序,不关心 UI 状态,也不负责数据来源,它的角色就像仓库管理员,只负责让“状态永远正确且完整”。

在更大的系统里,任何“被多个任务共享访问的可变状态”都适合放进这种 actor 中,例如用户会话、下载缓存、任务队列、配置相关等。

6.2 第二层:流程层(ConfigRefreshService + ConfigFetcher)定义业务逻辑顺序

流程层决定“何时读资源层、何时写资源层、何时生成最终结果”。资源层关注状态的正确性,流程层关注状态变化的顺序。示例中的流程层由两部分组成:

  • ConfigRefreshService:刷新逻辑本体
  • ConfigFetcher:保证刷新逻辑的串行化执行环境

刷新流程中包含至少三个对业务顺序非常敏感的阶段:

  • 读取本地版本
  • 请求远端配置
  • 写入新配置并生成快照

这些步骤必须是顺序且不可被打断的。如果流程层在 await 期间被插入另一条刷新请求,就可能出现“先来的流程写入旧版本,后来的流程写入新版本,再被前者覆盖”的典型并发错乱。

全局 actor ConfigFetcher 的作用是为流程层提供一个不可重入的隔离环境,让每一次刷新逻辑都拥有排他性执行权。这种特性与业务语义天然契合:刷新配置本身就是一种全局顺序化操作。在更多业务中,也能看到类似需求,例如令牌刷新、账号迁移、数据库写入、后台同步调度等,都需要以同样方式集中到一个业务域顺序执行。

6.3 第三层:UI层(ConfigViewModel)管理用户可见状态

UI层的职责是“响应快照变化并更新 UI”。它不参与网络、不参与版本判断、不参与存储结构,也不负责业务顺序。示例中的界面层由一个简单的 @MainActor ViewModel 构成:

1
2
3
4
5
6
@MainActor
final class ConfigViewModel {
private(set) var snapshot: ConfigSnapshot = .empty
private(set) var isLoading = false
private(set) var lastError: Error?
}

所有 UI 状态都在主线程读写,由 MainActor 自动提供线程安全保证。同时,界面层只接收 ConfigSnapshot 这样的值类型,不会参与存储更新,也不会泄漏资源层的内部结构。

流程层不会反向持有界面层,也不会直接操作界面状态,这让整个架构避免循环依赖和线程混乱。

6.4 三层之间的调用链路形成自然分工

整个“获取服务端配置”的调用链路可以用一句话概括:
UI层开始调用

  • → 流程层决定顺序
  • → 资源层维护状态
  • → 流程层返回快照
  • → UI 更新最终状态

这条链路始终是一条单向的数据流。对应的隔离域组合也清晰稳定:

  • 界面层归 MainActor
  • 流程层归 ConfigFetcher
  • 资源层归 ConfigStore

快照作为 Sendable 值类型安全地在不同隔离域之间传递,避免了引用共享导致的竞态问题。在结构上,这形成一种自然分工:

  • UI层负责呈现
  • 流程层负责决策
  • 资源层负责存储

这种分工保证了纵向隔离,让系统在并发场景下依然保持清晰的职责边界。

6.5 避免在流程层内部出现意外的暂停点

流程层的方法通常包含多个 await,因此必须确保这些 await 不会破坏业务语义。例如,refreshIfNeeded 中的网络请求是不可避免的 await,但版本判断与写入动作必须处于同一个连续流程中。如果流程层随意加入其他 await,例如:

1
2
3
token = ""
await Task.sleep(...)
token = newToken

会造成典型的可重入混乱:暂停让出流程执行权,使得业务逻辑被其他调用插队。

因此流程层需要注意,流程中不能暴露“半完成状态”,流程的 await 必须是业务上允许暂停的位置。流程的最终状态必须由单条路径持续推进。这些行为与资源层不同。资源层允许可重入,因为写入是原子操作;流程层不允许可重入,因为步骤之间存在业务依赖。

6.6 架构的可扩展性:从单一配置走向多模块并发控制

当业务规模扩大,获取服务端配置这种模式可以自然扩展到更多模块。例如:

  • 多个配置来源(默认配置、实验配置、本地覆盖配置)。
  • 多个刷新来源(页面触发、后台自动刷新、服务端推送)。
  • 多个业务模块依赖这一份配置(首页、任务系统、广告模块)。

扩展方式通常依然沿用三层结构:

  • 每类共享可变状态对应一个 Store(actor)。
  • 每类顺序敏感逻辑对应一个 Global Actor。
  • 每类 UI 对应自己的 ViewModel。

这种方式使得系统可以横向扩展多个模块,而不出现锁竞争、线程错乱或状态覆盖问题。

6.7 Swift 并发的架构价值:让状态、流程、界面各司其职

在获取服务端配置这个示例中,Swift 并发的架构价值体现为三个关键词:

  • 隔离,使每一类状态只属于一个领域
  • 不可变,使跨域数据永远安全
  • 顺序,使流程具有业务所需的原子性

这些特性组合在一起,使系统具有以下特点:

  • 即使大量任务同时触发刷新,最终状态仍然正确。
  • 即使服务端推送与手动刷新交替到来,最终顺序仍然可预测。
  • 即使 UI 多次更新,线程安全仍然由语言机制保障。
  • 即使后台有大量读取需求,资源层仍然保持一致性。

当这三层形成固定结构后,系统具备了长期维护能力。新的业务可以自然加入模块,而不破坏并发结构。获取服务端配置只是一个简单示例,但展示了 Swift 并发的核心思想:用隔离划分边界,用任务组织流程,用值类型传递结果,让整个系统在高并发下仍然保持清晰、可靠、可推断。

下一章将结合常见并发问题,展示这些结构如何在工程实践中避免真实业务踩坑,包括版本覆盖、半更新状态泄漏、乱序写入、跨线程 UI 更新等典型场景。

7. 常见并发问题与隔离模型的解决方式

我们从真实风险回头审视隔离架构的必要性,并发 bug 往往具有相同特征:偶发、难复现、代码看起来完全没问题,但实际运行会在特定时序下出错。

Swift Concurrency 的隔离体系并不是为了“写法更优雅”,而是为了让这些难以追踪的问题从结构上消失。获取服务端配置这个示例之所以适合作为贯穿案例,就是因为它天然包含几乎所有典型并发风险:本地缓存、多个刷新入口、网络开销、UI 状态更新、服务端推送、多任务并发触发等。

这一章逐一分析这些风险,以及为何在隔离架构下它们都被自然化解,而不需要额外技巧。

7.1 多个任务几乎同时刷新,导致最终版本不可预测

现实场景中,经常出现多个异步流程在短时间内触发配置刷新:应用冷启动时页面多次触发、多个模块同时校验配置、用户手动刷新、后台定时刷新等。如果刷新流程这样写:

1
2
3
4
Task {
let remote = try await api.fetchConfig()
await store.setAll(remote.asDictionary())
}

只要出现至少两个任务竞争刷新,顺序立刻失去确定性,先发请求的任务不一定最先返回,晚写入的版本可能比早写入的旧,最终缓存可能倒退。隔离架构中,这个问题完全被结构消除:

  • 所有刷新动作进入 ConfigFetcher(全局 actor)。
  • ConfigFetcher 是不可重入的,因此刷新按顺序排队。
  • 最终写入一定是“时间上最后一次刷新”的结果。

业务上对配置更新的预期就是串行的,隔离模型让这种语义自动成立。

7.2 状态写入如果有多步,可能在 await 暂停时暴露半更新状态

这是并发系统中最难排查的错误之一,写了部分字段,await 暂停并退出 actor,其他任务读取到了“不完整的配置”,
这种情况甚至可能造成白屏、崩溃等问题。在示例中,写入动作被设计成原子性:

1
await store.setAll(remote.asDictionary())

ConfigStore 内部写法是替换整个 storage:

1
storage = dict

写入没有多个步骤,也没有把内部状态暴露给外界,因此不存在半更新暴露的机会。这种“整体替换”策略非常可靠,是 actor 内部维护状态时的推荐模式。

7.3 刷新流程在等待网络返回时被插队,导致流程逻辑混乱

网络请求是长暂停点,actor 会在 await 时让出执行权。如果刷新流程放在普通 actor 中,可能出现这样的时序:

  • 流程 A:判断本地版本过期 → await 网络
  • 流程 B:也判断本地版本过期 → await 网络
  • 流程 A 恢复:写版本 2
  • 流程 B 恢复:写版本 1(覆盖新版本)

最终状态可能倒退。示例中刷新流程被挂载在 ConfigFetcher(全局 actor)上:

1
2
@ConfigFetcher
func refreshIfNeeded() async throws -> ConfigSnapshot

全局 actor 的执行模型保证await 虽然会放弃线程,但不会让流程本身被其他刷新插入。刷新逻辑从第一行到最后一行都是排他执行。这让版本判断和写入之间的因果关系永远成立。

7.4 UI 在后台线程更新,容易导致崩溃或状态跳跃

UIKit 和 SwiftUI 都要求UI更新必须发生在主线程。在多线程系统里,一旦有人忘记切换线程就会出现瞬间崩溃,再加上异步链路越写越长,问题越来越难发现。示例中的界面层被明确限制在 MainActor:

1
2
@MainActor
final class ConfigViewModel { ... }

这样ViewModel 的属性赋值始终发生在主线程,后台线程的刷新不会直接接触 UI 状态。不需要任何DispatchQueue.main.async。这类线程安全问题从源头被清除,界面层变得可预测。

7.5 共享引用对象跨隔离传递,导致意外的数据竞争

典型错误写法是后台任务解析一个配置对象(class),丢给 UI,UI 读取对象字段,后台又修改同一个对象引用。UI 和后台同时读写同一块内存,就会产生数据竞争。示例中,可以通过 ConfigSnapshot 避免这种风险:

1
struct ConfigSnapshot: Sendable

它是值类型,不共享引用,在跨隔离传递时会被复制,UI 拿到的 snapshot 永远不会被后台修改。Swift 在编译期验证 Sendable,让跨域数据变得彻底安全。

7.6 服务端推送与手动刷新可能乱序执行,导致最终状态错误

推送和手动刷新都是异步事件,如果没有统一调度,很容易出现以下情况:推送写入版本 5,而手动刷新写入版本 3
,最终状态回到旧版本。示例中,推送逻辑同样进入 ConfigFetcher:

1
2
@ConfigFetcher
func applyPushedConfig(_ response: RemoteConfigResponse) async

于是无论事件来源是什么,都进入同一个业务隔离域。顺序完全由到达的先后决定,而不是线程时序。最终状态始终是序列中最后一个更新事件的结果。这是 Swift 并发体系对“复杂事件源”最具价值的能力之一。

7.7 任务取消不生效,后台还在继续执行旧逻辑

很多时候可能出现这种情况,页面已经销毁,任务仍旧在后台跑,结果返回来修改了一个已经释放或不再需要的对象。Swift Concurrency 提供了自然的取消行为。例如 fetchConfig 使用 sleep 模拟网络:

1
try await Task.sleep(nanoseconds: ...)

sleep 本身就是可取消挂起点,一旦上层 Task 被取消,sleep 会直接抛出 CancellationError,当前刷新流程终止,不会进入 store.setAll。这让取消机制天然有效,不需要额外编码。

7.8 隔离本质上不是“避免 bug”,而是“消灭 bug 的发生条件”

在获取服务端配置这套架构中,常见并发问题不是靠技巧性写法规避,而是靠结构本身不再允许这些问题发生。可以总结成一句话:当业务状态有归属、业务流程有顺序、数据传递不可变、界面更新有线程保障时,并发问题自然消失。
为了更清晰,可以用下面这张简明对应表概括:

并发风险 架构中对应的解决机制
多个刷新覆盖 ConfigFetcher 串行业务顺序
半更新状态泄漏 ConfigStore 原子性写入
刷新流程被打断 全局 actor 不可重入
UI 跨线程更新 @MainActor
数据竞争 ConfigSnapshot + Sendable
推送与手动刷新乱序 统一进入 ConfigFetcher
任务取消失效 await 提供天然取消点
表7-1 Swift 并发风险项与解决机制对照表

这些机制叠加在一起,让系统具备高度一致性,并发不再是需要谨慎面对的问题。下一章将总结 Swift 并发的整体思路,并从更宏观的角度给出这套模式的扩展方向与适用场景。

8. Swift 并发的整体视野:从隔离模型构建可预测的系统

获取服务端配置是一个规模不大、逻辑也不复杂的案例,却覆盖了现代应用中常见的并发难点:共享状态、长耗时任务、多个入口触发、UI 驱动、推送事件、取消机制……。整个示例展示了:Swift Concurrency 的真正价值不在于 async/await,而在于它有了一个系统的结构。 这种结构不是人为制定的规范,而是由语言层面的隔离机制形成的。当隔离域、任务调度、数据传递方式组合在一起时,系统的稳定性不再依赖经验,而是变成可验证、可推断的一套规则。下面从全局视角总结这些规则如何协同工作。

8.1 Swift 并发的核心思想

async/await 的意义在于让异步链路可阅读、可组合,但它并不解决并发安全。真正决定系统稳不稳定的,是“边界”:

  • 哪些状态可以被访问?
  • 哪个线程负责 UI?
  • 哪条逻辑具有排他性?
  • 哪些值可以跨线程传递?
  • 哪个执行环境处理特定任务?

Swift 给这些问题提供了结构化答案:

  • MainActor → UI 的唯一入口
  • ConfigFetcher → 刷新流程的唯一调度域
  • ConfigStore → 缓存状态的唯一读写入口
  • ConfigSnapshot → 跨域传输的唯一载体

这四个角色构成了整个异步系统的骨架。任何并发行为都必须沿着这些骨架行走,从而保持一致性。

8.2 获取服务端配置示例构建的三层体系

这套示例的结构可以用“三层隔离”概括:

  1. 第一层:界面层(MainActor)
    承载所有用户可见状态,包括当前快照、是否加载中、错误提示;天然绑定主线程,保证界面更新的正确性。
  2. 第二层:业务流程层(ConfigFetcher + ConfigRefreshService)
    承载获取配置的业务逻辑与顺序要求;通过全局 actor 保证刷新流程不会交错执行
  3. 第三层:资源层(ConfigStore)
    承载可变状态,是整个系统状态的一致性中心;actor 的串行访问保证不会发生 data race;通过原子性整体写入避免半更新状态。

三层之间互不越界,各自承担独立职责。这种结构保证系统能在并发条件下保持清晰的状态流动。

8.3 Swift 并发是如何让系统“可预测”的

“可预测”是并发编程中最难获得的特性。许多语言提供 async、future、promise,却没有提供结构性的隔离手段,最终依旧依赖锁、队列和经验。Swift 则以以下方式建立了可预测性:

  1. 状态只存在一个入口
    ConfigStore 让共享可变状态集中在一个 actor 中,所有读写都必须经过 await 调度。
  2. 业务序列化而非线程序列化
    ConfigFetcher 让刷新流程的因果关系永远成立。并发任务不会破坏业务顺序。
  3. UI 始终在正确线程中更新
    MainActor 自动保证界面修改只发生在主线程。不需要 GCD,也没有遗漏风险。
  4. 数据流是不可变的
    ConfigSnapshot 是 Sendable 的值类型,没有共享引用,不可能发生跨线程修改。
  5. 推送事件、安全合流
    服务端推送、手动刷新、自动刷新都进入同一个业务域。顺序天然一致,逻辑不会分叉。这一体系将原本“靠习惯和技巧维持”的异步系统,变成“靠语言和结构保证”的系统。

8.4 隔离模型的可扩展性

获取服务端配置只是一个示例,但这种并发结构可自然扩展到更复杂的模块,例如:

  • 功能开关中心;
  • 会话管理;
  • 下载中心;
  • 多源同步系统;
  • 后端推送与本地存储合流;
  • 基于策略的线上配置管理;
  • 复杂 UI 状态同步;
  • 模块化架构中的跨模块状态共享。

在这些场景中,都可以复用相同的结构思想:

  • 资源层隔离可变状态
  • 流程层隔离业务顺序
  • 界面层隔离用户可见状态
  • 数据以不可变形式跨域传递

这让模块变得稳健、可扩展、易维护。

8.5 从“面向线程编程”到“隔离式编程”

在传统并发体系中,很多问题必须通过“线程意识”解决:这里应该上锁,那里需要回到主线程,这个对象不能跨线程传,那个流程要避免并发。Swift Concurrency 的思路完全相反:

  • 无需关心线程,而是关心隔离域。
  • 无需管理锁,而是管理状态归属。
  • 无需判断当前在哪个线程,而是让 MainActor 自动决定。
  • 无需担心谁先完成,而是让 Global Actor 决定顺序。
  • 无需拷贝整个对象,而是让 Sendable 保证不可变性。

从线程思维跳到隔离思维,是 Swift Concurrency 真正提升开发质量的地方。

8.6 总结

整个系统构建下来,可以用三句话总结 Swift 并发的真正目标:

  • 状态有归属。
    每一份可变状态都属于一个独立的 actor,访问方式受约束,永远不会裸奔。
  • 流程有顺序。
    业务逻辑的执行顺序不依赖线程竞争,而是由 Global Actor 明确控制。
  • 数据有形状。
    跨域传递的值必须 Sendable,可预测、可验证、不可变。

这三点结合起来,构成了 Swift 并发的稳健性基础。

Swift Concurrency 并没有发明新线程模型,而是给现有的并发世界增加了“结构”。这种结构使得应用在面对并发压力时仍然保持稳定,让复杂系统更容易推断与扩展。

未来无论遇到用户会话、数据同步、推送事件融合、复杂 UI 状态管理,都可以沿着这条思路构建可预测的并发体系。这样构建出来的系统,不仅稳定,而且优雅——因为它拥有清晰的边界、有序的数据流,以及默认正确的行为。

9. 备注

获取服务端配置示例代码