非功能语言中持久数据结构的使用


17

纯粹的功能或近乎纯功能的语言会从持久性数据结构中受益,因为它们是不可变的,并且非常适合无状态编程功能。

但是,我们不时看到用于Java等(基于状态的OOP)语言的持久数据结构库。人们经常听到有人主张使用持久性数据结构,因为它们是不可变的,因此是线程安全的

但是,持久性数据结构是线程安全的,原因是,如果一个线程将一个元素“添加”到持久性集合中,则该操作将返回一个集合,就像原始集合一样,但是添加了元素。因此,其他线程将看到原始集合。当然,这两个集合共享许多内部状态-这就是为什么这些持久性结构有效的原因。

但是,由于不同的线程看到的数据不同的状态,它似乎是持久数据结构是不是本身足以处理的情况,其中一个线程进行更改,对于其它线程是可见的。为此,似乎我们必须使用诸如原子,引用,软件事务性存储器乃至经典锁和同步机制之类的设备。

那么为什么PDS的不变性被吹捧为有利于“线程安全”的东西呢?在PDS协助同步或解决并发问题方面,有没有真实的例子?还是PDS只是一种为对象提供无状态接口以支持功能编程风格的方式?


3
您一直说“持久”。您是否真的要像“能够在程序重启后生存下来”那样的“持久性”,还是像“在程序创建后就永远不要改变”那样的“不变性”?
Kilian Foth,2013年

17
@KilianFoth持久性数据结构有一个公认的定义:“持久性数据结构是一种在修改后始终保留其自身先前版本的数据结构”。因此,这是关于在基于新结构创建新结构时重用以前的结构,而不是像“能够在程序重启后幸存”中那样保持持久性。
米哈尔Kosmulski

3
您的问题似乎与在非功能性语言中使用持久性数据结构有关,而与它们并不能解决并发和并行性的哪些部分无关,而与范式无关。

我的错。我不知道“持久数据结构”是一个与单纯的持久性不同的技术术语。
Kilian Foth,2013年

@delnan是的,这是正确的。
雷·托尔

Answers:


15

持久/不可变的数据结构本身并不能解决并发问题,但是它们使解决它们变得更加容易。

考虑将集合S传递到另一个线程T2的线程T1。如果S是易变的,则T1会遇到问题:它无法控制S发生的事情。线程T2可以对其进行修改,因此T1完全不能依赖S的内容。反之亦然-T2不能确保T1 T2对其进行操作时不会修改S。

一种解决方案是在T1和T2的通信中添加某种合同,以便仅允许其中一个线程修改S。这容易出错,并且给设计和实现带来负担。

另一个解决方案是T1或T2克隆数据结构(如果没有协调,则克隆这两个数据)。但是,如果S不是持久性的,则这是昂贵的O(n)操作。

如果您拥有持久的数据结构,那么您将免于负担。您可以将结构传递给另一个线程,而不必关心它的作用。两个线程都可以访问原始版本,并且可以对原始版本执行任意操作-不会影响其他线程看到的内容。

另请参阅:持久性与不可变的数据结构


2
嗯,因此在此上下文中的“线程安全性” 意味着一个线程不必担心其他线程会破坏它们看到的数据,而与同步以及处理我们希望在线程之间共享的数据无关。这与我的想法一致,但+1则优雅地表述了“不要自己解决汇率问题。”
Ray Toal 2013年

2
@RayToal是的,在这种情况下,“线程安全”的含义就是这样。如前所述,线程之间如何共享数据是一个不同的问题,它有许多解决方案(我个人很喜欢STM的可组合性)。线程安全性确保您不必担心共享后数据会发生什么。这实际上很重要,因为线程不需要同步谁在处理数据结构以及何时同步。
PetrPudlák13年

@RayToal这允许使用优雅的并发模型,例如actor,从而使开发人员不必处理显式的锁定和线程管理,并且依赖于消息的不变性-您不知道何时传递和处理消息,或者传递给其他消息被转发给的演员。
PetrPudlák2013年

谢谢彼得,我再给演员看一下。我熟悉所有Clojure机制,并且确实注意到Rich Hickey明确选择不使用actor模型,至少在Erlang中已得到证明。不过,您了解的越多越好。
Ray Toal 2013年

@RayToal一个有趣的链接,谢谢。我仅以演员为例,并不是说这将是最佳解决方案。我没有用过Clojure,但似乎它的首选解决方案是STM,我肯定会比actor更喜欢。STM还依赖于持久性/不变性-如果它不可撤销地修改了数据结构,则不可能重新启动事务。
PetrPudlák2013年

5

那么为什么PDS的不变性被吹捧为有利于“线程安全”的东西呢?在PDS协助同步或解决并发问题方面,有没有真实的例子?

在这种情况下,PDS的主要好处是您可以修改一部分数据而无需使所有内容唯一(可以说,无需深度复制所有内容)。除了允许您编写无副作用的廉价函数之外,这还有许多潜在的好处:实例化复制和粘贴的数据,琐碎的撤消系统,琐碎的游戏重播功能,琐碎的无损编辑,琐碎的异常安全性等等等。


2

可以想象一种持久但可变的数据结构。例如,您可以获取一个链表,该链表由指向第一个节点的指针表示,并执行前置操作,该操作将返回一个新列表,该列表由一个新的头节点加前一个列表组成。由于您仍然可以参考前一个标题,因此您可以访问和修改此列表,同时该列表也已嵌入到新列表中。尽管可能,但这种范例无法提供持久和不变数据结构的好处,例如,默认情况下它肯定不是线程安全的。但是,只要开发人员知道他们在做什么,就可以利用它,例如为了节省空间。还要注意,尽管该结构在语言级别上是可变的,但没有什么可以阻止代码对其进行修改,

长话短说,没有不变性(由语言或约定来强制执行),持久性od数据结构会失去一些好处(线程安全),而不会失去其他好处(某些情况下节省空间)。

至于非功能性语言的示例,Java String.substring()使用了我所说的持久性数据结构。字符串由一个字符数组以及实际使用的数组范围的开始和结束偏移量表示。创建子字符串时,新对象仅在修改了开始和结束偏移量的情况下重新使用相同的字符数组。由于String是不可变的,因此(相对于substring()操作而言,而不是其他方面而言)它是不可变的持久数据结构。

数据结构的不变性是与线程安全相关的部分。它们的持久性(在创建新结构时重用现有块)与使用此类集合时的效率相关。由于它们是不可变的,因此添加项之类的操作不会修改现有结构,而是返回新结构,并附加其他元素。如果每次都复制整个结构,从一个空集合开始并一个接一个地添加1000个元素,最后得到一个1000个元素的集合,则会创建具有0 + 1 + 2 + ... + 999 =的临时对象总共500000个元素,这将是一个巨大的浪费。使用永久性数据结构,可以避免这种情况,因为1元素集合在2元素集合中重复使用,而在3元素之一中重复使用,依此类推,


有时候,拥有准不变的对象是非常有用的,其中状态的一个方面都是不变的:使对象的状态几乎像给定对象的能力。例如,AppendOnlyList<T>由两个幂的数组支持的阵列可以生成不可变的快照,而不必为每个快照复制任何数据,但是一个人不能生成一个列表,其中包含此类快照的内容以及一个新项目,而无需重新复制一切都放在一个新的数组中。
2014年

0

毫无疑问,我被语言,其性质,领域,甚至我们使用该语言的方式偏向于在C ++中应用此类概念。但是考虑到这些因素,我认为设计一成不变的是最有趣的方面,当涉及到收获批量的函数式编程带来的好处,如线程安全,便于对系统的推理,寻找更多的重用的功能(并发现我们可以以任何顺序组合它们,而不会带来令人不愉快的惊喜),等等。

以这个简单的C ++示例为例(为避免简单起见,没有进行优化,以避免使自己在任何图像处理专家面前都感到尴尬):

// Inputs an image and outputs a new one with the specified size.
Image resized_image(const Image& src, int new_w, int new_h)
{
     Image dst(new_w, new_h);
     for (int y=0; y < new_h; ++y)
     {
         for (int x=0; x < new_w; ++x)
              dst[y][x] = src.sample(x / (float)new_w, y / (float)new_h);
     }
     return dst;
}

尽管该函数的实现以两个计数器变量和一个临时本地图像的形式更改了本地(和临时)状态,但它没有外部副作用。它输入图像并输出新图像。我们可以将其多线程化到我们内心的内容。这很容易推理,易于彻底测试。这是异常安全的,因为如果抛出任何异常,新图像将被自动丢弃,并且我们不必担心会回退外部副作用(可以说,没有外部图像在函数范围之外被修改)。

通过Image在C ++中在上述上下文中实现不可变,我看不到有什么收获,也有可能失去的很多,除了可能使上述函数难以实现,而且效率可能更低。

纯度

如此纯净的功能(无需外部副作用)对我来说非常有趣,并且我强调了即使在C ++中也经常使它们偏向团队成员的重要性。但是不可变的设计,通常只在没有上下文和细微差别的情况下应用,对我来说几乎没有那么有趣,因为鉴于语言的命令性,在高效的过程中能够对某些局部临时对象进行有效的变异通常是有用且实用的(两者都有)。 (对于开发人员和硬件而言)实现纯功能。

廉价复制大型结构

我发现的第二个最有用的属性是能够廉价地复制真正繁重的数据结构,而这样做的代价是不平凡的,因为这样做通常会使函数纯净(鉴于严格的输入/输出性质)。这些不是可以放在堆栈上的小结构。它们将是大型的重型结构,就像整个Scene视频游戏。

在那种情况下,复制开销可能会阻止有效并行的机会,因为如果物理使渲染器同时尝试绘制的场景发生变异,同时使物理深度变深,则可能很难在不相互锁定和瓶颈的情况下有效地并行化物理和渲染。复制整个游戏场景,仅输出一帧并应用物理效果可能同样无效。但是,如果物理系统是“纯粹的”,即仅输入一个场景并输出一个应用了物理的新场景,并且这种纯度不以牺牲天文复制开销为代价,那么它可以安全地与渲染器,无需一个等待另一个。

因此,能够廉价地复制应用程序状态的真正大量数据并以最小的处理和内存使用成本输出新的,经过修改的版本的能力,确实可以为纯净和有效的并行性打开新的大门,在这里,我发现了很多教训从如何实现持久性数据结构开始。但是,无论我们使用此类课程创建的内容都不必是完全持久的,也不必提供不可变的接口(例如,可以使用写时复制或“构建器/瞬态”)来实现此功能在我们追求功能/系统/管道的并行性和纯净度的同时,不需增加内存使用量和内存访问量即可复制和修改副本的各个部分。

不变性

最后,我认为这是不变性,这是这三者中最不感兴趣的,但是当某些对象设计不打算用作纯函数的局部临时对象,而是在更广泛的上下文中,有价值时,它可以用铁腕来实现一种“对象级纯度”,因为在所有方法中都不再引起外部副作用(不再在方法的直接局部范围之外对成员变量进行突变)。

尽管我认为这是C ++之类的这三种语言中最不有趣的一种,但它肯定可以简化非平凡对象的测试,线程安全性和推理。例如,保证对象不能在其构造函数之外获得任何唯一的状态组合,并且可以自由地传递它,甚至可以通过引用/指针自由传递它,而不必依赖于常量和读取,这可能是一项负担。仅迭代器和句柄等,同时保证(至少,在我们所能用的语言范围之内)它的原始内容不会被突变。

但是我发现这是最不有趣的属性,因为我认为大多数对象都以临时的形式以可变形式用于实现纯功能(甚至是更宽泛的概念,例如“纯系统”,它可能是一个对象或一系列对象)都是有益的功能仅具有输入和输出新内容而不会触及其他任何东西的最终效果),而且我认为,在很大程度上使用命令式语言使四肢保持不变是一个适得其反的目标。我会在代码库中最有帮助的部分上谨慎地使用它。

最后:

似乎持久性数据结构本身不足以处理一个线程进行的更改对其他线程可见的场景。为此,似乎我们必须使用诸如原子,引用,软件事务性存储器乃至经典锁和同步机制之类的设备。

自然,如果您的设计要求修改(从用户端设计的角度)在多个线程同时发生时对多个线程同时可见,那么我们将返回到同步状态,或者至少是通过绘图板来设计一些复杂的方法来解决此问题(我已经看到了专家处理函数编程中这类问题的一些非常精致的示例。

但是我发现,一旦获得了这种复制并能够廉价输出笨​​重的结构的部分修改版本,就像使用持久性数据结构作为示例那样,它通常会为您打开很多大门和机会以前从未考虑过在严格的I / O并行管道中并行化可以完全独立运行的代码。即使算法的某些部分本质上必须是串行的,您也可以将处理推迟到单个线程上,但会发现,依靠这些概念已经为轻松并行处理90%的繁重工作打开了大门,例如

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.