探索Swift中的结构化并发编程
当你有代码须要与其余代码同时运行时,选择合适的工具来完成任务是很重要的。咱们将带你了解你能够在Swift中建立的不一样类型的并发任务,告诉你如何建立任务组,并找出如何取消正在进行的任务。咱们还将提供指导,说明何时你可能想要使用非结构化的任务。为了得到本节课的最大收获,咱们首先建议观看 "在Swift中认识async/await"。数组
Swift 5.5 引入了一种编写并发程序的新方法,使用了一个叫作结构化并发的概念。结构化并发背后的想法是基于结构化编程的,它是如此直观,以致于你不多去想它,但思考它将帮助你理解结构化并发。缓存
所以,让咱们深刻了解一下。安全
在计算机的早期,程序很难阅读,由于它们被写成了一连串的指令,控制流被容许处处跳跃。今天,你不会看到这种状况,由于语言使用结构化编程,使控制流更加统一。 例如,if-then语句使用结构化的控制流。服务器
它规定了一个嵌套的代码块在从上到下移动的过程当中只能有条件地执行。在Swift中,该块也准守静态范围,这意味着只有当名字在一个封闭的块中被定义时才是可见的。这也意味着,在一个区块中定义的任何变量的生命周期将在离开区块时结束。所以,带有静态范围的结构化编程使得控制流和变量生命周期变得容易理解。markdown
更普遍地说,结构化的控制流能够天然地进行排序和嵌套。这可让你从上到下阅读你的整个程序。因此,这些就是结构化编程的基本原理。你能够想象,这很容易被认为是理所固然的,由于它对咱们今天来讲是如此直观。可是,今天的程序以异步和并发代码为特征,而它们却没有可以利用结构化编程来使这些代码更容易编写。网络
首先,让咱们考虑一下结构化编程如何使异步代码更简单。多线程
假设你须要从互联网上获取一堆图片,并按顺序调整它们的大小,使之成为缩略图。这段代码以异步方式完成这项工做,接收一个标识图片的字符串集合。你会注意到这个函数在调用时没有返回一个值。这是由于该函数将其结果或错误传给了一个完成处理程序。这种模式容许调用者在稍后的时间收到一个答案。做为这种模式的结果,这个函数不能使用结构化的控制流来处理错误。这是由于只有在处理从一个函数中抛出的错误时才有意义,而不是在一个函数中。此外,这个模式还阻止你使用循环来处理每一个缩略图。递归是必须的,由于函数完成后运行的代码必须嵌套在处理程序中。如今,让咱们来看看之前的代码,但要重写成使用新的async/await语法,它是基于结构化编程的。闭包
我放弃了函数中的完成处理程序参数。取而代之的是,在它的类型签名中用 "async "和 "throws "来注解。它还返回一个值,而不是什么都没有。并发
在函数的主体中,我使用 "await "来表示一个异步动做的发生,而且在该动做以后运行的代码不须要嵌套。
这意味着我如今能够在缩略图上循环,按顺序处理它们。
我还能够抛出和捕获错误,编译器会检查我是否忘记了。若是想深刻了解async/await,请查看 "在Swift中认识async/await "这一环节。
那么,这段代码很好,但若是你要为成千上万的图片制做缩略图呢?一次处理一个缩略图再也不是理想的作法。另外,若是每一个缩略图的尺寸必须从另外一个URL下载,而不是固定的尺寸,怎么办?如今有机会增长一些并发性,因此多个下载能够并行进行。你能够建立额外的任务来给程序添加并发性。
任务是Swift的一项新功能,与异步函数携手合做。
任务为运行异步代码提供了一个新的执行环境。每一个任务相对于其余执行上下文都是并发运行的。在安全有效的状况下,它们会被自动安排为并行运行。
因为任务被深度整合到Swift中,编译器能够帮助防止一些并发性错误。
另外,请记住,调用一个异步函数并不会为该调用建立一个新任务。你要明确地建立任务。
在 Swift 中有几种不一样的任务,由于结构化并发是关于灵活性和简单性之间的平衡。所以,在本次会议的其他部分,咱们将介绍和讨论每一种任务,以帮助你理解它们的权衡。
让咱们从这些任务中最简单的开始,它是用一种叫作async-let绑定的新语法形式建立的。
为了帮助你理解这种新的语法形式,我想首先分解一下普通let绑定的评估过程。有两个部分:等号右边的初始化表达式和左边的变量名称。在let以前或以后可能还有其余语句,因此我在这里也会包括这些。
一旦Swift到达一个let绑定,它的初始化器将被评估以产生一个值。在这个例子中,这意味着从一个URL下载数据,这可能须要一段时间。数据下载完毕后,Swift将把这个值绑定到变量名上,而后再进行后面的语句。请注意,这里只有一个执行流程,正如经过每一个步骤的箭头所追踪的那样。
因为下载可能须要一段时间,你但愿程序开始下载数据,并继续作其余工做,直到真正须要这些数据。 为了实现这一点,你能够在现有的let绑定前面加上async这个词。
这就把它变成了一个叫作async-let的并发绑定。
并发绑定的评估与顺序绑定是彻底不一样的,因此让咱们来学习它是如何工做的。 我将从遇到绑定以前的那一刻开始。
为了评估一个并发绑定,Swift首先会建立一个新的子任务,这是建立它的那个任务的子任务。由于每一个任务都表明了你的程序的执行环境,因此在这一步中会同时出现两个箭头。 第一个箭头是子任务的,它将当即开始下载数据。 第二个箭头是针对父任务的,它将当即把变量结果与一个占位符值绑定。 这个父任务就是正在执行前面语句的那个任务。当数据被子任务并发下载时,父任务继续执行并发绑定后的语句。 可是在到达一个须要实际结果值的表达式时,父任务将等待子任务的完成,子任务将履行结果的占位符。
在这个例子中,咱们对URLSession的调用也可能抛出一个错误。这意味着等待结果可能会给咱们一个错误。因此我须要写 "try "来处理它。不要担忧。再次读取结果的值不会从新计算其值。
如今你已经看到了async-let是如何工做的,你能够用它来为缩略图的获取代码添加并发性。我已经将以前的一段获取单张图片的代码分解到本身的函数中。
这里的这个新函数也是从两个不一样的URL中下载数据:一个是全尺寸的图片自己,另外一个是元数据,其中包含了最佳的缩略图尺寸。
请注意,在顺序绑定的状况下,你在let的右边写上 "try await",由于那是观察错误或暂停的地方。
为了使两个下载同时发生,你在这两个let的前面写上 "async"。
因为下载如今发生在子任务中,你再也不在并发绑定的右边写 "try await"。
这些效果只有在使用被并发绑定的变量时才会被父任务观察到。因此你在表达式读取元数据和图像数据以前写 "try await"。
另外,注意到使用这些被并发绑定的变量不须要方法调用或其余任何改变。这些变量的类型与它们在顺序绑定中的类型相同。
如今,我一直在谈论的这些子任务其实是一个叫作任务树的层次结构的一部分。这个树不只仅是一个实现细节。它是结构化并发的一个重要部分。它影响着你的任务的属性,如取消、优先级和任务本地变量。每当你从一个异步函数调用到另外一个异步函数时,同一个任务被用来执行调用。因此,函数fetchOneThumbnail继承了该任务的全部属性。当建立一个新的结构化任务时,好比用async-let,它就会成为当前函数所运行的任务的子任务。任务不是特定函数的子任务,但它们的生命周期多是以它为范围的。
树是由每一个父任务和其子任务之间的连接组成的。连接强制执行一条规则,即父任务只有在其全部的子任务都完成后才能完成其工做。
这条规则甚至在控制流异常的状况下也有效,由于控制流会阻止子任务被等待。
例如,在这段代码中,我首先在图像数据任务以前等待元数据任务。若是第一个等待的任务以抛出错误的方式结束,fetchOneThumbnail函数必须当即经过抛出错误退出。但执行第二个下载的任务会发生什么?在非正常退出过程当中,Swift会自动将未等待的任务标记为取消,而后等待它完成,再退出函数。将一个任务标记为取消并不会中止该任务。它只是通知该任务再也不须要其结果。
事实上,当一个任务被取消时,全部做为该任务后裔的子任务也将被自动取消。
所以,若是URLSession的实现建立了本身的结构化任务来下载图片,这些任务将被标记为取消。
一旦它直接或间接建立的全部结构化任务都完成了,函数 fetchOneThumbnail 就会经过抛出错误而最终退出。 这种保证是结构化并发的基础。
它经过帮助你管理任务的生命周期来防止你意外地泄露任务,就像ARC自动管理内存的寿命同样。
到目前为止,我已经给了你一个关于取消如何传播的概述。
但任务最终什么时候中止呢?若是任务正处于一个重要的事务中,或者有开放的网络链接,直接中止任务是不正确的。
这就是为何Swift中的任务取消是合做性的。
你的代码必须明确地检查取消,并以任何适当的方式结束执行。
你能够从任何函数中检查当前任务的取消状态,不管它是不是异步的。
这意味着你在实现你的API时应该考虑到取消的问题,特别是当它们涉及到长期运行的计算时。
你的用户可能会从一个能够取消的任务中调用你的代码,他们会但愿计算能尽快中止。
为了看看使用合做取消有多简单,让咱们回到缩略图获取的例子。
在这里,我重写了原来的函数,该函数被赋予全部要获取的缩略图,所以它使用fetchOneThumbnail函数来代替。
若是这个函数是在一个被取消的任务中调用的,咱们不但愿由于建立无用的缩略图而耽误咱们的应用程序。
因此我能够在每一个循环迭代的开始添加一个对checkCancellation的调用。
这个调用只有在当前任务被取消时才会抛出一个错误。
你也能够把当前任务的取消状态做为一个布尔值来获取,若是这对你的代码更合适的话。
注意,在这个版本的函数中,我返回了一个部分结果,一个只有部分请求的缩略图的字典。
当这样作时,你必须确保你的API清楚地说明能够返回部分结果。
不然,任务取消可能会给你的用户带来致命的错误,由于他们的代码须要一个完整的结果,即便是在取消的过程当中。
到目前为止,你已经看到async-let提供了一种轻量级的语法,用于在你的程序中添加并发性,同时抓住告终构化编程的本质
我想告诉你的下一种任务被称为组任务。它们提供了比async-let更多的灵活性,同时又不放弃结构化并发的全部美好特性。正如咱们前面所看到的,当有固定的并发量时,async-let工做得很好。让咱们考虑一下我前面讨论的两个函数。
对于循环中的每一个缩略图ID,咱们调用fetchOneThumbnail来处理它,这正好创造了两个子任务。即便咱们将该函数的主体内联到这个循环中,并发量也不会改变。Async-let的做用域就像一个变量绑定。这意味着这两个子任务必须在下一个循环迭代开始以前完成。可是,若是咱们想让这个循环启动任务来同时获取全部的缩略图呢?那么,并发量就不是静态的了,由于它取决于数组中ID的数量。 对于这种状况,正确的工具是任务组。
任务组是一种结构化并发的形式,旨在提供一个动态的并发量。
你能够经过调用withThrowingTaskGroup函数来引入一个任务组。这个函数给你一个范围内的组对象来建立容许抛出错误的子任务。
添加到组中的任务不能超过定义该组的块的范围。
因为我已经把整个for-loop放在块内,我如今可使用组来建立动态的任务数量。
你能够经过调用组的异步方法来建立组中的子任务。
一旦被添加到一个组中,子任务就会当即开始执行,而且以任何顺序执行。
当组对象超出范围时,组内全部任务的完成将被隐含地等待。
这是我前面描述的任务树规则的一个结果,由于组任务也是有结构的。
在这一点上,咱们已经实现了咱们想要的并发性:每次调用fetchOneThumbnail都有一个任务,它自己将使用async-let建立另外两个任务。这是结构化并发的另外一个不错的属性。
你能够在组任务中使用async-let,或者在async-let任务中建立任务组,而树中的并发层次天然地组成了。
如今,这段代码尚未彻底准备好运行。若是咱们试图运行它,编译器会颇有帮助地提醒咱们有一个数据竞争问题。
问题是,咱们试图从每一个子任务中插入一个缩略图到一个字典中。当增长程序中的并发量时,这是一个常见的错误。数据竞争就会意外地产生。
这个字典不能同时处理一个以上的访问,若是两个子任务试图同时插入缩略图,这可能会致使崩溃或数据损坏。
在过去,你必须本身调查这些bug,但Swift提供了静态检查,以防止这些bug首先发生。每当你建立一个新的任务时,该任务执行的工做都在一个新的闭包类型中,称为**@Sendable**闭包。
@Sendable闭包的主体被限制在其词法上下文中捕获可变的变量,由于这些变量在任务启动后可能被修改。这意味着你在任务中捕获的值必须是安全的,能够共享。
例如,由于它们是值类型,如Int和String,或者由于它们是旨在从多个线程访问的对象,如actors,以及实现本身同步的类。
咱们有一节课专门讨论这个话题,叫作 "用Swift actors保护易变状态",因此我鼓励你去看看。
为了不咱们例子中的数据竞争,你可让每一个子任务返回一个值。这种设计让父任务单独负责处理结果。 在这个例子中,我指定每一个子任务必须返回一个包含缩略图的字符串ID和UIImage的元组。而后,在每一个子任务中,我让它们返回键值元组供父任务处理,而不是直接写到字典中。
父任务可使用新的 for-await 循环来迭代每一个子任务的结果。for-await 循环按照完成的顺序从子任务中获取结果。由于这个循环按顺序运行,父任务能够安全地将每一个键值对添加到字典中。
这只是使用 for-await 循环来访问一个异步值序列的一个例子。
若是你本身的类型符合 AsyncSequence 协议,那么你也可使用 for-await 来迭代它们。
你能够在 "认识AsyncSequence "环节中了解更多。
虽然任务组是结构化并发的一种形式,但在任务树规则的实现方式上,组任务与async-let任务有一个小小的区别。
假设在遍历这个组的结果时,我遇到了一个完成时有错误的子任务。由于这个错误被抛出了组的块,而后组中的全部任务将被隐式取消,而后等待。
这就像async-let同样工做。
不一样的是,当你的组经过正常退出块而超出范围时。那么,取消就不是隐式的了。
这种行为使你更容易使用任务组来表达fork-join模式,由于**(jobs)任务**只会被等待,不会被取消。
你也能够在退出块以前使用组的cancelAll方法手动取消全部任务。
请记住,不管你如何取消一个任务,取消都会自动向树上传播。
Async-let和Group tasks是Swift中提供范围结构化任务的两种任务。
以前向你展现告终构化并发是如何简化错误传播、取消和其余处理工做的,当你向一个有明确层次的任务的程序中添加并发时。但咱们知道,当你在程序中添加任务时,你并不老是有一个层次结构。
Swift也提供了非结构化的任务API,这让你有更多的灵活性,代价是须要更多的人工管理。
有不少状况下,一个任务可能不属于一个明确的层次结构。
最明显的是,若是你想启动一个任务来作非同步代码的异步计算,你可能根本就没有一个父任务。
另外,你想要的任务的生命周期可能不适合单个范围甚至单个函数的限制。
例如,你可能想在一个将对象放入活动状态的方法调用中启动一个任务,而后在另外一个将对象停用的方法调用中取消其执行。
在AppKit和UIKit中实现委托对象时,这种状况常常出现。
UI工做必须发生在主线程上,正如Swift actors会议所讨论的,Swift经过声明属于MainActor的UI类来确保这一点。
假设咱们有一个集合视图,而咱们还不能使用集合视图的数据源API。相反,咱们想使用咱们刚刚写的fetchThumbnails函数,在集合视图中的项目显示时从网络上抓取缩略图。
然而,委托方法不是异步的,因此咱们不能只是等待对一个异步函数的调用。
咱们须要为此启动一个任务,但这个任务其实是咱们为响应委托动做而启动的工做的延伸。咱们但愿这个新任务仍然以UI优先级在主角色上运行。咱们只是不想把任务的生命周期限制在这个单一委托方法的范围内。
对于这样的状况,Swift容许咱们构建一个非结构化的任务。
让咱们把代码的异步部分移到一个闭包中,并经过该闭包来构造一个异步任务。
如今是在运行时发生的状况。
当咱们到达建立任务的点时,Swift 会安排它在与源做用域相同的行为体上运行,在这种状况下,它就是主行为体。
同时,控制权会当即返回给调用者。缩略图任务将在主线程上运行,而不会当即阻塞委托方法上的主线程。
以这种方式构造任务给了咱们一个介于结构化和非结构化代码之间的中间点。
一个直接构建的任务仍然继承了它所启动的上下文的Actor(若是有的话),它也继承了原任务的优先级和其余特征,就像一个组任务或一个async-let那样。
然而,新任务是无范围的。它的生命周期不受它被启动的范围的约束。
原点甚至不须要是异步的。咱们能够在任何地方建立一个无范围的任务。
为了换取全部这些灵活性,咱们还必须手动管理那些结构化并发会自动处理的事情。
取消和错误不会自动传播,任务的结果也不会被隐式地等待,除非咱们采起显式的行动来这样作。
因此咱们启动了一个任务,在显示集合视图项目时获取缩略图,若是该项目在缩略图准备好以前被滚动出视图,咱们也应该取消该任务。因为咱们使用的是一个无范围的任务,因此这个取消不是自动的。
如今让咱们来实现它。
在咱们构建任务以后,让咱们保存咱们获得的值。当咱们建立任务时,咱们能够把这个值放入一个以行索引为键的字典中,这样咱们之后就能够用它来取消这个任务。一旦任务完成,咱们也应该把它从字典中删除,这样咱们就不会在任务已经完成的状况下试图取消它。
注意这里,咱们能够在那个异步任务的内部和外部访问同一个 dictionary,而不会被编译器标记为数据竞争。
咱们的委托类被绑定到主角色上,而新任务则继承了主角色,因此它们永远不会一块儿并行运行。
咱们能够安全地访问这个任务中与主角色绑定的类的存储属性,而不用担忧数据竞争。
同时,若是咱们的委托人后来被告知同一表行已经从显示中移除,那么咱们能够调用该值的取消方法来取消该任务。
因此如今咱们已经看到了咱们如何建立独立于做用域运行的非结构化任务,同时仍然继承了该任务的起始上下文的特性。
但有时你并不想从你的起始上下文中继承任何东西。
为了得到最大的灵活性,Swift提供了分离式任务。
就像名字所暗示的那样,分离的任务是独立于其上下文的。
它们仍然是非结构化的任务。
它们的生命期不受其起始做用域的约束。
但分离的任务也不会从它们的起始做用域中获取任何其余东西。
默认状况下,它们不被限制在同一个角色上,也不须要在与它们被启动的地方相同的优先级上运行。
分离的任务是独立运行的,在优先级等方面有通用的默认值,但它们也能够用可选的参数来控制新任务的执行方式和位置。
比方说,当咱们从服务器上获取缩略图后,咱们想把它们写入本地磁盘缓存,这样若是咱们之后试图获取它们就不会再碰到网络。
缓存不须要发生在主角色上,即便咱们取消了对全部缩略图的获取,对咱们获取的缩略图进行缓存仍然是有帮助的。
所以,让咱们经过使用一个分离的任务来启动缓存。
当咱们分离一个任务时,咱们在设置新任务的执行方式上也有了更大的灵活性。
缓存应该发生在一个较低的优先级上,不会干扰主用户界面,咱们能够在分离这个新任务时指定后台优先级。
如今让咱们提早计划一下。若是咱们有多个后台任务要在咱们的缩略图上执行,咱们未来应该怎么作?咱们能够分离出更多的后台任务,但咱们也能够在分离出来的任务里面利用结构化的并发性。咱们能够将全部不一样种类的任务结合在一块儿,利用它们各自的优点。咱们能够设置一个任务组,并将每一个后台任务做为子任务生成到该组中,而不是为每一个后台任务分离出一个独立的任务。这样作有不少好处。
若是咱们未来确实须要取消后台任务,使用任务组意味着咱们能够取消全部的子任务,只需取消顶层的分离任务。
这种取消将自动传播到子任务中,咱们不须要跟踪一个处理数组。此外,子任务会自动继承其父任务的优先级。
为了保持全部这些工做在后台进行,咱们只须要将分离的任务放在后台,这将自动传播到它的全部子任务,因此咱们不须要担忧忘记过渡地设置后台优先级而意外地饿死了UI工做。
在这一点上,咱们已经看到了Swift中全部主要的任务形式。
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中编写并发代码变得简单而安全,让你在编写代码时可以最大限度地利用你的设备,同时仍然专一于你的应用程序的有趣部分,少考虑管理并发任务的机制或由多线程引发的潜在错误的担心。