Highlight
Instruments 14 新增了 Swift Concurrency 模板,可以可视化 Task 和 Actor 的运行状态。Mike 和 Harjas 用一个文件压缩 App 演示了如何定位 Main Actor 阻塞、Actor 串行竞争和线程池耗尽三大性能问题,并通过重构代码让 App 从”UI 卡死”变成”多核并行、界面流畅”。
核心内容
Swift Concurrency 让并发代码更容易写对,但写对不等于写快。Mike 和 Harjas 指出三类常见性能问题:Main Actor 阻塞导致 UI 卡死、Actor 串行竞争导致并行度下降、Continuation 误用导致泄漏或崩溃。Instruments 14 新增的 Swift Concurrency 模板专门用来捕捉和可视化这些问题。(00:28)
演示 App 叫 File Squeezer,功能是批量压缩文件夹里的文件。小文件时工作正常,大文件时 UI 完全冻结。Harjas 用 Command-I 启动 Instruments,选择 Swift Concurrency 模板,录制一段运行数据。(04:18)
先看顶层统计:Running Tasks 显示同时执行的任务数,Alive Tasks 显示存活任务数,Total Tasks 显示累计创建的任务数。Harjas 注意到 Running Tasks 大部分时间只有 1 个,说明所有工作被强制串行了。他用 Pin 功能把最长时间的任务钉在时间线上,Narrative View 显示这个任务先在后台线程跑了一小段时间,然后长时间占用主线程。这就是 Main Actor 阻塞。(08:42)
问题的根源在 CompressionState 类。整个类标记了 @MainActor,而 compressFile 方法执行的是耗时计算。虽然计算被包在 Task {} 里,但因为 Task 在 Main Actor 上下文中创建,它继承了 Main Actor 的隔离域,耗时计算仍然跑在主线程上。(10:24)
解决方案是拆分状态。files 属性是 @Published,必须在 Main Actor 上更新;但 logs 只是内部状态,不需要主线程。于是创建一个新的 ParallelCompressor Actor,把日志状态和压缩逻辑移进去。CompressionState 保留 UI 相关的 files,通过 await 调用 ParallelCompressor 的方法。(11:49)
改完后 UI 不再卡死,但压缩速度还是不够快。Harjas 再次用 Instruments 分析,发现 Task Summary 里大量任务处于 Enqueued 状态——它们在排队等待进入 ParallelCompressor Actor。原因是 compressFile 方法完全运行在 Actor 隔离域内,同一时间只能有一个任务执行压缩。(14:43)
第二次重构:把 compressFile 标记为 nonisolated,让它脱离 Actor 隔离。这样压缩计算可以在线程池的任意线程上并行执行,只在需要访问 logs 时才短暂进入 Actor。同时把 Task {} 改成 Task.detached {},确保新任务不继承创建时的 Actor 上下文。(17:46)
改完后 App 能同时压缩所有文件,UI 保持响应。Instruments 验证显示 ParallelCompressor Actor 上的任务都很短,队列长度始终可控。
详细内容
Main Actor 阻塞的诊断与修复
(04:18)File Squeezer 的初始代码把所有逻辑放在 @MainActor 类里:
@MainActor
class CompressionState: ObservableObject {
@Published var files: [FileStatus] = []
var logs: [String] = []
func update(url: URL, progress: Double) {
if let loc = files.firstIndex(where: {$0.url == url}) {
files[loc].progress = progress
}
}
func compressAllFiles() {
for file in files {
Task {
let compressedData = compressFile(url: file.url)
await save(compressedData, to: file.url)
}
}
}
func compressFile(url: URL) -> Data {
log(update: "Starting for \(url)")
let compressedData = CompressionUtils.compressDataInFile(at: url) { uncompressedSize in
update(url: url, uncompressedSize: uncompressedSize)
} progressNotification: { progress in
update(url: url, progress: progress)
log(update: "Progress for \(url): \(progress)")
} finalNotificaton: { compressedSize in
update(url: url, compressedSize: compressedSize)
}
log(update: "Ending for \(url)")
return compressedData
}
func log(update: String) {
logs.append(update)
}
}
关键点:
@MainActor让整个类跑在主线程上compressFile是耗时操作,但因为它在@MainActor类里,所以也在主线程执行Task {}继承了 Main Actor 上下文,所以异步任务也跑在主线程- Instruments 的 Task State Summary 可以定位最长任务,Pin 到时间线后 Narrative View 显示任务在主线程上长时间运行
修复方案:把 logs 和压缩逻辑拆到独立的 Actor:
actor ParallelCompressor {
var logs: [String] = []
unowned let status: CompressionState
init(status: CompressionState) {
self.status = status
}
func compressFile(url: URL) -> Data {
log(update: "Starting for \(url)")
let compressedData = CompressionUtils.compressDataInFile(at: url) { uncompressedSize in
Task { @MainActor in
status.update(url: url, uncompressedSize: uncompressedSize)
}
} progressNotification: { progress in
Task { @MainActor in
status.update(url: url, progress: progress)
await log(update: "Progress for \(url): \(progress)")
}
} finalNotificaton: { compressedSize in
Task { @MainActor in
status.update(url: url, compressedSize: compressedSize)
}
}
log(update: "Ending for \(url)")
return compressedData
}
func log(update: String) {
logs.append(update)
}
}
@MainActor
class CompressionState: ObservableObject {
@Published var files: [FileStatus] = []
var compressor: ParallelCompressor!
init() {
self.compressor = ParallelCompressor(status: self)
}
func compressAllFiles() {
for file in files {
Task {
let compressedData = await compressor.compressFile(url: file.url)
await save(compressedData, to: file.url)
}
}
}
}
关键点:
ParallelCompressor是独立 Actor,保护logs状态- 进度回调用
Task { @MainActor in ... }跳回主线程更新 UI CompressionState只保留@Published的 UI 状态compressAllFiles通过await调用 Actor 方法
Actor 串行竞争的诊断与修复
(14:43)第一次重构后,Instruments 显示大量任务在 Enqueued 状态等待进入 ParallelCompressor。原因是 compressFile 完全在 Actor 隔离域内执行,同一时间只能有一个任务运行。
修复方案:把 compressFile 标记为 nonisolated,用 Task.detached 创建独立任务:
actor ParallelCompressor {
var logs: [String] = []
unowned let status: CompressionState
init(status: CompressionState) {
self.status = status
}
nonisolated func compressFile(url: URL) async -> Data {
await log(update: "Starting for \(url)")
let compressedData = CompressionUtils.compressDataInFile(at: url) { uncompressedSize in
Task { @MainActor in
status.update(url: url, uncompressedSize: uncompressedSize)
}
} progressNotification: { progress in
Task { @MainActor in
status.update(url: url, progress: progress)
await log(update: "Progress for \(url): \(progress)")
}
} finalNotificaton: { compressedSize in
Task { @MainActor in
status.update(url: url, compressedSize: compressedSize)
}
}
await log(update: "Ending for \(url)")
return compressedData
}
func log(update: String) {
logs.append(update)
}
}
@MainActor
class CompressionState: ObservableObject {
@Published var files: [FileStatus] = []
var compressor: ParallelCompressor!
init() {
self.compressor = ParallelCompressor(status: self)
}
func compressAllFiles() {
for file in files {
Task.detached {
let compressedData = await self.compressor.compressFile(url: file.url)
await save(compressedData, to: file.url)
}
}
}
}
关键点:
nonisolated让方法脱离 Actor 隔离,可以在任意线程执行- 因为
nonisolated,访问 Actor 状态(如log)需要加await Task.detached不继承创建时的 Actor 上下文,确保任务在线程池自由调度self在 detached task 中需要显式捕获- 压缩计算现在可以真正并行,只受限于 CPU 核心数
Instruments 的 Swift Concurrency 模板功能
(04:58)Instruments 14 的 Swift Concurrency 模板包含多个视图:
Swift Tasks Instrument 统计:
- Running Tasks:同时执行的任务数
- Alive Tasks:当前存活任务数
- Total Tasks:累计创建的任务数
Task Forest:
- 图形化展示结构化并发中的父子任务关系
- 位于窗口下半部分
Task Summary:
- 显示每个任务在不同状态(Running、Suspended、Enqueued 等)的耗时
- 右键点击任务可以 Pin 到时间线
Pin Task 后的时间线包含:
- Task 状态轨道
- 扩展详情中的创建调用栈
- Narrative View:解释任务当前状态,如”等待哪个 Task”
- 可以从 Narrative View 继续 Pin 子任务、线程或 Actor
Swift Actors Instrument:
- 显示每个 Actor 的队列长度和任务执行时间
- 用于验证 Actor 是否被长时间占用
线程池耗尽与 Continuation 误用
(19:46)Mike 补充了两个常见问题:
线程池耗尽:
- Swift Concurrency 要求任务必须持续前进
- 阻塞调用(同步文件 IO、网络请求、锁等待)会占用线程但不使用 CPU
- 线程池有限,阻塞任务多了会导致其他任务无法执行,极端情况下死锁
- 解决方案:用异步 API 替代阻塞调用;必须用阻塞调用时,放到 Dispatch Queue 再用 Continuation 桥接
Continuation 误用:
withCheckedContinuation的回调必须且只能调用一次- 调用零次:任务永远挂起,内存泄漏
- 调用两次:程序崩溃或行为异常
- 用
withCheckedContinuation(而非withUnsafeContinuation)可以在运行时检测误用 - Instruments 会在时间线上标记任务处于 Continuation 等待状态
核心启发
-
做什么:给你的并发代码跑一遍 Instruments 的 Swift Concurrency 模板。
为什么值得做:Harjas 的演示说明,肉眼很难判断 Task 跑在哪个线程、Actor 队列有多长。Instruments 把时间线、任务状态、Actor 队列全部可视化,定位问题从”猜”变成”看”。
怎么开始:Xcode 里按 Command-I,选择 Swift Concurrency 模板,录制一段包含典型工作负载的运行数据。先看 Running Tasks 是否接近 CPU 核心数,再看有没有任务长时间 Enqueued。 -
做什么:审查代码中所有
@MainActor标记的类,把耗时操作移出去。
为什么值得做:File Squeezer 的问题根源是整个类标记了@MainActor,导致所有方法都跑在主线程。正确的做法是只把@Published属性和 UI 更新留在 Main Actor,其他状态用独立 Actor 或nonisolated方法保护。
怎么开始:搜索项目里的@MainActor,检查每个标记的类/方法是否做了耗时操作(文件读写、网络请求、大量计算)。如果有,把耗时逻辑拆到独立 Actor 或 detached task。 -
做什么:给 Actor 的方法加
nonisolated标记,让计算逻辑脱离 Actor 串行。
为什么值得做:第一次重构后 File Squeezer 的 UI 不卡了,但压缩还是串行。原因是compressFile完全在 Actor 隔离域内。标记nonisolated后,计算在普通线程池并行执行,只在写日志时短暂进入 Actor。
怎么开始:检查你的 Actor 方法,问自己”这个方法的大部分时间是在做计算,还是在访问 Actor 状态?“如果是前者,加nonisolated,把状态访问改成await。
关联 Session
- Eliminate data races using Swift Concurrency — explore swift’s approach to eliminating data races and its effect on app architecture.
- Swift concurrency: Behind the scenes — dive deep into the swift concurrency runtime and learn how async/await and actors work under the hood.
- Meet async/await in Swift — learn how to use async/await to write asynchronous code that is easier to read and maintain.
评论
GitHub Issues · utterances