说到C ++的并发内存模型,Stroustrup的 C ++编程语言,第4版,第1节。 41.2.1,说:
...(与大多数现代硬件一样),机器无法加载或存储任何小于单词的内容。
然而,我的x86处理器,几年前,可以存储小于一个单词的对象。例如:
#include <iostream>
int main()
{
char a = 5;
char b = 25;
a = b;
std::cout << int(a) << "\n";
return 0;
}
如果没有优化,GCC会将其编译为:
[...]
movb $5, -1(%rbp) # a = 5, one byte
movb $25, -2(%rbp) # b = 25, one byte
movzbl -2(%rbp), %eax # load b, one byte, not extending the sign
movb %al, -1(%rbp) # a = b, one byte
[...]
评论由我发表,但大会是由海湾合作委员会进行的。当然,它运行良好。
显然,我不明白Stroustrup在谈到硬件可以加载和存储任何小于一个单词的内容时所说的内容。据我所知,我的程序没有做什么但是加载和存储小于单词的对象。
C ++对零成本,硬件友好的抽象的彻底关注使C ++与其他易于掌握的编程语言区分开来。因此,如果Stroustrup在公交车上有一个有趣的信号心理模型,或者有其他类似的东西,那么我想了解Stroustrup的模型。
是什么 Stroustrup在说什么?
带有上下文的更长时间
这是Stroustrup在更全面的背景下的引用:
考虑如果链接器在内存中的同一个词中分配[{1}}类型的变量
char
类似于c
和b
并且(像大多数现代硬件一样)机器不能加载或存储小于单词的任何内容....如果没有定义明确且合理的内存模型,则线程1可能会读取包含b
和c
的单词,更改c
并写入这个词回到了记忆中。同时,线程2可以对b
执行相同的操作。然后,无论哪个线程设法首先读取该单词,哪个线程设法将其结果写回内存最后将确定结果....
其他备注
我不相信Stroustrup在讨论缓存行。即使他是,据我所知,缓存一致性协议将透明地处理该问题,除非在硬件I / O期间。
我已检查过处理器的硬件数据表。电子方面,我的处理器(Intel Ivy Bridge)似乎通过某种16位多路复用方案来解决DDR3L内存问题,所以我不知道那是什么意思。我不清楚这与Stroustrup的观点有很大关系。
Stroustrup是一个聪明的人,也是一位杰出的科学家,所以我不怀疑他正在采取一些明智的做法。我很困惑。另请参阅this question.我的问题在几个方面类似于链接的问题,链接问题的答案在这里也很有帮助。但是,我的问题还在于硬件/总线模型,它促使C ++成为它的方式,并导致Stroustrup写出他写的东西。我不仅仅针对C ++标准正式保证的内容寻求答案,而是希望理解为什么C ++标准会保证它。什么是潜在的想法?这也是我的问题的一部分。
答案 0 :(得分:7)
x86 CPU不仅能够读写单个字节,而且所有现代通用CPU都能够实现这一点。更重要的是,大多数现代CPU(包括x86,ARM,MIPS,PowerPC和SPARC)都能够以原子方式读取和写入单个字节。
我不确定Stroustrup指的是什么。曾经有一些单词可寻址的机器不能像8位字节寻址那样,就像Cray一样,并且Peter Cordes提到早期的Alpha CPU并不支持字节加载和存储,但今天只有无法进行字节加载和存储的CPU是特定应用中使用的某些DSP。即使我们假设他意味着大多数现代CPU没有原子字节加载并且存储这对大多数CPU来说都不是真的。
然而,简单的原子载荷和存储在多线程编程中没有多大用处。您通常还需要订购保证以及使读取 - 修改 - 写入操作成为原子的方法。另一个考虑因素是,虽然CPU a可能有字节加载和存储指令,但编译器并不需要使用它们。例如,编译器仍然可以生成Stroustrup描述的代码,使用单个字加载指令作为优化来加载b
和c
。
因此,虽然您确实需要一个定义良好的内存模型,但只有这样才能强制编译器生成您期望的代码,问题不在于现代CPU无法加载或存储更小的内容而不是一个字。
答案 1 :(得分:2)
Not sure what Stroustrup meant by "WORD". Maybe it is the minimum size of memory storage of the machine?
Anyway not all machines were created with 8bit (BYTE) resolution. In fact I recommend this awesome article by Eric S. Raymond describing some of the history of computers: http://www.catb.org/esr/faqs/things-every-hacker-once-knew/
中“......通常也知道36位架构 解释了C语言的一些不幸功能。原本的 Unix机器,PDP-7,具有对应的18位字 大型36位计算机上的半字。这些更自然 表示为六位八进制(3位)数字。“
答案 2 :(得分:2)
作者似乎关注线程1和线程2进入读取 - 修改 - 写入的情况(不是在软件中,软件执行两个单独的字节大小的指令,在线逻辑的某个地方必须做一个读取 - 修改 - 写入)代替理想的读取修改写入读取修改写入,成为读取读取修改修改写入或其他一些时序,使得读取预修改版本和最后一个写入获胜。读取修改修改写入写入,或读取修改读取修改写入或读取修改读取写入修改写入。
关注的是从0x1122开始,一个线程想要使其成为0x33XX,另一个想要使其成为0xXX44,但是例如读取修改修改写入写入最终为0x1144或0x3322,但不是0x3344
一个理智的(系统/逻辑)设计只是没有这个问题肯定不适用于像这样的通用处理器,我已经处理过像这样的时序问题的设计,但这不是我们在这里讨论的,用于不同目的的完全不同的系统设计。读取 - 修改 - 写入在一个理智的设计中没有跨越足够长的距离,而x86s是理智的设计。
读取 - 修改 - 写入将发生在第一个所涉及的SRAM附近(理想情况下,L1以典型方式运行x86,操作系统能够运行C ++编译的多线程程序)并且在几个时钟周期内发生公羊的理想速度是公交车的速度。正如彼得所指出的那样,这被认为是在缓存中体验这一点的整个缓存行,而不是处理器核心和缓存之间的读取 - 修改 - 写入。
&#34;同时&#34;的概念即使是多核系统也不一定是同时进行的,最终你会被序列化,因为性能不是基于它们从始至终并行,而是基于保持总线加载。
引用是说变量分配给内存中的同一个单词,所以这是同一个程序。两个独立的程序不会共享这样的地址空间。所以
欢迎您试试这个,制作一个多线程程序,一个写入地址0xnnn00000,另一个写入地址0xnnnn00001,每个写入一个写入,然后读取或更好的几个写入相同的值比一个读取,检查read是他们写的字节,然后用不同的值重复。让它运行一段时间,小时/天/周/月。看看你是否绊倒了系统......使用程序集来获取实际的写入指令,以确保它正在执行您所要求的操作(不是C ++或任何编译器或声称它不会将这些项目放在同一个单词中)。可以添加延迟以允许更多的缓存驱逐,但这会降低你同时的几率#34;碰撞。
你的例子只要你确保你没有坐在边界(缓存或其他)的两边,如0xNNNNFFFFF和0xNNNN00000,隔离两个字节写入地址,如0xNNNN00000和0xNNNN00001有指令背靠背,看看如果你得到一个读取修改修改写入写入。围绕它进行测试,每个循环的两个值都不同,您可以在以后的任何延迟中读回整个单词,并根据需要检查这两个值。重复几天/几周/几个月/几年,看它是否失败。阅读处理器执行和微码功能,了解它对该指令序列的作用,并根据需要创建一个不同的指令序列,试图在处理器核心远端的少数几个时钟周期内启动事务。 / p>
修改
引号的问题在于,这完全是关于语言和使用的。 &#34;像大多数现代硬件一样#34;将整个主题/文本置于一个敏感的位置,它太模糊了,一方可以说我要做的就是找到一个真实的案例,使其余的都成真,如果我找到一个,也可以争辩一方所有其余的都不是真的。使用像那种混乱这个词作为一个可能走出监狱免费卡。
现实情况是,我们数据的很大一部分存储在8位宽存储器的DRAM中,只是因为我们不能将它们作为8位宽访问,通常我们一次访问8个,64位宽。在几周/几个月/几年/几十年内,这个陈述是不正确的。
更大的引用同时说&#34;&#34;然后说阅读...首先,写...最后,第一个也是最后一个,同时也没有意义,它是并行还是连续?上下文作为一个整体关注上述读取修改修改写入写入变体,其中您有一个写入最后一个并取决于何时读取确定是否发生了两个修改。并不像大多数现代硬件那样同时出现#34;如果它们针对存储器中的同一个触发器/晶体管,那么最终会在单独的内核/模块中实际并行开始的事情最终会变得有意义,最终必须等待另一个先行。作为基于物理的我不会在未来几周/几个月/几年内看到这是不正确的。
答案 3 :(得分:2)
这是对的。与原始x86 CPU一样,x86_64 CPU无法读取或写入来自rsp的小于(在本例中为64位)字的任何内容。记忆。它通常不会读取或写入少于整个缓存行,但有一些方法可以绕过缓存,特别是在写入时(见下文)。
在此上下文中,Stroustrup引用潜在的数据争用(在可观察的级别上缺乏原子性)。由于您提到的缓存一致性协议,此正确性问题与x86_64无关。换句话说,是的,CPU仅限于全字传输,但是这是透明处理的,而你作为程序员通常不必担心它。实际上,从C ++ 11开始,C ++语言保证不同内存位置上的并发操作具有明确定义的行为,即您期望的行为。即使硬件不能保证这一点,实现也必须通过生成可能更复杂的代码来找到方法。
也就是说,保持这样一个事实仍然是一个好主意,因为有两个原因,即整个单词甚至缓存行总是涉及机器后面的问题。
volatile
关键字对于防止此类不合适的优化至关重要。这是一个有点人为的 - 一个非常糟糕的数据结构的例子。假设您有16个线程正在解析文件中的某些文本。每个帖子的id
从0到15。
// shared state
char c[16];
FILE *file[16];
void threadFunc(int id)
{
while ((c[id] = getc(file[id])) != EOF)
{
// ...
}
}
这是安全的,因为每个线程都在不同的内存位置上运行。但是,这些存储器位置通常驻留在同一高速缓存行上,或者最多分成两个高速缓存行。然后使用高速缓存一致性协议来正确地同步对c[id]
的访问。这就是问题所在,因为这会迫使每个其他线程等待,直到缓存行变为专用,然后再对c[id]
执行任何操作,除非它已经在“拥有”的核心上运行缓存行。假设有几个,例如如图16所示,核心,高速缓存一致性通常将高速缓存线从一个核心一直传送到另一个核心。出于显而易见的原因,这种效应被称为“缓存行乒乓”。它造成了一个可怕的性能瓶颈。这是 false sharing 非常糟糕的结果,即线程共享物理缓存线而没有实际访问相同的逻辑内存位置。
与此形成对比的是,特别是如果需要额外的步骤来确保file
数组驻留在自己的缓存行上,从性能角度来看,使用它将是完全无害的(在x86_64上)因为指针是只读,大部分时间。在这种情况下,多个核可以以只读方式“共享”高速缓存行。只有当任何核心尝试写入缓存行时,它必须告诉其他核心它将“抓住”缓存行以进行独占访问。
(这大大简化了,因为有不同级别的CPU缓存,并且多个核心可能共享相同的L2或L3缓存,但它应该让您基本了解问题。)
答案 4 :(得分:1)
Stroustrup 不说没有机器可以执行小于其原生单词大小的加载和存储,他说机器不能。
虽然这一开始似乎令人惊讶,但这并不是什么深奥的 对于入门者,我们将忽略缓存层次结构,我们稍后会考虑到这一点 假设CPU和内存之间没有缓存。
内存的一个大问题是密度,试图将更多的比特放到最小的区域。
为了实现这一目的,从电气设计的角度来看,尽可能宽地暴露总线是有利的(这有利于重复使用某些电信号,但我还没有看到具体的细节)。登记/>
因此,在需要大存储器的架构中(如x86)或简单的低成本设计是有利的(例如涉及RISC机器),存储器总线大于最小的可寻址单元(通常是字节)。 / p>
根据项目的预算和遗留情况,内存可以单独暴露更宽的总线,或者与一些边带信号一起选择特定的单元进入它。
这实际上意味着什么?
如果你看一下datasheet of a DDR3 DIMM,你会看到有64个 DQ0-DQ63 引脚来读/写数据。
这是数据总线,64位宽,一次8个字节
这个8字节的东西在x86架构中非常有根据,以至于英特尔在其优化手册的WC部分中引用它,它表示数据是从64 字节填充缓冲区传输的(请记住:我们现在忽略了缓存,但这类似于缓存行的写入方式,以8字节的突发(希望,连续)。
这是否意味着x86只能写QWORDS(64位)?
不,相同的数据表显示每个DIMM都有 DM0-DM7,DQ0-DQ7 和 DQS0-DQS7 信号来屏蔽,指示和选通每个字节中的8个字节。 64位数据总线。
因此x86可以原生和原子地读写字节 但是,现在很容易看出每个架构都不是这种情况 例如,VGA视频内存是DWORD(32位)可寻址,并使其适合8086的字节可寻址世界,导致凌乱的位平面。
通常,特定目的架构(如DSP)在硬件级别上不能具有字节可寻址内存。
有一个转折点:我们刚刚谈到了内存数据总线,这是最低层
某些CPU可以具有在字可寻址存储器之上构建字节可寻址存储器的指令
这是什么意思?
加载一个单词的一小部分很容易:只丢弃剩下的字节!
不幸的是,我无法回想起架构的名称(如果它甚至存在!),其中处理器通过读取包含它的对齐字并在将其保存在寄存器中之前旋转结果来模拟未对齐字节的加载。
对于商店来说,问题更复杂:如果我们不能简单地写出我们刚刚更新过的单词部分,我们也需要写下未更改的剩余部分。
CPU或程序员必须读取旧内容,更新并将其写回
这是一个读 - 修改 - 写操作,它是讨论原子性时的核心概念。
考虑:
/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};
/* Thread 0 Thread 1 */
foo[0] = 1; foo[1] = 2;
是否有数据竞争?
这在x86上是安全的,因为它们可以写入字节,但是如果架构不能呢?
两个线程都必须读取整个 foo
数组,修改它并将其写回。
在 pseudo-C 中,这将是
/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};
/* Thread 0 Thread 1 */
/* What a CPU would do (IS) What a CPU would do (IS) */
int tmp0 = *((int*)foo) int tmp1 = *((int*)foo)
/* Assume little endian Assume little endian */
tmp0 = (tmp0 & ~0xff) | 1; tmp1 = (tmp1 & ~0xff00) | 0x200;
/* Store it back Store it back */
*((int*)foo) = tmp0; *((int*)foo) = tmp1;
我们现在可以看到Stroustrup正在谈论的内容:两个商店*((int*)foo) = tmpX
互相阻碍,看看这个可能的执行顺序:
int tmp0 = *((int*)foo) /* T0 */
tmp0 = (tmp0 & ~0xff) | 1; /* T1 */
int tmp1 = *((int*)foo) /* T1 */
tmp1 = (tmp1 & ~0xff00) | 0x200; /* T1 */
*((int*)foo) = tmp1; /* T0 */
*((int*)foo) = tmp0; /* T0, Whooopsy */
如果C ++没有内存模型,这些类型的麻烦将是特定于实现的细节,使C ++在多线程环境中成为无用的编程语言。
考虑到玩具示例中描述的情况有多常见,Stroustrup 强调明确定义的记忆模型的重要性。
将内存模型正式化是一项艰苦的工作,它是一个令人筋疲力尽,容易出错和抽象的过程,所以我也看到Stroustrup中的一些 pride 。
我没有刷新C ++内存模型,而是更新了不同的数组元素is fine 这是一个非常有力的保证。
我们遗漏了缓存,但这并没有真正改变任何东西,至少对于x86案例而言。
x86通过缓存写入内存,缓存以64 字节的行排除。
在内部,每个核心都可以原子地更新任意位置的线,除非加载/存储穿过线边界(例如通过在其末端附近书写)。
这可以通过自然对齐数据来避免(你可以证明吗?)。
在多代码/套接字环境中,缓存一致性协议确保一次只允许一个CPU自由写入缓存的内存行(使其处于Exclusive或Modified状态的CPU)。登记/> 基本上,MESI协议系列使用类似锁定的概念找到了DBMS 出于写作目的,这对于分配&#34;分配&#34;不同的内存区域到不同的CPU 所以它并没有真正影响上面的讨论。