关于IORef操作在并发程序中重新排序的推理

时间:2014-02-02 03:40:55

标签: haskell concurrency ghc ioref

docs说:

  

在并发程序中,IORef操作可能会出现乱序   另一个线程,取决于底层的内存模型   处理器架构......需要实施以确保这一点   重新排序内存操作不能导致类型正确的代码   错误。特别是在检查从IORef读取的值时,   内存写入创建该值必须从   当前线程的观点。

我甚至不确定如何解析。 Edward Yang says

  

换句话说,“我们不保证重新排序,除此之外   你不会有任何类型安全违规行为。“......   最后一句话说明不允许IORef指出   未初始化的记忆

所以......它不会破坏整个哈克尔;不是很有帮助。记忆模型示例出现的discussion也给我留下了问题(甚至Simon Marlow似乎有些惊讶)。

我从文档中看到的东西

  1. 一个线程中atomicModifyIORef“永远不会在任何早期的IORef操作之前发生,或者在任何后来的IORef操作之后发生”即我们得到一个部分排序:在原子模块之上的东西 - > atomic mod - >之后的东西。虽然这里的措辞“从未被观察到”,但却暗示了我没有预料到的怪异行为。

  2. readIORef x可能会在writeIORef y之前移动,至少在没有数据依赖关系时

  3. 从逻辑上讲,我看不出readIORef x >>= writeIORef y之类的内容可以重新排序

  4. 我不清楚

    • newIORef False >>= \v-> writeIORef v True >> readIORef v总是会返回True吗?

    • maybePrint案例中(来自IORef文档),readIORef myRef之前的seq(以及可能是readIORef yourRef或其他内容)会强制屏障重新排序?

    我应该拥有直截了当的心理模型?它是这样的:

      

    从单个线程的角度来看,   IORef操作的订购将显得健全和顺序;但是   编译器实际上可能以中断的方式重新排序操作   并发系统中的某些假设;但是当一个线程做的时候   atomicModifyIORef,没有线程会观察对此的操作   出现在IORef以上的atomicModifyIORef {   反之亦然。

    ...?如果没有,上面的修正版本是什么?

    如果您的回复是“请勿在并发代码中使用IORef;请使用TVar”请说明具体事实以及您无法解决的事情的具体示例/ em>有IORef的原因。

3 个答案:

答案 0 :(得分:7)

我不了解Haskell的并发性,但我对内存模型有所了解。

处理器可以按照自己喜欢的方式对指令进行重新排序:负载可能超前于负载,负载可能超过存储,负载的负载可能会超过它们所依赖的负载(a [i]可能会加载来自数组首先,然后引用数组a!),存储可以相互重新排序。你根本不能把手指放在它上面说'#34;这两件事肯定以特定的顺序出现,因为它们无法重新排序"。但是为了使并发算法运行,他们需要观察其他线程的状态。这是线程状态以特定顺序进行的重要位置。这是通过在指令之间放置障碍来实现的,这可以保证指令的顺序与所有处理器的显示方式相同。

通常(最简单的型号之一),您需要两种类型的有序指令:不超过任何其他有序加载或存储的有序加载,以及完全不超出任何指令的有序存储,以及保证所有有序指令以相同的顺序出现在所有处理器上。通过这种方式,您可以解释IRIW的问题:

Thread 1: x=1

Thread 2: y=1

Thread 3: r1=x;
          r2=y;

Thread 4: r4=y;
          r3=x;

如果所有这些操作都是有序加载和有序商店,那么您可以得出结果(1,0,0,1)=(r1,r2,r3,r4)是不可能的。实际上,线程1和2中的有序存储应按某种顺序出现在所有线程中,并且r1 = 1,r2 = 0证明在x = 1之后执行y = 1。反过来,这意味着线程4永远不会观察到r4 = 1而没有观察到r3 = 1(这是在r4 = 1之后执行)(如果有序存储碰巧以这种方式执行,则观察y == 1意味着x == 1)。

此外,如果没有订购加载和存储,通常会允许处理器观察分配甚至以不同的顺序出现:一个可能看到x = 1出现在y = 1之前,另一个可能看到y = 1在x = 1之前出现,因此允许值r1,r2,r3,r4的任何组合。

这样就足够了:

有序装载:

load x
load-load  -- barriers stopping other loads to go ahead of preceding loads
load-store -- no one is allowed to go ahead of ordered load

有序商店:

load-store
store-store -- ordered store must appear after all stores
            -- preceding it in program order - serialize all stores
            -- (flush write buffers)
store x,v
store-load -- ordered loads must not go ahead of ordered store
           -- preceding them in program order

在这两个中,我可以看到IORef实现了一个有序的商店(atomicWriteIORef),但是我没有看到有序的加载(atomicReadIORef),没有它就无法解释IRIW问题以上。如果您的目标平台是x86,这不是问题,因为所有负载将在该平台上按程序顺序执行,并且存储永远不会超过负载(实际上,所有负载都是有序负载)。

原子更新(atomicModifyIORef)在我看来是一个所谓的CAS循环的实现(比较和设置循环,它不会停止,直到一个值原子设置为b,如果它的值是一个)。您可以将原子修改操作视为有序加载和有序存储的融合,其中包含所有这些障碍,并以原子方式执行 - 不允许处理器在CAS的加载和存储之间插入修改指令。


此外,writeIORef比atomicWriteIORef便宜,因此您希望使用writeIORef,就像您的线程间通信协议所允许的那样。虽然writeIORef x vx >> writeIORef y vy >> atomicWriteIORef z vz >> readIORef t不保证writeIORef对其他线程相对于彼此的顺序,但可以保证它们都会出现在atomicWriteIORef之前 - 所以,看到z == vz,你可以在此刻结束x == vx和y == vy,你可以得出结论,在存储到x之后已经加载了,其他线程可以观察到y,z。后一点要求readIORef是一个有序负载,据我所知,它没有提供,但它将像x86上的有序负载一样工作。

通常,在推理算法时,您不会使用x,y,z的具体值。相反,关于指定值的一些依赖于算法的不变量必须保持,并且可以证明 - 例如,在IRIW情况下,如果线程3看到(0,1)=(r3,r4),则可以保证线程4永远不会看到(1,0)=(r1,r2)和线程3可以利用这一点:这意味着在不获取任何互斥锁或锁定的情况下相互排除某些东西。


如果未对订单进行排序则无法正常工作的示例(不在Haskell中),或者有序存储不会刷新写入缓冲区(在执行有序加载之前要求写入值可见)。

假设,z将显示x直到y被计算,或者y,如果x已被计算。不要问为什么,在上下文之外看到它并不容易 - 它是一种排队 - 只是享受可能的推理。

Thread 1: x=1;
          if (z==0) compareAndSet(z, 0, y == 0? x: y);

Thread 2: y=2;
          if (x != 0) while ((tmp=z) != y && !compareAndSet(z, tmp, y));

因此,两个线程设置x和y,然后将z设置为x或y,具体取决于是否计算y或x。假设最初都是0.转换为加载和存储:

Thread 1: store x,1
          load z
          if ==0 then
            load y
            if == 0 then load x -- if loaded y is still 0, load x into tmp
            else load y -- otherwise, load y into tmp
            CAS z, 0, tmp -- CAS whatever was loaded in the previous if-statement
                          -- the CAS may fail, but see explanation

Thread 2: store y,2
          load x
          if !=0 then
          loop: load z -- into tmp
                load y
                if !=tmp then -- compare loaded y to tmp
                  CAS z, tmp, y  -- attempt to CAS z: if it is still tmp, set to y
                  if ! then goto loop -- if CAS did not succeed, go to loop

如果线程1 load z不是有序加载,那么它将被允许超过有序存储(store x)。这意味着无论z加载到何处(寄存器,缓存行,堆栈......),该值都是在x的值可见之前存在的值。查看该值是没用的 - 您无法判断线程2的位置。出于同样的原因,您必须保证写入缓冲区在load z执行之前被刷新 - 否则它仍将显示为在线程2看到x的值之前存在的值的加载。这很重要,如下所述。

如果线程2 load xload z不是有序加载,它们可能会先于store y,并会观察在y对其他线程可见之前写入的值。 / p>

但是,请注意,如果加载和存储是有序的,那么线程可以协商谁来设置z的值而不竞争z。例如,如果线程2观察到x == 0,则保证线程1肯定会在稍后执行x = 1,并且在此之后将看到z == 0 - 因此线程2可以离开而不尝试设置z。

如果线程1观察到z == 0,那么它应该尝试将z设置为x或y。所以,首先它将检查是否已经设置了y。如果它没有设置,它将在未来设置,所以尝试设置为x - CAS可能会失败,但只有当线程2同时将z设置为y时,才需要重试。类似地,如果已经设置了线程1观察到y,则无需重试:如果CAS失败,则线程2将其设置为y。因此,我们可以看到线程1根据需求将z设置为x或y,并且不会过多地竞争z。

另一方面,线程2可以检查是否已经计算了x。如果没有,那么线程1的工作就是设置z。如果线程1计算了x,则需要将z设置为y。这里我们需要CAS循环,因为如果线程1试图将z同时设置为x或y,则单个CAS可能会失败。

这里重要的一点是,如果"无关"加载和存储不是序列化的(包括刷新写缓冲区),不可能有这样的推理。但是,一旦订购了加载和存储,两个线程都可以找出每个线程_will_take_in_the_future的路径,这样就可以在一半的情况下消除争用。大多数时候x和y将在显着不同的时间计算,因此如果y在x之前计算,则很可能线程2根本不会触及z。 (通常,"触摸z"也可能意味着"唤醒等待cond_var z"的线程,所以这不仅仅是从内存中加载某些内容的问题)

答案 1 :(得分:4)

  
      
  1. 在一个线程中,从未发现过atomicModifyIORef“   在任何早期的IORef运营之前,或之后的任何IORef之前   操作“即我们获得部分排序:原子之上的东西   mod - > atomic mod - >之后的东西。虽然,措辞“永远不会   观察到“这里暗示了我没有的怪异行为   预料到的。
  2.   

“从未被观察到”是讨论内存重新排序问题时的标准语言。例如,CPU可以在必要之前发出对存储器位置的推测性读取,只要该值在执行读取(早期)和应该执行读取之间(按程序顺序)之间不改变。这完全取决于CPU和缓存,但它从未暴露给程序员(因此语言从未被观察到过)。

  
      
  1. readIORef x可能会在writeIORef y之前移动,至少在那里   没有数据依赖
  2.   

  
      
  1. 从逻辑上讲,我看不出像readIORef x>> = writeIORef y这样的东西   可以重新订购
  2.   

正确,因为该序列具有数据依赖性。要写入的值取决于第一次读取时返回的值。

对于其他问题:newIORef False >>= \v-> writeIORef v True >> readIORef v将始终返回True(此处没有机会让其他线程访问ref。)

myprint示例中,除了添加到未来GHC和各种CPU架构中的新优化之外,您几乎无法确保其可靠运行。如果你写:

writeIORef myRef True
x <- readIORef myRef
yourVal <- x `seq` readIORef yourRef

即使GHC 7.6.3产生了正确的cmm(并且可能是asm,虽然我没有检查),但没有什么可以阻止具有宽松内存模型的CPU将readIORef yourRef移动到所有{之前} {1}}东西。防止它的唯一100%可靠的方法是使用GHC不提供的内存栅栏。 (爱德华的博客文章确实介绍了你现在可以做的其他一些事情,以及为什么你可能不想依赖它们。)

我认为你的心理模型是正确的,但重要的是要知道并发操作引入的可能明显的重新排序可能真的不直观。

编辑:在cmm级别,上面的代码片段看起来像这样(简化,伪代码):

myref/seq

所以有一些事情可能发生。目前的GHC不太可能在最后一行上移动,但我认为如果这样做似乎更合理。我更关心的是,如果你通过LLVM进行编译,LLVM优化器可能会用刚刚写入的值替换第二行,然后第三行可能会不断折叠,这样就更有可能读取可以提前移动。无论GHC做什么,大多数CPU内存模型都允许CPU本身在没有内存障碍的情况下提前移动读取。

答案 2 :(得分:1)

http://en.wikipedia.org/wiki/Memory_ordering用于非原子并发读写。 (基本上当你不使用原子时,只需查看目标CPU的内存排序模型)

目前ghc可以被视为 not 重新排序非原子(和命令性)加载和存储的读写操作。但是,GHC Haskell目前没有指定任何类型的并发内存模型,因此这些非原子操作将具有底层CPU模型的排序语义,如上所述。

换句话说,目前GHC有 no 正式并发内存模型,并且因为任何优化算法都倾向于某种等价模型,所以当前没有重新排序。

即:您现在唯一可以拥有的语义模型是“实施方式”

给我发电子邮件!我正在努力修补7.10的原子,让我们尝试烹饪一些语义!

编辑:一些了解这个问题的人比我在ghc-users线程http://www.haskell.org/pipermail/glasgow-haskell-users/2013-December/024473.html中更好地理解了这个问题。 假设我在这个评论和我在ghc-users线程中说的任何内容都错了:)