函数式编程:关于并发和状态的正确想法?


21

FP支持者声称并发很容易,因为它们的范例避免了可变状态。我不明白

想象一下,我们正在使用FP创建一个多人地下城抓取(类似于Rogue),其中我们强调纯函数和不可变数据结构。我们生成一个由房间,走廊,英雄,怪物和战利品组成的地牢。我们的世界实际上是结构及其相互关系的对象图。随着事物的变化,我们对世界的表示也会进行修改以反映这些变化。我们的英雄杀死一只老鼠,捡起短剑,等等。

对我而言,世界(当前现实)带有这种状态观念,而我却缺少FP如何克服这种状况。当我们的英雄采取行动时,功能会改变世界状况。似乎每个决定(人工智能或人为决定)都需要基于当前的现状。我们在哪里允许并发?我们不能同时有多个进程来修改世界状态,以免一个进程的结果基于某个过期状态。在我看来,所有控制都应在单个控制循环中进行,以便我们始终在处理由当前当前对象图表示的当前状态。

显然,有些情况非常适合并发(例如,在处理状态彼此独立的孤立任务时)。

我没有看到并发在我的示例中如何有用,这可能是问题所在。我可能以某种方式歪曲了这一主张。

有人可以更好地代表这一主张吗?


1
您指的是共享状态;共享状态将始终是现状,并且将始终需要某种形式的同步,纯FP人员中通常首选的形式是STM,它允许您在共享状态上放置一个抽象层,从而使访问事务具有竞争性,从而将共享内存视为本地内存。条件会自动处理。共享内存的另一种技术是消息传递,您无需共享内存,而是拥有本地内存,并了解其他参与者的知识以请求他们的本地内存
Jimmy Hoffa 2013年

1
那么...您在问,共享状态并发如何轻松地帮助管理单线程应用程序中的状态?另一方面,无论示例是否以这种方式实现,您的示例在概念上显然都适合于并发(每个AI控制实体的线程)。我很困惑你在这里问什么。
CA McCann

1
一句话,拉链
Jk。

2
每个对象都有自己的世界观。最终会有一致性波动函数崩溃也可能是事物在我们“现实世界”中的工作方式
herzmeister 2013年

1
您可能会发现“纯功能的复古
user802500

Answers:


15

我会尝试提示答案。这不是答案,只是介绍性的例子。@jk的答案指向的是拉链。

假设您有一个不变的树结构。您想通过插入一个子节点来更改一个节点。结果,您将得到一棵全新的树。

但是大多数新树与老树完全相同。一个聪明的实现将重用大多数树碎片,在更改后的节点周围路由指针:

来自维基百科

冈崎的书中充斥着这样的例子。

因此,我想您可以合理地改变游戏世界的一小部分(捡起硬币),而实际上只改变您的世界数据结构(捡起硬币的单元)的一小部分。仅属于过去状态的零件将及时被垃圾收集。

在以适当的方式设计数据游戏世界结构时,可能需要考虑一些因素。不幸的是,我不是这些问题的专家。当然,它一定不是NxM矩阵可以用作可变数据结构的东西。它可能应该由彼此指向的较小部分(走廊?单个单元格?)组成,就像树节点一样。


3
+1:指着冈崎的书。我还没看过,但是在我的待办事项清单上。我认为您所描绘的是正确的解决方案。作为替代方案,您可以考虑唯一性类型(Clean,en.wikipedia.org/wiki/Uniqueness_type):使用这种类型,您可以破坏性地更新数据对象,同时保持引用透明性。
乔治

通过键或ID的间接引用定义关系是否有好处?就是说,我当时认为,当一种结构发生变化时,一种结构与另一种结构的较少实际接触将需要对世界结构进行较少的修改。还是在FP中未真正使用此技术?
Mario T. Lanza

9

9000的答案是一半,持久的数据结构使您可以重用未更改的部分。

但是,您可能已经在思考“嘿,如果我想更改树的根该怎么办?” 正如给出的示例所示,现在意味着更改所有节点。这是拉链营救的地方。它们允许在O(1)中更改焦点处的元素,并且焦点可以移动到结构中的任何位置。

拉链的另一点是,几乎所有您想要的数据类型都存在一个拉链


恐怕要花一些时间才能深入研究“拉链”,因为我只是在探索FP的边缘。我没有Haskell的经验。
Mario T. Lanza

我会尝试添加的例子在今天晚些时候
JK。

4

函数式风格的程序创造了很多使用并发的机会。每当您转换,过滤或聚合集合时,一切都是纯净的或不可变的,就有机会通过并发加速操作。

例如,假设您彼此独立且没有特定顺序执行AI决策。他们不轮流,他们都同时做出决定,然后世界前进。代码可能看起来像这样:

func MakeMonsterDecision curWorldState monster =
    ...
    ...
    return monsterDecision

func NextWorldState curWorldState =
    ...
    let monsterMakeDecisionForCurrentState = MakeMonsterDecision curWorldState
    let monsterDecisions = List.map monsterMakeDecisionForCurrentState activeMonsters
    ...
    return newWorldState

您具有一个函数来计算给定世界状态下怪物的行为,并将其应用于每个怪物,作为计算下一个世界状态的一部分。在函数式语言中这是很自然的事情,并且编译器可以自由地并行执行“将其应用于每个怪物”步骤。

用命令式语言,您更有可能遍历每个怪物,并将其效果应用于世界。这样做更容易,因为您不想处理克隆或复杂的别名。在这种情况下,编译器无法并行执行怪物计算,因为早期的怪物决定会影响以后的怪物决定。


这很有帮助。我可以看到,在游戏中让怪物同时决定下一步将有什么好处。
Mario T. Lanza

4

听一些Rich Hickey的讲话,尤其是这一讲话,减轻了我的困惑。他在一篇文章中指出并发进程可能没有最新状态是可以的。我需要听听。我当时难以理解的是,程序实际上可以以基于快照的决策为基础,此快照此后被更新的快照所取代。我一直想知道并发FP如何解决基于旧状态的决策问题。

在银行应用程序中,我们永远不会希望基于状态快照做出决定,此快照此后已被较新的状态快照取代(发生了提款)。

并发很容易,因为FP范式避免了可变状态,这是一项技术主张,它并未试图说出将决策基于潜在的旧状态的逻辑优缺点。FP仍最终模拟状态变化。这没有解决的办法。


0

FP支持者声称并发很容易,因为它们的范例避免了可变状态。我不明白

我想提出这个一般性问题,因为这个人是功能正常的新手,但多年来一直在关注我的副作用,并出于各种原因(包括更容易(或更具体地讲,“更安全,较少易于出错的“)并发性。当我瞥一眼功能正常的同龄人及其所做的事情时,至少在这方面,草看上去更绿了,香气也更好了。

串行算法

就是说,关于您的特定示例,如果问题本质上是串行的,并且在A完成之前无法执行B,那么从概念上讲,无论如何,您都不能并行运行A和B。您必须找到一种方法来打破顺序依赖性,就像在答案中基于使用旧游戏状态进行平行移动一样,或者使用一种数据结构,该结构允许对其一部分进行独立修改以消除其他答案中提出的顺序依赖性。或类似的东西。但是肯定有很多这样的概念设计问题,因为事情是不可变的,因此您不必如此轻松地对所有事物进行多线程处理。实际上,有些事情将是串行的,直到您找到某种打破订单依赖关系的聪明方法为止,即使可能的话。

并发更轻松

这就是说,有些情况下我们不能并行化涉及可能潜在显著改善的仅仅是因为可能性,它表现的地方副作用的方案很多情况下可能不是线程安全的。在我看来,消除可变状态(或更具体地说,外部副作用)有很多帮助,其中一种情况是,它将“可能或可能不是线程安全的”转变为“绝对线程安全的”

为了使该语句更具体,请考虑为我提供一个任务,以在C中实现排序功能,该功能接受比较器并使用该功能对元素数组进行排序。它的含义是相当笼统的,但是我将为您提供一个简单的假设,即它将用于如此规模的输入(数以百万计的元素或更多),毫无疑问,始终使用多线程实现将是有益的。您可以使用多线程排序功能吗?

问题是您不能,因为排序函数调用的比较器可能会引起副作用,除非您知道在所有可能的情况下如何实现(或至少记录在案),这在不降低功能的前提下是不可能的。比较器可能会做一些令人作呕的事情,例如以非原子方式修改内部的全局变量。99.9999%的比较器可能不会执行此操作,但仅由于0.00001%的情况可能引起副作用,我们仍然不能对通用函数进行多线程处理。结果,您可能必须同时提供单线程和多线程排序函数,并将责任传递给使用它的程序员,以便根据线程安全性来决定使用哪个函数。人们可能仍会使用单线程版本,却错过了多线程的机会,因为他们可能不确定比较器是否是线程安全的,

只要合理地确定事物的线程安全性,而又不会到处扔锁,就可以拥有大量的脑力。如果我们仅能保证功能不会在现在和将来造成副作用,那么这些锁就会消失。还有一种恐惧:实际的恐惧,因为任何不得不多次调试竞争条件的人都可能对多线程感到犹豫,他们无法确保110%的人认为线程安全并且将保持这种状态。即使对于最偏执的人(我可能至少是临界点),纯函数也提供了一种放心和自信心,我们可以安全地并行调用它。

这是我认为如此有益的主要情况之一,如果您可以硬保证此类函数是线程安全的,而这是纯函数式语言所能提供的。另一个是功能语言首先常常促进创建没有副作用的功能。例如,他们可能会为您提供持久的数据结构,在这种结构中,输入海量数据结构然后输出全新的,而原始数据只有一小部分更改而不触动原始数据的结构相当有效。那些没有这种数据结构的人可能想要直接对其进行修改,并在此过程中失去一些线程安全性。

副作用

就是说,我不同意我功能上的朋友(我认为他们很酷)的一部分:

[...]因为他们的范例避免了可变状态。

并发性并不一定像我所看到的那样永恒不变。该功能可避免引起副作用。如果函数输入要排序的数组,然后将其复制,然后对副本进行突变以对其内容进行排序并输出副本,则即使您传递相同的输入,它仍然与使用某些不可变数组类型的数组一样具有线程安全性来自多个线程的数组。因此,我认为可变类型在创建非常并发友好的代码中仍有地方,可以这么说,尽管不可变类型还有很多其他好处,包括持久性数据结构,我对它们的不可变属性使用的不是很多,消除了为了创建没有副作用的功能而必须深度复制所有内容的开销。

而且经常会有开销,例如通过改组和复制一些其他数据(可能是额外的间接级别)以及在持久性数据结构的某些部分上使用一些GC的形式来使函数免受副作用的影响,但是我看到我的一个伙伴一台32核计算机,我想如果我们可以更自信地并行执行更多操作,那么交换可能值得。


1
“可变状态”始终意味着在应用程序级别而不是过程级别的状态。消除指针并将参数作为副本值传递是FP中的一种技术。但是任何有用的函数都必须在某种程度上改变状态-函数编程的目的是确保属于调用者的可变状态不会进入过程,并且除了返回值外,变异不会退出过程!但是很少有程序可以完成很多工作而根本不改变状态,并且错误总是在接口处再次蔓延。
史蒂夫

1
老实说,大多数现代语言都允许使用某种功能性的编程风格(需要一点纪律),当然,还有一些语言专用于功能模式。但这是一种计算效率较低的模式,并且普遍以夸大其词作为解决所有弊端的方法,就像90年代的面向对象技术一样。大多数程序不受并行计算受益的CPU密集型计算的约束,而实际上,这往往是因为难以以适合并行执行的方式进行推理,设计和实现程序。
史蒂夫

1
大多数处理可变状态的程序之所以这样做,是因为它们必须出于一个或另一个原因。而且大多数程序都是不正确的,因为它们使用共享状态或异常更新它-通常是因为它们接收到意外的垃圾作为输入(确定垃圾输出),或者因为它们对输入的操作不正确(就目的而言是错误的)达成)。功能模式无济于事。
史蒂夫

1
@Steve,我可能至少同意一半,因为我只是在探索从诸如C或C ++之类的语言以更线程安全的方式进行操作的方式,而我真的不认为我们需要全面学习吹净功能做到这一点。但是我发现FP中的某些概念至少有用。我刚刚写了一个有关如何在这里找到PDS有用的答案,而我发现关于PDS的最大好处实际上不是线程安全的,而是实例化,无损编辑,异常安全,简单的撤消等操作:

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.