WWDC21 Swift concurrency: Behind the scenes

·  阅读 1053

简介

深入了解Swift并发性的细节,发现Swift如何在提高性能的同时提供更大的安全性,避免数据竞争和线程爆炸。我们将探讨Swift任务与Grand Central Dispatch有何不同,新的合作线程模型如何工作,以及如何确保你的应用程序获得最佳性能。为了获得本次会议的最大收获,我们建议首先观看 "Meet async/await in Swift"、"Explore structured concurrency in Swift"和 "Protect mutable state with Swift actors"。

前言

wwdc2021-10254_hd-0088.png

今天,我们和你们谈谈有关Swift并发的一些基本细微差别。这是一个高级讲座,它建立在早期关于Swift并发性的一些讲座之上。如果你不熟悉async/await、结构化并发和Actor的概念,我鼓励你先看一下其他的讲座。在之前关于Swift并发性的讲座中,你已经了解了今年Swift原生的各种语言特性以及如何使用它们。在这次讲座中,我们将深入了解为什么这些基元是这样设计的,不仅是为了语言安全,也是为了性能和效率。当你在自己的应用中尝试和采用Swift并发时,我们希望这次讲座能给你一个更好的心理模型,让你了解如何推理Swift并发,以及它如何与Grand Central Dispatch等现有线程库对接。

我们今天要讨论几件事。

首先,我们将讨论 Swift 并发背后的线程模型,并将其与 Grand Central Dispatch 进行对比。我们将谈论我们如何利用并发语言的特性,为Swift建立一个新的线程池,从而实现更好的性能和效率。

最后,在这一节中,我们将谈谈在将代码移植到使用Swift并发性时需要注意的事项。

然后,将谈论Swift并发中通过Actor进行的同步。 我们将讨论Actor是如何工作的,它们与你可能已经熟悉的现有同步基元(如串行调度队列)相比如何,

最后,在使用行为体编写代码时需要注意的一些事项。

今天我们有很多内容要讲,所以让我们直接开始吧。

Threading model

Grand Central Dispatch

在我们今天关于线程模型的讨论中,我们将首先看一下用当今可用的技术(如Grand Central Dispatch)编写的一个示例应用程序。然后,我们将看看同样的应用程序在用Swift并发性重写时的表现。

假设我想写我自己的新闻源阅读器应用。

让我们来谈谈我的应用程序的高级组件是什么。

wwdc2021-10254_hd-0089.png

我的应用程序将有一个主线程,用于驱动用户界面。

我将有一个数据库来跟踪用户订阅的新闻源,最后还有一个子系统来处理网络逻辑,以便从新闻源中获取最新内容。

让我们考虑一下如何用Grand Central Dispatch队列来构造这个应用程序。

我们假设用户要求查看最新的新闻。

wwdc2021-10254_hd-0090.png

在主线程上,我们将处理用户事件的手势。

从这里,我们将把请求异步分派到一个处理数据库操作的串行队列上。

这样做的原因有两个方面。

首先,通过将工作分配到不同的队列,我们确保主线程即使在等待潜在的大量工作发生时也能保持对用户输入的响应。

其次,对数据库的访问是受保护的,因为一个串行队列保证了相互排斥。

wwdc2021-10254_hd-0091.png

在数据库队列中,我们将遍历用户订阅的新闻源,并为每个新闻源安排一个网络请求到我们的URLSession,以下载该源的内容。

wwdc2021-10254_hd-0092.png

当网络请求的结果出现时,URLSession的回调将在我们的委托队列中被调用,该队列是一个并发的队列。在每个结果的完成处理程序中,我们将同步更新数据库中每个feeds的最新请求,以便缓存起来供将来使用。最后,我们会唤醒主线程来刷新用户界面。

这似乎是构造这样一个应用程序的一个完全合理的方式。我们已经确保了在处理请求时不会阻塞主线程。通过并发地处理网络请求,我们已经利用了程序中固有的并行性。让我们仔细看看一个代码片段,它显示了我们如何处理网络请求的结果。

wwdc2021-10254_hd-0093.png

首先,我们创建了一个URLSession,用于执行从新闻源的下载。正如你在这里看到的,我们已经将这个URLSession的委托队列设置为一个并发队列。

然后我们遍历所有需要更新的新闻源,并为每个新闻源在URLSession中安排一个数据任务。在数据任务的完成处理程序中--它将在委托队列中被调用--我们对下载的结果进行反序列化,并将其格式化为文章。

然后,在更新feed的结果之前,我们针对我们的数据库队列进行同步调度。

所以在这里你可以看到,我们写了一些线性代码来做一些相当直接的事情,但这段代码有一些隐藏的性能陷阱。

为了进一步了解这些性能问题,我们需要首先深入了解线程是如何处理GCD队列的工作的。

wwdc2021-10254_hd-0095.png

在Grand Central Dispatch中,当工作被排入一个队列时,系统会调出一个线程来处理该工作项目。

由于一个并发队列可以同时处理多个工作项目,系统会启动多个线程,直到我们的所有CPU核心都达到饱和。

然而,如果一个线程阻塞了--就像在这里的第一个CPU核上看到的那样--并且在并发队列上还有更多的工作要做,GCD将带起更多的线程来耗尽剩余的工作项目。

这样做的原因有两个方面。

首先,通过给你的进程提供另一个线程,我们能够确保每个核心在任何时候都有一个执行工作的线程。这给你的应用程序提供了一个良好的、持续的并发性水平。

其次,被阻塞的线程可能正在等待一个资源,如信号量,然后才能取得进一步进展。被带入队列继续工作的新线程可能能够帮助解开被第一个线程等待的资源。

现在我们对GCD中的线程提升有了更多的了解,让我们回头看看我们的新闻应用中的CPU执行代码。

wwdc2021-10254_hd-0097.png

在像Apple Watch这样的双核设备上,GCD首先会带出两个线程来处理新闻更新结果。 当这些线程在访问数据库队列时受阻,更多的线程被创建以继续处理网络队列。 然后,CPU必须在处理网络结果的不同线程之间进行上下文切换,如不同线程之间的白色垂直线所示。

wwdc2021-10254_hd-0099.png

这意味着在我们的新闻应用中,我们很容易就会出现非常多的线程。 如果用户有一百个Feeds需要更新,那么当网络请求完成后,每个URL数据任务都会在并发队列中有一个完成块。 当每个回调在数据库队列上阻塞时,GCD会带出更多的线程,导致应用程序有很多线程。

wwdc2021-10254_hd-0100.png

现在你可能会问,在我们的应用程序中拥有大量的线程有什么不好?在我们的应用程序中拥有大量的线程,意味着系统对线程的过度承诺超过了我们的CPU核心。

考虑一个有六个CPU核心的iPhone。 如果我们的新闻应用程序有一百个需要处理的feed更新,这意味着我们已经用比核心多16倍的线程对iPhone进行了过度配置。这就是我们所说的线程爆炸现象。 我们之前的一些WWDC讲座已经进一步详细介绍了与此相关的风险,包括在你的应用程序中出现死锁的可能性。 线程爆炸还伴随着内存和调度的开销,这些开销可能不会立即显现出来,所以让我们进一步研究一下。

wwdc2021-10254_hd-0101.png

回顾一下我们的新闻应用,每个被阻塞的线程在等待再次运行时,都在抓着宝贵的内存和资源。

每个被阻塞的线程都有一个堆栈和相关的内核数据结构来跟踪该线程。其中一些线程可能持有其他正在运行的线程可能需要的锁。这对于没有进展的线程来说,是大量的资源和内存的占用。 由于线程爆炸,也有更大的调度开销。 随着新线程的出现,CPU需要进行全线程上下文切换,以便从旧线程切换到开始执行新线程。 当被阻塞的线程再次变得可运行时,调度员必须在CPU上对线程进行分时,以便它们都能取得进展。

wwdc2021-10254_hd-0103.png

现在,如果这种情况只发生几次,线程的分时是没有问题的--这就是并发的力量。 但是,当出现线程爆炸时,必须在一个核心有限的设备上分时共享数百个线程,会导致过度的上下文切换。这些线程的调度延迟超过了它们会做的有用工作的数量,因此,导致CPU的运行效率也降低。

正如我们到目前为止所看到的,在使用GCD队列编写应用程序时,很容易错过一些关于线程卫生的细微差别,从而导致性能不佳和更大的开销。

Concurrency in Swift

基于这一经验,Swift在设计语言中的并发性时采取了不同的方法。 我们在构建 Swift 并发时也考虑到了性能和效率,因此你的应用程序可以享受到可控的、结构化的、安全的并发。 有了Swift,我们想把应用程序的执行模式从下面这种有很多线程和上下文切换的模式改为这样。

wwdc2021-10254_hd-0105.png

在这里你可以看到,我们的双核系统上只有两个线程在执行,而且没有线程上下文切换。 我们所有被阻塞的线程都消失了,取而代之的是一个被称为continuation的轻量级对象来跟踪工作的恢复情况。 当线程在Swift并发下执行工作时,它们会在**cont.**之间进行切换,而不是执行完整的线程上下文切换。 这意味着我们现在只需要支付函数调用的成本。

因此,我们希望Swift并发的运行时行为是,只创建与CPU核心数量相同的线程,并且线程在被阻塞时能够廉价、高效地在工作项目之间切换。我们希望你能写出容易推理的线性型代码,并为你提供安全、可控的并发性。

wwdc2021-10254_hd-0106.png

为了实现我们所追求的这种行为,操作系统需要一个运行时契约,即线程不会阻塞,而这只有在语言能够为我们提供这种契约时才有可能。 因此,Swift的并发模型和围绕它的语义在设计时就考虑到了这个目标。为此,我想深入探讨一下Swift语言层面的两个特点,它们使我们能够与运行时保持契约关系。

wwdc2021-10254_hd-0107.png

第一个是来自 await 的语义,第二个是来自 Swift 运行时对任务依赖关系的跟踪。

让我们在新闻应用的例子中考虑这些语言特性。

wwdc2021-10254_hd-0108.png

这是我们之前走过的代码片段,它处理了我们的新闻提要更新的结果。 让我们看看这个逻辑在用Swift并发原语编写时是什么样子。

wwdc2021-10254_hd-0109.png

我们首先会创建一个辅助函数的异步实现。 然后,我们不在并发调度队列中处理网络请求的结果,而是在这里使用一个任务组来管理我们的并发性。 在任务组中,我们将为每个需要更新的feed创建子任务。 每个子任务将使用共享的URLSessionFeedURL上执行下载。 然后,它将反序列化下载的结果,将其格式化为文章,最后,我们将调用一个异步函数来更新我们的数据库。 在这里,当调用任何异步函数时,我们用一个await关键字来注释它。

从 "Meet async/await in Swift "讲座中,我们了解到await是一个异步等待。也就是说,在等待异步函数的结果时,它不会阻塞当前线程。相反,该函数可能被暂停,线程将被释放出来执行其他任务。

这种情况是如何发生的?如何放弃一个线程呢?我的同事Varun现在将阐明在Swift运行时的引擎盖下是如何实现这一目标的。

Await and non-blocking of threads

在讨论异步函数是如何实现的之前,我们先快速回顾一下非异步函数的工作原理。

wwdc2021-10254_hd-0110.png

在一个正在运行的程序中,每个线程都有一个堆栈,它用来存储函数调用的状态。

wwdc2021-10254_hd-0111.png

现在让我们专注于一个线程。

wwdc2021-10254_hd-0112.png

当线程执行一个函数调用时,一个新的栈帧被推到它的堆栈上。

wwdc2021-10254_hd-0113.png

这个新创建的堆栈帧可以被函数用来存储局部变量、返回地址和任何其他需要的信息。

wwdc2021-10254_hd-0114.png

一旦函数执行完毕并返回,它的堆栈帧就被弹出。

wwdc2021-10254_hd-0116.png

现在我们来考虑一下异步函数。

假设一个线程从updateDatabase函数中调用了一个关于Feed类型的add(:)方法。 在这个阶段,最近的堆栈帧将是为add(:)

wwdc2021-10254_hd-0117.png

这个栈帧存储了不需要跨越暂停点的局部变量。 add(_:)的主体有一个暂停点,用 await 标记。 本地变量,idarticle,在被定义后立即在for循环的主体中使用,中间没有任何暂停点。所以它们将被存储在这个栈帧中。

wwdc2021-10254_hd-0118.png

此外,堆上将有两个异步调用帧,一个用于updateDatabase,一个用于add。异步调用帧存储的信息确实需要在各暂停点之间可用。

wwdc2021-10254_hd-0119.png

请注意,newArticles参数是在await之前定义的,但需要在await之后才可用。

这意味着add的异步调用帧 将保持对newArticles的跟踪。

假设该线程继续执行。

wwdc2021-10254_hd-0120.png

save函数开始执行时,add的堆栈帧被save的堆栈帧所取代。

不是添加新的堆栈帧,而是替换最上面的堆栈帧,因为任何未来需要的变量都已经存储在异步调用帧的列表中了。

wwdc2021-10254_hd-0121.png

保存函数也获得了一个异步调用帧供其使用。

当文章被保存到数据库时,如果线程能做一些有用的工作而不是被阻塞,那就更好了。

wwdc2021-10254_hd-0122.png

假设保存函数的执行被暂停。而线程被重新使用来做一些其他有用的工作,而不是被阻塞。

因为所有跨越暂停点的信息都存储在堆上,所以可以用来在以后的阶段继续执行。

这个异步调用帧的列表是一个Continuation的运行时表示。

wwdc2021-10254_hd-0123.png

假设过了一会儿,数据库请求完成了,假设一些线程被释放出来。

这可能是与之前相同的线程,也可能是一个不同的线程。

wwdc2021-10254_hd-0124.png

一旦它执行完毕并返回一些ID,那么save的堆栈帧将再次被add的堆栈帧所取代。

之后,该线程可以开始执行zip

wwdc2021-10254_hd-0125.png

对两个数组进行压缩是一个非异步操作,所以它将创建一个新的堆栈帧。

由于Swift继续使用操作系统堆栈,异步和非异步的Swift代码都可以有效地调用到C和Objective-C。

此外,C和Objective-C代码可以继续有效地调用非异步的Swift代码。

wwdc2021-10254_hd-0127.png

一旦zip函数完成,它的堆栈帧将被弹出,执行将继续。

到目前为止,我已经描述了await是如何设计的,以确保高效的暂停和恢复,同时释放线程的资源来做其他工作。

Tracking of dependencies in Swift task model

如前所述,一个函数可以在一个等待点(也被称为潜在的暂停点)被分解成Continuations

wwdc2021-10254_hd-0128.png

在这种情况下,URLSession数据任务是异步函数,它之后的剩余工作是Continuations。 只有在异步函数完成后,才能执行Continuations

这是一个由Swift并发运行时跟踪的依赖关系。

wwdc2021-10254_hd-0129.png

同样,在任务组中,一个父任务可能会创建几个子任务,每个子任务都需要在父任务进行之前完成。 这是一种依赖关系,在你的代码中通过任务组的范围来表达,因此明确地被Swift编译器和运行时所知。 在Swift中,任务只能等待Swift运行时已知的其他任务--无论是Continuations还是子任务。

因此,当用Swift的并发原语构建代码时,运行时会清楚地了解任务之间的依赖链。

wwdc2021-10254_hd-0131.png

到目前为止,你已经了解到Swift的语言特性是如何允许任务在等待过程中被暂停的。

相反,执行线程能够对任务的依赖性进行推理,并接上一个不同的任务。

这意味着用Swift并发性编写的代码可以维持一个运行时契约,即线程总是能够取得进展。

wwdc2021-10254_hd-0132.png

我们已经利用这个运行时契约,为Swift并发性建立了集成的操作系统支持。

这是以一个新的合作线程池的形式,支持Swift并发作为默认执行器。

新的线程池将只产生与CPU内核相同数量的线程,从而确保不对系统进行过度承诺。

与GCD的并发队列不同,当工作项目受阻时,会产生更多的线程,而Swift的线程总是可以向前推进。因此,默认运行时可以明智地控制线程的生成数量。

这让我们可以给你的应用程序提供你需要的并发性,同时确保避免过度并发的已知陷阱。

wwdc2021-10254_hd-0134.png

在以前关于Grand Central Dispatch并发性的WWDC讲座中,我们曾建议你将你的应用程序结构化为不同的子系统,并在每个子系统中保持一个串行调度队列,以控制你的应用程序的并发性。 这意味着你很难在一个子系统内获得大于1的并发性,而不会有线程爆炸的风险。

在Swift中,语言为我们提供了强大的不变性,运行时利用了这些不变性,从而能够在默认运行时中透明地为你提供更好的控制并发性。

现在你对Swift并发的线程模型有了更多的了解,让我们来看看在你的代码中采用这些令人兴奋的新功能时要注意的一些问题。

Adoption of Swift concurrency

wwdc2021-10254_hd-0137.png

你需要记住的第一个考虑因素与将同步代码转换为异步代码时的性能有关。 早些时候,我们谈到了一些与并发性相关的成本,如Swift运行时的额外内存分配和逻辑。 因此,你需要注意的是,只有当在代码中引入并发性的成本超过了管理并发性的成本时,才会用Swift并发性编写新的代码。

这里的代码片段实际上可能并没有从催生一个子任务的额外并发性中获益,只是为了从用户的默认值中读取一个值。这是因为子任务所做的有用工作被创建和管理任务的成本削弱了。 因此,我们建议在采用Swift并发时,用仪器系统跟踪对你的代码进行分析,以了解它的性能特征。

wwdc2021-10254_hd-0138.png

第二件需要注意的事情是围绕await的原子性概念。

Swift并不保证在await之前执行代码的线程也是将接续的线程。

事实上,await在你的代码中是一个明确的点,表明原子性被打破了,因为任务可能会被自愿取消调度。

因此,你应该注意不要在等待中持有锁。

wwdc2021-10254_hd-0139.png

同样地,线程特定的数据也不会在await中被保留下来。

你的代码中任何期望线程定位的假设都应该被重新审视,以考虑到 await 的暂停行为。

最后,最后的考虑与运行时契约有关,它是Swift中高效线程模型的基础。

wwdc2021-10254_hd-0140.png

回顾一下,在Swift中,语言允许我们坚持一个运行时契约,即线程总是能够向前推进。

正是基于这一契约,我们建立了一个合作线程池,作为Swift的默认执行器。

当你采用 Swift 并发时,必须确保在你的代码中也继续维护这一契约,以便合作线程池能够以最佳方式运行。

通过使用安全的基元,使你的代码中的依赖关系明确化和已知化,就有可能在合作线程池中保持这种契约。

wwdc2021-10254_hd-0141.png

有了Swift并发原语,比如awaitactors任务组,这些依赖关系在编译时就已经被知道了。因此,Swift编译器会强制执行这一点,并帮助你保留运行时契约。

os_unfair_locksNSLocks这样的原语也是安全的,但在使用它们时需要谨慎。在同步代码中使用锁是安全的,当用于围绕一个紧密的、众所周知的关键部分进行数据同步时。这是因为持有锁的线程总是能够在释放锁的过程中取得进展。因此,虽然该基元可能会在竞争中阻断线程一小段时间,但它并不违反向前推进的运行时契约。值得注意的是,与Swift并发原语不同,没有编译器支持来帮助正确使用锁,所以正确使用这一原语是你的责任。

wwdc2021-10254_hd-0142.png

另一方面,像semaphores条件变量这样的基元,在Swift并发中使用是不安全的。这是因为它们向Swift运行时隐藏了依赖性信息,但在你的代码中执行时却引入了依赖性。由于运行时不知道这种依赖关系,所以它无法做出正确的调度决策并解决这些问题。特别是,不要使用创建非结构化任务的原语,然后通过使用信号量或不安全的原语,追溯性地引入跨任务边界的依赖关系。这样的代码模式意味着一个线程可以无限期地阻塞信号量,直到另一个线程能够解除阻塞。这违反了线程向前推进的运行时契约。

wwdc2021-10254_hd-0143.png

为了帮助你识别代码库中这种不安全基元的使用,我们建议用以下环境变量测试你的应用程序。这将在修改过的调试运行时下运行你的应用程序,该运行时强制执行向前推进的不变量。 这个环境变量可以在Xcode中设置在你的项目方案的Run Arguments窗格中,如图所示。

wwdc2021-10254_hd-0144.png

wwdc2021-10254_hd-0145.png

wwdc2021-10254_hd-0146.png

当用这个环境变量运行你的应用程序时,如果你看到一个来自合作线程池的线程似乎被挂起,这表明使用了一个不安全的阻塞原语。

Synchronization

mutual exclusion

现在,在了解了线程模型是如何为Swift并发性设计的之后,让我们再来了解一下在这个新世界中可用于同步状态的基元。

在关于Actor的Swift并发性讲座中,你已经看到了Actor是如何被用来保护易变的状态不被并发访问的。

换句话说,Actor提供了一个强大的新同步原语,你可以使用。

回顾一下,Actor保证了相互排斥:一个Actor在同一时间最多只能执行一个方法调用。相互排斥意味着Actor的状态不会被同时访问,从而防止数据竞争。

让我们看看Actor与其他形式的互斥相比如何。

wwdc2021-10254_hd-0147.png

考虑一下前面的例子,通过同步到一个串行队列来更新数据库中的一些文章。 如果队列还没有运行,我们就说不存在竞争。 在这种情况下,调用线程被重用来执行队列上的新工作项目,而没有任何上下文切换。 相反,如果序列队列已经在运行,则称该队列处于争用状态。 在这种情况下,调用线程会被阻塞。 这种阻塞行为就是之前演讲中早先描述的引发线程爆炸的原因。 锁也有这种行为。

wwdc2021-10254_hd-0148.png

由于与阻塞有关的问题,我们通常建议你最好使用Dispatch asyncDispatch async的主要好处是它是无阻塞的。 因此,即使在争用的情况下,它也不会导致线程爆炸。 在串行队列中使用Dispatch async的缺点是,当没有竞争的时候,Dispatch需要请求一个新的线程来做异步工作,而调用线程则继续做其他事情。 因此,频繁使用Dispatch async会导致过多的线程唤醒和上下文切换。

这就给我们带来了Actor

SwiftActor利用合作线程池的优势进行有效的调度,从而结合了这两个世界的优点。 当你在一个没有运行的Actor上调用一个方法时,调用线程可以被重用来执行方法调用。 在被调用的Actor已经在运行的情况下,调用线程可以暂停它正在执行的函数,并接上其他工作。

wwdc2021-10254_hd-0149.png

让我们看看这两个属性在新闻应用的例子中是如何工作的。 我们来关注一下数据库和网络子系统。

wwdc2021-10254_hd-0150.png

当更新应用程序以使用Swift并发时,数据库的串行队列将被一个数据库Actor所取代。 网络的并发队列可以被每个新闻源的一个Actor所取代。为了简单起见,我在这里只展示了三个Actor--体育Actor、天气Actor和健康Actor--但在实践中,会有更多的Actor。 这些Actor将在合作线程池中运行。 feedActor与数据库互动,以保存文章和执行其他动作。 这种互动涉及到从一个Actor到另一个Actor的执行切换。

我们称这个过程为Actor跳转。 让我们来讨论一下Actor跳转是如何进行的。

假设体育频道的Actor在合作线程池中的一个线程上运行,它决定将一些文章保存到数据库中。

wwdc2021-10254_hd-0151.png

现在,让我们考虑数据库没有被使用。 这是不存在竞争的情况。

wwdc2021-10254_hd-0152.png

线程可以直接从体育频道的Actor跳到数据库的Actor

这里有两件事需要注意。 首先,线程在跳转Actor时没有阻塞。 第二,跳转不需要不同的线程;运行时可以直接暂停体育节目Actor的工作项目,为数据库Actor创建一个新的工作项目。

wwdc2021-10254_hd-0153.png

假设数据库Actor运行了一段时间,但它还没有完成第一个工作项。在这个时候,假设天气预报Actor试图在数据库中保存一些文章。

这就为数据库Actor创造了一个新的工作项目。Actor通过保证相互排斥来确保安全;在给定的时间内,最多只有一个工作项是活动的。 由于已经有一个活动的工作项目D1,新的工作项目D2将被保留。

wwdc2021-10254_hd-0154.png

Actor也是无阻塞的。在这种情况下,天气预报Actor将被暂停,它所执行的线程现在被释放出来做其他工作。

wwdc2021-10254_hd-0155.png

过了一会儿,最初的数据库请求完成了,所以数据库Actor的活动工作项被移除。

在这一点上,运行时可以选择开始执行数据库Actor的未决工作项目。 或者它可以选择恢复一个进位Actor。 或者它可以在被释放的线程上做一些其他工作。

Reentrancy and prioritization

当有很多异步工作,特别是有很多争论时,系统需要根据什么工作更重要来进行权衡。 理想情况下,高优先级的工作,如涉及用户互动的工作,将优先于后台工作,如保存备份。 由于重入的概念,Actor被设计成允许系统很好地安排工作的优先次序。 但是为了理解为什么重入性在这里很重要,让我们先看看GCD是如何处理优先级的。

wwdc2021-10254_hd-0156.png

考虑一下带有串行数据库队列的原始新闻应用。 假设数据库收到了一些高优先级的工作,比如获取最新数据以更新用户界面。 它也会收到低优先级的工作,例如将数据库备份到iCloud。 这需要在某个时间点完成,但不一定是立即完成。 随着代码的运行,新的工作项目被创建并以某种交错的顺序添加到数据库队列中。 Dispatch Queue以严格的先入先出顺序执行收到的项目。 不幸的是,这意味着在项目A执行完后,在进入下一个高优先级项目之前,需要执行五个低优先级的项目。 这就是所谓的优先级倒置。

wwdc2021-10254_hd-0157.png

串行队列通过提高队列中所有在高优先级工作之前的工作的优先级来解决优先级倒置的问题。 在实践中,这意味着队列中的工作将更快完成。 然而,这并没有解决主要问题,即在项目B开始执行之前,项目1到5仍然需要完成。 解决这个问题需要改变语义模型,使其脱离严格的先进先出。

这就把我们带到了Actor的重入。 让我们通过一个例子来探索重入是如何与排序相联系的。

wwdc2021-10254_hd-0158.png

考虑一下在一个线程上执行的数据库Actor。 假设它被暂停,等待一些工作,而体育节目的Actor开始在该线程上执行。 假设过了一会儿,体育频道的Actor调用数据库Actor来保存一些文章。 由于数据库Actor是未被征用的,线程可以跳到数据库Actor上,尽管它有一个待处理的工作项目。 为了执行保存操作,将为数据库Actor创建一个新的工作项。

wwdc2021-10254_hd-0159.png

这就是actor reentrancy的含义;当一个Actor上的一个或多个旧的工作项被暂停时,该Actor上的新工作项可以取得进展。 Actor仍然保持相互排斥:在一个给定的时间内最多只能有一个工作项在执行。

wwdc2021-10254_hd-0160.png

一段时间后,项目D2将完成执行。 注意,D2在D1之前完成了执行,尽管它是在D1之后创建的。 因此,对Actor重入的支持意味着Actor可以按照不是严格意义上的先入先出的顺序执行项目。

wwdc2021-10254_hd-0161.png

让我们再来看看之前的例子,但要用一个数据库Actor而不是一个序列队列。 首先,工作项目A将被执行,因为它有很高的优先级。 一旦执行完毕,就会出现和之前一样的优先级倒置。

wwdc2021-10254_hd-0162.png

由于Actor是为重入设计的,运行时可以选择将优先级较高的项目移到队列的前面,排在优先级较低的项目前面。 这样一来,较高优先级的工作就可以先执行,较低优先级的工作则在后面。 这直接解决了优先级倒置的问题,允许更有效的调度和资源利用。 我已经谈到了一些关于使用合作线程池的Actor是如何被设计来维持相互排斥和支持有效的工作优先级的。

Main actor

还有一种Actor,即MainActor,其特点有些不同,因为它抽象了系统中的一个现有概念:主线程。

考虑一下使用Actor的新闻应用程序的例子。

wwdc2021-10254_hd-0163.png

当更新用户界面时,你需要对MainActor进行调用,也需要从MainActor进行调用。 由于主线程与合作线程池中的线程是不相干的,这需要进行上下文切换。 让我们通过一个代码例子来看看这其中的性能影响。 考虑下面的代码,我们在MainActor上有一个函数updateArticles,它从数据库中加载文章,并为每篇文章更新UI。

wwdc2021-10254_hd-0164.png

循环的每一次迭代都需要至少两次上下文切换:一次是从MainActor跳到数据库Actor,另一次是跳回来。 让我们看看这样一个循环的CPU使用率是怎样的。

wwdc2021-10254_hd-0165.png

由于每个循环迭代都需要两次上下文切换,所以会出现一个重复的模式,即两个线程在短时间内相继运行。 如果循环迭代的数量不多,而且每次迭代都在做大量的工作,这可能是正确的。

wwdc2021-10254_hd-0166.png

然而,如果执行过程中频繁地在MainActor上跳来跳去,切换线程的开销就会开始增加。 如果你的应用程序在上下文切换中花费了大量的时间,你应该重组你的代码,使MainActor的工作被分批进行。

wwdc2021-10254_hd-0167.png

你可以通过把循环推到loadArticlesupdateUI方法调用中,确保它们处理数组而不是一次处理一个值来分批工作。 分批工作可以减少上下文切换的次数。 虽然在合作线程池上的Actor之间跳转很迅速,但在编写应用程序时,你仍然需要注意与MainActor之间的跳转。

总结

回顾过去,在这次演讲中,你已经了解到我们是如何努力使系统达到最高效的,从合作线程池的设计--非阻塞等待机制--到如何实现Actor。 在每个步骤中,我们都在使用运行时契约的某些方面来提高你的应用程序的性能。 我们很高兴看到你如何使用这些令人难以置信的新语言特性来编写清晰、高效和令人愉悦的Swift代码。 谢谢你的观看,祝你有一个美好的WWDC。

分类:
iOS
标签:
分类:
iOS
标签: