主线程与 @MainActor
本教程共 93 篇 · 第 85 篇 · 更新于 2026-08-08 · 约 7 分钟阅读
本节目标:理解主 actor 怎么保护界面相关数据、为什么 UI 更新必须串行到主线程,并掌握用 @MainActor 标记函数、类型和闭包。
在图形界面程序里,有一条铁律:所有界面相关的操作,都必须在主线程上进行。Swift 把这条铁律升华成了类型层面的保障,叫主 actor(main actor)。它本质上是一个全局的、单例的 actor,专门串行化所有跟 UI 有关的数据和代码。
1-1 为什么 UI 必须串到主线程
界面框架通常要求:更新按钮文字、刷新列表、改背景色这类操作,绝对不能多个线程同时做。否则两个线程各改一半,界面状态会乱套、甚至直接崩溃。
解决思路就是”排队”:把所有 UI 操作塞进一个串行通道,一次只处理一个,绝不同时发生。这个串行通道在 Swift 并发里就体现为主 actor。它在底层确实跑在主线程上,但你要打交道的是”主 actor”这个概念,而不是具体的线程。
Note主 actor 与主线程关系密切但不同:主 actor 持有私有的可变状态,主线程负责把这些访问串行化。代码跑在主 actor 上,Swift 就会让它在主线程执行。日常口语里两者常混用,但你的代码应该面向”主 actor”思考。
1-2 用 @MainActor 标记函数
要让某个函数保证在主 actor 上运行,给它加 @MainActor 属性:
@MainActor
func show(_ photo: Data) {
// 更新界面的代码
}
标了之后,show 只能在主 actor 上被调用。如果当前已经在主 actor 上(比如另一个被 @MainActor 标记的函数里),你可以像普通同步函数一样直接调它。但若从不在主 actor 上的代码调,必须写 await,因为跨到主 actor 是一个潜在挂起点:
func downloadAndShow(named name: String) async {
let photo = await downloadPhoto(named: name) // 后台下载
await show(photo) // 切回主 actor 更新 UI
}
这是个极常见的模式:耗时的下载/计算在后台做,做完 await show(photo) 切回主 actor 更新界面。只有 show 那步在主 actor 上,长耗时工作并不占用它。
1-3 用 @MainActor 标记整个类型
你可以把 @MainActor 写到结构体、类或枚举上,让它所有方法和属性访问都跑在主 actor:
@MainActor
struct PhotoGallery {
var photoNames: [String]
func drawUI() {
// 画界面的代码
}
}
photoNames 会影响界面,所以改它必须在主 actor 上串行。PhotoGallery 整体标了 @MainActor,内部所有读写自然都受保护。
1-4 更细粒度的标记
不必总是整类型标记。只把真正碰界面的部分标上即可,其余放后台:
struct PhotoGallery {
@MainActor var photoNames: [String]
var hasCachedPhotos = false
@MainActor func drawUI() { /* 界面代码 */ }
func cachePhotos() { /* 网络代码,不必在主 actor */ }
}
drawUI() 要画界面、标 @MainActor;photoNames 存着界面要用的状态,也标 @MainActor。cachePhotos() 只做网络缓存、不碰界面,就不标,能在后台跑。这样把”必须串行的”和”可以并行的”分得清清楚楚,性能更好。
1-5 标记闭包
想让一段闭包在主 actor 上运行,把 @MainActor 写在捕获列表之前、in 之前:
let photo = await downloadPhoto(named: "日出")
Task { @MainActor in
show(photo)
}
这里 Task { @MainActor in ... } 开启的任务被限定在主 actor 上,闭包内可以直接调 show,无需再 await。这是”后台取数据、主线程更新”的另一种写法。
1-6 框架替你标好了
用 UI 框架(比如 SwiftUI)时,框架的协议和基类通常已经标了 @MainActor。你写个遵循它的类型,会隐式继承主 actor 隔离,自己常常不用再写:
@MainActor
protocol View { /* ... */ }
// 隐式 @MainActor
struct PhotoGalleryView: View { /* ... */ }
因为 View 协议标了 @MainActor,PhotoGalleryView 自动也是。这跟”基类标了、子类隐式继承”一个道理。所以实际项目里你看到的 @MainActor 往往比想象的少——框架已经铺好了。
Tip经验法则:凡是会读或写界面状态的代码(方法、属性、闭包),都要确保在主 actor 上。最简单的做法是给相关类型整体标
@MainActor;框架类型已标好的,顺着它的隔离即可。
1-7 常见错误
最容易犯的是”在后台线程更新了 UI”。典型症状:程序偶尔崩溃、界面偶发错乱,报错指向界面 API。根因是你在没标 @MainActor 的异步函数里直接改了界面状态。修法是把那段代码移进 @MainActor 函数,或用 await 切回主 actor 再改。
另一个坑是滥用 @MainActor 把长耗时逻辑也圈进去,结果界面又卡了。记住:只有”读写界面”才需要在主 actor,耗时计算该留在后台,最后一步再 await 切回来。
Warning不要因为”标了 @MainActor 就不崩”就一股脑把所有异步代码都标主 actor。那等于把并发全串行回了主线程,等于没并发。精准标记,才既安全又流畅。
1-8 用 MainActor.run 临时切回
有时你手里是一段没标 @MainActor 的普通异步代码,但其中某一小步需要更新界面。不必把整个函数都标主 actor,可以用 MainActor.run 临时切回去:
func refresh() async {
let data = await loadFromNetwork() // 后台
let model = parse(data) // 后台
await MainActor.run {
self.label.text = model.title // 切回主 actor 更新 UI
}
}
MainActor.run 接受一个闭包,闭包内容保证在主 actor 上运行。它适合”只有一句话要碰界面”的场景,比给整个函数标 @MainActor 更精准,避免把后台逻辑也拖进主 actor。
Tip判断粒度的小窍门:如果函数里”只有最后几行碰界面”,用
MainActor.run;如果”大半个函数都在读写界面状态”,直接给函数标@MainActor。两种都是对的,只是粗细不同。
1-9 主 actor 是全局单例 actor
值得单独点一句:主 actor 不是一个普通的 actor 实例,而是 MainActor 这个类型的全局单例——整个程序只有一个。普通 actor 可以有多个实例,各自独立隔离;但主 actor 天生只有一个,所以光用 @MainActor 这个属性就足以标识”主线程那片状态”,不需要你持有它的实例。
这种”用属性而非实例来标记隔离”的灵活性,源于全局 actor 的设计。你自己也能用 @globalActor 定义类似的全局单例 actor(比如一个专门串行化数据库访问的 actor),但那是进阶话题。记住主 actor 的单例本质,有助于你理解为什么 @MainActor 能到处直接用、且指向同一片串行状态。
Tip当你看到
@MainActor出现在函数、属性、类型、闭包各处,要知道它们全都指向同一个全局串行通道——主线程。它们彼此不会并发,这正是界面安全的前提。
1-10 小结
主 actor 是保护界面数据的全局单例 actor,对应主线程。用 @MainActor 标记函数、类型、属性或闭包,确保相关代码串行执行;从非主 actor 跨域调用它要写 await。框架类型多已隐式标记,你顺着隔离即可。牢记”耗时在后台、更新回主 actor”的分工,是写出流畅又不崩并发代码的关键。
下一章讲 AsyncSequence:当数据不是一次给完、而是一股流时,怎么用 for await 逐条消费。