C++内存模型
C++内存模型定义多线程中对象的可见性和访问顺序,规范原子操作、防止数据竞争,确保并发程序行为可预测。
C++内存模型
C++内存模型
内存模型涉及两个方面:
- 内存布局(即对象如何映射到内存)
- 并发执行中的内存访问规则
并发编程中,特别是底层的原子操作和内存位置管理非常关键。由于C++中的所有对象都和内存地址紧密相关,因此理解对象与内存位置的关系是基础。
对象和内存位置
- 对象 是C++程序中的基本数据构成单元。
- 标准中定义类对象为“存储区域”,对象可以有成员函数,甚至有子对象(如数组、类的成员)。
- 例如,
int
、float
是基本类型的对象;用户定义的类的实例也是对象。 - 一些对象(数组、类实例、具有非静态数据成员的类实例)包含多个子对象,但其他对象则没有子对象。
内存位置(memory location)的定义:
- 一个对象会占据一个或多个内存位置。
- 每个内存位置可以存储标量类型的对象或其子对象(如
unsigned short
、指针、相邻位域等)。 - 对于相邻位域,虽然它们是不同的对象,但仍视为占用相同的内存位置。
例子:结构体对象的内存分解
假设一个struct
包含以下成员:
1
2
3
4
5
6
7
8
struct Example {
int a;
int bf1 : 4;
int bf2 : 4;
int bf3 : 0; // 宽度为0的位域
int bf4 : 8;
std::string s;
};
- 整个结构体是一个对象,包含多个子对象(成员变量)。
- 位域
bf1
和bf2
共享一个int
的内存位置(4字节/32位)。 - 宽度为0的位域
bf3
用于强制下一个位域bf4
对齐到下一个int
边界,因此bf4
拥有独立内存位置。 std::string s
对象内部可能由多个内存位置组成(如指针和缓冲区等),但对外表现为一个对象。
(注:图中bf3
作为命名的0宽度位域可能是示意用,C++标准中未命名的0宽度位域用于对齐。)
需要牢记的原则
- 每个变量都是对象,包括成员变量本身也是对象。
- 每个对象至少占用一个内存位置。
- 基本类型对象有确定的内存位置,无论大小、是否相邻或数组元素。
- 相邻位域属于相同内存位置的一部分。
对象、内存位置与并发
- 在多线程环境下,线程访问不同内存位置的数据不会产生问题。
- 多个线程访问同一内存位置时必须小心。
- 如果线程仅仅是读取数据,不需要同步。
- 如果至少有一个线程修改该内存位置,且没有同步机制,则会产生数据竞争,导致未定义行为。
如何避免数据竞争?
- 互斥量(mutex)
- 线程在访问共享数据前先锁住互斥量,保证同一时间只有一个线程访问。
- 原子操作
- 对共享数据使用原子类型或原子操作,明确规定访问顺序,保证操作的原子性和内存同步。
如果不规定同一内存地址的访问顺序,那么访问就不是原子的,写写冲突会导致数据竞争和未定义行为。
未定义行为是C++中的“黑洞”,出现未定义行为,程序行为无法预测,可能崩溃、数据损坏,甚至导致硬件异常(极端案例如显示器起火)。
使用原子操作的意义
- 原子操作不会消除竞争产生的可能性,但能将程序限制在定义良好的行为区域内,避免未定义行为。
- 这意味着通过原子操作程序是可预测的,但依然需要设计良好的同步逻辑。
修改顺序(Modification Order)
- 对每个对象,程序执行期间所有线程对该对象的修改都遵守一个全局确定的“修改顺序”。
- 这个顺序在程序初始化阶段确定,但可能与实际执行顺序不同。
- 所有线程必须遵守这个顺序,否则会出现数据竞争和未定义行为。
非原子类型对象的要求
- 如果对象不是原子类型,必须通过同步机制(如锁)确保所有线程遵守修改顺序。
原子类型对象的责任
- 原子操作负责实现同步,使修改顺序被所有线程可见和遵守。
投机执行与修改顺序
- 线程对特殊输入的读写不能乱序(投机执行受限)。
- 后续的读操作必须看到最新写入的值。
- 后续写操作必须发生在前面写操作之后。
其他说明
- 修改顺序是针对单个对象的顺序。
- 不同对象的操作间不要求有全局顺序。
小结:什么是原子操作?如何规定顺序?
- 原子操作是对某个内存位置的操作,保证该操作在并发下不可分割,即不可被中断。
- 原子操作为多线程环境下的共享数据访问提供了可见性和顺序保证。
- 通过原子操作,程序建立了访问同一内存位置的“修改顺序”,避免了数据竞争的未定义行为。
本文由作者按照 CC BY 4.0 进行授权