5.1 内存模型
内存模型在C++中分为两大部分:内存布局和并发。并发的基本结构非常重要,特别是低层原子操作。由于C++中的所有对象都与内存位置有关,我们将从基本结构开始讲解。
5.1.1 对象和内存位置
在C++程序中,数据都是由对象构成的。例如,可以创建一个 int
的衍生类,或者使用具有成员函数的基本类型,甚至像Smalltalk和Ruby那样——“一切都是对象”。对象是对C++数据构建块的声明,C++标准定义类对象为“存储区域”,但对象也可以将自己的特性赋予其他对象。
- 基本类型:如
int
或float
。 - 用户定义类的实例:如自定义的类。
- 复杂对象:如数组、派生类的实例、具有非静态数据成员的类实例等。
无论是哪种类型,都会存储在一个或多个内存位置上。每个内存位置要么是一个标量类型的对象,要么是标量类型的子对象,例如 unsigned short
、my_class*
或序列中的相邻位域。当使用位域时需要注意:虽然相邻位域是不同的对象,但仍被视为相同的内存位置。
图5.1 分解一个 struct
,展示不同对象的内存位置
struct MyStruct {
int bf1 : 3; // 位域bf1
int bf2 : 5; // 位域bf2
int bf3 : 0; // 位域bf3,宽度为0
int bf4 : 7; // 位域bf4
std::string s;
};
- 完整的
struct
是由多个子对象(每一个成员变量)组成的对象。 - 位域
bf1
和bf2
共享同一个内存位置(假设int
是4字节、32位类型)。 std::string
类型的对象s
由内部多个内存位置组成。- 其他成员各自拥有自己的内存位置。
- 宽度为0的位域
bf3
如何与bf4
分离,并拥有各自的内存位置。
四个需要牢记的原则:
- 每个变量都是对象,包括其成员变量的对象。
- 每个对象至少占有一个内存位置。
- 基本类型都有确定的内存位置(无论类型大小如何,即使它们是相邻的,或是数组的一部分)。
- 相邻位域是相同内存中的一部分。
你会奇怪,这些在并发中有什么作用?
5.1.2 对象、内存位置和并发
这部分对于C++的多线程编程至关重要。当两个线程访问不同的内存位置时,不会存在任何问题;当两个线程访问同一个内存位置时,就需要小心处理。
- 只读访问:如果线程不更新数据,只读数据不需要保护或同步。
- 写入访问:当线程对内存位置上的数据进行修改,就可能会产生条件竞争。
为了避免条件竞争,线程需要以一定的顺序执行。有两种主要方式:
- 使用互斥量:通过同一互斥量在两个线程同时访问前锁住,确保在同一时间内只有一个线程能够访问对应的内存位置。
- 使用原子操作:决定两个线程的访问顺序,当多个线程访问同一个内存地址时,对每个访问者都需要设定顺序。
如果不规定对同一内存地址访问的顺序,那么访问就不是原子的。当两个线程都是“写入者”时,就会产生数据竞争和未定义行为。
未定义的行为:是C++中的黑洞。一旦应用中有任何未定义的行为,就很难预料会发生什么事情。数据竞争绝对是一个严重的错误,要不惜一切代价避免它。
另一个重点是:当程序对同一内存地址中的数据访问存在竞争时,可以使用原子操作来避免未定义行为。当然,这不会影响竞争的产生——原子操作并没有指定访问顺序——而原子操作会把程序拉回到定义行为的区域内。
5.1.3 修改顺序
C++程序中的对象都有一个由程序中的所有线程对象在初始化开始阶段确定好的修改顺序。大多数情况下,这个顺序不同于执行中的顺序,但在给定的程序中,所有线程都需要遵守这个顺序。
- 非原子类型:必须确保有足够的同步操作,以确保线程都遵守了修改顺序。当不同线程在不同序列中访问同一个值时,可能会遇到数据竞争或未定义行为。
- 原子类型:编译器有责任去做同步。
因为当线程按修改顺序访问一个特殊的输入时,所以投机执行是不允许的。之后的读操作必须由线程返回新值,并且之后的写操作必须发生在修改顺序之后。虽然所有线程都需要遵守程序中每个独立对象的修改顺序,但没有必要遵守在独立对象上的操作顺序。
示例代码
#include <atomic>
#include <thread>
std::atomic<int> counter(0);
void increment_counter() {
for (int i = 0; i < 1000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(increment_counter);
std::thread t2(increment_counter);
t1.join();
t2.join();
std::cout << "Counter: " << counter.load() << std::endl;
}
在这个示例中,std::atomic<int>
确保了对 counter
的访问是线程安全的,并且通过 fetch_add
和 load
方法保证了修改顺序。
注意:虽然 memory_order_relaxed
不提供顺序保证,但它确保了操作的原子性,从而避免了数据竞争。
了解了对象和内存地址的概念后,接下来我们来看什么是原子操作以及如何规定顺序。
以上内容展示了如何使用对象、内存位置和并发来管理多线程程序中的同步问题。希望这些示例和解释能帮助你更好地理解和应用这些同步机制。
以下是经过优化排版后的5.2节内容,详细解释了C++中的原子操作和原子类型。每个部分都有详细的注释和结构化展示。
5.2 原子操作和原子类型
5.2.1 标准原子类型
原子操作是指不可分割的操作,系统的所有线程中不可能观察到原子操作完成了一半。如果读取对象的加载操作是原子的,那么这个对象的所有修改操作也是原子的,因此加载操作得到的值要么是对象的初始值,要么是某次修改操作存入的值。
另一方面,非原子操作可能会被另一个线程观察到只完成一半。如果这个操作是一个存储操作,那么其他线程看到的值可能既不是存储前的值,也不是存储的值。如果非原子操作是一个读取操作,可能先取到对象的一部分,然后值被另一个线程修改,然后再取到剩余的部分,所以它取到的既不是第一个值,也不是第二个值。这就构成了数据竞争(见5.1节),出现未定义行为。
标准库中的原子类型
标准原子类型定义在头文件 <atomic>
中。这些类型的操作都是原子的,语言定义中只有这些类型的操作是原子的,也可以用互斥锁来模拟原子操作。
-
is_lock_free() 成员函数:几乎所有的原子类型都有一个
is_lock_free()
成员函数,可以让用户查询某个原子类型的操作是否直接使用了原子指令(x.is_lock_free()
返回true
),还是内部使用了一个锁结构(x.is_lock_free()
返回false
)。 -
无锁状态宏:C++17 中,所有原子类型有一个静态常量成员变量
is_always_lock_free
,如果相应硬件上的原子类型是无锁类型,则返回true
。例如:std::atomic<int> counter; if (counter.is_always_lock_free) { // 该平台上的 std::atomic<int> 是无锁的 }
-
宏定义:编译时对各种整型原子操作是否无锁进行判别,如
ATOMIC_BOOL_LOCK_FREE
,ATOMIC_CHAR_LOCK_FREE
等。如果原子类型是无锁结构,值为 2;如果是基于锁的实现,值为 0;如果无锁状态在运行时才能确定,值为 1。
特殊的原子类型
-
std::atomic_flag:这是一个简单的布尔标志,并且在这种类型上的操作都是无锁的。初始化后,可以使用
test_and_set()
和clear()
成员函数进行查询和设置。std::atomic_flag f = ATOMIC_FLAG_INIT; f.clear(std::memory_order_release); // 清除标志 bool