Scala对象和线程安全性

7

我是Scala的新手。

我正在尝试弄清楚如何确保Scala对象(也称为单例)中的函数线程安全。

根据我目前所了解的,似乎应该将可见性限制在函数作用域(或以下),并尽可能使用不可变变量。然而,我还没有看到违反线程安全的示例,因此我不确定还需要采取哪些其他预防措施。

有人能否指向一篇关于这个问题的好文章,最好附带违反线程安全的示例?

3个回答

16
这是一个非常重要的话题。以下是基于Scala的并发简介,Oracle的Java课程也有一个相当不错的简介。这里提供一个简短的简介,说明为什么共享状态的并发读写(其中Scala对象是特定的情况)是一个问题,并提供常见解决方案的快速概述。
在线程安全和状态变化时,存在两种(根本相关的)问题类别:
  1. 覆盖(缺失)写入
  2. 不准确(从你手中更改)读取
让我们依次看看每个问题。
首先是覆盖写入:
object WritesExample {
  var myList: List[Int] = List.empty
}

想象一下,我们有两个线程同时访问WritesExample,每个线程都执行以下updateList

def updateList(x: WritesExample.type): Unit = 
  WritesExample.myList = 1 :: WritesExample.myList

你可能希望在两个线程完成后,WritesExample.myListlength为2。但是,如果两个线程在另一个线程完成写入之前都读取了WritesExample.myList,那么情况可能并非如此。如果两个线程读取WritesExample.myList时它为空,那么两个线程都会写回长度为1的列表,其中一个写操作会覆盖另一个,因此最终WritesExample.myList的长度仅为1。因此,我们实际上失去了应该执行的写操作。这不好。

现在让我们看一下不准确的读取。

object ReadsExample {
  val myMutableList: collection.mutable.MutableList[Int]
}

再次假设我们有两个线程同时访问ReadsExample。这一次,它们中的每一个都会重复执行updateList2
def updateList2(x: ReadsExample.type): Unit = 
  ReadsExample.myMutableList += ReadsExample.myMutableList.length

在单线程环境下,您期望重复调用updateList2时,仅生成递增数字的有序列表,例如0、1、2、3、4...。不幸的是,在多个线程同时使用updateList2访问ReadsExample.myMutableList时,可能会出现在读取ReadsExample.myMutableList.length和最终写入之间,ReadsExample.myMutableList已经被另一个线程修改的情况。因此,理论上您可能会看到像0、0、1、1这样的东西,或者如果一个线程需要比另一个线程更长时间才能写入,则可能会看到0、1、2、1(其中较慢的线程在另一个线程已经访问并写入列表三次后最终写入列表)。
发生的情况是读取不准确/过时;实际更新的数据结构与读取的数据结构不同,即在事情进行中从中更改。这也是错误的巨大来源,因为您可能希望保持的许多不变量(例如列表中的每个数字恰好对应其索引或每个数字仅出现一次)在并发上下文中不成立。
现在我们已经激发了一些问题,让我们深入探讨一些解决方案。您提到了不可变性,所以让我们首先谈论一下这个问题。您可能会注意到,在我模拟覆盖写入的示例中,我使用了一个不可变的数据结构,而在我不一致的读取示例中,我使用了一个可变的数据结构。这是有意为之的。它们在某种程度上是相互对偶的。
使用不可变的数据结构,您不能像我上面所述的那样产生“不准确”的读取,因为您从不改变数据结构,而是将数据结构的新副本放置在同一位置。数据结构不能因为它不能改变而从您身下改变!但是,通过将一个不包含另一个进程先前进行的更改的版本的数据结构放回其原始位置,您可以在此过程中丢失写入。
另一方面,对于可变的数据结构,您无法丢失写入,因为所有写入都是数据结构的就地突变,但是您可能会执行对状态与您分析以制定写入时不同的数据结构的写入。
如果是“选择你的毒药”情况,为什么常听到建议使用不可变数据结构来帮助并发?好吧,不可变数据结构使得更容易确保修改状态的不变量即使写操作丢失也能保持。例如,如果我重写ReadsList示例以使用不可变的List(和一个var),那么我可以自信地说列表的整数元素始终对应于列表的索引。这意味着您的程序很少会进入不一致的状态(例如,可以想象当并发变异时,天真的可变集合实现可能会出现非唯一元素)。而且事实证明,处理并发的现代技术通常非常擅长处理缺失写入。
让我们看看一些处理共享状态并发的方法。它们的核心都可以总结为各种序列化读/写对的方法。

锁(也称直接尝试序列化读/写对):这通常是你首先听到的处理并发的基本方法。想要访问状态的每个进程都会首先在其上放置一个锁。现在任何其他进程都被排除在访问该状态之外。然后该进程写入该状态,并在完成时释放锁。其他进程现在可以重复此过程。在我们的WritesExample中,updateList在执行和释放锁之前将首先获取锁;这将防止其他进程读取WritesExample.myList,直到写入完成,从而防止它们看到可能导致破坏性写入的旧版本myList(请注意,还有更复杂的锁定过程,允许同时读取,但让我们现在坚持基础知识)。

锁通常不适用于多个状态。使用多个锁时,通常需要按特定顺序获取和释放锁,否则您可能会死锁或活锁

Oracle和Twitter文档提供了对此方法的良好概述。

描述您的操作,而不是执行它(即建立一个连续表示您的操作的序列,并让其他人处理它):不直接访问和修改状态,而是描述如何执行此操作,然后将其交给其他人来实际执行。例如,您可以向对象传递消息(例如Scala中的actors),该对象会将这些请求排队,然后逐个在其从未直接公开给任何其他人的内部状态上执行它们。在actors的特定情况下,通过消除显式获取和释放锁的需求,改善了锁的情况。只要您将需要同时访问的所有状态封装在单个对象中,消息传递就非常有效。当您在多个对象之间分布状态时,actors会崩溃(因此,在此范例中,强烈不建议这样做)。 Akka actors是Scala中的一个很好的例子。

事务(也称为暂时将某些读写操作与其他操作隔离,并让隔离系统为您序列化):将所有读/写操作都包装在事务中,以确保在进行读/写操作期间,您对世界的看法与任何其他更改隔离。通常有两种实现方式。一种是类似锁定的方法,在事务运行时防止其他人访问数据;另一种是在检测到共享状态发生更改时从头开始重新启动事务并放弃任何进度(通常是为了提高性能)。一方面,与锁和演员不同,事务很好地适用于不同的状态片段。只需将所有访问操作都包装在事务中即可。另一方面,您的读/写操作必须无副作用,因为它们可能会被多次抛弃和重试,并且您无法真正撤消大多数副作用。

如果你真的很不幸,虽然使用良好的事务实现通常不会真正死锁,但长时间运行的事务可能会不断被其他短暂事务打断,导致它不断被抛弃和重试,从而永远无法成功(这相当于活锁)。实际上,您正在放弃对序列化顺序的直接控制,并希望您的事务系统合理地排序。

Scala的STM库是此方法的一个很好的示例。

消除共享状态:最终的“解决方案”是重新思考问题,尝试思考您是否真正需要可写的全局共享状态。如果您不需要可写的共享状态,则并发问题将完全消失!

生活中的一切都是关于权衡的,而并发也不例外。在考虑并发时,首先要了解你拥有什么状态以及你想要保留哪些不变量。然后使用这些来指导你决定使用什么样的工具来解决问题。


2
太棒了!这是我在互联网上读过的最好的东西。真的很有帮助。Actor范例在Scala中似乎相当容易实现。但我也在想,是否应该在使用Scala中的对象时更加谨慎。它们在初学者教程中被过度推荐,并没有警告。无论如何,非常有帮助。你应该得到100个赞。 - Jake

3
这篇Scala并发文章中的Thread Safety Problem部分可能会引起您的兴趣。简而言之,它使用一个简单的例子说明了线程安全问题,并概述了解决该问题的3种不同方法,即synchronizationvolatileAtomicReference
当您进入synchronized点、访问volatile引用或取消引用AtomicReferences时,Java会强制刷新其缓存行并提供数据的一致视图。
此外,还有一个简短的概述比较了这3种方法的成本:
由于您必须通过方法调度来访问值,所以AtomicReference是这两个选择中最昂贵的。volatilesynchronized建立在Java的内置监视器之上。如果没有争用,监视器的成本非常低。由于synchronized允许您更细粒度地控制何时同步,因此争用会更少,所以synchronized往往是最便宜的选项。

好的,非常有帮助。 - Jake
在我看来,对于对象(即单例)方法的最佳实践似乎是要注意可变变量,并考虑同步它们的值分配到这些变量,如果存在线程安全性问题的风险。你同意吗? - Jake
是的,我认为这些是从文章中得出的好结论。 - Leo C

2

如果您的对象包含可以在并发情况下进行修改的状态,则无论是使用Scala还是其他编程语言,都可能会破坏线程安全性,这取决于实现方式。例如:

object BankAccount {
   private var balance: Long = 0L

   def deposit(amount: Long): Unit = balance += amount
}

在这种情况下,对象不是线程安全的,有很多方法可以使其线程安全,例如使用Akka或同步块。为了简单起见,我将使用同步块进行编写。
object BankAccount {
   private var balance: Long = 0L

   def deposit(amount: Long): Unit = 
       this.synchronized {
           balance += amount
       }
}

这正是我所想的。和Java一样。因此,当开发Scala对象时,我们必须非常谨慎,特别是如果它们可能被部署到多线程应用程序中。非常感谢。 - Jake
2
如果您正在开发一个具有大量并发性的系统,我建议您看一下Akka,它非常出色。 - Mikel San Vicente
谢谢。目前我正在使用Scala/Spark进行一些数据处理。我还在努力理解Scala,但我可以看出Akka对于我的一些现有项目来说是非常优秀的。 - Jake

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接