【问题标题】:Haskell concurrency using simple IORef?Haskell并发使用简单的IORef?
【发布时间】:2012-04-24 08:06:03
【问题描述】:

我一直在问一些关于 Haskell 并发的问题,特别是 TVar,并且我对 TVar 的活锁感到担忧。

相反,我提出了这个解决方案。

(1) 将程序中的所有共享数据包装在一个数据结构中,并将其包装在IORef 中。 (2) 只需使用atomicModifyIORef 进行任何更改。

我相信这可以防止死锁和活锁(而 TVar 只能防止前者)。此外,因为atomicModifyIORef 只是将另一个 thunk 链接到一个链中(这是几个指针操作),所以这不是瓶颈。 所有对数据的实际操作都可以并行完成,只要它们不相互依赖即可。 Haskell 运行时系统会解决这个问题。

但是我觉得这太简单了。有没有我遗漏的“陷阱”?

【问题讨论】:

  • 你为什么不直接使用STM?它更简单,效果也很好。
  • 为什么STM比IORef简单? IORef 不用担心活锁,这让 IORef 更简单是吗?
  • 我很确定 STM 不会导致活锁,但我可能弄错了。
  • @Gabriel Gonzalez:考虑需要 1 毫秒运行的事务每 100 毫秒连续运行一次。任何在该变量上运行需要 200 毫秒的事务将永远不会完成,因为它在提交时会不断发生冲突。它会不断重试,让服务器忙于做无用的工作。
  • "所有对数据的实际操作都可以并行完成,只要它们不相互依赖即可。"

标签: haskell concurrency shared-state ioref


【解决方案1】:

如果满足以下条件,这种设计可能还可以:

  • 读取将比写入更普遍
  • 写入之间会穿插多次读取
  • (可能)写入只会影响全局数据结构的一小部分

当然,考虑到这些条件,几乎任何并发系统都可以。由于您担心活锁,我怀疑您正在处理更复杂的访问模式。在这种情况下,请继续阅读。

您的设计似乎受到以下推理链的指导:

  1. atomicModifyIORef 非常便宜,因为它只是创建 thunk

  2. 因为atomicModifyIORef很便宜,不会引起线程争用

  3. 廉价的数据访问 + 无争用 = 并发 FTW!

这是此推理中缺少的步骤:您的 IORef 修改只会创建 thunk,您无法控制评估 thunk 的位置。如果您无法控制评估数据的位置,那么您就没有真正的并行性。

由于您尚未提出预期的数据访问模式,这是推测,但我预计您对数据的反复修改会建立一个 thunk 链。然后在某个时候,您将从数据中读取并强制进行评估,从而导致所有这些 thunk 在一个线程中按顺序进行评估。至此,您可能已经开始编写单线程代码了。

解决此问题的方法是确保在将数据写入 IORef 之前对其进行评估(至少在您希望的范围内)。这就是atomicModifyIORef的返回参数的作用。

考虑这些函数,意在修改aVar :: IORef [Int]

doubleList1 :: [Int] -> ([Int],())
doubleList1 xs = (map (*2) xs, ())

doubleList2 :: [Int] -> ([Int], [Int])
doubleList2 xs = let ys = map (*2) xs in (ys,ys)

doubleList3 :: [Int] -> ([Int], Int)
doubleList3 xs = let ys = map (*2) xs in (ys, sum ys)

将这些函数用作参数时会发生以下情况:

  1. !() <- atomicModifyIORef aVar doubleList1 - 只创建一个 thunk,不评估数据。对于下一个从aVar 读取的线程来说,这是一个令人不快的惊喜!

  2. !oList <- atomicModifyIORef aVar doubleList2 - 仅评估新列表以确定初始构造函数,即(:)[]。仍然没有真正的工作。

  3. !oSum <- atomicModifyIORef aVar doubleList3 - 通过评估列表的总和,可以保证计算得到完全评估。

在前两种情况下,几乎没有什么工作要做,所以atomicModifyIORef 会很快退出。 但是那个工作并没有在那个线程中完成,现在你不知道它什么时候会发生。

在第三种情况下,您知道工作是在预期的线程中完成的。首先创建一个 thunk 并更新 IORef,然后线程开始计算总和并最终返回结果。但是假设其他线程在计算总和时读取数据。它可能会开始评估 thunk 本身,现在你有两个线程在做重复的工作。

简而言之,这种设计并没有解决任何问题。它可能在您的并发问题并不困难的情况下工作,但对于您一直在考虑的极端情况,您仍然会在多个线程执行重复工作的情况下燃烧循环。与 STM 不同的是,您无法控制如何以及何时重试。至少 STM 你可以在事务的中间中止,通过 thunk 评估它完全不在你的掌控之中。

【讨论】:

  • 第 2 点和第 3 点并不完全准确。除非您评估结果,否则不会导致对列表的任何评估。 atomicModifyIORef 非常不严格;即使atomicModifyIORef var undefined 也不会抛出任何东西,直到您强制结果或 IORef 的值。
  • @hammar - 这是一个很好的观点。我试图避免用爆炸模式和/或序列来混淆示例,但结果逻辑错误。偷工减料是不值得的。最后,这可能更有力地支持了依赖 thunk 更新来实现并发性不能扩展或组合的论点。
  • @John L:为什么多个线程做重复工作?我不能通过执行“-feager-blackholing”来轻松地黑洞thunk,因此它们只被评估一次吗?另外,什么时候需要重试?
  • @Clinton - 我的意思是重试作为中止或重试的简写。考虑:线程 A 更新 IORef 并开始评估,结果实际上不会在这里使用。线程 B 再次更新 IORef。现在线程 A 将继续评估其陈旧数据,浪费周期。使用 STM,您也许可以避免这些额外的工作。
  • “-feager-blackholing”也会有所帮助,但这里有很多假设。没有任何问题描述(在 7 个问题之后!),人们只是想出任何特定方案失败的方法。这是盲人委员会的建筑;您应该尝试一些代码并进行测量。
【解决方案2】:

AFAICT 如果您的计算在处理 IORef 内容后留下未评估的 thunk,则该 thunk 将简单地在尝试使用结果的任何线程中评估,而不是按照您的意愿并行评估。请参阅MVar 文档的陷阱部分,here

如果您提供一个您正在尝试解决的具体问题(或一个简化但类似的问题),这可能对其他人更有趣和更有帮助。

【讨论】:

  • jberryman:如果两个线程尝试使用两个不同的结果,它们不会被并行评估吗?当然,如果只有一个线程,它将连续完成,但这很简单,并没有解释任何事情吗?
  • 在这种情况下,我使用的是网络服务器(使用 Yesod)。所以它自然是多线程的。我想确保对我的数据的更改也是多线程的。我在这里建议的是使用 IORef,因为唯一被序列化的是创建 thunk,这是相对较小的工作量。
  • @Clinton:这取决于两个结果之间的依赖关系。如果它们是完全独立的,那么它们应该被并行评估。如果不是,那么两者都依赖的任何部分都将由一个线程评估,如果它同时需要该结果,可能会阻塞另一个线程。如果没有具体的例子,很难说这会有多有效,所以我建议你试着写一个你所想的原型,并使用ThreadScope分析它的性能。
  • @hammer:当然,我知道在f(g(x)) 中你不能同时做fg。我要确保在运行h(f(x1), g(x2)) 时,fg 可以同时运行,即使x1x2IORef 的一部分。如果可以,那么IORef 解决了TVar 存在的活锁问题。
  • @Clinton:它确实可以防止活锁,但如果这解决了您的 STM 理论问题,那么它实际上不太可能成为问题。这两个解决方案实际上非常相似:atomicModifyIORefatomically . modifyTVar。如果您觉得 IORef 很美味,它们的开销确实会更低。
【解决方案3】:

好吧,它不会很好地组成。通过单个 IORef 序列化所有共享内存修改将意味着一次只有一个线程能够修改共享内存,您真正所做的只是创建一个全局锁。是的,它会起作用,但它会很慢,而且远不及 TVar 甚至 MVar 那样灵活。

【讨论】:

  • dented42:不只是创建 thunk 序列化了吗?不能并行执行吗?
  • @Clinton - 您无法控制何时评估 thunk,除非指定评估必须不迟于控制流中的某个点进行。尝试确保并行评估 thunk 几乎是不可能设置和调试的。
  • 如果事务保证是原子的,那么它们就不能互相干扰,这是通过一次只运行一个原子代码块来完成的。如果同时运行多个“原子”thunk,那么编译器将无法保证它们相互干扰。就何时评估 thunk 而言,编译器/运行时只承诺在您之前评估它需要它的价值。
猜你喜欢
  • 1970-01-01
  • 2019-02-27
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-07-18
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多