WWDC21 Protect mutable state with Swift actors

·  阅读 786

Swift推出了一个新的类型actor来保护可变状态,使得在并发编程中避免数据竞争,使用关键字可以使编译器前置检查潜在的并发风险,写出更健壮的代码。

从几个例子看数据竞争以及如何避免

数据竞争 和 状态检查

数据竞争例子

当两个独立的线程同时访问相同的数据,并且其中至少有一个访问是写入时,就会发生数据竞赛。

数据竞赛的构造很简单,但却是出了名的难以调试。这里有一个简单的计数器类,它的一个操作是增加计数器并返回其新值。假设我们继续前进并尝试从两个并发的任务中增量。根据执行的时间,我们可能得到1然后2,或者2然后1。这是预料之中的,在这两种情况下,计数器都会被留在一个一致的状态。但由于我们引入了数据竞赛,如果两个任务都读0写1,我们也可能得到1和1。

或者,如果返回语句发生在两个增量操作之后,甚至是2和2。数据竞赛是出了名的难以避免和调试的。

值语义类型的 "let "属性是真正不可变的,所以从不同的并发任务中访问它们是安全的。试图使用值类型解决数据竞争,把我们的计数器变成一个结构,使它成为一个值类型。

我们还必须将增量函数标记为mutating的,使它可以修改值属性。还需要把counter变量改成var,使其成为可变的,这又回到了数据竞争的困境中。

除非使用锁或者串行队列来保证原子性,下面我们看actor是如何解决的。

Actor 概念

Actor提供一种共享可改变状态的同步机制。

一个Actor有它自己的状态,这个状态与程序的其他部分隔离。访问该状态的唯一途径是通过Actor

只要你通过ActorActor的同步机制就会确保没有其他代码在同时访问Actor的状态。这给了我们与手动使用锁或串行调度队列相同的互斥属性,但对于Actor来说,这是Swift提供的一个基本保证。

Actor是Swift中的一种新类型。它们提供了与Swift中所有命名类型相同的能力。

它们可以有属性、方法、初始化器、下标,等等。它们可以符合协议,也可以用扩展来增强。

像类一样,它们是引用类型;因为Actor的目的是表达共享的可变状态。

事实上,Actor类型的主要特征是它们将其实例数据与程序的其他部分隔离,并确保对这些数据的同步访问。

使用Actor解决数据竞争

我们又有两个并发的任务试图对同一个计数器进行增量。Actor的内部同步机制确保一个增量调用在另一个开始之前执行完毕。所以我们可以得到1和2或者2和1,因为这两个都是有效的并发执行,但是我们不能两次得到相同的计数或者跳过任何数值,因为Actor的内部同步已经消除了Actor状态的数据竞赛的可能性。

让我们考虑一下,当两个并发任务都试图同时增加计数器时,实际会发生什么。一个会先到,而另一个则必须等待轮到。但我们如何确保第二个任务能够耐心地等待轮到它的Actor呢?Swift有一个这样的机制。每当你从外部与Actor交互时,你都是异步进行的。 如果Actor很忙,那么你的代码就会暂停,这样你运行的CPU就可以做其他有用的工作。 当Actor再次变得空闲时,它将唤醒你的代码--恢复执行--以便调用可以在Actor上运行。

这个例子中的 await 关键字表明,对Actor的异步调用可能涉及到这样一个暂停。让我们再进一步扩展我们的反例,增加一个不必要的缓慢的重置操作。这个操作将值设置为0,然后调用适当次数的增量,使计数器达到新值。 这个resetSlowly方法被定义在计数器Actor类型的扩展中,所以它是在Actor内部。 这意味着它可以直接访问Actor的状态,它就是这样做的,将计数器的值重置为0。

它还可以同步地调用Actor上的其他方法,比如调用increment。 这不需要等待,因为我们已经知道我们是在Actor上运行。

这是Actor的一个重要属性。

Actor上的同步代码总是在不被打断的情况下运行到完成。

因此,我们可以按顺序推理同步代码,而不需要考虑并发对我们Actor状态的影响。

Actor 图片下载的例子

我们已经强调了我们的同步代码是不间断运行的,但是Actor之间或与系统中的其他异步代码之间经常进行交互。让我们花几分钟的时间来谈谈异步代码和Actor

它负责从其他服务中下载图像。它还将下载的图像存储在一个缓存中,以避免多次下载同一图像。

逻辑流程很简单:检查缓存,下载图片,然后在返回之前将图片记录在缓存中。因为我们是在一个Actor中,这段代码没有低级的数据竞赛;任何数量的图像都可以被同时下载。Actor的同步机制保证每次只有一个任务可以执行访问缓存实例属性的代码,所以缓存不可能被破坏。也就是说,这里的 await 关键字在传达一些非常重要的信息。每当await发生时,就意味着这个函数在此时可以被暂停。

它放弃了自己的CPU,所以程序中的其他代码可以执行,这影响了整个程序的状态。在你的函数恢复的时候,整个程序的状态将发生变化。

重要的是要确保你在等待之前没有对该状态做出假设,而这些假设在等待之后可能不成立。

想象一下,我们有两个不同的并发任务,试图在同一时间获取同一图像。第一个任务看到没有缓存条目,开始从服务器上下载图片,然后因为下载需要一段时间而被暂停。

当第一个任务正在下载图像时,一个新的图像可能被部署到服务器上,在同一个URL下。

现在,第二个并发任务试图从该URL下获取图像。

它也没有看到缓存条目,因为第一个下载还没有完成,然后开始第二次下载图像。

当它的下载完成时,它也被暂停。

过了一会儿,其中一个下载--让我们假设是第一个--将完成,它的任务将在Actor上恢复执行。

它填充了缓存,并返回所得到的猫的图像。

现在第二个任务完成了它的下载,所以它被唤醒。

它用它得到的那只悲伤的猫的图像覆盖了缓存中的同一条目。

因此,尽管缓存中已经有了一张图片,但我们现在在同一个URL上得到了一张不同的图片。

这就有点让人吃惊了。

我们期望,一旦我们缓存了一张图片,我们总是能在同一个URL上得到相同的图片,这样我们的用户界面就会保持一致,至少在我们去手动清除缓存之前是这样。但是在这里,缓存的图片意外地发生了变化。

我们没有任何低级别的数据竞赛,但是因为我们在等待中携带了关于状态的假设,我们最终出现了一个潜在的错误。

这里的修复方法是在等待后检查我们的假设。

如果当我们恢复时,缓存中已经有一个条目,我们就保留原来的版本,丢弃新的。一个更好的解决方案是完全避免多余的下载。

Actor重入可以防止死锁,并保证向前推进,但它要求你在每个等待中检查你的假设。

为了更好地设计重入性,在同步代码中执行Actor状态的修改。

最好是在一个同步函数中进行,这样所有的状态变化都被很好地封装起来。

状态的改变可能涉及到将我们的Actor暂时置于一个不一致的状态。

请确保在等待之前恢复一致性。

记住,await是一个潜在的暂停点。

如果你的代码被暂停,程序和世界会在你的代码被恢复之前继续前进。

你对全局状态、时钟、计时器或你的Actor所做的任何假设,都需要在await之后进行检查。

Actor isolation 是Actor类型行为的基础

在本节中,我们将讨论Actor隔离如何与其他语言特性互动,包括协议符合性、闭包和类。像其他类型一样,只要Actor能够满足协议的要求,它们就可以符合协议。

遵守协议

例如,让我们让这个LibraryAccount Actor符合Equatable协议。静态的相等方法根据两个图书馆账户的ID号码进行比较。

因为该方法是静态的,没有自我实例,所以它不是孤立于Actor的。相反,我们有两个Actor类型的参数,而这个静态方法是在这两个参数之外的。这没关系,因为这个实现只是在访问Actor的不可变的状态。

让我们进一步扩展我们的例子,使我们的LibraryAccount符合Hashable协议。这样做需要实现**hash(in)**操作,我们可以像这样做。

然而,Swift编译器会抱怨说这种一致性是不允许的。

发生了什么?嗯,这样符合Hashable意味着这个函数可以从Actor外部调用,但是hash(in)不是异步的,所以没有办法保持Actor的隔离。

为了解决这个问题,我们可以把这个方法变成非隔离的。

非隔离的意思是,这个方法被视为在Actor之外,即使它在语法上是在Actor上描述的。

这意味着它可以满足Hashable协议的同步要求。

因为非隔离的方法被视为在Actor之外,所以它们不能引用Actor上的可变状态。

这个方法是好的,因为它引用的是不可变的ID号。

如果我们试图基于其他东西进行哈希操作,比如说借阅的书籍数组,我们会得到一个错误,因为从外部访问易变的状态会允许数据竞赛。

协议符合性的问题就到此为止。

闭包

让我们来谈一谈闭包。闭包是在一个函数中定义的小函数,然后可以被传递给另一个函数,在一段时间后被调用。

像函数一样,闭包可能是Actor隔离的,也可能是非隔离的。

在这个例子中,我们要从我们借来的每本书中读出一些,并返回我们读过的总页数。

对reduce的调用涉及一个执行阅读的闭包。

注意,在对 readSome 的调用中没有 await

这是因为这个闭包是在与Actor隔离的函数 "read "中形成的,它本身就是与**(actor-isolated)Actor**隔离的。

我们知道这是安全的,因为reduce操作将同步执行,并且不能将闭包转移到其他线程中,以免造成并发访问。

现在,让我们做一点不同的事情。

我现在没有时间读,所以我们以后再读。

在这里,我们创建一个分离式任务。

一个分离的任务在执行闭包的同时,还执行Actor正在进行的其他工作。

因此,这个闭包不能在Actor上,否则我们会引入数据竞赛。

所以这个闭包并不是孤立于Actor的。

当它想调用读取方法时,它必须以异步方式进行,正如 await 所示。

我们已经谈了一些关于Actor隔离的代码,也就是这些代码是在Actor内部还是外部运行。

现在,让我们来谈谈Actor隔离和数据的问题。

在我们的图书馆账户的例子中,我们刻意避免了说书的类型到底是什么。

我一直假设它是一个值类型,像一个结构。

这是一个很好的选择,因为它意味着图书馆账户Actor实例的所有状态都是独立的。

如果我们继续调用这个方法来随机选择一本书来阅读,我们会得到一个我们可以阅读的书的副本。

我们对书的副本所做的改变不会影响到Actor,反之亦然。

然而,如果把这本书变成一个类,事情就有点不同了。

我们的图书馆账户Actor现在引用书的类的实例。

这本身并不是一个问题。

然而,当我们调用这个方法来选择一本随机书时,会发生什么呢?现在我们有一个对Actor的可变状态的引用,这个引用已经在Actor之外被共享。我们已经创造了数据竞赛的可能性。

现在,如果我们去更新书的标题,修改发生在Actor内部可访问的状态中。

因为访问方法不在Actor上,所以这个修改最终可能成为数据竞赛。

值类型和Actor在并发使用时都是安全的,但类仍然会带来问题。

我们为可以安全地并发使用的类型起了一个名字。Sendable可发送类型。

一个Sendable可发送的类型是一个其值可以在不同的Actor之间共享的类型。

如果你把一个值从一个地方复制到另一个地方,并且两个地方都可以安全地修改他们自己的值的副本而不互相干扰,那么这个类型就是可发送的。

值类型是可发送的,因为每个副本都是独立的,正如在前面谈到的。

Actor类型是可发送的,因为它们同步访问它们的可改变的状态。

类可以是可发送的,但只有在它们被仔细实现的情况下。

例如,如果一个类和它的所有子类只持有不可变的数据,那么它就可以被称为可发送。

或者,如果该类在内部执行同步,例如使用锁,以确保安全的并发访问,那么它就可以是可发送的。

但是大多数类都不是这样的,所以不能被称为可发送类。

函数不一定是可发送的,所以有一种新的函数类型,可以安全地跨Actor传递。

你的Actor--事实上,你所有的并发代码--应该主要以可发送类型进行通信。可发送类型可以保护代码免受数据竞赛的影响。

这是一个Swift最终会开始静态检查的属性。

到那时,跨越Actor边界传递非可发送类型将成为一个错误。

人们如何知道一个类型是可发送的呢?好吧,Sendable是一个协议,你声明你的类型符合Sendable,就像你对其他协议所做的一样。

然后 Swift 会检查以确保你的类型作为可发送类型是合理的。

如果一个图书结构的所有存储属性都是可发送类型,那么它就可以是可发送的。

比方说,作者实际上是一个类,这意味着它--以及作者数组--不是可发送的。

Swift会产生一个编译器错误,表明Book不能是可发送的。

对于泛型类型,它们是否是可发送的,可能取决于它们的泛型参数。

我们可以在适当的时候使用条件一致性来传播Sendable

例如,只有当一个对类型的两个泛型参数都是可发送的,它才是可发送的。

同样的方法被用来得出结论,一个可发送类型的数组本身就是可发送的。

我们鼓励你将Sendable的符合性引入到其值可以安全地并发共享的类型中。

在你的Actor中使用这些类型。

然后,当 Swift 开始执行跨ActorSendable 时,你的代码就准备好了。函数本身可以是可发送的,这意味着跨Actor传递函数值是安全的。

这对闭包尤其重要,因为它限制了闭包可以做的事情,以帮助防止数据竞赛。

例如,一个可发送的闭包不能捕获一个可变的局部变量,因为这将允许局部变量的数据竞赛。

闭包捕获的任何东西都需要是可发送的,以确保闭包不能被用来跨Actor边界移动非可发送类型。

最后,一个同步的Sendable闭包不能被(actor-isolated)Actor隔离,因为这将允许代码从外部运行到Actor上。

在这次演讲中,我们实际上一直在依赖Sendable闭包的想法。

创建分离任务的操作需要一个Sendable函数,这里写的是函数类型中的**@Sendable**。

还记得我们在讲座开始时的反例吗?我们正试图建立一个值类型的计数器。

然后,我们试图同时从两个不同的闭包中去修改它。

这将是一个关于可变局部变量的数据竞赛。

然而,由于分离任务的闭包是Sendable,Swift会在这里产生一个错误。

可发送的函数类型被用来指示哪里可以并发执行,从而防止数据竞赛。

下面是我们之前看到的另一个例子。

因为分离任务的闭包是可发送的,我们知道它不应该被隔离到Actor中。

因此,与它的交互必须是异步的。

可发送类型和闭包通过检查易变状态是否在Actor之间共享,以及是否不能被并发修改来帮助维护Actor的隔离。

Main Actor

我们一直在讨论Actor类型,以及它们如何与协议、闭包和可发送类型交互。

还有一个Actor要讨论 -- 一个特殊的Actor,我们称之为MainActor

当你构建一个应用程序时,你需要考虑到主线程。

它是核心用户界面渲染发生的地方,也是处理用户交互事件的地方。

与用户界面有关的操作通常需要在主线程中执行。

然而,你不希望在主线程上做所有的工作。

如果你在主线程上做了太多的工作,比如说,因为你有一些缓慢的输入/输出操作或与服务器的阻塞交互,你的用户界面会冻结。

因此,你需要注意在主线程与用户界面交互时,在主线程上做工作,但在计算成本高或等待时间长的操作中,要迅速离开主线程。

所以,我们在可以的时候从主线程上做工作,然后调用DispatchQueue.main.async

只要你有一个必须在主线程上执行的特定操作,就可以在你的代码中使用async

从机制的细节中回过头来看,这段代码的结构看起来隐约有些熟悉。

事实上,与主线程的交互就像与一个Actor的交互。

如果你知道你已经在主线程上运行,你可以安全地访问和更新你的用户界面状态。

如果你不在主线程上运行,你需要与它进行异步交互。

这正是演员的工作方式。

有一个特殊的Actor来描述主线程,我们称之为MainActorMainActor是一个代表主线程的Actor

它在两个重要方面与普通的Actor不同。

首先,MainActor通过主调度队列来执行所有的同步。

这意味着,从运行时的角度来看,MainActor可以与使用DispatchQueue.main互换

第二,需要在主线程上的代码和数据散布在各个地方。

它在SwiftUIAppKitUIKit和其他系统框架中。

它分散在你自己的视图、视图控制器和你的数据模型中面向用户界面的部分。

利用Swift并发性,你可以用MainActor属性来标记一个声明,表示它必须在main actor上执行。

我们在这里对checkedOut操作做了这样的标记,所以它总是在MainActor上运行。

如果你从MainActor之外调用它,你需要等待,这样调用就会在主线程上异步执行。

通过将必须在主线程上运行的代码标记为在MainActor上运行,就不需要再猜测何时使用DispatchQueue.main了。

Swift确保这些代码总是在主线程上执行。

类型也可以放在MainActor上,这使得它们所有的成员和子类都在MainActor上。

这对于你的代码库中必须与UI交互的部分很有用,在这些部分中,大多数东西都需要在主线程上运行。

个别方法可以通过nonisolated关键字选择退出,其规则与你所熟悉的普通Actor相同。

通过对面向用户界面的类型和操作使用MainActor,并引入自己的Actor来管理其他程序状态,你可以构建你的应用程序,以确保安全、正确地使用并发性。

总结

在这次会议中,我们谈到了Actor如何使用**(Actor isolation)Actor 隔离和要求来自Actor**外部的异步访问来序列化执行,从而保护他们的可变状态免受并发访问。

使用actor在你的Swift代码中建立安全、并发的抽象。

在实现你的Actor和任何异步代码时,要始终为重入性而设计;你的代码中的等待意味着世界可以继续前进,并使你的假设失效。

值类型和Actor一起工作,以消除数据竞赛。

要注意那些不处理自己的同步的类,以及其他重新引入共享可变状态的非Sendable类型。

最后,在你与UI交互的代码上使用main actor,以确保必须在主线程上运行的代码总是在主线程上运行。

分类:
iOS
标签: