WWDC Quick Look 💓 By SwiftGGTeam
Visualize and optimize Swift concurrency

Visualize and optimize Swift concurrency

观看原视频

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

评论

GitHub Issues · utterances