Jak*_*ke 4 concurrency scala thread-safety
我是Scala的新手.
我试图弄清楚如何使用Scala对象(也称为单例)中的函数来确保线程安全
从我到目前为止所读到的内容来看,我似乎应该保持对函数范围(或下面)的可见性,并尽可能使用不可变变量.但是,我没有看到违反线程安全的例子,所以我不确定应该采取什么其他预防措施.
有人能指出我对这个问题的一个很好的讨论,最好举例说明线程安全被违反的地方吗?
天啊.这是一个很大的话题.这是一个基于Scala的并发介绍,Oracle的Java课程实际上也有很好的介绍.这是一个简短的介绍,它激发了为什么并发读取和写入共享状态(其中Scala对象是特定的特定情况)是一个问题并提供常见解决方案的快速概述.
在线程安全和状态变异方面存在两类(基本相关)问题:
让我们依次看看这些中的每一个.
第一次破坏写道:
object WritesExample {
var myList: List[Int] = List.empty
}
Run Code Online (Sandbox Code Playgroud)
想象一下,我们有两个线程同时访问WritesExample,每个线程执行以下操作updateList
def updateList(x: WritesExample.type): Unit =
WritesExample.myList = 1 :: WritesExample.myList
Run Code Online (Sandbox Code Playgroud)
你可能希望当两个线程都完成时WritesExample.myList有length2个.不幸的是,如果两个线程WritesExample.myList在另一个线程完成写入之前读取,则可能不是这种情况.如果当两个线程都读取WritesExample.myList它为空时,则两者都将写回1的列表length,其中一个写入覆盖另一个,因此最终WritesExample.myList只有一个长度.因此,我们已经有效地丢失了我们应该执行的写入.不好.
现在让我们看看不准确的读取.
object ReadsExample {
val myMutableList: collection.mutable.MutableList[Int]
}
Run Code Online (Sandbox Code Playgroud)
再一次,假设我们有两个线程同时访问ReadsExample.这次他们每个人都updateList2反复执行.
def updateList2(x: ReadsExample.type): Unit =
ReadsExample.myMutableList += ReadsExample.myMutableList.length
Run Code Online (Sandbox Code Playgroud)
在单线程上下文中,updateList2当重复调用时,您可以期望简单地生成递增数字的有序列表,例如0, 1, 2, 3, 4,....不幸的是,当多个线程访问ReadsExample.myMutableList与updateList2在同一时间,它可能是之间时ReadsExample.myMutableList.length被读取,当写入终于坚持着,ReadsExample.myMutableList已经被另一个线程修改.所以理论上你可以看到类似0, 0, 1, 1或者可能的东西,如果一个线程需要比另一个线程更长的时间0, 1, 2, 1(在另一个线程已经访问并将其写入列表三次之后,较慢的线程最终写入列表).
发生的事情是读取不准确/过时; 更新的实际数据结构与读取的数据结构不同,即在事物中间从您下面更改.这也是一个巨大的错误来源,因为您可能希望保留的许多不变量(例如,列表中的每个数字与其索引完全对应,或者每个数字仅出现一次)保持在单线程上下文中,但在并发上下文中失败.
既然我们已经解决了一些问题,那么让我们深入研究一些解决方案.你提到了不变性,所以我们首先谈谈这个问题.您可能会注意到,在我的clobbering写入示例中,我使用了不可变数据结构,而在我不一致的读取示例中,我使用了可变数据结构.那是故意的.从某种意义上说,它们彼此是双重的.
对于不可变数据结构,您不能在上面列出的意义上进行"不准确"读取,因为您从不改变数据结构,而是将数据结构的新副本放在同一位置.数据结构不能从你下面改变,因为它不能改变!但是,通过将数据结构的版本放回其原始位置而不包含先前由另一个进程进行的更改,您可能会丢失写入过程.
另一方面,使用可变数据结构,您不会丢失写入,因为所有写入都是数据结构的就地突变,但您最终可以执行对数据结构的写入,该数据结构的状态与分析它时的状态不同以形成写.
如果它是一种"挑选毒药"的场景,为什么你经常听到建议使用不可变数据结构来帮助实现并发?即使写入丢失,良好的不可变数据结构也可以更容易地确保有关被修改状态的不变量.例如,如果我重写了ReadsList示例以使用不可变List(和var替代),那么我可以自信地说列表的整数元素将始终对应于列表的索引.这意味着您的程序进入不一致状态的可能性要小得多(例如,不难想象,当并发变异时,一个天真的可变集实现可能会以非唯一元素结束).事实证明,处理并发的现代技术通常非常适合处理丢失的写入.
让我们看一下处理共享状态并发的一些方法.在他们的心中,他们都可以概括为各种序列化读/写对的方法.
锁(也就是直接尝试序列化读/写对):这通常是您首先会听到的作为处理并发的基本方法.每个想要访问状态的进程都会锁定它.现在,任何其他进程都不会访问该状态.然后,该进程将写入该状态,并在完成时释放锁定.其他流程现在可以自由重复该流程.在我们中WritesExample,updateList首先在执行和释放锁之前获取锁; 这将阻止其他进程读取,WritesExample.myList直到写入完成,从而防止他们看到旧版本myList会导致破坏写入(请注意,这是更复杂的锁定过程,允许同时读取,但让我们现在坚持基础) .
锁通常不能很好地扩展到多个状态.使用多个锁定时,通常需要按特定顺序获取和释放锁定,否则可能会导致死锁或活锁.
最初链接的Oracle和Twitter文档对这种方法有很好的概述.
描述你的行动,不要执行它(也就是建立行动的串行表示并让其他人处理它):你不是直接访问和修改状态,而是描述如何执行此操作然后将其交给某人的操作否则实际执行动作.例如,您可以将消息传递给对象(例如Scala中的actor),这些对象将这些请求排队,然后在某个内部状态上逐个执行它们,它永远不会直接暴露给其他任何人.在actor的特定情况下,通过消除显式获取和释放锁的需要,这改善了锁的情况.只要您在一个对象中封装您需要立即访问的所有状态,消息传递就可以很好地进行.当你在多个对象之间分配状态时,Actor会崩溃(因此在这个范例中,这是非常不鼓励的).
在Scala中,Akka演员就是一个很好的例子.
事务(也称为暂时隔离其他人的一些读写操作,让隔离系统为您序列化事物):将所有读/写包裹在事务中,以确保在读写过程中您的世界视图与其他任何事物隔离开来变化.通常有两种方法可以实现这一目标.您可以采用类似于锁的方法,在事务运行时阻止其他人访问数据,或者每当您检测到共享状态发生更改并从而丢弃任何进度时从一开始就重新启动事务已经制作(出于性能原因通常是后者).一方面,与锁和演员不同,交易可以非常好地扩展到不同的状态.只需将所有访问权限包含在交易中,就可以了.另一方面,您的读取和写入必须是无副作用的,因为它们可能会被丢弃并重试多次,并且您无法真正撤消大多数副作用.
如果你真的不走运,虽然你通常不能真正实现与事务的良好实现的死锁,但是长期存在的事务可能会被其他短期事务中断,这样它就会被抛弃并重新执行,而且实际上从未实际成功(相当于活锁).实际上,您放弃了对序列化订单的直接控制,并希望您的交易系统明智地订购事物.
Scala的STM库就是这种方法的一个很好的例子.
删除共享状态:最终的"解决方案"是完全重新思考问题并尝试考虑您是否真的需要可写的全局共享状态.如果您不需要可写共享状态,那么并发问题就会完全消失!
生活中的一切都是权衡取舍,并发也不例外.在考虑并发性时,首先要了解您拥有的状态以及您希望保留的有关该状态的不变量.然后使用它来指导您决定使用哪种工具来解决问题.