为什么我不喜欢共享内存

在我上一篇博文《并发是容易的》里,我写了关于编写并发系统的一个简单模型。当你写博客的时候,你必须考虑目标受众和你想把博客定位在哪个级别上。它应该是技术先进的,还是应该普及你想谈论的观点?

我选择了用一个明显的非技术性的方式来谈论并发,我用人们互相交谈来类比并发这个概念。在我的博文里,我认为进程应该表现得很像人。人有私有的记忆并通过消息传递来改变自己的私有记忆。

现在对此文的反应超出了我的预料。首先很多人读我写的东西,这是一个惊喜。几乎没有宣传的情况下,这篇文章达到programming.reddit.com所有文章的第三位。事实上,上个星期三,排名reddit.com编程文章前五位的文章有三篇是关于Erlang的。其次,在reddit.com上有一个关于我这篇博文的讨论开始了。

在这里,我将回答在讨论里提出的第一个问题:

Dogger说:

我不太清楚为什么没有共享的内存会如此厉害。他抛出的简单的一问一答消息的例子并不是许多程序的工作机制。我认为发送消息有其地位,共享内存也一样。选择哪一个是最合适的。

很棒的评论!我们认为理所当然的事情是我们觉得最不需要解释的事情。现在我认为共享内存是错误的。在过去的二十年里,我一直这样认为,我认为这是不证自明的,而且从来没有真正解释为什么我认为共享内存是一个坏主意。所以在下面我将给出一些为什么我不喜欢共享内存的原因。

Dogger的第二个评论是:“他的例子…并不真正是很多程序的工作机制”,他这个评论当然是正确的。许多程序并不以我建议的方式来运行。关键点是这些程序可以用不同的编程风格编写,完全避免了共享内存和锁,并且利用了细粒度并发和纯消息传递。我也相信这样的程序更容易写和理解,因为我下面列出的所有与共享内存有关的问题都被避免了。请注意,我没有表明我有一个解决任何如下问题的方案。但我会说,这样的问题可以使用不同的编程风格来完全避免。

现在我将开始讲讲为什么我不喜欢共享内存:

问题1:在关键区域崩溃的程序

当两个或更多程序想要共享内存的时候下面的方法通常被用到。任何程序想操作共享内存必须如下列出的步骤来做:

1
2
3
1.获取一个锁
2.操作共享内存
3.释放这个锁

获取锁之后和释放锁之前程序运行的代码被成为关键区域。在关键区域里的程序在运行期间不应该崩溃,而且在关键区域内不应该花太多时间。

如果程序在关键区域内崩溃的话会发生什么?

如果出现这种情况,则事情会变得很复杂。在理想的世界里,我们想要有这样的事务语义,即程序运行的净效应将是关键区域内的所有内存的改变都成功了,或者没有一个内存的改变成功并且内存的状态和程序进入关键区域前相同。

假设A尝试修改10个内存区域,这些区域叫做M1、M2…M10,那么程序假设如下所列:

1
2
3
4
5
6
1.获取一个锁
2.修改 M1
3.修改 M2
4....
5.修改 M10
6.释放这个锁

但是实际上可能是这样:

1
2
3
4
1.获取一个锁
2.修改 M1
3.修改 M2
4.崩溃

我们运行A想要的结果是,要么M1到M10的修改都成功,要么M1到M10的修改都不成功。因此,在上述第4步崩溃时,我们希望撤消前两个内存修改的结果。

要做到这点非常复杂,要假设有一些监督者程序在崩溃事件里能侦测到线程的崩溃并且恢复内存到原始状态。

问题2:程序在关键区域花费太多时间

当一个程序位于一个关键区域内时,会发生很多事情,它可以操纵内存,这就是为什么它必须首先处于关键区域;并且它可以执行计算。问题是,这些计算发生时,该程序是在关键区域内。因此,如果这些计算需要很长的时间,那么等待访问共享内存的所有其他程序都将被排队,直到当前程序离开关键区域为止。

写在关键区域内执行的代码是非常困难的,因为我们要避免非常耗时的计算并将它们移到关键区域之外。我们还必须删除关键区域中的远程过程调用之类的东西,以防它们突然占用太长时间。所有这一切都是非常不自然的编程方式,很难难得到正确的结果。

问题3:锁得太多

非常不幸地是我们经常锁的内存比我们想要的多得多,程序通常都是锁住所有共享内存,却只操作其中的一小段。在允许指针直接修改内存的语言中,可以保护的最小内存大小由页面表的粒度决定。一个典型的页面大小可能在8k到64k字节范围。8k字节的页面,你可能只想保护一个单字节,但你不得不保护最少8k字节。

您的程序可能只需要保护1字节,而系统中的其他程序可能希望修改在同一页的其他部分的内存,但它们必须等待,直到你的程序离开这个关键区域,它们才可以操纵他们相关的内存部分。

现在这一点在一个单CPU上可能并不重要,不同的线程都运行在同一个CPU,CPU总是忙,至少它在做一些事情。但在多核处理器上,这一点确实很重要。在多核CPU上,许多进程会等待获取锁,尽管逻辑上所有这些CPU可以并行运行。

当然,我们可以将共享内存划分成不同的分区,并让程序锁定到他们感兴趣的内存部分,但这样编程变得更加困难。

问题4:分布式共享内存

现在事情变得真正复杂了。在单个主板上真正只有一个内存可以被不同的CPU访问,但在一个集群或在网络分布式系统,这是不可行的。真正发生的是,系统中的每个节点都有自己的内存,并且读写和锁被应用到本地内存。在任何一个系统中,其中一个内存必须承担某种主导角色,系统中的其他内存承担次要角色,并表现为高速缓存。然后在不同的内存之间运行某种缓存一致性协议,以确保访问该存储器的所有进程具有一致的世界观。

现在所有这一切是非常难以实现的。所以在这一点上,大多数程序员只有放弃并且使用容错分布式数据库。这种数据库通常是相当缓慢的,因为它必须在后台做很多复杂的东西。

问题5:共享限制可扩展性

共享数据的线程不能独立地和并行地运行。在一个单核CPU上无所谓,但是在一个多核CPU上就有问题了。在线程共享数据的地方执行,它们的执行变成串行而不是并行。线程中的关键区域引入了限制可扩展性的串行瓶颈。

如果我们真的想要高性能,我们必须确保我们的应用程序不共享任何数据,这样我们就可以在许多独立的CPU上复制我们的解决方案。

问题6:共享可以引入死锁

有时我们试图通过某种形式的细粒度共享来增加并发性。我们的想法是,不是锁定我们所有的内存,而是把内存分成更小的区域,只锁定那些我们感兴趣的内存。现在想象两个线程P和Q想要访问内存区域A和B。假设P锁定内存区域A,然后等待内存区域B,而Q刚好相反,即它先锁定B然后等待A。这导致死锁,P和Q现在无限期暂停。

问题7:共享使得系统容易出错和调试困难

假设两个线程A和B共享数据。在A里的一个错误能够覆盖B使用的数据。即使B的代码都是正确的,它可能也会崩溃,因为它操作的数据结构被A破坏了。那么所有的系统应该完美地服从 我的程序不应该能够搞崩溃你的程序 这样的规则,但是当程序可以共享数据的情况下,这样的规则显然不成立。

调试变得可怕。线程B已经崩溃了,所以假定线程B的代码是不正确的似乎是合理的。这个假设是错误的,因为线程A的代码可能是祸因。这种因果分离使得调试非常困难。

最后,一个更普遍的评论是:

共享不存在于现实世界中

我以前是一个物理学家。在经典物理学中同时忽略量子效应情况下,现实世界中的两个物体不能同时存在于同一地点。

如果我们有两个物体,它们必须在不同的地方。现在,一个对象可以与另一个对象交互的唯一方式就是发送一条消息(比如说用光线)。如果光线编码一些关于状态变化的信息,那么就关心状态变化的接收对象而言,状态的变化只有在它消息收到后它才知道。

在简单的相对论里,同时发生的概念是不存在的。

关键点是现实中的对象不共享状态,我相信在软件中建模现实中不存在的东西不是一个好主意。

事实上您需要共享和锁来实现并行软件的想法是错误的。一切可以用共享和锁实现的,也可以用纯消息传递和无锁来实现。这就是Erlang的方式。

在未来的博文中,我将告诉你如何做一个事务内存,它提供了一个无锁的方法在并行进程集里实现细粒度的状态一致性。

原文链接: http://armstrongonsoftware.blogspot.com.ar/2006/09/why-i-dont-like-shared-memory.html