5.1 内存模型基础

    一个C++程序中所有数据都是由对象构成。不是说创建一个int的衍生类,或者是基本类型中存在有成员函数,或是像在Smalltalk和Ruby语言那样——“一切都是对象”。对象仅仅是对C++数据构建块的声明。C++标准定义类对象为“存储区域”,但对象还是可以将自己的特性赋予其他对象,比如:相应类型和生命周期。

    像int或float这样的对象是基本类型。当然,也有用户定义类的实例。一些对象(比如,数组,衍生类的实例,特殊(具有非静态数据成员)类的实例)拥有子对象,但是其他对象就没有。

    无论对象是怎么样的类型,对象都会存储在一个或多个内存位置上。每个内存位置不是标量类型的对象,就是标量类型的子对象,比如,unsigned short、my_class*或序列中的相邻位域。当使用位域时就需要注意:虽然相邻位域中是不同的对象,但仍视其为相同的内存位置。如图5.1所示,将一个struct分解为多个对象,并且展示了每个对象的内存位置。

    图5.1 分解一个struct,展示不同对象的内存位置

    这里有四个需要牢记的原则:

    1. 每个对象至少占有一个内存位置。
    2. 相邻位域是相同内存中的一部分。

    我确定你会好奇,这些在并发中有什么作用?下面就让我们来见识一下。

    这部分对于C++的多线程来说是至关重要的:所有东西都在内存中。当两个线程访问不同的内存位置时,不会存在任何问题,一切都工作顺利。当两个线程访问同一个内存位置,就要小心了。如果没有线程更新数据,那还好;只读数据不需要保护或同步。当有线程对内存位置上的数据进行修改,那就有可能会产生条件竞争,就如第3章所述的那样。

    为了避免条件竞争,两个线程就需要一定的执行顺序。第一种方式,如第3章所述,使用互斥量来确定访问的顺序;当同一互斥量在两个线程同时访问前被锁住,那么在同一时间内就只有一个线程能够访问到对应的内存位置,所以后一个访问必须在前一个访问之后。另一种是使用原子操作(详见5.2节中对于原子操作的定义),决定两个线程的访问顺序。使用原子操作来规定顺序在5.3节中会有介绍。当多于两个线程访问同一个内存地址时,对每个访问这都需要定义一个顺序。

    如果不规定两个不同线程对同一内存地址访问的顺序,那么访问就不是原子的;并且,当两个线程都是“作者”时,就会产生数据竞争和未定义行为。

    另一个重点是:当程序对同一内存地址中的数据访问存在竞争,可以使用原子操作来避免未定义行为。当然,这不会影响竞争的产生——原子操作并没有指定访问顺序——但原子操作把程序拉回到定义行为的区域内。

    在了解原子操作前,还有一个有关对象和内存地址的概念需要重点了解:修改顺序。

    每个C++程序中的对象,都有(由程序中的所有线程对象)确定好的修改顺序,且在初始化开始阶段确定。大多数情况下,这个顺序不同于执行中的顺序,但在给定的程序中,所有线程都需要遵守这个顺序。如果对象不是一个原子类型(将在5.2节详述),必须确保有足够的同步操作,来确定每个线程都遵守了变量的修改顺序。当不同线程在不同序列中访问同一个值时,可能就会遇到数据竞争或未定义行为(详见5.1.2节)。如果使用原子操作,编译器就有责任去做必要的同步。

    这意味着:投机执行是不允许的,因为当线程按修改顺序访问一个特殊的输入,之后的读操作,必须由线程返回较新的值,并且之后的写操作必须发生在修改顺序之后。同样的,同一线程上允许读取对象的操作,要不就返回一个已写入的值,要不在对象的修改顺序后(也就是在读取后)再写入另一个值。虽然,所有线程都需要遵守程序中每个独立对象的修改顺序,但没有必要遵守在独立对象上的操作顺序。在5.3.3节中会有更多关于不同线程间操作顺序的内容。

    所以,什么是原子操作?它如何来规定顺序?接下来的一节中,会揭晓答案。