WWDC21 Explore structured concurrency in Swift

探索Swift中的结构化并发

当你有代码需要与其他代码同时运行时,选择合适的工具来完成任务是很重要的。我们将带你了解你可以在Swift中创建的不同类型的并发任务,告诉你如何创建任务组,并找出如何取消正在进行的任务。我们还将提供指导,说明什么时候你可能想要使用非结构化的任务。为了获得本节课的最大收获,我们首先建议观看 “在Swift中认识async/await”。

概述

Swift 5.5 引入了一种编写并发程序的新方法,使用了一个叫做结构化并发的概念。结构化并发背后的想法是基于结构化编程的,它是如此直观,以至于你很少去想它,但思考它将帮助你理解结构化并发。

因此,让我们深入了解一下。

wwdc2021-10134_hd-0036.png

在计算机的早期,程序很难阅读,因为它们被写成了一连串的指令,控制流被允许到处跳跃。今天,你不会看到这种情况,因为语言使用结构化编程,使控制流更加统一。
例如,if-then语句使用结构化的控制流。

wwdc2021-10134_hd-0039.png

它规定了一个嵌套的代码块在从上到下移动的过程中只能有条件地执行。在Swift中,该块也准守静态范围,这意味着只有当名字在一个封闭的块中被定义时才是可见的。这也意味着,在一个区块中定义的任何变量的生命周期将在离开区块时结束。因此,带有静态范围的结构化编程使得控制流和变量生命周期变得容易理解。

wwdc2021-10134_hd-0040.png

更广泛地说,结构化的控制流可以自然地进行排序和嵌套。这可以让你从上到下阅读你的整个程序。所以,这些就是结构化编程的基本原理。你可以想象,这很容易被认为是理所当然的,因为它对我们今天来说是如此直观。但是,今天的程序以异步和并发代码为特征,而它们却没有能够利用结构化编程来使这些代码更容易编写。

使用结构化并发的例子

首先,让我们考虑一下结构化编程如何使异步代码更简单。

wwdc2021-10134_hd-0041.png

假设你需要从互联网上获取一堆图片,并按顺序调整它们的大小,使之成为缩略图。这段代码以异步方式完成这项工作,接收一个标识图片的字符串集合。你会注意到这个函数在调用时没有返回一个值。这是因为该函数将其结果或错误传给了一个完成处理程序。这种模式允许调用者在稍后的时间收到一个答案。作为这种模式的结果,这个函数不能使用结构化的控制流来处理错误。这是因为只有在处理从一个函数中抛出的错误时才有意义,而不是在一个函数中。此外,这个模式还阻止你使用循环来处理每个缩略图。递归是必须的,因为函数完成后运行的代码必须嵌套在处理程序中。现在,让我们来看看以前的代码,但要重写成使用新的async/await语法,它是基于结构化编程的。

wwdc2021-10134_hd-0042.png

我放弃了函数中的完成处理程序参数。取而代之的是,在它的类型签名中用 “async “和 “throws “来注解。它还返回一个值,而不是什么都没有。

wwdc2021-10134_hd-0043.png

在函数的主体中,我使用 “await “来表示一个异步动作的发生,并且在该动作之后运行的代码不需要嵌套。

wwdc2021-10134_hd-0044.png

这意味着我现在可以在缩略图上循环,按顺序处理它们。

wwdc2021-10134_hd-0045.png

我还可以抛出和捕获错误,编译器会检查我是否忘记了。如果想深入了解async/await,请查看 “在Swift中认识async/await “这一环节。

那么,这段代码很好,但如果你要为成千上万的图片制作缩略图呢?一次处理一个缩略图不再是理想的做法。另外,如果每个缩略图的尺寸必须从另一个URL下载,而不是固定的尺寸,怎么办?现在有机会增加一些并发性,所以多个下载可以并行进行。你可以创建额外的任务来给程序添加并发性。

Tasks in Swift

任务是Swift的一项新功能,与异步函数携手合作。

wwdc2021-10134_hd-0046.png

任务为运行异步代码提供了一个新的执行环境。每个任务相对于其他执行上下文都是并发运行的。在安全有效的情况下,它们会被自动安排为并行运行。

由于任务被深度整合到Swift中,编译器可以帮助防止一些并发性错误。

另外,请记住,调用一个异步函数并不会为该调用创建一个新任务。你要明确地创建任务。

Swift 中有几种不同的任务,因为结构化并发是关于灵活性和简单性之间的平衡。因此,在本次会议的其余部分,我们将介绍和讨论每一种任务,以帮助你理解它们的权衡。

结构化任务

Async-let tasks

让我们从这些任务中最简单的开始,它是用一种叫做async-let绑定的新语法形式创建的。

wwdc2021-10134_hd-0047.png

为了帮助你理解这种新的语法形式,我想首先分解一下普通let绑定的评估过程。有两个部分:等号右边的初始化表达式和左边的变量名称。在let之前或之后可能还有其他语句,所以我在这里也会包括这些。

wwdc2021-10134_hd-0050.png

一旦Swift到达一个let绑定,它的初始化器将被评估以产生一个值。在这个例子中,这意味着从一个URL下载数据,这可能需要一段时间。数据下载完毕后,Swift将把这个值绑定到变量名上,然后再进行后面的语句。请注意,这里只有一个执行流程,正如通过每个步骤的箭头所追踪的那样。

由于下载可能需要一段时间,你希望程序开始下载数据,并继续做其他工作,直到真正需要这些数据。 为了实现这一点,你可以在现有的let绑定前面加上async这个词。

wwdc2021-10134_hd-0051.png

这就把它变成了一个叫做async-let的并发绑定。

并发绑定的评估与顺序绑定是完全不同的,所以让我们来学习它是如何工作的。
我将从遇到绑定之前的那一刻开始。

wwdc2021-10134_hd-0052.png

为了评估一个并发绑定,Swift首先会创建一个新的子任务,这是创建它的那个任务的子任务。因为每个任务都代表了你的程序的执行环境,所以在这一步中会同时出现两个箭头。
第一个箭头是子任务的,它将立即开始下载数据。
第二个箭头是针对父任务的,它将立即把变量结果与一个占位符值绑定。
这个父任务就是正在执行前面语句的那个任务。当数据被子任务并发下载时,父任务继续执行并发绑定后的语句。
但是在到达一个需要实际结果值的表达式时,父任务将等待子任务的完成,子任务将履行结果的占位符。

在这个例子中,我们对URLSession的调用也可能抛出一个错误。这意味着等待结果可能会给我们一个错误。所以我需要写 “try “来处理它。不要担心。再次读取结果的值不会重新计算其值。

应用示例

现在你已经看到了async-let是如何工作的,你可以用它来为缩略图的获取代码添加并发性。我已经将之前的一段获取单张图片的代码分解到自己的函数中。

wwdc2021-10134_hd-0053.png

这里的这个新函数也是从两个不同的URL中下载数据:一个是全尺寸的图片本身,另一个是元数据,其中包含了最佳的缩略图尺寸。

请注意,在顺序绑定的情况下,你在let的右边写上 “try await“,因为那是观察错误或暂停的地方。

wwdc2021-10134_hd-0054.png

为了使两个下载同时发生,你在这两个let的前面写上 “async“。

由于下载现在发生在子任务中,你不再在并发绑定的右边写 “try await“。

wwdc2021-10134_hd-0055.png

这些效果只有在使用被并发绑定的变量时才会被父任务观察到。所以你在表达式读取元数据和图像数据之前写 “try await“。

另外,注意到使用这些被并发绑定的变量不需要方法调用或其他任何改变。这些变量的类型与它们在顺序绑定中的类型相同。

任务树结构

现在,我一直在谈论的这些子任务实际上是一个叫做任务树的层次结构的一部分。这个树不仅仅是一个实现细节。它是结构化并发的一个重要部分。它影响着你的任务的属性,如取消、优先级和任务本地变量。每当你从一个异步函数调用到另一个异步函数时,同一个任务被用来执行调用。所以,函数fetchOneThumbnail继承了该任务的所有属性。当创建一个新的结构化任务时,比如用async-let,它就会成为当前函数所运行的任务的子任务。任务不是特定函数的子任务,但它们的生命周期可能是以它为范围的。

wwdc2021-10134_hd-0056.png

树是由每个父任务和其子任务之间的链接组成的。链接强制执行一条规则,即父任务只有在其所有的子任务都完成后才能完成其工作。

这条规则甚至在控制流异常的情况下也有效,因为控制流会阻止子任务被等待。

wwdc2021-10134_hd-0057.png

例如,在这段代码中,我首先在图像数据任务之前等待元数据任务。如果第一个等待的任务以抛出错误的方式结束,fetchOneThumbnail函数必须立即通过抛出错误退出。但执行第二个下载的任务会发生什么?在非正常退出过程中,Swift会自动将未等待的任务标记为取消,然后等待它完成,再退出函数。将一个任务标记为取消并不会停止该任务。它只是通知该任务不再需要其结果。

wwdc2021-10134_hd-0058.png

事实上,当一个任务被取消时,所有作为该任务后裔的子任务也将被自动取消。

因此,如果URLSession的实现创建了自己的结构化任务来下载图片,这些任务将被标记为取消。

wwdc2021-10134_hd-0059.png

一旦它直接或间接创建的所有结构化任务都完成了,函数 fetchOneThumbnail 就会通过抛出错误而最终退出。
这种保证是结构化并发的基础。

它通过帮助你管理任务的生命周期来防止你意外地泄露任务,就像ARC自动管理内存的寿命一样。

到目前为止,我已经给了你一个关于取消如何传播的概述。

wwdc2021-10134_hd-0060.png

但任务最终何时停止呢?如果任务正处于一个重要的事务中,或者有开放的网络连接,直接停止任务是不正确的。

这就是为什么Swift中的任务取消是合作性的。

你的代码必须明确地检查取消,并以任何适当的方式结束执行。

你可以从任何函数中检查当前任务的取消状态,无论它是否是异步的。

这意味着你在实现你的API时应该考虑到取消的问题,特别是当它们涉及到长期运行的计算时。

你的用户可能会从一个可以取消的任务中调用你的代码,他们会希望计算能尽快停止。

为了看看使用合作取消有多简单,让我们回到缩略图获取的例子。

wwdc2021-10134_hd-0062.png

在这里,我重写了原来的函数,该函数被赋予所有要获取的缩略图,因此它使用fetchOneThumbnail函数来代替。

如果这个函数是在一个被取消的任务中调用的,我们不希望因为创建无用的缩略图而耽误我们的应用程序。

所以我可以在每个循环迭代的开始添加一个对checkCancellation的调用。

这个调用只有在当前任务被取消时才会抛出一个错误。

wwdc2021-10134_hd-0063.png

你也可以把当前任务的取消状态作为一个布尔值来获取,如果这对你的代码更合适的话。

注意,在这个版本的函数中,我返回了一个部分结果,一个只有部分请求的缩略图的字典。

当这样做时,你必须确保你的API清楚地说明可以返回部分结果。

否则,任务取消可能会给你的用户带来致命的错误,因为他们的代码需要一个完整的结果,即使是在取消的过程中。

到目前为止,你已经看到async-let提供了一种轻量级的语法,用于在你的程序中添加并发性,同时抓住了结构化编程的本质

Group tasks

我想告诉你的下一种任务被称为组任务。它们提供了比async-let更多的灵活性,同时又不放弃结构化并发的所有美好特性。正如我们前面所看到的,当有固定的并发量时,async-let工作得很好。让我们考虑一下我前面讨论的两个函数。

wwdc2021-10134_hd-0064.png

对于循环中的每个缩略图ID,我们调用fetchOneThumbnail来处理它,这正好创造了两个子任务。即使我们将该函数的主体内联到这个循环中,并发量也不会改变。Async-let的作用域就像一个变量绑定。这意味着这两个子任务必须在下一个循环迭代开始之前完成。但是,如果我们想让这个循环启动任务来同时获取所有的缩略图呢?那么,并发量就不是静态的了,因为它取决于数组中ID的数量。 对于这种情况,正确的工具是任务组。

任务组是一种结构化并发的形式,旨在提供一个动态的并发量。

wwdc2021-10134_hd-0065.png

你可以通过调用withThrowingTaskGroup函数来引入一个任务组。这个函数给你一个范围内的组对象来创建允许抛出错误的子任务。

添加到组中的任务不能超过定义该组的块的范围。

由于我已经把整个for-loop放在块内,我现在可以使用组来创建动态的任务数量。

wwdc2021-10134_hd-0066.png

你可以通过调用组的异步方法来创建组中的子任务。

一旦被添加到一个组中,子任务就会立即开始执行,并且以任何顺序执行。

wwdc2021-10134_hd-0067.png

当组对象超出范围时,组内所有任务的完成将被隐含地等待。

这是我前面描述的任务树规则的一个结果,因为组任务也是有结构的。

在这一点上,我们已经实现了我们想要的并发性:每次调用fetchOneThumbnail都有一个任务,它本身将使用async-let创建另外两个任务。这是结构化并发的另一个不错的属性。

你可以在组任务中使用async-let,或者在async-let任务中创建任务组,而树中的并发层次自然地组成了。

wwdc2021-10134_hd-0068.png

现在,这段代码还没有完全准备好运行。如果我们试图运行它,编译器会很有帮助地提醒我们有一个数据竞争问题。

问题是,我们试图从每个子任务中插入一个缩略图到一个字典中。当增加程序中的并发量时,这是一个常见的错误。数据竞争就会意外地产生。

这个字典不能同时处理一个以上的访问,如果两个子任务试图同时插入缩略图,这可能会导致崩溃或数据损坏。

wwdc2021-10134_hd-0069.png

在过去,你必须自己调查这些bug,但Swift提供了静态检查,以防止这些bug首先发生。每当你创建一个新的任务时,该任务执行的工作都在一个新的闭包类型中,称为**@Sendable**闭包。

@Sendable闭包的主体被限制在其词法上下文中捕获可变的变量,因为这些变量在任务启动后可能被修改。这意味着你在任务中捕获的值必须是安全的,可以共享。

例如,因为它们是值类型,如IntString,或者因为它们是旨在从多个线程访问的对象,如actors,以及实现自己同步的类。

我们有一节课专门讨论这个话题,叫做 “用Swift actors保护易变状态“,所以我鼓励你去看看。

wwdc2021-10134_hd-0070.png

为了避免我们例子中的数据竞争,你可以让每个子任务返回一个值。这种设计让父任务单独负责处理结果。
在这个例子中,我指定每个子任务必须返回一个包含缩略图的字符串IDUIImage的元组。然后,在每个子任务中,我让它们返回键值元组供父任务处理,而不是直接写到字典中。

wwdc2021-10134_hd-0072.png

父任务可以使用新的 for-await 循环来迭代每个子任务的结果。for-await 循环按照完成的顺序从子任务中获取结果。因为这个循环按顺序运行,父任务可以安全地将每个键值对添加到字典中。

这只是使用 for-await 循环来访问一个异步值序列的一个例子。

如果你自己的类型符合 AsyncSequence 协议,那么你也可以使用 for-await 来迭代它们。

你可以在 “认识AsyncSequence “环节中了解更多。

虽然任务组是结构化并发的一种形式,但在任务树规则的实现方式上,组任务与async-let任务有一个小小的区别。

假设在遍历这个组的结果时,我遇到了一个完成时有错误的子任务。因为这个错误被抛出了组的块,然后组中的所有任务将被隐式取消,然后等待。

这就像async-let一样工作。

不同的是,当你的组通过正常退出块而超出范围时。那么,取消就不是隐式的了。

这种行为使你更容易使用任务组来表达fork-join模式,因为**(jobs)任务**只会被等待,不会被取消。

你也可以在退出块之前使用组的cancelAll方法手动取消所有任务。

请记住,无论你如何取消一个任务,取消都会自动向树上传播。

Async-letGroup tasksSwift中提供范围结构化任务的两种任务。

非结构化任务

Unstructured tasks

之前向你展示了结构化并发是如何简化错误传播、取消和其他处理工作的,当你向一个有明确层次的任务的程序中添加并发时。但我们知道,当你在程序中添加任务时,你并不总是有一个层次结构。

Swift也提供了非结构化的任务API,这让你有更多的灵活性,代价是需要更多的人工管理。

wwdc2021-10134_hd-0073.png

有很多情况下,一个任务可能不属于一个明确的层次结构。

最明显的是,如果你想启动一个任务来做非同步代码的异步计算,你可能根本就没有一个父任务。

另外,你想要的任务的生命周期可能不适合单个范围甚至单个函数的限制。

例如,你可能想在一个将对象放入活动状态的方法调用中启动一个任务,然后在另一个将对象停用的方法调用中取消其执行。

AppKitUIKit中实现委托对象时,这种情况经常出现。

wwdc2021-10134_hd-0074.png

UI工作必须发生在主线程上,正如Swift actors会议所讨论的,Swift通过声明属于MainActorUI类来确保这一点。

wwdc2021-10134_hd-0075.png

假设我们有一个集合视图,而我们还不能使用集合视图的数据源API。相反,我们想使用我们刚刚写的fetchThumbnails函数,在集合视图中的项目显示时从网络上抓取缩略图。

然而,委托方法不是异步的,所以我们不能只是等待对一个异步函数的调用。

我们需要为此启动一个任务,但这个任务实际上是我们为响应委托动作而启动的工作的延伸。我们希望这个新任务仍然以UI优先级在主角色上运行。我们只是不想把任务的生命周期限制在这个单一委托方法的范围内。

对于这样的情况,Swift允许我们构建一个非结构化的任务。

wwdc2021-10134_hd-0076.png

让我们把代码的异步部分移到一个闭包中,并通过该闭包来构造一个异步任务。

现在是在运行时发生的情况。

wwdc2021-10134_hd-0077.png

当我们到达创建任务的点时,Swift 会安排它在与源作用域相同的行为体上运行,在这种情况下,它就是主行为体。

wwdc2021-10134_hd-0078.png

同时,控制权会立即返回给调用者。缩略图任务将在主线程上运行,而不会立即阻塞委托方法上的主线程。

以这种方式构造任务给了我们一个介于结构化和非结构化代码之间的中间点。

wwdc2021-10134_hd-0079.png

一个直接构建的任务仍然继承了它所启动的上下文的Actor(如果有的话),它也继承了原任务的优先级和其他特征,就像一个组任务或一个async-let那样。

然而,新任务是无范围的。它的生命周期不受它被启动的范围的约束。

原点甚至不需要是异步的。我们可以在任何地方创建一个无范围的任务。

为了换取所有这些灵活性,我们还必须手动管理那些结构化并发会自动处理的事情。

取消和错误不会自动传播,任务的结果也不会被隐式地等待,除非我们采取显式的行动来这样做。

所以我们启动了一个任务,在显示集合视图项目时获取缩略图,如果该项目在缩略图准备好之前被滚动出视图,我们也应该取消该任务。由于我们使用的是一个无范围的任务,所以这个取消不是自动的。

现在让我们来实现它。

wwdc2021-10134_hd-0081.png

在我们构建任务之后,让我们保存我们得到的值。当我们创建任务时,我们可以把这个值放入一个以行索引为键的字典中,这样我们以后就可以用它来取消这个任务。一旦任务完成,我们也应该把它从字典中删除,这样我们就不会在任务已经完成的情况下试图取消它。

注意这里,我们可以在那个异步任务的内部和外部访问同一个 dictionary,而不会被编译器标记为数据竞争。

我们的委托类被绑定到主角色上,而新任务则继承了主角色,所以它们永远不会一起并行运行。

我们可以安全地访问这个任务中与主角色绑定的类的存储属性,而不用担心数据竞争。

wwdc2021-10134_hd-0082.png

同时,如果我们的委托人后来被告知同一表行已经从显示中移除,那么我们可以调用该值的取消方法来取消该任务。

Detached tasks

所以现在我们已经看到了我们如何创建独立于作用域运行的非结构化任务,同时仍然继承了该任务的起始上下文的特性。

但有时你并不想从你的起始上下文中继承任何东西。

为了获得最大的灵活性,Swift提供了分离式任务。

wwdc2021-10134_hd-0083.png

就像名字所暗示的那样,分离的任务是独立于其上下文的。

它们仍然是非结构化的任务。

它们的生命期不受其起始作用域的约束。

但分离的任务也不会从它们的起始作用域中获取任何其他东西。

默认情况下,它们不被限制在同一个角色上,也不需要在与它们被启动的地方相同的优先级上运行。

分离的任务是独立运行的,在优先级等方面有通用的默认值,但它们也可以用可选的参数来控制新任务的执行方式和位置。

wwdc2021-10134_hd-0084.png

比方说,当我们从服务器上获取缩略图后,我们想把它们写入本地磁盘缓存,这样如果我们以后试图获取它们就不会再碰到网络。

缓存不需要发生在主角色上,即使我们取消了对所有缩略图的获取,对我们获取的缩略图进行缓存仍然是有帮助的。

因此,让我们通过使用一个分离的任务来启动缓存。

当我们分离一个任务时,我们在设置新任务的执行方式上也有了更大的灵活性。

缓存应该发生在一个较低的优先级上,不会干扰主用户界面,我们可以在分离这个新任务时指定后台优先级。

wwdc2021-10134_hd-0085.png

现在让我们提前计划一下。如果我们有多个后台任务要在我们的缩略图上执行,我们将来应该怎么做?我们可以分离出更多的后台任务,但我们也可以在分离出来的任务里面利用结构化的并发性。我们可以将所有不同种类的任务结合在一起,利用它们各自的优势。我们可以设置一个任务组,并将每个后台任务作为子任务生成到该组中,而不是为每个后台任务分离出一个独立的任务。这样做有很多好处。

wwdc2021-10134_hd-0086.png

如果我们将来确实需要取消后台任务,使用任务组意味着我们可以取消所有的子任务,只需取消顶层的分离任务。

这种取消将自动传播到子任务中,我们不需要跟踪一个处理数组。此外,子任务会自动继承其父任务的优先级。

为了保持所有这些工作在后台进行,我们只需要将分离的任务放在后台,这将自动传播到它的所有子任务,所以我们不需要担心忘记过渡地设置后台优先级而意外地饿死了UI工作。

总结

在这一点上,我们已经看到了Swift中所有主要的任务形式。

wwdc2021-10134_hd-0087.png

Async-let允许将固定数量的子任务作为变量绑定来生成,如果绑定超出了范围,则自动管理取消和错误传播。

当我们需要一个动态数量的子任务,并且仍然被绑定在一个范围内时,我们可以上移到任务组。

如果我们需要把一些范围不大,但仍与原任务有关的工作分开,我们可以构建非结构化的任务,但我们需要手动管理这些任务。

为了获得最大的灵活性,我们还有分离的任务,这是手动管理的任务,不从其起源处继承任何东西。

任务和结构化并发只是Swift支持的并发功能套件中的一部分。

请务必查看所有这些其他的精彩讲座,看看它是如何与语言的其他部分结合起来的。

Meet async/await in Swift “为你提供了更多关于异步函数的细节,它为我们编写并发代码提供了结构化的基础。

Actor提供了数据隔离,以创建避免数据竞争的并发系统。请参阅 “Protect mutable state with Swift actors “一节,以了解更多信息。
我们看到任务组上的 “for await “循环,这些只是AsyncSequence的一个例子,它为处理异步数据流提供了一个标准接口。
Meet AsyncSequence “环节更深入地探讨了处理序列的可用API。

任务与核心操作系统集成,以实现低开销和高可扩展性,而 “Swift concurrency: Behind the scenes“给出了更多关于如何实现这一目标的技术细节。

所有这些功能结合在一起,使在Swift中编写并发代码变得简单而安全,让你在编写代码时能够最大限度地利用你的设备,同时仍然专注于你的应用程序的有趣部分,少考虑管理并发任务的机制或由多线程引起的潜在错误的担忧。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享