不变性是否完全消除了多处理器编程中对锁的需求?


39

第1部分

显然,不变性可以最大程度地减少多处理器编程中对锁的需求,但是它消除了这种需求吗?还是存在仅不变性还不够的情况?在我看来,您只能推迟处理和封装状态,直到大多数程序必须实际执行某些操作(更新数据存储,生成报告,引发异常等)之前。这样的动作能否始终不加锁地进行?扔掉每个对象并创建一个新对象而不是更改原始对象(对不变性的粗略看法)的纯粹行动是否提供了对进程间争用的绝对保护,还是有些仍然需要锁定的情况?

我知道很多函数式程序员和数学家都喜欢谈论“无副作用”,但是在“现实世界”中,所有事情都有副作用,即使这是执行机器指令所需的时间。我对理论/学术答案和实际/现实答案都感兴趣。

如果不变性是安全的,那么在给定某些界限或假设的情况下,我想知道“安全区”的边界到底是什么。可能的边界的一些示例:

  • 输入输出
  • 异常/错误
  • 与其他语言编写的程序的交互
  • 与其他机器(物理,虚拟或理论上的机器)的交互

特别感谢@JimmaHoffa 的评论,这开始了这个问题!

第2部分

多处理器编程通常用作优化技术-使某些代码运行更快。什么时候使用锁和不可变对象更快?

考虑到阿姆达尔定律所规定的限制,与可变对象锁定相比,什么时候可以实现更好的整体性能(考虑或不考虑垃圾收集器)?

摘要

我将这两个问题合并为一个,以尝试了解边界不变性在哪里作为线程问题的解决方案。


21
but everything has a side effect-呃,不,不是。一个接受某些值并返回其他值的函数,并且不会干扰该函数之外的任何东西,没有副作用,因此是线程安全的。电脑用电都没关系。如果您愿意,我们也可以讨论宇宙射线撞击存储单元的情况,但是让我们保持这种论点的实用性。如果您想考虑函数执行方式如何影响功耗等问题,那就是与线程安全编程不同的问题。
罗伯特·哈维

5
@RobertHarvey-也许我只是对副作用使用了不同的定义,我应该说“现实世界中的副作用”。是的,数学家的职能没有副作用。在实际计算机上执行的代码,无论是否更改数据,都会占用机器资源来执行。您的示例中的函数将其返回值放在大多数机器体系结构中的堆栈中。
GlenPeterson,2012年

1
如果您能真正解决问题,我想您的问题将成为这篇臭名昭著的论文research.microsoft.com/en-us/um/people/simonpj/papers/…的核心
Jimmy Hoffa,2012年

6
为了便于讨论,我假设您是指正在执行某种定义明确的编程语言的图灵完备机器,其中实现细节无关紧要。 换句话说,如果我用自己选择的编程语言编写的函数可以保证在该语言范围内的不变性,那么堆栈在做什么就无关紧要 当我使用高级语言编程时,我不会考虑堆栈,也不必这样做。
罗伯特·哈维

1
罗伯特·哈维(RobertHarvey)Monads heh而且您可以从前几页中收集到。我之所以提到它,是因为在整个过程中,它详细介绍了一种以几乎纯净的方式处理副作用的技术,我敢肯定它会回答格伦的问题,因此将其作为在任何发现此问题的人的很好的脚注发布未来的进一步阅读。
吉米·霍法

Answers:


35

如果回答得当,这是一个措辞古怪的问题,确实非常广泛。我将着重于澄清您要询问的一些细节。

不变性是设计权衡。它使某些操作变得更困难(快速修改大型对象的状态,零碎地构建对象,保持运行状态等),而使其他操作变得更困难(更轻松的调试,更容易的程序行为推理,而不必担心工作时底层的变化)并发等)。这是我们关心的最后一个问题,但我想强调一下这是一个工具。一个好工具,通常可以解决比它引起的问题更多的问题(在大多数现代程序中),但不是灵丹妙药……不是改变程序固有行为的东西。

现在,您能得到什么呢?不变性让您一件事:您可以自由阅读不可变对象,而不必担心其内部的状态发生变化(假设它确实是真正不可改变的……拥有一个可变成员的不可变对象通常会破坏交易)。而已。它使您无需管理并发(通过锁,快照,数据分区或其他机制;原来的问题侧重于锁是...鉴于问题的范围,这是不正确的)。

事实证明,尽管很多东西都可以读取对象。IO可以,但是IO本身往往不能很好地处理并发使用。几乎所有处理都可以,但是其他对象可能是可变的,或者处理本身可能使用对并发不友好的状态。在某些语言中,复制对象是一个很大的隐患,因为完整复制(几乎)绝不是原子操作。这是不可变对象为您提供帮助的地方。

至于性能,这取决于您的应用程序。锁(通常)很重。其他并发管理机制更快,但对您的设计有很大影响。通常,使用不可变对象(并避免其弱点)的高度并发设计将比锁定可变对象的高度并发设计更好。如果您的程序轻微并发,则取决于和/或无关紧要。

但是性能不是您最关心的问题。编写并发程序很难。调试并发程序很难。不可变的对象消除了手动实现并发管理错误的机会,从而帮助提高了程序的质量。它们使调试更加容易,因为您没有尝试跟踪并发程序中的状态。它们使您的设计更简单,从而消除了那里的错误。

综上所述:不变性有助于但不能消除正确处理并发所需的挑战。这种帮助往往无处不在,但是最大的收获是从质量角度而不是性能角度。不,不变性不会神奇地使您无法管理应用程序中的并发,抱歉。


+1这很有意义,但是您能否举一个例子,说明在深度可变的语言中您仍然需要担心如何正确处理并发性?你的国家,你这样做,但这样的场景是不是很清楚,我
吉米·霍法

@JimmyHoffa在不可变语言中,您仍然需要某种方式来更新线程之间的状态。我知道的两种最不可变的语言(Clojure和Haskell)提供了一种引用类型(atoms和Mvars),它们提供了一种在线程之间传送修改后的状态的方法。它们的ref类型的语义可防止某些类型的并发错误,但其他类型仍然可能。
stonemetal 2012年

@stonemetal有趣,在与Haskell在一起的4个月中,我什至没有听说过Mvars,我一直听说使用STM进行并发状态通信,其行为更像是我认为的Erlang消息传递。虽然不变性的完美示例不能解决并发问题,但我可以想到的是更新UI,但是如果您有2个线程试图使用不同版本的数据更新UI,则其中一个可能是较新的,因此需要进行第二次更新,因此您需要竞赛条件,您必须以某种方式保证测序的顺序。.有趣的想法..感谢您提供的详细信息
Jimmy Hoffa,2012年

1
@jimmyhoffa-最常见的示例是IO。即使语言是一成不变的,您的数据库/网站/文件也不是。另一个是您的典型地图/缩小图。不变性意味着地图的聚合更加安全,但是您仍然需要处理“一旦所有地图并行完成,减少”协调。
Telastyn 2012年

1
@JimmyHoffa:MVars是一个低级可变并发原语(从技术上讲,是对可变存储位置的不变引用),与您在其他语言中看到的没有太大不同;僵局和竞赛条件很有可能发生。STM是用于无锁可变共享内存(与消息传递完全不同)的高级并发抽象,它允许可组合事务处理而不会出现死锁或竞争条件。不可变的数据只是线程安全的,对此无话可说。
CA McCann 2012年

13

一个接受某些值并返回其他值的函数,并且不会干扰该函数之外的任何东西,没有副作用,因此是线程安全的。如果您想考虑函数执行方式如何影响功耗等问题,那就是另一个问题。

我假设您所指的是图灵完备的计算机,该计算机正在执行某种定义明确的编程语言,而其中的实现细节无关紧要。换句话说,如果我用自己选择的编程语言编写的函数可以保证在该语言范围内的不变性,那么堆栈在做什么就无关紧要。当我使用高级语言编程时,我不会考虑堆栈,也不必这样做。

为了说明它是如何工作的,我将在C#中提供一些简单的示例。为了使这些例子正确,我们必须做一些假设。首先,编译器遵循C#规范没有错误,其次,它生成正确的程序。

假设我想要一个简单的函数,它接受一个字符串集合,并返回一个字符串,该字符串是集合中所有字符串的连接,并用逗号分隔。C#中一个简单,简单的实现可能看起来像这样:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    string result = string.Empty;
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result += s;
        else
            result += ", " + s;
    }
    return result;
} 

这个例子是不可变的,表面上是相貌的。我怎么知道 因为string对象是不可变的。但是,实现并不理想。因为它result是不可变的,所以每次循环时都必须创建一个新的字符串对象,以替换result指向该对象的原始对象。这可能会对速度产生负面影响,并给垃圾收集器施加压力,因为它必须清理所有这些多余的字符串。

现在,让我说一下:

public string ConcatenateWithCommas(ImmutableList<string> list)
{
    var result = new StringBuilder();
    bool isFirst = false;

    foreach (string s in list)
    {
        if (isFirst)
            result.Append(s);
        else
            result.Append(", " + s);
    }
    return result.ToString();
} 

请注意,我已将其替换string result为可变对象StringBuilder。这是很多比第一个例子更快,因为一个新的字符串不是通过每一次循环中产生。相反,StringBuilder对象仅将每个字符串中的字符添加到字符集合中,并在最后输出整个内容。

即使StringBuilder可变,此函数是否不变?

是的。为什么?因为每次调用此函数,都会为该调用创建一个新的StringBuilder。 因此,现在我们有了一个纯函数,该函数是线程安全的,但包含可变组件。

但是,如果我这样做了怎么办?

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;

    public string ConcatenateWithCommas(ImmutableList<string> list)
    {
        foreach (string s in list)
        {
            if (isFirst)
                result.Append(s);
            else
                result.Append(", " + s);
        }
        return result.ToString();
    } 
}

此方法是线程安全的吗?不,不是。为什么?因为该类现在保持我的方法所依赖的状态。 该方法中现在出现了竞争条件:一个线程可以修改IsFirst,但是另一个线程可以执行第一个线程Append(),在这种情况下,我现在在字符串的开头有一个逗号,该逗号不应该存在。

我为什么要这样做?好吧,我可能希望线程在result不考虑顺序或线程进入顺序的情况下将字符串累积到我的计算机中。也许是记录器,谁知道呢?

无论如何,要解决此问题,我lock在方法的内在之处发表了声明。

public class Concatenate
{
    private StringBuilder result = new StringBuilder();
    bool isFirst = false;
    private static object locker = new object();

    public string AppendWithCommas(ImmutableList<string> list)
    {
        lock (locker)
        {
            foreach (string s in list)
            {
                if (isFirst)
                    result.Append(s);
                else
                    result.Append(", " + s);
            }
            return result.ToString();
        }
    } 
}

现在,它又是线程安全的。

我的不可变方法可能无法实现线程安全的唯一方法是,如果该方法以某种方式泄漏了其实现的一部分。这会发生吗?如果编译器正确且程序正确,则不是。我是否需要对此类方法进行锁定?没有。

有关在并发场景中如何泄漏实现的示例,请参见此处


2
除非我弄错了(因为a List是可变的),否则在您声明为“纯”的第一个函数中,另一个线程可以将其从列表中删除所有元素,或者在foreach循环中添加更多元素。不能确定如何将与玩IEnumerator幸福while(iter.MoveNext())版,但除非IEnumerator是不变的(值得怀疑),那么这将威胁到捶打foreach循环。
吉米·霍法

的确,您必须假设线程正在从中读取该集合时,永远不会将其写入。如果每个调用该方法的线程都建立自己的列表,那将是一个有效的假设。
罗伯特·哈维

我认为当其中包含通过引用使用的可变对象时,您不能称其为“纯”。如果它收到了IEnumerable,则您可以提出该声明,因为您无法从IEnumerable中添加或删除元素,但是它可能是作为IEnumerable上交的数组或列表,因此IEnumerable合同不保证任何形式纯度。使该函数纯净的真正技术是传递复制的不变性,C#不会这样做,因此您必须在函数接收到列表后立即复制它。但是唯一的办法就是在上面
穿上衣服

1
@JimmyHoffa:该死,你让我着迷于鸡和鸡蛋的问题!如果您在任何地方都可以找到解决方案,请告诉我。
罗伯特·哈维

1
刚刚遇到了这个答案,这是我遇到的主题的最佳解释之一,示例非常简洁,确实很容易理解。谢谢!
史蒂芬·伯恩

4

我不确定我是否理解您的问题。

恕我直言,答案是肯定的。如果所有对象都是不可变的,则不需要任何锁。但是,如果需要保留状态(例如,实现数据库或需要汇总来自多个线程的结果),则需要使用可变性,因此还需要锁定。不变性消除了对锁的需求,但是通常您负担不起完全不变的应用程序。

第2部分的答案-锁定应始终比没有锁定慢。


3
第二部分问“锁与不变结构之间的性能折衷是什么?” 如果它甚至可以回答,它可能也应该提出自己的问题。
罗伯特·哈维

4

将一堆相关状态封装在对一个不可变对象的单个可变引用中,可以使用以下模式无锁定地执行多种状态修改:

do
{
   oldState = someObject.State;
   newState = oldState.WithSomeChanges();
} while (Interlocked.CompareExchange(ref someObject.State, newState, oldState) != oldState;

如果两个线程都尝试someObject.state同时更新,则两个对象都将读取旧状态并确定没有彼此更改的新状态。执行CompareExchange的第一个线程将存储它认为下一个状态应该是的状态。第二个线程将发现该状态不再与先前读取的状态匹配,因此将在第一个线程的更改生效的情况下重新计算系统的正确下一个状态。

这种模式的优势在于,被交错放置的线程无法阻止其他线程的进程。它具有进一步的优势,即使存在大量争用,某些线程也将一直在进步。但是,它的缺点是,在存在争用的情况下,许多线程可能会花费大量时间来完成工作,最终将其丢弃。例如,如果不同CPU上的30个线程都尝试同时更改一个对象,则一个对象将在第一次尝试时成功,一次尝试在第二次尝试成功,一次尝试在第三次尝试等等,因此每个线程平均最终进行大约15次尝试更新其数据。使用“建议”锁可以显着改善事情:在线程尝试更新之前,它应检查是否设置了“争用”指示器。如果是这样的话,它应该在执行更新之前获取一个锁。如果线程尝试进行几次不成功的更新,则应设置竞争标志。如果试图获取锁的线程发现没有其他人在等待,则应清除竞争标志。请注意,此处的锁定对于“正确性”不是必需的;代码即使没有也可以正常工作。锁定的目的是最大程度地减少代码花费在不太可能成功的操作上的时间。


4

你开始

显然,不变性最大程度地减少了多处理器编程中的锁需求

错误。您需要仔细阅读所用每个类的文档。例如,C ++中的const std :: string 不是线程安全的。不可变的对象可以具有在访问它们时改变的内部状态。

但是您是从完全错误的角度来看待这个问题。对象是否不可变并不重要,重要的是您是否更改了它。您的意思是说“如果您从不参加驾驶考试,就永远不会因为醉酒驾驶而失去驾驶执照”。是的,但是没有讲到重点。

现在在示例代码中,有人用名为“ ConcatenateWithCommas”的函数编写了:如果输入是可变的并且您使用了锁,那么您将获得什么?如果在尝试连接字符串时其他人尝试修改列表,则锁可以防止崩溃。但是您仍然不知道在其他线程更改字符串之前还是之后将字符串连接起来。因此,您的结果相当无用。您遇到的问题与锁定无关,并且无法通过锁定解决。但是,如果您使用不可变的对象,而另一个线程用一个新的对象替换了整个对象,则您使用的是旧对象而不是新对象,因此结果是无用的。您必须在实际功能级别上考虑这些问题。


2
const std::string这是一个很差的例子,有点鲱鱼。C ++字符串是可变的,const无论如何不能保证不变性。它所做的只是说只能const调用函数。但是,这些功能仍然可以更改内部状态,并且const可以将其丢弃。最后,与任何其他语言都存在相同的问题:仅仅因为我的参考文献const并不意味着您的参考文献也是。不,应该使用真正不变的数据结构。
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.