消息传递并发语言在实践中比共享内存并发语言更好。

38

我已经是一名Java开发人员多年了,但直到开始进行Android开发并突然遇到“应用无响应”和明显的死锁情况之前,从未涉及过太多并发问题。

这让我认识到理解和调试某些并发问题有多么困难。新的语言如Scala和Go如何改进并发?它们如何更易于理解并如何预防并发错误?能否提供真实世界的例子来说明这些优势?

4个回答

49

简化并发的三种主要方法是actors、软件事务内存(STM)和自动并行化。Scala 实现了这三种方法。

Actors

Actors 最著名的实现是在 Erlang 语言中,据我所知这个想法就是从那里开始的。Erlang 是围绕着 actors 设计的。这个想法是让 actors 相互之间成为黑盒子;它们只通过传递消息来交互。

Scala 在其库中实现了 actors,而在外部库中也有一些变体可用。在主要库中,并没有强制要求使用黑盒子,但是有易于使用的方法来传递消息,而 Scala 使创建不可变消息变得容易(因此你不必担心在某个随机时间发送带有某些内容的消息,然后更改内容)。

actors 的优点是您无需担心复杂的共享状态,从而简化涉及的推理。此外,您可以将问题分解为比线程更小的片段,并让 actor 库确定如何将 actors 打包到适当数量的线程中。

缺点是如果您尝试做某些复杂的操作,则需要处理很多逻辑,例如发送消息、处理错误等,直到您确认它成功为止。

Software Transactional Memory

STM 基于这样的想法:最重要的并发操作是获取一些共享状态,对其进行修改,然后将其写回。因此,它提供了一种方法来执行此操作;但是,如果它遇到问题-通常延迟检测到最后一刻-然后检查以确保所有写入都正确执行,否则就会回滚更改并返回失败(或重试)。

这既具有高性能(在只有适度争用的情况下,因为通常一切都很顺利),又对大多数锁定错误具有鲁棒性,因为 STM 系统可以检测问题(甚至可能执行诸如从低优先级请求中取出访问权并将其给予高优先级请求等操作)。

与演员不同的是,只要你能处理失败,尝试复杂的事情就更容易。但是,您还必须正确推理底层状态;STM通过失败和重试来防止罕见的意外死锁,但如果您只是犯了一个逻辑错误,某些步骤无法完成,STM也无法允许它。

Scala有一个STM库,它不是标准库的一部分,但正在考虑将其包含进来。Clojure和Haskell都有成熟的STM库。

自动并行化

自动并行化的想法是,您不希望考虑并发性;您只想让东西快速发生。因此,如果您有某种并行操作--例如对集合中的每个项目应用某些复杂的操作,并产生另一组集合作为结果--则应该有自动执行此操作的例程。Scala的集合可以以这种方式使用(有一个.par方法,可以将传统的串行集合转换为其并行模拟)。许多其他语言也具有类似的功能(Clojure、Matlab等)。


编辑:实际上,Actor模型早在1973年就被描述过了,可能是受到了Simula 67中早期使用协程而不是并发的影响;1978年出现了相关的通信顺序进程。因此,Erlang的能力当时并不是独一无二的,但该语言在部署Actor模型方面具有独特的效果。


在我看来,“自动并行化”意味着:将一个串行程序不做任何修改地传递给编译器/工具,生成该程序的并行化版本。并行化版本必须至少与串行版本运行速度相同。 - user811773
@Atom - 这是理想情况,但几乎没有程序能够成功地做到这一点,因为要知道并行方法是否正确且更快速所需的分析是相当可观的。如果通过设置(主要是通过导入)始终使用集合的并行版本,可以在Scala中部分实现这一点。也许我应该称之为“半自动”;我完全同意它不是完全自动化的(无论是在Scala还是其他任何语言中)。 - Rex Kerr
+1。然而,使用.par.并不意味着无需考虑并发性。如果您正在修改可变状态,则它无法以任何方式保护您。但是,它确实有助于避免创建线程等。 - Matthew Farwell
@Matthew Farwell - 我同意;这大大减轻了负担(对于“这最好是可以轻松并行化且不依赖可变状态”),但并没有完全消除它。 - Rex Kerr
@RexKerr,你忽略了GCC自动转换为SIMD程序的数百万个程序。 - Miles Rout
@MilesRout - 当然。非常热情!我也忽略了现代CPU中的流水线和乱序执行,它们从字面上讲是并行计算。这种指令级并行性对于性能非常重要,但它与问题的主题毫不相关,因为任何语言都可以利用这些东西。问题明确是关于高级结构的。 - Rex Kerr

7
在Go语言的惯用程序中,线程通过通道进行状态和数据的通信。这可以在不需要锁的情况下完成(通道仍然在底层使用锁)。通过将数据通过通道传递给接收者,意味着数据的所有权转移。一旦您通过通道发送了一个值,您就不应该再对其进行操作,因为接收方现在“拥有”它。
但是,应该注意的是,Go运行时不以任何方式强制执行这种“所有权”的转移。通过通道发送的对象没有被标记或标记为任何东西。这只是一种约定。因此,如果您愿意的话,可以通过变异先前通过通道发送的值来自我伤害。
Go的优势在于Go提供的语法(启动goroutine和通道工作方式),使编写正确功能的代码变得更加容易,从而防止竞争条件和死锁。Go清晰的并发机制使得很容易推断出程序中会发生什么。
顺便说一句:如果您真的想使用它们,Go的标准库仍然提供传统的互斥锁和信号量。但显然您要自行决定和承担风险。

1
Go的通道基本上就是与演员的邮箱一样,对吧? - Rex Kerr
据我了解,Go语言的方法基于Tony Hoare的“CSP”(Communicating Sequential Processes)工作。一些信息可以在这里找到:usingcsp.com - jimt
1
嗯,主要的区别在于通信是同步还是异步的;我认为我会把它们放在同一类别中,即使两者背后的理论不同。实际上,你大部分时间都面临着相同的问题;只是问题以不同的方式显现出来(邮箱已满 vs. 永远等待发送消息)。 - Rex Kerr
大多数Go并发错误是由于通道的不正确使用引起的。事情并非凭空发生。并发仍然很难。Go通道不能保护您免受编写糟糕的并发代码的影响。 - Inanc Gumus

7
对我而言,使用Scala(Akka)actors相较于传统的并发模型有以下几个优势:
  1. 使用像actors这样的消息传递系统可以轻松处理共享状态。例如,我经常会将可变数据结构包装在一个actor中,这样访问它的唯一方式就是通过消息传递。由于actors总是一次处理一个消息,这确保了对数据的所有操作都是线程安全的。
  2. Actors部分地消除了处理生成和维护线程的需要。大多数actor库将处理将actors分布在线程之间,因此您只需要担心启动和停止actors。通常,我会为每个物理CPU核心创建一系列相同的actors,并使用负载平衡器actor将消息均匀地分配给它们。
  3. Actors可以帮助提高系统的可靠性。我使用Akka actors,其中一个功能是您可以为actors创建监督程序,如果一个actor崩溃,监督程序将自动创建一个新实例。这可以防止出现线程问题,其中一个线程崩溃,您卡在一个半运行的程序上。还可以很容易地根据需要启动新的actors,并与在另一个应用程序中运行的远程actors一起工作。

尽管死锁和竞争条件仍然可能存在,但您仍需要对并发和多线程编程有相当的了解,但actors使得识别和解决这些问题更加容易。我不知道这些在Android应用程序中有多少适用性,但我大多数时间都是做服务器端编程,使用actors使开发变得更加容易。


0

Scala actors 的工作原理是基于 shared-nothing 原则的,因此没有锁(也就不会有死锁)!Actors 监听消息并被调用来处理某个 actor 需要处理的代码。


2
如果两个Actor互相等待消息,但是它们从未收到消息,则可能会在基于Actor的系统中发生死锁。然而,与共享内存系统相比,这更容易理解,因为环境(处理器速度、可用处理器等)可能会改变时间等方面。 http://www.dalnefre.com/wp/2010/08/dining-philosophers-in-humus/ - oluies
这是一个很好的观点 - 谢谢你分享!虽然我猜想在Scala中编写这样的代码可能会更加困难,以至于会导致死锁。演员之间相互传递消息可能是一种有趣的编程方式。 - aishwarya

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