共享内存是否可以在没有互斥锁的情况下进行读取和验证?

8
在Linux上,我使用shmgetshmat来设置一个共享内存段,其中一个进程将写入数据,而一个或多个进程将从中读取。被共享的数据大小为几兆字节,当更新时会完全重写;它永远不会部分更新。
我将共享内存段布置如下:
    -------------------------
    | t0 | 实际数据 | t1 |
    -------------------------
其中t0和t1是写入者开始更新时的时间副本(具有足够的精度,以确保后续更新具有不同的时间)。写入者首先写入t1,然后复制数据,最后写入t0。另一方面,读者首先读取t0,然后读取数据,最后读取t1。如果读者得到的t0和t1的值相同,则认为数据一致且有效;否则,它将再次尝试。
这个过程能够确保如果读者认为数据有效,则实际上它就是有效的吗?
我需要担心乱序执行(OOE)吗?如果需要,那么读者使用memcpy来获取整个共享内存段是否能够解决读者端的OOE问题?(这假定memcpy通过地址空间线性升序执行其复制。这个假设是有效的吗?)

1
为什么要这样做?互斥锁是非常便宜的。而且,如果你担心开销,你可以想出一些基于原子类型的同步方式(就像你试图用t0和t1做的那样)。 - pajton
为什么这样做呢?因为我很懒,试图让最简单的东西工作。同时,我也不想让编写者等待锁。它有其他处理要做,这是我没有原始编写的代码,我也不想修改它,以便它可以等待读者完成阅读。确保读者每次读取时得到一致的数据对我来说并不重要,只要他们知道数据是否一致即可。 - Bribles
2个回答

5
现代硬件实际上与顺序一致性相反。因此,如果您不在适当的位置执行内存屏障,这不能保证起作用。屏障是必需的,因为架构实现了比顺序一致性更弱的共享内存一致性模型。这与流水线或乱序无关,而是允许多个处理器并行有效地访问内存系统。请参见共享内存一致性模型:教程。在单处理器上,您不需要障碍,因为所有代码都在该处理器上按顺序执行。
另外,没有必要拥有两个时间字段,序列计数可能是一个更好的选择,因为无需担心两个更新是否如此接近,以至于它们获得相同的时间戳,并且更新计数器比获取当前时间快得多。而且,时钟倒退的可能性也不存在,这种情况可能会发生,例如当ntpd调整时钟漂移时。尽管在Linux上可以通过使用clock_gettime(CLOCK_MONOTONIC, ...)来解决此问题。使用序列计数器而不是时间戳的另一个优点是,您只需要一个序列计数器。写入器在写入数据之前和之后都会增加计数器。然后,读取器会读取序列号,检查其是否为偶数,如果是,则读取数据,最后再次读取序列号并将其与第一个序列号进行比较。如果序列号是奇数,则表示正在进行写操作,无需读取数据。
Linux内核使用称为seqlocks的锁定原语来执行上述操作。如果您不担心“GPL污染”,可以搜索实现;本身很简单,但关键在于正确使用屏障。

1
假设我在读写操作之前和/或之后使用了适当的[SLM]FENCE汇编指令,我是否仍需要两个序列计数(seqc)的副本?如果我只有一个,写入者可以通过设置seqc开始写入数据,然后操作系统切换进程并启动读取器,读取seqc和大部分数据(一些已更新,一些未更新),然后切换回写入者完成,再切换回读取器完成。读取器两次获得相同的seqc,但最终仍会得到无效数据。 - Bribles
@andras:确实,这就是为什么我写了"...序列计数可能是更好的选择,因为不需要担心两个更新是否如此接近,以至于它们获得相同的时间戳..." - janneb
@andras:引用在C#和VC++中使用volatile,正如您在答案中提到的那样,它们在C++和GCC中的语义与C#和VC++中不同。我相信Java具有与C#和VC++类似的易失性语义,并且似乎使用XCHG或MFENCE进行易失性存储。但是,您正确地指出x86提供了相当强大的模型。我不确定,但我认为您是正确的,这个特定的算法在x86上不需要任何栅栏,因为架构保证存储顺序(来自处理器的存储总是按程序顺序由其他进程看到)。 - janneb
@janneb: …并且在这种特殊情况下,在x86 / x64上它们是真的不必要的。(我忘了添加食谱链接——不是说在谷歌上难以找到它——但这里是链接:http://gee.cs.oswego.edu/dl/jmm/cookbook.html) - Andras Vass
@janneb:这里唯一需要确保的是,编译器生成最简单且看似低效的机器代码,并且不进行巧妙的优化。 - Andras Vass
显示剩余4条评论

4

Joe Duffy提供了完全相同的算法,并称之为:"具有乐观重试的可扩展读写方案"

它是有效的。
您需要两个序列号字段。

您需要以相反的顺序读取和写入它们。
根据系统内存排序保证,您可能需要放置内存屏障。

具体而言,在读者和写者访问t0或t1进行读取和写入时,您需要读取获取和存储发布语义。

实现这个需要什么指令,取决于架构。例如,在x86/x64上,由于相对强的保证,此特定情况下不需要任何机器特定的屏障。*(参见链接1)
* 仍需确保编译器/JIT不会对负载和存储进行操作,例如使用volatile(在Java和C#中与ISO C/C++有不同的含义)。但是编译器可能不同。例如,在使用VC++ 2005或更高版本的情况下,使用volatile执行上述操作是安全的。请参阅“Microsoft Specific”部分。在x86/x64上也可以使用其他编译器进行操作。应检查发出的汇编代码,并确保编译器不会消除或移动对t0和t1的访问。)(参见链接2)
作为一个旁注,如果你需要使用MFENCElock or [TopOfStack],0可能是一个更好的选择,具体取决于你的需求

你知道这个技术有没有名称吗?此外,由于现代流水线过于复杂的架构,需要内存屏障,对吧?如果这段代码在8086或非常简单的微控制器上运行,那么内存屏障是否是不必要的?最后,如果数据复制是使用memcpy完成的,并且函数调用在时间戳读/写之间进行,那么memcpy中的大量数据移动是否总是使时间戳操作成为顺序操作? - Bribles
@Bribles:需要屏障,因为架构实现的共享内存一致性模型比顺序一致性要弱。这与流水线或乱序执行无关,而是为了允许多个处理器并行高效地访问内存系统。例如,请参见http://www.hpl.hp.com/techreports/Compaq-DEC/WRL-95-7.pdf。在单处理器上,您不需要屏障,因为所有代码都在一个处理器上按顺序执行。 - janneb
@Bribles:时间戳的问题在于:1)如果性能很重要,获取当前时间比递增计数器慢;2)如果时间向后改变(例如,ntpd将时钟向后移动以补偿时钟漂移),会怎样?好吧,在Linux 2)上,可以通过使用clock_gettime(CLOCK_MONOTONIC, ...)来解决。 - janneb
@janneb:我甚至没有考虑过在这里使用真实的时钟值(即使是CLOCK_MONOTONIC也只会减慢速度而不提供任何附加价值)。我认为误解源于此。:S 你说得对,我一开始应该在术语上更加精确。:P - Andras Vass
你不能说它不需要内存屏障,同时又声称它只适用于MSVC或Java volatile。因为这些系统在其volatile中使用内存屏障。所以是的,你确实需要内存屏障。 - Zan Lynx

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接