问题

当两个CPU核心要求读写同一内存地址时,其后果是未定义行为吗?

回答
当两个CPU核心试图同时读写同一个内存地址时,其后果 通常是未定义的行为(Undefined Behavior,UB),但具体情况会复杂得多,不能一概而论为简单的“未定义”。理解这个问题需要深入探讨计算机体系结构、内存模型和并发控制等概念。

核心问题:共享资源的竞争

想象一下,两个人在同一时间想要写同一本笔记本上的同一个字。如果没有规则或协调机制,结果将是混乱的:一个人可能写了一半,另一个人又写了上去,最终得到的可能是乱码,或者某个人的修改被完全覆盖,或者其他谁也说不准的奇怪结果。在CPU核心读写内存的场景下,内存地址就是那个“字”,而CPU核心就是“写字的人”。

为什么是“未定义行为”?

“未定义行为”意味着C++(或者其他语言)标准并没有规定在这种情况下程序应该如何表现。编译器、CPU硬件、操作系统都可能对此有不同的处理方式,并且这些方式在不同的环境下可能会发生改变。这会导致:

不可预测性: 程序可能在某些机器上运行正常,在另一些机器上崩溃,甚至在同一台机器上,同一段代码在不同时间运行结果也可能不同。
难以调试: 由于行为不可预测,你可能很难找到问题的根源。即使你看到一个错误,那也可能只是“症状”,而不是根本原因。
性能陷阱: 有些情况下,为了避免UB,程序员会引入锁或其他同步机制,这会增加开销。但如果误以为某种非同步操作是安全的,结果可能导致意想不到的性能问题或数据损坏。

更详细的解释:幕后的机制

要理解为什么会出现UB,我们需要了解几个关键点:

1. CPU缓存(CPU Cache):现代CPU为了提高性能,会使用多级缓存(L1, L2, L3)。当CPU核心读取一个内存地址时,它会先尝试从自己的缓存中获取数据。如果缓存中没有,才会去主内存(RAM)中读取。写入操作也是类似的,会先写入缓存。

问题所在: 如果两个核心都读取了同一地址的数据到各自的缓存,但随后一个核心修改了该数据。另一个核心的缓存中仍然是旧的数据,它并不知道自己缓存中的数据已经过期。这就是所谓的“缓存不一致性”。

2. 缓存一致性协议(Cache Coherence Protocols):为了解决缓存不一致性问题,CPU硬件实现了一系列缓存一致性协议(如MESI、MOESI等)。这些协议通过特殊的信号在核心之间进行通信,以确保所有核心都能看到内存的最新状态。

当一个核心写入某个内存地址时: 缓存一致性协议会确保:
发送一个“无效化”(Invalidate)信号给其他所有核心,告诉它们缓存中关于该地址的数据已经过时。
或者,如果其他核心也持有该地址的副本,它们需要先将自己的副本写回主内存(如果修改过),或者将自己的副本标记为“已修改”以便后续处理。

3. 原子操作(Atomic Operations):CPU提供了特殊的指令来执行原子操作。原子操作是指一个操作要么完全执行成功,要么完全不执行,不存在中间状态被其他核心看到。例如,“原子读取修改写入”(ReadModifyWrite)操作,比如原子地增加一个计数器。

问题所在: 如果两个核心尝试执行一个 非原子 的读改写序列,例如:
1. 核心A读取地址X的值(比如是5)。
2. 核心B读取地址X的值(也是5)。
3. 核心A计算新值(比如6)并写入地址X。
4. 核心B计算新值(比如7)并写入地址X。

在这种情况下,我们期望的结果可能是先加到6,再加到7,最终是7。但是,实际的执行顺序可能导致:
核心A读取5,核心B读取5。
核心A计算6,核心B计算7。
核心A写入6。
核心B写入7。
最终结果是7,但这是错误的,因为核心B并不知道核心A已经写了6。它应该在核心A写完后才能读取到6,并在此基础上计算。

更糟糕的是,根据缓存一致性协议的细节和执行顺序,可能出现核心A写入6后,核心B的缓存被无效化,然后它再次读取X时读取到的是6(而不是它自己先读到的5),再进行计算,最终可能是8。或者,核心A写入6后,核心B的无效化信号还没有到达,它还在用自己缓存的5计算,然后写回(覆盖了6)。

内存模型(Memory Model)

不同的编程语言和CPU架构定义了不同的 内存模型。内存模型定义了在多线程环境中,对共享内存的访问顺序的可见性规则。

强内存模型(Strong Memory Models):通常会保证读写操作的顺序性,对程序员来说更容易理解和预测。但为了保证顺序性,可能会牺牲一些性能。
弱内存模型(Weak Memory Models):允许编译器和CPU为了性能而重新排序读写操作。这使得程序的行为变得复杂,需要程序员使用特定的同步原语(如内存屏障、锁)来确保操作的正确顺序。

C++标准定义了自己的内存模型,它允许在某些情况下进行重排序。当两个核心同时读写同一内存地址而没有适当的同步时,就违反了C++内存模型对顺序性或可见性的要求,从而导致了未定义行为。

何时不是UB(或者说,如何避免UB)?

正是因为直接的、未经同步的读写同一个内存地址是UB,所以并发编程才需要同步机制:

1. 互斥锁(Mutexes):用锁来保护共享内存区域。一次只有一个核心能获取锁并访问共享数据。
```c++
std::mutex mtx;
int shared_data;

// 在一个核心中
{
std::lock_guard lock(mtx);
shared_data++; // 安全地修改
}

// 在另一个核心中
{
std::lock_guard lock(mtx);
shared_data++; // 安全地修改
}
```
通过锁,确保了只有一个核心在任何时候执行 `shared_data++` 这个读改写序列。

2. 原子类型(Atomic Types):C++11引入了 `std::atomic`。它提供了一系列原子操作,保证了操作的完整性和不可分割性。
```c++
std::atomic atomic_counter = 0;

// 在一个核心中
atomic_counter.fetch_add(1); // 原子地读取、增加、写入

// 在另一个核心中
atomic_counter.fetch_add(1); // 原子地读取、增加、写入
```
`fetch_add` 是一个原子操作,即使多个核心同时调用,也能保证计数器被正确地递增。它在底层会利用CPU提供的原子指令和可能的内存屏障。

3. 内存屏障(Memory Barriers/Fences):在更底层的编程中,可以使用内存屏障来强制指定内存操作的顺序。例如,一个写屏障可以确保在此之前的写操作都对其他核心可见,并且在此之后的读写操作不会被重排序到屏障之前。

总结

当两个CPU核心同时要求读写同一内存地址时,除非使用特定的同步机制(如原子操作或锁)来确保操作的互斥性和顺序性,否则其后果 极大概率是未定义行为。

这并不是因为CPU硬件本身无法“处理”并发访问(它有缓存一致性协议来尝试协调),而是因为在没有明确的协调规则时,并发访问可能导致数据损坏、逻辑错误或不可预测的行为。C++标准将这些情况标记为UB,是为了给编译器和硬件实现者最大的灵活性,允许他们以最优化的方式处理并发,而将保证正确性的责任放在程序员身上,通过使用语言提供的同步工具来实现。因此,任何依赖于特定非同步并发访问结果的程序都是脆弱且不可靠的。

网友意见

user avatar

题主是想问的是CPU核间怎么同步数据吧。先说x86架构下的实现:

早期的Intel CPU(SkyLake)内部有一个高速环形总线:

环形总线的频率等于CPU的主频(最高标称主频,不含睿频),多核间的数据同步就是通过这个环形总线完成的,新一点的CPU的总线设计有变化,改成网状设计的。

一个核心对内存的操作,不加锁(LOCK前缀)的情况下,需要20-50个cycle(指令周期)才能反应到另外一个核心上。

所以

       CPU0:MOV EAX,number CPU1:MOV number,EAX     

两个核心上的EAX并不是你的期望值。

当多个核心同时写一个地址时:

       CPU0:MOV number,EAX CPU1:MOV number,EAX     

并不存在同时写的情况,只是写到当前核心的L1 cache上,再经过20-50个cycle同步到其它核心,广播cache刷新的动作是由环形总线的仲裁机制实现的,目前CPU厂商没有公布具体的仲裁原理,一般来说,只有一个核心的写入动作会成功广播出去,另外一个核心的写操作会被丢弃。

到真实的硬件场景中,你很难做到同时写入,因为不同核心的工作频率可能都是不一样的、指令可能是乱序执行的。

相对于题主说的“未定义”,我更倾向于使用“不确定”来描述这种行为。

如果加了锁(LOCK),那么多核会依次执行对应的指令:

       CPU0: LOCK ADD sum,EAX CPU1: LOCK ADD sum,EAX     

sum会最终加上两次EAX(注意每个核都有自己的EAX)。遇到LOCK指令时,CPU核心会通过环形总线通告其它核心要锁定的总线地址,如果其它核心需要修改对应的地址,就会停下来等待。LOCK前缀通常需要20个cycle才能完成总线的锁定,而一般的MOV指令,则是3-4个cycle左右(内存作为操作数)。

所以上面的这个例子,在Intel的CPU上,需要几十个cycle才能完成。


以上都是硬件层面的例子,一个设计正确的软件,需要使用锁、原子变量等操作来保护多线程编程条件下的数据正确性,这是由程序员自己实现的,如果不使用正确的同步机制,代码的实际运行结果可能不满足期望值


对于非x86架构来说,锁的机制可能完全不同,CPU的乱序方式也不完全一样,甚至很多在x86上工作正常的代码(主要是驱动一类的代码),到了ARM上运行结果就不一样,所以,不加锁的情况下,对同一块内存的访问,不同的硬件结果可能不一样。

user avatar

这问题有意思:

首先,不要学了个ub,就到处拿着ub去套。CPU本质上就是个物理电路,它的一切行为都是由物理定律保证的,不可能有什么ub——哪怕CPU有设计失误,那也是bug,而不是ub。

然后,大多数主流的CPU不会直接对内存进行存取操作(极低端的单片机还有,但那些不可能有多核),都必须是把内存数据加载到缓存中进行访问和操作的。所以,你的问题实际上就转换为另一个业内更标准的问题表述:缓存一致性问题。

接着,多CPU为了解决这个缓存一致性问题,会使用MESI或类似机制(具体自己搜自己学)。这些机制保证了必然只有一个CPU的cache是有效的,其他的则必须放弃cache中的数据并且重新从内存中加载。

最后,LOCK的问题则完全不同。LOCK是明确需要锁总线或者cache line的,很多还默认带有mb的含义。所以,如果连汇编层面的LOCK都保证不了逻辑语义的话,那没有任何上层软件可以保证得了。

user avatar

独立缓存往公共缓存的操作是串行的,不会同时发生。

然后应该没有了,毕竟你一定要过缓存的,不可能直接读写内存。

实际上在计算机领域,绝大多数同时发生的事情最终在指令集层面都会变成串行发生。例如键盘同时按下两个键,由于键盘实际上是周期扫描每个键是否按下然后上报。扫描每个键的时间点不同,所以即便每个键精确的同时按下,最后上报的次序也有先后。

多请求同时操作单个实体的情况下,就算请求有多个来源,往往也是轮询每个来源的请求,然后逐个执行,最终依然还是串行。所以独立缓存往公共缓存读写数据最终也会变成串行操作。

绝大多数情况下,你不用担心指令集层面的并行问题,这是硬件设计会避免的。你只需要考虑软件设计的逻辑层面的并行问题。

类似的话题

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有