1. C++基础
1.1 指针和引用的区别
- 指针存放某个对象的地址,其本身就是变量(命了名的对象),本身就有地址,所以可以有指向指针的指针;可变,包括其所指向的地址的改变和其指向的地址中所存放的数据的改变
- 引⽤就是变量的别名, 从⼀⽽终,不可变,必须初始化
- 不存在指向空值的引⽤,但是存在指向空值的指针
- 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
1.2 C++ 中的指针参数传递和引用参数传递 ⭐
指针参数传递本质上是值传递,它所传递的是一个地址值。值传递过程中,被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本。值传递的特点是,被调函数对形参的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值实参指针不会变)。但我们可以通过解引用*
来修改指针所指向对象的值。
引用参数传递过程中,被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。
引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),而引用对象则不能修改。
1.3 关键字
1.3.1 const⭐
const
的作用:被它修饰的值不能改变,是只读变量,且必须在定义的时候就给它赋初值。- 作用域:由声明位置决定
- 存储期:不会影响变量的存储期
需要注意的是:
常量指针(
const int* a = &temp
或int const *a = &temp
,也叫底层const
)表示这个指针指向⼀个只读的对象,不能通过常量指针来改变这个对象的值 ,但我们可以改变这个指针所指向的对象。指针常量(
int* const p = &temp
,也叫顶层cons
t)表示这个指针的值只能在定义时初始化,其他地⽅不能改变。指针常量强调的是指针的不可改变性,即我们不能改变这个指针所指向的对象,但可以改变所指向对象的值。const 在类中的用法:
const 成员变量
,只在实例的生命周期内是常量,而对于整个类而言是可以改变的。因为类可以创建多个实例,不同的实例其 const 数据成员值可以不同。所以不能在类的声明中初始化 const 数据成员,因为类的对象在没有创建时候,编译器不知道 const 数据成员的值是什么。const 数据成员的初始化只能在类的构造函数的初始化列表中进行。const 成员函数:const 成员函数的主要目的是防止成员函数修改对象的内容。要注意,const 关键字和 static 关键字对于成员函数来说是不能同时使用的,因为 static 关键字修饰静态成员函数不含有 this 指针,即不能实例化,const 成员函数又必须具体到某一个函数:
this
指针冲突:当const
修饰成员函数时,C++ 编译器为确保函数不会修改对象状态,会在函数中隐式添加一个this
指针(类名 const * const this
,this指针既不能改变指向对象,也不能改变指向对象的值),这个指针指向调用函数的对象(类的实例),且既不能改变指向对象的地址,也不能通过它修改对象的值(实例的成员变量)。而static
修饰的成员函数属于类本身,并不属于类的某个对象,调用时不依赖类的实例,所以没有this
指针 。两者在this
指针的处理上是冲突的。- 语意矛盾:
static
修饰成员函数时,表明该函数只作用于类的静态变量,和类的实例没有关系;而const
修饰成员函数是为了保证函数不会修改类实例的状态,关注的是类的实例,和类的静态变量无关。二者含义相互矛盾,所以不能同时使用。
const 修饰类对象,定义常量对象:常量类对象只能调用常量函数,别的成员函数都不能调用。
- 原因:常量类对象一旦创建,里面的值(成员变量)就不能被修改。比如一个 “不可修改的学生” 对象,它的姓名、年龄等信息都不能被改变。而在
非const成员函数
中,函数内部有可能改变对象的值,这对常量对象是不允许的,所以常量对象不能调用非const
成员函数,只能调用const成员函数
。 - const 成员函数中如果实在想修改某个变量,可以使用 mutable 进行修饰成员变量。成员变量中如果想建立在整个类中都恒定的常量,应该用类中的枚举常量来实现或者 static const。
- 原因:常量类对象一旦创建,里面的值(成员变量)就不能被修改。比如一个 “不可修改的学生” 对象,它的姓名、年龄等信息都不能被改变。而在
1.3.2 static⭐
static
关键字主要用于控制变量和函数的生命周期、作用域以及访问权限。
- 修饰局部变量:一般情况下,对于局部变量在程序中是存放在栈区的,并且局部的生命周期在包含语句块执行结束时便结束了。但是如果用 static 关键字修饰的话,该变量便会存放在静态数据区,其生命周期会一直延续到整个程序执行结束。但是要注意的是,虽然用static 对局部变量进行修饰之后,其生命周期以及存储空间发生了变化,但其作用域并没有改变,作用域还是限制在其语句块。
- 修饰全局变量:对于一个全局变量,它既可以在本文件中被访问到,也可以在同一个工程中其它源文件被访问(添加 extern进行声明即可)。用 static 对全局变量进行修饰改变了其作用域范围,由原来的整个工程可见变成了本文件可见。
- 修饰函数:用 static 修饰函数,情况和修饰全局变量类似,也是改变了函数的作用域。
- 修饰成员函数:属于类而不是类的实例,可以通过类名直接调用,而无需创建对象。静态成员函数不能直接访问非静态成员变量或非静态成员函数。
- 修饰类:如果C++ 中对类中的某个函数用 static 修饰,则表示该函数属于一个类而不是属于此类的任何特定对象;如果对类中的某个变量进行 static 修饰,则表示该变量以及所有的对象所有,存储空间中只存在一个副本,可以通过
::
类和对象去调用。- 静态非常量数据成员,其只能在类外定义和初始化,在类内仅是声明 。static 修饰的变量先于类的对象存在,所以 static 修饰的变量要在类外初始化;
- static 成员函数不能被 virtual 修饰,static 成员不属于任何对象或实例,所以加上 vrtual 没有任何实际意义;静态成员函数没有 this 指针,虚函数的实现是为每一个对象分配一个 vptr 指针,而 vptr 是通过 this 指针调用的,所以不能加 virtual;虚函数的调用关系,
this->vptr->ctable(虚表)->virtual function
。
1.3.3 define 和 typedef 的区别⭐
含义与功能:
define
在C 和 C++ 编译的预处理阶段起作用,主要用于进行简单的文本替换,也称为宏定义,没有类型检查。它可以用来定义常量、函数式宏等。例如,#define PI 3.14159
定义了一个名为PI
的宏,在预处理阶段,所有出现PI
的地方都会被替换为3.14159
。define 定义的宏不会分配内存,给出的是立即数,代码中有多少地方使用宏就会进行多少次替换。typedef
是 C 和 C++ 中的关键字,用于为已存在的数据类型定义一个新的别名,用来增强代码的可读性和可维护性。例如,typedef int MyInt;
定义了MyInt
作为int
类型的别名,之后就可以使用MyInt
来声明变量,就像使用int
一样。但
typedef
定义的别名与原类型在本质上是同一种类型,在编译时会进行严格的类型检查,主要在编译、运行时起作用。在静态存储区中分配空间,在程序运⾏过程中内存中只有⼀个拷贝。
1.3.4 inline⭐
当函数被声明为
inline
时,编译器会尝试在调用该函数的地方将函数代码直接展开,而不是进行常规的函数调用操作。这样可以避免函数调用时保存现场、传递参数、跳转执行等操作带来的开销,提高程序的执行效率。常规函数调用时,先按值传递、指针传递或引用传递方式,将实参传给形参,在栈上为参数分配空间并处理数据;
接着保存当前程序执行状态,包括程序计数器(记录程序执行到的位置)和寄存器的值(上下文),把这些信息压入栈(这些信息将在函数执行完毕后用于恢复程序的执行状态,以便继续执行调用函数之后的代码);
然后修改程序计数器,跳转至被调用函数入口执行函数体,函数体内处理参数、定义和使用局部变量;执行完毕,按返回类型把返回值存到指定位置,再从栈中弹出之前保存的寄存器值和程序计数器值,恢复到调用前状态,最后依据恢复后的程序计数器值,继续执行调用函数之后的代码 。
在 C++ 中,如果在多个源文件中定义了同名的全局变量,链接时会出现冲突。而使用
inline
修饰全局变量,就可以在多个源文件中都定义这个变量,但实际上只会有一份变量的实例被创建(但要求所有定义必须一致,不然编译器无法确定使用哪个值),链接器会将这些同名的inline
变量合并为一个。
1.3.5 const和define的区别⭐
const
用于定义常量;而define
用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:
const
生效于编译的阶段;define
生效于预处理阶段。const
定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define
定义的常量,运行时是直接2的操作数,并不会分配内存const
定义的常量是带类型的;define
定义的常量不带类型。
1.3.6 new 和 malloc的区别 ⭐
new
内存分配失败时,会抛出bac_alloc
异常,它不会返回NULL
;malloc
分配内存失败时返回NULL
。使用new操作符申请内存分配时无须指定内存块的大小,而
malloc
则需要显式地指出所需内存的尺寸。opeartor new /operator delete
可以被重载,而malloc/free
并不允许重载,因为new/delete
是操作符,而malloc/free
是库函数。new/delete
会调用对象的构造函数/析构函数以完成对象的构造/析构,而malloc
则不会。所以new
和delete
的执行都是两步,调用malloc()
分配空间->调用构造函数;或者调用析构函数->调用free()
回收空间。new
操作符从自由存储区(当程序运行时,操作系统会为程序分配一块堆内存空间,自由存储区就存在于这个堆空间之中,但只有new
才能分配)上为对象动态分配内存空间,而malloc
函数从堆上动态分配内存。为什么有了
malloc/free
还需要new/delete
?针对非内部数据类型而言,比如自定义的类,它们在创建时不仅需要分配内存,还必须自动执行构造函数来完成初始化工作;在对象生命周期结束时,需要执行析构函数来释放资源、清理内存等。
malloc/free
作为库函数,并不在编译器的直接控制范围内,编译器无法强制它们在分配和释放内存时自动调用构造函数与析构函数。而new/delete
是 C++ 的运算符,编译器能够对其进行有效管控,从而确保在创建和销毁对象时,构造函数与析构函数得以正确执行,满足了动态对象全面的管理需求,所以new/delete
对于 C++ 的面向对象编程至关重要。
new/delete 时,编译器底层做了什么操作?
在C++中,当我们调用 new/delete 运算符进行对象创建、销毁以及内存分配、释放时,通常包含以下两阶段操作:
- 对
operator new
来说,编译器首先会调用底层的库函数malloc()
进行内存分配,然后调用对象的构造函数进行对象内容的构造 - 对
operator delete
来说,编译器首先会调用对象的析构函数进行对象内容的销毁,然后调用底层库函数free()
进行内存的释放
1.3.7 const和volatile的区别⭐
作用:
- **
const
**:表示常量,用于修饰变量、函数参数和函数返回值等,表明被修饰的对象的值在初始化后不能被修改,是一种编译时的限定。使用const
可以让编译器进行类型检查,防止意外地修改常量值,增强程序的健壮性和可读性。 volatile
:表示易变的,用于告诉编译器,被修饰的变量可能会在程序的控制流之外被改变,即变量的值可能会在编译器意想不到的情况下发生变化,比如来自硬件的异步操作或多线程环境中的其他线程。这会使编译器不对该变量进行优化,以确保每次访问该变量时都从内存中读取最新的值,而不是使用可能已经缓存的旧值(寄存器)。
内存访问:
const
:对于const
修饰的变量,如果编译器能够确定其值不会改变,可能会将其缓存在寄存器中,以提高访问速度。因为编译器认为在程序执行过程中该值始终保持不变,所以可以直接从寄存器中读取,而不需要每次都从内存中获取。volatile
:volatile
修饰的变量则相反,编译器会严格按照从内存中读取和写入的方式来处理对该变量的访问,即使编译器可能认为从优化的角度可以使用缓存的值,也会强制去内存中获取最新的值,以保证程序能够正确处理变量的意外变化。
举例:
1 | for(volatile int i=0; i<100000; i++){}; // 它会执⾏,不会被优化掉 |
如果不用 volatile 进行修饰,编译器会认为这段代码是空循环,会将其优化。
1.3.8 前置++与后置++
运算顺序:
- **前置 ++**:先将变量的值增加 1,然后再使用变量的新值进行表达式的计算。例如在
int a = 5; int b = ++a;
中,先将a
的值自增为 6,然后再将a
的新值 6 赋给b
,最终a
和b
的值都为 6。 - **后置 ++**:先使用变量原来的值进行表达式的计算,然后再将变量的值增加 1。例如在
int a = 5; int b = a++;
中,先将a
的值 5 赋给b
,然后a
的值再自增为 6,最终a
的值为 6,b
的值为 5。
返回值:
- 前置 ++:返回的是自增后变量的引用。这意味着可以将前置自增的结果继续作为左值使用,即可以对其进行赋值等操作。例如
int a = 5; (++a) = 10;
是合法的,最终a
的值为 10。 - 后置 ++:返回的是变量自增前的原始值的副本。由于返回的是副本,所以后置自增的结果不能作为左值使用。例如
int a = 5; (a++) = 10;
是不合法的,会导致编译错误。
举例:
1 | double arr[5] = {21.2, 32.8, 23.4, 2, 47.43}; |
1.3.9 explicit⭐
C++中的explicit
关键字只能用于修饰只有一个参数的类构造函数,它的作用是表明该构造函数是显式的,而非隐式的,跟它相对应的另一个关键字是implicit
,意思是隐藏的,类构造函数默认情况下即声明为implicit
(隐式)。
这种隐式转换可能导致意外的对象构造,尤其是在无意识的情况下发生(如参数传递、赋值等)。
假如有如下类:
1 | class CxString // 没有使用explicit关键字的类声明, 即默认为隐式声明 |
我们对其进行测试:
1 | CxString string1(24); // 这样是OK的, 为CxString预分配24字节的大小的内存 |
上面的代码中, "CxString string2 = 10"
这句为什么是可以的呢? 在C++中,如果的构造函数只有一个参数时, 那么在编译的时候就会有一个缺省的转换操作:将该构造函数对应数据类型的数据转换为该类对象。 也就是说 "CxString string2 = 10;"
这段代码,编译器自动将整型转换为CxString
类对象,实际上等同于下面的操作:
1 | CxString string2(10); |
不过注意,只有当该变量类型和只有一个参数构造函数形参的类型相同时才能发生隐式转换。
1.3.10 final 和 override⭐
借由虚函数实现运行时多态,但C++的虚函数又很多脆弱的地方:
- 无法禁止子类重写它。可能到某一层级时,我们不希望子类继续来重写当前虚函数了。
- 容易不小心隐藏父类的虚函数。比如在重写时,不小心声明了一个签名不一致但有同样名称的新函数。
C++11 提供了 final
来禁止虚函数被重写/禁止类被继承,override
来显式地重写虚函数。这样编译器给我们不小心的行为提供更多有用的错误和警告。
1.3.11 constexpr和const的区别
const
的特性
- 运行时常量:
const
修饰的变量可以在运行时初始化,一旦初始化就不能再修改。 - 修饰对象广泛:
const
既能修饰变量,也能修饰函数参数、返回值、成员函数等。
constexpr
的特性
- 编译时常量:
constexpr
修饰的变量或者函数,其值在编译时就能确定。这有助于编译器在编译阶段进行优化。 - 用途有限:
constexpr
主要用于修饰变量和函数,以确保其在编译时可求值。
区别总结:
- 初始化时间:
const
变量可以在运行时初始化,而constexpr
变量必须在编译时初始化。 - 编译器优化:由于
constexpr
变量在编译时就已确定值,编译器可以进行更多优化。 - 使用场景:
const
常用于确保变量在初始化后不会被修改,而constexpr
主要用于模板编程、数组大小定义等需要编译时常量的场景。
3. 代码示例
1.4 C和C++的区别(函数/类/struct/class)⭐
- 语言性质:C是面向过程编程,而C++是面向对象编程
- 类和对象:
- C语言:没有类和对象的概念,主要通过结构体来组织数据和相关操作函数,但结构体只是数据的集合,不具备面向对象编程中类的封装、继承和多态等特性。默认成员访问权限和继承权限是
public
。 - C++:引入了类的概念,将数据和操作数据的方法封装在类中,通过创建类的对象来访问和操作这些数据和方法,支持面向对象编程的三大特性:封装、继承和多态。默认成员访问权限和继承权限是
private
。
- C语言:没有类和对象的概念,主要通过结构体来组织数据和相关操作函数,但结构体只是数据的集合,不具备面向对象编程中类的封装、继承和多态等特性。默认成员访问权限和继承权限是
- 函数重载:⭐
- C 语言:不支持函数重载,并且没有虚函数的概念。
- **C++**:支持函数重载,且支持多态。
- C不支持重载的原因:因为C++ 函数的名字修饰与C不同,C++ 函数名字的修饰会将参数加在后面,例如,int func(int,double)经过名字修饰之后会变成_func_int_double,这样即使同名函数但在编译器编译和链接时都会有唯一一个内部名称用于分别重载函数;而C中则会变成 func,所有同名函数共用一个名称,所以 C++ 中会支持不同参数调用不同函数。
1.5 说⼀下 C++ 是怎么定义常量的?常量存放在内存的哪个位置?⭐
定义常量一般使用两种方式:const
关键字修饰或#define
预处理指令。
- 全局常量和静态常量:全局作用域中定义的常量以及类中的静态常量,如果是基本数据类型且值在编译时可确定,通常会存储在全局静态区(.data)中。这部分内存区域在程序运行期间是只读的,防止程序意外修改常量的值。
- 局部常量:一般存储在栈区,当超出作用域范围后,栈上的空间会被释放。
- 用
new
动态分配的常量:通过new
关键字动态分配内存创建的常量,会存储在堆区中的自由存储区中。
1.6 C++ 中重载、重写和重定义的区别 ⭐
- 重载:指同⼀可访问区内被声明的⼏个具有不同参数列表的同名函数,依赖于 C++函数名字的修饰会将参数加在后⾯(每个函数都有自己的内部名称,即使外部同名,但内部名字仍然不同),可以是参数类型,个数,顺序的不同。根据参数列表决定调⽤哪个函数,重载不关⼼函数的返回类型。
- 重写:派生类中重新定义父类中除函数体外完全相同的虚函数,注意被重写的函数不能是 static 的,一定要是虚函数(static和virtual不能共用,static没有this指针,而虚函数的调用需要this指针),且其他一定要完全相同。要注意,重写和被重写的函数是在不同的类当中的,重写函数的访问修饰符是可以不同的,尽管 virtual 中是 private 的,派生类中重写可以改为 public。
- 重定义(隐藏):派生类重新定义父类中相同名字的非 virtual 函数;参数列表和返回类型都可以不同,即父类中除了定义成 virtual 且完全相同的同名函数才不会被派生类中的同名函数所隐藏(重定义)。
1.7 C++ 的四种强制转换⭐
C++ 的四种强制转换分别是static_cast
、reinterpret_cast
、const_cast
和dynamic_cast
:
static_cast
:用于具有明确定义的类型转换,在使用时最好明确指出类型转换,如基本数据类型之间的转换,以及有继承关系的类之间的指针或引用转换。但不能用于去除const
等常量属性,也不能在不相关的类型之间进行转换,比如指针和int,两个没有继承关系的类之间就不可以转换。上行转换(派生类->基类)安全,下行转换(基类->派生类)不安全。dynamic_cast
:主要用于在有继承关系的类之间进行安全的向下转型,即从基类指针或引用转换为派生类指针或引用。它在运行时进行类型检查,如果转换失败,对于指针类型会返回nullptr
,对于引用类型会抛出std::bad_cast
异常,常用于多态场景下确定对象的实际类型。reinterpret_cast
:主要用于将一种指针类型转换为另一种完全不同的指针类型,或者将整数类型与指针类型进行转换,它是最底层的、最不安全的转换方式,可能会导致不可预测的结果,应谨慎使用。比如将int*
转成char*
。const_cast
:专门用于去除对象的const
或volatile
属性,使原本只读的对象可以被修改,但如果对一个真正的常量进行这种操作,会导致未定义行为。常用于函数需要修改传入的const
对象的场景。
1.8 函数指针 ⭐
从定义和用途两方面来说一下自己的理解:
- 首先是定义:函数指针是指向函数的指针变量。函数指针本身首先是一个指针变量,该指针变量指向一个具体的函数。这正如用指针变量可指向整型变量、字符型、数组一样,这里是指向函数,存储函数的地址。
在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样,在这些概念上是大体一致的。 - 其次是用途:调用函数和做函数的参数,比如回调函数。
1 | int add(int a, int b) { |
注意区分函数指针和指针函数的区别:前者是指向函数的指针变量 ,后者是⼀个返回指针类型的函数,⽤于返回指向某种类型的数据的指针 。
1.9 封装、继承、多态 、虚函数表、联编、纯虚函数⭐⭐⭐
设计模式(1)——面向对象和面向过程,封装、继承和多态 | 爱吃土豆的个人博客
1.10 编译器如何处理虚函数表
对于派生类来说,编译器建立虚函数表一共有三个步骤:
第一步:拷贝基类虚函数表:
若派生类是单继承,编译器会直接拷贝基类的虚函数表;要是多继承的情况,编译器会拷贝每个含有虚函数的基类的虚函数表。这里存在一种特殊情形,即可能有某个基类的虚函数表和派生类自身的虚函数表共用一个,此基类被称作派生类的主基类。
第二步:替换重写的虚函数地址:
编译器会检查派生类中是否存在重写基类虚函数的情况。若有,就会把虚函数表中对应基类虚函数的地址,替换为派生类中重写后的虚函数地址。这一操作能保证在运行时,当通过基类指针或引用调用虚函数时,实际调用的是派生类中重写后的版本。
第三步:追加派生类自身的虚函数:
编译器会查看派生类是否定义了自身特有的虚函数。若存在,就会将这些派生类自身的虚函数地址追加到派生类的虚函数表中。如此一来,当调用这些派生类特有的虚函数时,就能正确地找到对应的函数实现。
1.11 析构函数⼀般写成虚函数的原因 ⭐
为了降低内存泄漏的可能性。
举例来说就是,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构,造成内存泄漏。
如果基类的析构函数定义成虚函数,那么编译器就可以根据实际对象,执行派生类的析构函数,再执行基类的析构函数,成功释放内存。
1.12 构造函数为什么⼀般不定义为虚函数⭐
虚函数调用只需要知道“部分的”信息,即只需要知道函数接口,而不需要知道对象的具体类型。但是,我们要创建一个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;
而且从目前编译器实现虚函数进行多态的方式来看,虚函数的调用是通过实例化之后对象的虚函数表指针来找到虚函数的地址进行调用的,如果说构造函数是虚的,那么虚函数表指针则是不存在的(因为构造函数是虚的,那么同样需要虚函数表来进行调用,但是虚函数表只有在调用构造函数之后才会生成,自相矛盾了),无法找到对应的虚函数表来调用虚函数,那么这个调用实际上也是违反了先实例化后调用的准则。
1.13 构造函数或析构函数中调用虚函数会怎样⭐
在派生类构造函数或析构函数中调用虚函数不会展现出多态特性。
- 当在构造函数中调用虚函数时,调用的是当前构造函数所属类的虚函数版本,而不是派生类重写的版本。这是因为在构造派生类对象时,会先调用基类的构造函数,此时派生类部分还未完全构造好(派生类的虚函数表还未形成,无法调用派生类的虚函数),编译器只能保证基类部分是有效的,所以调用虚函数时只能调用基类的版本
- 在析构函数中调用虚函数,调用的同样是当前析构函数所属类的虚函数版本。这是因为在销毁派生类对象时,会先调用派生类的析构函数,之后再调用基类的析构函数,当进入基类析构函数时,派生类部分已经被销毁,此时只能调用基类的虚函数版本。
在基类的构造函数中是无法调用的,因为基类此时还未构造,虚函数表还未生成,无法调用。
1.14 构造函数的析构函数的执行顺序
构造函数顺序:
- 首先调用基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成初始化表中的顺序。
- 然后调用成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它出现在成员初始化表中的顺序。
- 最后调用派生类构造函数。
析构函数顺序:
- 调用派生类的析构函数;
- 调用成员类对象的析构函数;
- 调用基类的析构函数。
1.15 深拷贝和浅拷贝⭐
当出现类的等号赋值时,会调用拷贝函数,在未定义显示拷贝构造函数的情况下,系统会调用默认的拷贝函数,即浅拷贝,它能够完成成员的一一复制。当数据成员中没有指针时,浅拷贝是可行的。
但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针指向同一个地址,当两个对象共享同一个指针,其中一个对象析构时释放了内存,另一个对象的指针就变成了悬空指针。另外,如果两个对象都尝试释放同一个内存,就会导致重复释放。还有,如果其中一个对象修改了指针指向的内容,另一个对象也会受到影响,因为它们指向同一块内存。
所以,这时必需采用深拷贝。深拷贝与浅拷贝之间的区别就在于深拷贝会在堆内存中另外申请空间来存储数据,从而也就解决来野指针的问题。简而言之,当数据成员中有指针时,必需要用深拷贝更加安全。
当然,我们也可以通过智能指针管理对象,智能指针会自行管理内存。
浅拷贝:只复制对象的值或引用,多个对象共享同一个动态分配的内存空间,修改其中一个对象的数据会影响到另一个对象。
深拷贝:复制对象本身的值,并且对对象引用的内存进行递归复制,确保每个对象拥有独立的内存,修改其中一个对象的数据不会影响另一个对象。
1.16 什么情况下会调用拷贝构造函数(三种情况)
类的对象需要拷贝时,拷贝构造函数(不是默认构造)将会被调用,以下的情况都会调用拷贝构造函数:
- 一个对象以值传递的方式传入函数体,需要拷贝构造函数创建一个临时对象压入到栈空间中。
- 一个对象以值传递的方式从函数返回,需要执行拷贝构造函数创建一个临时对象作为返回值。
- 一个对象需要通过另外一个对象进行初始化。
1.17 为什么拷贝构造函数必须加const &?⭐
为了防止递归调用。当一个对象需要以值方式进行传递时,编译器会生成代码调用它的拷贝构造函数生成一个副本,如果类A的拷贝构造函数的参数不是引用传递,而是采用值传递,那么就又需要为了创建传递给拷贝构造函数的参数的临时对象,而又一次调用类 A的拷贝构造函数,这就是一个无限递归。
1) 拷贝构造函数的标准写法: 通常情况下,拷贝构造函数的声明为:
1 | ClassName(const ClassName& other); |
这样可以避免在传递对象时进行额外的拷贝操作,因为参数是以引用的方式传递的
2) 传值方式的拷贝构造函数: 如果不加 &,而是像这样声明:
1 | ClassName(ClassName other); |
这意味着拷贝构造函数的参数是按值传递的。按值传递要求创建一个临时的拷贝,这就需要再次调用拷贝构造函数来创建这个参数的拷贝
无限递归: 当拷贝构造函数以按值传递的方式声明时,每次调用拷贝构造函数都会再次调用自己来创建参数的拷贝,导致无限递归。这样会导致程序崩溃(栈溢出)
自己拷贝自己:此外,在实现拷贝赋值运算符时,如果对象不小心将自己作为右值传入,就会发生“自己拷贝自己”的情况。常见的做法是在赋值前检查是否是“自己拷贝自己”,如下所示:
1 | ClassName& operator=(const ClassName& other) { |
1.18 预处理,编译,汇编,链接程序的区别⭐
一段高级语言代码需要经过四个阶段的处理,最终形成可执行的目标二进制代码。这四个阶段依次为:预处理器→编译器→汇编器→链接器。
- 预处理阶段:当我们编写好如
hello.c
这样的高级语言程序文本后,预处理器会依据以#
开头的命令对原始程序进行修改。例如,#include <stdio.h>
指令会将系统中的头文件插入到程序文本里,预处理后的文件通常以.i
结尾。 - 编译阶段:编译器会把
hello.i
文件翻译成文本文件hello.s
,这是一个汇编语言程序。需要注意区分概念,高级语言编写的是源程序,而汇编语言程序的每条语句都以标准的文本格式精确描述一条低级机器语言指令。不同的高级语言经过编译后可能会生成相同的汇编语言程序。 - 汇编阶段:汇编器会把
hello.s
文件翻译成机器语言指令,并将这些指令打包成可重定位目标程序,也就是.o
文件。hello.o
是一个二进制文件,其字节码为机器语言指令,不再是字符形式,而前两个阶段处理后的文件仍包含字符。 - 链接阶段:以
hello
程序为例,若它调用了printf
函数,这是每个 C 编译器都会提供的标准库 C 中的函数,该函数存在于一个单独编译好的目标文件printf.o
中。链接器的作用就是将printf.o
以某种方式合并到hello.o
中,最终得到可执行目标文件。
1.19 动态编译与静态编译 ⭐
静态编译:
- 原理:在静态编译过程中,编译器会将程序所依赖的所有库文件(如函数库等)的代码都完整地包含到最终生成的可执行文件中。也就是说,可执行文件包含了运行所需的全部代码,不依赖外部的库文件来提供功能。
- 优点:可移植性强,因为可执行文件自身包含了所有必要的代码,在任何支持该平台的环境中都可以直接运行,无需额外安装依赖库。
- 缺点:生成的可执行文件体积较大,因为包含了大量的库代码;而且如果库文件更新,需要重新进行编译。
动态编译:
- 原理:动态编译生成的可执行文件并不包含所依赖库的完整代码,而是在程序运行时才去加载所需的动态链接库(
DLL
,在 Windows 系统中)或共享对象文件(.so
,在 Linux 系统中)。这些动态库可以被多个程序共享使用。 - 优点:生成的可执行文件体积较小,因为不包含库的代码;并且库文件更新时,只要接口不变,可执行文件无需重新编译就能使用新的库功能。
- 缺点:程序运行依赖于系统中存在相应的动态库,如果缺少这些库,程序将无法正常运行,这在一定程度上降低了程序的可移植性。
1.20 动态链接和静态链接⭐
静态链接库就是把(lib) 文件中用到的函数代码直接链接进目标程序,程序运行的时候不再需要其它的库文件;动态链接就是把调用的函数所在文件模块(DLL)和调用函数在文件中的位置等信息链接进目标程序,程序运行的时候再从 DLL 中寻找相应函数代码,因此需要相应 DLL 文件的支持。
静态链接库与动态链接库都是共享代码的方式,如果采用静态链接库,则无论你愿不愿意,lib 中的指令都全部被直接包含在最终生成的 EXE 文件中了。但是若使用 DLL,该 DLL不必被包含在最终 EXE 文件中,EXE 文件执行时可以“动态”地引用和卸载这个与 EXE 独立的 DLL 文件。
静态链接库和动态链接库的另外一个区别在于要包含的静态链接库中不能再包含其他的动态链接库或者静态库,而在动态链接库中还可以再包含其他的动态或静态链接库。
动态库就是在需要调用其中的函数时,根据函数映射表找到该函数然后调入堆栈执行。如果在当前工程中有多处对.dll
文件中同一个函数的调用,那么执行时,这个函数只会留下一份拷贝。但如果有多处对 .lib
文件中同一个函数的调用,那么执行时该函数将在当前程序的执行空间里留下多份拷贝,而且是一处调用就产生一份拷贝。
1.21 动态联编与静态联编 ⭐⭐
设计模式(1)——面向对象和面向过程,封装、继承和多态 | 爱吃土豆的个人博客
1.22 构造函数、析构函数可否抛出异常⭐
构造函数允许抛出异常。在构造对象的过程中,可能会遇到各种错误情况,比如内存分配失败、文件打开失败、网络连接失败等,此时可以通过抛出异常来通知调用者对象构造失败。
- 对象构造未完成:一旦构造函数抛出异常,对象的构造过程就会终止,已经分配的部分资源会被正确释放(如果在抛出异常前有相应的资源管理机制),并且对象不会被创建成功。
- 局部对象的析构:如果构造函数在初始化列表或者函数体中创建了局部对象,当构造函数抛出异常时,这些已经成功构造的局部对象会按照与构造顺序相反的顺序自动调用析构函数进行析构。
析构函数通常不应该抛出异常。
可能导致程序崩溃:如果析构函数抛出异常,并且在异常处理过程中又有其他异常抛出(例如,在析构过程中调用的其他函数也抛出异常),会导致程序进入未定义行为,很可能会使程序崩溃。
异常安全问题:在对象销毁的过程中,通常是为了释放资源,如果析构函数抛出异常,可能会导致部分资源无法正确释放,从而引发资源泄漏。
必须抛出异常时:如果析构函数中确实可能会出现异常情况,可以在析构函数内部捕获异常并进行处理,而不是将异常抛出。
1.23 何时需要成员初始化列表?过程是什么?⭐
- 当对引用成员变量进行初始化时;
- 当对
const
成员变量进行初始化时; - 成员变量是一个禁止拷贝的变量时;成员初始化列表是在对象构造时直接进行初始化,避免了赋值和拷贝的过程,所以可以用于初始化禁止拷贝的变量。
- 当调用基类的构造函数,且该构造函数带有一组参数时;
- 当调用成员类的构造函数,且该构造函数带有一组参数时;
编译器会对初始化列表进行处理,它会按照恰当的顺序,在构造函数内部插入初始化操作,并且这些操作会在任何用户显式编写的代码之前执行。需要注意的是,初始化列表中项目的执行顺序是由类中成员的声明顺序所决定的,而非初始化列表里的排列顺序。
1.24 程序员定义的析构函数被扩展的过程⭐
在 C++ 中,程序员定义的析构函数在编译阶段会被编译器进行扩展,以确保对象在销毁时能正确处理各种资源和执行必要的操作。
1. 执行用户自定义的析构函数代码
执行析构函数内部代码
2. 调用成员对象的析构函数
如果类包含成员对象,编译器会在用户自定义的析构函数代码之后,按照成员对象在类中声明的相反顺序调用它们的析构函数。这是为了确保成员对象的资源能够被正确释放。
3.如果对象有⼀个 vptr,会被重新新定义
当对象开始析构时,在执行用户自定义的析构函数代码之前,编译器会确保 vptr 仍然指向当前对象实际类型对应的虚函数表。这是因为在析构的早期阶段,对象仍然是完整的,其行为应该遵循当前类型的定义。
随着析构过程的推进,当成员对象和基类部分开始析构时,vptr 会逐步调整。在调用成员对象的析构函数时,vptr 不会改变,因为此时对象的主要类型特性还是当前类。当开始调用基类的析构函数时,vptr 会被调整为指向基类的虚函数表。这是因为在基类析构期间,对象的派生类部分已经被销毁,对象的行为应该遵循基类的定义。例如,在一个多层继承体系中,当从派生类析构进入基类析构时,vptr 会从指向派生类虚函数表切换到指向基类虚函数表,以保证在基类析构函数中调用虚函数时,调用的是基类版本的函数。
4. 调用基类的析构函数
如果类是派生类,编译器会在成员对象的析构函数调用之后,调用其直接基类的析构函数。如果基类还有基类,会按照继承层次从下往上依次调用各级基类的析构函数
5. 释放对象占用的内存
在完成上述所有操作后,编译器会安排释放对象本身占用的内存。对于栈上的对象,这通常由操作系统自动完成;对于堆上的对象,会调用 delete
或 delete[]
来释放内存。
1.25 构造函数的执行步骤⭐
- 在派生类构造函数中,所有的虚基类及上一层基类的构造函数被调用;
- 对象的 vptr 被初始化
- 如果有成员初始化列表,将在构造函数体内扩展开来,这必须在vptr 被设定之后才做;
- 执行程序员所提供的代码;
1.26 构造函数的扩展过程
- 记录在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序;
- 如果一个成员并没有出现在成员初始化列表中,但它有一个默认构造函数,那么默认构造函数必须被调用;
- 如果 class 有虚表,那么它必须被设定初值,即初始化;
- 所有上一层的基类构造函数必须被调用;
- 所有虚基类的构造函数必须被调用
1.27 哪些函数不能是虚函数⭐
构造函数,虚函数调用只需要知道“部分的”信息,即只需要知道函数接口,而不需要知道对象的具体类型。但是,我们要创建一个对象的话,是需要知道对象的完整信息的。特别是,需要知道要创建对象的确切类型,因此,构造函数不应该被定义成虚函数;
而且从目前编译器实现虚函数进行多态的方式来看,虚函数的调用是通过实例化之后对象的虚函数表指针来找到虚函数的地址进行调用的,如果说构造函数是虚的,那么虚函数表指针则是不存在的(因为构造函数是虚的,那么同样需要虚函数表来进行调用,但是虚函数表只有在调用构造函数之后才会生成,自相矛盾了),无法找到对应的虚函数表来调用虚函数,那么这个调用实际上也是违反了先实例化后调用的准则。
内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
1.28 菱形继承⭐
一个类可以从多个基类(父类)继承属性和行为。在C++等支持多重继承的语言中,一个派生类可以同时拥有多个基类。
多重继承可能引入一些问题,如萎形继承问题,比如存在同时继承了两个拥有相同基类的类,而最终的派生类又同时继承了这两个类时, 可能导致二义性和代码设计上的复杂性。为了解决这些问题,C++ 提供了虚继承,通过在继承声明中使用 virtual
关键字,可以避免在派生类中生成多个基类的实例,从而解决了萎形继承带来的二义性。
1 | // 公共基类 |
1.29 虚函数和纯虚函数的区别⭐
虚函数:
- 有实现:虚函数有函数声明和实现,即在基类中可以提供默认实现。
- 可选实现: 派生类可以选择是否覆盖虚函数。如果派生类没有提供实现,将使用基类的默认实现。
- 允许实例化: 虚函数的类可以被实例化。即你可以创建一个虚函数的类的对象。
- 调用靠对象类型决定: 在运行时,根据对象的实际类型来决定调用哪个版本的虚函数。
- 用
virtual
关键字声明: 虚函数使用virtual
关键字声明,但不包含=0
。
纯虚函数:
- 没有实现:纯虚函数没有函数体,只有函数声明,即没有提供默认的实现。
- 强制覆盖: 派生类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
- 禁止实例化: 包含纯虚函数的类无法被实例化,只能用于派生其他类。
- 用
=0
声明:纯虚函数使用=0
在函数声明末尾进行声明。 - 为接口提供规范: 通过纯虚函数,抽象类提供一种接口规范,要求派生类提供相关实现。
1.30 抽象类
抽象类是不能被实例化的类,它存在的主要目的是为了提供一个接口,供派生类继承和实现。抽象类中可以包含普通的成员函数、数据成员和构造函数,但它必须至少包含一个纯虚函数。即在声明中使用 virtual
关键字并赋予函数一个 =0
的纯虚函数。
派生类必须实现抽象类中的纯虚函数,否则它们也会成为抽象类
1.31 完美转发(引用折叠+forward)
C++——完美转发(引用折叠+forward) | 爱吃土豆的个人博客
1.32 静态成员变量的初始化位置⭐
- 非模板类的静态成员变量和函数需要在实现文件cpp中初始化,不能在头文件中初始化。因为当该头文件被多个源文件包含时,每个源文件在编译时都会对这个静态成员变量进行定义和初始化,链接时会出现多重定义错误。
1 | // MyClass.h |
- 模板类的静态成员变量必须在头文件中定义,模板类中的静态变量可以在头文件中定义,是因为模板类在编译时不会像普通类那样生成具体的类型和符号。相反,模板类的代码只会在模板被实例化时才生成具体的类型。因此,模板类的静态成员变量不会像普通类的静态成员那样在多个编译单元中重复定义。
1 | // MyClass.h |
constexpr
或const
整数类型静态成员变量:如果是const
整数类型或constexpr
静态常量,它们通常是编译时常量,直接在头文件中初始化,避免多次定义。
1 | class MyClass { |
- inline 静态成员变量(C++17 及以上):C++17 引入了
inline
关键字,可以在头文件中定义非 const
静态成员变量,避免重复定义。
1 | // MyClass.h |
模板的实例化机制:
- 模板的延迟实例化:模板本身并不是一个具体的类或函数,它是一种生成类或函数的蓝图。编译器在遇到模板定义时,并不会立即生成实际的代码,而是在需要使用模板的具体实例时才进行实例化。例如,当你使用
MyTemplate<int>
时,编译器才会根据MyTemplate
模板生成int
类型的具体实例。 - 静态成员变量的实例化:模板类的静态成员变量同样遵循延迟实例化的规则。每个不同的模板实例都会有自己独立的静态成员变量副本(
MyTemplate<int> a
和MyTemplate<int> b
共享静态成员,但是MyTemplate<double> c
中存在另一个静态成员副本,和a、c
不共享)。如果将静态成员变量的定义放在.cpp
文件中,编译器可能无法在该文件中获取到所有需要的模板实例信息(因为类型有很多,从而导致模板生成的实例也有很多类型,我们只要忽略了其中一种,就有可能导致这种类型的模板实例无法实例化),从而导致某些实例的静态成员变量没有被正确实例化。 - 多重定义问题的特殊性:对于非模板类的静态成员变量,在多个源文件中重复定义会导致链接错误。但对于模板类的静态成员变量,由于每个模板实例的静态成员变量是独立的,不同的模板实例不会导致多重定义问题。只要静态成员变量的定义在每个使用它的翻译单元中都是相同的,就不会出现链接错误。
1.33 移动构造函数什么时候会被自动调用
只有当传入的值为右值时,移动构造函数才会被调用。若传入的值是左值,并且类的拷贝构造函数被显式删除(使用 = delete
),那么编译器会直接报错,而不会调用移动构造函数。若传入的右值,编译器会自动调用移动构造函数。
一般来说,
push_back
会根据传入值的类型,选择使用拷贝构造函数或者移动构造函数。而std::thread
存在移动构造函数,如果push_back
不能使用thread
的拷贝构造,那为什么不使用移动构造函数呢?
当我们调用push_back 时,传递的对象通常是一个左值(即命名的变量)。如果直接传递左值,编译器会优先尝试调用拷贝构造函数,如果不存在拷贝构造那么会直接报错,而不会调用移动构造,因为thread内部并不会将左值隐式转换为右值,从而调用移动构造。
总结:
- 如果对象的拷贝构造函数可用,但移动构造函数也存在,
push_back
会选择合适的构造函数: - 如果传递的是一个左值,会调用拷贝构造函数。
- 如果传递的是一个右值,会调用移动构造函数。
- 如果对象的拷贝构造函数不可用,但移动构造函数存在,
push_back
的选择:- 如果传递的是一个左值,会直接报错,编译器无法将左值隐式转换为右值从而调用移动。
- 如果传递的是一个右值,会调用移动构造函数。
1 | std::vector<std::thread> threads; |
1.34 三/五/零法则
三五零法则就是三法则、五法则、零法则。
a. 三法则
三法则规定,如果一个类需要显式定义以下其中一项时,那么它必须显式定义这全部的三项:
- 拷贝构造函数
- 拷贝赋值运算符
- 析构函数
案例说明
根据RAII原则,当类手动管理至少一个动态分配的资源时,通常需要实现上述函数。
1 | class Student { |
在这个示例中,我们有一个Student
类手动管理了动态分配的资源(即name
),构造函数为name
分配内存,析构函数释放分配的内存。但是当Student
的对象被复制时会发生什么?
1 | Student s1("Tom", 12); |
当构造s2
时,将执行Student
的默认拷贝构造函数(因为用户没有显式定义拷贝构造函数)。默认的拷贝构造函数将每个成员进行浅拷贝,这意味着s1.name
和s2.name
都指向同一块内存。
当main()
函数结束时会发生什么?s2
的析构函数将被调用,这将释放name
所指向的内存,然后s1
的析构函数被调用,它将再次尝试释放name
指向的内存,但是这块内存已经被释放了!这就导致重复释放内存。
为了避免这种情况,需要提供适当的复制操作:
1 | // 拷贝构造函数 |
拷贝构造函数和拷贝赋值运算符都执行动态分配资源的深拷贝。
b. 五法则
五法则是三法则的扩展。五法则规定,如果一个类需要显式定义以下其中一项时,建议显式定义全部的五项:
- 拷贝构造函数
- 拷贝赋值运算符
- 析构函数
- 移动构造函数
- 移动赋值运算符
因为用户定义(包括=default或=delete)的析构函数、拷贝构造函数或赋值赋值运算符(任意一项),会阻止隐式定义移动构造函数和移动赋值运算符,所以任何想要移动语义的类必须声明全部五个特殊成员函数:
用户显式定义三法则中的任意一项时(包括=default或=delete),会阻止编译器隐式定义移动语义,导致失去优化的机会。该法则只是建议,不做强制要求。
不实现移动语义通常不被视为错误。如果缺少移动语义,编译器通常会尽可能使用效率较低的复制操作。如果一个类不需要移动操作,我们可以轻松跳过这些操作。但是,使用它们会提高效率。
c. 零法则
如果没有显式定义任何特殊成员函数,则编译器会隐式定义所有特殊成员函数(成员变量也会影响隐式定义)。
零法则就是建议优先选择不需要显式定义特殊成员函数的情况。
简单来说,如果类需要管理动态资源(如动态内存、文件句柄、网络连接等)就需要遵循五法则;如果类不需要管理动态资源,那最好不要显式定义析构函数、拷贝/移动构造函数、拷贝/移动赋值运算符。如果类的所有成员都遵循零法则,那么整个类也遵循零法则。 零法则说到底就是建议使用智能指针和其他资源管理工具,以自动处理资源的创建和销毁。这样大多数类都无需直接管理资源,从而可以避免许多常见的资源管理错误,如资源泄漏、重复释放等。通过遵循零法则,开发者可以编写更简洁、更安全的代码。
当有意将某个基类用于多态用途时,可能需要将它的析构函数声明为public和virtual。由于这会阻止生成隐式移动(并弃用隐式复制),因此如果希望保持移动和复制操作的支持,可以显式定义这些操作,或使用 = default 来告诉编译器生成默认的实现。
1 | class base_of_five_defaults |
但是,过多的定义拷贝、移动构造函数/运算符,如果不小心进行对象复制,可能会导致切片现象。切片发生在将派生类对象赋值给基类对象时,导致只保留基类部分,丢失派生类特有的属性和行为。这是因为基类对象不能持有派生类的信息。
这也是为什么多态类经常把复制定义(不是移动)=delete的原因:
- 避免切片:通过将拷贝构造函数和拷贝赋值运算符显式定义为 = delete,可以防止基类对象无意中复制派生类对象,从而避免切片现象。
- 控制对象的生命周期:如果类只应该通过指针或引用来管理,而不是通过值来复制,将复制操作禁用可以更好地控制对象的使用。
比如:
1 | class Base { |
我们可以保留析构、移动构造、移动赋值运算符,将复制构造、复制运算符给delete,避免切片现象。
1.34 std::ref和std::move的区别
a. std::ref
- 功能:
std::ref
用于创建一个可拷贝的引用包装器,将给定对象(左值引用,不接受右值引用)作为引用传递给其他需要按值传递的函数,如线程函数。它主要用于在传递引用时避免意外拷,保持对象的原始引用,不会转移所有权。 - 使用场景:在需要将引用传递给 STL 算法、线程或其他需要值拷贝的函数时使用。
thread中便用到了此原理,thread传入的参数若不经过std::ref包装,均会作为右值被保存使用,可以参考文章:
并发编程(1)——线程、thread源码解析 | 爱吃土豆的个人博客
std::ref只是尝试模拟引用传递,并不能真正变成引用,在非模板情况下,std::ref根本没法实现引用传递,只有模板自动推导类型或类型隐式转换时,ref能用包装类型reference_wrapper来代替原本会被识别的值类型,而reference_wrapper能隐式转换为被引用的值的引用类型。 为什么std::ref可以保存参数的引用呢?实现在thread修改参数值,影响到外部传入参数值的效果?
1 | template <class _Ty> |
reference_wrapper
是一个类类型,说白了就是将参数的地址和类型保存起来。
1 | _CONSTEXPR20 reference_wrapper(_Uty&& _Val) noexcept(noexcept(_Refwrap_ctor_fun<_Ty>(_STD declval<_Uty>()))) { |
当我们要使用这个类对象时,自动转化为取内部参数的地址里的数据即可,就达到了和实参关联的效果
1 | _CONSTEXPR20 operator _Ty&() const noexcept { |
我们可以这样理解通过 std::ref
传递给 std::thread
构造函数的参数:std::ref
传入的参数会被包装成 std::reference_wrapper
对象,该对象在std::thread
会被改造成右值引用,例如 std::ref(int)
实际是作为 std::reference_wrapper<int>
对象保存在 std::thread
的类成员里。当线程执行时,会触发 std::reference_wrapper
的 operator()
(仿函数调用),进而通过这个包装对象获取到外部实参所在地址中的数据,从而实现对外部实参的引用传递。
b. std::move
- 功能:
std::move
用于将对象的所有权转移,表示该对象可以被“移动”。它不会执行任何实际的移动操作,而是将对象转换为右值引用,使得可以调用移动构造函数或移动赋值运算符。可以接受左值或右值,对左值使用时将其转换为右值引用,对右值使用仍保持为右值引用
std::ref 关注的是引用的传递,而 std::move 则关注对象的所有权转移
std::move 的行为:
std::move
并不执行实际的“移动操作”,而是将对象转化为一个“右值引用”(T&&),提示编译器可以安全地“移动”数据。- 真正的数据移动发生在随后调用的移动构造函数或移动赋值运算符中。
- 一旦使用
std::move
,原数据的状态可能会变成“未定义的有效状态”(例如清空容器、将指针置为nullptr
等),这会破坏原队列中的数据。
1.35 类型成员和非类型成员
在定义类型别名的时候需要注意,如果定义别名的类型是已知类型,直接加
typedef
即可,但对于模板类中嵌套的类型,我们需要额外加typename
明确是类型。
1 | //存储元素的类型为pair,由key和value构成 |
比如上面 std::pair
和 std::list
是标准库中的普通模板类,它们本身是已知类型,不需要额外的提示来区分;而bucket_data::iterator
是一个嵌套类型,定义在 bucket_data
(即 std::list<bucket_value>
)内部,我们需要加 typename
表明它是一个类型,而不是其他可能的非类型成员(数据成员、成员函数、静态成员变量、枚举值)
注意区分类型成员和非类型成员:
- 类型成员是类中定义的类型,主要包括:typedef 或 using 定义的类型别名、嵌套类、模板嵌套类型
- 非类型成员是类的对象或行为,而不是类型定义,主要包括:数据成员(成员变量)、成员函数、静态成员变量、枚举值
1 | class Example { |
为什么需要这样区分?
在模板中,C++ 编译器无法直接区分某个嵌套标识符是 非类型成员 还是 类型成员,因为模板参数可能影响其定义。例如:
1 | template <typename T> |
如果不加 typename
,编译器可能认为 T::NestedType
是一个变量或其他非类型成员,而报错。但 T::static_member
是一个非类型成员(静态变量),因此不需要 typename
。
当我们对作用域或类重命名时,需要遵循以下规则:
1 | // 因为beast、http、net是作用域,所以可以直接用namespace重命名 |
1.36 左值和右值⭐
- 左值:可以被取地址,可以被修改(除了const修饰的左值),例如变量名或解引用的指针。
- 可以出现在赋值号 = 的左边或右边的表达式,它有一个明确的内存地址,可以被引用或修改。
- 可以绑定到左值引用(
T&
)的对象,通常有名字并且可以访问其内存地址。
- 右值:不能被取地址,也不能被修改,例如字面常量、表达式的返回值、函数的(非左值引用)返回值。只能出现在赋值号 = 的右边的表达式,它没有一个明确的内存地址,不能被引用或修改
- 不能直接绑定到左值引用上,但可以绑定到右值引用(
T&&
)上
- 不能直接绑定到左值引用上,但可以绑定到右值引用(
但注意,有名字的右值引用变量会被视为左值。
当一个右值引用(如 int&& a
)有了名字后,它就成为了一个可以访问的对象,可以通过它来取地址或者修改它的值,因此它满足左值的特征。虽然它最初是绑定到右值的,但它现在作为一个有名字的对象(有名字的变量包括右值引用变量,都是有明确内存地址的),就变成了左值。
1 | // 接受左值引用的函数 |
int&& rref = 10;
这里rref
是一个右值引用,它绑定到了右值10
。- 将右值引用变量视为左值:
processValue(rref);
调用processValue
函数时,因为rref
有名字,编译器会把它当作左值,所以会调用接受左值引用的processValue
函数。 - 使用
std::move
将其转换为右值:processValue(std::move(rref));
通过std::move
把rref
强制转换为右值,这样就会调用接受右值引用的processValue
函数。
1.37 临终值和纯右值⭐
临终值(xvalue)和纯右值(prvalue)都属于右值。
- 纯右值是传统意义上的右值,它是一个不代表任何对象的表达式,通常是字面量、临时对象或者求值结果不与任何对象关联的表达式。纯右值没有标识符,没有持久的内存地址,其生命周期仅限于表达式求值期间。例如:字面量
10
、3.14
,函数返回的非引用类型结果(如int func() { return 5; }
中的func()
返回值),以及算术表达式2 + 3
等都是纯右值。 - 临终值代表一个对象,该对象的资源(如动态分配的内存)可以被安全地移动,因为这个对象即将结束其生命周期。临终值有一个明确的对象与之关联,但它的资源即将被转移。例如:使用
std::move
转换后的对象(std::move(obj)
)、返回右值引用的函数调用结果(int&& func() { ... }
中的func()
返回值)都是临终值。
1.38 union 联合体/共用体
- 共用体是一种特殊的类,也是一种构造类型的数据结构。
- 共用体表示几个变量共用一个内存位置,在不同的时间保存不同的数据类型和不同长度的变量。
- 所有的共用体成员共用一个空间,并且同一时间只能储存其中一个成员变量的值。例如如下:
1 | union ChannelManager{ |
一个union
只配置一个足够大的空间以来容纳最大长度的数据成员,以上例而言,最大长度是 double
类型
所以 ChannelManager
的空间大小就是 double
数据类型的大小。在 C++ 里,union 的成员默认属性页为public。union 主要用来压缩空间,如果一些数据不可能在同一时间同时被用到,则可以使用 union。
1.39 C++中的模板全特例化和部分特例化
- 模板全特例化指的是针对某个具体的类型,为模板提供一个完全特化的实现。当使用该特定类型实例化模板时,编译器会优先使用全特例化的版本,而非通用的模板定义。
- 模板部分特例化允许你针对模板参数的某个子集或特定条件,为模板提供一个部分特化的实现。部分特例化只适用于类模板,函数模板不支持部分特例化。
STL 中迭代器的特性萃取用的就是模板特例化。
1.40 move和forward的区别⭐
st::move
的简单实现如下:
1 | template<typename T> |
它首先通过引用折叠将传入的是值折叠为左值引用或右值引用,然后调用 remove_reference<T>::type
移除类型 T
上的引用(如果有的话),无论 T
是左值引用、右值引用,还是非引用类型,remove_reference<T>::type
得到的都是原始的类型。
移除引用后,将该类型强制转换为右值引用(类型是右值引用),以便允许移动语义的优化
1 | template <class _Ty> |
而 std::forward<T>
只是单纯的返回一个T&&,但是T的类型需要上层函数传入,如果T是int&,那么返回的其实也是int&;如果T是int&&或者int(右值引用模板参数中,右值会被推断为int,所以这里的int代表右值),返回的其实还是int&&。
std::forward<T>
是为了解决传递参数时的临时对象(右值)被强制转换为左值的问题。
参考:C++——完美转发(引用折叠+forward) | 爱吃土豆的个人博客
1.41 C++为什么使用nullptr而不是null⭐
C语言中,NULL通常定义为(void*) 0
,C++中NULL一般定义为整型0,这是因为在C语言中,允许void*
类型的指针隐式转换为其他类型的指针,而且C语言不支持函数重载。但是
- C++不允许
void*
到其他指针的隐式类型转换,若把NULL
定义为(void*)0
,就无法将其直接赋值给其他类型的指针,这显然不符合空指针常量的使用习惯。 - C++支持函数重载,如果存在两个形参类型是整型和指针的同名重载函数,将NULL传入重载函数时,编译器会选择形参类型的重载函数而不是指针类型的重载函数,因为C++中,NULL通常被定义为整型0。
而 nullptr 是一个空指针常量,类型是std::nullptr_t,可以隐式转换为任意类型的指针,且不会和整数类型产生混淆。
搞明白这两点就明白为什么C可以用#define NULL (void*)0
,C++却需要nullptr
了。
1.42 在类内定义的函数为隐式的内联函数,即使没有加上inline。而虚函数也可以在类内定义,如何理解内联的虚函数?
- 矛盾点:内联函数是在编译时进行代码展开,要求编译器在编译阶段就确定要调用的函数代码;而虚函数是在运行时根据对象的实际类型动态绑定要调用的函数,这就意味着在编译阶段无法确切知道会调用哪个函数。所以,内联和虚函数的机制在本质上是相互冲突的。
- 实际情况:
- 通过对象调用(静态绑定):当使用对象(而非指针或引用)来调用虚函数时,编译器在编译阶段就能明确对象的具体类型,此时虚函数可以被内联展开。
- 通过指针或引用调用(动态绑定):当使用基类指针或引用调用虚函数时,由于要实现动态绑定,编译器在编译阶段无法确定实际调用的函数,因此虚函数不会被内联。
1.43 volatile如何实现可见性的
当一个变量被声明为 volatile
时,它会保证以下两点:
- 写操作会立即刷新到主内存:线程对
volatile
变量进行写操作之后,该变量的新值会马上刷新到主内存,而不是停留在工作内存。 - 读操作会从主内存读取最新值:线程读取
volatile
变量时,会直接从主内存读取,而非使用工作内存中的旧值。
2. 内存
2.1 C++内存分配⭐
C++程序运⾏时,内存被分为几个不同的区域,每个区域负责不同的任务。
- 栈:
- 由编译器管理分配和回收,存放局部变量、形参和返回值。函数的调⽤和返回通过栈来管理 。
- “向下增长” 意味着随着程序中函数调用、局部变量声明等操作,栈区占用的内存空间会朝着内存低地址方向扩展。例如,当一个函数被调用时,其形参和局部变量会依次压入栈中,栈顶指针向低地址移动 。
- 堆:
- 由程序员管理,需要⼿动
new
和delete
进⾏分配和回收,空间较⼤,但可能会出现内存泄漏和空闲碎片的情况。 - “向上增长” 表示随着程序员不断调用内存分配函数来获取更多内存,堆区占用的内存空间会朝着内存高地址方向扩展。
- 由程序员管理,需要⼿动
- 全局/静态存储区:
- 分为初始化和未初始化两个相邻区域,存储初始化和未初始化的全局变量和静态变量。
- ⽣命周期是整个程序运⾏期间。在程序启动时分配,程序结束时释放
- 内存在程序编译的时候就已经分配好
- 常量存储区:存储常量,⼀般不允许修改。
- 代码区:存放程序的⼆进制代码。
2.2 堆和栈的区别⭐
堆:
- 由程序员手动管理,需通过new delete进行分配和回收,不进行回收会造成内存泄漏。
- 不连续的空间。系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,但频繁的分配和释放内存,可能会导致内存碎片的产生(比如,一开始分配了几个小块内存,后来又释放了其中几块内存,这样就会在已分配的内存之间形成一些小块的空闲空间,这些空闲空间由于不连续,可能无法满足后续较大内存块的分配需求,就像市场中一些摊位被拆除后,剩下的一些小空间无法再容纳大型的摊位一样)
- 从低地址向高地址扩展,访问速度慢(堆上的内存分配和释放是动态的,系统需要花费一些时间来管理这些内存,比如在分配内存时需要查找合适的空闲空间,在释放内存时需要进行一些清理和标记操作)
栈:
由编译器进行管理,在需要时由编译器自动分配空间,在不需要时候自动回收空间,一般保存的是局部变量和函数参数等
连续的内存空间。在函数调用的时候,首先入栈的主函数的下一条可执行指令的地址,然后是函数的各个参数。
大多数编译器中,参数是从右向左入栈(原因在于采用这种顺序,是为了让程序员在使用C/C++的“函数参数长度可变”这个特性时更方便。以
printf
这类可变参数函数为例,它需要提前知晓可变参数表中各变量的类型,而第一个参数的作用正是描述这些类型。若从左向右压栈,这个关键参数就会被置于栈底。由于可变参数函数执行的首要步骤是解析可变参数表中各参数的类型,这就意味着一开始就得获取位于栈底的该参数。但栈遵循后进先出原则,栈底元素是最早入栈且最后才能被访问的,想要从栈底获取参数会涉及复杂的栈操作,效率低下且容易出错。所以从右向左压栈,把描述可变参数类型的关键参数放在栈顶附近,函数解析时能更便捷快速地获取,极大简化了可变参数函数的实现与使用 。本次函数调用结束时,局部变量先出栈,然后是参数,最后是栈顶指针最开始存放的地址,程序由该点继续运行,不会产生碎片。
访问速度很快,因为栈上的内存分配是连续的,并且系统对栈的管理非常高效,不需要像堆那样进行复杂的查找和管理操作
栈为什么比堆的效率高
- 内存分配与释放机制
- 栈的内存分配和释放是由系统自动完成的。当进入一个函数时,系统会为该函数的局部变量在栈上分配内存,这些内存是连续的,只需移动栈指针即可完成分配操作,这个过程非常迅速,时间复杂度为 O(1)。
- 堆的内存分配需要程序员手动调用特定的函数(如 C 语言中的
malloc
、calloc
,C++ 中的new
)来完成。操作系统需要在堆空间中寻找合适大小的连续内存块,如果没有足够大的连续内存,还可能需要进行内存碎片整理,这个过程相对复杂,**时间复杂度通常为 O(n)**。
- 内存访问速率
- 栈上的内存是连续分配的,因此 CPU 可以利用缓存机制更高效地访问栈上的数据。当访问栈上的某个数据时,附近的数据也很可能被加载到 CPU 缓存中,从而减少了从内存中读取数据的时间,提高了访问效率。
- 堆上的内存分配是不连续的,容易产生内存碎片。当程序频繁进行堆内存的分配和释放操作时,内存碎片会越来越多,导致 CPU 在访问堆上的数据时,需要花费更多的时间来查找和定位所需的数据,降低了访问效率。
2.3 计算下列类的大小⭐
1 | class A {}; // sizeof(A) = 1 |
对于空类,sizeof(A)
的结果为 1 字节。这是因为在 C++ 里,每个对象都必须有独一无二的内存地址,即便类中没有任何成员变量,编译器也会为其分配至少 1 字节的内存空间,以此保证该类的不同对象能够有不同的地址。
1 | class A{virtual Fun(){} }; // 4或8 |
存在虚函数时,编译器会为这个类创建一个虚函数表(vtable),用于存储该类的所有虚函数的地址。同时,类的每个对象都会包含一个指向虚函数表的指针(vptr)。
- 在 32 位系统中,指针的大小是 4 字节,所以
sizeof(A)
为 4 字节。 - 在 64 位系统中,指针的大小是 8 字节,
sizeof(A)
则为 8 字节。
1 | class A{static int a; }; // 1 |
静态成员变量属于类本身,而不是类的某个具体对象,它被存储在全局数据区,不占用对象的内存空间。所以,就像空类一样,编译器为对象分配 1 字节的内存,确保其有唯一的地址。
1 | class A{int a; }; // 4 |
在这个类中,只有一个 int
类型的成员变量 a
。在大多数系统中,int
类型通常占用 4 字节的内存空间,因此 sizeof(A)
等于 4 字节。
2.4 内存对齐⭐⭐
内存对齐是指将数据存储在特定的内存地址上,使得数据的起始地址是其大小的整数倍(为了方便计算机去读写数据)。
作用:
经过内存对齐之后,CPU 的内存访问速度大大提升。因为 CPU 把内存当成是一块一块的,块的大小可以是2,4,8,16 个字节,因此 CPU 在读取内存的时候是一块一块进行读取的,块的大小称为内存读取粒度。比如说 CPU 要读取一个4个字节的数据到寄存器中(假设内存读取粒度是 4),如果数据是从0字节开始的,那么直接将 0-3 四个字节完全读取到寄存器中进行处理即可
如果数据是从1字节开始的,就首先要将前4个字节读取到寄存器,并再次读取 4-7 个字节数据进入寄存器,接着把0字节,5,6,7字节的数据剔除,最后合并1,2,3,4字节的数据进入寄存器,所以说,当内存没有对齐时,寄存器进行了很多额外的操作,大大降低了CPU的性能。
另外,还有一个就是,有的CPU 遇到未进行内存对齐的处理直接拒绝处理,不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。所以内存对齐还有利于平台移植。
原理:
对齐的地址一般都是 n(n = 2、4、8)的倍数。
- 1 个字节的变量,例如 char 类型的变量,放在任意地址的位置上;
- 2 个字节的变量,例如 short 类型的变量,放在 2 的整数倍的地址上;
- 4 个字节的变量,例如 float、int 类型的变量,放在 4 的整数倍地址上;
- 8 个字节的变量,例如 long long、double 类型的变量,放在 8 的整数倍地址上;
结论:
结构体尾部补齐规则:
结构体整体的内存占用需要根据其中最大类型的成员来进行调整。如果结构体中最大类型是像
int
这种占 4 个字节的类型,并且结构体的结尾地址不是 4 的倍数,那么需要将结构体的内存空间补齐到离当前结尾地址最近的 4 的倍数地址;若最大类型是像double
这种占 8 个字节的类型,且结构体结尾地址不满足 8 的倍数,就需要补齐到离最近的 8 的倍数地址,依此类推。结构体嵌套情况:
当结构体中包含子结构体时,子结构体成员变量的起始地址由子结构体中最大类型的变量决定。例如,若结构体
a
包含结构体b
,而结构体b
中包含char
、int
、double
等元素,那么结构体b
应该从 8 的整数倍地址开始存储。具体可参考相关示例。含数组成员的结构体:
如果结构体中包含数组成员,例如
char a[5]
,其对齐方式等同于连续写 5 个char
类型的变量,即按一个字节进行对齐。你可以在多个示例中观察到这种对齐方式。含联合体成员的结构体:
若结构体中包含联合体(
union
)成员,联合体的起始地址需要取联合体中最大类型的整数倍地址。相关示例可以帮助你更好地理解这一规则。
举例:
1 | struct stu1 { |
char
类型的大小为 1 字节,它的对齐值也是 1 字节,所以a
可以从任意地址开始存储,假设其起始地址为 0,则 a[18] 结束的地址为 17;double
类型的大小为 8 字节,对齐值为 8 字节,由于a
只占 18 字节,若b
紧接着c
存储,其起始地址为 18,不满足 8 字节对齐要求。因此,编译器会在a
后面插入 6个填充字节,使i
从地址 24 开始存储,占用 8 个字节(地址 24 - 31);- 然后存储 c,地址为 32;
int
类型大小为 4 字节,对齐值为 4 字节,所以会在 c 后补 3 字节,使得 d 从 36 开始存储,36~39;short
类型的大小为 2 字节,对齐值为 2 字节。d
结束地址是 39,为了满足e
的对齐要求,e
从地址 40 开始存储,占用 2 字节,地址范围是 40 - 41。- 结构体的对齐值取决于其成员中最大的对齐值,在
struct stu1
中,最大对齐值是double
类型的 8 字节。当前结构体成员总共占用了 42 字节(18 + 7 + 1 + 3 + 4 + 1 + 2),但为了满足 8 字节对齐,需要在结构体末尾再插入 6 个填充字节,使得结构体总大小为 48 字节。
2.5 智能指针
2.6 野指针和悬浮指针⭐
- 野指针是指指向一个随机的、无效的或者未分配内存地址的指针。这种指针没有被正确初始化,它可能指向任何地方,访问野指针可能会导致程序崩溃、产生不可预期的结果或者引发安全漏洞。
- 产生原因:内存释放后未置空、未初始化(声明指针后没有对其进行初始化赋值,指针的值是随机的)
- 悬浮指针通常是指指针原本指向一个合法的内存地址,但由于该内存被释放或者对象的生命周期结束,导致指针指向的内存不再有效,但指针本身仍然保留着原来的地址值。
- 产生原因:对象生命周期结束(在函数内部定义的对象,当函数执行结束后,对象会被销毁,指向该对象的指针就会变成悬浮指针)
- 解决方法:避免在函数中返回局部变量的引用。使用返回指针或智能指针而不是引用,如果需要在函数之外使用函数内部创建的对象。
2.7 内存模型
并发编程(10)——内存模型、原子操作以及单例模式的三种实现 | 爱吃土豆的个人博客
并发编程(12)——内存次序与内存模型,以及单例模式的三种实现 | 爱吃土豆的个人博客
2.8 类如何实现只能静态分配和只能动态分配 ⭐
在 C++ 里,“类只能静态分配” 意味着该类的对象只能在栈上创建,不能通过 new
运算符在堆上动态分配;“类只能动态分配” 则表示该类的对象只能通过 new
运算符在堆上创建,不能在栈上静态分配。
要让类只能静态分配,也就是禁止在堆上动态分配对象,可通过将类的 new
和 delete
运算符重载为私有成员来达成。因为当 new
和 delete
是私有成员时,外部代码无法调用它们来进行堆上的对象分配和释放操作。
1 | class StaticAllocationOnly { |
要使类只能动态分配,也就是禁止在栈上静态分配对象,可将类的析构函数设为私有成员。由于栈上的对象在其作用域结束时会自动调用析构函数,而私有析构函数外部无法直接调用,所以不能在栈上创建对象。
但注意,析构函数私有之后,我们需要重新定义一个公有的静态成员函数来负责对象的销毁。
2.9 栈溢出的情况有哪些?⭐
栈溢出(Stack Overflow)是指程序在运行过程中,栈空间被耗尽,无法再为新的栈帧分配内存而导致的错误。以下是常见的栈溢出情况:
- 递归调用:比如在AVL树实现插入删除操作时,如果通过递归操作实现,在递归调用的层数超过栈的最大容量时,会导致栈空间被耗尽
- 局部变量占用空间过大:函数的局部变量通常存储在栈上。如果在函数中定义了非常大的局部变量,如大型数组或结构体,会占用大量的栈空间。当多个这样的函数被调用时,栈空间可能会被迅速耗尽,从而导致栈溢出。
- 嵌套函数调用层次过深:每一次函数调用都会在栈上分配一个新的栈帧,当函数调用嵌套层次过多时,栈空间会不断被占用。如果嵌套层次过深,栈空间最终会被耗尽,从而引发栈溢出。
- 线程栈空间过小:每个线程都有自己独立的栈空间,其大小是有限的。如果程序创建了大量的线程,或者某个线程的栈空间设置过小,而该线程又需要执行复杂的操作,可能会导致栈空间不足,从而引发栈溢出。
- 内存碎片问题:虽然栈是连续的内存区域,但在某些情况下,内存碎片可能会影响栈空间的分配。例如,在频繁的内存分配和释放操作后,内存中可能会出现许多不连续的小块空闲内存,当需要为栈帧分配较大的连续内存时,可能会因为找不到足够大的连续内存块而导致栈溢出。一般通过内存池解决内存碎片问题,即STL的一级/二级空间配置器。
解决方法:
- 使用迭代方法替代递归:迭代方法使用循环结构和显式的栈(如
std::stack
)来模拟递归调用的过程,将递归调用的状态保存在堆上,从而避免栈溢出。 - 尽量使用引用,或者将参数传入智能指针,智能指针本身存的是对象地址,而指针只占4/8字节,对内存的占用很小
- 线程栈空间太小可手动指定栈大小,或者在线程内尽量使用堆(动态分配而不是编译器分配)
- 内存碎片问题通过内存池解决,或者采用STL空间配置器的思想
2.10 内存泄漏的可能?如何排查?⭐⭐
C++相比于其他语言,内存需要开发者自行申请和释放,如果操作不当就容易造成内存泄漏。导致虚拟内存消耗越来越多,堆内存逐渐被消耗完导致程序崩溃。
- 分配的内存没有手动释放,可通过智能指针进行管理,对于类对象,可以通过RAII技术
- 智能指针的循环引用,通过引入weak_ptr解决
- 基类的析构函数没有定义成虚函数,举例来说就是,一个基类的指针指向一个派生类的对象,在使用完毕准备销毁时,如果基类的析构函数没有定义成虚函数,那 么编译器根据指针类型就会认为当前对象的类型是基类,调用基类的析构函数 (该对象的析构函数的函数地址早就被绑定为基类的析构函数),仅执行基类的析构,派生类的自身内容将无法被析构,造成内存泄漏。解决方法很简单,基类析构定义为虚函数即可。
出现内存泄漏的主要可能大概是以上三种,但是还有一些特殊的,比如STL内存分配后不会归还堆中,而是归还给STL的空间配置器。我们平常可以使用sTL中的对象代替数组,比如string和vector、array会自行管理内存,可以用来代替数组
如何排查?
- 笨方法,日志。每次分配内存的时候将指针地址打印出来,释放的时候也打印内存,然后再程序结束后,通过分配和释放的差(如果分配的条数大于释放的条数),基本就可以确定出现内存泄漏,然后根据日志的地址进行定位
- 统计,也就是日志的具体实现,创建三个自定义函数,一个用于内存分配,第二个用于内存释放,最后一个用于检查内存泄漏
1 | static unsigned int allocated = 0; |
- 使用工具。在Linux上比较常用的内存泄漏检测工具是
valgrind
,所以咱们就以valgrind
为工具,进行检测。
我们首先看一段代码:
1 |
|
通过gcc -g leak.c -o leak
命令进行编译
执行valgrind --leak-check=full ./leak
在上述的命令执行后,会输出如下:
1 | ==9652== Memcheck, a memory error detector |
valgrind
的检测信息将内存泄漏分为如下几类:
definitely lost
:确定产生内存泄漏
indirectly lost
:间接产生内存泄漏
possibly lost
:可能存在内存泄漏
still reachable
:即使在程序结束时候,仍然有指针在指向该块内存,常见于全局变量
主要上面输出的下面几句:
1 | ==9652== by 0x40052E: func (leak.c:4) |
提示在main函数(leak.c的第8行)fun函数(leak.c的第四行)产生了内存泄漏,通过分析代码,原因定位,问题解决。
valgrind
不仅可以检测内存泄漏,还有其他很强大的功能,由于本文以内存泄漏为主,所以其他的功能就不在此赘述了,有兴趣的可以通过valgrind --help
来进行查看
对于Windows下的内存泄漏检测工具,笔者推荐一款轻量级功能却非常强大的工具UMDH,笔者尝试了各种工具(免费的和收费的),最终发现了UMDH,如果你在Windows上进行开发,强烈推荐。
2.11 内存碎片⭐
内存碎片主要分为外部内存碎片和内部内存碎片:
系统中有一个空闲链表,当有程序申请的时候,系统遍历空闲链表找到第一个大于等于申请大小的空间分配给程序,但频繁的分配和释放内存,可能会导致内存碎片的产生(比如,一开始分配了几个小块内存,后来又释放了其中几块内存,这样就会在已分配的内存之间形成一些小块的空闲空间,这些空闲空间由于不连续,可能无法满足后续较大内存块的分配需求,就像市场中一些摊位被拆除后,剩下的一些小空间无法再容纳大型的摊位一样)。虽然整体内存足够,但是无法为较大的内存请求分配连续的内存块,这就是外部碎片。
内部内存碎片是有时分配的内存块大于进程实际需要的内存量,这意味着有一些内存浪费在内部碎片中。
3. C++新特性
3.1 once_flag和call_once⭐
std::call_once
和 std::once_flag
是 C++11 中引入的线程安全的函数和类型,用于确保某个函数只被调用一次。
std::once_flag
是一个类型,用于标记一段代码是否已经被执行过。它必须通过引用传递给std::call_once
函数,以确保在多线程环境下仅仅执行一次。std::call_once
函数接受两个参数:一个可调用对象(可以是函数、lambda 表达式等)和一个std::once_flag
对象的引用。该函数会检查std::once_flag
对象是否被设置过,如果没有,就调用可调用对象,并设置std::once_flag
对象为已设置状态。std::call_once
函数充当了加锁的作用,但不用手动操作,函数会自动进行加解锁。
使用 std::call_once
和 std::once_flag
可以避免在多线程环境下多次执行同一个函数,从而提高程序性能和正确性。
1 |
|
在这个例子中,我们定义了一个名为 do_something
的函数,并将其作为参数传递给 std::call_once
函数。 std::once_flag
对象被声明为全局变量,以便在多个线程之间共享。
- 当第一次调用
do_something
函数时,std::call_once
将检查std::once_flag
是否已经被设置过。由于初始状态为未设置,因此std::call_once
将执行提供的可调用对象——这里是一个 lambda 表达式,输出一条消息表示函数被调用了一次。 - 当第二次调用
do_something
函数时,std::call_once
将不再执行提供的可调用对象,因为std::once_flag
已经被设置过。
通过这种方式,我们可以确保 do_something
函数中std::call_once
提供的可调用对象被调用一次,无论有多少个线程同时调用它。
1 | do_something() called once |
单例模式有三种实现方式,通过std::call_once
和std::once_flag
实现便是其中的一种。
3.2 lambda及底层实现机制⭐
lambda
表达式是C++11
中引入的一项新技术,利用lambda
表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象,并且使代码更可读。
底层实现原理:
Lambda
表达式可以表示闭包,因为C++不像go一样存在闭包操作,但可以通过lambda
操作实现伪闭包(底层创建了一个匿名类,该匿名类重载了()运算符,使得该类的对象可以像函数一样被调用,并且将捕获列表中的参数添加至匿名类中,调用 Lambda 表达式时,实际上是调用了闭包对象的operator()
方法,该方法可以访问和操作捕获的变量)。
闭包就是能够读取其他函数内部变量的函数。例如在 javascript 中,只有函数内部的子函数才能读取局部变量,所以闭包可以理解成“定义在一个函数内部的函数“。在本质上,闭包是将函数内部和函数外部连接起来的桥梁。这句话里面重点,闭包是一个函数内部的函数,可以读取它所关联函数的局部变量。
1)原型
1 | [capture list] (params list) mutable exception-> return type { function body } |
- capture list:捕获外部变量列表
- params list:形参列表
- mutable指示符:用来说用是否可以修改捕获的变量
- exception:异常设定
- return type:返回类型
- function body:函数体
通常使用以下三种方式省略某写成分来声明不完整的lamba表达式:
1 | // 格式1 |
其中:
- 格式 1 显式指定返回类型
- 格式 2 省略了返回值类型,但编译器可以根据以下规则推断出 Lambda 表达式的返回类型:
- 如果 function body 是完全由一条返回语句组成时(只有return 语句),则该 Lambda 表达式的返回类型由return语句的返回类型确定;
- 如果 function body 中没有 return 语句,则返回值为 void 类型。
- 格式 3 中省略了参数列表,类似普通函数中的无参函数。
1 | auto singleExpressionLambda = [](int x) { return x * 2; }; // 返回类型可以自动推断 |
2)如何使用
1 | // 1 匿名调用 |
上述代码定义了一个匿名函数后直接调用。我们可以通过auto初始化一个变量存储lambda表达式
1 | // 2 通过auto赋值 |
通过auto定义fname,然后存储了lambda表达式,之后调用fname即可。也可以通过函数指针的方式接受lambda表达式
1 | typedef void (*P_NameFunc)(string name); |
定义了一个类型别名 P_NameFunc,它代表一个函数指针,P_NameFunc定义了fname2函数指针接受了lambda表达式。也可以通过function对象接受lambda表达式
1 | // 4 function |
3)lamba的捕获
Lambda 表达式可以使用其作用域内的任何动态变量,但必须明确声明(明确声明哪些外部变量可以被该 Lambda 表达式使用)。那么,在哪里指定这些外部变量呢?Lambda 表达式通过在最前面的方括号 [] 来明确指明其内部可以访问的外部变量,这一过程也称过 Lambda 表达式“捕获”了外部变量。
类似参数传递方式(值传递、引入传递、指针传递),在 Lambda 表达式中,外部变量的捕获方式也有值捕获、引用捕获、隐式捕获。
1.值捕获
1 | // 1.值捕获 |
上述lambda表达式捕获了age和name,是以值的方式来捕获的。所以无法在lambda表达式内部修改age和name的值,如果修改age和name,编译器会报错,提示无法修改const常量,因为age和name是以值的方式被捕获的。
2.引用捕获
1 | int age = 33; |
[]里age和name前边添加了&,此时age和name是以引用方式捕获的。所以可以在lambda表达式中修改age和name的值。
C++的lambda表达式虽然可以捕获局部变量的引用,达到类似闭包的效果,但不是真的闭包,golang和python等语言通过闭包捕获局部变量后可以增加局部变量的声明周期,C++无法做到这一点,所以下面的调用会出现崩溃(C++可以通过智能指针实现伪闭包)。
1 | vector<function<void(string)>> vec_Funcs; |
use_lambda2中将lambda表达式存储在function类型的vector里,当use_lambda2结束后,里边的局部变量都被释放了,而vector中的lambda表达式还存储着局部变量的引用,在调用use_lambda3时调用lambda表达式,此时访问局部变量已经被释放了,所以导致程序崩溃。
但如果将传入的参数通过智能指针指向,并将智能指针作为形参传入函数,那么智能指针内部的引用计数会自动加减,防止局部变量被提前释放导致程序崩溃(可以参考C++服务器中的伪闭包操作)
3. 全部用值捕获,name用引用捕获
1 | int age = 33; |
通过=表示所有变量都以值的方式捕获,如果希望某个变量以引用方式捕获则单独在这个变量前加&
- 全部用引用捕获,只有name用值捕获
1 | int age = 33; |
通过&方式表示所有变量都已引用方式捕获,如果希望某个变量以值方式捕获则单独在这个变量前加=。
总结:
- [] 不捕获任何外部变量
- [变量名, …] 默认以值的形式捕获指定的多个外部变量(用逗号分隔),如果引用捕获,需要显示声明(使用 & 说明符)
- [=] 值捕获,捕获所有外部变量,在函数内有个副本使用
- [&] 引用捕获,捕获所有外部变量,在函数体内当作引用使用
- [=, &x] 值捕获所有外部变量,只按引用捕获 x 变量
- [&, =x] 引用捕获所有外部变量,只按值捕获 x 变量
- [this] 捕获当前类中的 this 指针。如果已经使用了 & 或者 = ,无需显式写出
[this]
,编译器会自动捕获this
指针,方便在 Lambda 函数体中访问类的成员
3.3 std::function⭐
std::function
是从c++11开始支持的特性,使用它需要包含<functional>
头文件
在cppreference中解释为:类模板 std::function 是一个通用的多态函数包装器。std::function
的实例可以对任何可以调用的目标实体进行存储、复制、和调用操作,这些目标实体包括
- 函数
- 函数指针
- lambda 表达式
- bind 创建的对象
- 重载了函数调用运算符的类(仿函数)
std::function
可以绑定全局函数,静态函数,但是绑定类的成员函数时,必须要借助std::bind
的帮忙
它不能被用来检查相等或者不相等,但是可以与 NULL 或者 nullptr 进行比较
通俗的来说可以把它当做一个函数指针来使用
1)如何使用
function的模板是 std::function<返回值类型(传入参数类型)> 方法名
1 | typedef std::function<void(std::shared_ptr<CSession>, const short& msg_id, const std::string& msg_data)> FunCallBack; |
这里我定义了一个名为 FunCallBack
的函数指针,它传入的参数类型: 1)std::shared_ptr<CSession>
实现伪闭包,防止目标被提前释放;2)constshort& msg_id
,用于存储消息id;3)const std::string& msg_data
用于存储消息内容;返回类型为void。
1 | std::map<short, FunCallBack> _fun_callback; |
其中,HelloWordCallBack是_fun_callback
类型的函数指针,被bind绑定于_fun_callback[MSG_HELLO_WORD]
中,对应的键MSG_HELLO_WORD
会触发对应的值HelloWordCallBack
。
3.4 std::bind
std::bind
将原函数的几个参数通过bind绑定传值,返回一个新的可调用对象,也就是改造现有函数,生成新的函数。
比如我现在需求,我要一个有 2 个 int 类型参数的函数,并且第二个参数默认为 2。你可千万不要屁颠屁颠的在去写一个f(int i, int i = 2)
,这里std::bind
的作用体现出来了,看:
1 | int f(int a, int b); |
然后将bind的返回值交给function,定义函数原型fun如下
1 | std::function<int(int,int)> fun = std::bind(&f, std::placeholders::_1, 2); |
注:1)std::bind返回后的函数和原函数是完全不同的函数,他们的内存地址完全不同
2)bind中的‘&’都是取址符不是引用
在使用 std::bind
绑定可调用对象时,有时加 &
有时不加,这主要取决于可调用对象的类型:
- 对于普通函数,加
&
和不加&
效果是一样的。因为函数名在大多数情况下会隐式转换为函数指针。 - 对于成员函数,必须加
&
。因为成员函数指针和普通函数指针不同,它需要明确指出是成员函数的指针,不能通过隐式转换得到。 - 对于仿函数,通常不加
&
。仿函数是一个重载了()
运算符的类的对象,它本身就是一个可调用对象,不需要取地址。
1)如何使用
std::bind
函数定义在头文件<functional>
中,std::bind
可以将可调用对象和参数一起绑定,绑定后的结果使用std::function
进行保存,并延迟调用到任何我们需要的时候,所以经常用来实现回调函数。
std::bind
的作用:
- 将可调用对象与参数一起绑定为另一个
std::function
供调用 - 将 n 元可调用对象转成 m(m < n) 元可调用对象,绑定一部分参数,这里需要使用
std::placeholders
2)原型
1 | template <class Fn, class... Args> |
模板1:
- 基于参数
fn
返回一个函数对象,并且以args
参数绑定为函数对象的参数。 - 每个参数要么绑定一个参数值,要么绑定为一个
std::placeholders
(占位符,如 _1, _2, …, _n)。 - 如果参数绑定成一个值,那么返回的函数对象将总使用绑定的参数值做为调用参数,即调用传入参数将不起作用;如果参数绑定为
std::placeholders
,那么返回的函数对象在被调用时需要传入实时参数,参数填充的位置即由placeholder
指定的序号。
模板2:
fn
:可调用的函数对象,比如函数对象、函数指针、函数引用、成员函数或者数据成员函数。args
:需要绑定的函数的参数列表,使用命名空间占位符std::placeholders::_1
,std::placeholders::_2
标志参数,其中std::placeholders::_1
标志为参数列表中的第一个参数,std::placeholders::_2
标志参数列表中的第二个参数,std::placeholders::_3
标志参数列表中的第三个参数,以此类推。
3)举例
1 | //绑定全局函数 |
placeholders表示占位符,_1表示新生成函数的第一个参数, _2表示新生成函数的第二个参数,将这些参数传递给原函数达到占位的效果,原函数的其余参数通过bind绑定固定值。
接下来定义类
1 | class BindTestClass |
成员函数实现:
1 | void BindTestClass::StaticFun(const string &str, int age) |
使用bind绑定静态成员函数
1 | //绑定类的静态成员函数,加不加&都可以 |
新生成的staticbind函数可以直接传递一个参数zack就完成了调用。接下来用bind绑定成员函数(这里是&都是取地址而不是引用)
1 | BindTestClass bindTestClass(33, "zack"); |
也可以用function对象接受bind返回的结果
1 | // function接受bind返回的函数 |
3.5 condition_variable
并发编程(5)——条件变量、线程安全队列 | 爱吃土豆的个人博客
3.6 右值引用
在前面已经介绍过了,这里主要说明右值引用的两大作用:
- 完美转发
- 移动语义,通过移动构造函数和移动赋值运算符,可以将临时对象的资源高效地转移到新对象中,避免不必要的拷贝。
参考:C++——完美转发(引用折叠+forward) | 爱吃土豆的个人博客
3.7 智能指针
前文已介绍
3.8 auto和decltype ⭐
auto
可以让编译器在编译期就推导出变量的类型
- auto的使用必须马上初始化,否则无法推导出类型
- auto在一行定义多个变量时,各个变量的推导不能产生二义性,否则编译失败
- auto不能用作函数参数(可用模板参数代替)
- 在类中auto不能用作非静态成员变量
- auto不能定义数组,可以定义指针
- auto无法推导出模板参数
- 在不声明为引用或指针时,auto会忽略等号右边的引用类型和cv限定
- 在声明为引⽤或者指针时, auto会保留等号右边的引⽤和cv属性
decltype
获取一个表达式的类型,而不对表达式进行求值(类似于sizeof
)。
decltype
不会像auto一样忽略引用和cv属性,decltype
会保留表达式的引用和cv属性
对于decltype(exp)有:
- exp是表达式,decltype(exp)和exp类型相同
- exp是函数调用,decltype(exp)和函数返回值类型相同
- 若exp是⼀个纯右值,则返回值为
T
- 若 e 是⼀个临终值,则返回值为
T&&
1 | int num = 10; |
3.9 泛化的常量表达式
1 | int N = 5; |
该段代码是错的,N是一个变量,我们不能使用一个变量来为数组开辟一个确定大小的空间,因为这个变量的值随时都可以变,不能用做一个确定的对象。我们需要声明为 const int N = 5
才可以 。
我们也可以通过泛化常数来解决:
1 | constexpr int N = 5; // N |
constexpr
告诉编译器这是⼀个编译期常量,甚至可以把一个函数声明为编译期常量表达式。
1 | constexpr int getFive(){ return 5; } |
3.10 nullptr
nullptr
是用来代替NULL
,一般C++会把NULL、0视为同一种东西,这取决去编译器如何定义NULL,有的定义为((void&)0)
,有的定义为0.
C++不允许直接将void*
隐式转换到其他类型,在进行C++重载时会发生混乱。
比如,我们想要重载 foo 函数:
1 | void foo(char *); |
在代码中使用 NULL
时会出现一些问题。在某些实现里,NULL
被定义为 ((void*)0)
。当我们编写 char *ch = NULL;
这样的代码时,NULL
实际上会被当作 0 来处理。而当调用 foo(NULL)
时,由于此时 NULL
等同于 0,编译器会选择调用 foo(int)
这个重载版本,这就可能导致代码的行为与预期不符,产生混淆。
为了解决这个问题,C++11 引入了 nullptr
关键字。nullptr
的出现是为了清晰地区分空指针和整数 0。nullptr
的类型是 nullptr_t
,它具有很好的兼容性,能够隐式转换为任何指针类型或者成员指针类型。同时,nullptr
也可以进行相等或不等的比较操作。所以,在需要使用空指针的场景下,建议用 nullptr
来替代 NULL
,这样可以避免因 NULL
带来的类型匹配歧义问题。例如,调用 foo(nullptr)
时,编译器会明确地将 nullptr
当作空指针来处理,从而调用 foo(char *)
版本的函数,让代码的行为更加明确和可预期。
3.11 并行
并行也是C++11新特性,包括std::thread、std::async、std::future以及各种锁。
4. STL容器
4.1 基础知识
4.1.1 什么是STL,包含哪些组件
广义上讲,STL分为3类:Algorithm(算法)、Container(容器)和lterator(迭代器),容器和算法通过迭代器可以进行无缝地连接。
详细的说,STL由6部分组成:容器(Container)、算法(Algorithm)、迭代器(lterator)、仿函数(Functionobject)、适配器(Adaptor)、空间配制器(Allocator)。
STL六大组件的交互关系:
- 容器通过空间配置器取得数据存储空间
- 算法通过迭代器存储容器中的内容
- 仿函数可以协助算法完成不同的策略的变化
- 适配器可以修饰仿函数。
STL六大组件的介绍:
容器:各种数据结构,如
vector
、list
、deque
、set
、map
等,用来存放数据,从实现角度来看,STL容器是一种模板类
。算法:各种常用的算法,如
sort
、find
、copy
、for_each
。从实现的角度来看,STL算法是一种模板函数
。迭代器:扮演了容器与算法之间的胶合剂,共有五种类型,从实现角度来看,迭代器是一种将
operator*
,operator->
,operator++
,operator-
等指针相关操作予以重载的模板类。所有STL容器都附带有自己专属的迭代器,只有容器的设计者才知道如何遍历自己的元素。原生指针(
int*
、double*
)也是一种迭代器。仿函数:行为类似函数,可作为算法的某种策略。从实现角度来看,仿函数是一种重载了
operator()
的class 或者classtemplate适配器:一种用来修饰容器或者仿函数或迭代器接口的东西。STL提供的
queue
和stack
,虽然看似容器,但其实只能算是一种容器配接器,因为它们的底部完全借助deque
,所有操作都由底层的deque供应。空间配置器:负责空间的配置与管理。从实现角度看,配置器是一个实现了动态空间配置、空间管理、空间释放的模板类。一般的分配器的
std:alloctor
都含有两个函数allocate
与deallocte
,这两个函数分别调用operator new(
)与delete()
,这两个函数的底层又分别是malloc(and free();
但是每次maloc
会带来格外开销
4.1.2 常见的容器
STL容器分为序列式容器和关联式容器,如下图所示:
序列容器:
vector(向量):
std::vector
是一个动态数组实现,提供高效的随机访问和在尾部进行插入/删除操作。是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素。vector 维护的是一个连续的线性空间,而且普通指针就可以满足要求作为vector的迭代器。扩充的过程并不是直接在原有空间后面追加容量,而是重新申请一块连续空间,将原有的数据拷贝到新空间中,再释放原有空间,完成一次扩充。需要注意的是,每次扩充是重新开辟的空间,所以扩充后,原有的迭代器将会失
list(链表):
std::list
是一个双向链表实现,支持在任意位置进行插入/删除操作,但不支持随机访问。与 vector 相比,list 的好处就是每次插入或删除一个元素,就配置或释放一个空间,而且原有的迭代器也不会失效。list 是一个双向链表,普通指针已经不能满足 list 迭代器的需求,因为 list 的存储空间是不连续的。list 的迭代器必需具备前移和后退功能,所以list 提供的是 Bidirectionallterator。list 的数据结构中只要一个指向 node节点的指针就可以了。
deque(双端队列):
std::deque
是一个双端队列实现,允许在两端进行高效插入/删除操作。vector 是单向开口的连续线性空间,deque 则是一种双向开口的连续线性空间。所谓双向开口,就是说 deque 支持从头尾两端进行元素的插入和删除操作。相比于 vector 的扩充空间的方式,deque 实际上更加贴切的实现了动态空间的概念。deque 没有容量的概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并连接起来,代价是复杂的迭代器结构,除非必要,应该尽可能的使用 vector。
array(数组):
std::array
是一个固定大小的数组实现,提供对数组元素的高效随机访问。forward_list(前向链表):
std::forward 1ist
是一个单向链表实现,只能从头到尾进行遍历,不支持双向访问。
关联容器:
- set(集合):
std::set
是一个有序的集合,不允许重复元素,支持快速查找、插入和删除。 - multiset(多重集合):
std::multiset
是一个有序的多重集合,允许重复元素。 - map(映射):
std::map
是一个有序的键值对集合,不允许重复的键,支持快速查找、插入和删除。 - multimap(多重映射):
std::multimap
是一个有序的多重映射,允许重复的键。 - unordered set(无序集合):
std::unordered set
是一个无序的集合,不允许重复元素,支持快速查找、插入和删除。 - unordered multiset(无序多重集合):
std::unordered multiset
是一个无序的多重集合,允许重复元素。 - unordered map(无序映射):
std::unordered map
是一个无序的键值对集合,不允许重复的键,支持快速
查找、插入和删除。 - unordered_multimap(无序多重映射):
std::unordered multimap
是一个无序的多重映射,允许重复的键。
容器适配器:虽然它不是容器,但它底层是依靠容器实现的
stack(栈):
std::stack
是一个基于底层容器的栈实现,默认使用std::deque
。是一种先进后出的数据结构,只有一个出口,stack 允许从最顶端新增元素,移除最顶端元素,取得最顶端元素。deque 是双向开口的数据结构,所以使用 deque 作为底部结构并封闭其头端开口,就形成了一个 stack。
queue(队列):
std::queue
是一个基于底层容器的队列实现,默认使用std::queue
。是一种先进先出的数据结构,有两个出口,允许从最底端加入元素,取得最顶端元素,从最底端新增元素,从最顶端移除元素。deque 是双向开口的数据结构,若以 deque 为底部结构并封闭其底端的出口,和头端的入口,就形成了一个 queue。(其实 list 也可以实现 deque)
priority_queue(优先队列):
std::priority_queue
是一个基于底层容器的优先队列实现,默认使用std::vector
。priority_queue
允许用户以任何次序将任何元素推入容器内,但取出时一定是从优先权最高(数值最高)的元素开始取。
4.1.3 迭代器⭐
4.1.3.1 型别和类别
迭代器相应型别有 5 种:
value_type
:迭代器所指对象的类型,原生指针也是一种迭代器,对于原生指针int*
,int
即为指针所指对象的类型,也就是所谓的value_type
difference_type
:用来表示两个迭代器之间的距离,对于原生指针,STL以C++ 内建的ptrdiff_t
作为原生指针的difference_type
。reference_type
:是指迭代器所指对象的类型的引用,reference_type
一般用在迭代器的*
运算符重载上,如果value_type
是T,那么对应的reference_type
就是T&
;如果value_type
是const T
,那么对应的reference_type
就是const T&
。pointer_type
:就是相应的指针类型,对于指针来说,最常用的功能就是operator*
和operator->
两个运算符。iterator_category
:的作用是标识迭代器的移动特性和可以对迭代器执行的操作,从iterator_category
可将选代器分为 Inputlterator、Output lterator、Forward lterator、Bidirectional lterator、RandomAccess lterator
五类
1 | template<typename Category, |
iterator class
不包含任何成员变量,只有类型的定义,因此不会增加额外的负担。由于后面三个类型都有默认值,在继承它的时候,只需要提供前两个参数就可以了。 这个类主要是⽤来继承的,在实现具体的迭代器时,可以继承上面的类,这样子就不会漏掉上面的 5 个型别了。
迭代器型别 iterator_category
对应有 5 个类别:
- 输入迭代器
Input Iterator
:此迭代器不允许修改所指的对象,是只读的。支持 ==、!=、++、*、->等操作 - 输出迭代器
Output Iterator
:允许算法在这种迭代器所形成的区间上进行只写操作。支持 ++、*等操作。 - 前向迭代器
Forward Iterator
:允许算法在这种迭代器所形成的区间上进行读写操作,但只能单向移动,每次只能移动一步。支持Input lterator
和Output lterator
的所有操作。 - 双向迭代器
Bidirectional Iterator
:允许算法在这种迭代器所形成的区间上进行读写操作,可双向移动,每次只能移动一步。支持Forward lterator
的所有操作,并另外支持-
操作。 - 随机访问迭代器
Random Access Iterator
:包含指针的所有操作,可进行随机访问,随意移动指定的步数。支持前面四种lterator
的所有操作,并另外支持[n]
操作符等操作。
大部分容器都有 begin()
和 end()
两个成员函数,begin()
用于返回指向容器第一个元素的迭代器,而 end()
返回指向容器最后一个元素的下一个位置的迭代器,即超尾。
迭代器常用的操作如下:
4.1.3.2 简单使用
a) 引用容器迭代器
我们定义容器的迭代器一般通过容器的iterator
和const_iterator
来表示迭代器的类型:
1 | // 迭代器it, it能读写vector<int>的元素 |
begin()
和 end()
返回的具体类型由对象是否是常量决定,如果对象是常量,begin
和end
返回const_iterator
;如果对象不是常量,返回iterator
:
1 | std::vector<int> v; |
b) 结合解引用和成员访问操作
解引用迭代器可获得迭代器所指的对象,如果该对象的类型恰好是类,就有可能希望进一步访问它的成员。例如,对于一个由字符串组成的vector
对象来说,要想检查其元素是否为空,令it
是该vector
对象的迭代器,只需检查it
所指字符串是否为空就可以了,其代码如下所示:
1 | (*it).empty() |
(*it).empty()
中的圆括号必不可少,该表达式的含义是先对it
解引用,然后解引用的结果再执行点运算符。也可以用 it->empty()
代替/
1 | std::vector<std::string> vs = {"hello", "world"}; |
为了简化上述表达式,C++语言定义了箭头运算符(->)。箭头运算符把解引用和成员访问两个操作结合在一起,也就是说,it->mem
和(*it).mem
表达的意思相同。
4.2 面试知识
4.2.1 push_back和emplace_back
push_back
和 emplace_back
是 C++ 标准库容器(如 std::vector
、std::list
、std::deque
等)提供的用于在容器尾部添加元素的成员函数。
push_back
:
- 当使用
push_back
时,需要先在容器外部创建一个对象,然后将这个对象传递给push_back
函数。如果传递的是左值,会调用拷贝构造函数将对象复制到容器中;如果传递的是右值,会调用移动构造函数将对象移动到容器中。相当于连续创建了两个内容相同但地址不同的对象,一个在外部用于给容器提供拷贝构造或赋值运算符的参数对象,一个在内部。 - 在添加对象时可能会涉及到拷贝或移动操作,尤其是对于大型对象,这些操作会带来一定的性能开销。可能会因为内存不够而抛出异常。
emplace_back
:
emplace_back
会直接在容器的内存空间中调用对象的构造函数进行对象的构造,它接收构造对象所需的参数,然后在容器内部原地构造对象,避免了不必要的拷贝或移动操作。- 由于直接在容器内部构造对象,避免了拷贝或移动操作,因此在性能上通常更优,特别是对于需要动态分配内存的对象。且因为容器内部已经开辟好内存了,不会因为内存原因抛出异常。
举例:
1 | class MyClass { |
- 使用
push_back
添加左值:先创建对象obj
,然后调用push_back
时会调用拷贝构造函数将obj
复制到容器中。 - 使用
push_back
添加右值:直接传递临时对象MyClass(2, "World")
给push_back
,会调用移动构造函数将临时对象移动到容器中。 - **使用
emplace_back
**:直接传递构造对象所需的参数3
和"C++"
给emplace_back
,它会在容器内部原地调用构造函数构造对象,不会触发拷贝或移动构造函数。
4.2.2 resize() 和 reserve()⭐
可以使用 resize、reserve、shrink_to_fit
调整向量的大小
1 | // 辅助函数,用于输出 vector 的大小和容量 |
resize(count)
改变std::vector
中的元素数量,如果不指定元素值会默认添加 0- 若
count
大于当前大小,会在容器末尾添加新元素,可能会导致内存重新分配。 - 若
count
小于当前大小,会移除容器末尾的元素,不会改变容量。
- 若
reserve(new_cap )
为std::vector
预留一定的内存空间,改变容器的容量,但不会改变容器中元素的数量,数量仍旧是之前的- 若
new_cap
大于当前容量,会重新分配内存,将原有元素复制(或移动)到新的内存空间,释放旧内存,容器容量变为new_cap
- 若
new_cap
小于或等于当前容量,不会进行内存重新分配,容器容量保持不变。
- 若
shrink_to_fit
用于释放多余的内存,使容器的容量尽可能接近当前大小
注意:reserve()
是改变容器容量,而 reverse()
是颠倒容器元素
4.2.3 STL 迭代器的原理(特性萃取 iterator_traits)⭐⭐
迭代器其实也是一种智能指针(模板类),重载了一些运算符(如 *
、->
、++
、--
等),使得迭代器在使用上类似于普通指针,但它可以完成更复杂的操作,主要是一种统一的接口来遍历和操作各种不同容器中的元素。
主要难点其实在于如何成为一个共用的工具?迭代器是如何判断其所指对象的类型是什么的,从而获取迭代器操作的算法的返回类型?(迭代器可以用于不同类型的容器,我们无法提前知道具体的类型,这就给确定返回类型带来了困难)
主要步骤是:函数模板的参数推导机制(但是无法返回函数类型只能是void或者非推导参数类型,因为我们不能提前知道推导的类型,自然返回类型也不能提前指定了,虽然可以通过auto加尾置返回类型解决,但我们的重心不在于这个)->类内嵌类型声明(虽然在类中可以为所指对象的类型定义别名,然后在模板函数中将该类传入,然后使用这个类定义的别名作为返回类型,但是如果传入给函数模板的是一个原始指针int*
,那么就没办法了,int*
可不是一个类,自然也没办法声明一个内嵌类型)->模板偏特化(通过一层类封装,使得传入模板函数的类或原始指针先进入一个封装类,在这个封装类中声明所指对象的类型的别名,然后将封装类中定义的类型别名作为模板参数返回类型即可)。
1 | // 封装类模板 |
这样,不同算法的返回类型就是迭代器,我们通过将一个迭代器(原生指针也是迭代器)传入算法中,然后将传入模板函数的类或原始指针先放入一个封装类中,在这个封装类中声明所指对象的类型的别名,然后将封装类中定义的类型别名作为模板参数返回类型。
原理可参考:手撕 STL 迭代器源码与 traits 编程技法
4.2.4 空间配置器(malloc
底层实现原理)⭐⭐
该问题也可以理解为:**
malloc
底层实现原理**
在这之前我们需要明白 new/delete 时,编译器做了什么操作?
在C++中,当我们调用 new/delete 运算符进行对象创建、销毁以及内存分配、释放时,通常包含以下两阶段操作:
- 对
operator new
来说,编译器首先会调用底层的库函数malloc()
进行内存分配,然后调用对象的构造函数进行对象内容的构造 - 对
operator delete
来说,编译器首先会调用对象的析构函数进行对象内容的销毁,然后调用底层库函数free()
进行内存的释放
但是在 STL allocator 中,上面的两点四步骤被分为了四个函数:
- 对象构造由
::construct()
负责; - 对象释放由
::destroy()
负责; - 内存配置由
alloc::allocate()
负责; - 内存释放由
alloc::deallocate()
负责
在STL中,为了高效地为容器进行空间管理,并且防止频繁向系统申请小块内存而造成内存碎片、影响程序运行效率,STL 建立了空间配置器为容器进行空间配置。
考虑到频繁向系统申请小块内存而造成内存碎片的问题,STL 空间配置器被分为了两类:
- 当配置区块超过 128 字节时,使用一级配置器,直接使用
malloc()
和free()
。如果一级配置器的内存也不够,会直接调用oom_malloc()
,其实该函数内部只不过是不断循环进行对象的配置释放、以期有一次因为其他对象的释放而使得本次操作能分配成功。 - 当配置区块不大于 128 字节时,使用二级配置器,采用内存池进行内存分配。
内存池其实就是先申请一块比较大的内存块已做备用,当需要内存时,直接到内存池中去取,当池中空间不够时,再向内存中去取,当用户不用时,直接还回内存池即可。避免了频繁向系统申请小块内存所造成的效率低、内存碎片以及额外浪费的问题。
内存池中的空间是以哈希桶结构管理的,这里的哈希桶是以 8 字节 的 整数倍 进行设置的, 如果用户所需内存块不是8的整数倍,向上对齐到8的整数倍。原因有两个:
- 因为用户申请的空间基本都是4的整数倍,其他大小的空间几乎很少用到。
- 每个桶下面悬挂一个个的未被分配的空间,每个未被分配的内存块的首部(前 4 或 8 个字节)会存储下一个未分配内存块的地址(如果没有下一个内存块则存储
nullptr
),而32位下指针是 4 字节, 64 位系统下的地址是 8 字节。
首部地址其实是用 union
联合体存储的,以便节省空间。
二级配置器的空间申请流程:
- 如果配置空间大于 128 字节,使用 一级 allocator 进行分配
- 若配置空间 小于 128 字节且不是 8 的倍数,则将其上升至 8 的倍数去找相应大小的桶,如果下面有悬挂内存,就把第一个给用户
- 如果桶下没有内存,去找内存池索要(调用
refill()
函数从内存池中取),并将第一个内存块返回给用户
使用 refill()
填充内存块时,需注意:
桶下无可使用空间,向内存池接着索要
nobjs(20)
个 n 字节小块,需要计算内存池剩余空间是否足够给出- 如果剩余空间足够,就给出空间
- 如果剩余空间不够 20 块,就把能分配整数块空间一块一块的先切割出去
- 不足一块时,将剩余内存挂接到链表中,通过系统堆向内存池中补充内存
- 如果补充成功正常使用
- 如果补充失败,从哈希桶中找到比请求空间更大的内存块进行补充
- 如果补充成功正常使用
- 如果再次补充失败,向一级空间配置器申请补充
- 若还是失败,则抛出异常
二级配置器空间回收时:
- 和申请一样,以 128 为分界线
- 大于 128 交给 一级空间配置器来释放
- 小与 128 则找到对应的哈希桶,头插 到其中
4.2.5 deque的内存分配策略
deque
内部并不使用一个单一的连续内存块,而是将元素分割成多个固定大小的块(也称为缓冲区或页面),并通过一个中央映射数组(通常称为map)来管理这些块。具体来说,deque
的内部结构可以分为以下几个部分:
中央映射数组(Map):
一个指针数组,指向各个数据块。
map
本身也是动态分配的,可以根据需要增长或收缩。map
允许deque
在两端添加新的数据块,而无需移动现有的数据块。
数据块(Blocks):
每个数据块是一个固定大小的连续内存区域,用于存储元素。
数据块的大小通常与编译器和平台相关,但在大多数实现中,数据块的大小在运行时是固定的(如512字节或更多,具体取决于元素类型的大小)。
起始和结束指针:
deque 维护指向中央映射数组中第一个有效数据块的指针以及第一个无效数据块的指针。
这些指针帮助 deque 快速地在两端添加或删除数据块。
4.2.6 关联容器与无序关联容器的底层区别
关联容器如 map、set 底层通过红黑树实现(也可以通过AVL,但插入、删除时效率略低于红黑树),而无序关联容器底层通过哈希表实现。
数据结构 | 优点 | 缺点 |
---|---|---|
红黑树/AVL(关联容器) | 1. 有序性,便于有序遍历和范围查找 2. 插入、删除和查找平均与最坏时间复杂度均为(O(log n)),性能稳定 3. 无需额外空间处理哈希冲突 |
1. 每个节点需存储指针和颜色等信息,空间开销大 2. 插入和删除后需旋转与颜色调整,操作复杂、效率低 3. 遍历效率低,需指针跳转,缓存命中率低(相比数组这些连续缓存结构而言),效率相对低 |
哈希表(无序关联容器) | 1. 理想情况下查找、插入和删除时间复杂度可达(O(1)),速度快 2. 只需存储键值对和少量冲突处理信息,空间利用率高 3. 适合处理大规模数据 |
1. 元素无序,不便于有序遍历和范围查找 2. 存在哈希冲突,处理不当性能会急剧下降,O(n) 3. 性能依赖哈希函数质量,函数不佳会增加冲突概率 |
4.2.7 map关联容器的设计模式?(什么是红黑树?)
红黑树相当于是一个二叉搜索树BST,它的插入、删除步骤包括BST的插入、删除,但涉及到了颜色的变化和旋转。红黑树并不是一个绝对平衡的二叉树,相比于AVL树,红黑树只是相对平衡的,它只要求满足红黑树的五大性质即可:
- 节点颜色:每个节点要么是红色,要么是黑色。
- 根节点颜色:根节点是黑色。
- 叶子节点颜色:所有叶子节点(NULL 节点,即空节点)都是黑色的。这里的叶子节点不存储实际数据,仅作为树的终端。
- 红色节点限制:如果一个节点是红色的,则它的两个子节点都是黑色的。也就是说,红色节点不能有红色的子节点(不能有2个连续的红色节点,但可以有2个连续的黑色节点)。
- 黑色平衡:从任意节点到其所有后代叶子节点的路径上,包含相同数量的黑色节点。这被称为每条路径上的黑色高度相同。
这五个性质保证了红黑树在插入和删除操作后,树的高度不会相差太大,从而维持了树的平衡。因此删除、查找效率和AVL树基本相同,均为**O(logn)**,但相比AVL树,红黑树在插入、删除后最多只需要旋转两次+颜色变换即可恢复红黑树的性质,而AVL需要严格平衡,复杂度明显高于红黑树。
而在查找方面,如果数据是随机乱序的,则BST树基本是平衡的,它的查找效率维持在O(logn),而一旦数据是有序的,那么BST就只会有左子树或右子树,此时的查找效率是O(n),而红黑树的查找效率基本维持在O(logn),因此红黑树的查找效率高于BST树。但因为红黑树不是严格的高度平衡,其树的高度可能会比 AVL 树略高,因此在查找操作上,红黑树的平均时间复杂度虽然也是 O(logn),但实际性能可能会略逊于 AVL 树。
开销方面,AVL节点内需要一个 int 保存高度,明显大于红黑树的 bool 颜色,开销是AVL大。
至于排序,我们可以通过中序遍历将通过AVL或红黑树实现的map的键值对有序存储至指定容器中。
4.2.8 中序遍历
1 | // 中序遍历辅助函数 |
key 会从小到大依次排列到 vector 中。
4.2.9 Vector底层实现和扩容⭐
a)底层实现
Vector
在堆中分配了一段连续的内存空间来存放元素 ,且分配了三个迭代器表示 vector
的容量。
(1) first : 指向的是vector中对象的起始字节位置
(2) last : 指向当前最后一个元素的末尾字节
(3) end : 指向整个vector容器所占用内存空间的末尾字
(1) last - first : 表示 vector 容器中目前已被使用的内存空间
(2) end - last : 表示 vector 容器目前空闲的内存空间
(3) end - first : 表示 vector 容器的容量
b)扩容
若集合已满(end - first < 元素类型占用的字节数),在新增数据的时候,就要分配⼀块更⼤的内存,将原来的数据复制过来,释放之前的内存,再插入新增的元素。所以对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了
那么新增的容量选择多少合适?有什么方式可以选择?
1、固定扩容
机制:每次扩容的时候在原 capacity()
的基础上加上固定的容量,比如初始 capacity
为100,扩容⼀次为 capacity + 20
,再扩容仍然为 capacity + 20
;
缺点:考虑⼀种极端的情况, vector每次添加的元素数量刚好等于每次扩容固定增加的容量 + 1,就会造成⼀种情况,每添加⼀次元素就需要扩容⼀次,而扩容的时间花费十分⾼昂。所以固定扩容可能会面临多次扩容的情况,时间复杂度较高;
优点:固定扩容方式空间利用率比较高。
2、加倍扩容
机制:每次扩容的时候原 capacity
翻倍,比如初始 capcity = 100
, 扩容一次变为 200
, 再扩容变为 400
;
优点:⼀次扩容 capacity 翻倍的方式使得正常情况下添加元素需要扩容的次数大大减少(预留空间较多),时间复杂度较低;
缺点:因为每次扩容空间翻倍,而很多空间没有利用上,空间利用率不如固定扩容。在实际应用中,一般采用空间换时间的策略,加倍扩容的使用次数多一些
4.2.10 迭代器删除元素(迭代器失效)⭐
对于序列容器 vector, deque来说,使⽤ erase(itertor) 后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动⼀个位置,并且 erase 会返回下⼀个有效的迭代器;此外, vector 底层进行扩容后,原有容器中的所有迭代器均会失效。vector insert新元素后,后面元素的迭代器也会失效。
对于关联容器 map set 来说,使⽤了 erase(iterator) 后,当前元素的迭代器失效,但是其结构是红⿊树,删除当前元素的,不会影响到下⼀个元素的迭代器,所以在调⽤ erase 之前,记录下⼀个元素的迭代器即可。
对于 list 来说,它使⽤了不连续分配的内存,并且它的 erase ⽅法也会返回下⼀个有效的 iterator,因此上⾯两种正确的⽅法都可以使⽤。
清空 vector 数据时,如果保存的数据项是指针类型,需要逐项 delete,否则会造成内存泄漏
4.2.11 迭代器的作用?和指针的对比
作用:
迭代器其实也是一种智能指针(模板类),重载了一些运算符(如 *
、->
、++
、--
等),使得迭代器在使用上类似于普通指针,但它可以完成更复杂的操作,主要是一种统一的接口来遍历和操作各种不同容器中的元素。
主要难点其实在于如何成为一个共用的工具?迭代器是如何判断其所指对象的类型是什么的,从而获取迭代器操作的算法的返回类型?
主要步骤是:函数模板的参数推导机制(但是无法函数返回类型只能是void或者非推导参数类型,因为我们不能提前知道推导的类型,自然返回类型也不能提前指定了,虽然可以通过auto加尾置返回类型解决,但我们的重心不在于这个)->类内嵌类型声明(虽然在类中可以为所指对象的类型定义别名,然后在模板函数中将该类传入,然后使用这个类定义的别名作为返回类型,但是如果传入给函数模板的是一个原始指针int*
,那么就没办法了,int*
可不是一个类,自然也没办法声明一个内嵌类型)->模板偏特化(通过一层类封装,使得传入模板函数的类或原始指针先进入一个封装类,在这个封装类中声明所指对象的类型的别名,然后将封装类中定义的类型别名作为模板参数返回类型即可)。
原理可参考:手撕 STL 迭代器源码与 traits 编程技法
和指针的对比:
迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的⼀些功能,通过重载了指针的⼀些操作符, ->、*、 ++、 –等。迭代器封装了指针,是⼀个“可遍历STL( Standard Template Library)容器内全部或部分元素”的对象, 本质是封装了原⽣指针,是指针概念的⼀种提升(lift),提供了⽐指针更⾼级的⾏为,相当于⼀种智能指针,他可以根据不同类型的数据结构来实现不同的++, –等操作。
迭代器返回的是对象引⽤⽽不是对象的值,所以cout只能输出迭代器使⽤*取值后的值⽽不能直接输出其⾃身。
4.2.12 vector和list的区别⭐
- 实现上
- vector 使用动态数组实现。连续的内存块,支持随机访问。在尾部进⾏插⼊/删除操作较快,但在中间或头部进行插入/删除会涉及大量元素的移动。
- list 使⽤双向链表实现。不连续的内存块,不支持随机访问。在任意位置进⾏插⼊/删除操作都是常数时间复杂度。
- 访问上
- vector⽀持通过索引进⾏快速随机访问。使⽤迭代器进⾏访问时,效率较⾼。
- List不⽀持通过索引进⾏快速随机访问。迭代器在访问时需要遍历链表,效率相对较低。
- 内存管理
- Vector使⽤动态数组,需要在预估元素数量时分配⼀块较⼤的内存空间,不够时才进⾏翻倍扩容
- list由于采⽤链表结构,动态分配的内存⽐较灵活。每个元素都有⾃⼰的内存块, list每次插⼊新节点都会进⾏内存申请
- 应用
- Vector适⽤于需要频繁随机访问、在尾部进⾏插⼊/删除操作的场景。
- list适⽤于需要频繁在中间或头部进⾏插⼊/删除操作、不要求随机访问的场景。
5. 其他
5.1 一个进程占用了系统中的哪些资源
- 内存资源:堆、栈、共享内存…
- CPU资源:时间片、上下文切换
- 文件和I/O资源:文件描述符、打开的文件
- 进程控制块(PCB)、信号量、互斥锁、管道、消息队列
- 网络资源:socket
5.2 linux/win 系统内存管理机制
虚拟内存、内存分页、内存分段
5.3 进程中断时发生了什么
中断触发
- 硬件中断:由外设(如键盘、硬盘、网卡)或 CPU 自身(如定时器)触发。
- 示例:用户按下键盘,键盘控制器发送中断信号到 CPU。
- 软件中断:通过系统调用(如
int 0x80
)或异常(如除以零)触发。- 示例:进程调用
write()
函数写入文件,触发软中断进入内核。
- 示例:进程调用
- 信号:由其他进程或内核发送的异步通知(如
SIGINT
)。
中断响应
- 关中断:CPU 立即停止响应其他中断(防止嵌套中断导致混乱)。
- 保存当前上下文:
- 寄存器:保存通用寄存器(如 RAX、RBX)、段寄存器(CS、DS)等。
- 程序计数器(PC):记录中断发生时的指令位置。
- 栈指针(SP):保存当前栈的状态。
- 识别中断源:通过中断向量表(Interrupt Vector Table)找到对应的中断处理程序入口地址。
中断处理
- 执行中断服务程序(ISR):
- 硬件中断:处理外设请求(如读取键盘输入、DMA 完成)。
- 软件中断:执行系统调用(如分配内存、创建进程)。
- 信号处理:执行用户自定义的信号处理函数(如捕获
SIGINT
时终止进程)。
- 可屏蔽性:
- 硬件中断可分为可屏蔽(如键盘)和不可屏蔽(如电源故障)。
- 软中断通常不可屏蔽,必须立即处理。
上下文恢复与进程调度
- 恢复上下文:从中断栈中弹出保存的寄存器值,恢复程序计数器。
- 开中断:允许 CPU 再次响应新的中断。
- 进程调度:
- 如果中断处理改变了进程状态(如唤醒等待 I/O 的进程),可能触发调度。
- 若原进程优先级未变,则继续执行;否则可能切换到更高优先级进程。
5.4 进程的控制方式有哪几种
进程控制的主要功能是对系统中的所有进程实施有效的管理,它具有创建新进程、撤销已有进程、实现进程状态转换等功能。
简化理解:反正进程控制就是要实现进程状态转换。
如何实现进程控制?
操作系统会把各个处于不同状态的进程对应的PCB挂到相应的一系列队列当中,用这种方式来管理组织进程的PCB
进程控制过程如下:
进程控制是通过原语实现的(创建原语、撤销原语、阻塞原语、唤醒原语、切换原语),原语就是通过原子操作实现的指令行,无论哪个原语,要做的无非三类事情:
- 更新PCB中的信息(如修改进程状态标志、将运行环境保存到PCB、从PCB恢复运行环境)
a.所有的进程控制原语一定都会修改进程状态标志(修改进程状态)
b.剥夺当前运行进程的CPU使用权必然需要保存其运行环境(保存上下文)
c.某进程开始运行前必然要恢复期运行环境(恢复现场) - 将PCB插入合适的队列
- 分配/回收资源
比如当一些引起进程创建的事件发生时,系统会调用创建原语进行以下操作:
- 申请空白的PCB
- 为新进程分配所需资源
- 初始化PCB
- 将PCB插入就绪队列
引起进程创建的事件一般有:
- 用户登录:分时系统中,用户登录成功,系统会为其建立一个新的进程
- 作业调度:多道批处理系统中,有新的作业放入内存时,会为其建立一个新的进程
- 提供服务:用户向操作系统提出某些请求时,会新建一个进程处理该请求
- 应用请求:由用户进程主动请求创建一个子进程
操作系统还提供了用于进程终止的撤销原语,无非也就是更新一些PCB的内容,回收一些资源,如果一个处于运行态的进程,它运行正常结束或者运行过程中由于异常结束(如:整数除零这种错误),这种情况会使操作系统使用撤销原语来使进程从运行态转变为终止态(不是阻塞态),最后完成终止态的一系列相关操作,这个进程就被彻底撤销了。
另外,如果一个进程处于就绪态或阻塞态,此时如果有外界干预(如:用户主动请求撤销这个进程,如:windows下任务管理器直接杀进程正在操作),那操作系统也会使用撤销原语,来使这个进程直接从就绪态或阻塞态直接转变为终止态,然后完成一系列工作之后,最后结束进程。
总而言之,无论是进程正常结束、运行时遇到了异常而结束还是外界用户主动干预,操作系统都会调用撤消原语,将该进程切换至终止态。
操作系统还提供了进程的阻塞和进程的唤醒相关的原语就是阻塞原语和唤醒原语。阻塞原语和唤醒原语所做的这一系列的工作,其实也是处理PCB的一些内容,或者把它插入到合适的队列,这样的一系列的工作,那么如果一个正在运行的进程,它需要等待系统分配某种资源,或者说需要等待某个事件的发生,操作系统就会使用阻塞原语把这个进程进行阻塞,由此这个进程会由运行态转变为阻塞态,那么当这个进程等待的事件发生之后,操作系统又会使用唤醒原语来把刚才阻塞的那个进程从阻塞态要转变为就绪态,那么需要注意的是,进程的唤醒,这个事件其实就是进程被阻塞的时候所等待的那个事件。因此,阻塞原语和唤醒原语应该是成对使用的,进程因为什么事件被阻塞,那么就应该因为什么事件被唤醒。
另外呢,操作系统还提供了进程切换相关的原语。进程切换相关的切换原语,其实实现的也是一些PCB相关的一系列的操作。那么像当前正在运行的进程时间片到或者有更高的进程到达,然后抢占了CPU,或者说当前正在执行的进程,主动的申请阻塞,或者说当前进程终止,这些都有可能导致进程的切换。这个切换原语,会让当前处于运行态的进程变为阻塞态或者就绪态,另外,又会让处于一个处于就绪态的进程进入到运行态,所以这是切换原语的作用。
5.5 PCB
进程控制块能对进程动态特征的集中反映。因为我们将程序运行起来,首先需要将代码和数据加载到内存中,然后cpu访问内存中的代码和数据。如果只有一个进程加载到内存中,cpu访问很简单,但如果由多个进程加载到内存中时,操作系统如何将这些加载到内存中的进程管理起来?
为了解决这个问题,操作系统会在内存中开辟一个结构体来存放相应的进程,即此时就需要引出关于程序控制块PCB。对于PCB结构体,我们即可为其命名为task/pcb struct
,里面存放了进程相关的所有属性,方便系统分辨不同的进程的身份。
当程序被加载到内存运行时,操作系统会为其创建一个对应的 PCB 结构体,将进程的动态特征(如运行状态、资源占用)集中存储。多个进程的 PCB 结构体在内存中通过指针形成链表,就像一条锁链把所有进程串联起来。例如,每个 PCB 包含指向下一个 PCB 的指针,最终形成一个 “进程链表”。
操作系统通过遍历这条 “进程链表”,就能快速找到所有进程的 PCB 结构体,进而读取或修改进程属性。例如,当需要调度 CPU 执行某个进程时,操作系统只需遍历链表找到对应的 PCB,根据其中的内存地址和状态信息恢复进程运行。
PCB 的成员类型:
进程标识符:进程 pid,用于系统区分不同进程。ppid,记录父进程的pid(如果是父进程通过fork创建的子进程)
进程状态:运行、就绪、阻塞、僵尸等
优先级: 相对于其他进程的优先级。
程序计数器(PC): 程序中即将被执行的下一条指令的地址。
内存指针: 指向进程代码段、数据段、堆、栈的内存地址,帮助操作系统定位进程在内存中的位置。
上下文数据:保存 CPU 寄存器的当前值(如通用寄存器、状态寄存器),用于进程上下文切换时恢复现场。
其他信息:比如进程启动时间
5.6 多线程如何同步、异步操作
同步操作:互斥锁、内存栅栏、条件变量、原子操作
异步操作:线程池(线程池将任务提交与任务执行解耦,任务被立即加入任务队列,无需等待任务完成,主线程可继续执行其他操作)、async、future、promise、异步回调
5.7 中断和回调的区别
中断和回调是异步事件处理的两种核心机制。中断由硬件或内核强制触发,用于响应外设事件或系统调用,具有高优先级且运行于内核态,需保存完整上下文;回调则是软件设计模式,通过函数调用实现异步通知,运行于用户态且依赖事件注册机制。两者核心区别在于控制权来源(硬件 / 内核 vs 用户代码)和执行场景(底层硬件响应 vs 上层业务逻辑)。
对比项 | 中断 | 回调 |
---|---|---|
触发来源 | 硬件信号或内核指令 | 用户代码或库函数主动调用 |
执行态 | 内核态(特权模式) | 用户态(非特权模式) |
异步性 | 完全异步(事件不可预测) | 同步或异步(取决于实现) |
优先级 | 硬件固定优先级(高于进程) | 取决于调用者执行优先级 |
资源开销 | 需保存 / 恢复完整上下文,开销大 | 仅函数调用开销,资源占用小 |
典型场景 | 外设数据接收、定时器超时 | 网络请求完成、GUI 事件响应 |
控制权 | 硬件 / 内核强制接管 | 用户代码主动控制调用时机 |
回调的同步异步区别:
- 同步回调是回调在事件发生后立即执行,与主程序同步完成,与主程序共享同一调用栈,执行期间阻塞当前线程。
- 异步回调在事件发生后不立即执行,而是回调加入任务队列,等待合适时机执行,可能在不同线程或事件循环中执行,不阻塞主程序。
5.8 fork()
fork()
系统调用的主要功能是创建一个新的进程,这个新进程被称为子进程,而调用 fork()
的进程则被称为父进程。子进程是父进程的一个副本,它几乎复制了父进程的所有内容,包括代码段、数据段、堆、栈等。
- 在父进程中,
fork()
返回子进程的进程 ID(PID),这是一个正整数。 - 在子进程中,
fork()
返回 0。 - 如果
fork()
调用失败,它会返回 -1,并设置相应的错误码。
当 fork()
被调用时,操作系统会为子进程分配新的进程控制块(PCB),并将父进程的大部分内容复制到子进程中。这包括代码段、数据段(全局变量、静态变量等)、堆、栈(子进程有自己独立的堆和栈空间,但初始时内容与父进程相同)等,但子进程有自己独立的内存空间,对一个进程的内存修改不会影响另一个进程。
fork()
调用之后,父进程和子进程会从 fork()
调用的下一条语句开始独立执行。它们的执行顺序是不确定的,取决于操作系统的调度算法。
如果父进程在子进程结束之前退出,子进程会成为孤儿进程,被
init
进程收养。如果子进程结束后,父进程没有及时回收子进程的资源,子进程会成为僵尸进程,占用系统资源。
5.9 OSI/TCP模型
- 物理层:负责最后将信息编码成电流脉冲或其它信号用于网上传输
- 数据链路层:
- 数据链路层通过物理网络链路供数据传输。
- 规定了0和1的分包形式,确定了网络数据包的形式;
- 网络层
- 网络层负责在源和终点之间建立连接;
- 此处需要确定计算机的位置,通过IPv4,IPv6格式的IP地址来找到对应的主机
- 传输层
- 传输层向高层提供可靠的端到端的网络数据流服务。
- 每一个应用程序都会在网卡注册一个端口号,该层就是端口与端口的通信
- 会话层
- 会话层建立、管理和终止表示层与实体之间的通信会话;
- 建立一个连接(自动的手机信息、自动的网络寻址);
- 表示层:
- 对应用层数据编码和转化, 确保以一个系统应用层发送的信息 可以被另一个系统应用层识别;
5.10 CAS和ABA
CAS是解决多线程并行情况下使用锁造成性能损耗的一种机制。
- CAS操作包含三个操作数——内存位置(V)、预期原值(A)、新值(B)。
- 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。
- 否则,处理器不做任何操作。
- 无论哪种情况,它都会在CAS指令之前返回该位置的值。
- CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。
一个 CAS 涉及到以下操作:
假设内存中的原数据V,旧的预期值A,需要修改的新值B
- 比较 A 与 V 是否相等
- 如果比较相等,将 B 写入 V
- 返回操作是否成功
C++11中的CAS,C++11中的STL中的atomic类的函数可以让你跨平台。
1 | template< class T > bool atomic_compare_exchange_weak( std::atomic* obj,T* expected, T desired ); |
compare_exchange_weak
:尝试将原子对象的当前值与预期值进行比较,如果相等则将其更新为新值(不是将expected
的值赋给flag
,而是有另外一个设定值)并返回true
;否则,将原子对象的值加载进expected
(进行加载操作)并返回false
。此操作可能会由于某些硬件的特性而出现假失败,需要在循环中重试。1
2
3
4std::atomic<bool> flag{ false }; // 初始化为 false
bool expected = false; // 比较值
while (!flag.compare_exchange_weak(expected, true));它比较原子对象的当前持有值(相当于先调用
head.load()
)与预期值expected
是否相等。如果相等,则将原子对象的值更新为新值(此例为
true
),并返回true
。如果不相等,则不会更新原子对象的值,并将原子对象的当前值加载到
expected
中,返回false
。
返回
false
即代表出现了假失败,因此需要在循环中重试。。compare_exchange_strong
:类似于compare_exchange_weak
,但不会出现假失败,因此不需要重试。适用于需要确保操作成功的场合。1
2
3
4
5
6
7
8
9
10
11
12std::atomic<bool> flag{ false }; // 初始化为 false
bool expected = false; // 比较值
void try_set_flag() {
// 判断 flag 的值与 expected 是否相同,如果相同,将 flag 修改为我们设定的值,并返回 true
if (flag.compare_exchange_strong(expected, true)) {
std::cout << "flag 为 false,设为 true。\n";
}
else { // 如果不相同,将 expected 的值修改为我们设定的值,并返回false
std::cout << "flag 为 true, expected 设为 true。\n";
}
}假设有两个线程运行
try_set_flag
函数,那么第一个线程调用compare_exchange_strong
将原子对象flag
设置为true
。第二个线程调用compare_exchange_strong
,当前原子对象的值为true
,而expected
为false
,不相等,将原子对象的值设置给expected
。此时flag
与expected
均为true
。1
2
3
4
5
6std::thread t1{ try_set_flag };
std::thread t2{ try_set_flag };
t1.join();
t2.join();
std::cout << "flag: " << std::boolalpha << flag << '\n';
std::cout << "expected: " << std::boolalpha << expected << '\n';输出为:
1
2
3
4flag 为 false,flag 设为 true。
flag 为 true, expected 设为 true。
flag: true
expected: true
与 exchange
的另一个不同是,compare_exchange_weak
和 compare_exchange_strong
允许指定成功和失败情况下的内存次序。这意味着可以根据成功或失败的情况,为原子操作指定不同的内存次序。
1 | std::atomic<bool> data{ false }; |
- exchange 也是读改写操作,只不过它没有比较,而是直接修改原子变量,并返回原子变量持有的旧值
1 | x = b.exchange(false, std::memory_order_acq_rel); // 将 b 修改为false,并返回 b 持有的旧值 |
ABA问题描述:
- 进程P1在共享变量中读到值为A
- P1被抢占了,进程P2执行
- P2把共享变量里的值从A改成了B,再改回到A,此时被P1抢占。
- P1回来看到共享变量里的值没有被改变,于是继续执行。
真正要做到严谨的CAS机制,我们在compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。
举个栗子:
假设地址V中存储着变量值A,当前版本号是01。线程1获取了当前值A和版本号01,想要更新为B,但是被阻塞了。
这时候,内存地址V中变量发生了多次改变,版本号提升为03,但是变量值仍然是A
随后线程1恢复运行,进行compare操作。经过比较,线程1所获得的值和地址的实际值都是A,但是版本号不相等,所以这一次更新失败
解决思路:增加版本号,每次变量更新时把版本号+1,A-B-A就变成了1A-2B-3A。JDK5之后的atomic包提供了AtomicStampedReference
来解决ABA问题,它的compareAndSet
方法会首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志。全部相等,才会以原子方式将该引用、该标志的值设置为更新值。
5.11 最烦恼的解析
1 | class MyClass { |
在上面的代码中,MyClass obj()
; 被编译器解析为一个返回 MyClass
类型的函数 obj
,而不是一个 MyClass
类型的对象。这种情况被称为“最烦恼的解析”,导致编译器将原本应该是对象的构造解析为函数的声明。原因:
- 语法规则:C++ 的语法规则允许使用类名后跟括号的形式来声明函数(仿函数)。如果没有其他上下文,编译器会选择这种解析方式。
- 上下文歧义:在某些情况下,编译器无法明确判断你是想要创建一个对象还是声明一个函数,因此选择最符合语法的解析方式。
为了避免最烦恼的解析,可以使用如下方法:
1 | // 1. 使用花括号 |
所以我们如果使用仿函数作为可调用对象传入时,可以这样做:
1 | class background_task { |
但如果仿函数中有参数,那么就不会造成”最烦恼的解析”,因为上下文有解释,我是要调用仿函数(因为传入的参数和仿函数对应,构造函数与其不对应),比如:
1 | class background_task { |
“最烦恼的解析”一般在无传入参数的情况下发生。
5.12 为什么要使用join()等待子线程完成
虽然使用
std::thread
创建的线程在结束时会自动释放其资源,但在主线程(或创建线程的线程)中仍需要等待其子线程结束。我们需要在主线程中显式调用join()
函数等待子线程的结束,子线程结束后主线程才会继续运行。
原因如下:
- 如果创建的子线程在其执行过程中没有被主线程等待,那么当主线程结束或被销毁时,操作系统将会提前终止这个子线程,这可能导致子线程的资源(如内存、文件句柄等)不会被释放,产生资源泄漏。
- 如果不调用
join()
,主线程在没有等待子线程结束的情况下继续执行,可能会导致程序在子线程完成之前就结束,从而未能正确处理子线程的结果(子线程的结果可能不会被主线程处理)。 - 如果主线程需要依赖于子线程完成某些任务(例如数据处理或文件写入),需要通过
join()
确保子线程在主线程继续执行之前完成,可以避免因数据未更新而导致的不一致性。
线程的回收通过线程的析构函数来完成,即执行
terminate
操作。
5.13 detach() 不要将局部变量按引用传入
可以使用detach
允许子线程采用分离的方式在后台独自运行,不受主线程影响。主线程和子线程执行各自的任务,使用各自的资源。
注意:当一个线程被分离后,主线程将无法直接管理它,也无法使用
join()
等待被分离的线程结束。处理日志记录或监控任务这些线程一般会让其在后台持续运行,使用detach
。
1 | struct func { |
detach
使用时有一些风险,比如上述代码。
当主线程调用oops
时,会创建一个线程执行myfunc
的重载()
运算符,然后将主线程将oops
创建的一个线程分离。但注意,当oops
执行到 '}'
时,局部变量 some_local_state
会被释放,但引用(这里是引用传递而不是按值传递,按值传递不会引起该错误,因为线程中已经有一个自己的拷贝副本了)该局部资源的子线程 functhread
却仍然在后台运行,容易发生错误。
我们可以采取一些措施解决该问题:
- 通过智能指针传递局部变量,因为引用计数会随着赋值增加,可保证局部变量在使用期间不被释放,避免悬空指针的问题(网络编程中学习的伪闭包原理)。
- 按值传递,将局部变量的值作为参数传递而不是按引用传递,这么做需要局部变量有拷贝复制的功能,而且拷贝耗费空间和效率。
- 使用
join()
确保局部变量的生命周期,保证局部变量被释放前线程已经运行结束,但是可能会影响运行逻辑。
5.14 线程守卫&&joinable()
当启动一个子线程时,子线程和主线程是并发运行的。如果主线程由于某种原因崩溃(例如未捕获的异常),则整个进程将会终止(主线程崩溃或者结束时,主进程会回收所有线程的资源),这意味着所有正在运行的线程,包括子线程(不管有没有被detach)都会被强制结束,导致子线程未完成的操作(如数据库写入)被中断。这可能会导致子线程待写入的信息丢失。
为了防止主线程崩溃导致子线程异常退出,可以在主线程中捕获可能抛出的异常,在捕获到异常后,可以选择在主线程中等待所有子线程完成。这样可以确保即使主线程遇到问题,子线程仍然能够完成其操作,并安全地结束。
但是这样太过于繁琐,我们还得捕获异常后将对应的线程进行join
,但如果我们有多个线程和多个异常呢?难道还要一个个的组合,写异常处理?
可以使用RAII
技术:
1 | class thread_guard { |
joinable()
是std::thread
的一个成员函数,返回一个布尔值,指示线程是否可连接(即是否已创建且尚未调用join()
或detach()
),如果_t
是一个有效的线程对象且没有调用join()
或detach()
,那么调用join
等待该线程结束。
我们可以将需要保护的线程(可能发生异常错误的线程)传递给thread_guard创建一个实例,如果主线程异常发生,保护子线程实例的析构函数会自动调用,确保主线程发生异常时,子线程也能被正确管理,防止资源泄漏
举例:
1 | void auto_guard() { |
如上例所示,通过thread_guard
构造一个新实例来保护线程t,那么即使在 auto_guard
函数中发生异常,thread_guard
也会确保线程t被正确管理,避免资源泄漏。
5.15 线程中使用引用
在创建线程时,使用
std::thread
来传递参数时,参数是以拷贝的方式传递的。即使你传入的是一个左值(如一个变量),std::thread
会在内部创建该参数的拷贝。但是在main函数中,如果传入的实参是左值,形参类型是引用,那么函数不会创建副本,而是直接对传入的值进行修改。
主线程:当在主线程中调用函数时(没有创建线程,而是直接调用),参数是按值传递还是按引用传递取决于函数的参数声明。如果函数的参数是引用类型(如 int&
),那么传递的是对原始变量的引用,可以直接修改这个变量。
1 | void change_param(int& param) { |
子线程:当在子线程中调用函数时,即使参数在函数定义中是引用类型(如 int&
),如果在 std::thread
创建线程时直接传递一个变量(如 some_param
),这个变量仍会被复制到线程中,子线程内部的修改不会影响主线程中的原始变量。
1 | void change_param(int& param) { |
而且,上面这段代码和下面这段代码相同,都会报错:
1 | void change_param(int& param) { |
即使函数 change_param
的参数为int&
类型,我们传递给t2
的构造函数为some_param
,也不会达到在change_param
函数内部修改关联到外部some_param
的效果。
因为some_param
是外部传给函数ref_oops
实参的拷贝(左值,这里的拷贝不是右值,它仍然可以取地址,右值一般只会在字面常量、表达式返回值、函数非左值引用返回值中出现),左值传递给线程thread
的构造函数之后会被保存为右值引用(thread
内部通过move
,传入左值会被保存为右值,如果传入右值类型不会变化),右值如果传给调用对象change_param
就会报错。因为change_param
中的参数是左值引用,左值引用不能接收右值。有两种方法可以修正:
方法一:修改 change_param
的参数为 const
引用类型
1 | void change_param(const int& param) { |
但缺点是,不能对some_param
进行修改了,因为**const int&
既可以用于传递左值引用,也可以用于传递右值**,唯独不能修改传递过来的值。
方法二:传递 std::ref
1 | void change_param(int& param) { |
第一种方法是直接修改可调用对象参数列表的类型,使其可以接受右值类型。
第二种方法其实还是将左值参数通过ref
进行包装,使得thread
内部不会将其引用类型delay
,这样传递给调用对象的参数就仍是左值引用,而不是右值引用。
那么如果我传递的是一个左值,而不是实参的拷贝呢,会不会还有问题?
1 | void change_param(int& param) { |
该段函数中,我们传给线程调用对象change_param
的参数是一个左值,而change_param
形参的类型是引用,那么这样按理说应该是正确的,即线程内部对some_param
的处理会影响到外部的some_param
。但是,要注意线程无视引用,即使你传入的是左值,形参是引用,参数同样会被拷贝,除非你按引用传入(ref
),或者传入的实参本来就是个引用。
1 | void ref_oops() { |
线程调用中,左值同样要加ref
显式变为引用。
5.16 yield()
在线程中调用 yield()
函数之后,处于运行态的线程会主动让出自己已经抢到的CPU时间片,最终变为就绪态(就绪态的线程需要再次争抢CPU时间片,抢到之后才会变成运行态,这时候程序才会继续向下运行),这样其它的线程就有更大的概率能够抢到CPU时间片了。
使用这个函数的时候需要注意一点,线程调用了yield()
之后会主动放弃CPU资源,但是这个变为就绪态的线程会马上参与到下一轮CPU的抢夺战中,不排除它能继续抢到CPU时间片的情况,这是概率问题。
5.17 thread如何避免传入的对象是thread
虽然thread的拷贝构造函数被delete,但如果将thead作为可调用对象传入thread的构造函数时,仍会执行,但thread自定义的构造函数中有以下操作,可避免thead的拷贝操作:
1 | template <class _Fn, class... _Args, enable_if_t<!is_same_v<_Remove_cvref_t<_Fn>, thread>, int> = 0> |
enable_if_t
:这是一个 *SFINAE(Substitution Failure Is Not An Error)技术,用于在模板实例化过程中进行条件编译*。这里的条件 !is_same_v<_Remove_cvref_t<_Fn>, thread>
确保 _Fn
的类型在去除 const/volatile
修饰和引用后,不是 std::thread
类型本身,从而避免将 std::thread
对象作为函数参数,进一步避免线程的拷贝。**
关于这个约束你可能有问题,因为
std::thread
他并没有operator()
的重载,不是可调用类型,也就是说不能将std::thread
作为可调用参数传入,那么这个enable_if_t
的意义是什么呢?
1 | struct X{ |
在上段代码中,创建了一个 X 对象 x1,通过模板构造函数,传入了一个 Lambda 表达式(无参数的空函数)。模板构造函数匹配成功,因此 x1 被成功构造。
当试图通过已有的 X 对象 x1 创建另一个 X 对象 x2 时,编译器会选择模板构造函数。这是因为 x1 是一个 X 类型的对象,而模板构造函数可以接受任意类型(包括 X),并且与参数类型的匹配规则使得它可以接受一个 X 对象。这个过程不会导致编译错误,因为模板构造函数并不依赖于传入的对象是否是可调用的(构造函数的选择是基于类型匹配和参数的匹配,而不是基于可调用性),尽管 x1 不是可调用类型,编译器选择了这个构造函数来匹配。
以上这段代码可以正常的通过编译。这是重载决议的事情,但我们知道,std::thread
是不可复制的,这种代码自然不应该让它通过编译,选择到我们的有参构造,所以我们添加一个约束让其不能选择到我们的有参构造:
1 | template <class Fn, class... Args, std::enable_if_t<!std::is_same_v<std::remove_cvref_t<Fn>, X>, int> = 0> |
这样,这段代码就会正常的出现编译错误,信息如下:
1 | error C2280: “X::X(const X &)”: 尝试引用已删除的函数 |
5.18 joining_thread
线程守卫(RAII技术),即主线程出现异常时,希望子线程能够运行结束后才退出主程序。其实 joining_thread
就是一个自带RAII
技术的 thread
类,它和 std::thread
的区别就是析构函数会自动 join
joining_thread
是 C++17标准的备选提案,但是并没有被引进,直至它改名为 std::jthread
后,进入了C++20标准的议程(现已被正式纳入C++20标准)
并且 joining_thread
的赋值运算符在将将一个线程的管理权交给一个已经绑定线程的变量时,不会立即调用 terminate
引发崩溃,而是先 jion()
然后转移另一个线程的管理权。
1 | joining_thread& operator=(std::thread&& other)noexcept { |
5.19 std::jthread
std::jthread
相比于 C++11 引入的 std::thread
,只是多了两个功能:
- RAII 管理:在析构时自动调用 join()。
- 线程停止功能:线程的取消/停止。
std::jthread
所谓的线程停止只是一种基于用户代码的控制机制,而不是一种与操作系统系统有关系的线程终止。使用 std::stop_source
和std::stop_token
提供了一种优雅地请求线程停止的方式,但实际上停止的决定和实现都由用户代码来完成。如下:
1 | using namespace std::literals::chrono_literals; |
该段代码主要用于创建一个可以响应停止请求的线程。
- 当
std::jthread
对象超出作用域时,它会自动调用request_stop()
请求停止线程,并在销毁时调用join()
等待线程结束。 std::stop_token
允许线程检查是否接收到停止请求。在函数 f 中,循环体检查stop_token.stop_requested()
,如果返回 false,则继续执行;否则退出循环。- 在每次循环中,打印当前值并将其递增,然后线程休眠 200 毫秒。这样,每个数字的打印之间有一定的间隔。
- 在 main 函数中,主线程休眠 3 秒。这段时间内,f 函数将打印数字(大约会打印 15 个数字,因为 3 秒内会输出 1 到 15)。主线程结束后,
jthread
会自动请求停止并等待 f 函数完成。
std::jthread
提供了三个成员函数进行所谓的线程停止:
get_stop_source
:返回与jthread
对象关联的std::stop_source
,允许从外部请求线程停止。get_stop_token
:返回与jthread
对象停止状态关联的std::stop_token
,允许检查是否有停止请求。request_stop
:请求线程停止。
上面那段代码中,这三个函数并没有被显式调用,不过在 jthread
的析构函数中,会调用 request_stop
请求线程停止:
1 | void _Try_cancel_and_join() noexcept { |
至于 std::jthread thread{ f, 1 }
函数 f 的 std::stop_token
的形参是谁传递的?其实就是线程对象自己调用get_token()
传递的 ,源码一眼便可发现:
1 | template <class _Fn, class... _Args, enable_if_t<!is_same_v<remove_cvref_t<_Fn>, jthread>, int> = 0> |
std::stop_source:
- 这是一个可以发出停止请求的类型。当你调用
stop_source
的request_stop()
方法时,它会设置内部的停止状态为“已请求停止”。 - 任何持有与这个
stop_source
关联的std::stop_token
对象都能检查到这个停止请求。
std::stop_token:
- 这是一个可以检查停止请求的类型。线程内部可以定期检查
stop_token
是否收到了停止请求。 - 通过调用
stop_token.stop_requested()
,线程可以检测到停止状态是否已被设置为“已请求停止”。
5.20 什么是数据竞争
当某个表达式的求值写入某个内存位置,而另一求值读或修改同一内存位置时,称这些表达式冲突。拥有两个冲突的求值的程序就有数据竞争,除非
- 两个求值都在同一线程上,或者在同一信号处理函数中执行,或
- 两个冲突的求值都是原子操作(见 std::atomic),或
- 一个冲突的求值发生早于 另一个(见 std::memory_order)
5.21 lock_guard
我们可以使用lock_guard
进行自动加解锁,也就是之前说的RAII技术,当lock_guard
被实例化的时候进行加锁,当lock_guard
被析构的时候进行解锁。
1 | void use_lock() { |
lock_guard
在作用域结束时自动调用其析构函数解锁,这么做的一个好处是简化了一些特殊情况从函数中返回的写法,比如异常或者条件不满足时,函数内部直接return,锁也会自动解开。
lock_guard
也是一个“管理类”模板,用来管理互斥量的上锁与解锁,可以看一下 lock_guard
的源码实现:
1 | _EXPORT_STD template <class _Mutex> |
首先lock_guard
作为管理类,要求不可复制,我们定义复制构造与复制赋值为弃置函数。
它只保有一个私有数据成员,一个引用,用来引用互斥量。构造函数中初始化这个引用,同时上锁,析构函数中解锁,这是一个非常典型的 RAII 式的管理。
同时它还提供一个有额外std::adopt_lock_t
参数的构造函数 ,如果使用这个构造函数,则构造函数不会上锁。 adopt_lock
表示这个互斥量的锁已经在其他地方被获取,这样,lock_guard
会在构造时不再调用 lock()
方法,而是直接采用已锁定的状态。这个功能在某些情况下非常有用,尤其是在希望将一个已经被锁定的互斥量传递给 lock_guard
的时候。比如:
1 | std::mutex mtx; |
我们一般使用 lock_guard
时,经常使用下面的形式:
1 | std::mutex mtx; |
使用{}
创建了一个块作用域,限制了对象 lc 的生存期,进入作用域构造 lock_guard 的时候上锁(lock),离开作用域析构的时候解锁。
我们要尽可能的让互斥量上锁的粒度小,只用来确保必须的共享资源的线程安全。“粒度”通常用于描述锁定的范围大小,较小的粒度意味着锁定的范围更小,因此有更好的性能和更少的竞争。
5.22 try_lock
try_lock
是互斥量中的一种尝试上锁的方式。与常规的 lock 不同,try_lock
会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。
它的返回类型是 bool ,如果上锁成功就返回 true,失败就返回 false。
这种方法在多线程编程中很有用,特别是在需要保护临界区的同时,又不想线程因为等待锁而阻塞的情况下。
1 | std::mutex mtx; |
5.23 unique_lock
unique_lock 也是一种管理类模板(满足可移动构造和可移动赋值但不满足可复制构造或可复制赋值),灵活性比lock_guard
高很多(允许手动加解锁、延迟锁定、有时限的锁定尝试、递归锁定、所有权转移和与条件变量一同使用,存在RAII回收),但是效率比较差,内存占用也比较多。
工作中一般使用lock_guard
,但在和条件变量配合使用或者希望更加自由时可以时候unique_lock。
5.24 once_flage 和 call_once
once_flage
和 call_once
也相当于一种锁,std::call_once
确保所给的函数在多线程环境中只会被调用一次(仅仅只会调用一次),即使多个线程同时调用 call_once
,只有一个线程会实际执行该函数,其它线程会等待。
1 | std::once_flag s_flag; |
其实这也是C++11及之后为什么局部静态变量是线程安全的了。
- 在C++11之前,多线程同时首次调用
GetInstance()
时,可能触发竞态初始化,导致未定义行为(如重复初始化、值覆盖)。 - 在C++11及之后,多个线程首次初始化时,仅有一个线程执行初始化,其他线程等待直至完成。
- 通过原子标志 + 互斥锁实现,仅首个线程执行初始化,后续线程通过原子操作感知已初始化,避免加锁,确保 “初始化完成” 的状态全局可见
原理就是多线程中执行了once_flage 和 call_once,保证多线程初始化时仅有一个线程执行初始化。
5.25 不得向锁所在的作用域之外传递指针和引用
不得向锁所在的作用域之外传递指针和引用,指向受保护的共享数据没无论是通过函数返回值将它们保存到对外可见 内存,还是将它们作为参数传递给使用者提供的函数。简而言之:切勿将受保护数据的指针或引用传递到互斥量作用域之外,不然保护将形同虚设。
5.26 死锁
死锁其实就是不同线程在互斥上争抢锁:有两个线程,都需要同时锁住两个互斥,才可以进行某项操作,但它们分别都只锁住了一个互斥,都等着再给另一个互斥加锁。于时,双方毫无进展,因为它们都在等待对方解锁互斥。这种情形为死锁。
第一种情况:防范死锁的建议通常是:始终按相同的顺序对两个互斥加锁。这也是层级加锁的实现原理。比如我们这里将线程1和线程2都按 A->B 的顺序进行加锁,一般情况下就不会发生死锁。
第二种情况:但是有的时候即使固定锁顺序,依旧会产生问题。当有多个互斥量保护同一个类的对象时,对于相同类型的两个不同对象进行数据的交换操作,为了保证数据交换的正确性,就要避免其它线程修改,确保每个对象的互斥量都锁住自己要保护的区域。如果按照前面的的选择一个固定的顺序上锁解锁,则毫无意义,比如:
1 | struct X{ |
我们对同一个类的两个实例进行数据交换时,会导致它们陷入死锁:
1 | X a{ "1" }, b{ "2" }; |
1
执行的时候,先上锁 a 的互斥量,再上锁 b 的互斥量。2
执行的时候,先上锁 b 的互斥量,再上锁 a 的互斥量。
完全可能线程 A 执行 1 的时候上锁了 a 的互斥量,线程 B 执行
2
上锁了 b 的互斥量。线程 A 往下执行需要上锁 b 的互斥量,线程 B 则要上锁 a 的互斥量执行完毕才能解锁,哪个都没办法往下执行,死锁。其实也就回到了最初的问题。
解决方法:
法1:
C++ 标准库有很多办法解决这个问题,可以使用std::lock
,它能一次性锁住多个互斥量,并且没有死锁风险。修改 swap 代码后如下:
1 | void swap(X& lhs, X& rhs) { |
因为前面已经使用了 std::lock
上锁,所以后的 std::lock_guard
构造都额外传递了一个 std::adopt_lock
参数,让其选择到不会上锁的构造函数。函数退出也能正常解锁。
std::lock
给 lhs.m
或 rhs.m
上锁时若抛出异常,则在重抛前对任何已锁的对象调用 unlock()
解锁,也就是 std::lock
要么将互斥量都上锁,要么一个都不锁。如果 std::lock
给lhs.m
或 rhs.m
上锁时,这两个锁的任意一个被锁了, std::lock
就不可能不执行,所以在执行std::lock之前,必须保证要处理的所有锁都处于unlock状态。
法2:
此外,C++17新增了RAII类模板 std::scoped_lock
。std::scoped_lock<>
和 std::lock_guard<>
完全等价 ,只不过前者是可变参数模板,接收各种互斥型别作为模板参数列表,还以多个互斥对象作为构造函数的参数列表,通常scoped_lock
的效果比裸调用std::lock
更好。
代码可以改写为:
1 | void swap(X& lhs, X& rhs) { |
上例利用了C++17的新特性:类模板参数推导。①处的代码等价于
1 | std::scoped_lock guard<std::mutex, std::mutex> guard(lhs.m,rhs.m); |
如果我们需要同时获取多个锁,那么std::lock和std::scoped_lock 可以帮助我们防范死锁。但若代码分别获取各个锁,那么就需要程序员依靠经验将加锁和解锁的功能封装为独立的函数,这样能保证在独立的函数里执行完操作后就解锁,不会导致一个函数里使用多个锁的情况。
以下是一些常用的规则,用于约束程序员的行为,帮助写出无死锁的代码:
- 避免嵌套锁
线程获取一个锁时,就别再获取第二个锁。每个线程只持有一个锁,自然不会产生死锁。如果必须要获取多个锁,使用std::lock
- 避免在持有锁时调用外部代码
这个建议是很简单的:因为代码是外部提供的,所以没办法确定外部要做什么。外部程序可能做任何事情,包括获取锁。在持有锁的情况下,如果用外部代码要获取一个锁,就会违反第一个指导意见,并造成死锁(有时这是无法避免的)。当写通用代码时(比如保护共享数据中的 Date 类)。这不是接口设计者可以处理的,只能寄希望于调用方传递的代码是能正常执行的。 - 使用固定顺序获取锁
如同第一个示例那样,固定的顺序上锁就不存在问题。 - 层级加锁 按特定方式规定加锁次序,在运行期间据此查验枷锁操作是否遵守预设规则
层级锁能保证我们每个线程加锁时,一定是先加权重高的锁,后加权值低的锁,如果反过来就会抛出异常,并且释放时也保证了顺序。主要原理就是将当前锁的权重保存在线程变量中,这样该线程再次加锁时判断线程变量的权重是否大于锁的权重,如果满足条件则继续加锁。
要加锁时先检查当前线程的层级值是否大于要加锁的互斥量的层级值,只有大于才能加。
5.27 如何排查死锁
在 Linux 下,我们可以使用 pstack
+ gdb
工具来定位死锁问题。
pstack
命令可以显示每个线程的栈跟踪信息(函数调用过程),它的使用方式也很简单,只需要 pstack <pid>
就可以了。
这个命令在排查进程问题时非常有用,比如我们发现一个服务一直处于work状态(如假死状态,好似死循环),使用这个命令就能轻松定位问题所在;可以在一段时间内,多执行几次pstack,若发现代码栈总是停在同一个位置,那个位置就需要重点关注,很可能就是出问题的地方;
那么,在定位死锁问题时,我们可以多次执行 pstack
命令查看线程的函数调用过程,多次对比结果,确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。
我用 pstack 输出了我前面模拟死锁问题的进程的所有线程的情况,我多次执行命令后,其结果都一样,如下:
1 | ps -u |
可以看到,Thread 2 和 Thread 3 一直阻塞获取锁(pthread_mutex_lock)的过程,而且 pstack 多次输出信息都没有变化,那么可能大概率发生了死锁。
但是,还不能够确认这两个线程是在互相等待对方的锁的释放,因为我们看不到它们是等在哪个锁对象,于是我们可以使用 gdb 工具进一步确认。
整个 gdb 调试过程,如下:
1 | // gdb 命令 |
我来解释下,上面的调试过程:
- 通过
info thread
打印了所有的线程信息,可以看到有 3 个线程,一个是主线程(LWP 87746),另外两个都是我们自己创建的线程(LWP 87747 和 87748); - 通过
thread 2
,将切换到第 2 个线程(LWP 87748); - 通过
bt
,打印线程的调用栈信息,可以看到有 threadB_proc 函数,说明这个是线程 B 函数,也就说 LWP 87748 是线程 B; - 通过
frame 3
,打印调用栈中的第三个帧的信息,可以看到线程 B 函数,在获取互斥锁 A 的时候阻塞了; - 通过
p mutex_A
,打印互斥锁 A 对象信息,可以看到它被 LWP 为 87747(线程 A) 的线程持有着; - 通过
p mutex_B
,打印互斥锁 A 对象信息,可以看到他被 LWP 为 87748 (线程 B) 的线程持有着;
因为线程 B 在等待线程 A 所持有的 mutex_A, 而同时线程 A 又在等待线程 B 所拥有的mutex_B, 所以可以断定该程序发生了死锁。
5.28 读写锁
试想这样一个场景,对于一个DNS服务,我们可以根据域名查询服务对应的ip地址,它很久才更新一次,比如新增记录,删除记录或者更新记录等。平时大部分时间都是提供给外部查询,对于查询操作,即使多个线程并发查询不加锁也不会有问题,但是当有线程修改DNS服务的ip记录或者增减记录时,其他线程不能查询,需等待修改完再查询。或者等待查询完,线程才能修改。也就是说读操作并不是互斥的,同一时间可以有多个线程同时读,但是写和读是互斥的,写与写是互斥的。简而言之,写操作需要独占锁。而读操作需要共享锁。**
C++ 标准库自然为我们提供了其他两种互斥:***std::shared_timed_mutex(C++14)、std::shared_mutex***(C++17)。它们的区别简单来说,前者支持更多的操作方式,后者有更高的性能优势。C++11中无上述互斥,但可以通过boost库使用(boost库定义了该互斥)。
std::shared_mutex
同样支持std::lock_guard、std::unique_lock
。和 std::mutex
做的一样,保证写线程的独占访问。而那些无需修改数据结构的读线程,可以使用std::shared_lock<std::shared_mutex>
获取访问权,多个线程可以一起读取。
如果我们想构造共享锁,可以使用std::shared_lock
,如果我们想构造独占锁, 可以使用std::lock_gurad
。
我们用一个类DNService代表DNS服务,查询操作使用共享锁,而写操作使用独占锁,可以是如下方式的。
1 | class DNService { |
5.29 递归锁
线程对已经上锁的 std::mutex
再次上锁是错误的,这是未定义行为。然而在某些情况下,一个线程会尝试在释放一个互斥量前多次获取,所以提供了std::recursive_mutex
。比如,在实现接口的时候内部加锁,接口内部调用完结束自动解锁。会出现一个接口调用另一个接口的情况,如果用普通的std::mutex
就会出现卡死,因为嵌套加锁导致卡死,但是我们可以使用递归锁std::recursive_mutex
。
std::recursive_mutex
是 C++ 标准库提供的一种互斥量类型,它允许同一线程多次锁定同一个互斥量,而不会造成死锁。当同一线程多次对同一个 std::recursive_mutex
进行锁定时,只有在解锁与锁定次数相匹配时,互斥量才会真正释放。但它并不影响不同线程对同一个互斥量进行锁定的情况。不同线程对同一个互斥量进行锁定时,会按照互斥量的规则进行阻塞。
但在工作中并不推荐使用递归锁,我们可以从设计源头规避嵌套加锁的情况,将接口相同的功能抽象出来,统一加锁。
5.30 乐观锁/悲观锁
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
5.31 条件变量
参考:并发编程(5)——条件变量、线程安全队列 | 爱吃土豆的个人博客
为什么
std::condition_variable
的 wait 方法只能与std::unique_lock<std::mutex>
配合使用,而不能与std::lock_guard<std::mutex>
一起使用?
锁的管理:
std::unique_lock
提供了更灵活的锁管理功能,可以在等待条件时释放锁并在条件满足后重新获取锁。std::lock_guard
是一个简单的 RAII(资源获取即初始化)封装,用于自动管理互斥锁的获取和释放,但它不支持在持有锁的状态下进行锁的释放。
条件等待机制:
std::condition_variable::wait
方法会在等待期间释放锁,并在条件满足时重新获取锁。只有std::unique_lock
能够在等待时有效地管理这个过程。std::lock_guard
无法在持有锁的情况下释放锁,这样就无法在条件变量等待期间进行其他线程的操作,导致无法实现正确的等待机制。
wait()有两种重载及原理,第二种是对第一个版本的包装,等待并判断谓词,会调用第一个版本的重载。这可以避免“虚假唤醒”。请详细阅读文章。
条件变量虚假唤醒是指在使用条件变量进行线程同步时,有时候线程可能会在没有收到通知的情况下被唤醒。问题取决于程序和系统的具体实现。解决方法很简单,在循环中等待并判断条件可一并解决。
5.32 future/shared_future/async/packaged_task/promise
参考:并发编程(6)——future、promise、async,线程池 | 爱吃土豆的个人博客
C++ 标准库有两种 future
,都声明在 future
头文件中:独占的 std::future
、共享的 std::shared_future
。它们的区别与 std::unique_ptr
和 std::shared_ptr
类似。同一事件仅仅允许关联唯一一个std::future
实例,但可以关联多个 std::shared_future
实例。它们都是模板,它们的模板类型参数,就是其关联的事件(函数)的返回类型。当多个线程需要访问一个独立 future
对象时, 必须使用互斥量或类似同步机制进行保护。而多个线程访问同一共享状态,若每个线程都是通过其自身的 shared_future
对象副本进行访问,则是安全的。
async
和future
需要配合使用,使用 std::async 启
动一个异步任务(也就是创建一个子线程执行相关任务,主线程可以执行自己的任务),它会返回一个 std::future
对象,这个对象和任务关联,将持有任务最终执行后的结果。当需要任务执行结果的时候,只需要调用 future.get() 成员函数,就会阻塞当前线程直到 future 为就绪为止(即任务执行完毕),返回执行结果。future.valid() 成员函数检查 future 当前是否关联共享状态,即是否当前关联任务。如果还未关联,或者任务已经执行完(调用了 get()、set()),都会返回 false。
1 |
|
std::async
除传递可调用对象、对象参数之外,还需要传递枚举值(也叫策略,比如上面的std::launch::async
),这些策略在std::launch
枚举中定义。除了std::launch::async
之外,还有以下策略:
std::launch::deferred
:这种策略意味着任务将在需要结果时同步执行。惰性求值,不创建线程,等待future
对象调用wait
或get
成员函数的时候执行任务。std::launch::async
在不同线程上执行异步任务。std::launch::async | std::launch::deferred
:这种策略是上面两个策略的组合。任务可以在一个单独的线程上异步执行,也可以延迟执行,具体取决于实现。
默认情况下,std::async
使用std::launch::async | std::launch::deferred
策略。这意味着任务可能异步执行,也可能延迟执行,具体取决于实现。典型情况是,如果系统资源充足,并且异步任务的执行不会导致性能问题,那么系统可能会选择在新线程中执行任务。但是,如果系统资源有限,或者延迟执行可以提高性能或节省资源,那么系统可能会选择延迟执行。
5.33 Actor/CSP
参考:并发编程(9)——Actor/CSP设计模式 | 爱吃土豆的个人博客
在并发编程中,多个线程可能需要同时访问相同的内存资源。为了防止不同线程之间的资源冲突,传统并发设计方法通常使用共享内存和加锁机制来确保线程安全。例如,当一个线程在修改共享数据时,其他线程会被“锁住”,无法同时访问该数据。但是传统并发设计方法在频繁加锁的情况下会带来性能开销,降低系统的执行效率;并且共享内存加锁方式要求线程之间对共享数据有很强的依赖关系,这种依赖增加了代码的复杂性和耦合度,使代码难以维护。
新的设计模式:
- Actor模式:Actor模式通过消息传递的方式来实现线程间通信。每个Actor都有自己的状态和行为,它们通过发送消息来完成交互,而不需要共享内存。这种方式避免了加锁的复杂性和性能损耗。
- CSP(Communicating Sequential Processes)模式:CSP模式也是通过消息传递进行通信,但它强调线程(或进程)之间的严格隔离。各个线程通过通道(Channel)来传递消息,而不直接共享状态,避免了竞争条件和加锁问题。
5.34 什么是内存模型
内存模型定义了多线程程序中,读写操作如何在不同线程之间可见,以及这些操作在何种顺序下执行。内存模型确保程序的行为在并发环境下是可预测的。简单来说,内存模型就是控制读写操作在不同线程中的可见性以及指令执行顺序。
5.35 改动序列
在一个C++程序中,每个对象都具有一个改动序列,它由所有线程在对象上的全部写操作构成,其中第一个写操作即为对象的初始化。大部分情况下,这个序列会随程序的多次运行而发生变化,但是在程序的任意一次运行过程中,所含的全部线程都必须形成相同的改动序列。
若多个线程共同操作某一对象,但它不属于原子类型,我们就需要自己对这些线程进行互斥加锁,保证各个线程是按一定顺序访问操作该对象,进而确保对于一个变量,所有线程就其达成一致的改动序列(所有线程对变量的修改顺序相同,要么从头,要么从尾开始)。变量的值会随时间推移形成一个序列,在不同的线程上观察属于同一个变量的序列,如果所见各异,就说明出现了数据竞争和未定义行为.
改动序列基本要求如下:
- 只要某线程看到过某个对象,则该线程的后续读操作必须获得相对新近的值,并且,该线程就同一对象的后续写操作,必然出现在改动序列后方(每一次写都基于上一次的改动);
- 如果某线程先向一个对象写数据,过后再读取它,那么必须能读取到前面写的值;
- 若在改动序列中,上述读写操作之间还有别的写操作,则必须读取最后写的值;
- 在程序内部,对于同一个对象,全部线程都必须就其形成相同的改动序列,并且在所有对象上都要求如此;
- 多个对象上的改动序列只是相对关系,线程之间不必达成一致。
5.36 什么是原子操作?原子操作是线程安全的吗?
原子操作通过缓存一致性协议(MESI)实现的。
- 原子操作是不可分割的操作。在系统的任一线程中,我们不会观察到这种操作处于半完成状态;它或者完全做好,或者完全没做。
- 非原子操作在完成到一半的时候,有可能为另一线程所见。
当多个线程或进程并发访问同一内存位置,并且至少一个线程在写入数据,其他线程在读取或写入数据,而没有适当的同步机制来保护该内存位置时,称这些表达式冲突。拥有两个冲突的求值的程序就有数据竞争,除非
- 两个求值都在同一线程上,或者在同一信号处理函数中执行,或
- 两个冲突的求值都是原子操作(见 std::atomic),或
- 一个冲突的求值发生早于 另一个(见 std::memory_order)
原子操作都是线程安全的吗?
根据上面原子操作的概念:原子操作过程中不可被打断,所以寄存器内的内容就不会被其它线程修改,在原子操作结束后,结果存入内存,才会被切换到别的线程,听起来似乎没有问题。这一系列操作中,原子操作一定能将任务完成,并且返回正确的结果写入内存。所以你可能会认为原子操作是线程安全的。
原子操作本身是线程安全的,但是原子操作的初始化不是线程安全的,并且原子操作依赖于别的操作时,如果这些操作之间的整体性没有被原子化处理时,那么同样不是线程安全的。
其实并不尽然,在多线程环境中使用原子操作并不一定是线程安全的,因为线程安全需要确保在多线程情况下,整个代码的逻辑是正确的,而不仅仅是某个操作的原子性。当程序逻辑依赖多个操作的组合,而这些操作之间的整体性没有被原子化处理时,就可能不具有线程安全。
举例说明:
1 |
|
我们定义一个函数,这个函数只需要执行一个功能:检查类型为std::atomic<int>
的变量x
是否为0,并在判断条件为true
的条件下执行x++
。
如果我们创建两个线程,令它们几乎在同一时刻执行if(x==0)
,它们可能都会认为x==0
,然后执行x++
,导致最终x
的值为2,而不是预期的1。这样即使 x++
是原子操作,整个 if (x == 0)
检查和递增的过程并不是原子的,可能在检查后但修改前,另一个线程也修改了 x
,导致逻辑出错。这个过程和我们之前在并发编程(3)中分析两个线程同时pop队列数据可能会导致线程误判而引发一些错误的问题中,简单分析过,详见:并发编程(3)——锁(上) | 爱吃土豆的个人博客
我们可以通过互斥或原子操作来保证逻辑的完整性:
1 | // 原子比较交换,比较和交换同一步内进行,而不分开 |
由此可以得出:当操作独立且不依赖其他状态时,原子操作才可以保证线程安全。
比如,我们只对原子类型进行++
操作而不进行判断,那么即使在多线程操作中,仍然是线程安全的:
1 | std::atomic<int> counter = 0; |
当执行的原子操作是独立的并且不依赖于其他状态(比如判断执行原子操作的变量是否满足一些条件)时,即使在多线程中它仍然是线程安全的,并不需担心。
但注意:任何
std::atomic
类型的初始化不是原子操作。当我们在多个线程中同时对一个std::atomic
对象进行初始化时,并不会自动保证线程安全。
这是因为std::atomic
类型提供了对共享数据的原子操作,但这仅仅是指对该对象进行修改(如读、写、加法、减法等)时,操作本身是原子的,即操作是不可分割的。但对于初始化操作来说,它仍然是普通的内存操作。具体来说,初始化是对象创建的一部分,而对象的创建与内存分配过程(如内存的分配和指针的设置)并没有任何与原子性相关的保障。
不过我们可以通过以下三种方式保证 std::atomic
类型有线程安全的初始化:
- 在主线程中初始化
- 使用同步机制(如互斥锁、
std::call_once
等) - 可以使用单例模式的初始化,确保初始化只发生一次,比如使用
std::once_flag
和std::call_once
来保证在多线程环境下初始化只执行一次
总结:
- 如果操作是单步的、独立的,使用原子操作即可保证线程安全。
- 如果操作需要多个步骤或涉及依赖其他共享状态,应使用锁或其他同步机制来保护代码块的逻辑完整性。
5.37 什么是内存次序
内存次序指的是在多线程环境中,线程之间的内存操作顺序。由于现代处理器和编译器通常会进行优化(如指令重排序、缓存等),线程的内存操作可能不是按程序代码中的顺序执行的。内存次序的概念就是为了控制和保证不同线程间对共享数据的访问顺序,以确保程序行为的一致性和正确性。
在多线程编程中,常见的内存次序操作包括顺序一致性(sequential consistency)、强制顺序(strong order)和弱顺序(weak order)等。
- 顺序一致性(Sequential Consistency):要求所有线程看到的操作顺序是全局一致的,程序的执行行为按线程间的顺序一致。即每个线程中的操作执行顺序是按程序代码顺序进行的,不允许重排序。
- 强顺序(Strong Ordering):对于某些特定的内存操作(如读取、写入),强顺序要求操作顺序严格按照代码中的顺序执行。
- 弱顺序(Weak Ordering):允许内存操作在一定程度上进行重排序,但要求特定的同步操作(如锁)保证共享数据的正确性。
而对于原子类型上的每一种操作,我们都可以提供额外的参数(这个参数可以用来指定执行顺序),从枚举类std::memory_order
取值,用于设定所需的内存次序语义。枚举类std::memory_order
具有6个可能的值,包括std::memory_order_relaxed
、std:: memory_order_acquire
、std::memory_order_consume
、std::memory_order_acq_rel
、std::memory_order_release
和 std::memory_order_seq_cst
。
std::memory_order_relaxed
:不保证任何内存顺序,允许最大程度的重排序。std::memory_order_consume
:用于读取依赖于先前写入的值。大多数情况下和memory_order_acquire
相同。std::memory_order_acquire
:确保当前线程的所有读取和写入操作在当前原子操作之前完成。std::memory_order_release
:确保当前线程的所有读取和写入操作在当前原子操作之后完成。std::memory_order_acq_rel
:同时拥有acquire
和release
语义,适用于读写操作都涉及共享数据的情况。std::memory_order_seq_cst
:保证所有原子操作的顺序一致性,是最强的内存顺序保证。
原子类型的操作被划分为以下三类:
- 存储(
store
)操作,可选用的内存次序有std::memory_order_relaxed
、std::memory_order_release
或std::memory_order_seq_cst
。 - 载入(
load
)操作,可选用的内存次序有std::memory_order_relaxed
、std::memory_order_consume
、std::memory_order_acquire
或std::memory_order_seq_cst
。 - “读-改-写”(
read-modify-write
)操作,可选用的内存次序有std::memory_order_relaxed
、std::memory_order_consume
、std::memory_order_acquire
、std::memory_order_release
、std::memory_order_acq_rel
或std::memory_order_seq_cst
。
操作的类别决定了内存次序所准许的取值,若我们没有把内存次序显式设定成上面的值,则默认采用最严格的内存次序,即
std::memory_order_seq_cst
。
这六种内存顺序相互组合可以实现三种顺序模型 (ordering model):
Sequencial consistent ordering
:实现同步, 且保证全局顺序一致 (single total order) 的模型. 是一致性最强的模型, 也是默认的顺序模型Acquire-release ordering
: 实现同步, 但不保证保证全局顺序一致的模型Relaxed ordering
:不能实现同步, 只保证原子性的模型
5.38 原子操作为什么不可复制不可赋值
std::atomic_flag
不可复制不可赋值。这不是 std::atomic_flag
特有的,而是所有原子类型共有的属性。原子类型的所有操作都是原子的,而赋值和复制涉及两个对象,破坏了操作的原子性(复制构造和复制赋值操作不具备原子性)。复制构造和复制赋值会先读取第一个对象的值,然后再写入另一个对象。对于两个独立的对象,这里实际上有两个独立的操作,合并这两个操作无法保证其原子性。因此,这些操作是不被允许的。详细说明:
复制构造和复制赋值操作涉及两个对象,这实际上是两个操作:
- 读取第一个对象的值(对于复制构造或赋值的目标对象);
- 写入到另一个对象(即目标对象)。
这两个操作并不是在一个单一的原子步骤中完成的,而是需要两个独立的步骤。这会导致以下问题:
- 先读后写:在读第一个对象值并写入第二个对象之间,其他线程可能会修改第一个对象的值或第二个对象的值。这就破坏了操作的原子性,可能会导致数据不一致。
- 竞态条件:这两个步骤之间如果没有正确同步(如加锁或其他同步机制),就会出现竞态条件,多个线程同时进行赋值或复制操作时,会导致结果无法预测,发生未定义行为。
5.39 自旋锁
自旋锁可以理解为一种忙等锁,它的基本思想是,当一个线程尝试获取锁时,如果锁已经被其他线程持有,那么该线程就会不断地循环检查锁的状态,直到成功获取到锁为止。与此相对,std::mutex
互斥量是一种睡眠锁。当线程请求锁(lock()
)而未能获取时,它会放弃 CPU 时间片,让其他线程得以执行,从而有效利用系统资源。
从性能上看,自旋锁的响应更快,但是睡眠锁更加节省资源,高效。
我们可以利用std::atomic_flag
实现一个自旋锁:
1 |
|
- 通过
lock()
函数,我们可以将flag
通过test_and_set
函数设为true
,然后返回上一次flag
的值。如果返回的为false
(未持有锁),那就退出lock()
函数,上锁完毕;如果返回的为true
(持有锁),说明其他线程已持有该锁,无法继续上锁,通过循环调用test_and_set
函数,可以实现循环的判断锁的状态,一旦其他线程解锁,当前线程便上锁; - 通过
unlock()
函数,我们可以将flag
设为false
,表示释放锁; ATOMIC_FLAG_INIT
默认设flag
为false
。
测试函数:
1 | void TestSpinLock() { |
我们的 SpinLock
对象中存储的 flag
对象在默认构造时是清除 (false
) 状态。在 lock()
函数中调用 test_and_set
函数,它是原子的,只有一个线程能成功调用并将 flag
的状态原子地更改为设置 (true
),并返回它先前的值 (false
)。此时,该线程成功获取了锁,退出循环。
当 flag
对象的状态为设置 (true
) 时,其它线程调用 test_and_set
函数会返回 true
,导致它们继续在循环中自旋,无法退出。直到先前持有锁的线程调用 unlock()
函数,将 flag
对象的状态原子地更改为清除 (false
) 状态。此时,等待的线程中会有一个线程成功调用 test_and_set
返回 false
,然后退出循环,成功获取锁。
5.40 shared_ptr是线程安全的吗
现在我们可以很简单的回答这个问题:并不是。
引用计数是线程安全的!!!
shared_ptr
仅有引用计数是线程安全的,因为在shared_ptr的控制块中,引用计数变量使用类似于 std::atomic::fetch_add
的操作并结合 std::memory_order_relaxed
进行递增(递减操作则需要更强的内存排序,以确保控制块能够安全销毁)。其关键在于使用了原子操作对引用计数进行增加或减少,所以是线程安全的。 而且,因为引用计数是线程安全的,多个线程可以安全地操作引用计数和访问管理对象,即使这些 shared_ptr
实例是同一对象的副本且共享所有权也是如此,所以管理共享资源的生命周期是线程安全的,不用担心因为多线程操作导致资源提早释放或延迟释放。
shared_ptr
本身并不是线程安全的!!!
但是**shared_ptr
本身并不是线程安全的,shared_ptr
对象实例包含一个指向控制块的指针和一个指向底层元素的指针。这两个指针的操作在多个线程中并没有同步机制**。因此,如果多个线程同时访问同一个 shared_ptr
对象实例并调用非 const
成员函数(如 reset
或 operator=
),这些操作会导致对这些指针的并发修改,进而引发数据竞争(就像我们在并发编程(10)中说的一样,独立的原子操作当然是线程安全的,但是如果原子操作依赖于非原子操作,那么这个过程可能就是非线程安全的)。举例:
情况一:当多线程操作同一个shared_ptr对象时
1 | // 按指针传入 |
如果我们将shared_ptr
对象的指针或引用传入给可调用对象,当创建不同线程对shared_ptr进行修改时,比如修改其指向(如 reset
或 operator=
)。sp原先指向的引用计数的值要减去1,other_sp指向的引用计数值要加1。然而这几步操作加起来并不是一个原子操作(并发编程(10)在原子操作中说过,并不是所有原子操作都是线程安全的,如果原子操作依赖于非原子操作,那么这个过程可能就是非线程安全的,这里的条件判断并不是原子操作),如果多少线程都在修改sp的指向的时候,那么可能会出问题。比如在导致计数在操作减一的时候,其内部的指向,已经被其他线程修改过了。引用计数的异常会导致某个管理的对象被提前析构,后续在使用到该数据的时候触发core dump
。
如果不调用shared_ptr
的非const
成员函数修改shared_ptr
,那么就是线程安全的。
情况二:当多线程操作不同shared_ptr对象时
如果不是同一 shared_ptr
对象(管理的数据是同一份,引用计数共享,但shared_ptr不是同一个对象),每个线程读写的指针也不是同一个,引用计数又是线程安全的,那么自然不存在数据竞争,可以安全的调用所有成员函数。
1 | // 按值传递 |
这时候每个线程内看到的sp,他们所管理的是同一份数据,用的是同一个引用计数。但是各自是不同的对象,当发生多线程中修改sp指向的操作的时候,是不会出现非预期的异常行为的,也就是线程安全的。
shared_ptr
所管理的对象不是线程安全的!!!
尽管前面我们提到了如果是按值捕获(或传参)的shared_ptr
对象,那么是该对象是线程安全的。然而话虽如此,但却可能让人误入歧途。因为我们使用shared_ptr
更多的是操作其中的数据,对其管理的数据进行读写,而不是修改shared_ptr
的指向。尽管在按值捕获的时候shared_ptr
本身是线程安全的,我们不需要对此施加额外的同步操作(比如加解锁、条件变量、call_once
和once_flag
),但是这并不意味着shared_ptr
所管理的对象是线程安全的!
shared_ptr
本身和shared_ptr
管理的对象是两个东西,并不是同一个!!!如果我们要多线程处理shared_ptr
所管理的资源,我们需要主动的对其施加额外的同步操作(比如加解锁、条件变量、call_once
和once_flag
)。
如果shared_ptr
管理的数据是STL容器而不是仅仅是一个数字,那么多线程如果存在同时修改的情况,是极有可能触发core dump的。比如多个线程中对同一个vector
进行push_back
,或者对同一个map
进行了insert
。甚至是对STL容器中并发的做clear操作,都有可能出发core dump
,当然这里的线程不安全性,其实是其所指向数据的类型的线程不安全导致的,并非是shared_ptr
本身的线程安全性导致的。尽管如此,由于shared_ptr
使用上的特殊性,所以我们有时也要将其纳入到shared_ptr
相关的线程安全问题的讨论范围内。
除了STL容器的并发修改操作(这里指的是修改容器的结构,并不是修改容器中某个元素的值,后者是线程安全的,前者不是),protobuf的Message对象也是不能并发操作的,比如一个线程中修改Message对象(set、add、clear),另外一个线程也在修改,或者在将其序列化成字符串都会触发core dump。
STL容器如何解决线程安全可以参考这篇文章:C++ STL容器如何解决线程安全的问题? - 知乎
简单来说,保证STL容器的线程安全有两种方式:
对其施加同步操作,使容器在进行增删改查时是线程安全的,但是在高并发下,同步操作会造成性能开销
如果是非关联容器,比如vector,那就固定vector的大小,避免动态扩容(无push_back),我们可以使用resize来实现(不是reserve)。reserve就是预留内存。为的是避免内存重新申请以及容器内对象的拷贝。说白了,reserve()是给push_back()准备的!而resize除了预留内存以外,还会调用容器元素的构造函数,不仅分配了N个对象的内存,还会构造N个对象。从这个层面上来说,resize()在时间效率上是比reserve()低的。但是在多线程的场景下,用resize再合适不过。你可以resize好N个对象,多线程不管是读还是写,都是通过容器的下标访问【operator[]】来访问元素,不要push_back新元素。所谓的『写操作』在这里不是插入新元素,而是修改旧元素。而且,我们也可以将固定大小的vector修改成一个环形队列,索引通过原子变量来保证索引的安全。
至于非关联容器,比如map,我记忆中还是使用互斥好一点
最后,有很多人可能认为引用计数是通过智能指针的静态成员变量所管理的,但这很明显是错的:
1 | shared_ptr<A> sp1 = make_shared<A>(x); |
两个完全不相干的sp1和sp2,只要模板参数T
是同一个类型,即使管理的资源不是同一个,但如果使用静态成员变量管理引用计数,那么二者就会共享同一个计数。
5.41 如何实现线程安全的智能指针
引用计数的加减是线程安全的不用考虑,我们只需要考虑如何保证一个智能指针实例的 ptr
和 control
成员线程池安全。
方法一:我们可以使用互斥锁,在 SimpleSharedPtr
中引入 std::mutex
,在所有可能修改 ptr
和 control
的操作中加锁。
方法二:使用原子模板的偏特化版本:std::atomic
允许用户原子地操纵 shared_ptr
对象。因为它是 std::atomic
的特化版本,即使我们还没有深入讲述它,也能知道它是原子类型,这意味着它的所有操作都是原子操作,肯定是线程安全的(即使多个执行线程不同步地同时访问同一 std::shared_ptr
对象,且任何这些访问使用了 shared_ptr
的非 const 成员函数)。
方法一不多说,就是在进行写操作的时候加互斥即可,对于方法二,下面我分别使用std::shared_ptr
和**std::atomic<std::shared_ptr>
**来说明二者的区别:
1 | class Data { |
以上这段代码是典型的线程不安全,它满足:
- 多个线程不同步地同时访问同一
std::shared_ptr
对象 - 任一线程使用 shared_ptr 的非 const 成员函数
那么为什么呢?为什么满足这些概念就是线程不安全呢?为了理解这些概念,首先需要了解 shared_ptr
的内部实现:
shared_ptr
的通常实现只保有两个指针
- 指向底层元素的指针(get()) 所返回的指针)
- 指向控制块 的指针
控制块是一个动态分配的对象,其中包含:
- 指向被管理对象的指针或被管理对象本身
- 删除器(类型擦除)
- 分配器(类型擦除)
- 持有被管理对象的
shared_ptr
的数量 - 涉及被管理对象的
weak_ptr
的数量
控制块是线程安全的,这意味着多个线程可以安全地操作引用计数和访问管理对象,即使这些 shared_ptr
实例是同一对象的副本且共享所有权也是如此。因此,多个线程可以安全地创建、销毁和复制 shared_ptr
对象,因为这些操作仅影响控制块中的引用计数。也就是说对于引用计数这一变量的存储,是在堆上的,多个shared_ptr的对象都指向同一个堆地址,对引用计数的加减过程是一个原子过程,是线程安全的。
然而,shared_ptr
对象实例本身并不是线程安全的。shared_ptr
对象实例包含一个指向控制块的指针和一个指向底层元素的指针。这两个指针的操作在多个线程中并没有同步机制。因此,如果多个线程同时访问同一个 shared_ptr
对象实例并调用非 const
成员函数(如 reset
或 operator=
),这些操作会导致对这些指针的并发修改,进而引发数据竞争。
如果不是同一 shared_ptr
对象,每个线程读写的指针也不是同一个,控制块又是线程安全的,那么自然不存在数据竞争,可以安全的调用所有成员函数。
使用 std::atomic<shared_ptr>
修改:
1 | std::atomic<std::shared_ptr<Data>> data = std::make_shared<Data>(); |
很显然,这是线程安全的,store
是原子操作,而 sp->get_value()
只是个读取操作,并会对数据进行修改,所以读操作不需要调用原子操作。
5.42 shared_ptr内部实现
简单来说,shared_ptr
内部包含了两个指针,一个Ptr to T
指向目标管理对象T object
,另一个Ptr to Control Block
指向控制块Control Block
。控制块包含了一个引用计数(reference count)
、一个弱计数(weak count)
和其他数据(other data)
(比如删除器、分配器等)。
但在智能指针初始化过程中,需要为管理的对象T Object 和控制块Control Block分配内存,并使用两个指针指向它们。
简单举一个例子:
1 | std::shared_ptr<int> p1(new int(1)); |
shared_ptr
有很多构造函数,这里使用的构造函数原型为:
1 | template< class Y > |
二者的内存模型如下所示:
很明显,p1和p2都指向同一内存空间T Object
,而且引用计数为2,只有当p1和p2都被释放后,引用计数减为0的同时,智能指针管理的对象才会被释放。
5.43 make_shared相比直接构造shared_ptr的优点
std::make_shared
减少了内存分配的次数:- 使用
new
创建: 当直接使用std::shared_ptr
时,需要两次内存分配:- 为所管理的对象分配内存。
- 为
std::shared_ptr
的控制块(控制引用计数和资源信息)分配内存。
- 使用
make_shared
:std::make_shared
会在一次内存分配中同时分配对象和控制块的内存,避免了额外的内存分配。
- 使用
1 |
|
- 直接使用
new
创建std::shared_ptr
可能引发异常时的资源泄漏问题。- 如果在
std::shared_ptr
的构造过程中发生异常,new
分配的资源可能无法正确释放,导致内存泄漏。 std::make_shared
是异常安全的,因为其分配和构造过程是一体化的,保证资源不会泄漏。
- 如果在
1 | // 错误代码 |
- 当直接使用
new
时,需要确保动态分配的内存与std::shared_ptr
的删除器匹配。- 如果使用默认删除器管理动态分配的数组,会导致未定义行为(数组不会被正确释放)。
std::make_shared
自动匹配删除器,避免了这种错误。
1 | // 错误代码 |
但注意,当存在以下情况时,不应该使用make_shared
来构造shared_ptr
对象,而应直接构造shared_ptr
对象:
需要自定义删除器
std::make_shared
自动使用delete
来销毁对象,但如果我们创建对象管理的资源不是通过new
分配的内存,那么需要我们自定义一个删除器来销毁该内存;或者我们需要为std::shared_ptr
提供自定义的删除逻辑(例如释放资源时需要执行额外的操作),那么std::make_shared
就不适用了。在这种情况下,我们需要通过shared_ptr
的构造函数来创建对象,并传递一个自定义的删除器。创造对象的构造函数是保护或私有时
当我们想要创建的对象没有公有的构造函数时,
make_shared
无法使用!!!从已有裸指针构造,当对象已通过 new 分配,或由第三方库返回裸指针时,不能用
make_shared
使用
make_shared
时,对象和控制块的内存是连续的。若希望对象销毁后立即释放其内存(即使仍有weak_ptr
引用控制块),需直接构造shared_ptr
。因为std::make_shared
分配的对象和控制块的内存是连续的,当所有std::shared_ptr
销毁后,对象和控制块的连续内存块不会立即释放,因为控制块仍需维护弱引用计数(供std::weak_ptr
使用),只有当所有std::weak_ptr
也销毁后,整个内存块(控制块+对象)才会释放。而通过
std::shared_ptr
直接构造,当所有std::shared_ptr
销毁后,对象的内存(通过new
分配的独立内存块)立即释放,控制块的内存仍存在,直到所有std::weak_ptr
销毁后才释放。因此 make_shared 会意外延迟内存释放的时间。
5.44 智能指针返回裸指针需要注意什么
当我们需要获取内置类型(管理资源)时,可以通过智能指针的方法
get()
返回其底层管理的内置指针。注意,通过
get()
函数返回的内置指针时要注意以下问题:- 我们不能主动通过
delete
回收该指针,要交给智能指针自己回收,否则会造成double free或者使用智能指针产生崩溃等问题。 - 也不能用
get()
返回的内置指针初始化另一个智能指针,因为两个智能指针引用一个内置指针会出现问题,比如一个释放了另一个不知道就会导致崩溃等问题。 因为get()
方法返回的原始指针(即裸指针),不增加智能指针对对象的引用计数或所有权管理
1
2
3
4
5std::shared_ptr<int> sp1 = std::make_shared<int>(10);
int* raw_ptr = sp1.get();
// 错误:使用裸指针初始化另一个 shared_ptr
std::shared_ptr<int> sp2(raw_ptr); // 错误,sp2 和 sp1 都会管理同一个内存这里,
raw_ptr
是sp1
管理的对象的裸指针,但raw_ptr
不会增加对象的引用计数,也不会管理其生命周期。当我们通过raw_ptr
初始化sp2
时,sp2
会成为一个新的智能指针,指向相同的内存区域。由于sp1
和sp2
都管理同一个内存对象,但它们并没有共享引用计数。裸指针的生命周期与智能指针不同,它不被智能指针的生命周期管理,这可能会导致以下错误:- 多次释放同一内存:如果两个智能指针都拥有相同的裸指针,而其中一个智能指针释放了这个指针所管理的资源,另一个智能指针会在其析构时试图释放相同的资源。这会导致“双重释放”错误,通常会导致程序崩溃。
- 悬挂指针:如果原始智能指针在另一个智能指针之前被销毁,那么另一个智能指针会变成一个悬挂指针。虽然这个智能指针指向有效内存,但该内存已被释放,访问它会导致未定义行为(通常会崩溃)。
get()
用来将指针的访问权限传递给代码,只有在确定代码不会delete裸指针的情况下,才能使用get。特别是,永远不要用get初始化另一个智能指针或者为另一个智能指针赋值。5.44 不能将局部变量传给智能指针
如果将一个函数中的局部变量传给智能指针时,当作用域结束后,智能指针会 delete 局部变量,但是局部变量是栈空间的变量,用
delete
会导致崩溃。我们这时候必须传入一个自定义的删除器,用于删除局部变量:1
2
3
4
5
6
7
8
9
10void delfuncint(int *p)
{
cout << *p << " in del func" << endl;
}
void delfunc_shared()
{
int p = 6;
shared_ptr<int> psh(&p, delfuncint);
}如果不传递
delfuncint
,会造成p
被智能指针delete
,因为p
是栈空间的变量,用delete
会导致崩溃。- 我们不能主动通过
5.45 智能指针循环引用问题
除了new
和delete
能引发循环引用问题外,shared_ptr
本身也可能会引发内存泄漏问题,即循环引用问题。
shared_ptr
循环引用问题是指两个或多个对象之间通过shared_ptr
相互引用,导致对象无法被正确释放,从而造成内存泄漏。常见的情况是两个对象A和B,它们的成员变量互相持有了对方的shared_ptr
。当A和B都不再被使用时,它们的引用计数不会降为0,无法被自动释放。
1 | class Girl; |
有没有方法解决这个问题呢?这时候我们就用到了智能指针**weak_ptr
**。
weak_ptr
是一种弱引用,不会增加对象的引用计数,在对象释放时会自动设置为nullptr
。它只可以从一个 shared_ptr
或另一个 weak_ptr
对象构造, 它的构造和析构不会引起引用记数的增加或减少(当然,weak_ptr
其实不需要析构函数,因为它不需要管理和释放资源,即没有RAII)。 同时weak_ptr
没有重载operator*
和operator->
(不支持访问资源),但可以使用 weak_ptr.lock() 获得一个可用的 shared_ptr
对象,当对象已经释放时会返回一个空shared_ptr
1 | class Girl; |
我们将Boy
类的私有成员变量类型由shared_ptr
更换为了weak_ptr
,此时,对该变量赋值不会造成引用计数的增加,自然就解决了循环引用问题。
代码输出为:
1 | Boy 构造函数 |
spGirl
的引用计数之所以为3是因为,在调用setGirlFriend
函数时,按值传入一个spGirl
对象,在函数内部,拥有一个spGirl
副本,此时引用计数为2;当创建了一个shared_ptr<Girl>
类型的对象sp_girl
,并调用weak_ptr<Girl>
的lock
函数获取shared_ptr<Girl>
赋予给sp_girl
时,引用计数变为了3。但它们都是局部变量,所以当函数作用域结束后,都会被自动释放,所以最后引用计数变为了1。
spBoy
内部的成员变量类型是shared_ptr
而不是weak_ptr
,所以引用计数为2,但是,当spGirl
被释放后,spBoy
的引用计数为1,此时spBoy也可以正常释放。
5.46 enable_from_this_shared
在一个类的成员函数中,我们不能直接将this
指针作为shared_ptr
返回(因为这会破坏 std::shared_ptr
的引用计数机制,可能导致未定义行为),而需要通过派生std::enable_shared_from_this
类,通过其方法shared_from_this
来返回指针。原因是std::enable_shared_from_this
类中有一个weak_ptr
,这个weak_ptr
用来观察this
智能指针,调用shared_from_this()
方法其实是调用内部这个weak_ptr
的lock()
方法,将所观察的shared_ptr
返回。
需要注意的是,获取自身智能指针的函数仅在shared_ptr
的构造函数被调用之后才能使用,因为enable_shared_from_this
内部的weak_ptr
只有通过shared_ptr
才能构造。
但注意,你不能直接将
this
指针作为shared_ptr
返回回来!!!
我在前面说过,不能将智能指针通过get()
函数返回的裸指针用于初始化或reset
另一个指针。通过这种方法初始化的智能指针,其实和原本在类内部构造的智能指针是两个独立的对象,它们不共享引用计数,仅仅只是管理的资源相同。如果多次析构,会造成同一个资源被重复析构两次的问题。
所以,不要将this
指针作为shared_ptr
返回回来,因为this
指针本质上是一个裸指针,因此,可能会导致重复析构.
5.47 uuid
深度解读UUID:结构、原理以及生成机制 - -云- - 博客园
UUID是一个长度为128位的标志符,能够在时间和空间上确保其唯一性。到目前有5个版本:
- UUID_v1 : 使用MAC 地址和戳来生成,在过去,唯一性非常好,但是会暴露生成者的主机信息,据说历史上有因UUID v1的使用导致生成者被攻击的。在服务器网卡MAC基本都是随机出来的今天,比较少见了。
- UUID_v2 : 类似 v1, 增加了 DEC security, 因为这个版本没有提供任何实现细节,所以很多的实现都是实现了自己的 v2 版uuid。
- UUID_v3&v5: 这两个版本都是使用一个指定的UUID作为命名空间,然后对一个给定的Name进行Hash 之后生成 UUID, 相同命名空间和相同名称生成出来的UUID是相同的,v3和v5 的区别是, v3 使用MD5 ,而v5 使用 sha1。
- UUID_v4 :这个版本就是使用随机数生成UUID。一般算法都是使用随机数填充整个UUID,然后修改版本位及其它的几个位。
boost 库实现了 UUID 的v4 和 v5。
5.48 内存序
并发编程(12)——内存次序与内存模型,以及单例模式的三种实现 | 爱吃土豆的个人博客
C++ 标准中的可见:
- 如果线程 A 对变量 x 进行了修改,而线程 B 能够读取到线程 A 对 x 的修改,那么我们说线程 B 能看到线程 A 对 x 的修改。也就是说,线程 A 的修改对线程 B 是可见的。
但是在大多数的情况下,多线程代码并不会主动的使用内存序,一般都是互斥量、条件变量等同步手段,这些手段是否存在可见性的问题?
没问题,这些手段会自动确保数据的可见性。例如, std::mutex
的 unlock()
保证:
- 此操作同步于任何后继的取得同一互斥体所有权的锁定操作。
也就是 unlock()
同步于 lock()
。
“同步于”:操作 A 的完成会确保操作 B 在其之后的执行中,能够看到操作 A 所做的所有修改。
也就是说:
std::mutex
的unlock()
操作同步于任何随后的lock()
操作。这意味着,线程在调用unlock()
时,对共享数据的修改会对之后调用lock()
的线程可见。
我们也可以通过使用合适的内存序(如 memory_order_release 和 memory_order_acquire),可以确保线程 A 的写操作在其他线程 B 中是可见的,从而避免数据竞争问题。
C++支持六种内存序:
这 6 个常量,每一个常量都表示不同的内存次序。
大体来说我们可以将它们分为三类(不是根据原子操作的类型分的)。
memory_order_relaxed
宽松次序:不是定序约束,仅对此操作要求原子性。memory_order_seq_cst
先后一致次序,这是库中所有原子操作的默认行为,也是最严格的内存次序,是绝对安全的。- 剩下的四种内存序属于获取-释放次序。
5.49 先后一致次序
先后一致次序(SC)是库中所有原子操作的默认行为,也是最严格的内存次序,是绝对安全的。
SC要求所有内存操作表现为(appear)逐个执行(任一次的执行结果都像是所有处理器的操作都以某种次序执行),每个处理器中的操作顺序都以其程序指定的顺序执行。SC有两点要求:
- 每个处理器的执行顺序和代码中的顺序(program order)一样。
- 在所有处理器间,所有处理器都只能看到一个单一的操作执行顺序。对于写操作W1, W2, 不能出现从处理器 P1 看来,执行次序为 W1->W2; 从处理器 P2 看来,执行次序却为 W2->W1 这种情况。
- 这使得内存操作需要表现为原子执行(瞬发执行):可以想象系统由单一的全局内存组成,每一时刻,由switch将内存连向任意的处理器,每个处理器按程序顺序发射(issue)内存操作。这样,switch就可以提供全局的内存串行化性质。换大白话来说,就是所有线程的内存操作都必须以某种全局顺序执行,并且该顺序对所有线程可见,并符合每个线程的程序顺序。
我们通过宽松次序和先后一致次序的对比进而说明后者的作用:
1 | std::atomic<bool> x, y; |
该段代码通过宽松次序实现,具体原理可以参考接下来有关于宽松次序章节的内容。这里你只需要知道:虽然线程t1按次序执行1和2,但是因为宽松序列并不能保证线程间的同步性或先行性,所以线程t2看到的可能是y为true,x为false(可能先执行2→1,也可能先执行1→2)。进而导致TestOrderRelaxed
可能会触发断言z为0。
但如果换成memory_order_seq_cst
,则能保证所有线程看到的执行顺序是一致的。
1 | void write_x_then_y() { |
上面的代码x和y采用的是memory_order_seq_cst
,所以当线程t2执行到3处并退出循环时我们可以断定y为true,因为是全局一致性顺序,所以线程t1已经执行完2处将y设置为true,那么线程t1也一定执行完1处代码并对t2可见,所以当t2执行至4处时x为true,那么会执行z++保证z不为零,所以一定不会触发断言(因为全局一致性顺序能保证线程间的先行性和同步性,所以如果线程t2可以退出循环,说明y中的值被修改为true,因为在线程t1中1顺序先行于2发生,而2必须先行于3发生,所以1也先行于3发生。那么当3执行成功后,其实1处对x的修改是对3可见的,因为在线程t2中,3又顺序先行行于4,那么x的值对4也是可见的,所以必定会++z)。
SC的缺点:
实现
sequencial consistent
模型有一定的开销,现代 CPU 通常有多核,每个核心还有自己的缓存。为了做到全局顺序一致,每次写入操作都必须同步给其他核心。为了减少性能开销,如果不需要全局顺序一致,我们应该考虑使用更加宽松的顺序模型。SC实际上是一种强一致性,可以想象成整个程序过程中由一个开关来选择执行的线程,这样才能同时保证顺序一致性的两个条件:
图片来源:https://www.codedump.info/post/20191214-cxx11-memory-model-1/#sequential-consistency-%E9%A1%BA%E5%BA%8F%E4%B8%80%E8%87%B4%E6%80%A7 可以看到,这样实际上还是相当于同一时间只有一个线程在工作,这种保证导致了程序是低效的,无法充分利用上多核的优点。
5.50 宽松次序
宽松序列其实就是内存序 std::memory_order_relaxed
,它的一致性是最弱的,该操作仅要求原子性。因此该内存序适用于当同步关系或先行关系不是关键需求,而只需利用原子性来避免数据竞争的场景。
std::memory_order_relaxed
有以下几个功能:
作用于原子变量(利用操作不可分割的特性)。
原子类型上的操作不存在同步关系(synchronizes-with),即不会隐式地向其他线程传播可见性或顺序性信息。线程间仅存的共有信息是每个变量的改动序列。
1
2
3
4
5
6
7
8
9
10
11
12
13std::atomic<int> x{0};
std::atomic<int> y{0};
void thread1() {
x.store(1, std::memory_order_relaxed);
y.store(2, std::memory_order_relaxed);
}
void thread2() {
int a = y.load(std::memory_order_relaxed);
int b = x.load(std::memory_order_relaxed);
std::cout << a << " " << b << std::endl;
}在
thread2
中,a
和b
的值可能会以任何顺序出现(包括a=2, b=0
或a=0, b=1
),因为操作之间没有同步关系。在单线程中,对同一个变量上的操作严格服从先行关系,但不同变量不具有先行关系,操作顺序可以重排,即可以乱序执行(因为 relaxed 模式不会对指令的全局顺序作任何保证,编译器会对代码进行优化和CPU 对指令重排)。
多线程下不存在先行关系(可见性),换句话说,
relaxed
模式不保证某个线程对原子变量的写入对其他线程的读操作立即可见,可能得过一会儿后,其他 线程才能读到原子变量更新后的值。
对该内存序的唯一要求是:在单线程内,对相同变量的访问次序不得重新编排,即在一个线程中,如果某个表达式已经看到原子变量某时刻持有的值a,则该表达式的后续表达式只能看到a或者比a更新的值。
我们可以通过两个线程说明,采用宽松次序的操作能宽松到什么程度,代码如下:
1 | std::atomic<bool> x, y; |
我们启动了两个线程t1
和t2
,分别调用 write_x_then_y()
和 read_y_then_x()
,前者将原子变量x
和y
的值通过原子操作store
修改为true
;后者通过判断x
和y
的值执行相关的操作。
- 在理想情况下,线程t1执行的任务会将原子变量x和y按顺序置为true,从而在线程t2执行的任务中,将z的值++,主函数断言成功,z确实不为0,程序不报错。
- 但是还有一种情况,2 处先于 1 处执行,那么此时在 t2 任务中, y为true跳出循环,但是x仍然为 false,z不++,导致断言失败,程序报错。
我们还可以从以下两个角度分析:
- 从cpu架构分析
假设线程 t1 运行在 核1上,t2 运行在 核3上,那么 t1 对x和y的操作,t2 是看不到的(如果t1没将数据写入至Memory中)。比如当线程t1运行至1处将x设置为true,t1运行至2处将y设置为true。这些操作仅在核1的store buffer中,还未放入cache和memory中,核3 自然不可见。
如果 核1 先将y放入memory,那么核3就会读取y的值为true。那么t2就会运行至3处从while循环退出,进而运行至4处,此时核1还未将x的值写入memory。t2读取的x值为false,进而线程t2运行结束,然后核1将x写入true, t1结束运行,最后主线程运行至5处,因为z为0,所以触发断言。
- 从宽松内存序分析
因为memory_order_relaxed
是宽松的内存序列,它只保证操作的原子性,并不能保证多个变量之间的顺序性,也不能保证同一个变量在不同线程之间的可见顺序。
比如t1可能先运行2处代码再运行1处代码,因为我们的代码会被编排成指令执行,编译器在不破坏语义的情况下(2处和1处代码无耦合,可调整顺序),2可能先于1执行。如果这样,t2运行至3处退出while循环,继续运行4处,此时t1还未执行1初代码,则t2运行4处条件不成立不会对z做增加,t2结束。这样也会导致z为0引发断言。
还有一个涉及1个原子变量和4个线程的例子:
1 | void TestOderRelaxed2() { |
我们创建了一个类型为atomic<int>的变量a,两个vector容器v3和v4以及4个线程t1、t2、t3、t4。
线程t1向原子变量a
中存储偶数,线程t2向a中存储奇数,线程t3从原子变量a
中读取数据写入v3中,线程t4从原子变量a
中读取数据写入v4中。这四个线程并发执行,最后打印v3和v4的数据。
运行代码
因为memory_order_relaxed
不保证顺序性和可见性:
- 顺序性:
t1
和t2
写入的顺序未定义,可能会交错。 - 可见性延迟:
t3
和t4
读取的值不一定是最新的值(可能滞后)。
如果机器性能足够好,t1
和 t2
执行完所有写入操作时(最后一次是线程t2写入奇数9),t3
和 t4
才开始读取,那么 a
的最终值已经是 9
,因此 t3
和 t4
的所有读取结果都会是 9
。我们看到的可能是这种输出
1 | v3: 9 9 9 9 9 9 9 9 9 9 |
如果 t1
和 t2
与 t3
和 t4
并发执行,那么 t3
和 t4
在读取 a
的值时,可能捕捉到某些时刻的中间状态,导致 v3
和 v4
中的值看起来是乱序的。也可能是这种
1 | v3: 0 1 7 6 8 9 9 9 9 9 |
但我们能确定的是如果v3中7先于6,8,9等,那么v4中也是7先于6,8,9。
因为多个线程仅操作了a变量,通过memory_order_relaxed
的方式仅能保证对a的操作是原子的(同一时刻仅有一个线程写a的值,但是可能多个线程读取a的值)。
但是多个线程之间操作不具备同步关系,自然也就构成不了先行关系,那么多个线程之间就不存在可见性。也就是线程t1将a改为7,那么线程t3不知道a改动的最新值为7,它读到a的值为1。只是要过一阵子可能会读到7或者a变为7之后又改动的其他值。
但是t3,t4两个线程读取a的次序是一致的,比如t3和t4都读取了7和9,t3读到7在9之前,那么t4也只能读取到7在9之前。因为我们memory_order_relaxed
保证了多线程对同一个变量的原子操作的安全性,不同线程读取该原子变量的值,要么读到旧值要么读到新值,只不过新值可见性会有延迟。
5.51 获取-释放次序(Acquire-Release)
memory_order_acquire
:用来修饰一个读操作,表示在本线程中,所有后续的关于此变量的内存操作都必须在本条原子操作完成后执行,也就是确保该读取操作之前的所有写操作不被重排到该操作之后,确保在此之前的所有写操作都在此写操作完成前对其他线程可见
memory_order_release
:用来修饰一个写操作,在当前线程中,memory_order_release
之前的所有写操作(包括非原子操作)在释放时对其他线程可见,确保该写操作之后的所有读写操作不会被重排到该操作之前,确保在此之后的所有读操作只会在此读操作完成之后进行
memory_order_acq_rel
:同时包含memory_order_acquire
和memory_order_release
标志。
简而言之就是,任何指令都不能重排到 acquire 操作的前面, 且不能重排到 release 操作的后面
acquire-release 可以实现 synchronizes-with(同步)关系。如果一个
acquire
操作在同一个原子变量上读取到了一个release
操作写入的值,则这个release
操作 “synchronizes-with
” 这个acquire
操作。
我们以宽松次序中的一个例子举例:
1 | void TestOrderRelaxed() { |
在宽松次序中,上面所有的内存序均为std::memory_order_relaxed
,导致 2 和 3 不构成同步关系, 2 “ not synchronizes with “ 3。而这里通过使用Acquire-Release
模型,2 和 3 可构成同步关系,即 2 “ synchronizes with “ 3。
从C++语句层面来看:
我们将ry的读操作使用的内存序换为
memory_order_acquire
,将ry的写操作使用的内存序换为memory_order_release
;当线程t1执行至 2 处将ry设置为true,线程t2执行至 3 处时,因为使用了Acquire-Release
模型,所以 1 先行于 4(线程t1中 1 顺序先于 2(sequence before), 而2又先行于3,那么 1先行于3。那我们可以理解t2执行到3处时,可以获取到t1执行1操作的结果,也就是rx为true。t2线程中3顺序先行于4(sequence before),那么1 操作自然也先行于 4。也就是1 操作的结果可以立即被4获取)。从CPU指令层面来看:
如果原子变量使用了
store
原子操作,且该原子操作传入了内存序memory_order_release
,若在该行指令上面还有其他store原子操作(或者其他非原子的写操作),不管其他操作的内存序是memory_order_relaxed
、memory_order_seq_cst
还是memory_order_release
,CPU指令都必须按顺序执行(比如这里2处使用了memory_order_release
,那么不管1处的内存序是什么,CPU指令都必须是按 1→2 的顺序执行,将指令依次写入内存)。所以,只要CPU读到了
memory_order_release
操作,那么编译器就一定是将这行指令包括这行指令前面的指令按顺序写入内存,必定有顺序先行性。
Acquire-release
的开销比 sequencial consistent
小。
在 x86 架构下,memory_order_acquire
和 memory_order_release
的操作不会产生任何其他的指令, 只会影响编译器的优化:任何指令都不能重排到 acquire 操作的前面, 且不能重排到 release 操作的后面;否则会违反 acquire-release
的语义。因此很多需要实现 synchronizes-with
关系的场景都会使用 acquire-release
。
5.52什么是先行?什么是同步
同步发生在多线程环境中,而先行在单线程和多线程中都可以发生。
原子操作可以隐式地形成同步关系(synchronizes-with),但同步关系不限于原子操作,还可以通过其他机制(如互斥锁、条件变量等)实现。但这些同步机制其实都是对原子操作的模拟,所以这句话也可以说为:同步关系只存在于原子类型的操作之间。
同步关系的基本思想是:对变量 x 执行原子写操作 W 和原子读操作 R,且两者都有适当的标记(六种内存序)。只要满足下面其中一点,它们即彼此同步:
- R 读取了 W 直接存入的值。
- W 所属线程随后还执行了另一原子的写操作, R 读取了后面存入的值。
- 任意线程执行一连串 “读-改-写” 操作(如fetch_add或compare_exchange_weak(),前者相当于x++,后者相当于“比较-交换-返回”),而其中第一个操作读取的值由 W 写出。
上面的代码段符合第一点,先写后读,读到的数据必定是先写入的,所以是同步的。
“synchronizes-with
“ : 同步,“A synchronizes-with
B” 的意思就是 A和B同步,简单来说如果多线程环境下,有一个线程先修改了变量m,我们将这个操作叫做A,之后有另一个线程读取变量m,我们将这个操作叫做B,那么B一定读取A修改m之后的最新值。也可以称作 A “happens-before
“ B,即A操作的结果对B操作可见。
其实同步和先行差不多,不过得注意:操作A先行于操作B,并不是说操作A一定会在操作B之前发生,而是表示如果操作A在操作B之前发生,那么操作A的结果一定对操作B可见。比如:
1 | int Add() { |
在单线程下,虽然在代码中,指令A确实在指令B前面(A顺序先行B),则指令A确实先行于指令B,如果A先执行了,那么A的结果一定对B可见。但是计算机的指令可能不是按这样的顺序执行,一条C++语句对于多条计算机指令:有可能是先将b值放入寄存器eax做加1,再将a的值放入寄存器edx做加1,然后再将edx寄存器的值写回a,将eax写回b。
因为对于计算机来说 1处 操作和 2处 操作的顺序对于 3处 来说并无影响。只要3处返回a+b
之前能保证a和b的值是增加过的即可。
那我们语义上的”Happens-before”有意义吗? 是有意义的,因为如果 a 顺序先行于 b,那么无论指令如何编排,最终写入内存的顺序一定是a先于b(比如单核最后将数据放入Memory缓存中,顺序一定是a先于b)。
1. Happens-before 的意义是什么?
Happens-before 在程序设计中起到了逻辑约束的作用,规定了操作之间的可见性和一致性:
- 如果操作 A 先行发生于操作 B:
- 保证 A 的效果对 B 可见,即 B 必须观察到 A 的修改。
- 保证乱序不会破坏逻辑顺序:程序在最终写入共享内存时,A 的修改必须“先于” B 的修改,即使在执行过程中,操作 A 和 B 的指令被重排,只要 A 先行发生于 B,最终写入内存的顺序依然是 A 的结果先生效,然后是 B 的结果。
- 保证数据一致性:当一个线程观察到 A 的效果时,它必然也能观察到 A 之前所有操作的效果。
2. 指令乱序执行的背景?
现代 CPU 和编译器会对指令进行重排序,以提高性能:
- 编译器可能重新排列指令以优化寄存器或流水线使用。
- CPU 的硬件可能在执行时动态调整指令的顺序。
然而,这种乱序不会破坏程序的语义,因为 Happens-before 提供了一个逻辑上的约束,确保即使指令被重排,最终的行为仍符合程序员的预期。
5.53 CPU内存结构
一个简单的四核CPU内存结构示意图如下所示:
CPU 和内存之间通过三级缓存(StoreBuffer、Cache、Memory)进行数据交互,CPu每个核都有自己的缓存区StoreBuffer
,对其他核不可见。但是每两个核组成一个Bank
,每个Bank
共享一个Cache
缓存区,每个Bank中的核可以将缓存区StoreBuffer
中的数据写入至Cache
,这样两个核之间的数据就可以进行交互。每四个核又有一个缓存区Memory
,每个Bank
可以将自己的Cache
写入至Memory
,这样两个Bank
,即四个核就可以进行数据交互。
如果多个核对同一个快缓存区的数据进行修改,比如对Cache缓存区中的变量a进行值修改,可能会造成数据竞争,假如线程 1 从主存中读取到 x,并对其加 1 ,此时还没有写回主存,线程 2 也从主存中读取 x ,并加 1 ,它们是不知道对方的,也不可以读取对方的缓存。这时都将 x 写回主存,那此时 x 的值就少了 1 。那该如何保证数据一致性?这就要提及MESI一致性协议。
MESI 协议,是一种叫作写失效(Write Invalidate)的协议。在写失效协议里,只有一个 CPU 核心负责写入数据,其他的核心,只是同步读取到这个写入。在这个 CPU 核心写入 cache 之后,它会去广播一个“失效”请求告诉所有其他的 CPU 核心。
MESI 协议对应的四个不同的标记,分别是:
- M:代表已修改(Modified),该缓存行只被缓存在该 CPU 的缓存中,并且是被修改过的(dirty),即与主存中的数据不一致,该缓存行中的内存需要在未来的某个时间点(允许其它 CPU 读取请主存中相应内存之前)写回主存。当被写回主存之后,该缓存行的状态会变成独享(exclusive)状态。
- E:代表独占(Exclusive),表示该缓存行中的数据与主存中的数据一致,且其他处理器的缓存中没有该数据。当前处理器可以对其进行读写操作。当处理器对该缓存行进行写操作时,状态变为 Modified;当其他处理器读取该缓存行对应的主存地址时,状态变为 Shared。
- S:代表共享(Shared),共享状态就是在多核中同时加载了同一份数据。所以在共享状态下想要修改数据要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 cache ,都变成无效的状态,然后再更新当前 cache 里面的数据。多个处理器可以同时读取该缓存行的数据,但写操作需要先将状态转换为 Exclusive 或 Modified。
- I:代表已失效(Invalidated),表示该缓存行中的数据无效,需要从主存或其他处理器的缓存中重新获取。当处理器需要访问该缓存行时,会发起一个读取请求,从主存或其他处理器的缓存中获取最新的数据,并更新自己的缓存行状态。
我们可以这么理解,如果变量a此刻存在于各个核的StoreBuffer
中,那么CPU1核修改这个a的值,放入cache
时通知其他CPU核写失效,因为同一时刻仅有一个CPU核可以写数据,但是其他CPU核是可以读数据的,那么其他核读到的数据可能是CPU1核修改之前的。
假设有两个处理器 P1 和 P2,以及一个共享的内存地址 A。
- 初始状态:P1 和 P2 的缓存中都没有地址 A 的数据,该缓存行在两个处理器的缓存中状态均为 Invalid。
- P1 读取地址 A:P1 从主存中读取地址 A 的数据到自己的缓存中,此时该缓存行在 P1 的缓存中状态变为 Exclusive。
- P2 读取地址 A:P2 也需要读取地址 A 的数据,它会向总线发送一个读取请求。P1 收到请求后,将该缓存行的状态变为 Shared,并将数据通过总线发送给 P2。此时,该缓存行在 P1 和 P2 的缓存中状态均为 Shared。
- P1 写地址 A:P1 要对地址 A 的数据进行写操作,它会向总线发送一个 invalidate 消息。P2 收到消息后,将自己缓存中该缓存行的状态置为 Invalid。P1 将自己缓存中该缓存行的状态变为 Modified,并进行写操作。
CPU多级缓存设置的目的是什么
CPU 多级缓存设置主要是为了解决计算机系统中 CPU 与主存之间速度不匹配的问题,同时提升 CPU 的整体性能和效率。
1. 弥合 CPU 和主存之间的速度差距
- 速度差异显著:现代 CPU 的运行速度极快,而主存(如 DRAM)的访问速度相对较慢。这种速度上的巨大差异使得 CPU 在等待从主存中读取数据时会处于空闲状态,导致 CPU 的性能无法得到充分发挥。
- 缓存的作用:多级缓存是介于 CPU 和主存之间的高速存储部件,其访问速度比主存快很多。通过在 CPU 和主存之间设置多级缓存,可以将 CPU 近期可能会使用到的数据和指令提前存储在缓存中,当 CPU 需要这些数据和指令时,直接从缓存中读取,从而减少了 CPU 等待主存数据的时间,提高了 CPU 的利用率。
2.优化多级缓存层次结构
- 不同级别的缓存特点:现代 CPU 通常采用多级缓存结构,如一级缓存(L1 Cache)、二级缓存(L2 Cache)和三级缓存(L3 Cache)。一级缓存离 CPU 最近,访问速度最快,但容量较小;二级缓存的访问速度次之,容量比一级缓存大;三级缓存的访问速度相对较慢,但容量更大。
- 层次结构的优势:多级缓存的层次结构可以根据数据的访问频率和重要性进行合理的分配。频繁访问的数据和指令可以存储在一级缓存中,以获得最快的访问速度;而相对不那么频繁访问的数据和指令可以存储在二级或三级缓存中。这种层次结构可以在保证缓存访问速度的同时,提高缓存的利用率,进一步提升 CPU 的性能。
3. 减少主存带宽压力
- 主存带宽有限:主存的带宽是有限的,大量的 CPU 访问请求会对主存的带宽造成很大压力。如果 CPU 每次都直接从主存中读取数据,会导致主存的带宽被迅速耗尽,影响系统的整体性能。
- 缓存减少访问需求:多级缓存可以在一定程度上减少 CPU 对主存的访问需求。大部分数据和指令可以在缓存中找到,只有当缓存中没有所需的数据或指令时,CPU 才会访问主存。这样可以降低主存的访问频率,减轻主存的带宽压力,提高系统的整体性能。
5.54 Vector是存放在堆区还是栈区
1 | std::vector<T> vec; |
首先,说结论吧(假设T是一个定义好的类):
- 对于
std::vector<T> vec;
vec在栈上(stack),而其中的元素T保存在堆上(heap); - 对于
std::vector<T>* Vec = new std::vector<T>();
vec和其中的元素T都保存在堆上; - 对于
std::vector<T*> vec;
vec在栈上(stack),而其中的元素T保存在堆上(heap),而且需要手动释放该对象所占内存。
5.55 EAGAIN
EAGAIN
是在 Unix 和类 Unix 系统编程里常见的一个错误码,它在不同的场景下有着不同的含义,但核心意思是当前操作无法立即完成,不过后续可以再次尝试。
1. 在非阻塞 I/O 操作中的含义
当文件描述符被设置为非阻塞模式时,若进行 I/O 操作(像读取、写入等),而此时没有数据可读或者没有足够的空间可写,相应的系统调用就会失败,并且返回 -1
,同时将 errno
设置为 EAGAIN
(在某些系统中也可能是 EWOULDBLOCK
,这两者含义相同)。
2. 在线程同步中的含义
在使用 pthread
库进行线程同步时,有些操作(如尝试获取互斥锁)可能会因为锁已经被其他线程持有而失败,此时系统会返回 EAGAIN
。这表明当前无法立即获取锁,但后续可以再次尝试。
5.56 怎么设置非阻塞
在 Linux 系统中,可以通过
fcntl
函数来设置文件描述符为非阻塞模式。要解决阻塞问题,就需要将套接字默认的阻塞行为修改为非阻塞,需要使用
fcntl()
函数进行处理:1
2
3
4// 设置完成之后, 读写都变成了非阻塞模式
int flag = fcntl(cfd, F_GETFL);
flag |= O_NONBLOCK; // flag = flag | O_NONBLOCK;
fcntl(cfd, F_SETFL, flag);fcntl()
是一个变参函数, 并且是多功能函数,可以通过这个函数实现文件描述符的复制和获取/设置已打开的文件属性。该函数的函数原型如下:1
2
3
4
int fcntl(int fd, int cmd, ... /* arg */ );fd
: 要操作的文件描述符cmd
: 通过该参数控制函数要实现什么功能
参数 cmd 的取值 功能描述 F_DUPFD 复制一个已经存在的文件描述符 F_GETFL 获取文件的状态标志 F_SETFL 设置文件的状态标志 - 返回值:函数调用失败返回 -1,调用成功,返回正确的值:
- 参数
cmd = F_DUPFD
:返回新的被分配的文件描述符 - 参数
cmd = F_GETFL
:返回文件的flag属性信息
- 参数
文件状态标志 说明 O_RDONLY 只读打开 O_WRONLY 只写打开 O_RDWR 读、写打开 O_APPEND 追加写 O_NONBLOCK 非阻塞模式 O_SYNC 等待写完成(数据和属性) O_ASYNC 异步I/O O_RSYNC 同步读和写 网络编程中,可直接将socket设置为非阻塞
建立一个函数try_lock,将获取共享资源的过程设置为非阻塞。