目录
- 各类构造函数
- 左值与右值
- std::remove_reference
- 完美转发
- 虚函数
- 纯虚函数
- volatile
- 智能指针
- extern关键字
- const&static
- 大小端
- 地址对齐
- 原子操作&原子性
- 可见性
- 顺序性
- 内存模型
各类构造函数
-
构造函数
用来初始化对象的,若没有显示定义构造函数,有时候编译器会自动帮忙创建默认构造函数,编译器创建的默认构造函数是无参的且是空实现的,关于编译器什么情况下会自动帮忙创建默认构造函数略,一个良好的编程习惯是自己定义构造函数。
注意,构造函数并不是用来创建对象的,是用来给创建的对象进行初始化操作的。
-
析构函数
在对象销毁前调用,用来释放该对象的一些指针所指向的堆空间的(因为对象中的非指针变量在栈中,其实栈中的变量是不需要特意用析构函数来释放的)。若没有显示定义构造函数,有时候编译器会自动帮忙创建默认构造函数,编译器创建的默认构造函数是无参的且是空实现的,关于编译器什么情况下会自动帮忙创建默认构造函数略,一个良好的编程习惯是自己定义构造函数。
如果一个类作为基类,那么其析构函数要声明为虚函数,不然在 "用父类指针指向子类对象,然后delete子类对象"时 子类对象析构函数得不到调用。
-
拷贝构造函数
在①进行对象赋值操作的时候调用,用来初始化对象 ② 如下面代码所示。同样的编译器在某些情况下会自动生成默认拷贝构造函数,需要注意的是,默认拷贝构造函数是浅拷贝。
cpp#include <thread> #include <iostream> using namespace std; class Student{ public: Student(){ cout<<"构造函数"<<endl; } Student(const Student &a){ //用引用传递而不用值传递是防止"无限递归" 用const是为了防止a被修改 cout<<"拷贝构造函数"<<endl; } ~Student(){ cout<<"析构函数"<<endl; } }; int main(int argc,char* argv[]){ Student stu1=Student(); Student stu2=stu1; } /* 执行结果: 构造函数 拷贝构造函数 析构函数 析构函数 */
cpp#include <thread> #include <iostream> using namespace std; class Student{ public: Student(){ cout<<"构造函数"<<endl; } Student(const Student &a){ cout<<"拷贝构造函数"<<endl; } ~Student(){ cout<<"析构函数"<<endl; } }; void test(Student stu){ } int main(int argc,char* argv[]){ Student stu1=Student(); test(stu1); } /* 执行结果: 构造函数 拷贝构造函数 析构函数 析构函数 */
-
移动构造函数
见 左值与右值 章节
-
拷贝赋值函数 与 移动赋值函数
cpp#include <thread> #include <iostream> #include <vector> using namespace std; class Student{ public: Student() { cout<<"构造函数"<<endl; }; virtual ~Student() { cout<<"析构函数"<<endl; }; Student(const Student&){ cout<<"拷贝构造函数"<<endl; } Student& operator=(const Student&){ cout<<"拷贝赋值函数"<<endl; return *this; } Student(const Student&&){ cout<<"移动构造函数"<<endl; } Student& operator=(const Student&&){ cout<<"移动赋值函数"<<endl; return *this; } }; int main(int argc,char* argv[]){ Student stu1; Student stu2; stu2=stu1;//调用拷贝赋值函数 stu2=std::move(stu1);//调用移动赋值函数 }
左值与右值
参考链接
-
左值:可以取地址;右值:不能取地址。一定要以这个标准判断一个值是左值还是右值,比如字符串字面值其实是左值,因为其可以取地址。
-
左值引用:对左值的引用;右值引用:对右值的引用
-
左值引用可以引用左值,也可以引用右值(加const);右值引用只能引用右值
-
&一定是左值引用,&&即可能是右值引用也可能是万能引用(universal references,表示根据不同情况自动决定是左值引用还是右值引用),注意只有在类型需要推导的时候&&才表示万能引用
关于上图的解释:
调用f(a)时,T会被推导为int&,那么其实就是f(int& &¶m),这里进行了一个折叠引用,会被折叠为f(int& param),param是对左值的引用。
调用f(1)时,T会被推导为int,那么其实就是f(int &¶m),param是对右值的引用。
-
折叠引用
- T && &&折叠为T&&
- T & && 折叠为T&
- T && & 折叠为T&
- T & & 折叠为T&
-
右值引用+移动构造函数:实现节省堆内存空间
对于stu1对象,假如我确定之后不会再使用它了,并且我想把其值赋值给一个新的对象stu2,那么其实我可以使用移动构造函数,让stu2接管stu1在堆中的空间,而不是让stu2又重新在堆中开辟一个空间存age。
cppclass Student{ public: int* age; int sex; Student(){ sex=1; age=new int(18); cout<<"构造函数"<<endl; } Student(const Student &stu){ this->sex=stu.sex; this->age=(int*)malloc(sizeof(int));//深拷贝 *(this->age)=*(stu.age); cout<<"拷贝构造函数"<<endl; } Student(Student &&stu){ this->sex=stu.sex; this->age=(int*)malloc(sizeof(int)); this->age=stu.age;//接管stu.age stu.age=nullptr; cout<<"移动构造函数"<<endl; } ~Student(){ cout<<"析构函数"<<endl; } }; int main(int argc,char* argv[]){ Student stu1=Student(); Student stu2(std::move(stu1)); }
std::remove_reference
- 其实就是一个类型提取器,
std::remove_reference<int&&>::type a; 等价于int a;
完美转发
-
什么是完美转发,如下面代码所示
- 我们希望在调用函数时传入的形参是左值,那么在函数内部仍然保持左值;若在函数调用时传入的形参是右值,那么在函数内部仍然保持右值
- 完美转发是通过 万能引用+std::forward函数共同完成的
cpp#include <thread> #include <iostream> #include <vector> using namespace std; void print(int& t) { cout << "int&" << endl; } void print(int&& t) { cout << "int&&" << endl; } template <class T> void testforward(T&& a) { //若testforward的形参是右值,则forward的返回值是右值 //若testforward的形参是左值,则forward的返回值是左值 print(std::forward<T>(a)); } int main(int argc,char* argv[]){ int x=2; testforward(2); //形参传入右值 testforward(x); //形参传入左值 }
-
关于完美转发的实现原理,以上面的代码进行讲解,先贴出std::forward的源码(如下图)
-
当testforward(2)时,T=int,那么即
cppconstexpr int&& forward(int& __t) noexcept { return static_cast<int&&>(__t); } constexpr int&& forward(int&& __t) noexcept { static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" " substituting _Tp is an lvalue reference type"); return static_cast<int&&>(__t); }
当执行forward< T>(a)时,因为a是左值,那么重载到第一个函数也就是forward(int& __t)执行,
返回static_cast<int&&>(a),是一个右值(因为返回的a是右值引用,那么a只能是右值)
-
当testforward(x)时,T=int&,那么即(这里省略了折叠引用的过程)
cppconstexpr int& forward(int& __t) noexcept { return static_cast<int&>(__t); } constexpr int& forward(int&& __t) noexcept { static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" " substituting _Tp is an lvalue reference type"); return static_cast<int&>(__t); }
当执行forward< T>(a)时,因为a是左值,那么重载到第一个函数也就是forward(int& __t)执行(注意,两种情况其实都是执行第一个forward,因为a是左值)
返回static_cast<int&>(a),是一个左值(因为返回的a是左值引用,那么a只能是左值)
-
虚函数
-
准确来说是类的虚函数,因为virtual关键字修饰的函数只能是类的成员函数(注意不能与static一起使用)
-
虚函数的作用是用来实现多态,基类指针可以指向子类,若想通过父类指针调用子类函数,不用virtual关键字是无法实现的,如下方代码的运行结果是Father,只有给Father的test函数加上virtual关键字运行结果才是Son
cpp#include <iostream> using namespace std; class Father { public: void test(){ cout<<"Father"<<endl; } }; class Son : public Father { public: void test(){ cout<<"Son"<<endl; } }; int main() { Father* p = new Son;//若Son* p = new Son;那么运行结果是Son(运行哪个(非虚)函数是在编译使其确定的!) p->test(); return 0; }
-
虚函数表
参考链接- 每个类,只要含有虚函数,new出来的对象就包含一个虚函数指针(8字节),指向这个类的虚函数表(这个虚函数表一个类用一张)
- 子类继承父类,会形成一个新的虚函数表,但是虚函数的实际地址还是用的父类的,如果子类重写了某个虚函数,那么子类的虚函数表中存放的就是重写的虚函数的地址
纯虚函数
- 用virtual void 函数名()=0;在基类中声明一个纯虚函数,那么继承该基类的子类就必须实现该函数
volatile
- volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,都会直接从变量地址中读取数据。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象
- volatile一般在多线程开发中才会用到(单线程好像不需要用到volatile关键字?不确定...)
智能指针
参考链接
extern关键字
-
和extern "C"用在函数前面,表示用C而不是C++的规则去编译该函数。
由于C++支持函数重载而C不支持,所以同一个函数用C和C++编译得到的函数名字不同。
假如有一个用C开发并编译的库文件,这个库文件中 现在要写一个C++代码来调用这个C的库文件中的某个函数,我们在c++文件中声明函数时一定要带extern "C",如果不带的话,用g++编译的时候,会报函数undefined的错误,因为g++编译c++文件中的函数时候将该函数编译成了不同的名字。
cextern "C" void fun(){ }
-
用在变量前,表示该变量在其他文件中定义,如下方代码通过g++ file1.cpp file2.cpp可正确编译,输出结果是2
cpp//file1.cpp int x=2; //file2.cpp int main() { extern int x; std::cout<<x; return 0; }
const&static
-
static
- 修饰全局作用域 变量或函数 ,将其作用域限制在本文件内
- 修饰函数内的局部变量,在第一次访问的时候初始化并直至程序结束其生命周期才结束。
- 修饰类的成员变量或成员函数,将该变量或函数让类持有而不是类的对象持有
static修饰的成员变量不能在类内声明的时候初始化,必须在类外初始化(这点好像是由于历史原因导致的语法,感觉有点奇怪)
-
const
- 修饰变量,表明变量不可以被修改。const修饰的变量必须在声明的时候就初始化(在声明时的赋值操作我们通常称为是初始化)
- 修饰类的成员方法,表明此方法不会更改类对象的任何数据
- const int* x;和int * const x;
const int* x; x可以修改,x指向的内容不能修改
int * const x;x不可以修改,x指向的内容可以修改
-
底层const和顶层const
参考链接- 若是因为const直接修饰这个变量导致其不能修改,则称为top-level const
- 若是因为const间接修饰(比如指针或引用)这个变量导致其不能修改,则称为low-level const
- 对于关于const变量赋值的问题 参考链接
大小端
- 大端:低地址存高字节,高地址存低字节;小端:低地址存低字节,高地址存高字节
- 大小端是由CPU决定的,准确来讲是由指令集决定的(待定)
- 寄存器是不区分大小端的,因为寄存器其实是没用地址的概念的,若非要把寄存器强加地址概念
- 现代CPU一般都是小端序,网络字节序是大端序(即接受到的第一个字节认为是大端)
- 大端小端谁更好???
地址对齐
-
地址对齐规则
-
对于标准数据类型其对齐规则:该数据的首地址必须是m的整数倍,m的取值如下:
1.如果变量的尺寸小于4字节,那么该变量的m值等于变量的长度。
2.如果变量的尺寸大于等于4字节,则一律按4字节对齐。
3.如果变量的m值被人为调整过,则以调整后的m值为准。
-
对于类或结构数据类型其对齐规则
1.中的各个成员,第⼀个成员位于偏移为 0 的位置,以后的每个数据成员的偏移必须是min(#pragma pack()指定的数,数据成员本身长度)的倍数
2.在所有的数据成员完成各⾃对⻬之后,结构体或联合体本身也要进⾏对⻬,整体⻓度是 min(#pragma pack()指定的数,⻓度最⻓的数据成员的⻓度) 的倍数。
-
-
为什么需要地址对齐?
- 根本原因是存储器的物理结构
原子操作&原子性
-
什么是原子操作
首先原子操作在多线程下讨论才有意义,且原子操作要从指令级别去理解。
对于一组指令操作,要么全部执行完,要么一条都不执行,且其他线程看不到中间状态,我们称这组操作叫原子操作,称其具有原子性。
-
c++的互斥锁std::mutex
-
互斥锁
参考链接
参考链接
参考链接
参考链接
参考链接
参考链接下面代码是一个使用mutex的例子,这个例子通过mutex实现了对count变量的原子自增操作。当线程调用mtx.lock()函数时,若mtx未被上锁则线程获得该锁并继续执行,若mtx已被上锁则线程被挂起到阻塞队列;当拥有该锁的线程调用mtx.unlock();函数时,会从阻塞队列中唤醒一个线程。
std::mutex属于不可重入锁(若一个线程连续两次调用lock函数的话,在第二次就会死锁了),而std::recursive_mutex属于可重入锁(若一个线程连续调用lock函数不会导致死锁,但要注意,某个线程调了多少次lock就要调多少次unlock)
cpp#include <mutex> #include <thread> int count = 0; std::mutex mtx; void safe_increment() { mtx.lock(); ++count; mtx.unlock(); } int main() { std::thread t1(safe_increment); std::thread t2(safe_increment); t1.join(); t2.join(); return 0; } //g++ test.cpp -lpthread
mtx.lock()
底层原理:lock cmpxchg指令
其实锁具体而言就是存在内存中的一个变量罢了,mtx类中具体的锁是_M_mutex这个成员变量(假设这个变量在内存中初始化是initial_value也就是未上锁状态,_M_mutex的地址是lock_addr),在执行lock函数上锁的时候(假设我们上锁操作就是将_M_mutex置为set_value),mtx.lock()会被编译成指令:cmpxchg(当然肯定不止被编译成这一条指令,但最关键的是这个cmpxchg指令):
cmpxchg lock_addr,set_value
这条指令的作用是比较eax寄存器中的值(eax是该指令的一个隐藏操作数,执行cmpxchg指令前会执行一条指令先将eax中的值设置为initial_value) 与lock_addr地址处的值是否相等,若相等则将lock_addr地址处写入set_value并将写入成功标志写入状态寄存器中的某个标志位,若不相等则将写入失败标志写入状态寄存器中的某个标志位
需要注意的是,因为单条指令执行过程本身就是不可被中断的,那么其实在单核情况下,上面的
cmpxchg lock_addr,set_value
这条指令完全可以确保原子性了,因为单核情况下多线程是不能真正并行的,是通过时间切片并发的,多个线程之间并不会存在同时执行cmpxchg 指令的情况但在多核情况下,不同线程可能同时运行在多个核中,所以可能同时存在两个线程同时执行cmpxchg指令的情况或错开几个时钟周期执行(每个core都有自己的时钟,cmpxchg执行需要一个指令周期,一个指令周期分为若干个时钟周期),又由于一个core上的线程执行cmpxchg的整个指令周期中不会一直占用总线 ,可能core1读取了lock_addr地址处的数据后把总线让出来了(此时core1还未执行完cmpxchg指令),然后core2又去占用总线读取lock_addr地址处的数据,然后core1将读取到的数据和eax寄存器中的值比较发现相等,于是将set_value写入lock_addr,但core2读取到的是core1写之前的数据,所以这样其实多core之间就无法保证这种既读又写的指令的原子性
所以在多核编译下,cmpxchg都会带上一个lock前缀也就是
lock cmpxchg lock_addr,set_value
这个lock的意思是锁总线,在锁住总线的情况下,那么在某个core执行cmpxchg期间,总线会一直被其占用,从而导致其他core无法执行cmpxchg指令。所以在多核的情况下,std::mutex其实是通过lock cmpxchg
指令来实现原子操作的。mtx.unlock()
这个函数就是将mutex对象的_M_mutex的变量写入initial_value(也就是未上锁状态),对应的指令其实就是一个写指令,一个指令只执行写操作不需要加lock前缀即可保证原子性(因为写的时候core是独占总线的,其他core也拿不到总线使用权)。
补充
在x86下,若一条指有大于1次的内存操作(比如上面的cmpxchg),那么必须要用lock前缀保证其原子性(有些指令省略lock前缀,但执行的时候还是会锁总线,比如xchg指令);若一条指令只有一次内存操作,那该条指令是可以保证原子性的。
-
-
C++的原子变量std::atomic_flag和std::atomic< T >
-
std::atomic_flag
C++提供的最基础的原子变量类型,有两个函数可用:test_and_set()和clear(),具体见下面代码
cpp#include <atomic> #include <iostream> int main() { std::atomic_flag flag=ATOMIC_FLAG_INIT; //一定要用ATOMIC_FLAG_INIT初始化(ATOMIC_FLAG_INIT其实就是0) std::cout<<flag._M_i<<std::endl;//输出0 flag.test_and_set();//这个函数是将flag置为1,并返回flag的旧值 std::cout<<flag._M_i<<std::endl;//输出1 flag.clear();//这个函数是将flag置为0 std::cout<<flag._M_i<<std::endl;//输出1 return 0; }
需要注意的是test_and_set()函数和clear函数可以保证是原子性的,分析见下方。
test_and_set函数
该编译后得到了三条指令如下:
最关键的是xchg指令(这个指令不需要带lock前缀,是默认会锁总线的,因此在多核情况下也可以保证xchg指令的原子性),将寄存器dl(edx的低8位,值是1)和 内存地址rax处(rax是flag._M_i的地址)的值进行交换。也就是将flag._M_i的值写入1了,并通过edx寄存器拿到了flag._M_i的旧值返回(test_and_set函数的返回值就是这样拿到的)
clear函数
其编译后得到如下指令(红框中),最关键的是mov %dl,(%rax)指令,将0写入flag._M_i,因为mov是单次内存操作,所以不加lock前缀即可保证原子性。需要注意是后面还跟了个mfence指令,这个是用来保证可见性的。
atomic_flag是lock free的,因为只用到了指令级别的锁(只用到了指令级别的锁我们仍然称为lock free,大家都是这么叫的)
-
-
std::atomic< T >
std::atomic< T >是C++提供的更高级的原子变量的封装,下面代码给出了常见用法
cpp#include <atomic> #include <iostream> using namespace std; int main() { std::atomic<int> x(9); //初始化一个int原子变量,初始值是9 x.store(2); //原子写入2 cout<<x.load()<<endl; //输出2 load是原子读操作 x.fetch_add(1);//原子加1 cout<<x.load()<<endl;//输出3 x.fetch_sub(3);//原子减1 cout<<x.load()<<endl;//输出0 int new_value=20; int old_value = x.exchange(new_value); //原子交换并得到旧值(这里的交换有点误导人,实际上这里的交换并不是跟new_value交换,函数执行后new_value的值还是20) cout<<old_value<<" "<<x.load()<<" "<<new_value<<endl;//输出0 20 20 int expect_value = 20; int update_value = 30; bool flag=x.compare_exchange_strong(expect_value,update_value); //原子比较交换操作 若x等于expect_value则将update_value写入x并返回true;若不等则将x的值写入expect_value并返回false // bool flag=x.compare_exchange_weak(expect_value,update_value); cout<<flag<<" "<<x.load()<<" "<<expect_value<<" "<<update_value<<endl;//输出1(true) 20 return 0; }
需要注意几点:
compare_exchange_weak和compare_exchange_strong的区别是,
weak在某些架构平台上(不包括x86)是可能执行失败的:若x的值等于expect_value时,可能不会将update_value写入x并返回false(原因暂时没调研),相当于啥也没干。而compare_exchange_strong可以保证一定执行成功
所以在x86上weak和strong并没有啥区别
std::atomic< T >支持的T是有限制的参考链接
且并不是对于所有T都是lock free的参考链接
-
C++的自旋锁
mutex是互斥锁,当线程加锁不成功时,线程会阻塞挂起,而自旋锁的思想是:若线程加锁失败了,还让线程一直尝试加锁(让线程一直跑,不阻塞挂起)
c++没用直接提供自旋锁的接口,需要自己实现,常见的实现方法是用刚刚讲的atomic_flag或atomic< T >来实现自旋锁:
用std::atomic_flag实现自旋锁 (TAS)
cpp#include <atomic> #include <thread> #include <iostream> int count = 0; class SpinLock { public: SpinLock() : flag(ATOMIC_FLAG_INIT) {} void lock() { while (flag.test_and_set()); } void unlock() { flag.clear(); } private: std::atomic_flag flag; }; SpinLock spinLock; void safe_increment() { spinLock.lock(); ++count; spinLock.unlock(); } int main() { std::thread t1(safe_increment); std::thread t2(safe_increment); t1.join(); t2.join(); std::cout<<count; return 0; }
用std::atomic< bool > 实现自旋锁(CAS)
cpp#include <atomic> #include <thread> #include <iostream> int count = 0; class SpinLock { public: SpinLock() : flag(false) {}//初始值为false 表示未上锁 void lock() { bool expect = false; //若flag是期望值false(即未上锁),则将其置为true(上锁) while (!flag.compare_exchange_weak(expect, true)){ expect = false;//这里一定要将expect复原, 因为刚刚讲过 若x不等于expect,则将x的值写入expect_value并返回false } } void unlock() { flag.store(false); } private: std::atomic<bool> flag; }; SpinLock spinLock; void safe_increment() { spinLock.lock(); ++count; spinLock.unlock(); } int main() { std::thread t1(safe_increment); std::thread t2(safe_increment); t1.join(); t2.join(); std::cout<<count; return 0; }
可见性
-
什么是可见性
上面讨论了原子性,在多线程下我们可以利用锁来保证一组操作的原子性,比如用互斥锁
cppstd::mutex mtx; int count=0; mtx.lock(); count++;//锁之间的操作是原子的 mtx.unlock();
但只保证原子性还是不够的,CPU中是有cache的,每个core都有自己的L1 cache,当cache采用write back策略时(先写入cacheline,等cacheline被替换的时候再写回内存),再看上述代码,count++编译成的三条指令(load add store),当执行store时只写到了自己所在core的l1 cache并未写回内存(这三条指令仍然是原子操作,因为这三条指令执行的时候其他指令会被mtx锁总线而无法操作内存),那么当core1执行unlock释放锁后,其他core才能去执行count++(load add store),但由于最新的count值是在core1的cache中而不在内存,其他core去内存中读到的count就不是最新的,我们称最新的count对其他core是不可见的,或称多core之间的缓存是不一致的。
-
如何保证可见性
为了保证可见性,即每个core上线程对内存的读写是可以被其他core观察到的,引入了缓存一致性协议MESI (这里就不展开讲MESI了),我们先不考虑MESI的store buffer和invalid queue(假设暂时没有这两个玩意),那么此时MESI协议可以保证各个core之间对内存的修改都是可以被立即相互观察到的,称MESI通过缓存一致性保证了可见性
-
再讨论lock前缀指令
前面一直说指令前带lock前缀是锁总线,其实这个锁总线的效率是不高的,因为某个core其实只需要读或写某一个内存处的地址,却把整个总线锁住了,若其他core想读或写另一个不同的内存地址也会因为总线被锁住了导致无法读写。其实在引入MESI协议后,大部分情况下lock前缀指令是会锁cache而不是锁总线。
比如core1执行lock cmpxchg(两次内存操作,先读一次,再写一次,设读a写b),读取a到core1的cache中并把a标记为E(独占)状态,并锁住a(应该是锁a所在的cacheline);此时core2也要执行lock cmpxchg指令,于是core2也准备读a,根据MESI协议core2会从core1的cache中读a,但由于a被锁住了,core2就会阻塞住(如果core2读取的是另一个地址那么core2是可以正常执行的,但如果是锁总线core2不管读什么地址都会被阻塞);等core1把cmpxchg指令全部执行完后,就会解锁,那么core2就得以继续执行了。所以在core1执行cmpxchg指令的整个过程中对内存的读写是可以保证原子性的。
需要注意的是,lock前缀指令有时候还是会锁总线的 参考资料,对于无法cache的数据(比如加了volatile关键字)的读写是锁总线的,对于跨cacheline的数据的读写是锁总线的(不过好像x86有split lock可以支持跨cacheline锁,暂时不深究了)
-
store buffer和invalid queue
参考链接上面没有考虑store buffer和invalid queue,若MESI不带考虑store buffer和invalid queue的话是具有强一致性的,即core之间对内存的读写是可以立即被相互看见的,但由于性能原因引入了 store buffer和invalid queue,破坏了强一致性,只能保证最终一致性,也就是 store buffer和invalid queue 刷新后才能保证各个core之间对内存的修改是相互看见的。
我们先不讨论 指令重排(包括编译器重排和cpu重排)
比如下面的代码,若没有store buffer和invalid queue的捣乱,在cpu2执行assert(b== 1)的时候,是可以正确执行的,也就是此时b就是1,而引入了store buffer和invalid queue可能导致执行assert(b==1)的时候b是0,分析如下:
假设此时:c在core1中是E(独占)状态,b在core1中是S(共享),b在core2中是S(共享)
core1执行b=a时,因为b是S状态,会先将b写入store buffer(store buffer过一会才会广播invalid消息并在收到ack后将b写入cache,注意只有当b写入cache时才能被其他core观察到);core1接着执行c=1,由于c在core1中是E状态所以会直接写入到core1的cache中,此时b的最新值还有可能在core1的store buffer
core2执行while(c== 0) continue; 此时core2读到的是最新的c也就是1,会跳出while循环;core2接着执行assert(b == 1),此时b的最新值还有可能在core1的store buffer中还未刷出来,所以就导致了预期之外的执行结果(我们预期此时b应该等于1),为了解决该问题,引入了内存模型(后面讲解)
顺序性
-
指令重排
分为 编译器重排 和 cpu重排,指令重排的目的是为了CPU流水线的高效执行(这里就不展开讨论了),且指令重排可以保证单线程的正确性,也就是对于单线程程序,就算使用了指令重排技术,不会影响程序最终的正确性。比如a == 1 操作和 b == 2操作,把 b == 2重排到 a == 1之前并不会影响最终的结果
-
什么是顺序性
由于指令重排的存在,多线程之间执行的结果可能不会按照我们预期的样子执行,破坏了顺序性。还是以上面的例子来分析
下方代码中,编译器可能会将c==1对应的指令重排到a == 1对应的指令之前,从而先将c赋值为1了,此时core2由于判断到c!=0而跳出while循环进而执行assert(b == 1)语句,但此时b还未被赋值为1,那么assert语句就报错了
-
如何保证顺序性
为了确保顺序性,引入了内存模型,见下章讲解
内存模型
在可见性一章中,我们分析了store buufer和invalld queue会导致可见性被破坏的问题(破坏了强一致性,不能被立即可见)
在顺序性一章中,我们分析了指令重排会破坏顺序性的问题
内存模型可以解决这两个问题,内存模型是一种抽象的概念,用于描述程序中不同线程之间对内存操作的可见性和顺序性
TODO...
================================================================