在C语言中使用phtreads,从多个线程的不同位置读写数组是否安全?

5
假设有两个线程A和B,并且有一个共享数组:float X [100]
线程A按顺序每次写入一个元素到数组中,每10步它以安全的方式更新一个共享变量index(表示当前索引),然后向线程B发送一个信号。 一旦线程B接收到信号,它会以安全的方式读取index,然后继续读取X中位置小于或等于index的元素。
这种方法是安全的吗?线程A真正更新了数组还是只是缓存中的副本?
3个回答

2
这是安全的吗?
只要您的数据修改通过关键部分、锁或其他方式得到了安全和保护,就可以完全放心地进行硬件访问。
线程A是否真正更新了数组还是只是缓存中的副本?
只是缓存中的副本。现在大多数缓存都是写回的,并且只有当缓存行被驱逐时才将数据写回内存,如果已经被修改。这在多核上下文中大大提高了内存带宽。
但是所有的操作都好像内存已经被更新了。
对于共享内存处理器,通常有缓存一致性协议(除了某些用于实时应用程序的处理器)。这些协议的基本思想是为每个缓存行关联一个状态。状态描述了不同处理器缓存中该行的信息。例如,这些状态指示该行是否仅存在于当前缓存中,或者由多个缓存共享,在与内存同步,无效等。请参阅流行的MESI缓存一致性协议的this description
当一个缓存行被写入并且在另一个处理器中也存在时会发生什么呢?由于状态的存在,缓存知道其他一个或多个处理器也拥有该行的副本,并将发送一个无效信号。该行将在其他缓存中失效,当它们想要读取或写入它时,它们必须重新加载其内容。实际上,为了限制内存访问,这个重新加载将由具有有效副本的缓存提供。
这样,虽然数据只写入了缓存,但行为类似于数据已经写入内存的情况。
但是,尽管硬件在功能上保证了传输的正确性,但必须考虑到缓存的存在,以避免性能下降。假设缓存A正在更新一行数据,而缓存B正在读取它。每当缓存A写入时,缓存B中的行就会失效。每当缓存B想要读取它时,如果该行已失效,则必须从缓存A中获取。这可能导致行在缓存之间的多次传输,从而使内存系统效率低下。
因此,关于您的例子,使用缓存信息来改进发送者和接收者之间的交换可能不是一个好主意,您应该选择其他方法。
例如,如果您使用带有64字节缓存行的Pentium处理器,则应将X声明为:
_Alignas(64) float X[100];

这样,X 的起始地址将是 64 的倍数,并适合于缓存行边界。自 C17 起,_Alignas 限定符存在,通过包含 stdalign.h,您还可以使用类似的 alignas(64)。在 C17 之前,大多数编译器都有一些扩展来实现对齐放置。
当然,您应该告知进程 B 只有在写入完整的 64 字节行(16 个浮点数)后才读取数据。
这样,当线程 B 访问数据时,缓存行将不再被线程 A 修改,只会发生一次缓存 A 和 B 之间的初始传输。这种减少缓存之间传输次数的方法可能会对程序的性能产生显著影响。

每当缓存A写入时,缓存B中的行将被作废。我认为这里没有问题。A正在将条目写入一个缓冲区(循环?)中,该缓冲区具有递增的索引。风险在于,当B消耗到索引时,A会将更多的项放入索引之前的缓冲区。但是这些位是为B而设计的;B的缓存提前获取它们是完全可以的。 - Kaz
根据OP的说法,在软件方面一切都是安全的,这在这个简单的生产者消费者模式中并不难实现。但我想让OP意识到虚假共享的问题。即使在软件方面一切都是安全的,线程B正确读取一个块,而线程A填充下一个块,如果连续块的缓冲区共享一个缓存行,由于缓存一致性协议的原因,传输可能非常低效。 - Alain Merigot
我不相信这个;如果A只读取B正在写入的内容,那么就没有一致性问题。只需要A从B那里获取更新即可。低效率只是双重刷新;如果B放置一个项目,发出信号,然后放置另一个项目并发出信号,并且这些项目在同一缓存行中,则会发生两次更新而不是一次。但是将项目放入单独的缓存行中无法解决此问题。如果B能够同时放置多个项目并将索引增加几个位置,那么这个问题就基本解决了。 - Kaz
@kaz 如果两个线程读写相同的缓存行,则存在一致性问题,这会影响性能。编译此代码。它只创建了两个线程:一个写入数组的一部分,另一个读取数组的另一个不相交的部分。并且它测量每个点的周期数,如果这些部分在相同的缓存行中或在不同的缓存行中。在我的计算机上,使用错误共享会导致系统性的2-4倍减速。必须在Pentium上使用gcc进行编译,因为它使用内置函数来读取周期计数。 - Alain Merigot
就是这样,没有“虚假共享”;A和B实际上是在共享。B放入缓冲区的所有项目都将由A消耗。观察到的减速是必要的,因为项目从一个处理器传输到另一个处理器;这是一种具有开销的通信形式;它不能与A和B独立地在相邻的数组单元上工作进行比较,因为在那里发生的事实上通信只是接近的副产品。 - Kaz
当一个线程正在填充一个块时,另一个线程正在读取另一个块,并且它们在缓存层面上存在交互时,我认为这是一种伪共享形式。但是,如果您喜欢另一个术语,也可以。重要的是我们可能会同意这样一个事实,即操作员应该将其传输与缓存行对齐以提高性能。 - Alain Merigot

2

每种一条线程向另一条线程发送信号的合理方式都提供了保证:在发送信号之前由线程写入的任何内容都保证对接收该信号的线程可见。因此,只要您通过某种提供此保证的方式发送信号(它们几乎都提供此保证),您就是安全的。

请注意,尝试在没有由互斥量保护的谓词的情况下使用条件变量不是一种合理的一条线程向另一条线程发送信号的方式!除其他事项外,它不能保证您认为已接收信号的线程实际上已接收到信号。您确实需要确保执行读取操作的线程实际上接收到了由执行写入操作的线程发送的信号。

最初的回答


1
如果您正在使用一个变量来跟踪对索引的读取准备情况,该变量受互斥锁保护,并且通过 pthread 条件变量进行信号传递,线程 B 在互斥锁下等待,则可以这样做。
如果您正在使用 POSIX 信号,则我认为您需要在其上面使用同步机制。在线程 A 中使用 memory_order_release 写入原子变量,并在线程 B 中使用 memory_order_acquire 读取它,应该以最轻量级的方式保证在写入原子之前在 A 中的写入在 B 中读取原子后可见。
为了获得最佳性能,数组共享也应该以这样的方式进行,即共享数组的部分不要跨越缓存行边界(否则由于虚假共享,您的性能可能会降低)。

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