使用原子变量的共享内存IPC技术,对于x86架构来说是否是一个好选择?

10
我有以下代码用于通过共享内存进行进程间通信。一个进程将日志写入,另一个进程从中读取。其中一种方法是使用信号量,但在这里我使用类型为 atomic_t 的原子标志 (log_flag),它驻留在共享内存中。日志 (log_data) 也是共享的。
现在的问题是,这是否适用于 x86 架构,或者我需要使用信号量或互斥锁?如果我使 log_flag 非原子性会怎样?鉴于 x86 具有严格的内存模型和积极的缓存一致性,并且指针上不应用优化,我认为它仍然可以工作吗?
编辑:请注意,我有一台带有 8 个核心的多核处理器,因此在这里没有忙等待的问题!
// Process 1 calls this function
void write_log( void * data, size_t size )
{
    while( *log_flag )
           ;
    memcpy( log_data, data, size );
    *log_flag = 1;
}

// Process 2 calls this function
void read_log( void * data, size_t size )
{
    while( !( *log_flag ) )
       ;
    memcpy( data, log_data, size );
    *log_flag = 0;
}

9
拥有多核处理器并不意味着繁忙等待循环是个好主意 —— 这只会浪费能源,同时阻塞其他无关的进程。 - Oliver Charlesworth
4
由于你只是串行发送数据,阻塞是可接受的,并且你不想干扰信号量,所以你应该使用管道。 - John K
3
@JonathanLeffler:volatile对于多线程是无用的 - GManNickG
3
@GMan,volatile并非在所有情况下都是无用的。这里的含义是告诉编译器应该在每次迭代时从内存中重新读取*log_flag的值,而不是将其缓存在寄存器中(将忙等待转换为无限循环)。当我们有2个进程在2个CPU上,并且存在共享内存时,对内存的更改对于编译器来说看起来非常像内存映射硬件操作或信号处理程序操作。 - osgx
3
没有使用volatile关键字,编译器会对这段代码while( *log_flag ) ;进行自由优化,可能会变成int tmp = *log_flag; while(tmp) ;volatile并不是毫无用处,只是其与Java中的volatile有所不同。 - Bartosz Milewski
显示剩余6条评论
4个回答

4

在循环中,您可能希望使用以下宏,以避免过度压力内存总线:

#if defined(__x86_64) || defined(__i386)
#define cpu_relax() __asm__("pause":::"memory")
#else
#define cpu_relax() __asm__("":::"memory")
#endif

此外,它充当内存屏障("memory"参数),因此不需要将log_flag声明为volatile
但我认为这有些过度了,应该只在硬实时的情况下才需要这样做。您可以使用futex,这样就可以了。也许您可以简单地使用管道,对于几乎所有目的来说,速度已经足够快了。

除非 log_flagvolatile,否则不能保证它被读取或写入。屏障只影响可观察的行为。 - Simon Richter
@SimonRichter:你在说什么呢?那根本没有任何意义……对于一致性来说,不是观察到的行为才是最重要的吗? - Ismael Luceno
抱歉,我的错,我在想编译时的障碍而不是运行时的。 - Simon Richter

2
我不建议这样做的原因有两个:首先,虽然指针访问可能不会被编译器优化,但这并不意味着指向的值不会被处理器缓存。其次,在while循环结束和* log_flag = 0行之间进行读取访问的事实是原子性的,这并不能防止。使用互斥锁更安全,但速度要慢得多。
如果您正在使用pthread,请考虑使用RW mutex来保护整个缓冲区,这样您就不需要一个标志来控制它,mutex本身就是标志,并且在频繁读取时可以获得更好的性能。
我也不建议使用空的while()循环,这样会占用所有处理器。在循环内部放置usleep(1000),以便给处理器一个停顿的机会。

4
如果你有一个多处理器系统并且想要充分利用它的话,忙等待并不像你描述的那么糟糕。 - Jeff Mercado
5
@Jeff:你能详细解释一下吗?我不明白如何通过每次等待时引起100% CPU利用率并完全使用调度时间片,从而降低进程的优先级是如何“利用多处理系统”的。 - Niklas B.
4
如果你知道等待资源的时间不会很长,那么繁忙等待可以节省上下文切换和重新调度的开销。但如果在单处理器系统上,它效果不佳(因为你无论如何都必须进行上下文切换)。繁忙等待有其用途,只需要注意正在保护的资源和系统架构即可。 - Jeff Mercado
1
@FabioCeconello:没有任何区别。除非你在谈论早期(即 Pentium 之前)的 x86 SMP 系统... - Ismael Luceno
1
@Ismael,我认为你关于futex的建议可能是性能和安全之间的最佳平衡。我不知道它的存在,又学到了一件事 :-) 。 - Fabio Ceconello
显示剩余14条评论

1

使用信号量而不是依赖标志有很多原因。

  1. 您的读取日志循环正在不必要地旋转。这会消耗系统资源,如电力,也意味着CPU无法用于其他任务。
  2. 如果x86不能完全保证读写顺序,我会感到惊讶。传入数据可能仅将日志标志设置为1,以使传出数据将其设置为0。这可能意味着您最终会丢失数据。
  3. 我不知道您从哪里得到的信息,即指针通常不适用于优化。优化可以应用于任何没有外部更改差异的地方。编译器可能不知道log_flag可以被并发进程更改。

问题2可能很少出现,并且跟踪该问题将很困难。因此,请自己做个好人并使用正确的操作系统原语。它们将保证事情按预期工作。


如果指针可以被优化,为什么C99中会发明restrict关键字?编译器不会冒指针的风险,因为它们可以指向内存中的任何位置。 - MetallicPriest
x86保证写入顺序,但仅在CPU内部,并不保证甚至在同一CPU内的读取顺序。 - ugoren
1
@doron:x86会主动监视内存总线并进行预先失效。 - Ismael Luceno
1
@ugoren:x86缓存是强一致性的,因此按设计它不能进行写重排序,除非相应的内存页面被标记为写组合(通过MTRRs或PAT),这是您必须明确执行的操作... - Ismael Luceno
1
@MetallicPriest:好吧,"broken"(破碎)这个词可能有点过于强烈了,抱歉。相反,我会说它是微妙和脆弱的,在没有基准测试显示操作系统提供的同步原语太慢时,对于生产代码来说是毫无意义的(当然,作为学习练习是可以的!)。另外,什么是atomic_t?C、C++、GCC或POSIX都没有这样的类型,而且谷歌搜索只能找到一些Linux内核内部类型。因此,我们所知道的是,它可能是一种x86不保证原子读/写的类型(即自己定义的类型)。 - janneb
显示剩余13条评论

1
只要log_flag是原子的,你就没问题了。
如果log_flag只是一个普通的bool类型,你无法保证它会起作用。
编译器可能会重新排列你的指令。
*log_flag = 1;
memcpy( log_data, data, size );

在单处理器系统上,只要在memcpy内部没有访问log_flag,它们的语义是完全相同的。你唯一的救命稻草可能是较差的优化器无法推断memcpy中访问了哪些变量。

CPU可以重新排序指令。
它可能会选择在循环之前加载log_flag以优化流水线。

缓存可能会重新排序内存写入操作。
包含log_flag的缓存行可能会在包含data的缓存行之前被同步到其他处理器上。

你需要的是一种方法来告诉编译器、CPU和缓存 "不要动手",这样它们就不会对顺序做出假设。只有通过内存屏障才能实现这一点。std::atomicstd::mutex和信号量都嵌入了正确的内存屏障指令。


log_flag在memcpy中没有被访问,它是log_data。 - MetallicPriest

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