0%

C++八股

0️⃣C/C++基础

语言基础

向上转型为什么是安全的?

向上转型是安全的,因为它是将子类对象转换为父类对象,不会丢失子类对象的任何信息。这是因为子类继承了父类的属性和方法,所以子类对象包含了父类对象的所有信息

C++ 中的四种类型转换

static_cast<type>(a)

和传统的C语言的强制转换一样,不做任何检查,如转型失败会发生未知错误。

static_cast<type>(a) 用于基本数据类型之间的转换,如把 int 转换成 char,把 char 转换成 int 这种转换的安全性也要开发人员来保证

dynamic_cast<type>(a)

主要用于类层次间的上行转换和下行转换 在类层次间进行上行转换时,dynamic_cast 和 static_cast 的效果是一样的 在进行下行转换时,dynamic_cast 具有类型检查的功能,比 static_cast 更安全

转型失败会返回0并抛出bad_cast

const_cast<type>(a) :用来去除某指针或者引用的const属性 常量指针被转化成非常量指针,并且仍然指向原来的对象

reinterpret_cast<type>(a) :可进行任意转型,把a中的内存按type的方式来解读,需要程序员保证转换的正确性。


说说 C++中 struct 和 class 的区别
  1. struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装

  2. struct 中默认的访问控制权限是 public 的,而 class 中默认的访问控制权限是 private 的

    struct A { int iNum; } // 默认访问控制权限是 public 
    class B { int iNum; } // 默认访问控制权限是 private
  3. struct 默认是公有继承,而 class 是私有继承

  4. class 关键字可以用于定义模板参数,就像 typename,而 struct 不能用于定义模板参数

    template<typename T, typename Y> // 可以把typename 换成 class  
    int Func(const T& t, const Y& y) {}

说说头文件双引号””和尖括号<>的区别
  • <>的头文件是系统文件,””的头文件是自定义文件
  • 编译器预处理阶段查找头文件的路径不一样
    • 使用<>的头文件:编译器设置的头文件路径
    • 使用””的头文件:当前目录 or g++指定-I参数查找

说说C++结构体和C结构体的区别
  1. C++ 中的 struct 是对 C 中的 struct 进行了扩充, 它们在声明时的区别

    C C++
    成员函数 不能有 可以
    静态成员 不能有 可以
    访问控制权限 默认public,不能修改 public/private/protected
    继承关系 不可以继承 可继承类或者其他结构体
    初始化 不能直接初始化数据成员 可以
  2. C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字使用

    struct Student{  
    int iAgeNum;
    string strName;
    } typedef struct Student Student2; //C中取别名

    struct Student stu1; // C 中正常使用
    Student2 stu2; // C 中通过取别名的使用
    Student stu3; // C++ 中使用

导入C函数的关键字?C++编译时和C有何不同?
  1. 关键字:在C++中,导入C函数的关键字是extern,表达形式为extern “C”。加上extern “C”后,会指示编译器这部分代码按C语言的进行编译,而非C++的

  2. 编译区别:由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时只包括函数名

    //在C++程序里边声明该函数,会指示编译器这部分代码按C语言的进行编译 
    extern "C" int strcmp(const char *s1, const char *s2);

⚠️简述C++从代码到可执行文件过程

image-20230502080518742

C++和C语言类似,一个C++程序从源码到执行文件,有四个过程,预处理、编译、汇编、链接

  1. 预处理

    (1) 展开所有的宏定义#define

    (2) 处理所有的条件预编译指令,如#if、#ifdef

    (3) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置

    (4) 过滤所有的注释, 添加行号, 添加文件名标识

  2. 编译 - 处理内联函数,constexpr,static

    (1) 词法分析:将源代码的字符序列分割成一系列的记号

    (2) 语法分析:对记号进行语法分析,产生语法树

    (3) 语义分析:判断表达式是否有意义

    (4) 代码优化

    (5) 生成汇编代码

  3. 汇编 将汇编代码 -> 机器码

  4. 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。分为静态链接和动态链接。

    静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;Windows下.lib Linux下.a

    ar rcs Calc.a x.o y.o

    gcc main.c -o myprogramme -L ./ -l Calc [第一个myprogramme是生成的可执行文件名]

    动态链接,是在链接的时候没有把调用的函数代码链接进去,生成的可执行文件中没有函数代码, 只包含函数的重定位信息, 在执行的过程中,再去找要链接的函数. 所以当删除动态库时,可执行程序就不能运行. Windows下.dll,Linux下.so

    gcc -c -fPIC a.c b.c gcc 得到.o文件,得到和位置无关的代码
    gcc -shared a.o b.o -o libcalc.so gcc 得到动态库


说说 static关键字的作用
  1. 修饰全局变量时,表明一个全局变量只对定义在同一文件中的函数可见。
  2. 修饰局部变量时,表明该变量的值不会因为函数终止而丢失。
  3. 修饰函数时,表明该函数只在同一文件中调用。
  4. 静态成员函数和静态成员变量是类的一部分,可以被多个对象所共享,无法访问一个对象中的非静态成员

静态变量什么时候初始化

对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化

而C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造


说说静态局部变量, 全局变量, 局部变量的特点
  1. 首先从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件

    全局变量:全局作用域,可以通过extern作用于其他的源文件

  2. 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值


说说什么是函数指针
  1. 概念:函数指针就是指向函数入口地址的指针。
  2. 定义形式如下:
int func(int a);  
int (*f)(int a);
f = &func;
  1. 函数指针的应用场景回调(callback)。我们调用别人提供的 API称为Call;如果别人的库里面调用我们的函数,就叫Callback。
//以库函数qsort排序函数为例,它的原型如下:
void qsort(void *base,//void*类型,代表原始数组
size_t nmemb, //第二个是size_t类型,代表数据数量
size_t size, //第三个是size_t类型,代表单个数据占用空间大小
int(*compare)(const void *,const void *)//第四个参数是函数指针
);
//第四个参数告诉qsort,应该使用哪个函数来比较元素,即只要我们告诉qsort比较大小的规则,它就可以帮我们对任意数据类型的数组进行排序。在库函数qsort调用我们自定义的比较函数,这就是回调的应用。

//示例
int num[100];
int cmp_int(const void* _a , const void* _b){//参数格式固定
int* a = (int*)_a; //强制类型转换
int* b = (int*)_b;
return *a - *b;  
}
qsort(num,100,sizeof(num[0]),cmp_int); //回调

nullptr可以调用成员函数吗?为什么?
//给出实例
class animal{
public:
void breathe(){ cout << "animal breathe haha" << endl; }
};
class fish :public animal{
public:
void breathe(){ cout << "fish bubble" << endl; }
};
int main(){
animal *pAn = nullptr;
pAn->breathe(); // 输出:animal breathe haha
fish *pFish = nullptr;
pFish->breathe(); // 输出:fish bubble
return 0;
}

可以,因为在编译时指针就绑定了函数地址,和指针空不空没关系。pAn->breathe();编译的时候,函数的地址就和指针pAn绑定了;调用breath(*this), this就等于pAn。由于函数中没有需要解引用this的地方,所以函数运行不会出错,但是若用到this,因为this=nullptr,运行出错。


内联函数和函数的区别,内联函数的作用
  1. 内联函数:调用表达式用内联函数体来替换,避免了函数调用的开销;普通函数有调用的开销
  2. 普通函数在被调用的时候,需要寻址(函数入口地址);内联函数不需要寻址。
  3. 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句;普通函数没有这个要求。

说说内联函数和宏的区别
  1. 宏定义不是函数,相当于直接替换;而内联函数本质上是一个函数,内联函数满足函数的性质,比如有返回值、参数列表
  2. 宏函数是在预编译的时候字符串替换;而内联函数则是在编译的时候进行代码插入
  3. 宏定义是没有类型检查的;而内联函数在编译的时候会进行类型的检查

说说new和malloc的区别,底层实现原理
  1. new是操作符可以被重载,而malloc是函数
  2. new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数
  3. malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转
  4. new分配内存更直接和安全
  5. new发生错误抛出异常,malloc返回null

malloc底层实现:当开辟的空间小于 128K 时,调用 brk () 函数【当进程需要分配更多内存时,可以使用brk()函数将堆的结束位置向上移动一定的距离。相反,当进程释放内存时,可以使用brk()将堆的结束位置向下移动】;当开辟的空间大于 128K 时,调用mmap()

malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲块。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址

new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:

(1) 创建一个新的对象

(2) 将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)

(3) 执行构造函数(为这个新对象添加属性)


delete 和 free 的区别
  1. delete 是操作符,而 free 是函数;
  2. free 不会调用对象的析构函数,而 delete 会调用对象的析构函数;
  3. 调用 free 之前需要检查要释放的指针是否为 NULL,使用 delete 释放内存则不需要检查指针是否为 NULL;

说说const和define的区别。

const用于定义常量;而define用于定义宏 :

  1. const生效于编译的阶段;define生效于预处理阶段
  2. const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的
  3. const定义的常量是带类型的;define定义的常量不带类型,不利于类型检查

常量指针和指针常量
//const* 是常量指针,*const 是指针常量
int const *a; // a指针所指向的内存里的值不变,即(*a)不变
int *const a; // a指针所指向的内存地址不变,即a不变

1. const int a; // 指的是a是一个常量,不允许修改
2. const int *a; // a指针所指向的内存里的值不变,即(*a)不变
3. int const *a; // 同 const int *a;
4. int *const a; // a指针所指向的内存地址不变,即a不变
5. const int *const a; // 都不变,即(*a)不变,a也不变

⚠️同名全局变量在多个c文件中公用的方法

项目文件夹下有a.c、b.c和c.h三个文件, 其中a.c和b.c文件中都#include c.h 我们希望声明一个变量key,在a.c和b.c中公用

有人想,既然是想两个文件都用,那就在c.h中声明一个unsigned char key,然后由于包含关系,在a.c和b.c中都是可见的,所以就能共用了,但实际写出来,我们发现编译的时候编译器提示出错,编译器认为我们重复定义了key这个变量。这是因为**#include命令就是原封不动的把头文件中的内容搬到#include的位置**,所以相当于a.c和b.c中都执行了一次unsigned char key,而C语言中全局变量是项目内可见的,这样就造成了一个项目中两个变量key,编译器就认为是重复定义

正确办法1:使用extern关键字来声明变量为外部变量。在其中一个c文件中定义一个全局变量key,然后在另一个要使用key的c文件中用extern声明一次,说明这个变量为外部变量,是在其他的c文件中定义的全局变量。请注意我这里的用词:定义声明。例如在a.c文件中定义变量key,在b.c文件中声明key变量为外部变量,这样这两个文件中就能共享这个变量key了

image-20230215202524179

正确办法2:

/*a.c文件中:*/
uint8_t changeflag = FALSE;

/*c.h文件中:*/
extern uint8_t changeflag;

/*b.c文件中:*/
#include "c.h" // 这样就已经可以使用全局变量 changeflag 了。

正确方法3: 在不同的C文件中以static形式来声明同名全局变量


c++命名空间

namespace命名空间语法,定义格式为namespace A {},大括号是范围限定,也就是括号内是一个整体空间,可以有任何东西,如变量、函数等,括号内可以直接引用,而括号外的想相互访问必须指定空间名称+内部名称,namespace本质上就是改变全局变量或函数的链接属性,即改变作用域。

定义:

namespace test{ 
void func1(void);
void func2(void);
}

引用:

// 法1
test::func1();
test::func2();

// 法2
using test::func1; //单独声明命名空间中的func1函数
func1(); //直接使用func1,无须添加其他的前缀

// 法3
using namespace test; //将整个命名空间全部声明
func1(); //访问空间内的函数1
func2(); //访问空间内的函数2

跨文件使用:

image-20230215210345480

注意

命名空间只能全局范围内定义(以下错误写法)

void test(){
namespace A{
int a = 10;
}
namespace B{
int a = 20;
}
cout << "A::a : " << A::a << endl;
cout << "B::a : " << B::a << endl;
}

命名空间可嵌套命名空间

namespace A{
int a = 10;
namespace B{
int a = 20;
}
}
void test(){
cout << "A::a : " << A::a << endl;
cout << "A::B::a : " << A::B::a << endl;
}

命名空间是开放的,即可以随时把新的成员加入已有的命名空间中

namespace A{ int a = 10; }

namespace A{ void func(){ cout << "hello namespace!" << endl; } }

void test(){
cout << "A::a : " << A::a << endl;
A::func();
}

匿名命名空间,意味着命名空间中的标识符只能在本文件内访问,相当于给这个标识符加上了static

namespace{
int a = 10;
void func(){ cout << "hello namespace" << endl; }
}
void test(){
cout << "a : " << a << endl; //直接访问就行,可以看成静态变量
cout << "a : " << ::a << endl;
func();
}

C++如何在main函数前运行一个函数
#include <iostream>
void init() {
std::cout << "Init function is called" << std::endl;
}

class InitWrapper {
public:
InitWrapper() { init(); }
};

InitWrapper s_initWrapper;

int main() {
std::cout << "Main function is called" << std::endl;
return 0;
}

需要注意的是,这种方法虽然可以在 main() 函数执行之前执行一个函数,但是由于全局变量的构造函数在程序启动时会被自动调用,因此在多个文件中定义全局变量时可能会引起链接错误。为了避免这个问题,可以将全局变量定义在一个独立的文件中,并在其他文件中使用该全局变量


Explicit和隐式类型转换

隐式类型转换指的是一种自动进行的类型转换,将一个数据类型自动转换为另一个数据类型,而不需要显式地调用任何类型转换函数。

隐式类型转换在表达式求值时自动发生,可以将一个值从一种数据类型转换为另一种数据类型。例如,当一个int类型的值被赋值给一个float类型的变量时,就会发生隐式类型转换:

int n = 42;
float f = n; // 隐式类型转换,将n从int类型转换为float类型

此外,当表达式中出现多种数据类型时,也会发生隐式类型转换,例如:

int n = 42;
float f = 3.14;
double d = n + f; // 隐式类型转换,将n和f分别转换为double类型,然后执行加法运算

虽然隐式类型转换可以方便地进行数据类型的转换,但是在某些情况下可能会引发一些不必要的错误或问题,因此需要谨慎使用。可以通过使用explicit关键字来限制某些类型转换的发生,使代码更加明确和安全。

class MyClass {
public:
explicit MyClass(int n) {
value = n;
}
private:
int value;
};

int main() {
int n = 42;
//MyClass obj = n; // 错误,不能进行隐式类型转换
MyClass obj(n); // 正确,使用显式构造函数进行对象创建
return 0;
}

如果去掉explicit关键字,则可以进行隐式类型转换,例如MyClass obj = n;就可以编译通过,将一个int类型的变量转换为MyClass类型的对象

C++内存

⚠️进程运行时虚拟地址空间
img
  1. data段:已初始化的全局变量和静态变量

  2. 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量

  3. bss段:未初始化的全局变量和静态变量

  4. 可执行程序在运行时又会多出两个区域:堆区和栈区

    堆区:动态申请内存用。堆从低地址向高地址增长

    栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间

  5. 最后还有一个共享区,位于堆和栈之间

程序启动的过程:

  1. OS创建进程并分配私有进程空间, 然后OS加载器把可执行文件的数据段和代码段映射到进程的虚拟内存空间
  2. 加载器读入可执行程序的导入符号表, 根据符号表可以查找出该可执行程序的所有依赖的动态链接库。调用动态链接库的初始化函数
  3. 初始化全局变量和全局对象
  4. 进入可执行程序入口处开始执行

简述C++的内存管理
  1. 内存分配方式

    在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。

    ,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放

    ,就是那些由malloc等分配的内存块

    自由存储区,就是那些由new分配的内存块,和堆是十分相似的

    静态存储区,全局变量和静态变量被分配到同一块内存中

    常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改

  2. 内存泄露

    简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数


常量存放在内存的哪个位置?

局部常量,存放在栈区;

全局常量,编译期一般不分配内存,放在符号表中以提高访问效率;

字面值常量, 比如字符串,放在常量区。


简述C++中内存对齐的使用场景

内存对齐应用于三种数据类型中:struct/class/union,对齐原则有四个:

  1. struct或union的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小的整数倍开始

  2. 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部”最宽基本类型成员”的整数倍地址开始存储。(class a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储)

  3. sizeof(struct) = 其内部“最宽基本类型成员”的整数倍。(基本类型不包括struct/class/uinon)

  4. sizeof(union) = 结构里面size最大元素的size,因为在某一时刻,union只有一个成员真正存储于该地址


说下RAII, 与智能指针之间的联系
// 不用RAII
#include <iostream>
using namespace std;
class A{
public:
A() { cout << "Agouzao" << endl; }
~A() { cout << "Axigou" << endl; }
};

int main(){
A *a = new A();
// delete a; 堆内存忘记释放,多了可能引起内存泄露
return 0;
}
// 用RAII改造后
#include <iostream>
using namespace std;

template<typename T>
class A{
T *a;

public:
A(int n = 1) { a = new T[n]; }
~A() {
delete []a;
a = nullptr;
};
T* get(){ return a; }
};

int main(){
A<int> a; // 调用A构造创建a对象.是普通的栈内存对象.
// 当其作用范围结束后,就会自动调用其析构函数释放a对象的内存
int *ptr = a.get();
return 0;
}
// or
#include <iostream>
#include <memory>
using namespace std;

template<typename T>
class A{
unique_ptr<T[]> a;

public:
A(int n = 1) : a(new T[n]) {}
T* get(){ return a.get(); }
};

int main() {
A<int> a;
int *ptr = a.get();
return 0;
}

这里只是为了理解RAII简单的实现了一下,实际操作中这么写还会出现很多问题.

RAII对象也可以进行复制,但是拷贝的时候必须要一并复制它所管理的资源。一般设置为不可拷贝是为了防止误拷贝时使用了自动生成的拷贝函数,自动生成的函数一般进行的是浅拷贝

简单的处理就是为A类定义一个父类,在父类中将拷贝构造、赋值操作符与new操作符重载声明为私有接口

// RAII升级版
#include <iostream>
using namespace std;

class ABase{
private:
ABase(const ABase&); // 防止子类调用默认拷贝构造
ABase &operator = (const ABase&); // 防止子类调用默认重载操作符
void* operator new(size_t size); // 防止子类new
protected:
ABase(){}
~ABase(){}
};

RAII与智能指针

智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源。

#include <memory>

class Resource {
public:
Resource() : data_(new int[100]) {}
~Resource() { delete[] data_; }

private:
int* data_;
};

void doSomething() {
unique_ptr<Resource> ptr(new Resource()); // 等价于 Resource* ptr = new Resource(); delete ptr;
// 使用ptr管理Resource对象,无需手动调用delete释放资源
// ...
} // ptr在此处离开作用域,自动释放Resource对象

RAII补充
#include <iostream>
class A {
public:
A(int size) {
m_data = new int[size];
m_size = size;
}
~A() { delete[] m_data; }

void fillArray() { for (int i = 0; i < m_size; i++) m_data[i] = i;}
void printArray() { for (int i = 0; i < m_size; i++) std::cout << m_data[i] << " "; }
private:
int* m_data;
int m_size;
};

void testA() {
A a(10);
a.fillArray();
a.printArray();
}

int main() {
testA();
return 0;
}

如果在 testA 函数中没有正确地删除 a 对象,就会导致数组的内存泄漏。例如,如果我们在 testA 函数中添加一个无限循环:

void testA() {
A a(10);
a.fillArray();
a.printArray();
while (true) {
// do nothing
}
}

此时程序会陷入无限循环,无法退出 testA 函数。当 main 函数结束时,a 对象并没有被正确地删除,导致数组的内存泄漏。

RAII改写

#include <iostream>
#include <memory>

class A {
public:
A(int size) {
m_data = make_unique<int[]>(size);
m_size = size;
}

void fillArray() { for (int i = 0; i < m_size; i++) m_data[i] = i; }
void printArray() { for (int i = 0; i < m_size; i++) cout << m_data[i] << " "; }

private:
unique_ptr<int[]> m_data; // unique_ptr<T[]> a;
int m_size;
};

void testA() {
A a(10);
a.fillArray();
a.printArray();
while (true) {
// do nothing
}
}

int main() {
testA();
return 0;
}

C++面向对象

简述一下 C++重载和重写
  • 重写:是指派生类中存在重新定义的函数。

    其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同,派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。基类中被重写的函数必须有virtual修饰

    #include<stdio.h> 
    using namespace std;
    class A{ public: virtual void fun() { cout << "A"; } };
    class B : public A { public: void fun() { cout << "B"; } };
    int main() {
    A* a = new B();
    a -> fun();//输出B,A类中的fun在B类中重写
    }
  • 重载

    在C++中人们提出了用一个函数名定义多个函数,也就是所谓的函数重载。函数重载是指同一可访问区内被声明的几个具有不同参数(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载的返回值类型可以不同

    #include<stdio.h> 
    using namespace std;
    class A{
    void fun(){};
    void fun(int i){};
    void fun(int i, int j){};
    int fun(int i, int j){};
    };

说说 C++ 重载和重写是如何实现的
  • 重写

    在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数

    1. 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数
    2. 有虚函数的都有个一维的虚函数表叫做虚表,类的每个对象有一个指向虚表开始的虚指针。虚函数表中存储的是类中的虚函数的地址。如果派生类重写了基类中的虚函数,则派生类对象的虚指针指向派生类的虚函数地址,如果派生类没有重写基类中的虚函数,则派生类对象的虚指针指向的是父类的虚函数地址
    3. 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性

    纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数

  • 重载

    C++利用命名倾轧(name mangling)技术,来改名函数名,区分参数不同的同名函数。命名倾轧是在编译阶段完成的。


说说C++构造函数有几种

C++中的构造函数可以分为4类:默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数。

  1. 默认构造函数和初始化构造函数。 在定义类的对象的时候,完成对象的初始化工作。

    class Student{ 
    public:
    Student(){ //默认构造函数
    num=1001;
    age=18;
    }
    Student(int n,int a):num(n),age(a){} //初始化构造函数
    private:
    int num;
    int age;
    };
    int main() {
    Student s1; //用默认构造函数初始化对象S1
    Student s2(1002,18); //用初始化构造函数初始化对象S2
    return 0;
    }

    有了有参的构造了,编译器就不提供默认的构造函数。

  2. 复制构造函数

    class Test {     
    int i;
    int *p;
    public:
    Test(int ai,int value){
    i = ai;
    p = new int(value);
    }
    ~Test(){
    delete p;
    }
    Test(const Test& t){
    this->i = t.i;
    this->p = new int(*t.p);
    }
    };
    int main(int argc, char* argv[]){
    Test t1(1,2);
    Test t2(t1);//将对象t1复制给t2。注意复制和赋值的概念不同
    return 0;
    }

    浅拷贝:只是对指针的拷贝,拷贝后两个指针指向同一个内存空间。修改其中任意的值,另一个值都会变化

    深拷贝:不但对指针进行拷贝, 且对指针指向的内容拷贝, 经过深拷贝后的指针是指向两个不同地址的指针。修改其中任意的值,另一个值不会变化

  3. 移动构造函数

    我们用对象a初始化对象b,后对象a我们就不在使用了,但是对象a的空间还在呀(在析构之前),既然拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷

    拷贝构造函数中,对于指针,我们采用深拷贝,而移动构造函数中,对于指针,我们采用浅拷贝。浅拷贝之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针 (如a-> value) 置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间

    移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只有用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值

    #include<iostream>
    #include<vector>
    using namespace std;

    class Test{
    public:
    Test(Test&& a) : x(a.x){ // && 是右值引用 int num = 10; int && a = 10;
    p = a.p;
    a.p = NULL;
    cout << "移动构造函数" << endl;
    }
    ~Test(){ if(p != NULL) delete p; }
    private:
    int x;
    char *p;
    };

    int main(){
    int x = 8;
    char ch = 'A';
    char *p = &ch;
    Test c(move(p));

    return 0;
    }

只定义析构函数,会自动生成哪些构造函数

只定义了析构函数,编译器将自动为我们生成拷贝构造函数和默认构造函数。

默认构造函数和初始化构造函数。 在定义类的对象的时候,完成对象的初始化工作


一个类默认会生成哪些函数
//定义一个空类
class Empty{
};
//默认会生成以下几个函数

//1.默认构造
Empty(){
}

//2.拷贝构造
Empty(const Empty& copy){
}

//3.赋值运算符
Empty& operator = (const Empty& copy){
}

//4.析构函数(非虚)
~Empty(){
}

哪些因素会影一个类对象的大小

对象大小 = 虚函数指针 + 所有非静态数据成员大小 + 因对齐而多占的字节

静态成员变量是属于类的,而不是属于对象的。它们在内存中只有一份副本(虚表也是),被所有该类的对象所共享。因此,无论该类有多少个对象,静态成员变量的内存占用量都是固定的


继承一个类的对象的内存结构
|----------------- |
| vptr
|----------------- |
| Base class |
| members |
|------------------|
| Derived class |
| members |
|----------------- |

类的虚表放在程序的数据段(.data), 类对象的虚指针放在对象内存的开头


继承两个类并重写了它们的虚函数的对象的内存结构
class A {
public:
virtual void foo();
virtual void bar();
};

class B {
public:
virtual void baz();
virtual void qux();
};

class C : public A, public B {
public:
virtual void foo() override;
virtual void baz() override;
};
|------------------------ |
| vptr to A's vtable |
|------------------------ |
| A class members |
|------------------------ |
| vptr to B's vtable |
|------------------------ |
| B class members |
|------------------------ |
| C class members |
|------------------------ |

简述下向上转型和向下转型
  1. 向上转型本身就是安全的

  2. 向下转型:

    (1) 可以使用强制转换, 这种转换不安全, 会导致数据的丢失, 原因是父类的指针或者引用的内存中可能不包含子类的成员的内存

    (2) dynamic_cast<type_id>(expression) 主要还是用于执行“安全的向下转型(safe downcasting)”,也即是基类对象的指针或引用转换为同一继承层次的其他指针或引用

    // dynamic_cast<type_id>(expression) 把expression转换成type-id类型的对象
    // type_id 必须是类的指针、类的引用或者void*
    // 使用场景:我们想使用基类对象的指针或引用来调用某个派生类的操作,并且该操作不是虚函数
    class Base{
    public :
    virtual void foo() const{
    cout << "Base class" << endl;
    }
    };
    class Derived :public Base{
    public :
    virtual void foo() const{
    cout << "Derived class" << endl;
    }
    void func() const{
    cout << "hello world!" << endl;
    }
    };
    int main(){
    Base *Pb = new Derived();
    Derived* Pd = dynamic_cast<Derived*>(Pb);
    Pd->func(); //将输出hello world!
    return 0;
    }

简述下深拷贝和浅拷贝, 如何实现深拷贝
  1. 浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
  2. 深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的。深拷贝情况下,不会出现重复释放同一块内存的错误。

深拷贝的实现:

char* deepCopyString(const char* src) {
size_t len = strlen(src);
char* dest = new char[len + 1]; // 分配内存
memcpy(dest, src, len + 1); // 拷贝字符串
return dest;
}
int main(){
string a = "xxxxxx";
const char* aa = a.c_str();
cout << deepCopyString(aa) << endl;
}
STRING& operator=(const STRING& s){ // 法2: 赋值运算符的重载
if (this != &s){
//this->_str = s._str;
delete[] _str;
this->_str = new char[strlen(s._str) + 1];
strcpy_s(this->_str, strlen(s._str) + 1, s._str);
}
return *this;
}

这里的拷贝构造函数我们很容易理解,先开辟出和源对象一样大的内存区域,然后将需要拷贝的数据复制到目标拷贝对象 , 那么这里的赋值运算符的重载是怎么样做的呢?

img
简述一下 C++ 中的多态
  1. 多态成员变量: 编译运行看左边

  2. 静态方法和变量:编译运行都看左边,同成员变量一样

    Fu *f = new Zi();
    cout << f -> num << endl; // 取Fu中的值
  3. 多态成员方法: 编译看左边,运行看右边

    Fu* f1 = new Zi();
    cout << f1-> show() << endl; // 用基类类型指针绑定派生类实例,调用的是子类重写后的方法

说说为什么要虚析构, 为什么不能虚构造
  1. 虚析构:将可能会被继承的父类的析构函数设置为虚函数,

    1. 用子类指针绑定子类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
    2. 用父类指针绑定子类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构父类,不会析构子类对象
    class TimeKeeper {
    public:
    TimeKeeper() {}
    virtual ~TimeKeeper() {}
    };

    C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。

  2. 不能虚构造:

    虚函数需要一个虚表存储, 这个表的地址是存储在对象的内存空间开始的。如果将构造函数设置为虚函数,就需要到虚表中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)


构造函数中可以调用虚函数吗?

可以,但是没有动态绑定的效果,父类构造函数中调用的仍然是父类版本的函数,子类中调用的仍然是子类版本的函数


说说模板类
  1. 模板实例化:模板的实例化分为显示实例化和隐式实例化,前者是研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数,后者是在编译的过程中由编译器来决定使用什么类型来实例化一个模板。不管是显示实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
  2. 模板具体化:当模板使用某种类型类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义 [修改类内函数(不是重写)]
#include <iostream>
using namespace std;

// #1 模板定义
template<class T>
struct TemplateStruct{
TemplateStruct(){
cout << sizeof(T) << endl;
}
};

// #2 模板显示实例化
template struct TemplateStruct<int>;

// #3 模板具体化
template<> struct TemplateStruct<double>{
TemplateStruct() {
cout << "--8--" << endl;
}
};

int main(){
// #4 模板隐式实例化
TemplateStruct<int> intStruct; // 4
TemplateStruct<double> doubleStruct; // --8--
TemplateStruct<char> llStruct; // 1
}

C++ 类内可以定义引用数据成员吗?

c++类内可以定义引用成员变量,但要遵循以下规则:

  1. 必须用初始化列表(构造)来初始化引用成员变量。否则会造成引用未初始化错误
  2. 构造函数的形参也必须是引用类型
class A{
public:
A(int &target) :a(target){ //初始化列表
cout << "构造函数" << endl;
}
void printA(){ cout << "a is:" << a << endl; }
private:
int &a;
};
int main(){
int a = 20; A r(a);
r.printA(); // 错

int &b = a; A r1(b);
r1.printA(); // 20

return 0;
}

⚠️简述一下什么是常函数,有什么作用

类的成员函数后面加 const(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),表明这个函数不会对这个类对象中的非静态数据成员作任何改变。

非const类型的数据可以给const类型的变量赋值,反之则不成立

在实例化对象时添加const关键字,就是const对象,const对象只能访问类中的const成员变量和const成员函数


说说 C++ 中什么是菱形继承问题,如何解决
img

假设我们有类B和类C,它们都继承了相同的类A。另外我们还有类D,类D通过多重继承机制继承了类B和类C

/*  *Animal类对应于图表的类A* */ 
class Animal{
int weight;
public:
int getWeight() {
return weight;
}
};
class Tiger : public Animal { /* ... */ };
class Lion : public Animal { /* ... */ };
class Liger : public Tiger, public Lion { /* ... */ };

int main(){
Liger lg; /*编译错误,下面的代码不会被任何C++编译器通过 */
int weight = lg.getWeight(); //
}
  • 在我们的继承结构中,我们可以看出Tiger和Lion类都继承自Animal基类。所以问题是:因为Liger多重继承了Tiger和Lion类,因此Liger类会有两份Animal类的成员(数据和方法),Liger对象”lg”会包含Animal基类的两个子对象。

    调用”lg.getWeight()”将会导致一个编译错误。这是因为编译器并不知道是调用Tiger类的getWeight()还是调用Lion类的getWeight()。所以,调用getWeight方法是不明确的,因此不能通过编译。

  • 我们给出了菱形继承问题的解释,但是现在我们要给出一个菱形继承问题的解决方案。如果Lion类和Tiger类在分别继承Animal类时都用virtual来标注,对于每一个Liger对象,C++会保证只有一个Animal类的子对象会被创建。看看下面的代码:

    class Tiger : virtual public Animal { /* ... */ };  // 在被继承的类前面加上virtual关键字,这时被继承的类称为虚基类
    class Lion : virtual public Animal { /* ... */ };

你可以看出唯一的变化就是我们在类Tiger和类Lion的声明中增加了”virtual”关键字。现在类Liger对象将会只有一个Animal子对象,下面的代码编译正常:

int main() {  
Liger lg; /*既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK */
int weight = lg.getWeight();
}

虚继承多个父类的子类的内存结构
class Base1 {
public:
int a;
};

class Base2 {
public:
int b;
};

class Derived : virtual public Base1, virtual public Base2 {
public:
int c;
};

int main() {
Derived d;
d.a = 1;
d.b = 2;
d.c = 3;
return 0;
}

在这个例子中,Derived 类虚继承了 Base1Base2 两个基类,并拥有一个自己独有的成员 c。由于 Derived 类使用了虚继承,因此在内存中它的布局与普通的继承方式有所不同。

以下是 Derived 对象的内存结构示意图:

+------------------------+
| 虚基类子对象 1 |
+------------------------+
| 虚基类子对象 2 |
+------------------------+
| int c |
+------------------------+

其中,虚基类子对象 1 包含了 Base1 类的成员,而 虚基类子对象 2 包含了 Base2 类的成员。由于 Base1Base2 都是虚基类,因此它们的成员在 Derived 类的对象中只存在一份,被 虚基类子对象 共享。


说说C++中虚函数与纯虚函数的区别
  1. 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类
  2. 虚函数可以被直接使用,也可以被子类重写以后,以多态的形式调用,而纯虚函数必须在子类中重写才可以使用,因为纯虚函数在基类有声明而没有定义。
  3. 虚函数的定义形式:virtual(){};纯虚函数的定义形式:virtual() = 0;在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样

⚠️C++友元函数和友元类( friend)详解

友元是一种定义在类外部的普通函数或类,但它需要在类体内进行说明,为了与该类的成员函数加以区别,在说明时前面加以关键字friend。友元不是成员函数,但是它可以访问类中的私有成员。打个比方,这相当于是说:朋友是值得信任的,所以可以对他们公开一些自己的隐私

friend 返回值类型 函数名(参数表); //将全局函数声明为友元
friend 返回值类型 其他类的类名::成员函数名(参数表); //将其他类的成员函数声明为友元

// 类B的所有成员函数都是类A的友元函数,能存取类A的私有成员和保护成员
class A{
public:
friend class B;
};

// 不能把其他类的私有成员函数声明为友元.
// 友元具有单向性, 不具有传递性和继承性
#include<iostream>
using namespace std;
class CCar; //提前声明CCar类,以便后面的CDriver类使用

class CDriver{
public:
void ModifyCar(CCar* pCar); //改装汽车
};

class CCar{
private:
int price;
friend int MostExpensiveCar(CCar cars[], int total); //声明友元
friend void CDriver::ModifyCar(CCar* pCar); //声明友元
};
void CDriver::ModifyCar(CCar* pCar){
pCar->price += 1000; //汽车改装后价值增加
}
int MostExpensiveCar(CCar cars[], int total){
int tmpMax = -1;
for (int i = 0; i<total; ++i)
if (cars[i].price > tmpMax)
tmpMax = cars[i].price;
return tmpMax;
}

int main(){
return 0;
}

⚠️C++ 中哪些函数不能被声明为虚函数

常见的不不能声明为虚函数的有:普通函数(不定义在类里),静态成员函数,内联成员函数,构造函数,友元函数

  1. 为什么C++不支持普通函数为虚函数?

    普通函数(非成员函数)只能被overload,不能被override

  2. 为什么C++不支持构造函数为虚函数?

  3. 为什么C++不支持内联成员函数为虚函数?

    内联函数就是为了在代码中直接展开,减少函数调用花费的代价

    内联函数是在编译时期展开,而虚函数的特性是运行时才动态绑定,所以两者矛盾,不能定义内联函数为虚函数

  4. 为什么C++不支持静态成员函数为虚函数?

    这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。静态成员函数属于一个类而非某一对象,没有this指针,它无法进行对象的判别

    static函数是在编译时期绑定,而虚函数的特性是运行时才动态联编,两者矛盾

  5. 为什么C++不支持友元函数为虚函数?

    因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。


类模板和模板类

模板的声明或定义只能在全局,命名空间或类范围内进行。

不能在局部范围,函数内进行,比如不能在main函数中声明或定义一个模板。

// 类模板
template <typename Type>
class ClassName{
public:
Type DataMember;
}
// 模板类是实实在在的类定义,是类模板的实例化。类定义中参数被实际类型所代替
ClassName A = new ClassName<int>();

C++STL

哈希冲突的解决办法,哈希表适用哪些场景

哈希冲突是指不同的键值可能会映射到哈希表的同一个位置,造成数据覆盖和查询效率下降的问题。常见的哈希冲突解决办法包括:

  1. 开放地址法:如果发生哈希冲突,则继续往下探测空闲位置,直到找到为止。探测的方式可以是线性探测、二次探测、双重哈希等。
  2. 链地址法:将哈希表中的每个位置都指向一个链表,发生哈希冲突时将新元素插入到对应的链表中即可。
  3. 其他方法:如再哈希法、建立公共溢出区等。

哈希表适用于需要快速查找和插入元素的场景,特别是当数据集合很大时。常见的应用包括数据库索引、缓存。

哈希表的负载因子是指哈希表中已经存储的元素数量与哈希表大小的比值。通常用字母“α”表示,即 α = n / m

n是已经存储的元素数量,m是哈希表的大小。负载因子反映了哈希表的使用程度,也可以看作是哈希表的密度。

负载因子对哈希表的性能有着重要的影响。当负载因子过高时,哈希冲突的概率会增大,导致哈希表的查询和插入效率下降;而当负载因子过低时,哈希表的空间利用率较低,造成空间浪费。

一般来说,当负载因子达到一定阈值时,需要进行哈希表的扩容操作。扩容可以增大哈希表的大小,使哈希冲突的概率降低,从而提高哈希表的性能。哈希表的负载因子通常设置在0.7左右,但具体的取值要根据实际情况进行调整。


请说说 STL 的基本组成部分

标准模板库(Standard Template Library,简称STL)简单说,就是一些常用数据结构和算法的模板的集合。

广义上讲,STL分为3类:算法、容器、迭代器,容器和算法通过迭代器可以进行无缝地连接。

详细的说,STL由6部分组成:容器(Container)、算法(Algorithm)、 迭代器(Iterator)、仿函数(Function object)、适配器(Adaptor)、空间配制器(Allocator)

  1. 仿函数(Function object)

    仿函数又称之为函数对象, 其实就是重载了()的类

  2. 适配器(Adaptor)

    简单的说就是一种接口类,专门用来修改现有类的接口,提供一种新的接口;或调用现有的函数来实现所需要的功能。主要包括3种适配器:Container Adaptor、Iterator Adaptor、Function Adaptor

  3. 空间配制器(Allocator)

    为STL提供空间配置的系统。其中主要工作包括两部分

    (1)对象的创建与销毁;

    (2)内存的获取与释放。


请说说 STL 中常见的容器, 并介绍一下实现原理
  1. 序列式容器

所谓序列式容器,其中的元素都是可序的,但是未必都是有序的

(1)vector :动态数组。元素在内存连续存放。随机存取任何元素都能在常数时间完成。在尾端增删元素具有较佳的性能。

(2)deque :双向队列。元素在内存连续存放。随机存取任何元素都能在常数时间完成。在两端增删元素具有较佳的性能

(3)list :双向链表。元素在内存不连续存放。在任何位置增删元素都能在常数时间完成。不支持随机存取

  1. 关联式容器

关联式容器,每笔数据(每个数据)都有一个键值(key)和一个实值(value)

关联式容器没有头尾(只有最大元素和最小元素),所以不会有push_back()、push_front()、pop_back()、pop_front()

关联式容器: set、map、multiset、multimap底层均以RB-tree(红黑树)完成

(1)set/multiset :set 即集合。set中不允许相同元素,multiset中允许存在相同元素

(2)map/multimap :map与set的不同在于map中存放的元素有且仅有两个成员变,一个名为first,另一个名为second, map根据first值对元素从小到大排序,并可快速地根据first来检索元素

  1. 容器适配器

封装了一些基本的容器,使之具备了新的函数功能,比如把deque封装一下变为一个stack。这新得到的数据结构就叫适配器。包含stack,queue,priority_queue。stack和queue基于deque实现,priority_queue基于vector实现

(1)stack

(2)queue :队列。插入只可以在尾部进行,删除、检索和修改只允许从头部进行。先进先出。

(3)priority_queue:优先级队列。内部维持某种有序,然后确保优先级最高的元素总是位于头部。最高优先级元素总是第一个出列。


两种C++类对象实例化方式的异同
  1. 在c++中,创建类对象一般分为两种方式:一种是直接利用构造函数,直接构造类对象,如 Test test;另一种是通过new来实例化一个类对象,如 Test *pTest = new Test();那么,这两种方式有什么异同点呢?
我们知道,内存分配主要有三种方式:

(1) 静态存储区分配:内存在程序编译的时候已经分配好,这块内存在程序的整个运行空间内都存在。如全局变量,静态变量等

(2) 栈空间分配:程序在运行期间,函数内的局部变量通过栈空间来分配存储(函数调用栈),当函数执行完毕返回时,相对应的栈空间被立即回收

(3)堆空间分配:程序在运行期间,通过在堆空间上为数据分配存储空间,通过malloc和new创建的对象都是从堆空间分配内存,这类空间需要程序员自己来管理,必须通过free()或者是delete()函数对堆空间进行释放,否则会造成内存溢出。



那么,从**内存空间分配的角度**来对这两种方式的区别,就比较容易区分:

(1)对于第一种方式来说,是直接通过调用Test类的构造函数来实例化Test类对象的,如果该实例化对象是一个局部变量,则其是在栈空间分配相应的存储空间

(2)对于第二种方式来说, new一个类对象,其实是执行了两步操作:首先,调用new在堆空间分配内存,然后调用类的构造函数构造对象的内容;使用delete释放时,也是经历了两个步骤:首先调用类的析构函数释放类对象,然后调用delete释放堆空间

⚠️迭代器用过吗?什么时候会失效?
  • 针对数组型数据结构
    数组型结构有vector、deque等,由于它们的元素是分配在连续的内存中,当进行insert和erase操作,都会使得插入点和删除点之后的元素挪位置,插入点和删除掉之后的迭代器全部失效。

    解决方法就是更新迭代器,对于删除,erase()返回的是下一个有效迭代器的值,可以通过iter=vec.erase(iter);来避免迭代器失效。insert同理,insert返回的是插入元素的迭代器的值。
    注意:在deque首部或尾部删除元素则只会使指向被删除元素的迭代器失效,在deque容器的任何其他位置的插入和删除操作将使指向该容器元素的所有迭代器失效

  • 针对链表型数据结构
    如list容器,使用了不连续分配的内存,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。

    解决办法有两种,一种是erase(iter)会返回下一个有效迭代器的值,可以通过iter=vec.erase(iter);来避免迭代器失效,另一种方法是通过**erase(iter++)**;来避免迭代器失效,(顺便说一下,erase(iter++)避免迭代器失效的原理,先把iter传值到erase里面,然后iter自增,在失效前已经自增,然后执行erase将自增前的迭代器删除,自增前的迭代器失效),对于插入不会使迭代器失效。

  • 针对树形数据结构
    如map, set, multimap, multiset, 它们是使用红黑树来存储数据,插入不会使得任何迭代器失效,删除会使指向删除位置的迭代器失效,但是不会失效其他迭代器。

    解决方法:由于erase()返回值为void,所以要采用erase(iter++);来避免迭代器失效。


说说 STL 中 resize 和 reserve 的区别
  1. 首先必须弄清楚两个概念:

    (1)capacity:该值在容器初始化时赋值,指的是容器能够容纳的最大的元素的个数。还不能通过下标等访问,因为此时容器中还没有创建任何对象

    (2)size:指的是此时容器中实际的元素个数。可以通过下标访问0-(size-1)范围内的对象

  2. resize和reserve区别主要有以下几点:

    (1)resize既分配了空间,也创建了对象;reserve表示容器预留空间,但并不是真正的创建对象,需要通过insert()或push_back()等创建对象

    (2)resize既修改capacity大小,也修改size大小;reserve只修改capacity大小,不修改size大小


char*和string的区别

char*string 都可以用来表示字符串,但它们之间有一些区别:

  1. 字符串长度

使用 char* 表示字符串时,需要手动计算字符串的长度,并且在操作字符串时需要确保不超过字符串的长度,否则可能会导致内存越界等问题。而 string 则可以自动维护字符串的长度信息,并提供了一些方便的成员函数来操作字符串,比如 size()append() 等,可以避免手动计算字符串长度的烦恼。

  1. 内存管理

使用 char* 表示字符串时,需要手动分配和释放字符串内存,并且需要注意内存泄漏和指针悬挂等问题。而 string 则使用了动态内存管理,它会自动分配和释放内存,可以避免这些问题。

  1. 字符串拷贝

使用 char* 表示字符串时,需要手动实现字符串拷贝函数,比如 strcpy()strncpy() 等。而 string 则提供了重载的赋值运算符和拷贝构造函数,可以方便地进行字符串的拷贝和赋值操作。


数组与vector的对比

1、数组名与vector名

数组名和vector名是有区别的,数组名不仅表示数组的名称,还代表了数组的首地址,数组名有时候可看作指针,并使用一些类似于指针的操作,例如初始化了一个数组 int a[10];可进行如下操作,a+4、*(a+5)等来访问a中的数据。而vector名的话就仅仅只是vector的名称了,它没有类似于数组名的那些操作

2、大小能否变化

数组的大小在初始化后就固定不变,而vector可以通过push_back或pop等操作进行变化。

3、初始化

数组不能将数组的内容拷贝给其他数组作为初始值,也不能用数组为其他数组赋值;而vector可以。

4、执行效率

数组 > vector 主要原因是vector的扩容过程要消耗大量的时间。


STL容器哪些是线程安全的

STL容器不是线程安全的。

当程序运行的过程中异常终止或崩溃,操作系统会将程序当时的内存状态记录下来,保存在一个文件中,这种行为就叫做Core Dump(中文有的翻译成“核心转储”)。我们可以认为 core dump 是“内存快照”,但实际上,除了内存信息之外,还有些关键的程序运行状态也会同时 dump 下来,例如寄存器信息(包括程序指针、栈指针等)、内存管理信息、其他处理器和操作系统状态和信息。core dump 对于编程人员诊断和调试程序是非常有帮助的,因为对于有些程序错误是很难重现的,例如指针异常,而 core dump 文件可以再现程序出错时的情景。


要将STL库改造为线程安全的

可以采用以下几种方法:

  1. 在访问STL容器时使用锁。可以在访问STL容器时使用互斥锁来保证线程安全。例如,可以使用std::mutex和std::lock_guard来实现互斥锁
  2. 使用并发容器替换STL容器。一些第三方库或框架提供了线程安全的并发容器,例如Intel TBB和Boost等。这些并发容器可以直接替换STL容器,以实现线程安全

❤️如何对core dump 文件debug

以下是在Linux环境下如何对core dump文件进行debug的步骤:

  1. 确认core dump文件是否已启用:执行以下命令:ulimit -c,如果输出的结果为0,表示core dump文件未启用,需要执行命令ulimit -c unlimited启用。
  2. 程序必须以调试模式编译,即在编译时需要加上-g选项。
  3. 使用gdb工具进行debug:gdb <程序名> <core dump文件名>,进入gdb交互界面。
  4. 在gdb交互界面中,使用bt命令查看崩溃时的函数调用栈,可以确定程序崩溃的位置。
  5. 使用print命令查看变量的值,可以确定程序崩溃的原因。
  6. 使用run命令重新运行程序,可以观察程序在哪个位置崩溃。
  7. 修复问题后,重新编译程序并测试。

注意事项:

  1. 在debug过程中,如果程序中有多线程,需要使用thread命令切换线程进行调试。
  2. 在debug过程中,如果程序中有动态链接库,需要使用shared命令进行调试。
  3. 如果core dump文件过大,可以使用gdb -c <core dump文件名>命令打开core dump文件,然后使用set pagination off命令关闭分页,以加快查看速度。

❤️多线程 core dump 文件debug

可以使用gdb的thread命令

假设程序中有两个线程,分别是主线程和子线程,其中子线程执行一个函数,主线程等待子线程执行完毕后退出程序。在程序运行过程中,我们想要查看子线程的运行状态,可以使用如下命令:

  1. 使用gdb调试程序,输入命令“info threads”查看程序中的所有线程及其编号。
  2. 输入命令“thread 线程编号”切换到需要调试的线程。
  3. 使用gdb常规的调试命令如“bt”、“print”、“step”等对该线程进行调试。

例如,在gdb交互界面中,输入“thread 2”切换到子线程进行调试,然后使用“bt”命令查看子线程的函数调用栈,使用“print”命令查看子线程中的变量值,以便定位子线程的问题。调试完成后,再输入“thread 1”切换回主线程进行调试。


手写string类
#include <iostream>
#include <cstring>
using namespace std;

class String{
private:
char* m_data;
int length;
public:
String(const char *src = NULL);//构造函数
String(const String &other);//拷贝构造函数
~String(void);//析构函数
String & operator =(const String &other);//重载赋值运算符
//friend String operator+(const String &str1, const String &str2); //重载加号运算符(友元函数)
String operator+( const String &str2); //重载加号运算符(成员函数)
String SubString(int pos,int len); //求子串,第pos个字符起长度为len的子串
void ShowString();
};
//String 的构造函数
String::String(const char *src) {
if(src == NULL){ //当初始化串不存在的时候,为m_data申请一个空间存放'\0';
m_data = new char[1];
*m_data = '\0';
}
else {//当初始化串存在的时候,为m_data申请同样大小的空间存放该串;
length = strlen(src);
m_data = new char[length+1];
strcpy(m_data,src);
}
}
//析构函数
String::~String(){
delete [] m_data;//析构函数释放地址空间
}

⚠️vector 的 部分STL 源码

vector底层实现原理为一维数组(元素在空间连续存放)。

  1. 新增元素

    Vector通过一个连续的数组存放元素,如果内存已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素。插入新的数据分在最后插入push_back和通过迭代器在任何位置插入,这里说一下通过迭代器插入,通过迭代器与第一个元素的距离知道要插入的位置,即int index = iter-begin()。这个元素后面的所有元素都向后移动一个位置,在空出来的位置上存入新增的元素。

    //新增元素  
    void insert(const_iterator iter,const T& t ) {
    int index=iter-begin();
    if (index<size_){
    if (size_==capacity_){
    int capa=calculateCapacity();
    newCapacity(capa);
    }
    memmove(buf+index+1,buf+index,(size_-index)*sizeof(T));
    buf[index]=t;
    size_++;
    }
    }
  2. 删除元素

    删除和新增差不多,也分两种,删除最后一个元素pop_back和通过迭代器删除任意一个元素erase(iter)。通过迭代器删除还是先找到要删除元素的位置,即int index = iter-begin();这个位置后面的每个元素都想前移动一个元素的位置。同时我们知道erase不释放内存只初始化成默认值。

    删除全部元素clear:只是循环调用了erase,所以删除全部元素的时候,不释放内存。内存是在析构函数中释放的。

    //删除元素  
    iterator erase(const_iterator iter){
    int index=iter-begin();
    if (index<size_ && size_>0){
    memmove(buf+index ,buf+index+1,(size_-index)*sizeof(T));
    buf[--size_]=T();
    }
    return iterator(iter);
    }

C++新特性

说说 C++14 新特性
  1. C++14:
    • 函数返回值类型推导
    • lambda参数auto
    • 变量模板
    • 别名模板
    • constexpr的限制
    • [[deprecated]]标记
    • 二进制字面量与整形字面量分隔符
    • make_unique
    • shared_timed_mutex与shared_lock
    • integer_sequence
    • exchange
    • quoted

说说 C++11 新特性

C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点:

  1. 语法的改进

    (1)初始化方法可以拓展到任意类

    (2)成员变量默认初始化

    (3)auto关键字

    (4)decltype 求表达式的类型

    (5)智能指针 & 空指针 nullptr(原来NULL)

    (6)基于范围的for循环

    (7)右值引用和move语义 让程序员有意识减少进行深拷贝操作

  2. 标准库扩充(往STL里新加进一些模板类,比较好用)

    (1)无序容器(哈希表 hashtable)用法和功能同map一模一样,区别在于哈希表的效率更高

    (2)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串

    (3)Lambda表达式

详细:

  1. 统一的初始化方法

    C++98/03 可以使用初始化列表(initializer list)进行初始化:

    int i_arr[3] = { 1, 2, 3 }; 
    long l_arr[] = { 1, 3, 2, 4 };
    struct A {
    int x;
    int y;
    } a = { 1, 2 };

    但是这种初始化方式的适用性非常狭窄,只有上面提到的这两种数据类型可以使用初始化列表。在 C++11 中,初始化列表的适用性被大大增加了。它现在可以用于任何类型对象的初始化,实例如下:

    class Foo { 
    public:
    Foo(int) {}
    private:
    Foo(const Foo &);
    };
    int main(void) {
    Foo a1(123);
    Foo a2 = 123; //error: 'Foo::Foo(const Foo &)' is private
    Foo a3 = {123};
    Foo a4 {123};
    int a5 = {3};
    int a6 {3};
    return 0;
    }

    在上例中,a3、a4 使用了新的初始化方式来初始化对象,效果如同 a1 的直接初始化。a5、a6 则是基本数据类型的列表初始化方式。可以看到,它们的形式都是统一的。这里需要注意的是,a3 虽然使用了等于号,但它仍然是列表初始化,因此,私有的拷贝构造并不会影响到它。a4 和 a6 的写法,是 C++98/03 所不具备的。在 C++11 中,可以直接在变量名后面跟上初始化列表,来进行对象的初始化

  2. 成员变量默认初始化

    好处:构建一个类的对象不需要用构造函数初始化成员变量。

    #include<iostream> 
    using namespace std;
    class B {
    public:
    int m = 1234; //成员变量有一个初始值
    int n;
    };
    int main() {
    B b;
    cout << b.m << endl;
    return 0;
    }
  3. auto关键字

    用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)

    vector< vector<int> >::iterator i = v.begin();   // auto i = v.begin();  
    auto x = 27; 	//x类型被推断为int
    const auto cx = x; //cx被推断为 const int
    const auto &rx = x; //rx被推断为const int &

    auto 原理 : 调用类模板传参

    // auto pos = container.begin()的推断等价于如下调用模板的推断
    template<typename T>
    void deducePos(T pos);

    deducePos(container.begin());
  4. decltype 求表达式的类型

    decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导。

    (1)为什么要有decltype

    auto 要求变量必须初始化,而 decltype 不要求

    decltype(exp) varname;

    (2)代码示例

    int a = 0; 
    decltype(a) b = 1; //b 被推导成了 int
    decltype(10.8) x = 5.5; //x 被推导成了 double
    decltype(x + 100) y; //y 被推导成了 double
  5. 智能指针

    见下⬇️

  6. 空指针 nullptr(原来NULL)

    nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”。也就是说,nullptr 仅是该类型的一个实例对象(已经定义好,可以直接使用),如果需要我们完全定义出多个同 nullptr 完全一样的实例对象

    int *a1 = nullptr;
    char *a2 = nullptr;
    double *a3 = nullptr;

    nullptr空指针常数可以转换为任意类型的指针类型

    在c++中 (void *) 不能转化为任意类型的指针,即 int *p = (void*)是错误的,但int *p = nullptr是正确的

    void fun(int i){cout<<"1";};
    void fun(char *p){cout<<"2";};
    int main(){
    fun(NULL); //输出1,c++中NULL为整数0
    fun(nullptr); //输出2,nullptr 为空指针常量。是指针类型
    }
  7. 基于范围的for循环

    #include <iostream>
    #include <vector>
    int main(void){
    std::vector<int> arr = { 1, 2, 3 };
    for(auto n : arr){ //使用基于范围的for循环
    std::cout << n << std::endl;
    }
    return 0;
    }

    在上面的例子中,我们都是在使用只读方式遍历容器。如果需要在遍历时修改容器中的值,则需要使用引用,代码如下:

    for(auto& n : arr){
    std::cout << n++ << std::endl;
    }
  8. 右值引用和move语义

    (1) 右值引用

    C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:

    int num = 10;
    int &b = num; //正确
    int &c = 10; //错误
    const int &c = 10; // 正确, 虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值

    我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。

    为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。

    需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,如:

    int num = 10;
    //int && a = num; //右值引用不能初始化为左值
    int && a = 10;
    a = 100; // 和常量左值引用不同的是,右值引用还可以对右值进行修改

    另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:

    const int&& a = 10;//编译器不会报错

    但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成

    (2) move语义

    move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。move() 函数的用法也很简单,其语法格式如下:

    move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
    class first {
    public:
    first() :num(new int(0)) {
    cout << "construct!" << endl;
    }
    //移动构造函数
    first(first &&d) :num(d.num) {
    d.num = NULL;
    cout << "first move construct!" << endl;
    }
    private:
    int *num;
    };
  9. 无序容器(哈希表)

    用法和功能同map一模一样,区别在于哈希表的效率更高。

    (1) 无序容器具有以下 2 个特点:

    • 无序容器内部存储的键值对是无序的,各键值对的存储位置取决于该键值对中的键
    • 和关联式容器相比,无序容器擅长通过指定键查找对应的值;但对于使用迭代器遍历容器中存储的元素,执行效率较低

    (2) 和关联式容器一样,无序容器只是一类容器的统称,其包含有 4 个具体容器,分别为 unordered_map、unordered_multimap、unordered_set 以及 unordered_multiset。功能如下表:

    无序容器 功能
    unordered_map 存储键值对 <key, value> 类型的元素,其中各个键值对键的值不允许重复,且该容器中存储的键值对是无序的。
    unordered_multimap 和 unordered_map 唯一的区别在于,该容器允许存储多个键相同的键值对。
    unordered_set 不再以键值对的形式存储数据,而是直接存储数据元素本身(当然也可以理解为,该容器存储的全部都是键 key 和值 value 相等的键值对,正因为它们相等,因此只存储 value 即可)。另外,该容器存储的元素不能重复,且容器内部存储的元素也是无序的。
    unordered_multiset 和 unordered_set 唯一的区别在于,该容器允许存储值相同的元素。
  10. 正则表达式

    符号 意义
    ^ 匹配行的开头
    $ 匹配行的结尾
    . 匹配任意单个字符
    […] 匹配[]中的任意一个字符
    (…) 设定分组
    \ 转义字符
    \d 匹配数字[0-9]
    \D \d 取反
    \w 匹配字母[a-z],数字,下划线
    \W \w 取反
    \s 匹配空格
    \S \s 取反
    + 前面的元素重复1次或多次
    * 前面的元素重复任意次
    ? 前面的元素重复0次或1次
    {n} 前面的元素重复n次
    {n,} 前面的元素重复至少n次
    {n,m} 前面的元素重复至少n次,至多m次
    | 逻辑或
  11. Lambda匿名函数

    所谓匿名函数,简单地理解就是没有名称的函数,又常被称为 lambda 函数或者 lambda 表达式

    (1) 定义

    lambda 匿名函数很简单,可以套用如下的语法格式:

    [外部变量访问方式说明符] (参数) mutable noexcept/throw() -> 返回值类型 { 函数体; };

    a. [ ] 捕获外部变量

    b. (参数) 和普通函数的定义一样,lambda 匿名函数也可以接收多个参数。和普通函数不同的是,如果不需要传递参数,可以连同 () 小括号一起省略

    c. mutable 此关键字可以省略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的值(可以理解为这部分变量都是 const 常量)。而如果想修改它们,就必须使用 mutable 关键字

    **注意:**对于以值传递方式引入的外部变量,lambda 表达式修改的是拷贝的那一份,并不会修改真正的外部变量

    d. noexcept/throw() 可以省略,如果使用,在之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,lambda 函数的函数体中可以抛出任何类型的异常。而标注 noexcept 关键字,则表示函数体内不会抛出任何异常;使用 throw() 可以指定 lambda 函数内部可以抛出的异常类型

    e. -> 返回值类型 指明 lambda 匿名函数的返回值类型。值得一提的是,如果 lambda 函数体内只有一个 return 语句,或者该函数返回 void,则编译器可以自行推断出返回值类型,此情况下可以直接省略”-> 返回值类型”

    f. 函数体 和普通函数一样,lambda 匿名函数包含的内部代码都放置在函数体中。该函数体内除了可以使用指定传递进来的参数之外,还可以使用指定的外部变量以及全局范围内的所有全局变量

    // 值捕获
    int main() {
    int a = 123;
    auto f = [a] { cout << a << endl; };
    a = 321;
    f(); // 输出:123
    }

    // 引用捕获
    int main(){
    int a = 123;
    auto f = [&a] { cout << a << endl; };
    a = 321;
    f(); // 输出:321
    }

    // 隐式捕获(两种)
    int main(){
    int a = 123;
    auto f = [=] { cout << a << endl; }; // 值捕获
    f(); // 输出:123
    }

    int main() {
    int a = 123;
    auto f = [&] { cout << a << endl; }; // 引用捕获
    a = 321;
    f(); // 输出:321
    }

    // 自定义排序函数
    sort(res.begin(), res.end(), [&](const string& a, const string& b)->bool{
    return mp[a] == mp[b] ? a < b : mp[a] > mp[b];
    });

说说 C++中的智能指针

C++中的智能指针有4种,分别为:shared_ptr、unique_ptr、weak_ptr、auto_ptr 其中auto_ptr被C++11弃用。

使用智能指针的原因

申请的空间(即new出来的空间),在使用结束时,需要delete掉,否则会形成内存碎片。使用智能指针可以很大程度上避免这个问题,因为智能指针就是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源。所以,智能指针的作用原理就是在函数结束时自动释放内存空间,避免了手动释放内存空间,智能指针本质是类模板。

  1. shared_ptr
 多个 shared_ptr 智能指针可以共同使用同一块堆内存。即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)

 
#include <iostream> 
#include <memory>
using namespace std;
int main() {
//构建 2 个智能指针
std::shared_ptr<int> p1(new int(10));
std::shared_ptr<int> p2(p1);
cout << *p2 << endl; //输出 p2 指向的数据
p1.reset();//引用计数减 1,p1为空指针
if (p1) cout << "p1 不为空" << endl;
else cout << "p1 为空" << endl; //以上操作,并不会影响 p2
cout << *p2 << endl;
cout << p2.use_count() << endl; //判断当前和 p2 同指向的智能指针有多少个
return 0;
} /* 程序运行结果: 10 p1 为空 10 1 */
  1. weak_ptr

    它只可以从一个 shared_ptr或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的变化

    weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放

    weak_ptr和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr

    class B;
    class A {
    public:
    shared_ptr<B> pb_;
    ~A() { cout<<"A delete\n"; }
    };

    class B{
    public:
    shared_ptr<A> pa_;
    ~B() { cout<<"B delete\n"; }
    };

    void fun() {
    shared_ptr<B> pb(new B());
    shared_ptr<A> pa(new A());
    pb->pa_ = pa;
    pa->pb_ = pb;
    cout<<pb.use_count()<<endl;
    cout<<pa.use_count()<<endl;
    }

    int main() {
    fun();
    return 0;
    }

    可以看到fun函数中pa ,pb之间互相引用,两个资源的引用计数为2,当要跳出函数时,智能指针pa,pb析构时两个资源引用计数会减一,但是两者引用计数还是为1,导致跳出函数时资源没有被释放(A B的析构函数没有被调用),如果把其中一个改为weak_ptr就可以了

    我们把类A里面的shared_ptr pb_; 改为 weak_ptr pb _ ; 运行结果如下,这样的话,资源B的引用开始就只有1,当pb析构时,B的计数变为0,B得到释放,B释放的同时也会使A的计数减一,同时pa析构时使A的计数减一,那么A的计数为0,A得到释放。

    注意:我们不能通过weak_ptr直接访问对象的方法,比如B对象中有一个方法print(),我们不能这样访问,pa->pb_->print(); pb_是一个weak_ptr,应该先把它转化为shared_ptr,如:shared_ptr p = pa->pb_.lock(); p->print();

  2. auto_ptr

    C++98的方案,C++11已经弃用. 采用所有权模式

    auto_ptr<string> p1(new string("I reigned loney as a cloud."));
    auto_ptr<string> p2;
    p2=p1; //auto_ptr不会报错

    此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题

  3. unique_ptr(替换auto_ptr)

    unique_ptr<string> pu1(new string ("hello world"));
    unique_ptr<string> pu2;
    pu2 = pu1; // 1 not allowed
    unique_ptr<string> pu3;
    pu3 = unique_ptr<string>(new string ("You")); // 2 allowed

    // 其中1留下悬挂的unique_ptr(pu1),这可能导致危害
    // 2不会留下悬挂的unique_ptr,因为它调用 unique_ptr 的构造函数, 该函数创建的临时对象在其所有权让给 pu3 后就会被销毁

智能指针有内存泄露风险吗

有,当两个类对象中各自有一个 shared_ptr 指向对方时,会造成循环引用,使引用计数失效,从而导致内存泄露。 为了解决循环引用导致的内存泄漏,引入了弱指针weak_ptr

weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针

weak_ptr不会指向引用计数的共享内存,但是可以检测到所管理的对象是否已经被释放,从而避免非法访问


⚠️说说三种智能指针原理和使用场景和线程安全
  1. 实现原理: 所有权(ownership)概念,对于特定的对象,只能有一个智能指针可拥有它,这样只有拥有对象的智能指针的析构函数会删除该对象。然后,让赋值操作转让所有权。这就是用于 auto_ptr 和 unique_ptr 的策略.但 unique_ptr 的策略更严格,unique_ptr 能够在编译期识别错误。

    跟踪引用特定对象的智能指针计数,这称为引用计数(reference counting)。例如,赋值时,计数将加 1,而指针过期时,计数将减 1. 仅当最后一个指针过期时,才调用 delete。这是 shared_ptr 采用的策略

  2. 使用场景: 如果程序要使用多个指向同一个对象的指针,应该选择 shared_ptr;

    如果程序不需要多个指向同一个对象的指针,则可以使用 unique_ptr; 如果使用 new分配内存,应该选择 unique_ptr; 如果函数使用 new 分配内存,并返回指向该内存的指针,将其返回类型声明为 unique_ptr 是不错的选择。

  3. 线程安全: shared_ptr 加减引用计数是原子操作,只要shared_ptr在拷贝或赋值时增加引用,析构时减少引用就可以了。所以 shared_ptr 在多线程下引用计数也是安全的.

    但是指向对象的指针不是线程安全的,使用 shared_ptr 访问资源不是线程安全的,需要手动加锁解锁。智能指针的拷贝也不是线程安全的


完美转发

是指在函数调用过程中,将参数以原始形式(左值或右值)转发到另一个函数,而不会改变它们的值类别(左值或右值)。在C++中,可以使用std::forward函数实现完美转发

#include <iostream>
#include <utility>

void print(int& x){
std::cout << "lvalue: " << x << std::endl;
}

void print(int&& x){
std::cout << "rvalue: " << x << std::endl;
}

template <typename T>
void forward_print(T&& x){
print(forward<T>(x));
}

int main(){
int a = 1;
forward_print(a); // 输出:lvalue: 1
forward_print(2); // 输出:rvalue: 2
forward_print(move(a)); // 输出:rvalue: 1
return 0;
}
// 在这个示例中,我们定义了两个打印函数,一个接受左值引用,一个接受右值引用。
// 然后我们定义了一个函数模板forward_print,使用完美转发来将参数转发给打印函数。

constexpr

C++11引入了constexpr(常量表达式)关键字,它表示表达式的值可以在编译期间计算,并且在运行时不会变

使用constexpr修饰的变量或函数可以在编译期间进行求值,这使得它们比运行时计算更加高效,同时还可以帮助我们避免一些运行时错误。在一些场景下,使用constexpr可以提高代码的可读性和可维护性,因为它可以使得一些常量在编译期间计算而不需要在运行时计算。

例如,下面是一个使用constexpr的示例代码,其中计算了斐波那契数列的第n项:

constexpr int fib(int n){
return (n <= 1) ? n : fib(n-1) + fib(n-2);
}

int main(){
constexpr int N = 10;
constexpr int result = fib(N);
cout << "Fibonacci number #" << N << " is: " << result << endl;
return 0;
}

编译器可以在编译期间计算fib(N)的值,并将结果赋值给常量result。在运行时,程序输出斐波那契数列的第10项的值:55。

需要注意的是,constexpr并不是万能的,它有一些限制条件,例如被修饰的函数必须满足编译期间可以进行求值的要求,不能包含运行时的操作,例如I/O操作、动态内存分配等。在使用constexpr时,需要仔细考虑这些限制条件,以确保代码的正确性和性能

1️⃣操作系统

OS的功能

  1. 进程管理:操作系统负责管理计算机中运行的所有进程,包括进程的创建、撤销、调度和同步等。
  2. 内存管理:操作系统负责管理计算机的存储器,包括内存的分配和回收、虚拟内存的管理等。
  3. 设备管理:操作系统负责管理计算机的所有输入输出设备,包括设备的驱动程序、中断处理和设备的分配等。
  4. 文件管理:操作系统负责管理计算机中的所有文件和目录,包括文件的创建、删除、复制、移动等操作。
  5. 网络管理:操作系统负责管理计算机系统中的网络资源,包括协议栈、网络接口和路由表等。

说一说常用的 Linux 命令

cd:切换当前目录

ls:查看当前文件与目录

touch: 创建新文件

grep:通常与管道命令一起使用,用于对一些命令的输出进行筛选加工

cp:是在同一个linux系统上,在不同的目录之间复制文件

scp:是在不同linux系统之间来回复制文件;

mv:移动文件或文件夹

rm:删除文件或文件夹

rm -rf test1 可删除非空文件或文件夹

rmdir 只能删除空文件夹

ps:查看进程情况

tar:对文件进行解压缩

cat:查看文件内容

top:查看操作系统的信息,如进程、CPU占用率、内存信息等(实时)

  1. PID:进程的唯一标识符;
  2. USER:进程所属的用户名;
  3. %CPU:进程使用 CPU 的占比;
  4. %MEM:进程使用内存的占比;
  5. VSZ:进程的虚拟内存大小(单位为 KB);
  6. RSS:进程正在使用的物理内存大小(单位为 KB);
  7. TTY:进程所属的终端;
  8. STAT:进程状态,如 R(运行)、S(睡眠)、D(不可中断的睡眠)等;
  9. START:进程启动的时间;
  10. TIME:进程使用 CPU 的累计时间;
  11. COMMAND:进程启动时的命令。

image-20230323150925539

free:查看内存使用情况 -m以MB为单位显示

image-20230323150826237

pwd:显示当前工作目录

chmod: 修改用户权限

chown: 更改或文件的所有权转让给指定的用户名

echo: 将一些数据移到文件中。如果要将文本 “Hello, 我的名字叫 John” 添加到名为 name.txt 的文件中, echo Hello,my name is John >> name.txt

ldd: ldd [OPTION] FILE 查看libstdc++.so.6依赖的动态库的详细信息

ldd -v /home/libstdc++.so.6

–help 获取指令帮助信息;
–version 打印指令版本号;
-d,–data-relocs 执行重定位和报告任何丢失的对象;
-r, –function-relocs 执行数据对象和函数的重定位,并且报告任何丢失的对象和函数;
-u, –unused 打印未使用的直接依赖;
-v, –verbose 详细信息模式,打印所有相关信息;


查看进程运行状态、查看内存使用情况、tar解压文件

  1. 查看进程运行状态的指令:

    ps aux | grep PID 

    image-20230323152150339

  2. 查看内存使用情况的指令:

    free -m // 查看内存使用情况 

查看进程运行状态、查看内存使用情况的指令均可使用top指令

  1. tar解压文件的参数:
1.压缩 -cvf
tar -cvf xxx.tar /data : 仅打包
tar -zcvf xxx.tar /data : 打包后,以gzip方式压缩
tar -jcvf xxx.tar /data : 打包后,以bzip2方式压缩

2.解压 -xvf
先进入需要解压缩的文件夹下
cd /tmp/data
tar -xvf xxx.tar : 解包
tar -zxvf xxx.tar : 解压gzip压缩文件
tar -jxvf xxx.tar : 解压bzip2压缩文件
tar -zxvf xxx.tar.gz etc/passwd :只解压部分文件夹

查找一个字符串是否在文件中

  • 如果在给定的文件中搜索某个字符串, 直接grep “main” ./main.c即可;

  • 如果你要搜索某个特定的字符串,而不确定这个字符串可能会在哪个文件中出现,那只能在某个大的目录下递归搜索:grep -r "main" ./

  • 如果只指定-r来搜索,有时候可能会打印出很多无用的错误的信息,这会严重干扰你在搜索结果中查找你想要的信息。所以我们要用grep -rs "main" ./使用-s选项可以帮助我们将这些因为文件不存在或者文件不可读而打印出来的错误信息统统去除掉;

  • 如果你想知道字符串所在的行号,那一定要指定-n选项:grep -nrs "main" ./

  • 可是很多时候你按照上面指定的条件进行搜索,在搜索结果中可能仍然有很多不是你想要的。比如你要搜索的字符串是”main”,而要搜索结果中你可能会看到很多诸如”main_function”, “mainly”等等这些包含”main”的更长的字符串。此时我们就需要借助于-w这个命令选项来过滤。grep -nrws "main" ./


查找本机一个端口号的状态

netstat 命令:

-a:显示所有连接和监听端口,包括 TCP、UDP 和 UNIX 套接字(socket)等
-n:以数值形式显示地址和端口,而不是以名称和服务方式显示
-p:显示与每个连接关联的进程和程序名称
-r:显示路由表信息
-s:显示各种网络统计信息,如传输层协议统计、网络接口统计等
-t:仅显示 TCP 连接信息
-u:仅显示 UDP 连接信息

# 例如,在命令行中输入 netstat -a 将显示当前计算机上的所有活动连接和监听端口。
netstat -rn # 显示所有TCP的统计信息

如何判断远程服务的端口有没有开启

telnet 命令可以测试远程主机是否可以访问指定的端口。在命令行中输入 “telnet 远程主机IP 端口号”,如果能够建立连接,说明该端口已经开启。例如,如果要测试远程主机的 80 端口是否开启,可以在命令行中输入 telnet 远程主机IP 80,如果成功建立连接,则说明该端口已经开启。


文件权限怎么修改

文字设定法设置权限(ugoa)

chmod [操作对象] [操作符号] [权限] [文件|目录]

image-20221128220607286

image-20221128220621457

chmod u+w a 添加所有者对a文件的写入权限

chmod u-r a 取消所有者对a文件的读取权限

chmod g=w a 重新分配同组用户对a文件有写入的权限

chmod u+rw,g+r,o+rwx a 添加所有者为读取、写入权限;同组用户为读取权限;其他用户读取、写入和执行的权限

chmod a-rwx a 取消所有用户的读取、写入和执行权限

数字设定法设置权限(ugo)

chmod [n1n2n3] [文件|目录]

n1表示用户所有者的权限 ,n2表示组群所有者的权限,n3表示其它用户的权限。

  • 文件和目录的权限表中用r、w、x这三个字符来为用户所有者、组群所有者和其它用户设置权限。有时候,字符似乎过于麻烦,因此还有另外一种方法是以数字来表示权限,而且仅需3个数字。

  • 使用数字设定法更改文件权限,首先必须了解数字表示的含义:0表示没有权限,1表示可执行权限,2表示写入权限,4表示读取权限,然后将其相加。

  • 所有数字属性的格式应该是三个0~7的数,其顺序是u、g、o

r:对应数值4 w:对应数值2 x:对应数值1 -:对应数值0

若该文件为目录则第一位用d标志,否则用-标志

-rwx——:用数字表示为700

-rwxr–r–:用数字表示为744

-rw-rw-r-x:用数字表示为665

drwx–x–x:用数字表示为711

drwx——:用数字表示为700

chmod 777 a 所有用户拥有读取、写入和执行的权限

chmod (00)7 a 设置a文件权限,其他用户拥有读取、写入和执行的权限

特殊权限(SUID SGID Sticky)

(1)SUID: 以用户所有者身份来执行一个可执行文件; 对一个目录无影响

(2)SGID: 以组群所有者身份来执行一个可执行文件;

(3)Sticky: 对一个可执行文件无影响, 对目录设置Sticky后,尽管其它用户有写权限,也必须由所有者执行删除和移动等操作

文字设定法设置特殊权限

chmod u+s a 添加a文件的特殊权限为SUID

chmod g+s a 添加a文件的特殊权限为SGID

chmod o+t a 添加a文件的特殊权限为Sticky

数字设定法设置特殊权限

chmod 4000 a 设置文件a具有SUID权限

chmod 2000 a 设置文件a具有SGID权限

chmod 1000 a 设置文件a具有Sticky权限

chmod 7000 a 设置文件a具有SUID,SGID和Sticky权限


如何以root权限运行某个程序

su: 切换成root, 但不改变当前工作目录和环境变量

su-: 切换成root, 改变当前工作目录和环境变量为root的

sudo: 一种权限管理机制,授权给哪个用户可以以管理员的身份执行什么命令

sudo [选项] [-u 新使用者账号] 要执行的命令

sudo chown root 文件
sudo chmod u+s 文件

什么是大端小端, 如何判断

小端模式:低的有效字节存储在低的内存地址。小端一般为主机字节序;X86结构和大多数ARM都为小端模式

大端模式:高的有效字节存储在低的内存地址。大端为网络字节序

如何判断:我们可以根据联合体来判断系统是大端还是小端。因为联合体变量总是从低地址存储

int fun1(){  
union test{
char c; // 一个字节,低地址
int i; // 四个字节,高地址
};
test t; t.i = 1;
return (t.c == 1); //如果是小端,则t.c为1; 反之是大端
}

字节序转换函数, IP转换函数

// 从主机字节序到网络字节序的转换函数: 
htons // 主机端口 -> 网络端口
htonl // 转换IP的,IP地址32位

// 从网络字节序到主机字节序的转换函数
ntohs
ntohl
inet_pton  // "192.168.12.1" -> 整数
inet_ntop // 整数 -> IP字符串

⚠️简述Linux内核态与用户态

用户态是指进程在执行自己的代码时所处的一种特殊状态,此时进程只能访问自己的地址空间和受限的系统资源,例如打开文件或者建立网络连接。用户态下,进程不能直接访问操作系统核心代码,需要通过系统调用的方式向操作系统发起请求

内核态是指操作系统内核执行自己的代码时所处的一种特殊状态,此时操作系统具有对整个系统的控制权,可以访问任何系统资源,包括进程的地址空间和硬件资源。内核态下,操作系统可以直接执行特权指令

进入内核态的步骤如下:

  1. 进入内核态:共有三种方式:a、系统调用。b、异常。c、设备中断
  2. CPU从用户态切换到内核态,将用户程序的当前状态(如寄存器值)保存到内核栈中。
  3. 操作系统根据系统调用号确定需要执行的操作,并检查参数的合法性。
  4. 操作系统执行需要的操作,如果需要返回结果,则将结果保存到用户程序指定的内存地址中。
  5. 操作系统从内核态切换回用户态,恢复用户程序的状态(如寄存器值),继续执行用户程序。

需要注意的是,进入内核态和从内核态返回到用户态的过程涉及到CPU寄存器和堆栈的切换和保存,因此会带来一定的开销。为了提高系统性能,操作系统通常会尽量减少用户态和内核态之间的切换次数,尽可能在用户态完成所有操作


虚拟地址到物理地址怎么映射的

image-20230207131212493 image-20221205224538889

❤️说说进程,线程,协程是什么,区别?

  1. 进程:进程则是程序的运行实例,包括程序计数器、堆栈和变量值

  2. 线程:一个进程里更小粒度的执行单元。一个进程里包含多个线程并发执行任务

  3. 协程:协程运行在线程之上,当一个协程执行完成后,可以选择主动让出,让另一个协程运行在当前线程之上。协程并没有增加线程数量,只是在线程的基础之上通过分时复用的方式运行的,而且协程的切换在用户态完成,切换的代价比线程从用户态到内核态的代价小很多. 在协程中尽量不要调用阻塞IO的方法,比如打印,读取文件,Socket接口等,除非改为异步调用的方式,并且协程只有在IO密集型的任务中才会发挥作用

  4. 线程与进程的区别

    (1)一个线程从属于一个进程;一个进程可以包含多个线程

    (2)一个进程挂掉,对应的线程挂掉;一个进程挂掉,不会影响其他进程

    (3)进程是系统资源分配的最小单位;线程CPU调度的最小单位

    (4)进程系统开销显著大于线程开销;线程需要的系统资源更少

    (5)进程在执行时拥有独立的内存单元; 多个线程共享进程的内存;但每个线程拥有自己的栈和.text段

    (6)进程切换时需要刷新TLB并获取新的地址空间,然后切换硬件上下文和内核栈,线程切换时只需要切换硬件上下文和内核栈

    (7)通信方式不一样

  5. 线程与协程的区别:

    (1)协程直接操作栈基本没有内核切换的开销,所以上下文的切换非常快,切换开销比线程更小

    (2)协程不需要多线程的锁机制,因为协程从属于线程,不存在同时写变量冲突

    (3)协程占用内存少。执行协程只需要极少的栈内存(4~5KB),而线程栈的大小为1MB ;

    如何处理在协程中调用阻塞IO的操作呢?一般有2种处理方式:

    1. 在调用阻塞IO操作的时候,重新启动一个线程去执行这个操作,等执行完成后,协程再去读取结果。
    2. 对系统的IO进行封装,改成异步调用的方式,这需要大量的工作,最好寄希望于编程语言原生支持。

❤️多进程和多线程架构

(1)多进程

1.数据是分开的,共享复杂,同步简单
2.占用内存多,切换复杂,CPU利用率低
3.创建销毁复杂,切换复杂,速度慢
4.编程简单,调试简单
5.进程间不会相互影响
6.适应于多核、多机分布 ;如果一台机器不够,扩展到多台机器比较简单

(2)多线程

1.多线程共享进程数据,共享简单,同步复杂
2.占用内存少,切换简单,CPU利用率高
3.创建销毁简单,切换简单,速度快
4.编程复杂,调试复杂
5.当一个线程挂掉时,同一个进程下的线程可能会受到影响,取决于是否是共享内存区域遭到破坏,
6.适应于多核分布

二.使用场景
1)需要频繁创建和销毁的优先用线程。

实例:web 服务器,来一个任务建立一个线程,完了就销毁线程。要是用进程,创建和销毁的代价是很高的。

2)需要进行大量计算的优先使用进程。

3)强相关的处理用线程,弱相关的处理用进程。

4)可能扩展到多机分布的用进程,多核分布的用线程。

5)都满足需求的情况下,用你最熟悉、最拿手的方式。


为何CPU密集型适合多进程, I/O密集适合多线程

CPU密集型任务是指任务的执行主要消耗CPU资源,而不涉及大量的IO操作。这类任务通常需要进行大量的计算、逻辑判断和数据处理,而CPU的计算能力是限制性能的关键因素。

在CPU密集型任务中,多进程的并行处理能力可以充分利用多核处理器的优势。每个进程都可以在独立的CPU核心上执行任务,同时进行计算和处理,提高整体的处理能力和效率。由于多进程之间相互独立,一个进程的计算任务不会影响其他进程的执行,因此可以实现更好的并行化效果。

另一方面,IO密集型任务是指任务的执行主要涉及大量的IO操作,如文件读写、网络通信等。在这种情况下,CPU的计算能力往往不是瓶颈,而是IO操作的速度限制了任务的执行效率。

多线程在IO密集型任务中有一些优势。由于多个线程可以共享同一进程的内存空间,线程之间可以直接访问共享数据,这样可以避免进程间的数据传输和通信开销,提高了数据共享和交互的效率。此外,线程的创建和切换开销较小,适合处理大量的IO操作,可以在等待IO操作完成的同时,切换到其他线程执行,提高系统的并发处理能力和响应速度。


❤️nginx为何采用多进程

  1. 对于每个worker进程来说,独立的进程,不需要加锁,所以省掉了锁带来的开销,同时在编程以及问题查找时,也会方便很多

  2. 采用独立的进程,可以让互相之间不会影响,一个进程退出后,其它进程还在工作,服务不会中断,master进程则很快启动新的worker进程。worker进程的异常退出,只会导致当前worker上的所有请求失败,不过不会影响到所有请求,所以降低了风险

Nginx多进程模型

master进程主要用来管理worker进程,具体包括如下4个主要功能:

(1)接收来自外界的信号。

(2)向各worker进程发送信号。

(3)监控woker进程的运行状态。

(4)当woker进程退出后(异常情况下),会自动重新启动新的woker进程。

woker进程主要用来处理网络事件,各个woker进程之间是对等且相互独立的,它们同等竞争来自客户端的请求,一个请求只可能在一个woker进程中处理,woker进程个数一般设置为机器CPU核数。


❤️当一个子进程挂了后,主进程怎么重启它

int main(){
pid_t pid = fork();

if (pid < 0) { // 处理错误
}

if (pid == 0) execl("/path/to/program", "program", NULL);

if (pid > 0) {
int status;
waitpid(pid, &status, WNOHANG);
if (WIFEXITED(status)) {
// 子进程正常退出,根据需要重启它
pid = fork();
if (pid == 0) execl("/path/to/program", "program", NULL);
}
else if (WIFSIGNALED(status)) {
// 子进程被信号终止,根据需要重启它
pid = fork();
if (pid == 0) execl("/path/to/program", "program", NULL);
}
}

}

❤️当一个子线程挂了后,主线程怎么重启他

在C++中,主线程无法直接重新启动一个已经终止的子线程,因为一旦一个线程结束,它的资源被操作系统回收,除非重新创建一个新线程。

因此,通常的做法是在主线程中创建一个循环,监视子线程的状态。如果发现子线程已经结束,主线程可以重新启动一个新的线程来替代它。

#include <iostream>
#include <thread>

void myThreadFunc() {
// 线程运行的代码
}

int main() {
thread myThread(myThreadFunc); // 创建一个子线程
while (true) {
if (myThread.joinable() && myThread.join() == thread::id()) {
// 如果子线程已经结束,join() 返回空 thread id
// 重新启动一个新线程
myThread = thread(myThreadFunc);
}
// 等待一段时间再检查子线程状态, 以避免不必要的 CPU 占用。
this_thread::sleep_for(chrono::milliseconds(100));
}
return 0;
}

❤️Linux 和 Windows 多线程编程的区别

  1. 线程的实现方式:Linux 线程被实现为一个特殊类型的文件描述符,可以通过系统调用如 clone()pthread_create() 来创建。在这种方式下,线程就像一个文件一样被管理和操作;而 Windows 则是通过创建线程对象实现线程。
  2. 线程调度:Linux 使用的是抢占式调度,即线程可以被强制中断;而 Windows 则使用的是协作式调度,即需要线程主动放弃 CPU 时间片才会切换到其他线程。
  3. 线程同步:Linux 中使用 pthread 库实现线程同步,如互斥锁、条件变量等,基于POXIS标准实现;而 Windows 中则使用 Win32 API 实现线程同步,如互斥锁、信号量等。
  4. 线程的调试:Linux和Windows都提供了线程调试工具。在Linux下,gdb是一种常用的命令行调试工具,可以对多个线程进行调试。在Windows下,Visual Studio提供了可视化的调试工具,可以方便地调试多线程序

❤️POXIS 和SystemV是什么

POSIX是一个操作系统接口标准,定义了一套应用程序接口(API)和一组命令行工具,其目的是为了保证不同的操作系统之间的互操作性和可移植性。

System V 是一个 UNIX 操作系统版本。System V 包括一套标准的系统调用、程序库、头文件和一些基本工具。它提供了一些进程间通信(IPC)机制,如消息队列、共享内存和信号量等,这些机制已经成为 UNIX 操作系统的标准特性,并被各种衍生版本所采用。

POSIX 和 System V 在 UNIX 系统中都有广泛的应用。POSIX 旨在提供一个可移植的接口标准,使得 UNIX 操作系统之间的软件移植更加容易,而 System V 则提供了一些标准的系统调用和 IPC 机制,使得 UNIX 操作系统可以在进程间进行通信和数据共享。


❤️thread 和 pthread 区别

thread 是 C++11 引入的标准线程库,可以在 C++11 或更高版本的标准中使用。它提供了一种方便、跨平台的方式来创建和管理线程。它使用面向对象的方式,通过创建 std::thread 类对象来表示一个线程,可以使用成员函数 join()detach() 来控制线程的执行和结束。

pthread 是 POSIX 线程库,是一种 C 语言线程库,提供了一组用于多线程编程的 API。它在很多 UNIX 系统中都得到了支持,可以用于创建和管理线程、线程同步等操作。与 thread 不同,pthread 不使用面向对象的方式,而是提供了一组 C 函数来创建和管理线程,例如 pthread_create()pthread_join() 等。

总体来说,thread 更加现代化、易用,而 pthread 更加传统、底层。在使用时,可以根据实际需求和平台限制来选择合适的线程库。如果要编写跨平台的程序,建议使用 thread,而在需要与 POSIX 线程库交互的环境中,可能需要使用 pthread


❤️多线程debug

  1. GDB: 包括调用栈、变量值等信息,从而定位崩溃原因。可以通过设置断点、单步执行来观察程序执行过程

  2. 打印日志

  3. 开源检测工具:如 Linux 的 straceltrace 命令,Windows 的 Process Monitor

  4. 并发测试工具:Valgrind ThreadSanitizer 可以检查多线程程序中的并发问题,包括数据竞争、死锁、死循环等。这些工具可以帮助发现程序中的并发问题,并提供相应的调试信息


lock_guard 和 unique_lock 区别,使用案例

// lock_guard:被设计为在作用域结束时自动释放锁,从而防止忘记解锁的错误。
// 由于其简单性和效率,lock_guard 通常用于保护共享数据的简单操作
#include <mutex>
#include <thread>
using namespace std;

mutex mtx;
int A = 0;
void func(){
lock_guard<mutex> lock(mtx);
A ++; // 对共享资源进行操作
}

int main() {
int cnt = 10;
while(cnt --){
thread t1(func);
thread t2(func);

t1.join();
t2.join();
}
cout << A; // 20
return 0;
}
// unique_lock是一个更为灵活和功能更强大的锁定类。unique_lock 允许你手动地锁定和解锁
// 此外还支持时间限制、递归锁定、条件变量等功能。通常用于需要更复杂的线程同步操作的情况
#include <mutex>
#include <condition_variable>
#include <thread>
#include <iostream>
mutex mtx;
condition_variable cv;
queue<int> data_queue;
bool is_end = false;

void producer(){
for (int i = 0; i < 10; ++i){
unique_lock<mutex> lock(mtx);
cv.wait(lock, []{ return data_queue.size() < 10; }); // 等待队列有空闲位置
data_queue.push(i); // 生产数据
cout << "Produced: " << i << endl;
cv.notify_one(); // 通知消费者
}
is_end = true; // 生产结束
cv.notify_all(); // 通知消费者
}

void consumer() {
while (true) {
unique_lock<mutex> lock(mtx);
cv.wait(lock, []{ return !data_queue.empty() || is_end; }); // 等待队列非空或生产者结束
if (!data_queue.empty()) { // 消费数据
int data = data_queue.front();
data_queue.pop();
cout << "Consumed: " << data << endl;
}
if (is_end && data_queue.empty()) break; // 判断是否结束
}
}

int main() {
thread t1(producer);
thread t2(consumer);
t1.join();
t2.join();
return 0;
}

什么是孤儿进程?什么是僵尸进程,如何解决僵尸进程

  1. 孤儿进程:是指一个父进程退出后,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并且由init进程对它们完整状态收集工作。

  2. 僵尸进程:是指一个进程使用fork函数创建子进程,如果子进程退出,而父进程并没有调用wait()或者waitpid()系统调用取得子进程的终止状态,子进程残留资源(PCB) 存放于内核中,占用系统资源,这种进程称为僵尸进程。

    如何解决僵尸进程:

    (1) 一般,为了防止产生僵尸进程,在fork子进程之后我们都要及时使用wait系统调用;同时,当子进程退出的时候,内核都会给父进程一个SIGCHLD信号,所以我们可以建立一个捕获SIGCHLD信号的信号处理函数,在函数体中调用wait(或waitpid),就可以清理退出的子进程以达到防止僵尸进程的目的

    (2) 使用kill命令杀死其父进程(使僵尸进程变成孤儿进程)

    打开终端并输入下面命令:

    ps aux | grep Z 

    会列出进程表中所有僵尸进程的详细内容。然后输入命令:

    kill -s SIGCHLD pid(父进程pid)

说说什么是守护进程, 如何实现?

守护进程是运行在后台的一种生存期长的特殊进程。它独立于控制终端,处理一些系统级别任务

  1. 创建子进程,父进程退出
  2. setsid() 函数用于创建一个新的会话,并担任该会话组的组长
  3. 改变当前目录为根目录
  4. 重设文件权限掩码
  5. 关闭文件描述符
int main(){
pid_t pc;
int i,fd,len;
char *buf = "this is a Dameon\n";
len = strlen(buf);

pc = fork(); /*第一步: 创建子进程,父进程退出*/
if(pc<0){
printf("error fork\n");
exit(1);
}
else if(pc>0){ //子进程号=0,父进程号大于0
exit(0);//父进程退出,子进程成为孤儿进程
}

setsid(); /*第二步:setsid() 函数用于创建一个新的会话,并担任该会话组的组长
调用setid作用:1、让进程摆脱原会话控制; 2、让进程摆脱原进程组的控制; 3、让进程摆脱原控制终端的控制*/

chdir("/"); /*第三步:改变当前目录为根目录*/

umask(0); /*第四步:重设文件权限掩码*/

for(i=0;i<MAXFILE;i++){
close(i);
} /*第五步:关闭文件描述符*/

while(1){
if((fd=open("/tmp/dameon.log",O_CREAT|O_WRONLY|O_APPEND,0600))<0){
perror("open");
exit(1);
}
write(fd,buf,len+1);
close(fd);
sleep(10);
}
return 0;
}

说说进程通信的方式有哪些

image-20221206023051122


进程通信中的管道实现原理是什么

操作系统在内核中开辟一块缓冲区管道)用于通信。管道是一种两个进程间进行单向通信的机制,半双工。管道分为无名管道和命名管道,无名管道只能用于具有亲缘关系的进程直接的通信(父子进程或者兄弟进程)。管道本质是一种文件

pipe()函数创建的管道处于一个进程中间,因此一个进程在由 pipe()创建管道后,一般再使用fork() 建立一个子进程,然后通过管道实现父子进程间的通信。管道两端可分别用描述字fd[0]以及fd[1]来描述。读端由描述字fd[0]表示,称其为管道读端;写端由描述字fd[1]来表示。一般文件的 I/O 函数都可以用于管道,如close()、read()、write()等

#include<unistd.h>     
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define INPUT 0
#define OUTPUT 1
int main(){
//创建管道
int fd[2];
pipe(fd);

pid_t pid = fork(); //创建子进程, 父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管道。
if (pid < 0){
printf("fork error!\n");
exit(-1);
}else if (pid == 0){//执行子进程
printf("Child process is starting...\n");
//子进程向父进程写数据,关闭管道的读端
close(fd[INPUT]);
write(fd[OUTPUT], "hello douya!", strlen("hello douya!"));
exit(0);
} else{//执行父进程
printf ("Parent process is starting......\n");
//父进程从管道读取子进程写的数据 ,关闭管道的写端
close(fd[OUTPUT]);
char buf[255];
int output = read(fd[INPUT], buf, sizeof(buf));
printf("%d bytes of data from child process: %s\n", output, buf);
}
return 0;
}

使用管道的四种特殊情况

image-20230207143613149


⚠️共享内存

共享内存允许多个进程共享物理内存的同一块区域。这种IPC 机制无需内核介入。所有要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。

与管道等要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比(管道是一种存在于内核的文件),这种IPC技术的速度更快

共享内存使用步骤

  1. 调用shmget()这个调用将返回后续调用中需要用到的共享内存标识符
  2. 使用shmat()和当前进程进行关联, 返回已开辟的内存的首地址
  3. 调用shmdt() 来分离共享内存段。在此之后,进程就无法再引用这块共享内存了。这是可选的,并且在进程终止时会自动完成这一步
  4. 调用shmctl()来删除共享内存段。只有当前所有附加内存段的进程都与之分离后,内存段才会销毁。只有一个进程需要执行这一步

⚠️简述mmap的原理和使用场景(6参数)

原理mmap是一种内存映射文件的方法,即将一个文件映射到进程的虚拟地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read, write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 内存映射

addr 是要映射的内存的初始地址,一般由内核指定,我们写 NULL 就行

length 要映射的数据的长度,这个值不能为0 (一般为分页的整数倍),一般使用文件的长度 ⬇️

// 法1
int size_of_file = lseek(fd, 0, SEEK_END);

// 法2
struct stat st;
stat(_pName, &st);
return st.st_size; 什么意思

prot 对申请的内存映射区的操作权限 [不能只指定写权限]

PROT_READ      Data can be read.
PROT_WRITE Data can be written.
PROT_EXEC Data can be executed.
PROT_NONE Data cannot be accessed.

flags

MAP_SHARED          Changes are shared. 内存映射区的数据会自动和磁盘文件进行同步,进程间通信必须要设置这个选项
MAP_PRIVATE Changes are private. 内存映射区的数据改变了,不会修改原来磁盘的文件,会创建一个新文件
MAP_FIXED Interpret addr exactly.

fd 需要映射的文件的文件描述符(通过open函数得到(PROT权限要小于open的权限))

offset 偏移量,一般不用。必须是4K的整数倍,0表示不偏移

返回值:返回要创建的内存的首地址,失败返回MAP_FAILED宏

int munmap(void * addr, size_t length) ; 解除内存映射

addr 要释放的内存的首地址

length 要释放的内存的大小,要和 mmap 中的 length 一样

使用场景

  1. 进程对同一块区域频繁读写操作或者大规模数据传输;
  2. 进程间相互通信

差别:

  1. 内存对象的大小限制不同:使用shmem时,共享内存对象的大小通常受到系统限制,而使用mmap则可以映射任意大小的文件到内存中。
  2. 内存对象的生命周期不同:使用shmem时,内存对象可以被系统自动清理,也可以在所有进程都释放后由系统清理;而使用mmap则需要手动释放内存,否则会导致内存泄漏。

说说常见信号

信号代号 信号名称 说 明
1 SIGHUP 该信号让进程立即关闭.然后重新读取配置文件之后重启
2 SIGINT 程序中止信号,用于中止前台进程。相当于输出 Ctrl+C 快捷键
8 SIGFPE 在发生致命的算术运算错误时发出。包括浮点运算错误,溢出及除0
9 SIGKILL 用来立即结束程序的运行。本信号不能被阻塞、处理和忽略。般用于强制中止进程
14 SIGALRM 时钟定时信号,计算的是实际的时间或时钟时间。alarm 函数使用该信号
15 SIGTERM 正常结束进程的信号,kill 命令的默认信号。如果进程已经发生了问题,那么这 个信号是无法正常中止进程的,这时我们才会尝试 SIGKILL 信号,也就是信号 9
17 SIGCHLD 子进程结束时, 父进程会收到这个信号。
18 SIGCONT 该信号可以让暂停的进程恢复执行。本信号不能被阻断
19 SIGSTOP 该信号可以暂停前台进程,相当于输入 Ctrl+Z 快捷键。本信号不能被阻断

⚠️如何保护一个进程不被杀死,具体的代码实现

  1. 拦截 SIGTERM / SIGHUP 信号来保护进程不被杀死

  2. fork()一个子线程,在子线程里setsid()

  3. setsid ./a

  4. nohup ./a

  5. ./a & 切换到后台运行【ls看不到, ctrl + c取消不了,fg切换回前台】


进程, 线程的中断切换过程是怎样的?

上下文切换指的是内核在CPU上对进程或者线程进行切换

  1. 进程上下文切换

    (1)保护被中断进程的处理器现场信息

    (2)修改被中断进程的PCB有关信息,如进程状态等

    (3)把被中断进程的进程控制块加入有关队列

    (4)选择下一个占有处理器运行的进程

    (5)根据被选中进程设置操作系统用到的地址转换存储保护信息

    切换页目录以使用新的地址空间

    切换内核栈和硬件上下文(包括分配的内存,数据段,堆栈段等)

    (6)根据被选中进程恢复处理器现场

  2. 线程上下文切换

    (1)保护被中断线程的处理器现场信息

    (2)修改被中断线程的TCB有关信息,如线程状态等

    (3)把被中断线程的线程控制块加入有关队列

    (4)选择下一个占有处理器运行的线程

    (5)根据被选中线程设置操作系统用到的存储保护信息

    切换内核栈和硬件上下文(切换堆栈,以及各寄存器)

    (6)根据被选中线程恢复处理器现场


❤️死锁产生条件以及如何解决死锁

  1. 死锁: 是指多个进程在执行过程中,因争夺资源而造成了互相等待。此时系统产生了死锁。比如两只羊过独木桥,若两只羊互不相让,争着过桥,就产生死锁

  2. 产生的条件:死锁发生有四个必要条件

    (1)互斥:一个资源每次只能被一个进程使用

    (2)请求保持:一个进程因请求资源而阻塞时,对已获得的资源保持不放

    (3)不可剥夺:进程已获得的资源,在使用完前不可剥夺;

    (4)环路等待:若干进程之间形成一种头尾相接的循环等待资源关系。

  3. 死锁的预防:

    1. 避免互斥:尽可能减少资源的互斥性,例如使用共享内存代替互斥锁。
    2. 破坏循环等待:对资源进行排序,确保线程或进程按照相同的顺序请求资源,从而避免循环等待。
    3. 避免请求保持:线程或进程在请求资源时不应该持有其他资源,这可以通过在获取所有资源之前释放已经持有的资源来实现。
    4. 避免不可剥夺:确保在任何时候都可以撤销线程或进程所持有的资源
    5. 检测和恢复:系统应该能够检测到死锁的发生,并尝试恢复。例如,可以使用超时机制,如果一个线程或进程长时间等待资源,则可以取消其请求,释放已经持有的资源。

⚠️在Linux中,让程序在系统开启时自启动

  1. 使用rc.local文件

在Linux系统中,rc.local是一个启动脚本,可以在系统启动时自动运行。

如,如果要在系统启动时启动Apache Web服务器,可以在rc.local文件中添加以下命令:

/usr/sbin/apachectl start

注意:如果使用了Systemd,则rc.local可能不再适用。

  1. 使用Systemd服务

Systemd是Linux系统中最新的init系统之一。使用systemd服务管理器,可以在系统启动时自动启动指定的程序。可以创建一个新的systemd服务文件,并在其中指定要自启动的程序。例如,如果要在系统启动时启动Nginx Web服务器,可以创建一个名为nginx.service的文件,并将其放置在/etc/systemd/system目录中,然后在文件中添加以下内容:

[Unit]
Description=nginx - high performance web server
After=network.target

[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStart=/usr/sbin/nginx -c /etc/nginx/nginx.conf
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID

[Install]
WantedBy=multi-user.target

中断

是指计算机在执行指令的过程中,由于硬件设备的需要或者其他原因,被迫中断当前的工作而转而去处理其他的工作。

在操作系统中,中断是一种重要的机制,它允许计算机在运行应用程序时响应外部事件或者硬件设备的请求。当外部事件或者硬件设备需要操作系统的处理时,会向处理器发送一个中断信号,处理器会立即暂停正在执行的程序,转而执行与中断信号相对应的中断处理程序(也称为中断服务程序或者中断处理例程)。

  1. 硬件中断:由于硬件设备出现故障或需要处理某些事务而引发的中断,例如磁盘操作完成、网络数据传输完成等。
  2. 软件中断:由于软件程序需要访问操作系统的某些服务或资源而引发的中断,例如系统调用、异常等。
  3. 外部中断:由外部设备发送的信号或事件引发的中断,例如键盘输入、鼠标点击等。
  4. 内部中断:由CPU内部的错误或操作引发的中断,例如除零错误、页故障等。
  5. 陷阱:由于用户进程在执行时意外触发了操作系统预定义的异常条件而引发的中断,例如系统调用等。

image-20230327182127099


锁的种类

锁是用来实现多线程同步的一种机制,常见的锁包括以下几种:

  1. 互斥锁:也称为 Mutex,是一种最基本的锁。它是一种二进制锁,只有两种状态:锁定和非锁定。多个线程同时尝试获取该锁时,只有一个线程能够成功获取,其他线程需要等待该线程释放锁才能尝试获取。
  2. 读写锁:也称为 RWLock,是一种特殊的锁。它可以允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。这种锁的使用可以提高程序的并发性能。
  3. 自旋锁:也称为 SpinLock,是一种非阻塞锁。它是通过忙等待的方式来实现的,当一个线程需要获取该锁时,如果锁已经被其他线程持有,该线程会一直忙等待,直到锁被释放。自旋锁的等待是忙等待,因此适合用于锁被持有时间很短的情况下。
  4. 条件变量:是一种基于线程等待和通知机制的锁。当一个线程需要等待某个条件成立时,它会通过条件变量进入阻塞状态,等待其他线程通知条件成立。当条件成立时,其他线程通过条件变量通知等待的线程,使其从阻塞状态中唤醒并继续执行。
  5. 信号量:是一种计数器,用来控制多个线程对共享资源的访问。当计数器为 0 时,线程需要等待,否则可以继续执行。
  6. 屏障:也称为 Barrier,是一种同步机制。它可以保证多个线程在某个点上同步执行,当所有线程到达该点时,才能继续执行下一步操作。

自旋锁和条件变量的区别

自旋锁和条件变量都是用来实现多线程同步的机制,但它们的实现方式和作用有所不同。

自旋锁是一种非阻塞锁,它是通过忙等待的方式来实现的。当一个线程需要获取自旋锁时,如果锁已经被其他线程持有,那么该线程会一直忙等待,直到锁被释放。由于自旋锁的等待是忙等待,所以在锁被持有的时间很短的情况下,自旋锁的效率很高。但是当锁被持有的时间很长时,忙等待会浪费大量的 CPU 资源,因此自旋锁不适合用于长时间的等待。

条件变量是一种阻塞锁,它是基于线程等待和通知机制来实现的。当一个线程需要等待某个条件成立时,它会通过条件变量进入阻塞状态,等待其他线程通知条件成立。当条件成立时,其他线程通过条件变量通知等待的线程,使其从阻塞状态中唤醒并继续执行。因为条件变量是基于线程等待和通知机制实现的,所以它不会浪费 CPU 资源,适合用于长时间的等待。


零拷贝 & DMA

比如想实现一个下载功能,服务端的任务就是:

将服务器主机磁盘中的文件从已连接的socket中发出去,关键代码如下

while((n = read(diskfd, buf, BUF_SIZE)) > 0)
write(sockfd, buf, n);

以上过程包括四次内核态切换和2次DMA
read:将数据从磁盘读取到内核缓存区中,在拷贝到用户缓冲区
write:先将数据写入到socket缓冲区中,最后写入网卡设备

DMA本质上是一块主板上独立的芯片,允许外设设备和内存之间直接进行IO数据传输,其过程不需要CPU的参与

如何实现零拷贝?

零拷贝并不是没有拷贝数据,而是减少用户态/内核态的切换次数以及内存拷贝次数;实现零拷贝主要有两种方式分别是

  1. mmap

  2. sendfile


mmap 和sendfile 的区别

sendfilemmap都是在进行文件传输和IO时使用的函数,但是它们的实现机制不同,各自具有不同的优缺点。

sendfile是一个系统调用函数,它将文件从一个文件描述符传输到另一个文件描述符,通常用于网络服务器发送静态文件到客户端。它避免了数据在内核与用户空间之间的复制,直接从文件系统缓存中将数据复制到网络缓存中,因此它是非常快的。但是它的使用场景有限,只能用于网络传输,不能用于内存操作。

mmap也是一种高效的IO方式,它将文件映射到进程的虚拟地址空间,使得进程可以直接访问文件中的数据。这种直接的内存访问方式比较快,因为它避免了数据在内核与用户空间之间的复制。此外,mmap允许进程将文件的某一部分映射到内存中,这意味着进程可以仅仅访问文件的一部分而无需将整个文件加载到内存中。

综上所述,sendfilemmap都是高效的IO方式,但是它们适用于不同的场景。如果你需要在网络上传输文件,那么使用sendfile会更快;如果你需要直接访问文件中的数据,那么使用mmap会更快。


future

future 可以理解为金融中的 期货. 我有一个 future 类型的变量, 交给一个异步的线程去处理. 我到期来提货交割就可以了.这个能提供 future 服务的就叫provider.

// future example
#include <iostream> // std::cout
#include <future> // std::async, std::future
#include <chrono> // std::chrono::milliseconds

// a non-optimized way of checking for prime numbers:
bool is_prime(int x) {
for (int i = 2; i < x; ++i) if (x%i == 0) return false;
return true;
}

int main() {
// call function asynchronously:
future<bool> fut = async(is_prime, 444444443);

// do something while waiting for function to set future:
cout << "checking, please wait";
chrono::milliseconds span(100);
while (fut.wait_for(span) == future_status::timeout)
std::cout << '.' << std::flush;

bool x = fut.get(); // retrieve return value

cout << "\n444444443 " << (x ? "is" : "is not") << " prime.\n";

return 0;
}

---
=> checking, please wait........
444444443 is prime.

2️⃣计算机网络 5.23

网络编程

TCP通信流程
//服务器端(被动接受连接的角色)
1.创建一个用于监听客户端连接的套接字(文件描述符)【lfd】
2.将这个监听文件描述符和本地的IP和端口绑定(IP和端口就是服务器的地址信息)【【serveraddr要设置IP、端口、协议】lfd与serversaddr绑定】
-客户端连接服务器的时候使用的就是这个IP和端口
3.设置监听,lfd开始工作
4.阻塞等待,当有客户端发起连接,解除阻塞,接受客户端的连接,会得到一个新的和客户端通信的套接字【clientaddr只要定义, cfd【cfd与clientaddr通信】】
5.通信
-接收数据
-发送数据
6.通信结束,断开连接
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>
#include<stdlib.h>

using namespace std;
int main(){
// 1.socket()
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

if(listenfd == -1){
perror("socket");
exit(-1);
}

// 2.bind()
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET; // 网络协议
// inet_pton(AF_INET, "192.168.12.1", &serveraddr.sin_addr.s_addr); // IP
serveraddr.sin_addr.s_addr = INADDR_ANY; // 服务器开发时可写,表示服务器端任何IP都可以被客户端访问
serveraddr.sin_port = htons(9999); // 端口
int ret = bind(listenfd, (struct sockaddr*)&serveraddr, sizeof(serveraddr));

if(ret == -1){
perror("bind");
exit(-1);
}

// 3.listen()
ret = listen(listenfd, 8); // 8为连接数

if(ret == -1){
perror("listen");
exit(-1);
}

// 4.accept()
struct sockaddr_in clientaddr;
int len = sizeof(clientaddr);
int cfd = accept(listenfd, (sockaddr*)&clientaddr, (socklen_t *)len);

if(cfd == -1){
perror("accept");
exit(-1);
}

// 输出客户端的信息
char clientIP[16];
inet_ntop(AF_INET, &clientaddr.sin_addr.s_addr, clientIP, sizeof(clientIP));
unsigned short clientPort = ntohs(clientaddr.sin_port);
cout << "client ip:" << clientIP << ", port:" << clientPort << endl;

// 5. recv() & send()
// 获取客户端的数据
char recvBuf[1024] = {0};
int len1 = read(cfd, recvBuf, sizeof (recvBuf)) ;
if(len1 == -1){
perror("read");
exit(-1);
} else if(len1 > 0){
printf("recv client data : %s\n", recvBuf);
} else if(len1 == 0){
//表示客户端断开连接
printf("clinet closed...");
}
// 给客户端发送数据
char * data = "hello,i am server";
write(cfd, data, strlen(data));

// 6.关闭文件描述符
close(cfd);
close(listenfd);

return 0;
}
//客户端
1.创建一个用于通信的套接字【clientfd】
2.连接服务器,需要指定连接的服务器的IP和端口【serveraddr要设置IP、端口、协议【clientfd与serveraddr连接,用clientfd通信】】
3.连接成功了,客户端可以直接和服务器通信
-接收数据
-发送数据
4.通信结束,断开连接
#include<stdio.h>
#include <arpa/inet.h>
#include<unistd.h>
#include<iostream>
#include<string.h>

int main(){
// 1.socket()
int clientfd = socket(AF_INET, SOCK_STREAM, 0);

if(clientfd == -1){
perror("socket");
exit(-1);
}

// 2.connect()
struct sockaddr_in serveraddr;
serveraddr.sin_family = AF_INET;
inet_pton(AF_INET, "172.16.208.128", &serveraddr.sin_addr.s_addr);
serveraddr.sin_port = htons(9999); // 两个端口要一致
int ret = connect(clientfd, (sockaddr *)&serveraddr, sizeof(serveraddr));

if(ret == -1){
perror("connect");
exit(-1);
}

// 3. recv() & send()
// 给服务器端发送数据
char * data = "hello,i am client";
write(clientfd, data, strlen(data));
// 获取服务器端的数据
char recvBuf[1024] = {0};
int len = read(clientfd, recvBuf, sizeof (recvBuf));
if(len == -1){
perror("read");
exit(-1);
} else if(len > 0){
printf("recv server data : %d\n", recvBuf);
} else if(len == 0){
//表示服务器断开连接
printf("server closed...");
}

// 4.close()
close(clientfd);

return 0;
}

listen() 里的backlog参数

backlog:等待连接队列的最大长度。当有客户端请求连接时,如果服务器端没有足够的资源去处理这些连接请求,那么这些连接请求就会被放入一个等待队列中,等待服务器处理。

backlog参数对于管理服务器性能和连接处理非常重要。如果backlog设置得太低,服务器可能无法处理所有传入的连接请求,导致连接丢失或响应时间缓慢。如果backlog设置得太高,服务器可能会消耗过多的资源来管理大量的挂起连接。


accept() 在三次握手哪里

accept()需要给此次连接分配资源。设想一个情景,若有10000个客户端都和该服务端进行连接,发送SYN,服务端收到之后,这些客户端却不再理会服务端的回复,然而此时服务端的资源却都用accept()分配了。这就是所谓的“DDOS攻击”。 为了解决这个问题,accept() 放在三次握手之后。


防范SYN攻击(DDOS的一种)
  1. 配置防火墙规则:可以配置防火墙规则,限制每个IP地址对服务器的连接数,以及每个端口的连接数,从而减少SYN攻击对服务器的影响。
  2. 启用SYN Cookie:可以在服务器上启用SYN Cookie功能。当服务器收到大量的SYN数据包时,它会在内存中创建一个SYN Cookie,用于保存客户端的状态信息。当客户端返回ACK数据包时,TCP服务器再根据那个cookie值检查这个TCP ACK包的合法性。如果合法,再分配专门的数据区进行处理未来的TCP连接
  3. 加强网络监控:可以使用网络监控工具,实时监控网络流量,及时发现并阻止SYN攻击。
  4. 调整服务器性能:可以通过增加服务器性能,如增加CPU、内存、网络带宽等,提高服务器抵御SYN攻击的能力。

网络理论

(应用层 -> 传输层 -> 网络层 -> 链路层)

我们就以一个HTTP请求数据包为例子来说明.发送数据的计算机叫做源主机,接收数据的计算机叫做目标主机

应用层
首先一个HTTP数据包在应用层中大概包含HTTP协议的版本号、各种字段属性值、最后是包含的要发送的实际数据

image-20230227131442484

传输层

传输层对应着有UDP和TCP两种协议,HTTP采用的是TCP协议,因为TCP能够提供差错控制。

传输层会为将HTTP数据包包装上源端口号和目的端口号等信息。

目的端口号是为了在数据包到达目的计算机的时候让其了解需要将数据包交给什么应用层协议进行处理。

源端口号是为了让目标计算机想要返回数据的时候,知道给源计算机的哪个应用层协议发送数据

image-20230227131536296

如果HTTP报文超过了数据链路层规定的最大传输单元MTU,TCP会对HTTP报文进行拆解, 将HTTP报文拆分成多个满足传输要求的报文并包装,这些报文之间是有先后顺序的,TCP对这些报文进行顺序编号,保证数据的正确读

image-20230227131834043

网络层

会为其加入源IP和目标IP等信息。 源IP 指的是源计算机的IP 目标IP 指的是目标计算机的IP

image-20230227131924338

数据链路层
在数据链路层中会在数据包中,加入发送方MAC地址和接收方MAC地址。
发送方MAC地址 就是源计算机的MAC地址,不变。
接收方MAC地址 并不是目标计算机的MAC地址, 不变。而是数据包的下一跳的MAC地址,也就是网关的地址,也可以说是第一个转发的路由器的端口mac地址

image-20230227132509995

image-20230227132827930

接收方MAC地址是路由器端口的MAC地址,而不是交换机的端口地址。交换机不会验证接收方MAC地址,也就是帧经过交换机 源MAC地址和目标MAC地址都不会发生变化。交换机只负责转发交换,如果存在对应的MAC地址缓存,就从对应的端口转发出去,如果不存在缓存,就从所有端口转发出去

交换机
数据包首先会发送到交换机中,交换机工作在MAC层,是一个二层网络设备。

接收方MAC地址是路由器端口的MAC地址,而不是交换机的端口地址。交换机不会验证接收方MAC地址,也就是帧经过交换机 源MAC地址和目标MAC地址都不会发生变化。交换机只负责转发交换,如果存在对应的MAC地址缓存,就从对应的端口转发出去,如果不存在缓存,就从所有端口转发出去

(链路层 -> 网络层 -> 传输层 -> 应用层)
  1. 路由器——路由转发

数据包出了交换机就算是出了家门了,开始进入路由器。

数据包通过端口进入路由器,执行以下步骤:
1、 路由器首先会检查数据包的接收方MAC地址是否等于路由器端口的MAC地址,如果等于就接收,如果不等于就抛弃。
2、 路由器去除头部的MAC包装,暴露出IP地址信息,取出目标IP地址,然后查看路由表。

取出路由表中的记录与目标IP地址挨个进行检验,检验过程如下:

将记录中的子网掩码与目标IP地址进行&运算,如果等于记录中的目标网络,说明存在当前路由器可以到达目标IP地址,就通过记录中对应的端口转发出去, 在转发出去之前需要包装MAC地址

3、 如果路由表中不存在对应的目标地址,那么就会通过默认路由发出去,默认路由的目标地址和子网掩码都是 0.0.0.0,MAC的包装和上述一样。

4、 如果同时存在多个符合的路由,就按照最长掩码匹配原则,选择掩码中1最多的路由进行转发

从路由转发的过程来看,源IP地址和目标IP地址一直不变,发送方MAC地址和接收方MAC地址一直在变。

  1. 到达目标计算机
    就这样通过路由器的不断转发,数据包会到达与目标计算机直连的路由器。

此时,路由器中目标IP地址对应的路由记录中的下一跳IP地址 就是 目标IP地址。

这个时候,路由表会将目标IP地址的MAC地址当作目的MAC地址,将路由器转发端口对应的MAC地址当作源MAC地址,然后通过端口将MAC帧发送到 路由器端口对应的子网中。

路由器子网是由多个交换机构成的局域网,交换机只会通过MAC地址缓存将数据包转发给对应的端口,如果没有对应的缓存,就转发给所有的端口。

这样的话,数据包就会转发到了目标计算机中

  1. 目标计算机在收到数据包后会将数据包从下层往上层拆封。

1、 首先是数据链路层,目标计算机会将MAC包装信息去除,取出接收方MAC地址,查看是否和自己的MAC地址一致,如果不一致,就抛弃。

2、 接着是网络层,将IP包装信息去除,取出目标IP地址,查看是否与自己的IP地址一致,如果不一致,就抛弃。

3、 接着是传输层,会取出目标端口号,通过端口号获取对应的进程,将数据包交给对应的进程。比如HTTP数据包的目标端口号是80,就会交给HTTP进程,HTTP会调用其业务逻辑,将返回的数据包装成数据包通过源IP地址发送给源计算机。

⚠️网络模型

image-20230210161432357

image-20230227103852887

image-20230406225101735

广播域就是说,如果站点发出一个广播信号后能接收到这个信号的范围,通常来说一个局域网就是一个广播域。(用路由器连接的除外)。冲突域是一个站点向另一个站点发出信号,除目的站点外,有多少站点能收到这个信号,这些站点就构成一个冲突域

三种模型对比

image-20230210161511598


TCP三次握手 & 四次挥手

三次握手(发生在客户端connect()中)

img

三次握手完成,成功建立连接,开始传输数据

总结:传输层TCP是全双工的,但是其上层应用层可能是半双工的。每一个层次都有自己的双工模式,传输层有传输层的双工模式,应用层有应用层的双工模式。下层的双工模式是支持上层双工模式的上限。比如下层支持半双工,上层顶多支持半双工,不可能支持全双工。tcp是全双工的,但它的上层可能支持半双工,比如http1.1,也有可能支持全双工,比如http2.0

四次挥手(发生在两端close()中)

img
TCP与UDP区别

image-20230208211405978


TCP流量控制:滑动窗口

在流量控制中那些已经被客户端发送但是还未被确认的分组的许可序号范围可以被看成是一个在序号范围内长度为N的窗口,随着TCP协议的运行、数据的运输,这个窗口在序号空间向前滑动,因此这个窗口被称为滑动窗口。

可以将整个报文段分为四组

  1. 已被确认的分组
  2. 已发送但未被确认的分组
  3. 接下来可以分发的分组
  4. 超出窗口长度之后的待使用的分组

TCP拥塞控制:慢开始&拥塞避免、快重传&快恢复

发送方的发送窗口大小 = Min(接收窗口rwnd,拥塞窗口cwnd)

image-20230301183029287
说说 TCP 粘包

TCP基于字节流,无法判断发送方报文段边界

多个数据包被连续存储于连续的缓存中,在对数据包进行读取时由于无法确定发送边界,若发送方发送数据包的长度和接收方在缓存中读取的数据包长度不一致,就会发生粘包

发送端可能堆积了两次数据,每次100字节一共在发送缓存堆积了200字节的数据,而接收方在接收缓存中一次读取120字节的数据,这时候接收端读取的数据中就包括了下一个报文段的头部,造成了粘包。 解决粘包的方法:

  1. 发送定长的数据包。每个数据包的长度一样,接收方可以很容易区分数据包的边界
  2. 数据包末尾加上\r\n标记,模仿FTP协议,但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界
  3. 数据包头部加上数据包的长度,也是目前所采取的方案

TCP 和 UDP 可以同时绑定相同的端口吗?

当主机收到数据包后,可以在 IP包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP / UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理

因此, TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 8888号端口,UDP 也可以有一个 8888 号端口

运行这两个程序后,通过 netstat 命令可以看到,TCP 和 UDP 是可以同时绑定同一个端口号的

image-20230302181113760


多个 TCP 服务进程可以绑定同一个端口吗?

如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是 “Address already in use”

没有设置端口复用的socket在当 TCP 服务进程重启时,客户端处于 TIME_WAIT 状态,在TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误

解决办法:

int on = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

因为 SO_REUSEADDR 作用是:如果当前启动进程绑定的 IP+PORT 与处于TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功

SO_REUSEADDR 的另外一个作用是绑定的 IP地址 + 端口时,只要 IP 地址不是exactly相同,那么允许绑定。


同一客户端可以 bind 同一个端口吗?

客户端在执行 connect() 的时候,会在内核里随机选择一个端口,然后向服务端发起 SYN 报文,然后与服务端进行三次握手。

所以,客户端的端口选择的发生在 connect (),内核会随机选择一个端口

当客户端与服务端完成 TCP 连接建立后,我们可以通过 netstat 命令查看 TCP 连接
image-20230302182404371

上面客户端已经用了 64992 端口,那么还可以继续使用该端口发起连接吗?

正确的理解是,TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题


⚠️客户端connect函数选择端口号的过程
img
产生大量CLOSE_WAIT原因和解决
  1. 服务器迟迟无法进行第三次挥手,从而导致CLOSE_WAIT状态的堆积。(正确关闭套接字连接)
  2. 网络问题:在网络故障或不稳定的情况下,连接关闭可能会失败,导致CLOSE_WAIT状态的出现
  3. 服务器端使用阻塞式I/O:当服务器端使用阻塞式I/O时,如果有某个连接上的数据未被及时处理,则可能会导致连接被阻塞,从而导致CLOSE_WAIT状态的堆积
  4. 过多的并发连接:如果服务器端同时处理大量的并发连接,可能会导致连接的处理不及时,从而导致CLOSE_WAIT状态的堆积
// 解决方法: 在发送数据时,如果发生错误,应该及时关闭套接字,而不是等待数据发送完毕
if (send(socket_fd, buffer, buffer_size, 0) == -1) {
close(socket_fd);
return -1;
}

产生大量TIME_WAIT原因和解决
  1. 大量短连接:如果一个应用程序频繁地打开和关闭TCP连接,就会产生大量的TIME_WAIT状态。这是因为在TIME_WAIT状态下,操作系统会保留连接信息,直到超时时间到期。如果一个应用程序频繁打开和关闭连接,就会导致大量TIME_WAIT状态堆积。
  2. 网络延迟:在网络延迟比较大的情况下,连接关闭时可能需要更长的时间来等待所有的数据包到达。在这种情况下,TIME_WAIT状态会持续更长的时间,从而导致大量TIME_WAIT状态的出现。
  3. 大量并发连接:此时,TIME_WAIT状态的数量可能会增加。这是因为每个连接在关闭后都会变成TIME_WAIT状态,如果有大量连接,就会产生大量TIME_WAIT状态。

解决办法: 优化内核参数,让服务器能够快速回收和重用那些TIME_WAIT的资源

%编辑内核文件/etc/sysctl.conf,加入以下内容:

net.ipv4.tcp_syncookies = 1 %表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭;
net.ipv4.tcp_tw_reuse = 1 %表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭;
net.ipv4.tcp_tw_recycle = 1 %表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。
net.ipv4.tcp_fin_timeout %修改系默认的 TIMEOUT连接超时 时间

执行 /sbin/sysctl -p 让参数生效

HTTP请求报文&响应报文
image-20230209152923685
GET / HTTP/1.1   
-----------------------↑请求行,↓请求头部---------------------------
Host: https://www.baidu.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Cookie: BAIDUID=6729CB682DADC2CF738F533E35162D98:FG=1; BIDUPSID=6729CB682DADC2CFE015A8099199557E; PSTM=1614320692; BD_UPN=13314752; BDORZ=FFFB88E999055A3F8A630C64834BD6D0; __yjs_duid=1_d05d52b14af4a339210722080a668ec2161****694782; BD\_HOME=1; H_PS_PSSID=33514_33257_33273_31660_33570_26350; BA_HECTOR=8h2001alag0lag85nk1g3hcm60q
Upgrade-Insecure-Requests: 1
Cache-Control: max-age=0
-----------------------------↓请求体-----------------------------
image-20230209152937850
HTTP/1.1 200 OK
-----------------------↑状态行,↓响应头部---------------------------
Bdpagetype: 1
Bdqid: 0xf3c9743300024ee4
Cache-Control: private
Connection: keep-alive
Content-Encoding: gzip
Content-Type: text/html;charset=utf-8
Date: Fri, 26 Feb 2021 08:44:35 GMT
Expires: Fri, 26 Feb 2021 08:44:35 GMT
Server: BWS/1.1
Set-Cookie: BDSVRTM=13; path=/
Set-Cookie: BD_HOME=1; path=/
Set-Cookie: H_PS_PSSID=33514_33257_33273_31660_33570_26350; path=/; domain=.baidu.com Strict-Transport-Security: max-age=172800
Traceid: 161****0751284122890175****9583927635684
X-Ua-Compatible: IE=Edge,chrome=1
Transfer-Encoding: chunked
-----------------------------↓响应体-----------------------------
<html>
<head>
<title>Wrox Homepage</title>
</head>
<body>
<!-- body goes here -->
</body>
</html>

一次HTTP请求响应的流程

​ 浏览器先查看浏览器缓存, 如果缓存中有, 会直接在屏幕中显示页面内容

  • 域名解析:浏览器查询 DNS,获取域名对应的IP地址: 浏览器先在本地DNS服务器进行查询, 如果本地域名服务器并未缓存该域名对应IP,那么将根据其设置发起递归查询或者迭代查询;
  • 浏览器获得对应的IP后,向服务器请求建立TCP链接,发起三次握手
  • 建立TCP连接后发起http请求
  • 服务器响应http请求,浏览器得到html代码
  • 浏览器解析html代码,并请求html代码中的资源(如js、css、图片等)
  • 浏览器对页面进行渲染呈现给用户

HTTP协议特点

1.无连接:限制每次连接只处理一个请求,服务端完成客户端的请求后,即断开连接。(传输速度快,减少不必要的连接,但也意味着每一次访问都要建立一次连接,效率降低)

2.无状态:对于事务处理没有记忆能力。每一次请求都是独立的,不记录客户端任何行为。(优点解放服务器,但可能每次请求会传输大量重复的内容信息)


HTTP方法
  • GET:用于请求访问已经被URI(统一资源标识符)识别的资源,可以通过URL传参给服务器
  • POST:用于传输信息给服务器,主要功能与GET方法类似,但一般推荐使用POST方式
  • PUT:传输文件,报文主体中包含文件内容,保存到对应URI位置
  • DELETE:删除文件,与PUT相反,删除对应URI位置文件。
  • HEAD:获得报文头部,与GET类似,只是不返回报文主体,一般用于验证URI是否有效。
  • OPTIONS:查询相应URL支持的HTTP方法

GET、POST区别
  1. get重点在从服务器上获取资源;post重点在向服务器发送数据;

  2. get是不安全的,因为URL是可见的,可能会泄露私密信息,如密码等

    post传输数据将字段与对应值封存在请求体中发送给服务器,这个过程对用户是不可见的

  3. Get传输的数据量小,因为受URL长度限制,但效率较高

    Post可以传输大量数据,所以上传文件时只能用Post方式

  4. get方式只能支持ASCII字符,向服务器传的中文字符可能会乱码

    post支持标准字符集,可以正确传递中文字符


Http状态码
  • 1xx:表示请求已接收,继续处理
  • 2xx:成功–表示请求已被成功接收、理解、接受
  • 3xx:重定向–要完成请求必须进行更进一步的操作
  • 4xx:客户端错误–请求有语法错误或请求无法实现
  • 5xx:服务器端错误–服务器未能实现合法的请求

Http的keepalive字段

既然上面提到了HTTP是基于请求与响应的,且最主要的两个特点就是无连接和无状态,但需要说明的是,虽然是无连接的,但其底层也就是传输层大多却是基于 TCP面向连接的通信方式,因此,这里的无连接指的是:当server端和client端进行通讯的时候,client端向server端发起请 求,server端接收请求之后返回给client端一个响应,之后就会断开不再继续保持连接了;这样有一个好处就是对于只有一次访问的连接来说不仅节省 资源还很高效,但很明显,如果client端还想继续多次访问server端就需要重新建立连接也就是会多次进行TCP的“三次握手,四次挥手”的过程, 这样一来并没有节省资源而且还很低效,因此使用keep-alive(又称持久连接、连接重用)可以改善这种状态,即在一次TCP连接中可以持续发送多份数据而不会断开连接。减少了建立或者重新建立连接的次数,也意味着可以减少TIME_WAIT状态连接,以此提高性能和提高http服务器的吞吐率

HTTP 1.0 中keep-alive默认是关闭的,需要在HTTP头加入”Connection: Keep-Alive”,才能启用Keep-Alive;

HTTP 1.1中默认启用Keep-Alive,如果加入”Connection: close “则关闭。目前大部分浏览器都是用HTTP 1.1协议

keepalive_timeout时间值意味着:一个http产生的TCP连接在传送完最后一个响应后,还需要保持多久时间后才开始关闭这个连接;如果在这个时间内client端还有请求发过来,那么server端会继续给予响应


❤️长连接和短连接

长连接多用于操作频繁,点对点的通讯,而且连接数不能太多情况(会在后台开启http守护进程,一个http守护进程消耗是5MB内存的话)

例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成 socket 错误,而且频繁的 socket 创建也是对资源的浪费。

而像 WEB 网站的 http 服务一般都用短链接,因为长连接对于服务端来说会耗费一定的资源,而像 WEB 网站这么频繁的成千上万甚至上亿客户端的连接用短连接会更省一些资源,如果用长连接,而且同时有成千上万的用户,如果每个用户都占用一个连接的话,那可想而知吧。所以并发量大,但每个用户无需频繁操作情况下需用短连好。


❤️既然有了HTTP为什么还要RPC

虽然HTTP和RPC都是用于网络通信的协议,但它们的设计目标和使用场景是不同的。

HTTP是一种通用的应用层协议,常用于客户端与服务器之间的请求响应模式的通信,例如浏览器请求网页资源。HTTP协议是无状态的,每次请求需要携带所有必要的信息,如请求头、请求体等。HTTP协议也支持基于文本的协议,易于调试和理解,因此被广泛应用于Web应用程序和API的开发。

RPC支持客户端调用远程服务器上的函数,就像本地函数一样。与HTTP协议相比,RPC协议更加高效、紧凑和灵活,因为它通常使用二进制协议和更紧凑的数据格式来传输数据,避免了HTTP协议中的文本格式和不必要的信息。RPC协议也可以提供更高级的服务发现、负载均衡和故障恢复功能,因此被广泛应用于分布式系统和微服务架构中。

RPC:可以基于thrift实现高效的二进制传输

HTTP:大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能

总的来说,HTTP协议适合于简单的请求-响应场景,而RPC协议适合于更复杂的分布式系统场景。当需要在分布式系统中进行服务调用时,RPC协议可以提供更好的性能、可靠性和扩展性。


Http和Https的区别

Http协议运行在TCP之上,明文传输;Https是身披SSL外壳的Http,运行于SSL上,SSL运行于TCP之上,是添加了加密和认证机制的HTTP。二者之间存在如下不同:

1)Http端口号80, Https是443

2)Https由于加解密处理消耗更多的CPU和内存资源

3)Https通信需要证书,而证书一般需要向认证机构购买

4)Https的加密机制是一种结合对称加密和非对称加密的混合加密机制


❤️HTTPS握手步骤
  1. 客户端向服务器发出HTTPS连接请求。
  2. 服务器将自己的数字证书(含公钥)发送给客户端。
  3. 客户端验证证书的有效性。如验证通过,则客户端使用服务器证书中的公钥来生成一个随机密钥
  4. 客户端将生成的随机密钥用服务器证书中的公钥进行加密, 然后发送给服务器。
  5. 服务器使用自己的私钥解密客户端发送过来的随机密钥。
  6. 客户端和服务器使用该随机密钥进行加密和解密, 从而保证HTTPS通信的安全性。

⚠️对称加密与非对称加密
  • 对称加密是指加密和解密使用同一个密钥的方式

  • 非对称加密是指使用一对非对称密钥,即公钥和私钥,公钥可以随意发布,但私钥只有自己知道。发送方使用对方的公钥进行加密处理,接收方接收到加密信息后,使用自己的私钥进行解密。

由于非对称加密的方式不需要发送用来解密的私钥, 所以可以保证安全性, 但是慢; 所以我们还是要用对称加密来传送消息, 对称加密所使用的密钥我们可以通过非对称加密的方式发送出去


⚠️HTTP1.x 和 HTTP2.0 的区别
  • 二进制格式:HTTP1.x的解析是基于文本,但是基于文本协议的格式解析存在天然缺陷。文本的表现形式应该具有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮
  • 多路复用:一个客户端的多个HTTP请求通过一个TCP连接进行处理。一个请求对应一个id,这样一个连接上可以有多个请求,每个连接的请求可以随机的混杂在一起,接收方可以根据请求的 id将请求再归属到各自不同的服务端请求里面
  • 头部压缩:HTTP1.x的头部带有大量信息,而且每次都要重复发送,HTTP2.0通讯双方各自缓存一份头部表,既避免了重复头部的传输,又减小了需要传输的大小

WebSocket 协议

是一种基于 TCP 的协议,在客户端和服务器之间建立双向通信的通道,是长连接,可以在不需要刷新页面或进行轮询的情况下实时传输数据。客户端和服务器就可以通过该连接进行实时通信。

此外,WebSocket 协议的数据传输是二进制的,传输效率更高,而且支持跨域通信,可以在不同的域名和端口之间建立连接。

WebSocket 协议的使用场景非常广泛,例如在线聊天室、多人游戏、实时数据传输等。可以使用 WebSocket API 在客户端和服务器之间建立 WebSocket 连接,从而实现实时通信的功能。


WebSocket API 有哪些
  1. WebSocket 对象:WebSocket API 的核心对象,用于创建 WebSocket 连接、发送和接收数据。

  2. onopen 事件:WebSocket 连接成功建立时触发的事件

  3. onmessage 事件:接收到 WebSocket 数据时触发的事件

  4. onclose 事件:WebSocket 连接关闭时触发的事件

  5. onerror 事件:WebSocket 出错时触发的事件

  6. send() 方法:用于向 WebSocket 服务器发送数据

  7. close() 方法:用于关闭 WebSocket 连接


C++中如何实现websocket协议

在 C++ 中实现 WebSocket 协议,通常需要使用一个第三方库来处理 WebSocket 的底层协议。以下是一些常用的 C++ WebSocket 库

  1. libwebsockets:一个小型、轻量级的 C 语言库,用于实现 WebSockets 协议和 HTTP 协议。
  2. WebSocket++:一个基于 Asio 的 I/O C++ WebSocket 库,支持客户端和服务器端的 WebSocket 连接。
// 以下是使用 WebSocket++ 库在 C++ 中实现 WebSocket 服务器的示例代码:
#include <websocketpp/config/asio_no_tls.hpp>
#include <websocketpp/server.hpp>
#include <iostream>

typedef websocketpp::server<websocketpp::config::asio> server;

void on_message(server* s, websocketpp::connection_hdl hdl, server::message_ptr msg) {
std::cout << "Received message: " << msg->get_payload() << std::endl;
s->send(hdl, msg->get_payload(), msg->get_opcode());
}

int main() {
server echo_server;
echo_server.set_message_handler(&on_message);
echo_server.init_asio();
echo_server.listen(9002);
echo_server.start_accept();
echo_server.run();
return 0;
}

在上述代码中,使用 WebSocket++ 库创建一个 WebSocket 服务器,监听端口号 9002,并在收到消息时打印消息内容。使用 init_asio() 方法初始化 Asio I/O 系统,使用 start_accept() 方法开始接收连接请求,使用 run() 方法等待连接和消息。


HTTP 协议和 websocket 协议的区别

HTTP协议和WebSocket协议都是应用层协议,但是它们有一些重要的区别。

  1. 连接方式: HTTP协议是一种请求-响应协议,客户端发送请求给服务器端,服务器端响应请求,然后断开连接。这种连接方式被称为”短连接”。而WebSocket协议是一种全双工协议,客户端和服务器端之间可以保持长时间的连接,并且可以在任何时间发送数据。这种连接方式被称为”长连接”。
  2. 数据格式: HTTP协议传输的数据格式是纯文本格式,通常使用JSON或XML格式。而WebSocket协议可以传输任何格式的数据,例如二进制数据、文本数据等。
  3. 性能: HTTP协议每次请求都需要重新建立连接,这会带来额外的延迟。而WebSocket协议可以在一次连接中传输多个请求和响应,从而可以提高传输效率和性能。
  4. 安全性: HTTP协议的安全性较低,通常需要使用SSL协议来加密数据。而WebSocket协议可以在建立连接时使用SSL协议进行加密,从而保证传输的数据安全。

总的来说,WebSocket协议比HTTP协议更适合实时通信和数据传输。但是由于WebSocket协议相对于HTTP协议较新,支持程度和兼容性有时可能存在问题


说说ARP协议

ARP是根据IP地址获取其物理地址的协议

工作原理:

源主机在向目标主机发送IP包前,通过广播ARP请求包, 若源主机不知道目标主机的MAC地址,源主机就会广播一个ARP请求包,请求包中有目标主机的IP,以太网中的所有计算机都会接受到这个请求,而正常情况下只有目标主机会给出ARP应答包,包中就填充上了目标主机的MAC地址,并回复给源主机。源主机得到应答后将目标主机的MAC地址存入本机ARP高速缓存中以便下次使用


说说NAT协议
image-20230301183418866 image-20230301183448058
Session、Cookie

同:Cookie和Session都是客户端与服务器之间保持状态的解决方案

不同:

1)cookie机制采用的是在客户端保持状态的方案,而session机制采用的是在服务器端保持状态的方案。

2)Cookie有大小限制并且浏览器对每个站点也有cookie的个数限制,Session没有大小限制,理论上只与服务器的内存大小有关;

3)Cookie存在安全隐患,通过拦截或本地文件找得到cookie后可以进行攻击,而Session由于保存在服务器端,相对更加安全


⚠️国内访问不了谷歌的技术原因
  1. IP封锁:IP封锁是一种网络安全措施,用于阻止某个特定的IP地址或一组IP地址访问某个网站或网络资源。封锁可以通过防火墙、路由器或其他网络设备进行实现。

    IP封锁通常用于以下情况:

    防止恶意攻击或滥用服务:当网络管理员检测到某个IP地址正在进行恶意攻击,可以将该IP地址封锁以防止攻击者继续攻击

    要进行IP封锁,可以使用以下方法:

    1. 防火墙:网络管理员可以使用防火墙来封锁特定的IP地址。防火墙可以根据规则集或白名单/黑名单来进行配置,以阻止或允许特定的IP地址或IP地址范围的访问。
    2. 路由器:网络管理员可以使用路由器来封锁特定的IP地址。路由器可以使用访问控制列表(ACL)来限制特定的IP地址或IP地址范围的访问。
  2. DNS劫持:在中国,政府使用DNS劫持来屏蔽谷歌等国外网站。当用户输入被屏蔽的网站的域名时,政府会将其DNS解析请求重定向到另一个地址,通常是一个被政府控制的服务器,而不是实际的DNS服务器。这意味着用户将无法访问被屏蔽的网站,因为他们的计算机将连接到错误的服务器,而不是正确的目标网站。


⚠️国内可以通过什么技术手段访问谷歌

(简单来说 VPN就是更安全的正向代理)

  1. 虚拟私人网络(VPN):VPN 的工作原理是建立一个加密通道,在这个加密通道中,数据被加密并封装在一个特定的协议中,这个协议可以保证数据的完整性和机密性。可以避免网络上的拦截、监视、截获和篡改等安全问题
  2. 正向代理服务器:代理服务器是一种充当中间人的服务器,它会将用户的互联网请求转发到目标网站,从而帮助用户绕过封锁。用户可以在互联网上找到许多公开的代理服务器,或者使用自己的私人代理服务器,以访问被屏蔽的谷歌网站。

防火墙的原理

防火墙是一种网络安全设备,它可以监控和控制网络通信,以保护计算机网络免受恶意攻击和未经授权的访问。防火墙通常作为一个网络边界设备,位于内部网络和外部网络之间。

防火墙的原理基于路由器访问控制列表(ACL),它可以允许或拒绝网络流量通过特定端口和协议。当流量进入防火墙时,它会根据预定义的规则来决定是否允许流量通过。如果流量满足规则,则它会被允许通过防火墙,否则它会被阻止或丢弃。

总的来说,防火墙通过监控和控制网络流量来保护网络免受恶意攻击和未经授权的访问。


⚠️运营商给用户限速的类型和原理

带宽限制(限制用户每秒钟可以传输的数据量)、时间限制、流量限制、服务限制

限速的底层原理

  1. 在路由器上设置流量控制算法:运营商会在核心路由器或边缘路由器上设置流量控制算法,根据用户的套餐类型、用量等因素进行流量控制。
  2. 调度算法:运营商通过调度算法来控制网络资源的分配,以保证整个网络的公平性和平衡性。常见的调度算法包括最小带宽保证、公平队列调度、公平带宽分配
  3. 数据包标记:运营商还可以通过给数据包打上不同的标记,实现不同的服务质量级别。例如,将数据包标记为低优先级的,就会被放入低优先级队列中,从而减少其处理优先级,达到限速的效果。
  4. 限速设备:运营商在网络中还可以设置专门的限速设备,例如调度器、速率控制器等,通过对数据包进行筛选和处理,实现对用户网速的限制

⚠️常见的流量控制算法
  1. 漏桶(水漏的速度是接口的响应速率):水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率,(因为漏桶的漏出速率是固定的参数,所以,即使网络中不存在资源冲突(没有发生拥塞),漏桶算法也不能使流突发(burst)到端口速率.因此,漏桶算法对于存在突发特性的流量来说缺乏效率

  2. 令牌桶算法(加令牌的的速度是接口的响应速率):和漏桶效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定时间间隔往桶里加入Token(想象和漏洞漏水相反,有个水龙头在不断的加水),如果桶已经满了就不再加了,新请求来临时,会各自拿走一个Token,如果没有Token可拿了就阻塞或者拒绝服务。令牌桶的另外一个好处是可以方便的改变速度. 一旦需要提高速率,则按需提高放入桶中的令牌的速率. 一般会定时(比如100毫秒)往桶中增加一定数量的令牌, 有些变种算法则实时的计算应该增加的令牌的数量。

img

3️⃣数据库 5.21

存储引擎和存储结构

概述

存储引擎存储的是数据文件和索引文件

我们把索引文件的组织形式称为存储引擎的存储结构

image-20230408103355270 image-20230408103757831

索引组织表直接把数据记录存储在索引文件内部

堆组织表的数据文件是无序的,但我们可以通过索引文件来快速得到数据文件在堆里的位置

堆组织表的写入性能 > 索引组织表, 读取性能 < 索引组织表

但这种区分较小,远不如索引文件的的组织形式对性能的影响大


⚠️堆表和索引组织表

① 堆组织表,其索引中记录了记录所在位置 (文件号:页号:槽号),查找的时候先找索引,然后再根据索引找到块中的行数据。索引和表数据是分离的

② 索引组织表,其行数据以索引形式存放,因此找到索引,就等于找到了行数据。索引和数据是在一起的

堆表是一种数据库表的存储方式,它将新插入的行直接插入到表的末尾,而不像传统的B树索引那样插入到合适的位置。这使得插入操作更快,因为不需要寻找正确的位置进行插入,而是直接将数据追加到表的末尾,然后使用一个指针来指向该行。

HOT的优点是:

  1. 快速的插入操作:由于新数据不需要寻找合适的位置,直接追加到表末尾,因此插入操作速度很快。
  2. 避免索引分裂:当表中的数据通过插入或删除操作引起页分裂时,B树索引需要进行数据重组,而HOT则避免了这种情况的发生。
  3. 适合高并发:由于插入操作的快速性和避免索引分裂的特点,HOT适合于高并发的应用场景。

HOT的缺点是:

  1. 读取操作效率较低:由于数据是随机存储的,因此读取操作需要扫描整个表,效率较低。
  2. 不支持有序查询:HOT不支持有序的查询,因为数据是随机存储的,因此必须扫描整个表才能得到有序的结果。

⚠️聚集索引和非聚集索引的区别

聚集索引和非聚集索引的根本区别是表记录的排列顺序和与索引的排列顺序是否一致。

  • 聚集索引一个表只能有一个,而非聚集索引一个表可以存在多个

  • 聚集索引存储记录是物理上连续存在,而非聚集索引是逻辑上的连续,物理存储并不连续

  • 聚集索引:物理存储按照索引排序;

    非聚集索引:物理存储不按照索引排序;仅仅只是对数据列创建相应的索引,不影响整个表的物理存储顺序

  • 索引是通过B+树的数据结构来描述的,我们可以这么理解聚簇索引:索引的叶节点就是数据节点。而非聚簇索引的叶节点仍然是索引节点,指向对应的聚集索引/ 物理地址(有缓存的情况才有)。

image-20230415124213113

image-20230415124310204

需要注意的是,如果查询的列都在非聚集索引中,那么数据库就不需要回表操作,而可以直接从非聚集索引中获取需要的数据。这被称为“覆盖索引”,可以提高查询的性能,减少回表操作的开销。但是,覆盖索引的缺点是需要占用更多的存储空间


存储结构分类和发展

image-20230408105440294

2014年左右随着SSD固态硬盘的普及,LSM变得更流行,原因有二,一是SSD的随机读省去了在磁盘上的寻道时间,二是SSD由闪存实现,LSM特性保证了不用每次写入时擦除,延长了SSD使用寿命

存储结构的共性特点

image-20230408110247633

存储结构的锁与事物的锁有什么不同

image-20230408110921178

Latch在B+树中锁一个页,Lock锁一行,但是Lock不能省的原因就是可能对页的修改结束了,但事务还没结束

B树的变种

image-20230408111425752

LSM树结构图
image-20230408111639335

image-20230408112035495

目前主流的LSMTree结构图

image-20230408112241808

布隆过滤器(LSM中读取时快速判断key在不在该层中)

布隆过滤器的基本思想是使用多个哈希函数对元素进行哈希,并将哈希结果对应到一个位数组中的相应位置,用于表示该元素的存在或不存在。其实现步骤如下:

  1. 初始化一个位数组,将所有位都设置为0。
  2. 对于要加入集合的元素,使用多个不同的哈希函数对其进行哈希,得到多个哈希值。
  3. 将每个哈希值对应的位数组位置设置为1,表示该元素存在于集合中。

当要查询某个元素是否在集合中时,同样使用多个哈希函数对该元素进行哈希,得到多个哈希值。

判断每个哈希值对应的位数组位置是否都为1,如果存在任意一个位置为0,则表示该元素不在集合中,否则可能存在该元素(注意:可能存在)。可能存在误判,即一个元素可能被判断为存在于集合中,但实际上并不存在。

数据库理论


MySQL体系架构

网络连接层、服务层、存储引擎层和系统文件层

image-20230218115419272

一、网络连接层

客户端连接器:提供与MySQL服务器建立连接的支持。如 Java/C通过各自API与MySQL建立连接

二、服务层

服务层是MySQL Server的核心,主要包含六个部分

  • 系统管理和控制工具:例如备份恢复、安全管理、集群管理【集群就是指一组相互独立的计算机,利用网络组成的一个较大的计算机服务系统,每个集群节点(即每台计算机)都是运行各自服务的独立服务器。这些服务器之间可以彼此通信,协同向用户提供应用程序,系统资源和数据,并以单一系统的模式加以管理。当用户请求集群系统时,集群给用户的感觉就是一个单一独立的服务器,而实际上用户请求的是一组集群服务器】

  • 连接池:负责存储和管理客户端与数据库服务器的连接,一个线程负责管理一个连接

  • SQL接口:用于接收客户端发送的各种SQL命令,并且返回用户需要的查询结果

  • 解析器:负责检查请求的SQL语句的合法性

  • 查询优化器:当解析树通过语法检查后,将交由优化器将其转化为执行计划,然后与存储引擎交互

  • 缓存:缓存机制是由表缓存,记录缓存,权限缓存,引擎缓存组成。如果查询语句有命中的结果,则直接在查询缓冲中取数据

三、存储引擎层

负责MySQL中的数据存储和提取,与底层系统文件交互。服务器中的查询执行引擎通过接口和存储引擎进项通信,接口屏蔽了不同存储引擎的差异

四、系统文件层

负责将数据库的数据和日志存储在文件系统中,并完成与存储引擎的交互,是文件的物理存储层。主要包括日志文件,数据文件,配置文件,socket文件等


SQL语句运行机制

①建立连接:通过客户端/服务器通信协议与MySQL建立连接。MySQL 客户端与服务端的通信方式是 “ 半双工 ”。对于每一个 MySQL 的连接,时刻都有一个线程状态来标识这个连接正在做什么

②查询缓存:如果开启了查询缓存且在查询缓存过程中查询到完全相同的SQL语句,则将查询结果直接返回给客户端;如果没有开启查询缓存或者没有查询到SQL 语句则会由解析器进行语法语义解析

③解析:将客户端发送的SQL进行语法解析。

④查询优化:根据解析结果生成最优的执行计划。MySQL使用很多优化策略生成最优的执行计划,可以分为两类:静态优化(编译时优化)、动态优化(运行时优化)

⑤执行引擎执行 SQL 语句:此时执行引擎会根据 SQL 语句得到查询结果并返回给客户端。若开启用查询缓存,这时会将SQL 语句和结果完整地保存到查询缓存中


数据库事物特性(ACID)
  • 原子性(Atomicity)指一个事务是不可分割的单位,要么全部执行,要么全部回滚;
  • 一致性(Consistency)指一个事务在执行前和执行后都保持系统的一致性状态;
  • 隔离性(Isolation)指一个事务的执行不会影响其他事务的执行;
  • 持久性(Durability)指一个事务提交后,对数据的修改将永久保存在数据库中。

数据库并发一致性问题:丢读不幻

​ 丢 (丢失修改) 读(读脏数据) 不(不可重复读)【一次事务内的两次读数值不同】 幻(幻影读)

  • 丢失修改:事务1读取某表中的数据A=20,事务2也读取A=20,事务1修改A=A-1,事务2也修改A=A-1,最终结果A=19,事务1的修改被丢失
  • 读脏数据:当一个事务正在访问数据并且对数据进行了修改,而这种修改还没有提交到数据库中,这时另外一个事务也访问了这个数据,然后使用了这个数据。
  • 幻读与不可重复读类似。它发生在一个事务(T1)读取了几行数据,接着另一个并发事务(T2)插入了一些数据时。在随后的查询中,第一个事务(T1)就会发现多了一些原本不存在的记录,就好像发生了幻觉一样,所以称为幻读。

当前读和快照读

快照读:普通的 select 语句

它是基于多版本并发控制即 MVCC机制,快照读读到的数据不一定是当前最新的数据,有可能是之前历史版本的数据。

如下的操作是快照读:不加锁的 select 操作(前提是隔离级别不是串行化,串行化的是当前读)

当前读:能读到所有已经提交的记录的最新值

它读取的记录都是数据库中当前的最新版本,会对当前读取的数据进行加锁,防止其他事务修改数据,是一种悲观锁。

selectfor update # 当前读

⚠️两个事务并行提交一定会幻读吗

在RR(可重复读)隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的

因此,幻读在“当前读”下才会出现。

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 引入了新的锁,也就是间隙锁 (Gap Lock)

间隙锁就是,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体


说下mysql死锁

假设有一个名为“accounts”的表,其中包含两个字段“id”和“balance”,并且有两个事务在同时更新“accounts”表中的行

  • 事务1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
  • 事务2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;

要解决这个问题,可以在两个事务中按照相同的顺序获取锁定资源,例如,都先锁定id=1的行再锁定id=2的行。或者可以使用MySQL的死锁检测机制,自动选择其中一个事务回滚,以解除死锁


数据库锁类型
  1. 表级锁

(1)锁定粒度大,锁冲突概率高、并发度低

(2)好处是不会出现死锁、开销小、获取锁和释放锁的速度很快

(3)使用表级锁定的主要是MyISAM等一些非事务性存储引擎,适用于以查询为主,少量更新的应用

image-20230219191007830

意向锁:加意向锁的目的是为了表明某个事务正在锁定一行或者将要锁定一行。(防止另一个事务逐行判断是否加锁)是InnoDB自动加的,不需用户干预。意向锁不会与行级的读/写锁互斥

  1. 行级锁

(1)好处是锁定对象的颗粒度很小,发生锁冲突的概率低、并发度高;

(2)缺点是开销大、加锁慢,行级锁容易发生死锁;

(3)使用行级锁定的主要是InnoDB存储引擎。适用于对事务完整性要求较高的系统

InnoDB行级锁类型:读锁、写锁

  1. 页面锁

(1)介于行级锁和表级锁之间

(2)会发生死锁


关于锁的常见问题
  1. InnoDB存储引擎什么时候会锁住整张表,什么时候或只锁住一行呢?

只有通过索引条件查询数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁

  1. mysql读锁和写锁

用select 命令时触发读锁,当使用update,delete,insert时触发写锁,并且使用rollback或commit后解除本次锁定

  1. InnoDB行锁的3种算法:

Record Lock: 锁定单个索引记录

Gap Lock :锁定一个范围,但不包含记录本身

Next-Key Lock:锁定一个范围,并且锁定记录本身

所以 Next-KeyLocks = Gap锁+ Recordlock锁

Next-Key Locks是 MySQL 的 InnoDB 引擎的一种锁实现。MVCC (多版本的并发控制协议。最大的优点是读不加锁,因此读写不冲突,并发性能好)不能解决幻影读问题,Next-Key Locks 就是为了解决这个问题而存在的。在可重复读隔离级别下,使用 MVCC + Next-Key Locks 可以解决幻读问题。


数据库封锁协议
  1. 三级封锁协议(一级:写前加X 二级:写前加X读前加S,读完释放 三级:写前加X读前加,S锁事务结束释放)
  2. 两段锁协议(加锁与解锁串行)

数据库隔离级别:未 提 可 可

1、读未提交:顾名思义,就是一个事务可以读取另一个未提交事务的数据

2、读已提交:一个事务要等另一个事务提交后才能读取数据

3、可重复读: 在开始读取数据(事务开启)时,不再允许该行的修改操作

4、串行化:是最高的事务隔离级别,在该级别下,事务串行化顺序执行,可以避免脏读、不可重复读与幻读。但是这种事务隔离级别效率低下,一般不使用

并行性依次降低,安全性依次提高

image-20230129192103478


1NF 2NF 3NF BCNF

对于 A->B(A推出B),如果能找到 A 的真子集 A’,使得 A’-> B,那么 A->B 就是部分函数依赖,否则就是完全函数依赖

A->B: B依赖于A

第一范式(1NF):确保每个列都具有原子性,即每个列中都只包含单一的、不可分割的数据项

第二范式(2NF):在满足1NF的基础上,确保每个非主键列都完全依赖于全部主键,而不是仅依赖于主键的一部分

image-20230323230413593

image-20230323230607279

BCNF:设关系模式R是1NF。如果对于R的每个函数依赖X->Y,X必为候选键,则R是BCNF范式。


超键, 候选键, 主键

**超键(super key): **在关系中能唯一标识元组的属性集称为关系模式的超键
候选键(candidate key): 不含有多余属性的超键称为候选键
**主键(primary key): **用户选作元组标识的一个候选键


数据库索引
什么是索引?优缺点?

索引就一种特殊的查询表,数据库的搜索引擎可以利用它加速对数据的检索。

它很类似与现实生活中书的目录,不需要查询整本书内容就可以找到想要的 数据。索引可以是唯一的,创建索引允许指定单个列或者是多个列。

缺点是它减慢了数据录入的速度,同时也增加了数据库的尺寸大小


索引的类型

普通索引:

普通索引是mysql里最基本的索引,没有什么特殊性,在任何一列上都能进行创建

-- 创建索引的基本语法
CREATE INDEX indexName ON table(column);

主键索引

CREATE TABLE mytable (
id INT PRIMARY KEY,
age INT
);

复合索引(组合索引):

指的是我们在建立索引的时候使用多个字段,例如同时使用身份证和手机号建立索引

复合索引的使用复合最左原则。举个例子 我们使用 phone和name创建索引。

-- 创建索引的基本语法
CREATE INDEX indexName ON table(column1,column2);

我们看下面的查询语句,

SELECT * FROM user_innodb where name = '程冯冯';
SELECT * FROM user_innodb where phone = '15100046637';
SELECT * FROM user_innodb where phone = '15100046637' and name = '程冯冯';
SELECT * FROM user_innodb where name = '程冯冯' and phone = '15100046637';

三条sql只有 2 、 3、4能使用的到索引idx_phone_name,因为条件里面必须包含索引前面的字段才能够进行匹配。而3和4相比where条件的顺序不一样,为什么4可以用到索引呢?是因为mysql本身就有一层sql优化,他会根据sql来识别出来该用哪个索引,我们可以理解为3和4在mysql眼中是等价的。

全文索引:

全文索引主要用来查找文本中的关键字,而不是直接与索引中的值相比较。fulltext索引跟其它索引大不相同,它更像是一个搜索引擎,而不是简单的where语句的参数匹配。fulltext索引配合match against操作使用,而不是一般的where语句加like。

它可以在create table,alter table ,create index使用,不过目前只有char、varchar,text 列上可以创建全文索引。正常情况下我们也不会使用到全文索引,因为这不是mysql的专长。

空间索引:

空间索引是对空间数据类型的字段建立的索引,MYSQL中的空间数据类型有4种,分别是GEOMETRY、POINT、LINESTRING、POLYGON。MYSQL使用SPATIAL关键字进行扩展,使得能够用于创建正规索引类型的语法创建空间索引。

创建空间索引的列必须声明为NOT NULL,只能在存储引擎为MYISAM的表中创建。


如何创建及保存MySQL的索引?

在创建表的时候创建索引:

CREATE TABLE t1 (
id INT NOT NULL,
name CHAR(30) NOT NULL,
UNIQUE INDEX UniqIdx(id)
);

在已存在的表上创建索引:

# 在已经存在的表中创建索引,可以使用ALTER TABLE语句或者CREATE INDEX语句。
ALTER TABLE book ADD UNIQUE INDEX UniqidIdx (bookId);
CREATE UNIQUE INDEX UniqidIdx ON book (bookId);

如何判断索引有没有生效?

使用EXPLAIN语句, 假设已创建了book表, 并已经在其year_publication字段上建立了普通索引, 执行如下语句:

EXPLAIN SELECT * FROM book WHERE year_publication = 1990; 

EXPLAIN语句将为我们输出详细的SQL执行信息, 其中:

  • possible_keys行给出了MySQL在查询时可选用的各个索引。
  • key行是MySQL实际选用的索引。

如果possible_keys行和key行都包含year_publication字段,则说明在查询时使用了该索引。


EXPLAIN

MySQL中提供了EXPLAIN语句和DESCRIBE语句,用来分析查询语句,EXPLAIN语句的基本语法如下:

EXPLAIN [EXTENDED] SELECT select_options

使用EXTENED关键字,EXPLAIN语句将产生附加信息。执行该语句,可以分析EXPLAIN后面SELECT语句的执行情况,并且能够分析出所查询表的一些特征。下面对查询结果进行解释:

  • id:SELECT识别符。这是SELECT的查询序列号。
  • select_type:表示SELECT语句的类型。
  • table:表示查询的表。
  • type:表示表的连接类型。
  • possible_keys:给出了MySQL在搜索数据记录时可选用的各个索引。
  • key:是MySQL实际选用的索引。
  • key_len:给出索引按字节计算的长度,key_len数值越小,表示越快。
  • ref:给出了关联关系中另一个数据表里的数据列名。
  • rows:是MySQL在执行这个查询时预计会从这个数据表里读出的数据行的个数。
  • Extra:提供了与关联操作有关的信息。

扩展阅读

DESCRIBE语句的使用方法与EXPLAIN语句是一样的,分析结果也是一样的,DESCRIBE语句的语法形式如下:

DESCRIBE SELECT select_options

索引优化
  1. 优化索引的列宽:索引列的宽度越小,查询效率越高。因此,在创建索引时应该尽可能使用较短的列。

  2. 多列索引:要注意索引列的顺序,让选择性最强的索引列放在前面

  3. 前缀索引:例如下面 SQL 语句不能使用索引。select * from doc where title like ‘%XX’

​ 而非前导模糊查询则可以使用索引,如下面的 SQL 语句。select * from doc where title like ‘XX%’

  1. 覆盖索引:如果有一个查询包含了所有索引的列,我们称之为覆盖索引

应该建立索引的条件
  1. 在经常使用在 WHERE 子句中的列上面创建索引
  2. 在经常用在连接的列上, 这些列主要是一些外键
  3. 在经常需要根据范围进行搜索的列上创建索引
  4. 在经常需要排序的列上创建索引

索引的优缺点

索引的优点

  1. 通过创建唯一性索引, 可以保证数据库表中每一行数据的唯一性。
  2. 可以大大加快数据的检索速度
  3. 可以加速表和表之间的连接
  4. 加快分组和排序

索引的缺点

  1. 索引有可能降低查询性能,带来磁盘的开销和处理开销等
  2. 太多的索引,让设计不稳定
  3. 不便维护
  4. 数据修改需求>检索需求时,索引会降低性能

索引的最左前缀问题

在MySQL建立联合索引时会遵守最左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。


sql优化
  1. 对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。
  2. 应尽量避免在 where 子句中使用!=或<>操作符,否则引擎放弃使用索引而进行全表扫描。
  3. 下面的查询也将导致全表扫描:select id from t where name like ‘%abc%’
  4. in 和 not in 也要慎用,否则会导致全表扫描,如:select id from t where num in(1,2,3)
  5. 不要在 where 子句中的“=”左边进行函数、算术运算或其他表达式运算,否则系统将可能无法正确使用索引2
  6. 多使用LIMIT 避免使用SELECT *

InnoDB和MyISAM
  • 事务:InnoDB 是事务型的,可以使用 Commit 和 Rollback 语句。
  • 并发:MyISAM 只支持表级锁,而 InnoDB 还支持行级锁。
  • 外键:InnoDB 支持外键。
  • 备份:InnoDB 支持在线热备份。
  • 崩溃恢复:MyISAM 崩溃后发生损坏的概率比 InnoDB 高很多,而且恢复的速度也更慢。
  • 其它特性:MyISAM 支持压缩表和空间数据索引。

⚠️InnoDB体系结构

innodb的体系架构由多个内存块组成的缓冲池及多个后台线程构成

缓冲池缓存磁盘数据(解决cpu速度和磁盘速度的严重不匹配问题)

后台进程保证缓存池和磁盘数据的一致性(读取、刷新),并保证数据异常宕机时能恢复到正常状态

缓冲池主要分为三个部分:redo log buffer、innodb_buffer_pool、innodb_additional_mem_pool。

  • innodb_buffer_pool由包含数据、索引、insert buffer ,adaptive hash index,lock 信息及数据字典。
  • redo log buffer用来缓存重做日志
  • additional memory pool:用来缓存LRU链表、等待、锁等数据结构

后台进程分为:master thread,IO thread,purge thread,page cleaner thread

  • master thread负责刷新缓存数据到磁盘并协调调度其它后台进程
  • IO thread 分为 insert buffer、log、read、write进程。分别用来处理insert buffer、重做日志、读写请求的IO回调
  • purge thread用来回收undo 页
  • page cleaner thread用来刷新脏页

master thread根据服务器的压力分为了每一秒及每十秒的操作。每一秒的操作包括:刷新重做日志、根据过去一秒的磁盘吞吐量来判断是否需要merge insert buffer、根据脏页在缓冲池中占比是否超过最大脏页占比及是否开启自适应刷新来刷新脏页。每十秒的操作包括:根据过去10秒的磁盘吞吐量来刷新脏页,刷新重做日志,回收undo 页,再根据脏页占比是否超过70%刷新定量脏页

image-20230218122345750
⚠️说说 InnoDB 的 MVCC

InnoDB默认的隔离级别是RR,RR解决脏读、不可重复读、幻读等问题,使用的是MVCC。

MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。它最大的优点是读不加锁,读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:

  1. 隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log(可以实现事务的回滚操作)的指针
  2. 基于undo log的版本链:每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,形成一条版本链
  3. ReadView:用于确定事务能够看到哪些行版本。当一个事务开始时,它会创建一个ReadView,该ReadView会记录事务开始时所有活跃事务的事务ID以及它们创建或删除每行数据的版本号。如果该行某版本的事务ID <= ReadView中的最大事务ID,则该版本对于当前事务可见

Mysql数据类型

整数类型:TINY/SMALL/MEDIUM INT、INT、BIGINT

实数类型:FLOAT、DOUBLE、DECIMAL

字符串类型:CHAR、VARCHAR、TEXT、BLOB

枚举类型:ENUM

日期与时间:YEAR、TIME、DATE、DATETIME、TIMESTAMP


⚠️主从复制

主要涉及三个线程:binlog 线程、I/O 线程和 SQL 线程。

  • binlog 线程 :负责将主服务器上的数据更改写入二进制日志(Binary log)中。
  • I/O 线程 :负责从主服务器上读取二进制日志,并写入从服务器的中继日志(Relay log)。
  • SQL 线程 :负责读取中继日志,解析出主服务器已经执行的数据更改并在从服务器中重放(Replay)。

主从复制的优点:

读写分离: 主服务器处理写操作以及实时性要求比较高的读操作,而从服务器处理读操作。

读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用;
  • 从服务器可以使用 MyISAM,提升查询性能以及节约系统开销;
  • 增加冗余,提高可用性。

关系型数据库和非关系型数据库

关系型

主要是指创建在关系模型上的数据库,借助于集合代数数学概念和方法来处理数据库中的数据。

由关系数据结构、操作集合、完整性约束三部分组成

—优点—

1.事务处理—保持数据的一致性 2.可以进行Join等复杂查询 3.基于严格的数学

—缺点—

1.性能 2.纵向扩展 3.贵

非关系型

—优点—

  1. 简单的扩展(集群)

  2. 高性能(它们可以处理超大量的数据):主要例子有Redis,由于其逻辑简单,而且纯内存操作,使得其性能非常出色,单节点每秒可以处理超过10万次读写操作;

  3. 低廉的成本:这是大多数分布式数据库共有的特点,因为主要都是开源软件,没有昂贵的License成本;

  4. 灵活的数据模型。不需要事先对存储数据建立字段。

—缺点—

  1. 不支持SQL的工业标准,将会对用户产生一定的学习和应用迁移成本;
  2. 不支持事务,很难保证数据一致性
  3. 不能支持比较复杂的查询
  4. NoSQL 并不完全安全稳定,由于它基于内存,一旦停电或者机器故障数据就很容易丢失数据,其持久化能力也是有限的,而基于磁盘的数据库则不会出现这样的问题

数据库完整性

实体完整性:指表中行记录的非空、唯一且不重复

域完整性:指表中的列必须满足某种数据类型或约束。CHECK、FOREIGN KEY 约束和DEFAULT、 NOT NULL都属于域完整性的范畴

参照完整性:修改表,与之相关联的表也随之改变 不一致的处理方法:拒绝执行 、级联操作 、设置为空

CREATE TABLE customers (
customer_id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
PRIMARY KEY (customer_id)
);

CREATE TABLE orders (
order_id INT NOT NULL AUTO_INCREMENT,
customer_id INT NOT NULL,
PRIMARY KEY (order_id),
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
);

这个外键约束确保了数据的完整性,因为它防止在orders表中插入一个不存在于customers表中的顾客ID。


对表的外键构建索引的好处

为确保安全,在对主表操作时,需要对参照表进行加锁操作。如果外键没有索引,查找子记录就会很慢,引起全表扫描。且参照表被锁的时间很长,进而可能发生死锁。

image-20230409231715303

注:联合主键:就是用多个字段一起作为一张表的主键。


一个好的模式分解具有哪些性质

无损连接和保持依赖

无损连接指的是对关系分解时,原关系模型中任意列能通过自然联接运算恢复起来。

保持函数依赖指的是对关系分解时,原关系的闭包与分解后关系闭包的并集相等。


视图的作用

视图是从几个表或视图导出的表。是一个虚表。

数据库中只存放视图的定义,而不存放视图对应的数据。

视图就像一个窗口,透过它可以看到数据库中自己感兴趣的数据及其变化


⚠️MySQL分区分表

MySQL分区是将一个大表分解成更小、更易于管理的部分的过程。分区可以提高查询性能、减少锁定冲突和减轻磁盘I/O负载。分区包括水平分区和垂直分区。水平分区将表中的行分成不同的分区,而垂直分区将表中的列分成不同的分区。

MySQL分表是将一个大表拆分成多个小表的过程。这种做法可以解决单表数据量过大、索引效率低下的问题。分表一般按照某个条件(例如日期、区域、用户ID等)将数据分散到不同的表中。

下面是MySQL进行分区分表的步骤:

  1. 创建分区表:使用CREATE TABLE语句创建分区表。
  2. 选择分区键:选择一个或多个用于分区的列。
  3. 定义分区:使用PARTITION BY子句指定如何将表分区,例如按照日期或地理位置进行分区。
  4. 分配数据:将数据插入分区表中。MySQL会根据分区键的值将数据插入正确的分区。
  5. 管理分区:可以使用ALTER TABLE语句管理分区,例如添加或删除分区。
CREATE TABLE sales (
id INT NOT NULL AUTO_INCREMENT,
region VARCHAR(20),
country VARCHAR(20),
product VARCHAR(50),
date DATE,
amount INT,
PRIMARY KEY (id, date)
) PARTITION BY RANGE(YEAR(date)) (
PARTITION p0 VALUES LESS THAN (2015),
PARTITION p1 VALUES LESS THAN (2016),
PARTITION p2 VALUES LESS THAN (2017),
PARTITION p3 VALUES LESS THAN (2018),
PARTITION p4 VALUES LESS THAN (2019),
PARTITION p5 VALUES LESS THAN (2020),
PARTITION p6 VALUES LESS THAN (2021),
PARTITION p7 VALUES LESS THAN (2022)
);
# 上述代码创建了一个名为sales的分区表,包含六个列,其中id和date列被指定为主键。分区键使用了date列,按照年份进行分区。一共定义了8个分区,每个分区包含小于指定年份的数据。

# 将数据插入分区表中,MySQL会根据分区键的值将数据插入正确的分区,例如:
INSERT INTO sales (region, country, product, date, amount)
VALUES ('Asia', 'China', 'Phone', '2018-01-01', 1000),
('Asia', 'Japan', 'Laptop', '2019-02-01', 2000),
('Europe', 'Germany', 'TV', '2020-03-01', 3000),
('North America', 'USA', 'Tablet', '2021-04-01', 4000),
('South America', 'Brazil', 'Phone', '2022-05-01', 5000);
# 上述代码将五条数据插入到sales表中,MySQL会将它们分别插入到不同的分区中。

# 可以使用ALTER TABLE语句管理分区,例如添加或删除分区。以下是添加新分区的示例代码:
ALTER TABLE sales ADD PARTITION (
PARTITION p8 VALUES LESS THAN (2023)
);
# 上述代码向sales表添加了一个新分区p8,包含小于2023年的数据。

delete、drop、 truncate区别
  • truncate和delete只删除数据,不删除表结构;drop删除表结构
  • 删除数据的速度: drop > truncate > delete
  • delete属于DML语言,需要事务管理,commit之后才能生效; drop 和truncate属于DDL语言,操作立刻生效,不可回滚
  • 使用场合:不再需要表时使用drop语句;保留表删除所有记录用truncate语句;删除部分记录用delete语句

exists和in的区别

下面将主查询的表称为外表;子查询的表称为内表。exists与in的主要区别如下:

  • 使用exists, 会先进行主查询,将查询到的每行数据循环带入子查询校验是否存在,过滤出整体的返回数据; in,会先进行子查询获取结果集,然后主查询匹配子查询的结果集,返回数据

  • 内表大,用exists 效率较高;内表小,用in效率较高。

  • not exists的效率一般要高于not in


说说触发器

触发器是指一段代码,当触发某个事件时,自动执行这些代码

MySQL数据库中有六种触发器:

  • Before Insert

  • After Insert

  • Before Update

  • After Update

  • Before Delete

  • After Delete


⚠️数据库(DDL,DML,DQL、DCL)
  1. 数据查询语言DQL:SELECT…FROM…WHERE

  2. 数据管理语言DML [需事务管理]:INSERT、UPDATE、DELETE

  3. 数据定义语言DDL [不需事务管理]

    创建数据库中的各种对象—–表、视图、索引等 如:CREATE TABLE(表)/VIEW(视图)/INDEX(索引)

  4. 数据控制语言DCL:GRANT / ROLLBACK / COMMIT


最左匹配原则

使用关联多列索引时,跳过左边的右边的全部失效

例如:建立一个组合索引(a,b,c),写了查询条件where a = 1 and c = 3,索引a是最左边的,c是最右边的,而这里只写了a和c的条件,跳过了b,那b右边的c虽然写了条件c=3但是查询的时候也用不上

范围条件右边失效

例如:建立一个组合索引(a,b,c),写了查询条件where a = 1 and b > 2 and c = 3,b是个范围条件,那么索引智能用到a和b,c是范围条件右边的内容,索引用不到

注意:a = 1 and b > 2 and c = 3和a = 1 and c = 3 and b > 2是一样的,a、b、c的顺序不是写sql条件时的顺序,而是建立索引时的顺序

模糊查询like ‘%’在左边时失效

例如:条件where name like ‘%a’,这里name这个索引时用不上的

原理:

image-20230220214904180
B树/B+树
image-20230217182905354 image-20230217182934695

不同点:

  1. b树的叶子节点没有指针,b+树有,有指针可以更加方便范围查询,同一种范围查询,b树可能得多次从头节点开始遍历;
  2. b树非叶子节点也存放数据,但是b+树只有叶子节点存放数据;
  3. 存放同样的数据,b树的高度可能比b+树要高。
  • B+树在B树中做了一个优化,因为每个磁盘块的大小都是有限的,如果在每个非叶子节点处都存放数据,那么每次获取到的磁盘块上的索引指针信息以及关键字信息将会很少,这样会增加我们的IO次数以及树结构的深度
  • B+树只在每个非叶子节点处只存放指针以及关键字信息,这样最大化的增加每个磁盘块存放的索引信息,可以更加有效的获取出相对应的地址信息,从而也降低了树结构的深度,而且叶子顶部节点允许互链减少了重新IO的次数
  • MYSQL引擎InnoDB就是按这种方式存放数据,存储引擎在设计时是将根节点常驻内存的,也就是说查找某一键值的行记录时最多只需要1~3次磁盘I/O操作

数据库为什么不用红黑树而用 B+ 树

首先,红黑树是一种近似平衡二叉树(不完全平衡),结点非黑即红的树,它的树高最高不会超过 2*log(n),因此查找的时间复杂度为 O(log(n)),无论是增删改查,它的性能都十分稳定;

但是,红黑树本质还是二叉树,在数据量非常大时,需要访问+判断的节点数还是会比较多,同时数据是存在磁盘上的,访问需要进行磁盘IO,导致效率较低; 而B+树是多叉的,可以有效减少磁盘IO次数;同时B+树增加了叶子结点间的连接,能保证范围查询时找到起点和终点后快速取出需要的数据


红黑树
image-20230330220200112
  1. 根节点是黑色。
  2. 每个叶子节点都是黑色的空节点(NIL节点)。
  3. 如果一个节点是红色的,则它的子节点必须是黑色的。
  4. 从任意一个节点到其子树中每个叶子节点的路径上包含相同数量的黑色节点(称为黑色平衡或黑高度)

红黑树插入删除:链接


各种连接

内连接:只连接匹配的行

等值连接:

select A.c1,B.c2 from A join B on A.c3 = B.c3;

theta连接:使用等值以外的条件来匹配左、右两个表中的行

select A.c1,B.c2 from A join B on A.c3 != B.c3;

内连接 = theta连接 or 等值连接

自然连接:是一种特殊的等值连接,它要求两个关系中进行比较的分量必须是相同的属性组,并且在结果中把重复的属性列去掉。而等值连接并不去掉重复的属性列。

左外连接:包含左边表的全部行(不管右边的表中是否存在与它们匹配的行)以及右边表中全部匹配的行

select A.c1,B.c2 from A left join B on A.c3 = B.c3;

右外连接:包含右边表的全部行(不管左边的表中是否存在与它们匹配的行)以及左边表中全部匹配的行

select A.c1,B.c2 from A right join B on A.c3 = B.c3;

全外连接:包含左、右两个表的全部行,不管在另一边的表中是否存在与它们匹配的行

select A.c1,B.c2 from A full join B on A.c3 = B.c3;

交叉连接:生成笛卡尔积——它不使用任何匹配或者选取条件,而是直接将一个数据源中的每个行与另一个数据源的每个行一一匹配

select A.c1,B.c2 from A,B;


数据库连接池优点

①资源重用

由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。

②更快的系统响应速度

数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池内备用。对于业务请求处理而言,直接利用现有连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。

③强制收回被占用的连接,避免数据库连接泄露


MySQL的慢查询优化有了解吗?

优化MySQL的慢查询,可以按照如下步骤进行:

开启慢查询日志:在MySQL服务启动的时候使用--log-slow-queries[=file_name]启动慢查询日志。

启动慢查询日志时,需要在my.ini或者my.cnf文件中配置long_query_time选项指定记录阈值,如果某条查询语句的查询时间超过了这个值,这个查询过程将被记录到慢查询日志文件中。

分析慢查询日志:

直接分析mysql慢查询日志,利用explain关键字可以模拟优化器执行SQL查询语句,来分析sql慢查询语句。

常见慢查询优化:

  1. 索引没起作用的情况

    • 在使用LIKE关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引不会起作用。只有“%”不在第一个位置,索引才会起作用。
    • 复合索引要注意最左匹配原则
    • 若查询语句的查询条件中只有OR关键字,则当OR前后的两个条件中的列都是索引时,查询中才使用索引
  2. 优化数据库结构

    • 对于列比较多的表,如果有些列的使用频率很低,可以将这些字段分离出来形成新表。因为当一个表的数据量很大时,会由于使用频率低的字段的存在而变慢。
    • 对于需要经常联合查询的表,可以建立中间表以提高查询效率。通过建立中间表,把需要经常联合查询的数据插入到中间表中,然后将原来的联合查询改为对中间表的查询,以此来提高查询效率
  3. 优化LIMIT分页

    当偏移量非常大的时候,例如可能是limit 10000,20这样的查询,这是mysql需要查询10020条然后只返回最后20条,前面的10000条记录都将被舍弃,这样的代价很高。

    用 between 优化 SELECT * FROM t_topic WHERE id BETWEEN 10000 AND 10020;


⚠️Redo log, Undo log和Binlog

都是数据库中常见的日志文件,但是它们在功能和使用方面有一些区别。

  1. Redo Log(重做日志):它用于记录在事务执行过程中所做的更改操作,以便在数据库崩溃或重新启动时能够恢复未完成的事务。Redo Log是在内存中记录的,当事务提交时,将其刷入磁盘
  2. Undo Log(撤销日志):它记录了在事务执行过程中所做的更改操作的相反操作,以便在回滚事务时可以撤销这些更改操作。Undo Log是在磁盘上记录的
  3. Binlog(二进制日志):它记录了所有对数据库的更改操作,包括DDL和DML语句。Binlog是在磁盘上记录的,可以用于数据库的备份、复制和恢复等操作
  • Redo Log和Undo Log是在数据库的服务器端生成的,而Binlog可以在任何MySQL客户端上生成。

常见的数据库

KV:Redis、LevelDB、RocksDB

关系型:SQLite、SQL Server、Oracle


❤️什么是消息队列

消息队列(Message Queue)是一种通信模式,用于在不同组件、系统或服务之间传递消息。它是一种异步通信机制,允许发送者将消息发送到队列中,而不需要显式地等待接收者的响应。接收者可以在适当的时候从队列中获取消息,并进行处理。

消息队列的基本原理是,发送者将消息发送到队列中,然后接收者从队列中获取消息进行处理。消息队列可以在不同的时间和速率下处理消息的发送和接收,因此发送者和接收者可以解耦,彼此不会直接依赖或受制于对方。

消息队列的好处包括:

  1. 异步通信:发送者和接收者之间的通信是异步的,发送者不需要等待接收者的响应即可继续执行其他任务。
  2. 解耦性:通过消息队列,发送者和接收者可以彼此解耦。它们不需要直接知道对方的存在或实现细节,只需关注消息的发送和接收。
  3. 缓冲能力:消息队列可以作为缓冲区,即使发送者和接收者之间的处理速度不同,也能确保消息的安全传递和存储。
  4. 可靠性:消息队列通常提供可靠的消息传递机制,确保消息不会丢失,并且可以处理故障情况。
  5. 扩展性:消息队列可以用于构建分布式系统,并支持水平扩展。多个发送者和接收者可以连接到同一个消息队列,从而实现系统的扩展性。

消息队列在软件开发和系统架构中广泛应用,用于处理异步任务、解耦系统组件、平衡负载和构建可靠的分布式系统等方面。常见的消息队列系统包括RabbitMQ、Apache Kafka、ActiveMQ等。

❤️Redis的几种应用场景
  1. 缓存 :Redis的高性能使其成为一个非常有效的缓存解决方案。它可以将热门的数据存储在内存中,以加快数据访问速度。同时,Redis提供了多种数据结构,如哈希表和有序集合等,可以方便地存储和访问不同类型的数据。
  2. 会话管理 :在Web应用程序中,会话管理是一项重要的任务。Redis可以作为分布式会话存储,将会话数据存储在内存中,以提高速度和可扩展性。通过使用Redis,可以轻松地跨多个应用程序实例共享会话数据。
  3. 消息队列 :Redis的发布/订阅模式和列表数据结构可以很方便地用作消息队列。发布者将消息发布到一个通道,订阅者可以从该通道接收消息并进行处理。此外,Redis还支持各种高级队列操作,如阻塞队列和优先级队列等。
  4. 计数器和排行榜 :Redis提供了对计数器和排行榜的原生支持,这使得它成为构建实时统计和排名应用程序的理想解决方案。例如,可以使用Redis轻松地实现网站访问量计数器或者实时排行榜功能。

4️⃣设计模式 5.21

六种关系

依赖

依赖关系是在运行过程中起作用的,就是一个类A运行时使用到了另一个类B,而这种使用关系是临时性的、非常弱的

  • A 类是 B 类中的(某种方法的)局部变量;
  • A 类是 B 类方法当中的一个形参;
在这里插入图片描述

关联

是一种静态关系,在运行前就可以确定;体现的是两个类、或者类与接口之间一种强依赖关系。一般是长期性的,而且双方的关系一般是平等的

  • 类B以类属性的形式出现在关联类A中

在这里插入图片描述

聚合

但是公司和员工就属于聚合关系了,因为公司没了员工还在

在这里插入图片描述

组合

组合中整体和部分是强依赖的,整体不存在了部分也不存在了。比如公司和部门,公司没了部门就不存在了

img

继承(extend)

在这里插入图片描述

实现(implement)

在这里插入图片描述

面向对象设计原则

image-20230129223341112


单例模式

其主要作用是确保一个类仅有一个实例,并提供一个全局访问点来访问该实例

懒汉式:

#include <iostream> 
#include <mutex>
using namespace std;

class singleInstance{
public:
static singleInstance* GetsingleInstance(){
mutex mu;//mutex mlock; 加锁互斥
if (instance == NULL){
mu.lock();
if (instance == NULL) instance = new singleInstance();
mu.unlock();
}
return instance;
};
~singleInstance(){};
private:// 涉及创建对象的函数都设置为private
singleInstance(){}; // 它拥有一个私有构造函数,这确保用户无法通过new直接实例它
singleInstance(const singleInstance& other){};
singleInstance& operator=(const singleInstance& other){ return *this; };
static singleInstance* instance;
};

singleInstance* singleInstance::instance = nullptr;
int main(){
// 因为没有办法创建对象,就得采用静态成员函数的方法返回静态成员变量
singleInstance *s = singleInstance::GetsingleInstance();
//singleInstance *s1 = new singleInstance(); // 报错
return 0;
}

饿汉式:

#include <iostream> #include <pthread.h>
using namespace std;

class singleInstance{
public:
static singleInstance* GetsingleInstance(){
// 饿汉式,直接创建一个对象,不需要加锁
static singleInstance instance;
return &instance;
};
~singleInstance(){};
private:// 涉及创建对象的函数都设置为private
singleInstance(){};
singleInstance(const singleInstance& other){};
singleInstance& operator=(const singleInstance& other){ return *this; };
};

int main(){
// 因为没有办法创建对象,就得采用静态成员函数的方法返回
singleInstance *s = singleInstance::GetsingleInstance();
//singleInstance *s1 = new singleInstance(); // 报错
return 0;
}

单例模式多线程是不安全的

  • 解决方法加mutex

image-20230221143214363


观察者模式

  1. 是一种对象行为模式。它定义对象间的一种一对多的关系,当一个对象的状态发生改变时,所有它关联的对象都得到通知并被自动更新
  2. 在观察者模式中,Subject是通知的发布者,发出通知时并不需要知道谁是他的观察者,可有任意数目的Observer订阅并接收通知。
  3. 观察者模式将观察者和被观察的对象分离开。在模块之间划定了清晰的界限,提高了应用程序的可维护性和重用性。

数据库和消息队列系统:在分布式系统中,观察者模式可以用于实现数据的订阅和同步。当数据库中的数据发生变化时,观察者可以更新本地缓存或者发送消息到消息队列。

日志记录系统:日志作为subject,观察者订阅日志事件。当系统产生新的日志事件时,观察者可以接收到通知并将日志记录到文件或者发送到远程服务器。

#include <iostream>
#include <vector>

class Observer {
public:
virtual void update() = 0;
};

class Subject {
public:
void addObserver(Observer* obs) { observers.push_back(obs);}

void removeObserver(Observer* obs) {
for (auto it = observers.begin(); it != observers.end(); ++it)
if (*it == obs) observers.erase(it), break;
}

void notifyObservers() {
for (auto obs : observers)
obs -> update();
}

private:
vector<Observer*> observers;
};

class ConcreteObserver : public Observer {
public:
void update() override {
cout << "ConcreteObserver received update." << endl;
}
};

class ConcreteSubject : public Subject {
public:
void doSomething() {
cout << "ConcreteSubject is doing something." << endl;
notifyObservers();
}
};

int main() {
ConcreteSubject subject;
ConcreteObserver observer1, observer2;

subject.addObserver(&observer1);
subject.addObserver(&observer2);

subject.doSomething(); // ConcreteObserver received update. ConcreteObserver received update.
subject.removeObserver(&observer2);
subject.doSomething(); // ConcreteObserver received update.

return 0;
}

请说说工厂设计模式

工厂模式属于创建型模式,大致可以分为三类,简单工厂模式、工厂方法模式、抽象工厂模式

简单工厂模式

它的主要特点是需要在工厂类中做判断,从而创造相应的产品。当增加新的产品时,就需要修改工厂类

img
enum CTYPE {COREA, COREB};     
class SingleCore{
public:
virtual void Show() = 0;
};
//单核A
class SingleCoreA: public SingleCore {
public:
void Show() { cout<<"SingleCore A"<<endl; }
};
//单核B
class SingleCoreB: public SingleCore {
public:
void Show() { cout<<"SingleCore B"<<endl; }
};
//唯一的工厂,可以生产两种型号的处理器核,在内部判断
class Factory{
public:
SingleCore* CreateSingleCore(enum CTYPE ctype){
if(ctype == COREA) //工厂内部判断
return new SingleCoreA(); //生产核A
else if(ctype == COREB)
return new SingleCoreB(); //生产核B
else return NULL;
}
};

优点: 简单工厂模式可以根据需求,动态生成使用者所需类的对象

缺点:就是要增加新的核类型时,就需要修改工厂类。这就违反了开闭原则

工厂方法模式

所谓工厂方法模式, 是指定义一个用于创建对象的接口, 让子类决定实例化哪一个类。Factory Method使一个类的实例化延迟到其子类

img

举例:这家生产处理器核的产家决定再开设一个工厂专门用来生产B型号的单核,而原来的工厂专门用来生产A型号的单核。这时,客户要做的是找好工厂,比如要A型号的核,就找A工厂要;否则找B工厂要,不再需要告诉工厂具体要什么型号的处理器核了

class SingleCore{    
public:
virtual void Show() = 0;
};
//单核A
class SingleCoreA: public SingleCore{
public:
void Show() { cout<<"SingleCore A"<<endl; }
};
//单核B
class SingleCoreB: public SingleCore{
public:
void Show() { cout<<"SingleCore B"<<endl; }
};
class Factory{
public:
virtual SingleCore* CreateSingleCore() = 0;
};
//生产A核的工厂
class FactoryA: public Factory{
public:
SingleCoreA* CreateSingleCore() { return new SingleCoreA; }
};
//生产B核的工厂
class FactoryB: public Factory{
public:
SingleCoreB* CreateSingleCore() { return new SingleCoreB; }
};

优点: 扩展性好,符合了开闭原则,新增一种产品时,只需增加改对应的产品类和对应的工厂子类即可。

缺点:显然,相比简单工厂模式,工厂方法模式需要更多的类定义

抽象工厂模式
image-20221116164855060

举例:这家公司的技术不断进步,不仅可以生产单核处理器,也能生产多核处理器。现在简单工厂模式和工厂方法模式都鞭长莫及。抽象工厂模式登场了。具体这样应用,这家公司还是开设两个工厂,一个专门用来生产A型号的单核多核处理器,而另一个工厂专门用来生产B型号的单核多核处理器

//单核    
class SingleCore{
public:
virtual void Show() = 0;
};
class SingleCoreA: public SingleCore{
public:
void Show() { cout<<"Single Core A"<<endl; }
};
class SingleCoreB :public SingleCore{
public:
void Show() { cout<<"Single Core B"<<endl; }
};
//多核
class MultiCore{
public:
virtual void Show() = 0;
};
class MultiCoreA : public MultiCore{
public:
void Show() { cout<<"Multi Core A"<<endl; }
};
class MultiCoreB : public MultiCore{
public:
void Show() { cout<<"Multi Core B"<<endl; }
};
//工厂
class CoreFactory{
public:
virtual SingleCore* CreateSingleCore() = 0;
virtual MultiCore* CreateMultiCore() = 0;
};
//工厂A,专门用来生产A型号的处理器
class FactoryA :public CoreFactory{
public:
SingleCore* CreateSingleCore() { return new SingleCoreA(); }
MultiCore* CreateMultiCore() { return new MultiCoreA(); }
};
//工厂B,专门用来生产B型号的处理器
class FactoryB : public CoreFactory{
public:
SingleCore* CreateSingleCore() { return new SingleCoreB(); }
MultiCore* CreateMultiCore() { return new MultiCoreB(); }
};
⭐️三种工厂模式的区别

简单工厂 :用来生产同一产品族中的任意产品(对于增加新的产品,无能为力)

工厂方法 :用来生产同一产品族中的任意产品(对于增加新的产品)

抽象工厂 :产品按产品族(手机,电脑)抽象,工厂就按品牌分【增加品牌就符合开闭原则】;产品按品牌抽象,工厂就按产品族分【增加产品族就符合开闭原则】;




5️⃣项目相关

面试官您好,我是孔剑刚,本科毕业于大连理工大学,目前就读于南京大学软工专业。荣耀实习,我曾参加过一些项目开发,包括课程设计和个人项目我曾分别用JAVA和Damg写过简单的管理系统,也使用GO语言开发了一个简易的分布式(数据库。最近完成的项目是linux环境下使用C++开发的高并发HTTP服务器。语言方面我对C++的了解最为深人和系统,能够熟练掌握STL库、多线程编程,了解C++内存分配和指针。在项目和学习中遇到问题后,我能够快速定位问题所在,并采取有效的解快方案,并且愿意向他人学习和分享自己的知识和经验。在去年8月份左右,我开始写个人博客,从力扣题解到语言框架和项目bug,我都有完整地记录下来,对我的帮助很大。总之,我对编程充满热情,并希望能够在实践中提高自己的技能,也非常希望能够加人贵公司,贡献自己的力量。

webserver

介绍一下项目

此项目是基于Linux的轻量级多线程Web服务器,应用层实现了一个简单的HTTP服务器,利用多路IO复用,可以同时监听多个请求,使用线程池处理请求,使用模拟proactor模式,主线程负责监听,监听有事件之后,从socket中循环读取数据,然后将读取到的数据封装成一个请求对象放入队列。睡眠在请求队列上的工作线程被唤醒进行处理,使用状态机解析HTTP请求报文,将响应报文和资源文件写回通信的socket,并对系统进行了压力测试

// 由线程池中的工作线程调用,这是处理HTTP请求的入口函数   process() = process_read() + process_write()
void http_conn::process() {
// 解析HTTP请求
HTTP_CODE read_ret = process_read();
if ( read_ret == NO_REQUEST ) {
modfd(m_epollfd, m_sockfd, EPOLLIN); // 请求不完整,需要继续读取客户数据
return;
}

// 生成响应
bool write_ret = process_write( read_ret );
if ( !write_ret ) close_conn();
modfd( m_epollfd, m_sockfd, EPOLLOUT);
}

为什么将socket设置为非阻塞

image-20230412120540372

在Web服务器中,为了能够同时处理多个客户端请求,通常会使用多线程或多进程的方式。其中,每个线程或进程会负责处理一个客户端连接。为了高效地处理多个连接,Web服务器通常使用非阻塞IO。

当一个socket被设置为阻塞IO时,当它调用recv()或send()函数时,如果没有数据可读或者写缓冲区已满,该函数会一直阻塞,直到有数据可读或写缓冲区有空闲空间。这意味着,当有多个客户端连接时,每个连接都会阻塞线程或进程的执行,导致服务器的性能受到影响。

相反,当socket被设置为非阻塞IO时,当调用recv()或send()函数时,如果没有数据可读或写缓冲区已满,该函数会立即返回,并且在缓冲区有数据可读或有空闲空间时再继续执行。这意味着线程或进程可以在等待数据的同时处理其他连接,提高了服务器的并发性能。

因此,为了提高Web服务器的并发性能,将socket设置为非阻塞是一个常用的做法。


定时器处理非活跃连接

设置SIGALRM信号处理函数, 设置一个定时器, 到期处理函数就往pipefd[1]里面写, epoll_wait就会由于pipefd[0]可以读而触发, 再设置timeout为true, 调用tick()函数删除非活跃连接, 然后再设置一个定时器. 以此重复.


主从状态机

image-20230227144327617

主状态机:三种状态,标识解析位置

CHECK_STATE_REQUESTLINE,解析请求行

CHECK_STATE_HEADER,解析请求头

CHECK_STATE_CONTENT,解析消息体,仅用于解析POST请求

从状态机:三种状态,标识解析一行的读取状态

LINE_OK,完整读取一行

LINE_BAD,报文语法有误

LINE_OPEN,读取的行不完整

服务器处理HTTP请求的可能结果,报文解析的结果

NO_REQUEST : 请求不完整,需要继续读取客户数据

GET_REQUEST : 表示获得了一个完整的客户请求

BAD_REQUEST : 表示客户请求语法错误

NO_RESOURCE : 表示服务器没有资源

FORBIDDEN_REQUEST : 表示客户对资源没有足够的访问权限

FILE_REQUEST : 文件请求,获取文件成功

INTERNAL_ERROR : 表示服务器内部错误

CLOSED_CONNECTION : 表示客户端已经关闭连接了


子线程如何提醒主线程

子线程调用http_conn::process()方法

// 向epoll中添加需要监听的文件描述符
void addfd( int epollfd, int fd, bool one_shot ) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLRDHUP;
if(one_shot) event.events |= EPOLLONESHOT; // 防止同一个通信被不同的线程处理
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd); // 设置文件描述符非阻塞
}

// 从epoll中移除监听的文件描述符
void removefd( int epollfd, int fd ) {
epoll_ctl( epollfd, EPOLL_CTL_DEL, fd, 0 );
close(fd);
}

// 修改文件描述符,重置socket上的EPOLLONESHOT事件,以确保下一次可读时,EPOLLIN事件能被触发
void modfd(int epollfd, int fd, int ev) {
epoll_event event;
event.data.fd = fd;
event.events = ev | EPOLLET | EPOLLONESHOT | EPOLLRDHUP;
epoll_ctl( epollfd, EPOLL_CTL_MOD, fd, &event );
}
void http_conn::process() {
// 解析HTTP请求
HTTP_CODE read_ret = process_read();
if ( read_ret == NO_REQUEST ) {
modfd(m_epollfd, m_sockfd, EPOLLIN); // 请求不完整,修改通信m_sockfd上event.events = EPOLLIN,让主线程继续读取客户数据 那边wait到了就开始读
return;
}

// 生成响应
bool write_ret = process_write( read_ret );
if ( !write_ret ) close_conn();
modfd( m_epollfd, m_sockfd, EPOLLOUT); // 响应完,注册写事件,让主线程写入通信socket 那边wait到了就开始写
}

服务器是怎么把静态资源传回客户端的

首先客户端请求里有一个url,服务器端在解析请求首行的时候已经把他存在一个字符串m_url里了。然后我们要把存在服务器相应的资源目录地址拼接上m_url,判断有没有该文件以及该文件的访问权限。如果目标文件存在、对所有用户可读,且不是目录。则使用mmap将其映射到内存地址m_file_address处,并告诉调用者获取文件成功

最后由主线程执行write方法,将其传输给本次连接的socket

// write方法里 分散写,共有两块内存要写出去 m_write_buf 、 m_file_address
temp = writev(m_sockfd, m_iv, m_iv_count);

select
int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

// 3. listen()
ret = listen(lfd, 8);

// 创建一个fd_set的集合,存放的是需要检测的文件描述符
fd_set rdset, temp; // rdset是用户自己维护的, temp是交给内核去修改的
FD_ZERO(&rdset); //初始化,全置0
FD_SET(lfd, &rdset); //将参数文件描述符fd对应的标志位,设置为1
int maxfd = lfd;

while(1){
temp = rdset;
// 调用select,让内核检测那些文件描述符有数据
int ret = select(maxfd + 1, &temp, NULL, NULL, NULL);
if(ret == -1){
perror("select");
exit(-1);
}else if(ret == 0){ // 这里我们设置的timeval为NULL,所以是阻塞型,ret不可能返回0
continue;
}else if(ret > 0){ // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
if(FD_ISSET(lfd, &temp)){
// 判断fd对应的标志位是0还是1 , 为1代表有新的客户端连接进来了
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

// 将新的文件描述符加入到set中
FD_SET(cfd, &rdset);
maxfd = maxfd > cfd ? maxfd : cfd;
}

for(int i = lfd + 1; i <= maxfd; i ++){ // lfd最先被监听,肯定在最前面
if(FD_ISSET(i, &temp)){ // 说明该文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(i, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
close(i); // close(cfd)
FD_CLR(i, &rdset);
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(i, buf, strlen(buf) + 1);
}
}
}
}
}

close(lfd);
return 0;
}
select缺点
  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
  3. select支持的文件描述符数量太小了默认是1024
  4. fds集合不能重用,每次都需要重置

poll
int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

// 3. listen()
ret = listen(lfd, 8);

// 初始化检测的文件描述符数组
struct pollfd fds[1024];
for(int i = 0; i < 1024; i ++){
fds[i].fd = -1;
fds[i].events = POLLIN; //需要检测读事件
}
fds[0].fd = lfd;
int nfds = 0; // 这里就是最大索引,而不是最大索引 + 1

while (1){
// 调用 poll,让内核检测那些文件描述符有数据
int ret = poll(fds, nfds + 1, -1); // -1表示阻塞
if(ret == -1){
perror("select");
exit(-1);
}else if(ret == 0){
continue;
}else if(ret > 0){ // 说明检测到了有文件描述符的对应的缓冲区的数据发生了改变
if(fds[0].revents & POLLIN){ // 有新客户端连接进来了,因为revents 返回的是 POLLIN | POLLOUT
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

// 将cfd加入到监听数组
for(int i = 1; i < 1024; i ++){ // 0是lfd
if(fds[i].fd == -1){ // fds[i]可用
fds[i].fd = cfd;
fds[i].events = POLLIN;
break;
}
}

//更新最大的文件描述符索引
nfds = nfds > cfd ? nfds : cfd;
}

for(int i = 1; i <= nfds; i ++){ // lfd最先被监听,为0
if(fds[i].revents & POLLIN){ // 说明该文件描述符对应的客户端发来了数据
char buf[1024] = {0};
int len = read(fds[i].fd, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
close(i); // close(cfd)
fds[i].fd = -1;
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(fds[i].fd, buf, strlen(buf) + 1);
}
}
}
}
}

close(lfd);
return 0;
}
poll缺点
  1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大

  2. 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大

  3. select支持的文件描述符数量太小了默认是1024

  4. fds集合不能重用,每次都需要重置


epoll
说说 epoll 的原理

wait检测,有EPOLLIN就读,有EPOLLOUT就回

执行epoll_create会在内核中维护一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点

在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数, 内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中

epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可

epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多


说说epoll流程
  1. 创建一个epoll对象,通过调用epoll_create函数来创建一个epoll实例。它会返回一个文件描述符,用于后续对epoll的操作
  2. 将需要监控的文件描述符添加到epoll对象中,通过调用epoll_ctl函数来添加和删除需要监听的文件描述符,可以通过传递参数EPOLL_CTL_ADDEPOLL_CTL_DELEPOLL_CTL_MOD来进行添加、删除和修改操作
  3. 等待事件的发生,通过调用epoll_wait函数来等待epoll对象中的事件。该函数将会一直阻塞,直到有一个或多个文件描述符发生了指定的事件或者超时
  4. 处理事件,当epoll_wait函数返回后,可以遍历返回的epoll_event数组来处理所有的事件。epoll_event结构体中包含发生事件的文件描述符和事件类型
  5. 回到步骤3,等待下一个事件的发生
int main(){
// 1. socket()
int lfd = socket(AF_INET, SOCK_STREAM, 0);

// 2. bind()
sockaddr_in saddr;
saddr.sin_family = AF_INET;
saddr.sin_addr.s_addr = INADDR_ANY;
saddr.sin_port = htons(9999);
int ret = bind(lfd, (sockaddr*)&saddr, sizeof(saddr));

// 3. listen()
ret = listen(lfd, 8);

//(1)调用epoll_create()创建一个epoll实例
int epfd = epoll_create(1);
//(2)将监听的文件描述符相关的检测信息加入到epoll实例中
epoll_event epev;
epev.events = EPOLLIN;
epev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &epev);

epoll_event epevs[1024]; // 内核检测后会将已就绪的文件描述符放在这里面
while(1){
int ret = epoll_wait(epfd, epevs, 1024, -1); //(3)-1设置阻塞。只有设置了阻塞时,会返回0,代表超时了都没有检测到变化的文件描述符
if(ret == -1){
perror("epoll_wait");
exit(-1);
}

for(int i = 0; i < ret; i ++){
if(epevs[i].data.fd == lfd) { // 监听到了客户端的连接
sockaddr_in clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int cfd = accept(lfd, (sockaddr*)&clientaddr, &clientlen);

epev.events = EPOLLIN | EPOLLOUT;
epev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &epev); // 添加到epoll实例中
}else{ // epevs[i].data.fd == cfd 有数据到达,通信
if(epevs[i].events & EPOLLOUT){
continue;
}
char buf[1024] = {0};
int len = read(epevs[i].data.fd, buf, sizeof(buf));
if(len == -1){
perror("read");
exit(-1);
}else if(len == 0){
cout << "client closed..." << endl;
epoll_ctl(epfd, EPOLL_CTL_DEL, epevs[i].data.fd, NULL); // (4)将此fd从红黑树中删除
close(epevs[i].data.fd); // close(cfd)
}else if(len > 0){
cout << "read buf =" << buf << endl;
write(epevs[i].data.fd, buf, strlen(buf) + 1);
}
}
}
}

close(lfd);
close(epfd);// (5)关闭epoll实例

return 0;
}

epoll事件类型

epoll是一种高效的I/O多路复用机制,在Linux系统上被广泛应用。它可以同时处理大量的socket连接,并且可以有效地减少系统开销。在epoll中,有四种不同的事件类型:

  1. EPOLLIN:读事件就绪。当socket接收到数据,就会触发这种事件。
  2. EPOLLOUT:写事件就绪。当socket可以发送数据,就会触发这种事件。
  3. EPOLLERR:错误事件。当socket出现错误,就会触发这种事件。
  4. EPOLLHUP:挂起事件。当socket被挂起,就会触发这种事件。

在epoll中,有两种就绪状态:读就绪和写就绪。当一个socket可以读取数据时,就会触发读就绪事件。而当一个socket可以发送数据时,就会触发写就绪事件

在epoll中,写完成事件指的是数据已经被完全写入socket缓冲区的事件,而写就绪事件指的是socket缓冲区中有足够的空间可以继续写入数据的事件。类似地,读完成事件指的是数据已经被完全读取的事件,而读就绪事件指的是socket缓冲区中有足够的数据可以读取的事件


epoll写就绪事件和写完成事件

epoll中的写就绪事件是指一个文件描述符上的输出缓冲区可用空间已经超过一定阈值,此时可以向该文件描述符进行写操作(就是用该fd写出去)。在默认的水平触发模式下,只要输出缓冲区还有空闲空间,就会一直触发写就绪事件。而在边缘触发模式下,只有在输出缓冲区从空变为非空时才会触发写就绪事件。

epoll中,写就绪事件可以通过监听EPOLLOUT事件来实现。当一个文件描述符的输出缓冲区可用空间大于一定阈值时,epoll会将该文件描述符上的EPOLLOUT事件加入到事件集合中,通知应用程序该文件描述符可以进行写操作了

epoll中,写完成事件指的是一个文件描述符上的所有数据已经写入完毕,此时可以关闭该文件描述符或者等待更多的写操作。在默认的LT模式下,只要输出缓冲区还有空闲空间,就会一直触发写就绪事件,不管数据是否已经全部写入完毕。而在边缘触发模式下,只有当输出缓冲区从非空变为空时才会触发写完成事件。

epoll中的写完成事件无法直接注册,需要通过一些技巧来实现。一种常用的方法是在注册EPOLLOUT事件时,将文件描述符关联一个状态标志,当输出缓冲区变为空时,将该状态标志设置为“写完成”,在处理EPOLLOUT事件时,检查该状态标志,如果为“写完成”,则认为所有数据已经写入完毕,可以进行相应的处理


epoll水平触发与边缘触发的区别

LT模式(水平触发): 同时支持 Block (读写操作完才返回)和 Nonblock Socket (读写不等待完毕就返回); 只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。LT模式通常需要使用阻塞I/O,以确保程序能够正确处理事件。

而在ET(边缘触发): 支持 Nonblock socket, 它只会提示一次, 直到下次再有数据流入之前都不会再提示了, 无论fd中是否还有数据可读。如果程序没有及时处理事件,事件会被丢失。ET模式通常需要使用非阻塞I/O,以确保程序能够及时响应事件。

场景上来说

ET模式适用于需要高并发、高吞吐量的场景,例如网络编程中的服务器端。服务器需要及时响应大量的客户端请求,并发地处理多个请求。ET模式可以确保服务器能够及时响应客户端请求,并发地处理多个请求。

LT模式适用于需要保证数据的完整性和可靠性的场景,例如数据库、事务处理等。LT模式可以确保程序能够正确处理所有的事件,并保证数据的完整性和可靠性。


epoll是同步的还是异步的
  1. 一个同步的IO操作使得请求进程一直被阻塞,直到IO操作完成;
  2. 一个异步的IO操作不会导致请求进程被阻塞.

epoll既可以是同步的,也可以是异步的,这取决于它的使用方式。

在epoll的LT(Level Triggered)模式下,epoll_wait函数是同步的。这意味着当调用epoll_wait函数时,程序会一直等待,直到有事件发生,然后返回就绪的事件集合。

而在epoll的ET(Edge Triggered)模式下,epoll_wait函数是异步的。这意味着当调用epoll_wait函数时,如果有事件已经就绪,它会立即返回就绪的事件集合,如果没有就绪的事件,它会立即返回空集合,而不会阻塞等待事件的发生。


EPOLLONESHOT事件(保证线程安全)

即使可以使用 ET 模式,一个socket 上的某个事件还是可能被触发多次。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该 socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。一个socket连接在任一时刻都只被一个线程处理,对此我们可以使用 epoll 的 EPOLLONESHOT 事件实现

对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个 socket 上的 EPOLLONESHOT 事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket


在使用同步方式模拟 Proactor 模式时,每个 HTTP 连接的处理流程通常如下:

  1. 主线程从 epoll 中取出一个 EPOLLONESHOT 类型的 fd。
  2. 主线程处理该 fd,并将处理结果传递给工作线程池。
  3. 工作线程池异步地处理请求,并将响应结果返回给主线程。
  4. 主线程将响应结果发送给客户端。

由于在步骤2和步骤3中都需要处理 fd,为了保证同一时刻只有一个线程处理一个 fd,需要将每个 HTTP 连接

的 fd 设置为 EPOLLONESHOT 模式。这样可以保证同一时刻只有一个线程处理同一个 fd,从而避免线程安全问题。

 if(users[sockfd].read()) {
pool->append(users + sockfd);
}
// 如果不注册 EPOLLONESHOT,那 sockfd 有新数据时,主线程被唤醒来读取这些新的数据。
// 于是就出现了两个线程同时操作一个 socket 的局面。一个socket连接在任一时刻都只被一个线程处理,对此我们可以使用 epoll 的 EPOLLONESHOT 事件实现

阻塞和非阻塞、同步和异步

IO同步与进程同步不一样,IO同步是指自己操作数据,异步是指告诉内核要怎么做然后处理自己的事

无论阻塞还是非阻塞,都是同步,只有调用了相关的API才是异步

image-20221120132728012


说说Reactor、Proactor模式
Reactor模式

要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成
使用同步I/O(以epoll_wait为例)实现的Reactor模式的工作流程是:

  1. 主线程往epoll内核事件表中注册socket上的读事件
  2. 主线程调用epoll_wait等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列(线程池)
  4. 线程池中的某个线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件
  5. 主线程调用epoll_wait等待socket可写
  6. 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入线程池
  7. 线程池上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

image-20221120192657906

Proactor模式

Proactor模式将所有I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。使用异步I/O模型(以aio_read和aio_write 为例)实现的Proactor 模式的工作流程是:

1.主线程调用aio_read 函数向内核注册socket上的读完成事件,并告诉内核 用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)。

2.主线程继续处理其他逻辑。

3.当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号, 以通知应用程序数据已经可用。

4.应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用aio_ write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。

5.主线程继续处理其他逻辑。

6.当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号, 以通知应用程序数据已经发送完毕。

7.应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。 image-20221120194303146

同步IO的模拟Proactor

使用同步I/O方式模拟出Proactor模式。原理是:主线程执行数据读写操作,读写**(就是将socket上的数据(客户端传的http报文)读取至http类对象维护的读写缓冲区)**完成之后,主线程向工作线程通知这一”完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理

使用同步I/O模型(以epoll_wait为例)模拟出的Proactor模式的工作流程如下:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件
  2. 主线程调用epoll_wait 等待socket上有数据可读
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理完客户请求,然后往epoll内核事件表中注册socket上的写完成事件
  5. 因为主线程调用epoll_wait 等待socket可写, 所以当socket可写时, epoll_wait 通知主线程。主线程往socket上写入服务器处理客户请求的结果
image-20221120203431492
将文件描述符设置为非阻塞有什么用

将文件描述符设置为非阻塞可以使文件 I/O 操作变得更加灵活和高效。

当文件描述符被设置为阻塞时,当进行 I/O 操作时,程序会一直等待直到操作完成,如果操作需要花费很长时间(如从网络中读取大量数据),程序就会在等待操作完成时阻塞。这样可能会导致程序在等待 I/O 操作完成时无法执行其他任务,从而影响整个程序的性能和响应能力。

相比之下,当文件描述符被设置为非阻塞时,I/O 操作不会阻塞程序的执行。如果 I/O 操作需要花费很长时间,程序可以继续执行其他任务。此外,当进行非阻塞 I/O 操作时,如果数据还没有准备好或者无法立即写入,I/O 操作将会立即返回并返回一个错误码(如 EAGAIN 或 EWOULDBLOCK),程序可以在稍后再次尝试 I/O 操作,从而避免了长时间的等待阻塞。这种方法可以使程序更加高效和响应快速。


Unix、Linux上的五种IO模型
阻塞IO模型

进程发起IO系统调用后,进程被阻塞,转到内核空间处理,整个IO处理完毕后返回进程,操作成功则进程获取到数据

img
非阻塞IO模型

非阻塞等待,每隔一段时间就去检测IO事件是否就緒。没有就緒就可以做其他事。非阻塞IO执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据errno区分这两种情况,对于accept, recv和send,事件末发生时,errno通常被设置成EAGAIN / EWOULDBLOCK

这种工作方式下需要不断轮询查看状态

img
多路复用

Linux用select/poll/epoll实现IO多路复用模型,这些函数也会使进程阻塞,但是和阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时,才真正调用IO操作函数

image-20221120135606806
信号驱动

Linux用工接口进行信号驱动IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到SIGIO信号,然后处理IO事件

image-20221120140459752
异步IO模型

当进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果。内核把整个IO处理完后,会通知进程结果,如果IO操作成功则进程直接获取到数据

img
⭐️5种IO模型的区别

在这里插入图片描述


线程池

该项目使用线程池(同步模拟Proactor)并发处理用户请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等)

#ifndef THREADPOOL_H
#define THREADPOOL_H
#include <list>
#include <cstdio>
#include <exception>
#include <pthread.h>
#include "locker.h"

// 线程池类,将它定义为模板类是为了代码复用,模板参数T是任务类
template<typename T>
class threadpool {
public:
/*thread_number是线程池中线程的数量,max_requests是请求队列中最多允许的、等待处理的请求的数量*/
threadpool(int thread_number = 8, int max_requests = 10000);
~threadpool();
bool append(T* request);

private:
/*工作线程运行的函数,它不断从工作队列中取出任务并执行之*/
static void* worker(void* arg);
void run();

private:
int m_thread_number; // 线程的数量

pthread_t * m_threads; // 描述线程池的数组,大小为m_thread_number

int m_max_requests; // 请求队列中最多允许的、等待处理的请求的数量

std::list< T* > m_workqueue; // 请求队列 按理说应当叫 m_requestqueue

locker m_queuelocker; // 保护请求队列的互斥锁

sem m_queuestat; // 是否有任务需要处理

bool m_stop; // 是否结束线程
};

template< typename T >
threadpool< T >::threadpool(int thread_number, int max_requests) :
m_thread_number(thread_number), m_max_requests(max_requests),
m_stop(false), m_threads(NULL) {

if((thread_number <= 0) || (max_requests <= 0) ) throw std::exception();

m_threads = new pthread_t[m_thread_number];
if(!m_threads) throw std::exception();

// 创建thread_number 个线程,并将他们设置为脱离线程。
for ( int i = 0; i < thread_number; ++i ) {
printf( "create the %dth thread\n", i);
if(pthread_create(m_threads + i, NULL, worker, this ) != 0) {
delete [] m_threads;
throw std::exception();
}

if( pthread_detach( m_threads[i] ) ) {
delete [] m_threads;
throw std::exception();
}
}
}

template< typename T >
threadpool< T >::~threadpool() {
delete [] m_threads;
m_stop = true;
}

template< typename T >
bool threadpool< T >::append( T* request ){
// 操作工作队列时一定要加锁,因为它被所有线程共享。
m_queuelocker.lock();
if ( m_workqueue.size() > m_max_requests ) {
m_queuelocker.unlock();
return false;
}
m_workqueue.push_back(request);
m_queuelocker.unlock();
m_queuestat.post();
return true;
}

template< typename T >
void* threadpool< T >::worker( void* arg ){
threadpool* pool = ( threadpool* )arg;
pool->run();
return pool;
}

template< typename T >
void threadpool< T >::run() {
while (!m_stop) {
m_queuestat.wait();
m_queuelocker.lock();
if ( m_workqueue.empty() ) {
m_queuelocker.unlock();
continue;
}
T* request = m_workqueue.front();
m_workqueue.pop_front();
m_queuelocker.unlock();
if ( !request ) continue;
request->process();
}
}

#endif
如何确定线程数量

线程数量的确定需要考虑多种因素,如处理器核心数、内存大小、任务类型、线程间的依赖关系等。

  1. 处理器核心数:线程数不宜超过处理器核心数,因为超过这个数量后,线程之间可能会竞争处理器资源,导致性能下降。
  2. 内存大小:每个线程需要占用一定的内存,因此线程数不能过多,否则会导致内存不足。
  3. 任务类型:如果任务是I/O密集型的,例如网络通信或者文件读写等,线程数可以比较多,因为线程会在I/O操作中阻塞,不会占用过多的处理器资源。而如果是CPU密集型的任务,例如图像处理或者计算密集型算法等,则线程数需要适当减少,避免CPU资源竞争。
  4. 线程间的依赖关系:如果任务中的线程之间存在依赖关系,需要根据依赖关系来确定线程数,避免出现死锁等问题。
线程数量与CPU核心数的关系

如果任务是CPU密集型的,那么线程数不应该超过CPU核心数,因为过多的线程会导致线程之间的上下文切换,从而浪费CPU时间。此时,线程数可以根据CPU核心数进行适当调整,以充分利用CPU资源。

如果任务是I/O密集型的,那么线程数可以适当增加,以充分利用CPU资源。此时,线程数可以根据CPU核心数和系统负载情况进行适当调整,以提高并发处理能力

线程池中的工作线程是一直等待吗?

在run函数中,我们为了能够处理高并发的问题,将线程池中的工作线程都设置为阻塞等待在请求队列是否不为空的条件上,因此项目中线程池中的工作线程是处于一直阻塞等待的模式下的

线程池工作线程处理完一个任务后的状态是什么?

(1) 当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态

(2) 当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格

如果同时1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?

本项目是通过对子线程循环调用来解决高并发的问题的


Nginx的负载均衡

模块目前支持4种调度算法,下面进行分别介绍,其中后两项属于第三方的调度方法。

  • 轮询(默认):每个请求按时间顺序逐一分配到不同的后端服务器,如果后端某台服务器宕机,故障系统被自动剔除,使用户访问不受影响;
  • Weight:指定轮询权值,Weight值越大,分配到的访问机率越高,主要用于后端每个服务器性能不均的情况下;
  • ip_hash:每个请求按访问IP的hash结果分配,这样来自同一个IP的访客固定访问一个后端服务器,有效解决了动态网页存在的session共享问题
  • fair:比上面两个更加智能的负载均衡算法。此种算法可以依据页面大小和加载时间长短智能地进行负载均衡,也就是根据后端服务器的响应时间来分配请求,响应时间短的优先分配。
  • url_hash:按访问url的hash结果来分配请求,使每个url定向到同一个后端服务器,可以进一步提高后端缓存服务器的效率。Nginx本身是不支持url_hash的,如果需要使用这种调度算法,必须安装Nginx 的hash软件包

在HTTP Upstream模块中,可以通过server指令指定后端服务器的IP地址和端口,同时还可以设定每个后端服务器在负载均衡调度中的状态。常用的状态有:

  • down:表示当前的server暂时不参与负载均衡;
  • backup:预留的备份机器。当其他所有的非backup机器出现故障或者忙的时候,才会请求backup机器,因此这台机器的压力最轻;
  • max_fails:允许请求失败的次数,默认为1。当超过最大次数时,返回proxy_next_upstream 模块定义的错误;
  • fail_timeout:在经历了max_fails次失败后,暂停服务的时间。max_fails可以和fail_timeout一起使用。

nginx配置

upstream 说白了就是做负载均衡,它可以帮助我们定义一组相同服务的别名,如backend,当请求到来的时候可以通过相关策略帮我们选一组服务提供响应。

目前只能被 proxy_passfastcgi_passuwsgi_passscgi_passmemcached_passgrpc_pass 使用。


读多写少的优化
  • 加读写锁

    但是,只要加了锁,就会带来竞争,即使加的是读写锁,虽然读之间不互斥,但写一样会影响读,而且读写同时争夺锁的时候,锁优先分配给写。

    例如,写的时候,要求所有的读请求阻塞住,等到写线程或协程释放锁之后才能读。如果写的临界区耗时比较大,则所有的读请求都会受影响,所有的读请求都在队列中等待处理,如果在下个更新周期来之前,服务能处理完这些读请求,可能情况没那么糟糕。但极端情况下,如果下个更新周期来了,读请求还没处理完,就会形成一个恶性循环,不断的有读请求在队列中等待,最终导致队列被挤满,服务出现假死,情况再恶劣一点的话,上游服务发现某个节点假死后,由于负载均衡策略,一般会重试请求其他节点,这时候其他节点的压力跟着增加了,最终导致整个系统出现雪崩。
    因此,加锁在高并发场景下要尽量避免,如果避免不了,需要让锁的粒度尽量小,接近无锁(lock-free)更好,简单的对一大片临界区加锁,在高并发场景下不是一种合适的解决方案

  • 双缓冲

image-20230221161535855


GDB 常见的调试命令
GDB命令-启动/退出/查看代码
  • 进入gdb环境和退出

gdb 可执行文件

quit

  • 给程序设置参数/获取设置参数[需要先进入gdb环境]

set args 10 20

show args

  • GDB使用帮助 直接help 或者set(还可以是其他的关键字) help

  • 查看当前文件代码[vim 文件名]

list/l (从默认位置 显示 前面必须有-g)

list/l 行号 (从指定的行显示 前面必须有-g)

list/l 函数名 (从指定的函数显示 前面必须有-g)

  • 查看非当前文件代码

list/l 文件名:行号

list/l 文件名:函数名

  • 设置/显示行数

show list/listsize 显示行数
set list/listsize 行数 设置行数

GDB命令-断点操作
  • 设置断点

b/break 行号

b/break 函数名

b/break 文件名:行号

b/break 文件名:函数

  • 查看断点

i/info b/break

image-20221029160442047
  • 删除断点

d/del/delete 断点编号

  • 设置断点无效

dis/disable 断点编号

  • 设置断点生效

ena/enable 断点编号

  • 设置条件断点(一般用在循环的位置)

b/break 10 if i=5 在第十行设置断点

GDB命令-调试命令
  • 运行GDB程序

start (程序停在第一行)

run (遇到断点才停)

  • 继续运行,到下一个断点停

c/ continue

  • 向下执行一行代码(不会进入函数体)

n/ next

  • 向下执行一行代码((遇到函数进入函数体)

s/ step

finish (跳出函数体)

  • 变量操作

p/print 变量名 (打印变量值)

ptype 变量名 (打印变量类型)

  • 自动变量操作

display a; display b; 每次调试时如果a, b值发生变化的话, 自动打印

i/info display 查看设置了哪些自动变量

undisplay 编号

  • 其它操作

set var 变量名 = 变量值

until (跳出循环)


提高WebServer的性能
  1. 加机器
  2. 提升机器性能(内存, CPU)
  3. 拓展线程池的大小

负载均衡

假如我们刚刚上线一个网站,最多只有 10 个人同时访问,那么只需要把网站放到一台服务器上就够了,又叫 单机部署

image-20230221131733787

随着我们网站的不断宣传,可能出现上万用户同时访问的情况。由于一台服务器的 CPU、内存、带宽等资源都是有限的,无法同时支撑那么多用户。因此可能需要多台服务器一起来扛,分摊用户的请求,又叫 集群部署

image-20230221131811712

但这样有个问题,每个服务器都有一个不同的 IP 地址,想把用户的请求分摊到不同的服务器上,不能让用户自己去输入不同的 IP 访问。

因此,我们还需要一台 代理服务器 ,对外提供 唯一 的入口,统一 接受用户的请求。再根据请求(或流量)的 特征 ,依据一定的 算法 ,将请求转发到内部的服务器集群中

image-20230221131913227

这样对于用户来说,始终通过一个域名访问网站即可,他完全感知不到你的网站到底部署到多少台服务器上、也不关心它是如何部署的。这便是 负载均衡(Load Balancing 简称 LB),是企业中最重要的高并发解决方案

优点:

提高整个系统的可用性,假如集群中有一台服务器挂了,代理服务器只要不再把请求转发给它就行了,集群中的其他服务器仍然能够正常地接受和处理请求

此外,负载均衡还能够减少用户等待响应的时间、通过并行提高整个系统的处理能力等


负载均衡分类

虽然通过代理服务器转发请求能够提升整个系统的并发访问数,但不要忘了,代理服务器本身的资源也是有限的啊!像比较常用的 Nginx 代理,能有个几万并发就撑死了。如果同时访问的用户量再大一点,不就忍不下了么?

而且代理服务器也存在挂掉的可能性,一旦它挂了,后果不堪设想。因此,我们可以将负载均衡进行分类,针对不同的场景来选择相对合适的实现方式。比较常见的分类方法是:根据 计算机网络七层模型 ,按照负载均衡所属的网络层次去区分

  1. 二层负载均衡:二层指数据链路层,数据以数据帧的形式通过交换机进行传输。

    这一层是没有 IP 地址概念的,只能用 MAC 地址对机器进行区分。因此负载均衡服务器会通过一个虚拟 MAC 地址接受请求,并通过改写报文目标 MAC 地址的方式将请求转发到具有不同 MAC 地址的目标机器

    image-20230221132718380
  2. 三层负载均衡:三层即网络层,这一层开始有了 IP 地址的概念,可以根据 IP 地址路由网络。

    这一层的负载均衡设备会对外提供一个虚拟的 IP 地址(VIP)以接收请求,然后根据算法将请求转发到 IP 地址不同的目标机器

    image-20230221133036044
  3. 四层即传输层:除了包含三层的 IP 地址信息之外,还多了源目端口号的概念,可以区分同一机器上不同的应用。

    由于得到了更多的信息,这一层的负载均衡会更加灵活,对外提供一个虚拟的 IP 地址 + 端口号来接收请求,然后根据算法将请求转发到不同目标机器的不同端口上

    image-20230221133148277
  4. 七层指应用层:是计算机网络模型的最上层,因此能得到请求最为详细的信息,比如 HTTP 请求头等。

    可以根据域名或主机 IP + 端口接收请求,并通过应用层信息(请求头、Cookie 等)灵活地转发请求,比如将手机端用户转发到服务器 A、桌面端用户转发到服务器 B 等

    image-20230221133313580

负载均衡算法:一致性哈希
  • 哈希算法

    以分布式缓存为例,假设现在有3台缓存服务器(S0,S1,S2),要将一些图片尽可能平均地分配到不同的服务器上,hash算法的做法是:

    (1) 以图片的名称作为key,然后对其做hash运算。

    (2) 将hash值对服务器数量进行求余,得到服务器编号,最后存入即可。

    如:a.jpg 需要存入, 我们就得到hash(a.jpg) = 5 ——-> 5%3 = 2 得到数据存入S2 思考:

    上述HASH算法时,会出现一些缺陷:如果服务器已经不能满足缓存需求,就需要增加服务器数量,假设我们增加了一台缓存服务器,此时如果仍然使用上述方法对同一张图片进行缓存,那么这张图片所在的服务器编号必定与原来3台服务器时所在的服务器编号不同,因为除数由3变为了4,最终导致所有缓存的位置都要发生改变,也就是说,当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据;同理,假设突然有一台缓存服务器出现了故障,那么我们则需要将故障机器移除,那么缓存服务器数量从3台变为2台,同样会导致大量缓存在同一时间失效,造成了缓存的雪崩,后端服务器将会承受巨大的压力,整个系统很有可能被压垮。为了解决这种情况,就有了一致性哈希算法

  • 一致性哈希算法

    也是使用取模的方法,但是取模算法是对服务器的数量进行取模,而一致性哈希算法是对 2^32 取模,具体步骤如下:

    1. 一致性哈希算法将整个哈希值空间按照顺时针方向组织成一个虚拟的圆环,称为 Hash 环;
    2. 接着将各个服务器使用 Hash 函数进行哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,从而确定每台机器在哈希环上的位置
    3. 最后使用算法定位数据访问到相应服务器:将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针寻找,第一台遇到的服务器就是其应该定位到的服务器
    image-20230221135029847 image-20230221135135171

优点:

image-20230221135313611

哈希环的倾斜与虚拟节点:

image-20230221135454172
性能测试 Webbench

父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出(4gb,单核)

webbench -c 1000 -t 60 http://172.16.208.129:8000/index.html

每秒500个并发测试60秒

image-20230409145537174

image-20230331120441103

每秒1000个并发测试60秒

image-20230409145816015

image-20230409145841789

每秒2000个并发测试60秒

image-20230409151210972

image-20230409151031497

每秒3000个并发测试60秒

image-20230409151550693

每秒3000+ 出现CPU资源不足

如果在使用 Webbench 进行测试时,发现 CPU 占用率非常高,但内存消耗很低,可以考虑进行优化,比如使用缓存技术

nginx配置后(设置event数量为63335后全部成功)

image-20230331141852209 image-20230331142913248

对于服务器我们需要测试两个指标

  • 同时连接服务器的数量
  • 每秒的接入量

什么是QPS和TPS, 如何计算

QPS和TPS都是衡量系统性能的指标,它们分别代表每秒钟处理的请求数量事务处理数量

QPS = 请求数 / 时间 # 在1秒钟内处理了100个请求,则QPS为100
TPS = 事务数 / 时间 # 通常用于衡量事务性系统的处理能力,例如交易处理系统

访问服务端延迟高的原因
  1. 服务端应用压力太大,确实处理不过来了
  2. 请求的资源太大
  3. 线程池配置的不合理,线程数配置的太少导致的请求积压
  4. 客户端网络原因,丢包、带宽限制、重传等
  5. 节点距离:需要跳转的网络节点越多,呈现在现实就是网络访问速度会越慢

如何定位服务器性能瓶颈

观察机器的磁盘IO:dstat -d

image-20230409165759148

观察网卡的流量情况: dstat -n

image-20230409165506186

查看内存(top) + jstack

top // 使用top指令找到CPU使用最高的进程
top -Hp 6962 // 使用 top -Hp 进程Id ,找到使用率最高的线程
printf "%x\n" 2846 // 将这些线程id转换为16进制的,printf “%x\n” 线程Id
jstack 6962 | grep a33 -A 100 // 打印进程堆栈信息 (6962是进程id,a33是线程id的对应的16进制)
// 这样就能找到导致CPU使用率飙高的具体的代码了

查看端口状态(netstat)

抓包(tcpdump)

host 192.168.130.1表示一台主机. 没有指明数据类型,那么默认就是host

net 192.168.130.0表示一个网络网段

port 80 指明端口号为80

tcpdump ip dst 192.168.56.1 and src 192.168.56.210 and port 80 and host ! www.baidu.com

tcpdump udp port 53 监听本机udp的53端口的数据包

tcpdump tcp port 22 and host 192.168.56.210 捕获主机192.168.56.210接收和发出的tcp协议的数据包


防止恶意请求,服务器端可以采取措施
  1. 在服务器端对所有输入进行验证, 确保只有有效的请求才会被处理. 可以使用正则表达式或其他验证库来验证输入
  2. 可以限制单个IP地址或用户在特定时间内访问服务器的次数。这可以防止暴力攻击和DDoS攻击等。
  3. 可以在敏感操作前要求用户输入验证码,这可以防止自动化脚本或机器人攻击。
  4. 可以使用HTTPS协议进行通信,这可以防止网络窃听和中间人攻击等。
  5. 可以在服务器上安装防火墙,来过滤不受欢迎的网络流量,包括恶意请求。
  6. 可以实时监控服务器端的请求日志,这可以帮助发现潜在的恶意请求和攻击。

如何限制单个IP地址在特定时间内访问服务器的次数
  1. 配置防火墙规则:大多数防火墙都允许您设置规则以限制特定IP地址或用户在特定时间内的访问次数。您可以配置规则,使它们在指定时间内仅允许有限数量的请求通过。这是一个基于网络层面的解决方案,可以防止任何类型的请求访问服务器。
  2. 使用限流器:限流器是一个应用层面的解决方案,可以限制单个IP地址或用户在特定时间内访问服务器的次数。您可以配置限流器以允许每个IP地址或用户在指定的时间段内仅能发送有限数量的请求。这可以确保您的服务器不会被恶意请求攻击。
  3. 使用反向代理服务器:如果您使用反向代理服务器,您可以在代理服务器上设置规则以限制特定IP地址或用户在特定时间内访问服务器的次数。反向代理服务器可以充当服务器和客户端之间的中介,允许您在代理服务器上执行任意数量的控制和限制。

服务器端限流

一种防止服务器过载的措施。通过限制每个客户端在一定时间内可以发送到服务器的请求数量,可以避免某些恶意用户或脚本通过高速请求导致服务器宕机或运行缓慢

以下是实施服务器端限流的一些方法:

  1. 客户端IP地址限制:限制来自单个IP地址的请求数量,这可以防止单个客户端对服务器进行过度请求。
  2. 并发连接数限制:限制服务器能够同时处理的连接数量,这可以防止服务器过载。
  3. 请求速率限制:限制客户端在一定时间内可以发送的请求数量,这可以防止DDoS攻击和暴力破解等。
  4. Token Bucket算法:Token Bucket算法是一种常见的限流算法。服务器为每个客户端分配一个令牌桶,每个令牌代表一个请求。客户端在发送请求之前必须从令牌桶中获取令牌。如果令牌桶为空,则请求被拒绝。
  5. 漏桶算法:漏桶算法是另一种常见的限流算法。服务器维护一个固定大小的漏桶,每个请求被视为一个水滴。当一个请求到达时,服务器将水滴放入漏桶中。如果漏桶已经满了,则请求被拒绝。

基于 Go 开发的分布式 KV 数据库

Raft算法
一、概述

不同于Paxos算法直接从分布式一致性问题出发推导出来,Raft算法则是从多副本状态机的角度提出,用于管理多副本状态机的日志复制。Raft实现了和Paxos相同的功能,它将一致性分解为多个子问题:

Leader选举(Leader election)、日志同步(Log replication)、安全性(Safety)、日志压缩(Log compaction)、成员变更(Membership change)等。同时,Raft算法使用了更强的假设来减少了需要考虑的状态,使之变的易于理解和实现。

Raft将系统中的角色分为领导者(Leader)、跟从者(Follower)和候选人(Candidate):

  • Leader:接受客户端请求,并向Follower同步请求日志,当日志同步到大多数节点上后告诉Follower提交日志。
  • Follower:接受并持久化Leader同步的日志,在Leader告之日志可以提交之后,提交日志。
  • Candidate:Leader选举过程中的临时角色。
image-20230407221130979

Raft要求系统在任意时刻最多只有一个Leader,正常工作期间只有Leader和Followers。Raft算法角色状态转换如下:

image-20230407221213065

Follower只响应其他服务器的请求。如果Follower超时没有收到Leader的消息,它会成为一个Candidate并且开始一次Leader选举。收到大多数服务器投票的Candidate会成为新的Leader。Leader在宕机之前会一直保持Leader的状态。

image-20230407221528931

Raft算法将时间分为一个个的任期(term),每一个term的开始都是Leader选举。在成功选举Leader之后,Leader会在整个term内管理整个集群。如果Leader选举失败,该term就会因为没有Leader而结束。

二、Leader选举

Raft 使用心跳来维持 leader 身份。任何节点都以 follower 的身份启动。 leader 会定期的发送心跳给所有的 follower 以确保自己的身份。 每当 follower 收到心跳后,就刷新自己的 electionElapsed(当前经过的选举耗时),重新计时。

一旦一个 follower 在指定的时间内没有收到任何 RPC(称为 electionTimeout),则会发起一次选举。 当 follower 试图发起选举后,其身份转变为 candidate,在增加自己的 term 后, 会向所有节点发起 RequestVoteRPC 请求,candidate 的状态会一直持续直到:

  • 赢得选举
  • 其他节点赢得选举
  • 一轮选举结束,无人胜出

选举的方式非常简单,谁能获取到多数选票 (N/2 + 1),谁就成为 leader。 在一个 candidate 节点等待投票响应的时候,它有可能会收到其他节点心跳, 此时有两种情况:

  • 该请求的 term 和自己一样或更大:说明对方已经成为 leader,自己立刻退为 follower。
  • 该请求的 term 小于自己:拒绝请求并返回当前 term 以让请求节点更新 term。

安全性要求:

  • 日志完整性高的跟随者(也就是最后一条日志项对应的任期编号值更大,索引号更大)拒绝投票给日志完整性低的候选人。比如节点B的任期编号为3,节点C的任期编号为4,节点B的最后一条日志项对应的任期编号为3,而节点C为2,那么当节点C请求节点B投票给自己时,节点B将拒绝投票

为了防止在同一时间有太多的 follower 转变为 candidate 导致无法选出绝对多数, Raft 采用了随机选举超时(randomized election timeouts)的机制, 每一个 candidate 在发起选举后,都会随机化一个新的选举超时时间, 一旦超时后仍然没有完成选举,则增加自己的 term,然后发起新一轮选举。 在这种情况下,应该能在较短的时间内确认出 leader。 (因为 term 较大的有更大的概率压倒其他节点)

通过一个节点在一个 term 只能给一个节点投票,Raft 保证了对于给定的一个 term 最多只有一个 leader,从而避免了选举导致的 split brain 以确保 safety;通过不同节点每次随机化选举超时时间以确保 liveness。

“liveness” 问题指的是如何确保当网络分裂或节点故障时,系统仍然能够继续运行,并最终达成一致性.

禁止节点对外提供服务:在 Raft 中,当节点开始发起领导者选举时,它会暂停对外提供服务,直到选举结束并成为领导者后才重新开始对外提供服务。通过这个机制,Raft 算法可以确保在选举期间系统不会出现不一致的情况。

三、日志同步 (复制)

Leader选出后,就开始接收客户端的请求。Leader把请求作为日志条目(Log entries)加入到它的日志中,然后并行的向其他服务器发起 AppendEntries RPC 复制日志条目。当这条日志被复制到大多数服务器上,Leader将这条日志应用到它的状态机并向客户端返回执行结果

因为领导者的日志复制RPC消息或心跳消息,包含了当前最大的、将会被提交的日志项索引值。所以通过日志复制RPC消息或心跳消息,跟随者就可以知道领导者的日志提交位置信息

image-20230407222412142
  1. 接收到客户端请求后,领导者基于客户端请求中的指令,创建一个新日志项,并附加到本地日志中
  2. 领导者通过日志复制RPC,将新的日志复制到其他的服务器
  3. 当领导者将日志项成功复制到大多数的服务器上的时候,领导者会将这条日志项应用到它的状态机中
  4. 领导者将执行的结果返回给客户端
  5. 当跟随者接收到心跳消息,或者新的日志复制RPC消息后,如果跟随者发现领导者已经提交了某条日志项,而它还没应用,那么跟随者就将这条日志项应用到本地的状态机上
image-20230407223527749

Raft日志同步保证如下两点:

  • 如果不同日志中的两个条目有着相同的索引和任期号,则它们所存储的命令是相同的。
  • 如果不同日志中的两个条目有着相同的索引和任期号,则它们之前的所有条目都是完全一样的。

第二条特性源于 AppendEntries 的一个简单的一致性检查。当发送一个 AppendEntries RPC 时,Leader会把新日志条目紧接着之前的条目的log index和term都包含在里面。如果Follower没有在它的日志中找到log index和term都相同的日志,它就会拒绝新的日志条目。

一般情况下,Leader和Followers的日志保持一致,因此 AppendEntries 一致性检查通常不会失败。然而,Leader崩溃可能会导致日志不一致:旧的Leader可能没有完全复制完日志中的所有条目。

image-20230407224347287

上图阐述了一些Followers可能和新的Leader日志不同的情况。一个Follower可能会丢失掉Leader上的一些条目,也有可能包含一些Leader没有的条目,也有可能两者都会发生。丢失的或者多出来的条目可能会持续多个任期。

Leader通过强制Followers复制它的日志来处理日志的不一致,Followers上的不一致的日志会被Leader的日志覆盖。

Leader为了使Followers的日志同自己的一致,Leader需要找到Followers同它的日志一致的地方,然后覆盖Followers在该位置之后的条目。

Leader会从后往前试,每次AppendEntries失败后尝试前一个日志条目,直到成功找到每个Follower的日志一致位点,然后向后逐条覆盖Followers在该位置之后的条目。

四、安全性

选举限制:

因为 leader 的强势地位,所以 Raft 在投票阶段就确保选举出的 leader 一定包含了整个集群中目前已 committed 的所有日志。

当 candidate 发送 RequestVoteRPC 时,会带上最后一个 entry 的信息。 所有的节点收到该请求后,都会比对自己的日志,如果发现自己的日志更新一些,则会拒绝投票给该 candidate。

判断日志新旧的方式:获取请求的 entry 后,比对自己日志中的最后一个 entry。 首先比对 term,如果自己的 term 更大,则拒绝请求。 如果 term 一样,则比对 index,如果自己的 index 更大(说明自己的日志更长),则拒绝请求。

提交限制:

为什么需要 no-op 日志?

leader 永远只提交当前 term 的 entry, 过去的 entry 只会随着当前的 entry 被一并提交。 见博客

节点崩溃:

如果 leader 崩溃,集群中的所有节点在 electionTimeout 时间内没有收到 leader 的心跳信息就会触发新一轮的选主。总而言之,最终集群总会选出唯一的 leader 。按论文中的说法,计算一次 RPC 耗时高达 30~40ms 时,99.9% 的选举依然可以在 3s 内完成,但一般一个机房内一次 RPC 只需 1ms。当然,选主期间整个集群对外是不可用的。

如果 follower 和 candidate 奔溃相对而言就简单很多, 因为 Raft 所有的 RPC 都是幂等的,所以 Raft 中所有的请求,只要超时,就会无限的重试。follower 和 candidate 崩溃恢复后,可以收到新的请求,然后按照上面谈论过的追加或拒绝 entry 的方式处理请求。

时间与可用性:

Raft 原则上可以在绝大部分延迟情况下保证一致性, 不过为了保证选择和 leader 的正常工作,最好能满足下列时间条件:

broadcastTime << electionTimeout << MTBF
  • broadcastTime:向其他节点并发发送消息的平均响应时间;
  • electionTimeout:follower 判定 leader 已经故障的时间(heartbeat 的最长容忍间隔);
  • MTBF(mean time between failures):单台机器的平均健康时间;

一般来说,broadcastTime 一般为 0.5~20ms,electionTimeout 可以设置为 10~500ms,MTBF 一般为一两个月。

五、日志压缩

在实际的系统中,不能让日志无限增长,否则系统重启时需要花很长的时间进行回放,从而影响可用性。Raft采用对整个系统进行snapshot来解决,snapshot之前的日志都可以丢弃。

每个副本独立的对自己的系统状态进行snapshot,并且只能对已经提交的日志记录进行snapshot。

Snapshot中包含以下内容:

  • 日志元数据。最后一条已提交的 log entry的 index和term。这两个值在snapshot之后的第一条log entry的AppendEntries RPC的完整性检查的时候会被用上。
  • 系统当前状态。

当Leader要发给某个日志落后太多的Follower的log entry被丢弃,Leader会将snapshot发给Follower。或者当新加进一台机器时,也会发送snapshot给它。发送snapshot使用InstalledSnapshot RPC。

做snapshot既不要做的太频繁,否则消耗磁盘带宽, 也不要做的太不频繁,否则一旦节点重启需要回放大量日志,影响可用性。推荐当日志达到某个固定的大小做一次snapshot。

做一次snapshot可能耗时过长,会影响正常日志同步。可以通过使用copy-on-write技术避免snapshot过程影响正常日志同步。

六、成员变更
image-20230408212943630

由于在分布式的系统中,每一个节点收到并执行配置变更指令的时间都是不完全一致的,因此系统可能出现上图所述的双leader的情况:

  • 对于server2来说,它此时还在Cold配置中,可以通过获得1、2的选票成为leader
  • 对于server3来说,它已经处在Cnew配置中,可以获得3、4、5的选票成为leader

这种情况下,5个节点出现了两个leader,直接违反了核心的Safety原则。

在这种情况下,一次变更一个节点能够保证不会出现两个leader的split。因为新的group和majority和旧的group的majority一定是overlap的。具体解释可以看论文中的图:

image-20230408213627214
七、Raft与Multi-Paxos的异同
八、Raft算法总结

Raft算法各节点维护的状态:

对于一个Raft节点服务器来说,当宕机重新启动的时候,它必须恢复到宕机以前的状态。论文中提到了需要持久化的状态:currentTerm、voteFor、logs(全部)

img

Leader选举:

img

日志同步:

img

Raft状态机:

img

安装snapshot:

img
分布式架构中handler和sender

具体来说,如果一个节点需要向其他节点发送消息,那么它就扮演 sender 角色。需要负责将消息打包成特定格式,然后将消息发送到目标节点。sender 节点需要考虑到网络状况、消息是否发送成功等问题,以确保消息能够被可靠地发送到目标节点。

如果一个节点需要接收并处理其他节点发送过来的消息,那么它就扮演 handler 角色。这个节点需要负责解析消息并根据消息的内容进行相应的处理。消息的处理可能包括更新本地数据、调用本地服务、向其他节点发送消息等操作。在处理消息的过程中,handler 节点需要考虑到系统的可用性、容错性等问题,以确保系统能够正常工作并避免数据丢失或错误。


在实现Raft的什么情况下不要持锁
  1. 发送RPC或push channel时持有锁:如果发送方在发送RPC或push channel之前持有锁,则在接收方没有准备好接收时,它将无法释放锁并阻塞,这可能导致死锁。
  2. 接收RPC或pop channel时持有锁:如果接收方在接收RPC或pop channel之前持有锁,则发送方无法在接收方已准备好接收之前释放锁,这也可能导致死锁。

因此,为避免死锁,应该尽量避免在发送RPC、push channel、接收RPC、pop channel时持有锁。相反,应该使用异步或非阻塞的方法来发送和接收信息。如果必须使用锁,则应确保在发送和接收信息之前释放锁,以便其他进程可以访问共享资源。


Raft 哪些 state 需要持久化,为什么
  1. currentTerm:当前节点所处的任期号;
  2. votedFor:当前节点在当前任期内投票给了哪个节点;
  3. logs:当前节点保存的日志。
  4. 已知的最大的已经被提交的日志条目的索引值(commitIndex):Raft 确保所有节点都应用了相同的日志条目
  5. 每个节点的最后一条日志条目的索引值(lastApplied):Raft 通过复制日志来实现一致性

这些状态信息对于Raft算法的正常运行非常关键,需要在所有节点之间保持一致。如果这些状态信息在不同节点之间不一致,就可能导致Raft算法的执行出现问题。

而其他节点信息,如节点的ID、地址等信息通常不会发生变化,因此可以不用进行序列化和反序列化。这些信息可以在节点启动时从配置文件或其他来源中读取,并在整个运行期间保持不变。因此,这些信息不会影响节点之间的一致性。


commit和apply的区别

Commit(提交)是指Leader将一个日志条目成功复制到了大多数的Follower节点的日志中,并将该条目标记为已提交。在Raft中,只有被提交的日志条目才能被应用到状态机中

Apply(应用)是指将已提交的日志条目应用到状态机中,以便状态机的状态能够更新到与Leader节点相同的状态。Apply的过程是在每个节点上独立进行的,而Commit的过程是在整个Raft集群中协同进行的。

commitIndex 与 applyIndex

它们的关系是 commitIndex <= applyIndex。因为只有被commit 的指令才能被应用到状态机中

在 Raft 算法中,只有被大多数节点确认的指令才能被认为是已提交的。这意味着在 commitIndex 确定之前,即使 Leader 已经确认了某些指令,它们也不会被认为是已提交的。只有当 commitIndex 更新到这些指令的索引时,它们才会被认为是已提交的。

需要注意的是,applyIndex 和 commitIndex 都是需要持久化的,以确保系统在节点宕机或网络故障等情况下能够恢复状态。


raftstate和snapshot的区别

raftstate 是用来记录当前节点的状态机信息的,包括节点已经提交但未被应用的日志项、已知的最新领导人、节点当前的角色等。每个节点都需要定期将自己的 raftstate 发送给其他节点,以便其他节点了解该节点的状态,并在需要的时候进行状态同步。因此,raftstate 是用于节点之间的状态同步的。

snapshot 则是用来记录节点状态机的快照的,以便节点在某些情况下可以通过快照来恢复其状态机。当节点的状态机变得太大,无法通过网络传输,或者由于节点加入或离开导致节点的状态机需要重新同步时,就会使用快照来进行状态机的同步。快照通常包含状态机中的部分信息,以及最后一个已知的日志项的索引,以便节点在接收快照后恢复到正确的状态。

因此,raftstatesnapshot 都是用于记录节点状态机信息的数据,但是它们的作用和用途不同。raftstate 用于节点之间的状态同步,而 snapshot 用于恢复节点的状态机。


如何确保 applier 的 exactly once

对于异步 apply,其触发方式无非两种,1. leader 提交了新的日志 2.follower 通过 leader 发来的 leaderCommit 来更新 commitIndex。很多人实现的时候可能顺手就在这两处分别异步开启一个goroutine把 [lastApplied + 1, commitIndex] 的 entry push 到 applyCh 中,但其实这样子是可能重复发送 entry 的,原因是 push applyCh 的过程不能够持锁,那么这个 lastApplied 在没有 push 完之前就无法得到更新,从而可能被多次调用。

虽然只要上层服务可以保证不重复 apply 相同 index 的日志到状态机就不会有问题,但这样的做法是不优雅的。考虑到异步 apply 时最耗时的步骤是 push channel 和 apply 日志到状态机,其他的都不怎么耗费时间。因此我们完全可以只用一个 applier 协程,让其不断的把 [lastApplied + 1, commitIndex] 区间的日志 push 到 applyCh 中去。这样既可保证每一条日志只会被 exactly once 地 push 到 applyCh 中,也可以使得日志 apply 到状态机和 raft 提交新日志可以真正的并行。


为什么 Leader 不能提交之前任期的日志,只能通过提交自己任期的日志,从而间接提交之前任期的日志? 为什么需要 no-op 日志?
  1. 如果Leader能提交之前任期的日志, 则同一index 的日志可能会被提交了多次,每次的term不一样

  2. no-op 日志即只有 index 和 term 信息,command 信息为空。也是要写到磁盘存储的。为了解决如果一直没新的请求进来最新的日志不能被提交的问题

    具体流程是在 Leader 刚选举成功的时候,立即追加一条 no-op 日志,并立即复制到其它节点,no-op 日志一经提交,Leader 前面那些未提交的日志全部间接提交,问题就解决了。像上面的 kv 数据库,有了 no-op 日志之后,Leader 就能快速响应客户端查询了。本质上,no-op 日志使 Leader 隐式地快速提交之前任期未提交的日志,确认当前 commitIndex,这样系统才会快速对外正常工作。


说一说raft算法的快照机制

Raft算法的快照机制是用来减少存储在日志中的数据量和提高性能的一种机制。当系统中的状态机状态更新时,Raft算法会将状态机状态持久化到日志中。但是,如果不对日志进行限制,日志会不断增长,导致系统性能下降。

为了解决这个问题,Raft算法使用了快照机制。快照机制是通过将状态机的快照存储到磁盘上,从而缩减了日志的长度。当系统中的日志长度达到一定值时,Raft算法会自动触发快照机制,生成一个快照并将其存储到磁盘上。

在Raft算法中,每个节点都有自己的快照,并且在成为leader之前,follower节点的快照必须和leader节点的快照一致。为了保证节点间的一致性,leader节点会周期性地向follower节点发送快照信息。follower节点会根据接收到的快照信息来更新自己的状态机状态,从而保证节点间的状态一致。


raft在什么时候安装快照
  1. 日志长度过长

当系统中的日志长度达到一定值时,Raft算法会触发快照机制,生成一个快照并将其存储到磁盘上。这是因为随着系统运行时间的增加,日志的长度会不断增长,导致系统性能下降,因此需要通过安装快照来减少日志的长度。

  1. 崩溃恢复

当系统发生故障或崩溃后,Raft算法会使用快照来进行恢复。在这种情况下,Raft算法会先从磁盘上读取最近的快照,然后通过读取该快照之后的日志来恢复系统状态。这是因为在发生故障或崩溃时,系统的状态可能已经落后于最新的快照,因此需要通过读取最近的快照来恢复系统状态。

需要注意的是,在安装快照时,Raft算法会将快照应用到状态机上,并更新节点的状态,以保证节点间的一致性。同时,Raft算法还会向其他节点发送更新信息,以通知它们快照已经被安装。


Raft 的优化

如果集群有一个节点网络延迟很大,可能每隔一段时间它就收不到leader的心跳,然后发起投票。而它的currTerm又高于leader,导致leader变成follower,最终导致整个集群的可用性很低?

使用分布式缓存可以解决该问题,因为分布式缓存可以将数据缓存在节点之间,这样在节点之间的数据读写时就可以减少网络传输的数据量,从而减少网络延迟。

当一个节点的网络延迟很大时,可能会导致该节点无法及时响应leader节点的心跳信号,并导致发起选举。在这种情况下,如果使用分布式缓存系统如Redis等,可以将数据缓存在节点之间,这样就可以在多个节点之间共享数据。当需要访问该数据时,可以直接从缓存中读取,而不是每次都从远程节点读取。这样可以减少节点之间的数据传输量,从而减少网络延迟,降低选举的发生率。

当一个节点的网络延迟很大时,可能会导致该节点无法及时响应心跳信号,并导致发起选举。如果使用分布式缓存,节点之间可以缓存一些数据,当需要访问该数据时,可以直接从缓存中读取,而不是每次都从远程节点读取。这样可以减少节点之间的数据传输量,从而减少网络延迟,降低选举的发生率。

此外,使用分布式缓存还可以提高系统的读写性能。当多个节点都需要访问同一份数据时,可以将数据缓存在多个节点中,这样可以避免单个节点的瓶颈,提高整个系统的读写性能。


KV存储的客户端和服务器端模型

对于 raft 的日志序列,状态机需要按序 apply 才能保证不同节点上数据的一致性,因此,在实现中服务器端一定得有一个单独的 apply 协程去顺序的 apply 日志到状态机中去。

对于客户端的Command请求,在服务器端 rpc 框架也会生成一个线程Command去处理逻辑。

为此,我的实现是:

  1. 服务器 将日志传递raft 层,调用Raft的Start(), 用于在 Leader 节点接收到新的客户端请求时,将客户端请求转化为日志条目, 并将这些条目复制到所有节点的日志中,从而实现状态的一致性.
  2. 服务器 随即注册一个 channel 去阻塞等待,我们在启动服务器的时候开启一个applier 线程监控 applyCh,在得到 raft 层已经 commit 的日志后,applier 协程首先将其 apply 到状态机中,接着根据 index 得到对应的 channel ,最后将状态机执行的结果 push 到 channel 中
  3. 这使得客户端能够解除阻塞并得到结果。对于这种只需通知一次的场景,这里使用 channel 而不是 cond 的原因是理论上一条日志被路由到 raft 层同步后,客户端call Command协程拿锁注册 notifyChan 和 applier 协程拿锁执行该日志再进行 notify 之间的拿锁顺序无法绝对保证,虽然直观上感觉应该一定是前者先执行,但如果是后者先执行了,那前者对于 cond 变量的 wait 就永远不会被唤醒了,那情况就有点糟糕了。

在目前的实现中,读请求也会生成一条 raft 日志去同步,这样可以以最简单的方式保证线性一致性。


如果一个 Raft Group 中存在多个 Learner,如何防止多个 Learner 同时同步数据给 Leader 造成压力

raft算法中,follower收到一个日志后发现有冲突怎么办

Follower 会采取以下步骤来解决冲突:

  1. 拒绝接受 Leader 发送的冲突日志。Follower 会向 Leader 发送拒绝响应(AppendEntries RPC 响应)并包含自己的日志中下一个条目的索引(即与 Leader 发送的日志冲突的第一个条目的索引加 1)。
  2. Leader 收到拒绝响应后会尝试减少它的日志匹配的索引。Leader 会将自己的 nextIndex 减少到收到拒绝响应的 Follower 日志索引的位置。
  3. Leader 重试向 Follower 发送日志。在减小 nextIndex 后,Leader 将重试发送上次被拒绝的日志条目。如果仍然发生冲突,Leader 将继续减小 nextIndex 并重试,直到成功地将日志复制到 Follower 中为止。
  4. 一旦 Leader 成功地将日志条目复制到 Follower 中,它会更新 Follower 的匹配索引。Leader 会将 Follower 的 matchIndex 更新为最新复制的日志条目的索引,然后继续发送后续的日志条目。

总之,当 Follower 收到冲突的日志时,它会拒绝该日志并返回自己的下一个条目的索引,Leader 将根据此更新自己的 nextIndex 并重试发送日志,直到成功为止。这样可以确保 Raft 群集中所有的节点最终保持一致的日志。


说说 ACID和CAP

ACID 是一种数据管理的事务处理模型,而 CAP 是分布式计算中的一个理论

C 和 CAP 中的 C: 在 CAP 理论中,C 指的是一致性(Consistency),而在 ACID 中,C 指的是另一个特性,即一致性(Consistency)。虽然它们都叫做一致性,但是它们的含义和应用场景却不同。在 ACID 中,一致性指的是数据在事务执行前后保持一致的状态;而在 CAP 中,一致性指的是在分布式系统中,当多个节点同时对同一数据进行操作时,最终结果必须保证一致。

CAP: CAP 理论指的是在分布式计算中,一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)这三个特性无法同时满足的问题。其中:

  • 一致性(Consistency)指的是所有节点有效的数据是一致的;
  • 可用性(Availability)指的是系统必须保证每个请求都能收到响应;
  • 分区容错性(Partition tolerance)指的是系统在遇到网络故障等分区情况时仍能正常运行。

CAP 理论认为,在分布式系统中,由于网络通信的不可靠性和不确定性,无法同时满足这三个特性,因此需要根据具体的应用场景进行选择。例如,对于金融领域这样需要强一致性的场景,可以牺牲可用性来保证一致性;而对于社交网络这样需要高可用性的场景,可以在一定程度上放弃一致性来换取可用性和分区容错性。

CAP 理论的实现主要依赖于分布式数据存储技术和分布式一致性算法。常见的分布式存储技术包括关系型数据库的分布式部署、NoSQL 数据库、分布式文件系统等。而分布式一致性算法则包括 Paxos、Raft、Zab 等。这些算法的主要目标是保证数据在多个节点之间的一致性,实现 CAP 中的一致性特性。


说说读优化

在目前的实现中,读请求也会生成一条 raft 日志去同步,这样可以以最简单的方式保证线性一致性。当然,这样子实现的读性能会相当的差,实际生产级别的 raft 读请求实现一般都采用了 Read Index

由于只读请求并没有需要写入的数据,因此并不需要将其写入Raft日志,而只需要关注收到请求时leader的commit index。只要在该commit index被应用到状态机后执行读操作,就能保证其线性一致性。因此使用了ReadIndex的leader在收到只读请求时,会按如下方式处理:

记录当前的commit index,作为read index;
向集群中的所有节点广播一次心跳,如果收到了数量达到quorum的心跳响应,leader可以得知当收到该只读请求时,其一定是集群的合法leader;
继续执行,直到leader本地的apply index大于等于之前记录的read index。此时可以保证只读操作的线性一致性;
让状态机执行只读操作,并将结果返回给客户端。


Raft如何实现线性语义

Raft 的目标是要实现线性化语义(每一次操作立即执行,只执行一次,在他调用和收到回复之间)。但是,Raft 是可以执行同一条命令多次的:例如,如果leader在提交了这条日志之后,但是在响应客户端之前崩溃了,那么客户端会和新的leader重试这条指令,导致这条命令就被再次执行了。解决方案就是客户端对于每一条指令都赋予一个唯一的序列号。然后,状态机跟踪每条指令最新的序列号和相应的响应。如果接收到一条指令,它的序列号已经被执行了,那么就立即返回结果,而不重新执行指令。


工程问题

❤️检测内存泄漏(用top发现虚拟内存在涨)

线上系统要做好热更新,方便随时打开内存调试。 #if mem = 1 #endif

法一: malloc_stats() 和 malloc_info(0, stdout)

检测具体函数的内存泄漏: gdb中打断点配合调用malloc_stats()函数 call malloc_stats()

函数执行前后调用call malloc_info(0, stdout)输出内存,比较

法二:宏定义

创建一个文件夹。每次malloc的时候在其中创建一个文件,文件名是本次malloc申请的内存地址。free的时候去该文件夹中查找与没有 那个名为那个地址的文件,有的话就删除

#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>

void* _malloc(size_t size, const char *filename, int line){
void *p = malloc(size);

char buff[128] = {0};
sprintf(buff, "./memleak/%p.mem", p);
FILE *fp = fopen(buff, "w");
fprintf(fp, "[+%s:%d]_malloc:%ld,ptr:%p\n", filename, line, size, p);
fflush(fp);
fclose(fp);

return p;
}

void _free(void *p, const char *filename, int line){
free(p);

char buff[128] = {0};
sprintf(buff, "./memleak/%p.mem", p);
if(unlink(buff) < 0){ // 删除失败
printf("double free: %p\n", p);
return;
}
// printf("[-%s:%d]_free:%p\n", filename, line, p);
return;
}

#define malloc(size) _malloc(size, __FILE__, __LINE__)
#define free(ptr) _free(ptr, __FILE__, __LINE__)
int main(){
void* p1 = malloc(5);
void* p2 = malloc(15);
free(p1);
return 0;
}

image-20230222155844859

法三: hook截获malloc和free, dlsym改成自定义的
#include <dlfcn.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>

typedef void *(*malloc_t)(size_t size);
malloc_t malloc_f; // malloc_f 是 malloc_t 类型的函数指针
typedef void (*free_t)(void *p);
free_t free_f;

int enable_malloc_hook = 1, enable_free_hook = 1; // 防止malloc中循环调用malloc

void *malloc(size_t size){
if (enable_malloc_hook){
enable_malloc_hook = 0;

void *p = malloc_f(size);
void *caller = __builtin_return_address(0);
char buff[128] = {0};
sprintf(buff, "./mem/%p.mem", p);
FILE *fp = fopen(buff, "w");
fprintf(fp, "[+%p]malloc --> addr:%p size:%lu\n", caller, p, size);
fflush(fp);

enable_malloc_hook = 1;
return p;
}
else return malloc_f(size);
}

void free(void *p){
if (enable_free_hook){
enable_free_hook = 0;

char buff[128] = {0};
sprintf(buff, "./mem/%p.mem", p);
if (unlink(buff) < 0) printf("double free: %p\n", p);
free_f(p);

enable_free_hook = 1;
}
else free_f(p);
}

// 将所有malloc和free包括第三方库中的,走我们自定义的那一段
static void init_hook(){
malloc_f = dlsym(RTLD_NEXT, "malloc"); // 获取 malloc 地址为 malloc_f, 因此调用malloc_f等价于调用malloc
free_f = dlsym(RTLD_NEXT, "free");
}

// gcc -o memleak_0 memleak_0.c -ldl -g
// addr2line -f -e memleak_0 -a 0x4006d8 (-f表示file -e表示execute -a表示代码段地址)可以查看 内存泄漏的行数
int main(){
init_hook();
void *p1 = malloc(10);
void *p2 = malloc(20);
free(p1);

return 0;
}
法四: mtrace

mtrace() 函数中会为那些和动态内存分配有关的函数(譬如 malloc()、realloc()、memalign() 以及 free())安装 “钩子(hook)” 函数,这些 hook 函数会为我们记录所有有关内存分配和释放的跟踪信息,而 muntrace() 则会卸载相应的 hook 函数。基于这些 hook 函数生成的调试跟踪信息,我们就可以分析是否存在 “内存泄露” 这类问题了。

#include <mcheck.h>
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv){
mtrace(); // 开始跟踪

char *p = (char *)malloc(100);

free(p);
p = NULL;

p = (char *)malloc(100);

muntrace(); // 结束跟踪,并生成日志信息
return 0;
}
gcc -g test.c -o test  // 一定加上 -g 能够帮我们定位代码中的具体位置,否则看到的只是执行文件中的地址信息

mtrace 机制需要我们实际运行一下程序,然后才能生成跟踪的日志,但在实际运行程序之前还有一件要做的事情是需要告诉 mtrace生成日志文件的路径。具体的方法是通过定义并导出一个环境变量 “MALLOC_TRACE”,如下所示。

export MALLOC_TRACE=./test.log  // 当前目录下

image-20230226200347354

image-20230226200602769


查看链接了哪些动态库和第三方库有无内存泄漏
  • 查看链接了哪些动态库
ldd a.so	//查看SO文件的动态链接库
nm -D a.so //查看so文件的函数列表
objdump -tT a.so //查看so文件的导出函数及源文件等信息
  • Linux/unix 提供了使用 dlopen 和 dlsym 方法动态加载库和调用函数
#include <dlfcn.h>
//打开指定的动态库,返回的是该动态库的handle,在dlsym,dlclose中将继续使用。
void *dlopen(const char *filename, int flag);
// RTLD_LAZY 暂缓决定,等有需要时再解出符号
// RTLD_NOW 立即决定,返回前解除所有未决定的符号

//查看错误信息
char *dlerror(void);
//获得对应的函数或变量
void *dlsym(void *handle, const char *symbol);
//关闭打开的动态库
int dlclose(void *handle);

c++程序崩溃定位方法
  1. 使用调试器

    使用调试器是定位程序崩溃的最常用方法之一。你可以使用诸如GDB或Visual Studio等调试器。调试器能够提供有关程序崩溃的详细信息,如程序的状态、变量值、函数调用堆栈等。

  2. 输出调试信息

    在程序中插入调试信息,可以帮助你了解程序崩溃的位置和原因。你可以在关键位置打印变量的值或者调用函数,以确定程序执行到哪里就崩溃了。

  3. 内存检测工具

    使用内存检测工具可以帮助你检测内存泄漏、野指针等问题。例如,使用mtrace, Valgrind可以在Linux环境下检测内存问题。

  4. 静态代码分析工具

    使用静态代码分析工具可以帮助你找到潜在的代码缺陷,例如未初始化的变量、函数调用不匹配等。例如,Cppcheck是一款常用的静态代码分析工具。

  5. 测试用例

    编写测试用例可以帮助你定位程序崩溃的原因。测试用例应该包含各种输入和边界条件,以确保程序能够处理各种情况。如果测试用例能够触发程序崩溃,就可以进一步分析和修复问题。


git 如何查看远程服务器分支

要查看远程服务器上的分支,可以使用 git branch -r 该命令会显示所有已知的远程分支列表。

如果你想查看特定远程分支的详细信息,可以使用 git show <remote-name>/<branch-name> 命令,其中 remote-name 是远程服务器的名称,branch-name 是要查看的分支名称。例如,要查看名为 master 的远程分支的详细信息,可以运行以下命令:

git show origin/master

请注意,在使用此命令之前,你需要先确保已经将远程分支拉取到本地仓库中,否则该命令将无法正常工作。可以使用 git fetch 命令将远程分支更新到本地仓库

6️⃣其他

金融知识

期权与期货的区别

期权(Options):期权是一种合约,它给予买方(持有者)在未来某个特定时间(欧式期权)或一段时间内(美式期权)以特定价格(行权价格)买入(看涨期权)或卖出(看跌期权)一定数量的基础资产的权利,但不是义务。期权买方需支付给卖方(期权发行者)一定的费用,称为期权费或权利金。

期货(Futures):期货是一种标准化合约,它要求合约的买方和卖方在未来的特定日期以合约规定的价格买入或卖出一定数量的基础资产。与期权不同,期货合约的买卖双方都有义务履行合约,即在到期日必须进行实物交割或现金结算。

区别

  1. 履约义务:期权给予持有者权利而非义务,而期货则要求双方履行合约。
  2. 风险暴露:期权买方的风险限于支付的权利金,而期货合约可能面临无限风险。
  3. 成本:期权需要支付权利金,期货通常不需要支付费用,但可能需要缴纳保证金。
  4. 到期日:期权有欧式和美式之分,而期货通常只有一个到期日。
  5. 可执行性:期权可在到期日之前或到期时执行,而期货合约在到期时必须履行。
  6. 合约规模:期权的合约规模通常较小,而期货合约通常是标准化且规模较大。

联系

  1. 衍生性:两者都是衍生工具,价格依赖于基础资产。
  2. 市场参与者:期权和期货市场通常吸引相同类型的参与者,包括投资者、对冲基金、机构投资者等。
  3. 交易场所:期权和期货往往在同一交易所或相关市场上交易。
期货中主力合约与普通合约的关系

在期货市场中,”主力合约”指的是在所有可交易的同一商品的期货合约中,交易量最大、流动性最好的合约。主力合约通常是投资者和交易者最为关注的合约,因为高流动性意味着交易者可以更容易地进入和退出市场,通常也意味着较小的买卖价差。

主力合约的特点:

  1. 交易量大:主力合约的交易量通常远远超过其他月份的合约,因为大多数交易者都集中在这些合约上进行交易。
  2. 流动性高:由于交易量大,买卖双方更容易找到交易对手,因此主力合约的流动性也会相应更高。
  3. 价格代表性强:主力合约的价格通常被视为该商品当前市场价格的最佳代表,因为它反映了市场最活跃部分的供求关系。

与普通合约的关系:

普通合约或者说非主力合约,指的是除了主力合约之外的其他月份的期货合约。这些合约可能交易量较小,流动性较差,因此在实际交易中可能面临较大的买卖价差和较难找到交易对手的问题。

主力合约和普通合约之间的关系主要体现在:

  1. 流动性转移:随着合约的临近到期,交易者会将持仓从旧的主力合约转移到新的主力合约,这个过程称为”换月”。因此,主力合约的地位可能会随着时间推移而在不同月份的合约之间转移。
  2. 价格连续性:虽然不同月份的合约可能因为季节性因素、存储成本等原因而价格有所不同,但这些合约的价格通常会保持一定的连续性,因为它们基于同一基础商品。
  3. 对冲使用:在对冲时,企业可能会选择更接近其预期交割日期的合约,即使这些合约不是当前的主力合约。

在实际操作中,交易者通常会关注主力合约的价格走势作为交易决策的依据,同时也会关注主力合约转移的情况,以便在合适的时机进行换仓操作。交易所和市场分析师也常常以主力合约的价格来报价和进行市场分析。

智力题

沙漏计时问题

有一个能计时6分钟的小沙漏和一个能计时8分钟的大沙漏,如何计时10分钟?

1.两个沙漏同时倒置开始计时,等小沙漏漏完,大沙漏还剩2分钟,这时倒置小沙漏继续计时;
2.大沙漏漏完小沙漏还剩4分钟,再把大沙漏倒置继续计时;
3.小沙漏漏完大沙漏还剩4分钟,这时准备工作已经完毕;
4.等待大沙漏漏完(4分钟)+小沙漏(6分钟)=10分钟。

吃药片问题

某种药方要求非常严格,你每天需要同时服用A、B两种药片各一颗,不能多也不能少。这种药非常贵,你不希望有任何一点的浪费。一天,你打开装药片A的药瓶,倒出一粒药片放在手心;然后打开另一个药瓶,但不小心倒出了两粒药片。现在,你手心上有一颗药片A,两颗药片B,并且你无法区别哪个是A,哪个是B。你如何才能严格遵循药方服用药片,并且不能有任何的浪费?

1. 把手上的三片药各自切成两半,分成两堆摆放;
2. 再取出一粒药片 A,也把它切成两半,然后在每一堆里加上半片的 A;
3. 现在,每一堆药片恰好包含两个半片的 A 和两个半片的 B;
4. 一天服用其中一堆即可。

老鼠毒药问题
// 步骤一: 给所有瓶子转化为二进制
10000000001
20000000010
30000000011
40000000100
......10001111101000
// 步骤二: 让第i只老鼠喝二进制表示第i位为一的所有瓶子
// 一星期后看第几只老鼠死了, 比如第1,3,5只死了, 则毒药编号就是 1010100000

箱子开锁问题

A、B两人分别在两座岛上。B生病了,A有B所需要的药。C有一艘小船和一个可以上锁的箱子。C愿意在A和B之间运东西,但东西只能放在箱子里。只要箱子没被上锁,C都会偷走箱子里的东西,不管箱子里有什么。如果A和B各自有一把锁和只能开自己那把锁的钥匙,A应该如何把东西安全递交给B?

1. A 把药放进箱子,用自己的锁把箱子锁上;
2. B 拿到箱子后,再在箱子上加一把自己的锁;
3. 箱子运回 A 后,A 取下自己的锁;
4. 箱子再运到 B 手中时,B 取下自己的锁,获得药物。

人鬼过桥问题

有三个人跟三个鬼要过河,河上没桥只有条小船,然后船一次只能渡一个人和一个鬼,或者两个鬼和两个人,无论在哪边岸上,只有是人比鬼少的情况下(如两鬼一人,三鬼两人,三鬼一人),人会被鬼吃掉,然而船有一定需要人或鬼操作才能航行(要有人或鬼划船),问,如何安全的把三人三鬼渡过河对岸?

1. 先两鬼过去,再一鬼回来。此时,对面有一鬼,这边有三人两鬼;
2. 再两鬼过去,再一鬼回来。此时对面有两鬼,这边有三人一鬼;
3. 再两人过去,一人一鬼回来。此时,对面一人一鬼。这边两人两鬼;
4. 最后两人过去,一鬼回来。此时,对面三人,这边三鬼;
5. 剩下的就三个鬼,两个过去,一个回来再接另外一个鬼就结束了。

赛马找最快的马匹

25匹马5条跑道找最快的3匹马,需要跑几次?参考回答:7

64匹马8条跑道找最快的4匹马,需要跑几次?参考回答:11

25匹马5条跑道找最快的5匹马,需要跑几次?参考回答:最少8次最多9次

25匹马5条跑道找最快的3匹马

将25匹马分成ABCDE5组,假设每组的排名就是A1>A2>A3>A4>A5,这里比赛5次

第6次,每组的第一名进行比赛,可以找出最快的马,这里假设A1>B1>C1>D1>E1

D1,E1肯定进不了前3,直接排除掉。 第7次,B1 C1 A2 B2 A3比赛,可以找出第二,第三名

64匹马8条跑道找最快的4匹马

第一步:全部马分为8组,每组8匹,每组各跑一次,然后淘汰掉每组的后四名

第二步:取每组第一名进行一次比赛,然后淘汰最后四名所在组的所有马。这个时候总冠军已经诞生,它就是这场比赛第一名

第三步:可能是前四名的只能是下面淡黄色的9只,随机选出8匹马进行一次比赛

image-20230309193955191

第四步:上面比赛完,选出了前三名,但是9匹马中还有一匹马没跑呢,就和前三名比一比,这四匹马比一场,选出前三名。最后加上总冠军,跑得最快的四匹马诞生了

场景题

如何将敏捷开发应用到项目中

敏捷开发是一种以人为本、迭代、快速响应变化的软件开发方法。

1.明确需求:与利益相关者合作,以确定项目需求,以及确定优先级和业务价值。

2.规划Sprint:根据需求,规划一系列Sprint,每个Sprint都是一个短期的开发周期,通常为1到4周。

3.制定任务:将每个Sprint拆分为可管理的任务,每个任务都应该很小且具体,以便在Sprint期间完成。

4.持续开发和集成:在Sprint期间,团队成员应该持续进行开发,并将代码集成到主干分支中,以便进行测试和审查。

5.持续交付:在每个Sprint结束时,将交付可用的软件功能,以便利益相关者进行测试和反馈。

6.迭代和反馈:利用利益相关者的反馈,对需求进行迭代,并对Sprint计划进行调整。

7.持续集成和交付:在整个开发过程中,应该持续集成和交付可用的软件功能。

8.团队合作:敏捷开发强调团队合作和通信,团队成员应该经常交流和协作,以确保项目的顺利开展。

9.自我评估:在每个Sprint结束时,团队应该进行自我评估,以确定在下一个Sprint中需要改进的方面。

微内核架构有什么特点

微内核架构是一种操作系统设计模式,其核心思想是将操作系统内核中的大部分功能剥离出来,构建成一个小巧的内核,只保留最基本的功能,如进程管理、内存管理和线程调度等。其他的操作系统功能则通过进程间通信(IPC)的方式在用户空间实现,这样可以提高系统的稳定性、安全性和可维护性。微内核架构有以下几个主要特点:

  1. 简洁、可靠:微内核架构将操作系统内核中的大部分功能剥离出来,只保留最基本的功能,简化了内核的设计和实现,可以提高内核的可靠性。
  2. 灵活、可扩展:由于微内核架构将大部分操作系统功能移到用户空间实现,因此可以方便地对系统进行功能扩展和修改,具有很强的灵活性。
  3. 安全、可维护:微内核架构将操作系统功能拆分成多个独立的进程,通过进程间通信实现功能交互,使得系统中的每个功能模块都可以独立运行和维护,从而提高系统的安全性和可维护性。
  4. 性能:尽管微内核架构会引入额外的进程间通信开销,但由于内核只保留了最基本的功能,相比于传统的宏内核架构,微内核架构具有更好的性能表现。