大多数浏览器和
Developer App 均支持流媒体播放。
-
Swift 并发的可视化与优化
了解如何利用 Instruments 中的 Swift 并发模板优化您的 App。我们将讨论常见的性能问题,并向您介绍如何借助 Instruments 查找与解决这些问题。学习如何保持您的 UI 响应性,最大化并行性能,以及分析您的 App 中的 Swift 并发活动。为能更好地理解此讲座,我们建议您先熟悉 Swift 并发 (包括任务和角色) 的相关内容。
资源
相关视频
WWDC23
WWDC22
WWDC21
-
下载
♪ ♪
Mike: 欢迎来到 Swift 并发的可视化与优化 我是 Mike 我从事 Swift 运行时库方面的工作 Harjas: 大家好 我是 Harjas 从事 Instruments 方面的工作 Mike: 我们将一起讨论如何更好地理解 您的 Swift 并发代码 并使其运行得更快 也会谈到在 Instruments 14 中 提供的一个新可视化工具 让我们先快速回顾一下 Swift Concurrency 的各个部分 以及它们是如何一起工作的 以确保您保持速度 之后 我们将演示新的并发工具 我们将向您展示该如何使用它 解决一些使用 Swift Concurrency 的 App 的实际性能问题 最后 我们将讨论潜在的线程池耗尽 和continuation误用问题 以及如何避免它们 去年 我们引入了 Swift Concurrency 这是一个新的语言功能 包括 async/await 结构化并发和 Actor 我们很高兴地看到 那之后在 Apple 内外 这些功能都被大量运用 Swift concurrency 为该语言 增加了几个新功能 它们共同使并发编程更容易 更安全 Async/await 是并发代码的 基本语法构建块 它们允许您创建并调用 可以在执行过程中 挂起工作 并在稍后恢复工作的函数 而不会阻塞执行线程
Task 是并发代码中的基本工作单元 Task 会执行并发代码 并管理其状态和相关数据 它们包含局部变量 处理取消 开始和挂起异步代码的执行 结构化并发使生成并行运行的子任务 并等待它们完成变得很容易 该语言提供了 将工作聚集在一起的语法 并确保任务被等待 或在不使用时自动取消 Actor 会协调需要访问 共享数据的多个任务 它们将数据与外部隔离 并且每次只允许一个任务 操作它们的内部状态 从而避免了并发变更带来的数据竞争 在 Instruments 14 中 我们新引入了一套 可以捕获及可视化 App 中 所有这些活动的工具 以帮助您了解 App 正在做什么 定位问题 并提高性能 为了更深入地讨论 Swift Concurrency 的基本原理 我们在相关视频部分 放了几个 关于这些功能的视频链接
让我们看看如何使用 Swift Concurrency 代码来优化 App Swift concurrency 使得 编写正确的并发和并行代码 变得很容易 然而 编写误用并发结构的代码 仍然是可能的 您也有可能正确地使用它们 但无法获得您所期望的性能优势
在使用 Swift concurrency 编写代码时 会出现一些常见的问题 这些问题可能会导致性能下降 或出现 bug Main Actor 阻塞会导致 App 中止 Actor 争用和线程池耗尽会减少并行执行 从而损害性能 continuation误用会导致泄漏或崩溃 新的 Swift Concurrency 工具 可以帮助您发现并修复这些问题 让我们从 main Actor 阻塞开始 逐一看看 当一个长期运行的任务 在 main Actor 上运行时 就会发生 main Actor 阻塞 main Actor 是一个特殊的 Actor 它在主线程上 执行所有工作 UI 工作必须在主线程上完成 main Actor 允许您将 UI 代码 集成到 Swift Concurrency 中 但是 因为主线程对 UI 非常重要 所以它需要保持可用 不能被长时间运行的工作单元占用 当这种情况发生时 您的 App 会像锁定一样并不再响应 在 main Actor 上运行的代码 必须快速完成 要么完成它的工作 要么将计算 从 main Actor 移到后台 通过将工作放到一个普通的 Actor 或一个分离的任务中 可以将它移到后台 小的工作单元可以在 main Actor 上执行 以更新 UI 或执行其他 必须在主线程上完成的任务 让我们看看实际的演示 Harjas: 谢谢 Mike 这是我们的 File Squeezer App 我们构建这个 App 是为了 能够快速压缩文件夹中的 所有文件 对小文件 它似乎运行得很好 然而 当我使用更大的文件时 它花费的时间比预期长得多 UI 完全停摆 不响应任何交互 这种行为会让用户非常反感 可能会让他们认为 App 已经崩溃 或永远不会完成运作 我们应该努力确保我们的 UI 能够持续响应 最佳用户体验 为了研究这个性能问题 我们可以使用 Instruments 中 新的 Swift Concurrency 模板 Swift Tasks 和 Swift Actors 工具 提供了一套完整的工具 来帮助您可视化并优化并发代码 当您刚刚开始调查性能问题时 您应该首先查看一下 Swift Tasks 工具提供给您的 顶层统计数据 其中第一个是 Running Tasks 它显示了有多少任务正在同时执行 接下来 是我们的 Alive Tasks 它会显示在给定的时间点上 有多少任务出现 最后是 Total Tasks 它会绘制到该时间点为止 已创建的任务总数 当您试图减少 App 的内存占用时 应该仔细查看 Alive 和 Total Tasks 统计信息 所有这些统计数据的组合 可以让您很好地了解 代码的并行化情况 以及正在消耗多少资源 该工具的众多细节视图之一 是 Task Forest 它被显示在这个窗口的下半部分 提供了结构化并发代码中 任务之间的 主从关系的图形表示 接下来 是我们的 Task Summary 视图 这显示了每个任务 在不同状态下花费的时间 我们已经通过 允许您右键单击一个任务 就将包含所选任务所有信息的轨道 固定到时间线上 使视图更加强大 这允许您快速查找并了解需要关注的 可能运行了很长时间 或等待访问 Actor 的任务 一旦您把一个 Swift Task 固定到 时间线上 就会得到四个关键功能 首先是显示您的 Swift Task 处于什么状态的轨道 接下来 是扩展详细视图中的 任务创建回溯 第三个是叙述视图 它提供了更多 关于 Swift Task 所处状态的背景 例如 如果它正在等待一个任务 它将通知您正在等待哪个任务 最后 您可以在叙述视图中 访问与在摘要视图中 相同的固定操作 所以 您可以把一个子任务 一个线程 甚至一个 Swift Actor 固定到时间线上 这种叙述视图有助于发现 一个 Swift Task 如何与其他 并发原语和 CPU 相关联 现在我们已经简要地了解了 新工具中的一些功能 让我们来分析 App 并优化代码吧 我们可以在 Xcode 中拉出我们的项目 按下 Command-I 来实现这一点 这将编译我们的 App 打开 Instruments 并将目标预选为 File Squeezer App 您可以从这里在模板选择器中 选择 Swift Concurrency 选项 并开始记录
我再次将大文件放入了 App 中
我们再次看到 App 开始旋转 UI 没有响应 我们将让它再运行几秒钟 以便 Instruments 能够捕获 关于我们 App 的所有信息
既然我们有了线索 那么就可以开始调查了 我要全屏显示这段跟踪 以便更好地看到所有信息
我们可以使用 option+拖拽 来放大我们感兴趣的区域
在进程跟踪中 Instruments 向我们显示了 UI 中止发生的确切位置 对于不清楚中止的发生时间 或持续时间的情况 这很有用 正如我前面提到的 一个很好的起点 是顶层 Swift Task 统计数据 我马上注意到的是 Running Tasks 计数 大多数时候 只有一个任务在运行 这告诉我们 部分问题在于 我们的所有工作都被迫序列化 我们可以用 Task State 摘要 来查找运行时间最长的任务 并使用固定操作 将其固定到时间线上
这个任务的叙述视图告诉我们 它在后台线程上 运行了一小段时间 然后在主线程上 运行了很长一段时间 为了进一步研究 我们可以将主线程固定到时间线上
主线程被几个长时间运行的任务阻塞 这就展示了 Mike 提到的 Main Actor 阻塞问题 所以我们要问自己的问题是 “这个任务在做什么” 以及 “这个任务从何而来” 我们可以回到叙述视角 来回答这两个问题 扩展详细视图中的创建回溯显示 任务是在 compressAllFiles 函数中创建的 这个说明显示任务正在执行 compressAllFiles 中的 一号闭包 右键单击这个符号 我们就可以 在源代码查看器中打开它
这个函数中的一号闭包 调用了我们的压缩代码 现在我们知道了这个任务 是在哪里创建的以及它在做什么 我们就可以在 Xcode 中 打开我们的代码并调整它了 这样我们就不会在主线程上 运行这些繁重的计算 compressAllFiles 函数 位于 CompressionState 类中 整个 CompressionState 类被注释为 在 @MainActor 上运行 这解释了 为什么任务也运行在主线程上 我们需要将这个类放在 MainActor 上 因为这里的 @Published 属性 必须只从主线程更新 否则 我们可能会遇到运行时问题 因此 我们可以尝试将这个类 转换为它自己的 Actor 然而 编译器会告诉我们不能这样做 因为我们其实是在说 这个共享的可变状态 需要由两个不同的 Actor 保护 但这确实给了我们一个关于 真正的解决方法是什么的提示 在这个类中 我们有两个不同的可变状态 其中一个状态 即 files 属性 需要与 MainActor 隔离 因为 SwiftUI 会观察它 但是 需要保护对另一种状态 即日志的访问 不受并发访问的影响 但是在任何给定的点上 哪个线程访问日志并不重要 因此 它实际上并不需要 被放在 Main Actor 上 但是 我们仍然希望保护它 不受并发访问的影响 所以我们要将它 封装到自己的 Actor 中 我们现在所要做的就是添加一种方法 让 Task 根据需要在两者之间进行跳转 我们可以创建一个新的 Actor 并将其命名为 ParallelCompressor
然后 我们可以将日志状态 复制到新的 Actor 中 并添加一些额外的设置代码
从这里开始 我们需要 让这些 Actor 相互通信 首先 让我们从 CompressionState 类中 删除引用日志变量的代码 并将其添加到 ParallelCompressor Actor 中
最后 我们需要更新 CompressionState 以在 ParallelCompressor 上 调用 compressFile
有了这些更改 让我们再次测试 App 我要再次把大文件放到 App 中
UI 不再中止 这是一个很大的改进 但我们没有得到我们期望的速度 我们很想充分利用机器中的所有核心 以尽可能快的速度完成这项工作 Mike 我们还应该注意什么呢
Mike: 我们把工作从 Main Actor 上移走 解决了卡顿问题 但我们仍没有得到我们想要的性能 要知道为什么 我们需要仔细看看 Actor Actor 使多个任务 可以安全地操纵共享状态 但是 它们是通过序列化 对共享状态的访问来实现这一点 一次只允许一个任务占用 Actor 其他需要使用该 Actor 的任务 将会等待 Swift concurrency 允许使用 非结构化任务 任务组 和 async let 进行并行计算 理想情况下 这些结构 能够同时使用多个 CPU 核心 当使用这些代码中的 Actor 时 要注意不要在这些任务 共享的 Actor 上执行大量的工作 当多个任务试图 同时使用同一个 Actor 时 Actor 将序列化这些任务的执行 因此 我们失去了并行计算的性能优势
这是因为每个任务 都必须等待 Actor 可用 要解决这个问题 我们需要确保当任务真正需要 独占访问 Actor 的数据时 才在 Actor 上运行 其他的都应该在 Actor 之外运行 我们要把任务分成几块 有些块必须在 Actor 上运行 而其他的则不需要 非 Actor 隔离块可以并行执行 这意味着计算机可以更快地完成工作 让我们看看运行中的演示 Harjas: 谢谢 Mike 让我们看看我们更新的 File Squeezer App 的轨迹 并想着 Mike 刚刚告诉我们的内容 Task Summary 显示我们的并发代码 在 Enqueued 状态中花费了惊人的时间 这意味着我们有很多任务等待着 获得对 Actor 的独占访问权 让我们锁定其中一个任务 来了解原因
这个任务在运行压缩工作之前 会花很长时间 等待进入 ParallelCompressor Actor 让我们继续将 Actor 固定到时间线上
这里 我们有 ParallelCompressor Actor 的 一些顶层数据 这个 Actor Queue 似乎被一些 长时间运行的任务阻塞了 任务应该只在 Actor 上 停留其需要的时间 让我们回到任务叙述上
在 ParallelCompressor 上排队之后 任务会在 compressAllFiles 的 一号闭包中运行 所以让我们从那里开始调查 源代码显示了这个闭包 主要是在运行我们的压缩工作 因为 compressFile 函数是 ParallelCompressor Actor 的一部分 所以这个函数的整个执行 都发生在 Actor 上 阻塞了其他所有压缩工作 要解决这个问题 我们需要把 compressFile 函数 从 Actor 隔离中取出来 放到一个分离的任务中
通过这样做 我们就可以 通过分离的任务 在 Actor 上 尽占用更新相关可变状态所需的时长 所以现在压缩函数可以在线程池中的 任何线程上自由执行 直到它需要访问 被 Actor 保护的状态 例如 当它需要访问 files 属性时 它将移到 Main Actor 上 但一旦完成 它就会 再次进入 “并发的海洋” 直到它需要访问 logs 属性 为此它会移到 ParallelCompressor Actor 上 但同样地 一旦在那里完成 它就会再次离开 Actor 继续在线程池上执行 不过当然了 我们不是只有一个 在做压缩工作的任务 我们有很多这样的任务 由于不受 Actor 的限制 它们都可以并发执行 只受线程数量的限制
当然 每个 Actor 一次只能执行一个任务 但大多数时候 我们的任务 不需要在 Actor 上执行 因此 就像 Mike 解释的那样 这允许我们的压缩任务 并行执行 并利用所有可用的 CPU 核心 现在我们来做些改变
我们可以将 compressFile 函数 标记为非隔离的
这确实会导致一些编译器错误 通过将它标记为 nonisolated 我们是在告诉 Swift 编译器 我们不需要访问 这个 Actor 的共享状态 但这并不完全正确 这个日志功能是受 Actor 隔离的 并且需要访问共享的可变状态 为了解决这个问题 我们需要让这个函数异步 然后用 await 关键字 标记所有的日志调用
现在 我们需要更新任务创建 以创建一个分离的任务
我们这样做是为了确保 任务不会继承 曾创建它的 Actor 上下文 对于分离的任务 我们需要显式捕获 self
让我们再次测试我们的 App
该 App 能够同时压缩所有文件 并使 UI 保持响应 为了验证我们的改进 我们可以检查 Swift Actors 工具 看看 ParallelCompressor Actor Actor 上执行的大部分工作 都是在很短的时间内完成的 队列的大小也不会超出控制范围 总结一下 我们使用 Instrument 来分析 UI 卡顿的原因 重新构造我们的并发代码 以获得更好的并行性 并使用数据验证性能改进 现在 Mike 要告诉我们一些 其他潜在的性能问题 Mike: 除了我们在演示中看到的 我还想讲两个常见的问题 首先 让我们讨论一下线程池耗尽 线程池耗尽会损害性能 甚至导致 App 死锁 Swift concurrency 要求任务 在运行时向前推进 当任务在等待什么的时候 它通常通过挂起完成 但是 任务中的代码 可以执行阻塞调用 例如阻塞文件或网络 IO 或获取锁 而不挂起 这打破了任务向前推进的要求 当这种情况发生时 任务会继续占用它正在执行的线程 但它实际上并没有使用 CPU 核心 由于线程池是有限的 其中一些线程被阻塞了 并发运行时将无法完全使用 所有 CPU 内核 这减少了可执行的并行计算量 降低了 App 的最大化性能 在极端情况下 当整个线程池被阻塞的任务所占用 并等待一些需要在线程池上 运行的新任务 并发运行时可能会死锁 确保避免任务中阻塞的调用 文件和网络 IO 必须使用 异步 API 执行 避免等待条件变量或信号量 如果需要 细粒度 短暂持有的锁 是可以接受的 但是要避免有大量争用 或长时间持有的锁 如果您有需要完成这些任务的代码 请将该代码移出并发线程池 例如 通过在 Dispatch 队列上运行它 并使用 continuation 将其桥接到并发世界 只要有可能 使用异步 API 来替换阻塞操作 以保持系统的顺畅运行 在使用 continuation 时 必须小心 正确地使用它们 continuation 是 Swift concurrency 和其他形式的异步代码之间的桥梁 continuation 会暂停当前任务 并提供一个在调用时 恢复任务的回调函数 然后 这可以与基于回调的 异步 API 一起使用 从 Swift concurrency 的角度看 任务会挂起 然后在 continuation 被恢复时恢复 从基于回调的 异步 API 的角度来看 工作开始 然后在工作完成时调用回调 Swift Concurrency Instrument 知道 continuation 并会标记相应的时间间隔 显示任务正在等待 一个 continuation 被调用 continuation 回调函数有一个特殊的要求 它们必须被调用一次 不能多也不能少 在基于回调的 API 中 这是一个常见的需求 但它往往是非正式的 并且不被语言强制执行 常常发生疏忽 Swift concurrency 将此作为一个硬性需求 如果回调函数被调用两次 程序就会崩溃或出错 如果从未调用回调 任务将会泄漏 在这个代码片段中 我们使用 withCheckedContinuation 来获得 continuation 然后调用一个基于回调的 API 在回调中 我们恢复 continuation 这满足了恰好调用它一次的要求 当代码比较复杂时 一定要小心 在左侧 我们修改了回调 只在成功时恢复 continuation 这是一个 bug 一旦失败 continuation 将不会恢复 任务将永远挂起 在右边 我们恢复了两次 continuation 这也是一个 bug App 将不正常运行或崩溃 这两个片段都违背了 只恢复一次 continuation 的要求 有两种可用的 continuation checked 和 unsafe 除非性能绝对重要 否则 continuation 一定要使用 withCheckedContinuation API checked continuation 会 自动检测误用并标记错误 当 checked continuation 被调用两次时 continuation 会卡住 当 continuation 根本没有被调用时 continuation 被销毁时 会向控制台打印一条消息 警告您 continuation 泄漏了 Swift Concurrency 工具 将显示相应的任务 无限期停留在 continuation 状态 Instruments 中有很多新的 Swift Concurrency 模板 您可以获得结构化并发的图形可视化 查看任务创建调用树 并检查确切的组装指令 以获得 Swift Concurrency 运行时的全貌 要想了解更多关于 Swift Concurrency 如何在后台运作的信息 请观看去年的讲座 “Swift Concurrency: Behind the Scenes” 要想了解更多关于数据竞争的知识 请观看利用 Swift 并发消除数据争用 感谢收看 Harjas: 祝您调试并发代码愉快
-
-
10:24 - CompressionState class
@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 update(url: URL, uncompressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].uncompressedSize = uncompressedSize } } func update(url: URL, compressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].compressedSize = compressedSize } } 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) }
-
11:49 - CompressionState class using ParallelCompressor 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 update(url: URL, progress: Double) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].progress = progress } } func update(url: URL, uncompressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].uncompressedSize = uncompressedSize } } func update(url: URL, compressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].compressedSize = compressedSize } } func compressAllFiles() { for file in files { Task { let compressedData = await compressor.compressFile(url: file.url) await save(compressedData, to: file.url) } } } }
-
17:46 - CompressionState class using ParallelCompressor with minimal actor-isolation and detached tasks
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 update(url: URL, progress: Double) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].progress = progress } } func update(url: URL, uncompressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].uncompressedSize = uncompressedSize } } func update(url: URL, compressedSize: Int) { if let loc = files.firstIndex(where: {$0.url == url}) { files[loc].compressedSize = compressedSize } } func compressAllFiles() { for file in files { Task.detached { let compressedData = await self.compressor.compressFile(url: file.url) await save(compressedData, to: file.url) } } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。