整理一些C++知识, 主要参考这里,文中引用已给出连接。
C/C++
封装、继承、多态、重载、覆盖、隐藏
-
面向对象的三个特征:
- 封装:就是把客观事物封装为抽象的类,且类可以把自己的数据和方法只让可信的对象或者类进行操作,对不可信的类进行隐藏;
- 继承:可以使用现有类的所有功能,并在无需重新编写的情况写对这些功能进行拓展。通过继承产生了基类和派生类。继承是一种从一般到复杂的过程。
- 基类的数据成员和成员函数在派生类中都有一份拷贝,派生类能够直接访问从基类继承而来的public和protected成员,且只能够通过这两类成员访问从基类继承而来的private成员
- 多态:同一操作作用于不同对象,可以有不同的解释,产生不同的执行结果。在运行时,可以通过指向基类的指针,来调用实现派生类中的方法。
-
多态
- 多态是以封装和继承为基础的,分为静态多态与动态多态两种
- 静态多态:函数重载, 运算符重载属于静态多态, 复用函数名。函数地址早绑定:编译阶段确定函数地址。
- 动态多态:派生类和虚函数实现运行时多态。
-
静态多态(编译器、早绑定)
-
动态多态
- 虚函数:用virtual修饰成员函数,使其成为虚函数。
重载和覆盖
- 重载:两个函数名相同,但是参数列表不同(个数,类型),返回值类型没有要求,在同一作用域中
- 重写:子类继承了父类,父类中的函数是虚函数,在子类中重新定义了这个虚函数,这种情况是重写
构造函数和析构函数
虚函数与纯虚函数
- 虚函数:virtual 修饰的成员函数。 类里如果声明了虚函数,这个类就是实现的,哪怕是空实现。作用是可以让这个函数在子类里面能被覆盖。
- 纯虚函数:virtual 返回值类型 函数名(形参)=0;
virtual void fun()=0;
。纯虚函数只是一个接口,是个函数声明,需要在子类里去实现。
- 虚函数在子类里可以不用重写,但是纯虚函数必须在子类实现才可以实例化子类。
- 虚函数的类用于 实作继承 ,继承接口的同时也继承了父类的实现。纯虚函数关注的是接口的统一性,实现由子类完成。
- 带纯虚函数的类叫抽象类, 这种类不能直接生成对象,只能被继承,并且只有重写虚函数之后才可以使用。抽象类被继承后,子类可以继续是抽象类也可以是普通类。
- 虚基类是虚继承中的基类。
虚函数指针,虚函数表
- 虚函数指针:在含有虚函数的对象中,指向虚函数表,在运行时确定。
- 虚函数表:在程序只读数据段,存放虚函数指针,如果派生类实现了基类的某个虚函数,则在虚表中覆盖原本基类的那个虚函数指针,在编译时根据类的声明创建。
为什么析构函数必须是虚函数?为什么C++默认的析构函数不是虚函数?
- 将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。
- C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚函数指针,占用额外的内存。对于不会被继承的类来说,其析构函数若是虚函数,会浪费内存。因此C++默认的析构函数不是虚函数,而只有当需要当做父指针时,设置为虚函数。
静态函数和虚函数的区别
- 静态函数在编译的时候就已经确定运行时机,虚函数在运行的时候动态绑定。虚函数使用虚函数表机制,调用的时候会增加一次内存开销。
你理解的虚函数和多态
-
多态的实现主要分为静态多态和动态多态,静态多态主要是重载,在编译的时候就已经确定;动态多态是用虚函数机制实现的,在运行期间动态绑定。举个例子:一个父类类型的指针指向一个子类对象时候,使用父类的指针去调用子类中重写了的父类中的虚函数的时候,会调用子类重写过后的函数,在父类中声明为加了virtual关键字的函数,在子类中重写时候不需要加virtual也是虚函数。
-
虚函数的实现:在有虚函数的类中,类的最开始部分是一个虚函数表的指针,这个指针指向一个虚函数表,表中放了虚函数的地址,实际的虚函数在代码段(.text)中。当子类继承了父类的时候也会继承其虚函数表,当子类重写父类中虚函数时候,会将其继承到的虚函数表中的地址替换为重新写的函数地址。使用了虚函数,会增加访问内存开销,降低效率。
虚函数表具体是怎么样实现运行时多态的
子类若重写父类虚函数,虚函数表中,该函数的地址会被替换。对于存在虚函数的类的对象,对象的对象模型的头部存放指向虚函数表的指针,通过该机制实现多态。
C++中类成员的访问权限
C++通过public,protected,private三个关键字来控制成员变量和成员函数的访问权限,他们分别表示共有的,受保护的,私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为public,protected还是private,都是可以互相访问的,没有访问权限的限制。类的外部(定义代码除外),只能通过对象访问成员,并且通过对象只能访问public属性的成员。
struct和class的区别
在C++中,可以用struct和class定义类,都可以继承。区别在于:struct的默认继承权限和默认访问权限是public,而class的默认继承和访问权限是private;
另外,class可以定义模板类形参,如 template
C++ 类内可以定义引用数据成员吗
可以,但是必须通过成员函数列表初始化。
指针和引用
- 定义:
- 引用:C++是C语言的继承,它可进行过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行以继承和多态为特点的面向对象的程序设计。引用就是C++对C语言的重要扩充。引用就是某一变量的一个别名,对引用的操作与对变量直接操作完全一样。引用的声明方法:类型标识符 &引用名=目标变量名;引用引入了对象的一个同义词。定义引用的表示方法与定义指针相似,只是用&代替了*。
- 指针:指针利用地址,它的值直接指向存在电脑存储器中另一个地方的值。由于通过地址能找到所需的变量单元,可以说,地址指向该变量单元。因此,将地址形象化的称为“指针”。意思是通过它能找到以它为地址的内存单元。
- 区别:
- 指针有自己的一块空间,引用只是一个别名;
- 指针可以被初始化为NULL(空),而引用必须初始化且必须是一个已有对象的引用;
- 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用修改都会改变引用所指向的对象;
- 可以有const指针,但是没有const引用;
- 指针在使用中可以指向其他对象,但是引用只能是一个对象的引用,不能被改变;
- 指针可以有多级指针(**p),而引用只有一级;
- 指针和引用使用++运算符的意义不同;
int a = 0; int b = &a; int *p = &a; p++; b++;
- 如果返回动态内存分配的对象或者内存,必须使用指针,使用引用可能会引起内存泄漏;
- 使用sizeof看,指针是一个指针的大小,而引用是被引对象的大小;
const
作用:
- 修饰变量,说明该变量不能被改变;
- 修饰指针,分为指向常量得到指针与自身是常量的指针;
- 修饰引用,指向常量的引用,用于形参类型,即避免了拷贝,又避免了函数对值得修改;
- 修饰成员函数,说明该成员函数不能修改成员变量。
const 指针与作用:
-
指针
- 指向常量得指针
char* ptr = const int a;
- 是常量的指针
const chat* ptr = &a
-
引用
- 指向常量的引用
fun(const string &str)
- 引用本身就是常量
const string str; fun(str);
-
详解:
const char p
限定变量p为只读。这样如p=2这样的赋值操作就是错误的。
const char *p
p为一个指向char类型的指针,const只限定p指向的对象为只读。这样,p=&a或 p++等操作都是合法的,但如*p=4这样的操作就错了,因为企图改写这个已经被限定为只读属性的对象。
char *const p
限定此指针为只读,这样p=&a或 p++等操作都是不合法的。而*p=3这样的操作合法,因为并没有限定其最终对象为只读。
const char *const p
两者皆限定为只读,不能改写。
const char **p
p为一个指向指针的指针,const限定其最终对象为只读,显然这最终对象也是为char类型的变量。故像**p=3
这样的赋值是错误的,而像*p=? p++这样的操作合法。
const char * const *p
限定最终对象和 p指向的指针为只读。这样 *p=?的操作也是错的。
const char * const * const p
全部限定为只读,都不可以改写。
char greeting[] = "Hello";
char* p1 = greeting; // 指针变量,指向字符数组变量
const char* p2 = greeting; // 指针变量,指向字符数组常量(const 后面是 char,说明指向的字符(char)不可改变)
char* const p3 = greeting; // 自身是常量的指针,指向字符数组变量(const 后面是 p3,说明 p3 指针自身不可改变)
const char* const p4 = greeting; // 自身是常量的指针,指向字符数组常量
static
作用:
- 修饰普通变量,放置在静态存储区,main函数之前就分配了空间,有储值按照初值进行初始化,没有使用系统默认值进行初始化。变量在程序运行期间一直存在。局部静态函数结束后不可访问,再次调用函数可用。
- 修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。不可extern,不可写在头文件中。
- 修饰成员变量,使所有对象共享该变量,且不需要生成对象就可以访问该成员。
- 修饰成员函数,使得不需要生成对象就可以访问该函数,但是在static函数内不可访问非静态成员。
volatile
volatile int i = 10;
- volatile 关键字是一种类型修饰符,用它声明的变量表示可以被某些编译器未知的因素(操作系统、硬件、其他线程等)更改。使用volatile告诉编译器不对这种对象进行优化。
- volatile关键字声明的变量每次访问必须从内存取值(未被volatile修饰可能由于编译器的优化,直接从CPU寄存器取值)
- const 可以是volatile(如只读的状态寄存器);
- 指针可以是volatile。
explicit(显示)关键字
C++中的关键字explicit主要是用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换。类构造函数默认情况下声明为隐式的即implicit
struct A
{
A(int) { }
operator bool() const { return true; }
};
struct B
{
explicit B(int) {}
explicit operator bool() const { return true; }
};
void doA(A a) {}
void doB(B b) {}
int main()
{
A a1(1); // OK:直接初始化
A a2 = 1; // OK:复制初始化
A a3{ 1 }; // OK:直接列表初始化
A a4 = { 1 }; // OK:复制列表初始化
A a5 = (A)1; // OK:允许 static_cast 的显式转换
doA(1); // OK:允许从 int 到 A 的隐式转换
if (a1); // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a6(a1); // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a7 = a1; // OK:使用转换函数 A::operator bool() 的从 A 到 bool 的隐式转换
bool a8 = static_cast(a1); // OK :static_cast 进行直接初始化
B b1(1); // OK:直接初始化
B b2 = 1; // 错误:被 explicit 修饰构造函数的对象不可以复制初始化
B b3{ 1 }; // OK:直接列表初始化
B b4 = { 1 }; // 错误:被 explicit 修饰构造函数的对象不可以复制列表初始化
B b5 = (B)1; // OK:允许 static_cast 的显式转换
doB(1); // 错误:被 explicit 修饰构造函数的对象不可以从 int 到 B 的隐式转换
if (b1); // OK :被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b6(b1); // OK:被 explicit 修饰转换函数 B::operator bool() 的对象可以从 B 到 bool 的按语境转换
bool b7 = b1; // 错误:被 explicit 修饰转换函数 B::operator bool() 的对象不可以隐式转换
bool b8 = static_cast(b1); // OK:static_cast 进行直接初始化
return 0;
}
assert()
- 断言,而非宏。其作用是如果它的条件返回错误,则终止程序执行。可以通过定义 NDEBUG 来关闭 assert,但是需要在源代码的开头,include 之前。
#define NDEBUG // 加上这行,则 assert 不可用
#include
assert( p != NULL ); // assert 不可用
attribute
在main函数之前执行:
__attribute() void before_main(){}
sizeof()
- 对对象表示对象所占空间大小;
- 对指针表示指针本身所占空间大小。
左值与右值引用
- 概念:
- 左值:能对表达式取地址,或具名对象/变量。一般指表达式结束后依然存在的持久对象;
- 右值: 不能对表达式取地址,或匿名对象。一般指表达式结束就不在存在的临时变量。
int i = 1;
int * p = i;
int a = &p; //左值引用;
int &&r = i*2; //右值引用,将i*2的结果绑定到r上;
- 区别:
- 左值可以寻址,右值不可以;
- 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值;
- 左值可变,右值不可变;
this指针
- this指针是隐藏于每一个非静态成员函数中隐含的一个参数,它指向调用该成员函数的那个指针。
- 当一个对象调用成员函数时,编译程序会首先将对象的地址赋值给this,然后通过this存取数据成员。
- 当一个成员函数被调用时,会自动向他传递一个隐含的参数,即该成员函数所在对象的指针。
- this 被隐式的声明为
CLASSNAME *const this
, 说明不可修改。
- this 不是常规变量,是一个右值,不能取地址(
&this
)。
- 以下场景经常使用this"
- 为实现对象的链式引用;
- 为避免对同一对象进行赋值操作;
- 在实现一些数据结构时,如list。
智能指针
智能指针的作用是管理一个指针,因为存在以下这种情况:申请的空间在函数结束时忘记释放,造成内存泄漏。使用智能指针可以很大程度上避免这个问题,因为智能指针是一个类,当超出了类的作用域时,类会自动调用析构函数,析构函数会自动释放资源,所以智能指针的原理就是在函数结束时自动释放内存空间。
-
auto_ptr:
-
unique_ptr:采用独占式拥有,意味着一个对象和其相应的资源在同一时间只能被一个pointer拥有。一旦销毁或者编程empty,或开始拥有另外一个对象,先前拥有的那个对象就会被销毁,其资源也被释放。对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用。
unique_ptr p3 (new string ("auto")); //#4
unique_ptr p4; //#5
p4 = p3;//此时会报错!!
编译器会认为 p4 = p3非法,避免了p3不在指向有效数据的问题。另外,如果unique_ptr是个临时右值,编译不会报错,如果源unique_ptr将存在一段时间,编译器将禁止这么做:
unique_ptr pu1(new string ("hello world"));
unique_ptr pu2;
pu2 = pu1; // #1 not allowed
unique_ptr pu3;
pu3 = unique_ptr(new string ("You")); // #2 allowed
说明:
-
shared_ptr:多个智能指针共享一个对象,对象的最末一个拥有销毁对象的责任,并清理与该对象有关的所有资源。其使用计数机制来表明资源被几个指针共享,可以通过成员函数use_count()来查看资源的所有者个数。处理可以通过new创建,还可以通过传入auto_ptr, unique_ptr, weak_ptr来构造。调用release()时当前指针会释放资源的所有权,计数减一。计数为0时,资源被释放。
-
weak_ptr:weak_ptr不控制对象的生命周期,它是对shares_ptr管理的对象,进行该对象内存管理的是那个强引用的shares_ptr。 weak_ptr设计的目的是为配合shared_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 pb_;
~A()
{
cout<<"A delete\n";
}
};
class B
{
public:
shared_ptr pa_;
~B()
{
cout<<"B delete\n";
}
};
void fun()
{
shared_ptr pb(new B());
shared_ptr pa(new A());
pb->pa_ = pa;
pa->pb_ = pb;
cout<
可以看到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得到释放。
智能指针有没有内存泄漏的情况
当两个对象相互使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄漏。如上描述。
智能指针的内存泄漏如何解决
为了解决循环引用导致的内存泄漏,引入了weak_ptr弱指针,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但不指向引用计数的共享内存,但是其可以检测到所管理的对象是否已经被释放,从而避免非法访问。
野指针
野指针就是指向一个已删除的对象或者未申请访问受限内存区域的指针;
隐式类型转换
- 对于内置类型,低精度给高精度的变量赋值会发生隐式类型转换;
- 对于只存在单个参数的构造函数的对象构造来说,函数调用可以直接使用该参数输入,编译器会启动调用其构造函数生成临时变量;
隐式 类 类型 转换
调用单个实参的构造函数定义了从形参类型到该类类型的一个隐式转换。
eg:
class Student
{
public:
Student() { }
Student(int age) { }
};
class Teacher
{
public:
Teacher() { }
Teacher(int age, string name="unkown") { }
};
Student foo;
Teacher bar;
foo = 12; //隐式转换
bar = 40; //隐式转换
Student类的构造函数仅需要一个实参就能构造对象。Teacher类的构造函数有两个参数,但是由于第二个参数提供了默认值,也可以通过一个参数构造对象。因此,按照C++规则,这两个类的构造函数都实现了隐式转换功能。上述的隐式类型转换同样由编译器完成,编译器通过构造函数构造出一个临时对象,并将其复制为foo和bar。
- 优点就是语法简洁,省事儿。
- 缺点就比较多了:
- 容易隐藏类型不匹配的错误;
- 代码更难阅读;
- 接收单个参数的构造方法可能会意外地被用做隐式类型转换;
不能清晰地定义那种类型在何种那个情况下应该被隐式转换,从而使代码变得晦涩难懂。
类型转换
- static_cast:
- 用于非多态类型的转换
- 不执行运行时类型检查(安全性不如dynamic_case)
- 通常用于转换数值数据类型(如 float -> int)
- 可以在类层次结构中移动指针,子类转化为父类安全,父类转为子类不安全。
- dynamic_cast
- 用于多态类型的转换;
- 执行运行时类型检查;
- 只适用于指针或引用;
- 对不明确的指针的转换将失败,返回nullptr,但不会引发异常;
- 可以再整个类层次中移动指针,包括向上转换、向下转换;
- const_cast
- 用于删除const、volatile和__unaligned(如将
const int ->int
)
- reinterpret_cast
- 用于位的简单重新解释
- 允许将任何指针转换为任何其他指针类型(如 char* 到 int* 或 One_class* 到 Unrelated_class* 之类的转换,但其本身并不安全)
- reinterpret_cast 运算符不能丢掉 const、volatile 或 __unaligned 特性。
::范围解析运算符
C++内存管理
在C++中,虚拟内存分为代码段、数据段、BSS段、堆区、文件映射区以及栈区六部分。
- 代码段:包括只读存储区和文本区,其中只读存储区存储字符串常量,文本存储区存储程序的机器代码;
- 数据段:存储程序中已初始化的全局变量和静态变量;
- BSS段:存储为初始化的全局变量和静态变量(局部+全局),以及所有被初始化为0的全局变量和静态变量。
- 堆区:调用new/malloc函数时在堆区动态分配内存,同时需要调用delete/free来手动释放申请的内存。
- 映射区:存储动态链接库以及调用mmap函数进行的文件映射;
- 栈:使用栈空间存储函数的返回地址,参数,局部变量,返回值;
内存分配和管理
- malloc、calloc、realloc、alloca
- malloc: 申请指定字节数的内存。申请到的内存中的初始值不确定。
- calloc: 为指定长度的对象,分配能容纳其指定个数的内存。申请到的内存的每一位(bit)都初始化为 0。
- realloc: 更改以前分配的内存长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定。
- alloca:在栈上申请内存。程序在出栈的时候,会自动释放内存。但是需要注意的是,alloca 不具可移植性, 而且在没有传统堆栈的机器上很难实现。alloca 不宜使用在必须广泛移植的程序中。C99 中支持变长数组 (VLA),可以用来替代 alloca。
- malloc、free
- 用于分配释放内存;
- 申请内存,并确认申请成功, 最后释放内存后指针置空(避免野指针):
char *str = (char*) malloc(100);
assert(str != nullptr);
free(p);
p = nullptr;
- new、delete
- new / new[]:完成两件事,先底层调用 malloc 分配了内存,然后调用构造函数(创建对象);
- delete/delete[]:也完成两件事,先调用析构函数(清理资源),然后底层调用 free 释放空间;
- new 在申请内存时会自动计算所需的字节数,而malloc需要用户自己输入申请的内存空间地址。
- 用法:
int main()
{
T* t = new T(); // 先内存分配 ,再构造函数
delete t; // 先析构函数,再内存释放
return 0;
}
- new/delete是C++的关键字,mallo/free是C语言的库函数,后者使用必须指明申请内存空间的大小,对于类类型的对象,后者不会调用构造函数与析构函数;
内存溢出和泄漏
-
内存泄漏
- 定义:指程序在申请内存后,无法释放已申请的内存空间。
- 原因:内存泄漏通常是由于调用了malloc/new等内存申请操作,但是缺少对应的free/delete。
- 检查:
- 使用linux环境下的内存泄漏检查工具Valgrind;
- 在写代码时添加内存申请和释放的统计功
能,统计当前申请和释放的内存是否一致判断是否泄漏。
-
内存溢出
- 定义:程序在申请内存时,没有足够的内存空间供其使用,出现
out of memory
;
- 原因:泄漏的太多;
- 解决:通过内存映像分析工具对Dump出来的堆转储快照进行分析
堆和栈
- 栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。在一个进程中,位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数的调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩;
- 堆,就是那些由 new 分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个 new 就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。堆可以动态地扩展和收缩;
- 自由存储区,就是那些由 malloc 等分配的内存块,他和堆是十分相似的,不过它是用 free 来结束自己的生命的;
- 全局/静态存储区,全局变量和静态变量被分配到同一块内存中;
- 常量存储区,这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改
- 堆和栈的区别:
- 管理方式:栈是编译器自动管理,堆需要程序员控制,容易产生内存泄漏;
- 空间大小:32位系统堆可以达到4G,栈比较小(1M);
- 碎片问题:对于堆来讲,频繁的 new/delete 势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出;
- 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
- 分配方式:堆都是动态分配的,没有静态分配的堆。栈有2种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由 malloc 函数进行分配;
- 分配效率:栈的效率比堆高。
external C 是什么
C++调用C函数需要extern C,因为C语言没有函数重载;
inline 内联函数
-
特征:
- 相当于把内联函数里面的内容写在调用内联函数处;
- 相当于不执行进入函数的步骤直接执行函数体;
- 相当于宏,但比宏多了类型检查,真正具有函数特性;
- 编译器一般不内联包含循环、递归、switch等复杂操作的内联函数;
- 在类声明中定义的函数,除了虚函数的其他函数都会自动隐式的当成内联函数。
-
使用:
// 声明1(加 inline,建议使用)
inline int functionName(int first, int second,...);
// 声明2(不加 inline)
int functionName(int first, int second,...);
// 定义
inline int functionName(int first, int second,...) {/****/};
// 类内定义,隐式内联
class A {
int doA() { return 0; } // 隐式内联
}
// 类外定义,需要显式内联
class A {
int doA();
}
inline int A::doA() { return 0; } // 需要显式内联
-
编译器对inline函数的处理步骤
- 将inline函数体复制到inline函数调用点处;
- 为所用inline函数中的局部变量分配内存空间;
- 将inline函数的输入参数和返回值映射到调用方法的局部变量空间中;
- 若inline函数有多个返回点,将其转变为inline函数代码末尾的分支。
-
优缺点
- 优点:
- 内联函数同宏函数一样将在被调用处进行代码展开,省去了参数压栈、栈帧开辟与回收,结果返回等,从而提高程序运行速度;
- 内联函数相比宏函数来说,在代码展开时,会做安全检查或自动类型转换(同普通函数),而宏定义则不会;
- 在类中声明同时定义的成员函数,自动转化为内联函数,因此内联函数可以访问类的成员变量,宏定义则不能;
- 内联函数在运行时可调试,而宏定义不可以。
- 缺点:
- 代码膨胀。内联是以代码膨胀(复制)为代价,消除函数调用带来的开销。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间;
- inline 函数无法随着函数库升级而升级。inline函数的改变需要重新编译,不像 non-inline 可以直接链接;
- 是否内联,程序员不可控。内联函数只是对编译器的建议,是否对函数内联,决定权在于编译器。
位域
有些信息在存储时,并不需要占用一个完整的字节, 而只需占几个或一个二进制位。例如在存放一个开关量时,只有0和1 两种状态, 用一位二进位即可。为了节省存储空间,并使处理简便,C语言又提供了一种数据结构,称为“位域”或“位段”。所谓“位域”是把一个字节中的二进位划分为几个不同的区域,并说明每个区域的位数。每个域有一个域名,允许在程序中按域名进行操作。这样就可以把几个不同的对象用一个字节的二进制位域来表示。
struct bs
{
int a:8;
int b:2;
int c:6;
}data;
如上,data为bs变量,共占两个字节。其中a8位,b2位,c6位。另外需要注意以下几点:
-
一个位域必须存储在同一个字节中,不能跨两个字节。如一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域。也可以有意使某位域从下一单元开始。例如:
struct bs
{
unsigned a:4
unsigned :0 /空域/
unsigned b:4 /从下一单元开始存放/
unsigned c:4
}
在这个位域定义中,a占第一字节的4位,后4位填0表示不使用,b从第二字节开始,占用4位,c占用4位。
-
位域可以无位域名,这时它只用来作填充或调整位置。无名的位域是不能使用的.如:
struct k
{
int a:1
int :2 /该2位不能使用/
int b:3
int c:2
};
位域在本质上就是一种结构类型, 不过其成员是按二进位分配的
友元(friend)
友元的目的是让一个函数或者类访问另一个类中的私有成员,三种实现:全局函数做友元,类做友元,成员函数做友元
RTTI(Runtime Type Information) 机制
运行时类型信息,其提供了运行时确定对象类型的方法。…(待扩充)
C是如何进行函数调用?
每一个函数调用都会分配函数栈,在栈内进行函数执行过程。调用前,先把返回地址压栈,然后把当前的esp指针压栈。参数压栈顺序为从右向左。
- ESP(Extended Stack Pointer)为扩展栈指针寄存器,是指针寄存器的一种,用于存放函数栈顶指针。
- EBP(Extended Base Pointer),扩展基址指针寄存器,也被称为帧指针寄存器,用于存放函数栈底指针。
C++如何处理返回值
生成一个临时变量,把它的引用作为函数参数传入值传递。
C++中拷贝赋值函数的形参能否进行值传递?
不能。如果在这种情况下,调用拷贝函数的时候,首先要将实参传递给形参,这个传递的时候又要调用拷贝函数。如此循环,无法完成拷贝,也会造成栈满。
str 系列函数
-
strcat
- 函数原型:
char *strcat(char *dst, char const *src);
- 函数说明:函数要求dst参数原先已经包含一个字符串(可以为空)。函数找到这个字符串末尾,并把src字符串的一份拷贝到这个位置。若src 和dst 的位置发生重叠,结果是未定义的。需要编程者保证目标字符串数组剩余空间可以保存整个字符串;
- 实现:
char *strcat(char *dst, char const *src){
assert(NULL != dst && NULL != src); //断言检测,源码无
char *cp = dst;
while(*cp)
cp++; //找到dst末尾
while(*cp++ = *src++) ; //src拷贝到dst末尾
return dst;
}
注意:while(*cp++ = *src)会拷贝src这个字符串中的’\0’,因为是先进行赋值,然后判断while(’\0’)不满足。
-
strcpy
- 函数原型:
char *strcpy(char *dst, char const *src);
- 函数说明:将src复制到dst,包括结束符。由于dst参数进行修改,所以其必须是字符串数组或是一个指向动态内存分配的数组指针。使用是必须保证目标字符串的空间足以容纳需要复制的字符串。若多余的字符仍被复制,他们将覆盖原先存储于数组后面的空间。
- 实现代码:
char *strcpy(char *dst, const char *src){
if(dst == src)
return dst;
assert((dst != NULL) && (src != NULL));
char *cp = dst;
while(*cp++ = *src++);
return dst;
}
注:为什么要有返回值char* ?
有时候函数不需要返回值,但是为了增加灵活性如支持链式表达,可以附加返回值。
如: char str[20]; int length = strlen(strcpy(str, "Hello World"));
-
strncpy
- 函数原型:
char *strncpy(char *dst, char const *src, size_t len);
- 说明:strncpy 把源字符串的字符复制到目标数组,它总是正好向 dst 写入 len 个字符。如果 strlen(src) 的值小于 len,dst 数组就用额外的 NUL 字节(C语言中的’\0’)填充到 len 长度。如果 strlen(src)的值大于或等于 len,那么只有 len 个字符被复制到dst中。这里需要注意它的结果将不会以NULL字节结尾。
- 实现:
char *strncpy(char *dst, char const *src, size_t len){
assert(dst != NULL && src != NULL);
char *cp = dst;
while(len-- > 0 && *src != '\0'){
*cp++ = *src++;
}
*cp = '\0';
return dst;
}
-
strstr
- 说明:判断substr 是否是str的子串,返回值是src中开始匹配的那个字符的字符数组;
- 实现:
char* My_strstr(char *src, char *substr)
{
if (src == nullptr && substr == nullptr)
return nullptr;
int len = strlen(src);
for (int i = 0; i < len; ++i,++src)
{
char *p = src;
for (char *q = substr;; p++, q++)
{
if (*q == '\0')
return src;
if (*q != *p)
break;
}
}
return nullptr;
}
C++异常处理
需要包含头文件#include
,如果 try 块在不同的情境下会抛出不同的异常,这个时候可以尝试罗列多个 catch 语句,用于捕获不同类型的异常,使用方法如下:
try
{
// 保护代码
}catch( ExceptionName e1 )
{
// catch 块
}catch( ExceptionName e2 )
{
// catch 块
}catch( ExceptionName eN )
{
// catch 块
}
异常处理:
可以使用 throw 语句在代码块中的任何地方抛出异常。throw 语句的操作数可以是任意的表达式,表达式的结果的类型决定了抛出的异常的类型。以下是尝试除以零时抛出异常的实例:
double division(int a, int b)
{
if( b == 0 )
{
throw "Division by zero condition!";
}
return (a/b);
}
vector 和 list
- 概念:
- vector: 连续存储容器,动态数组,在堆上分配空间。底层由数组实现,插入时若内存不够会重新分配原有元素的两倍空间,然后全部复制过来。之前的迭代器会失效。
- list: 动态链表,在堆上分配空间,每插入一个元数都会分配空间,每删除一个元素都会释放空间。底层由双链表实现。随机访问性差,只能快速访问头尾节点。插入删除很快。
- 区别:
- 底层实现不同;
- vector支持随机访问,list不支持;
- vector是顺序内存, list不是;
- vector在中间节点进行插入删除会导致内存拷贝,list不会;
- vector一次性分配好内存,不够是进行2倍扩容; list每次插入删除都会进行内存管理;
- vector随机访问性能好,插入性能差;list反之。
- 应用:
- vector适用需要高速随机访问,不在乎插入和删除的效率;
- list有一段不联系的内存,高效插入删除不在乎随机访问效率时使用。
map和set有什么区别,分别怎么实现的?
map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree)。由于map和set所开放的各种操作接口,RB-Tree也都提供了,所几乎所有的map和set操作行为,都只是转调RB-Tree的操作行为。
区别:
- map中的元素是key-value对;关键字起到索引的作用,值则表示与索引相关联的数据;set与之相对就是关键字的简单集合,set中的每个元素只包含一个关键字。
- set的迭代器是const的,不允许修改元素的值;map允许修改value,但不允许修改key。其原因是map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么首先需要删除该键,然后调节平衡,在插入修改后的键值,调节平衡,这样就严重破坏了map和set的而机构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置为const,不允许修改迭代器的值;map迭代器不允许修改key,但是可以修改value;
- map支持下标操作,set不支持。map可以用key做下标,map的下标运算符[ ]将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和mapped_type类型默认值的元素至map中,因此下标运算符[ ]在map应用中需要慎用,const_map不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type类型没有默认值也不应该使用。如果find能解决需要,尽可能用find。
map 和 UNordered_map
-
内部实现:
- map:map内部实现了一个红黑树,该结构具有自动排序的功能,因此map内部的所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素,因此,对于map进行的查找,删除,添加等一系列的操作都相当于是对红黑树进行这样的操作,故红黑树的效率决定了map的效率
- UNordered_map:unordered_map内部实现了一个哈希表,因此其元素的排列顺序是杂乱的,无序的
-
优缺点:
- map:
- 优点:
- 有序性:这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作;
- 红黑树:内部实现一个红黑书使得map的很多操作在lgn的时间复杂度下就可以实现,因此效率非常的高
- 缺点:
- 空间占用率高,因为map内部实现了红黑树,虽然提高了运行效率,但是因为每一个节点都需要额外保存父节点,孩子节点以及红/黑性质,使得每一个节点都占用大量的空间;
- unordered_map:
note:哈希原理
STL介绍
STL(Standard Template Library),即标准模板库。一个重要特点是算法和数据结构的分离:如可以用sort处理几乎任何数据集合; 另一个特点是不是面向对象的,具有足够的通用性。主要有三部分:
- 容器:一种数据结构,以模板类的方法提供.为访问容器中的数据,可以使用由容器类输出的迭代器。容器分为:
- 序列式容器:每个元素有固定的位置,其取决于插入时机和地点,和元素无关,如vector,list,deque。
- 关联容器:元素位置取决于特定的排序准则,和插入顺序无关。set, multiset, map, multimap;
- set/multiset:内部元素依据其值自动排序,set内形同的数值元素只能出现一次,multuiset内可以包含多个数值相同的元素,内部由二叉树实现,便于查找。
- map/multimap: Map的元素是成对的<键值,元素>,内部元素根据其值自动排序,map相同数值的元素只能出现一次, mltimap内可以包含多个数值相同的元素。
容器类自动申请和释放内存,无需new和delete操作。
- 迭代器:iterator模式又称Cursor(游标)模式,用于提供一种方法顺序访问一个聚合对象中各个元素,而不需要暴露该对象内部的表示。
- 作用:能够让迭代器与算法不干扰的相互发展,最后又能无间隙的粘合起来,重载了*,++,==,!=, =运算符。用以操作复杂的数据结构,容器提供迭代器,算法使用迭代器。
- 常见的迭代器类型:iterator、const_iterator、reverse_iterator和const_reverse_iterator。
- 算法:
解释STL中迭代器,有指针为何还要使用迭代器
-
迭代器:
-
与指针的区别:迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符,->、*
、++
、--
等。迭代器封装了指针,是一个“可遍历STL( Standard Template Library)容器内全部或部分元素”的对象, 本质是封装了原生指针,是指针概念的一种提升(lift),提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的++,–等操作。
迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用*取值后的值而不能直接输出其自身。
STL迭代器删除元素
- 对于关联容器(map,set,multimap,multiset),删除当前iterator,仅仅会使当前的iterator失效,只要在erase时,递增当前的iterator即可。只是因为map之类的容器,使用了红黑树来实现,插入,删除一个节点不会对其他节点造成影响。使用方式如下:
set valset = { 1,2,3,4,5,6 };
set::iterator iter;
for (iter = valset.begin(); iter != valset.end(); )
{
if (3 == *iter)
valset.erase(iter++);
else
++iter;
}
因为传给erase的是iter的一个副本,iter++是下一个有效的迭代器.
- 对于序列式容器(vector,deque,list),删除当前的iterator会使后面所有元素的iterator都失效。这是因为vector,deque使用了连续分配的内存,删除一个元素导致后面所有的元素会向前移动一个位置。不过erase方法可以返回下一个有效的iterator。使用方式如下:
vector val = { 1,2,3,4,5,6 };
vector::iterator iter;
for (iter = val.begin(); iter != val.end()){
if (3 == *iter)
iter = val.erase(iter); //返回下一个有效的迭代器,无需+1
else
++iter;
}
STL中的resize和reserve的区别
- resize():改变当前容器内含有元素的数量(size()),eg:
vectorv; v.resize(len);
v的size变为len,如果原来v的size小于len,那么容器新增(len-size)个元素,元素的值为默认为0.当v.push_back(3);之后,则是3是放在了v的末尾,即下标为len,此时容器是size为len+1;
- reserve(): 改变当前容器的最大容量(capacity),它不会生成元素,只是确定这个容器允许放入多少对象,如果reserve(len) 的值大于当前的capacity(),那么会重新分配一块能存len个对象的空间,然后把之前的v.size()个对象通过copy construtor复制过来,并销毁之前的内存。
C++源文件从文本到可执行文件经历的过程?
需要四个阶段
- 预处理阶段:对源代码文件中文件包含关系(头文件)、预编译语句(宏定义)进行分析和替换,生成预编译文件。
- 编译阶段:将经过处理后的预编译文件转换成特定的汇编代码,生成汇编文件;
- 汇编阶段: 将编译阶段生成的汇编文件转化成机器码,生成可重定位目标文件;
- 连接阶段:将多个目标文件及所需的库连接成最终的可执行目标文件。
Include头文件的顺序以及双引号和尖括号的区别?
- include头文件的顺序:对于include的头文件来说,如果在文件a.h中声明一个在文件b.h中定义的变量而不引用b.h,那么需要在a.c文件中的引用b.h文件,并且要先引用b.h,后引用a.h,否则有变量未声明错误。
- 双引号和尖括号:编译器预处理阶段查找头文件的路径不同:
- 双引号:当前头文件目录 -> 编译器设置的头文件目录 -> 系统变量中指定的头文件目录;
- 尖括号:编译器设置的头文件路径 -> 系统变量指定的头文件路径。
段错误
段错误通常发生在访问非法内存地址的时候,具体可以分为:
指针总结
- 指针函数:带指针的函数,本质是一个函数。函数返回类型是某一类型的指针。
类型标识符 *函数名(参数表)int *f(x,y)
- 函数指针:指向函数(首地址)的指针变量,即本质是一个指针变量
类型说明符(*函数名)(参数) int (*f)(int a, int b)
or char* (*fun)(char* p1, char* p2)
;
- 函数指针数组:(返回值类型)(fun[])(形参变量)
char*(*fun2[3])(char *p)
返回值为char,形参为char* 的函数指针数组,包含三个元素。
- 指针数组:
int *p[5]
- 数组指针:
int (*p)[5]
常用点:
-
依据变量定义vector大小:
vector> dp(m, vector(n,0)); //定义m*n的矩阵;
-
寻找向量中的最大值:
*max_element(dp.begin(), dp.end());
-
查看向量中是否有某元素:
#include
vector t;
int num = count(t.begin(),t.end(), i);//t中i个数;
vector s;
int num2 = count(s.begin(),s.end(), "hello world!");//查看是否有hello world.
-
迭代器输出容器中的值
- vector中
vector temp = slv.ntostr(digits);
for (auto i = temp.begin(); i != temp.end(); i++){
cout << *i << endl;
}
- map中
map _map;
map::iterator iter;
iter = _map.begin();
while(iter != _map.end()) {
cout << iter->first << " : " << iter->second << endl;
iter++;
}
-
string 同样可以应用push 和 pop
string s;
s.push_back('aa');
s.pop_back();
-
在向量起始位置插入元素:
vector temp;
temp.insert(temp.begin(), n);
-
map中的元素已经自动按照键值升序排列, 某些排序可以替换后排序在替换回去
-
插入元素方式:
map m;
m[a] = b; // 可以直接这样使用
m.insert(pair(a,b)); //也可以这样
* 初始化方式:
```C++
map m2 ={{1,'k'}, {2, 's'}, {3, 'h'}, {4, 'p'}, {5, 'q'}};
数据结构
二叉树
-
性质:
- 非空二叉树第 i 层最多 2^(i-1) 个结点 (i >= 1);
- 深度为 k 的二叉树最多 2^k - 1 个结点 (k >= 1);
- 度为 0 的结点数为 n0,度为 2 的结点数为 n2,则 n0 = n2 + 1;
- 树的度——也即是宽度,简单地说,就是结点的分支数。以组成该树各结点中最大的度作为该树的度。
- 有 n 个结点的完全二叉树深度 k = ⌊ log2(n) ⌋ + 1;
- 对于含 n 个结点的完全二叉树中编号为 i (1 <= i <= n) 的结点:
- 若 i = 1,为根,否则双亲为 ⌊ i / 2 ⌋;
- 若 2i > n,则 i 结点没有左孩子,否则孩子编号为 2i;
- 若 2i + 1 > n,则 i 结点没有右孩子,否则孩子编号为 2i + 1.
-
遍历方式:前中后序遍历,循环与递归两种实现
-
分类:
- 满二叉树:除了叶结点外每一个结点都有左右子叶且叶结点都处在最底层的二叉树,即深度为k有2^k-1个节点;
- 完全二叉树:从根往下数,除了最下层外都是全满(都有两个子节点),而最下层所有叶结点都向左边靠拢填满。
- 大顶堆:根 >= 左 && 根 >= 右;
- 小顶堆:根 <= 左 && 根 <= 右;
- 平衡二叉树(AVL树):| 左子树树高 - 右子树树高 | <= 1;
- 红黑树:
- 特征:
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是 NIL 节点)。
- 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)(新增节点的父节点必须相同)
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。(新增节点必须为红)
- Treap_二分平衡树
平衡二叉树(AVL树)
左右子树的高度相差不超过 1 的树为平衡二叉树。
-
平衡二叉查找树:简称平衡二叉树。由前苏联的数学家 Adelse-Velskil 和 Landis 在 1962 年提出的高度平衡的二叉树,根据科学家的英文名也称为 AVL 树。它具有如下几个性质:
- 可以是空树;
- 若不是空树,任何一结点的左子树与右子树都是平衡二叉树,且高度之差的绝对值不超过1.
-
平衡因子:某结点的左子树与右子树的高度差即为该结点的平衡因子(BF,Balance Factor),平衡二叉树中不存在平衡因子大于1的结点。在一棵平衡二叉树中,节点的平衡因子只能取0、1/-1, 分别对应着左右子树等高。左子树比较高,右子树比较高。
-
最小失衡子树:在新插入的结
点向上查找,以第一个平衡因子的绝对值超过1的结点为根的子树成为最小不平衡子树。
平衡二叉树的失衡调整主要是通过旋转最小失衡子树来实现的。根据旋转的方向有两种处理方式,左旋与 右旋 。旋转的目的就是减少高度,通过降低整棵树的高度来平衡。哪边的树高,就把那边的树向上旋转
红黑树(RBTree)
红黑树是一颗二叉搜索树,它在每个节点增加了一个存储为记录节点的颜色,可以是RED,也可以是BLACK;通过任意一条根到叶子节点简单路径上颜色的约束,红黑树保证最长路径不超过最短路径的两倍,因而近似平衡,证明可见下图:最长路径就是一个红一个黑,最多是全黑的两倍。
-
红黑树的性质:
- 每个节点不是黑色就是红色;
- 根节点是黑色的;
- 如果一个节点是红色,那么他的两个子节点就是黑色的(没有连续的红节点);
- 对于每个节点,从该节点到其后代叶节点的简单路径上,均包含相同数目的黑色节点。
-
红黑树的插入:
- 根节点为NULL, 直接插入新节点并将其颜色置为黑色;
- 根节点不为NULL,找到要插入新节点的位置;
- 插入新节点;
- 判断新插入节点对全树颜色的影响,更新调整颜色。
操作系统
进程与线程
基本概念
- 进程是对运行程序的封装,是系统进行资源调度和分配的基本单位,实现了操作体统的并发;
- 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
区别:
- 对于有线程的系统(无线程系统两个都是进程)
- 进程是资源分配的独立单位
- 线程是资源调度的独立单位
- 进程有自己独立的地址空间,每启动一个进程,系统都会为其分配地址空间,建立数据表来维护代码段、堆栈段和数据段,线程没有独立的地址空间,它使用相同的地址空间共享数据;
- CPU切换/创建一个线程比进程花费小;
- 线程占用的资源比进程少很多;
- 线程之间的通信更加方便,同一个进程下,线程共享全局变量,静态变量等数据, 进程之间的通信需要以通信的方式(IPC)进行 (多线程程序处理同步于互斥是难点);
- 多线程程序更安全,生命力更强,一个进程死掉不会对另一个进程造成影响,多线程不易维护,一个线程死进程就死。
- 一个线程只能属于一个进程,一个进程可以有多个线程;
进程之间通讯方式
- 管道(PIPE):
- 有名管道:一种半双工的通信方式,允许无亲缘关系进程间通信
- 无名管道:一种半双工通信方式,只能在具有亲缘关系的进程间使用(父子进程)
- 优点: 简单方便
- 缺点:
- 局限于单向通信
- 只能创建在它的进程以及其有亲缘关系的进程之间
- 缓冲区有限
- 信号量(Semaphore):一个计数器,可以用来控制多个线程对共享资源的访问。
-
优点:可以实现同步进程/互斥进程
-
缺点:信号量有限
-
原理:
用PV操作来管理共享资源时,首先要确保PV操作自身执行的正确性。由于P(S)和V(S)都是在同一个信号量S上操作,为了使得它们在执行时不发生因交叉访问信号量S而可能出现的错误,约定P(S)和V(S)必须是两个不可被中断的过程,即让它们在屏蔽中断下执行。把不可被中断的过程称为原语。于是,P操作和V操作实际上应该是P操作原语和V操作原语。
- P操作主要动作:
- S减1;
- 若S减1后仍大于或等于0,则进程继续执行;
- 若S减1后小于0, 则该进程被阻塞后放入等待该信号量的等待队列中,然后转进程调度;
- V操作的主要动作:
- S加1;
- 若相加后结果大于0,则进程继续执行;
- 若相加后结果小于等于0,则从该信号的等待队列中释放一个等待进程,然后在返回原进程继续执行或转进程调度。
PV操作对于每一个进程来说,都只能进行一次,而且必须成对使用。在PV原语执行期间不允许有中断发生。原语不能被中断执行,因为原语对变量的操作过程如果被打断,可能会去运行另一个对同一变量的操作过程,从而出现临界段问题
-
实现进程间同步:
-
调用P操作测试消息是否到达:任何进程调用P操作可测试到自己所期望的消息是否已经到达。若消息尚未产生,则S=0,调用P(s)后,P(S)一定让调用者成为等待信号量S的状态,即调用者此时必定等待直到消息到达;若消息已经存在,则S≠0,调用P(S)后,进程不会成为等待状态而可继续执行,即进程测试到自己期望的消息已经存在。
-
调用V操作发送信息:任何进程要向其他进程发送消息时可调用V操作。若调用V操作之前S=0,表示消息尚未产生且无等待消息的进程,则调用V(S)后,V(s)执行S:=S+1使S≠0,即意味着消息已存在;若调用V操作之前S<0,表示消息未产生前已有进程在等待消息,则调用V(S)后将释放一个等待消息者,即表示该进程等待的消息已经到达,可以继续执行。
-
实现进程互斥:
- 设立一个互斥信号量S,表示临界区,其取值为1,0,-1,…其中,S=1表示无并发进程进入S临界区;S=0表示已有一个并发进程进入了S临界区;S等于负数表示已有一个并发进程进入S临界区,且有|S|个进程等待进入S临界区,S的初值为1;
- 用PV操作表示对S临界区的申请和释放。在进入临界区之前,通过P操作进行申请,在退出临界区之后,通过V操作释放
- 信号(Signal): 一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
- 信号可以在任意时刻发送给某一进程而不需要知道该进程的状态;
- 若该进程未在执行,则信号量保存下来,直到进程恢复执行并传递成功为止;
- 若一个信号被进程设置为阻塞,该信号传递会被延迟,直到阻塞被取消才会传给进程。
- 消息队列(Message Queue):是消息链表,存放在内核中并由消息队列标识符标识;
- 优点:可以实现任意进程间的通信,并通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题,方便;
- 缺点:信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合;
- 共享内存(Shared Memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。+
- 优点:无需赋值,快捷,信息量大;
- 缺点:
- 通信是通过将共享空间缓冲区直接附加到进程的虚拟地址空间来实现的,因此存在进程间读写操作同步的问题。
- 利用内存缓冲区直接交换信息,内存的实体存在于计算机中,只能同一个计算机系统中的诸多进程共享,不方便网络通信;
- 套接字(Socket):可用于不同计算机间的进程通信
- 优点:
- 传输数据为字节段,传输数据可以自定义,数据量小效率高;
- 传输数据时间短,性能高;
- 适合用于客户端和服务器端之间信息实时交互;
- 可以价目,数据安全性高
- 缺点: 需对传输数据进行解析,转化为应用级数据。
线程间通讯
- 锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)
- 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法;
- 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的;
- 自旋锁(spin lock):与互斥锁类似,都是为了 保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。
- 条件变量(condition):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁一起使用。
- 信号量机制:
- 信号机制:类似进程间的信号处理
- 屏障:屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。
线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制。
参考进程线程面试题总结
进程间私有和共享的资源
* 私有:地址空间、堆、全局变量、栈、寄存器;
* 公有:代码段、公共数据、进程目录、进程ID;
线程间私有和共享的资源
* 私有:线程栈、寄存器、程序计数器;
* 公有:堆、地址空间、全局变量、静态变量;
有了进程为何还要线程
- 线程产生的原因:进程可以使多个程序能并发的执行以提高资源的利用率和系统的吞吐量,但是同一进程在同一时刻只能干一件事,如果进程阻塞,整个进程就会挂起,及时进程中有些工作不依赖于等待的资源也不会执行。 因此,操作系统引入了比进程更小的线程作为并发执行的基本单位,从而减少程序在并发执行时所付出的时空开销,提高并发性。
和进程相比,进程的优势:
- 从资源上来讲,线程是一种非常“节俭”的多任务操作模式。linux下,启动一个进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段,,,,;
- 切换效率上讲:运行与一个进程汇总的多个线程,他们之间使用相同的地址空间,而且线程之间切换的时间也远远小于金城江切换所需的时间。
- 通信机制上:线程间的通信机制更方便。对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过进程间通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进城下的线程之间贡献数据空间,所以一个线程的数据可以直接为其他线程所用,这不仅快捷,而且方便。
- 除以上优点外,多线程程序作为一种多任务、并发的工作方式,还有如下优点:
- 使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
- 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序才会利于理解和修改。
进程间同步的方式
- 信号量:信号量本身无法完成传递数据,配合共享内存使用,类似于线程中的锁,用以保护临界资源;
- 管道:只局限与父子进程;
- 信号,进程间传递信号,捕捉到信号后执行对应绑定的代码,可以实现进程通信的“单播”,“广播”;
- 共享内存:进程间最常用的数据同步方式,与信号量配合使用。
- 消息队列:把数据放入队列,内核逐一处理发送至目的线程;
- socket:()
线程间同步的方式
- 信号:和进程类似;
- 信号量:和进程类似,功能和互斥锁基本一致;
- 互斥锁:保护临界资源;
- 控制变量:常配合互斥锁,控制线程执行的先后。
linux四种锁机制
- 互斥锁:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒
- 读写锁:rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。 注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
- 自旋锁:spinlock,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
- RCU:即read-copy-update,在修改数据时,首先需要读取数据,然后生成一个副本,对副本进行修改。修改完成后,再将老数据update成新的数据。使用RCU时,读者几乎不需要同步开销,既不需要获得锁,也不使用原子指令,不会导致锁竞争,因此就不用考虑死锁问题了。而对于写者的同步开销较大,它需要复制被修改的数据,还必须使用锁机制同步并行其它写者的修改操作。在有大量读操作,少量写操作的情况下效率非常高。
互斥锁机制,互斥锁和读写锁的区别
- 互斥锁:mutex,用于保证在任何时刻,都只能有一个线程访问该对象。当获取锁操作失败时,线程会进入睡眠,等待锁释放时被唤醒。
- 读写锁:rwlock,分为读锁和写锁。处于读操作时,可以允许多个线程同时获得读操作。但是同一时刻只能有一个线程可以获得写锁。其它获取写锁失败的线程都会进入睡眠状态,直到写锁释放时被唤醒。 注意:写锁会阻塞其它读写锁。当有一个线程获得写锁在写时,读锁也不能被其它线程获取;写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)。适用于读取数据的频率远远大于写数据的频率的场合。
- 区别:
- 读写锁区分读者和写者,而互斥锁不区分
- 互斥锁同一时间只允许一个线程访问该对象,无论读写;读写锁同一时间内只允许一个写者,但是允许多个读者同时读对象。
进程的状态转换图
- 创建状态:进程由创建而产生。创建进程是一个非常复杂的过程,一般需要通过多个步骤才能完成:如首先由进程申请一个空白的进程控制块(PCB),并向PCB中填写用于控制和管理进程的信息;然后为该进程分配运行时所必须的资源;最后,把该进程转入就绪状态并插入到就绪队列中。
- 就绪状态:这是指进程已经准备好运行的状态,即进程已分配到除CPU以外所有的必要资源后,只要再获得CPU,便可立即执行。如果系统中有许多处于就绪状态的进程,通常将它们按照一定的策略排成一个队列,该队列称为就绪队列。有执行资格,没有执行权的进程。
- 运行状态:这里指进程已经获取CPU,其进程处于正在执行的状态。对任何一个时刻而言,在单处理机的系统中,只有一个进程处于执行状态,而在多处理机系统中,有多个进程处于执行状态。既有执行资格,又有执行权的进程。
- 阻塞状态:这里是指正在执行的进程由于发生某事件(如I/O请求、申请缓冲区失败等)暂时无法继续执行的状态,即进程执行受到阻塞。此时引起进程调度,操作系统把处理机分配给另外一个就绪的进程,而让受阻的进程处于暂停的状态,一般将这个暂停状态称为阻塞状态
- 终止状态:进程的终止也要通过两个步骤:首先,是等待操作系统进行善后处理,最后将其PCB清零,并将PCB空间返还给系统。当一个进程到达了自然结束点,或是出现了无法克服的错误,或是被操作系统所终结,或是被其他有终止权的进程所终结,它将进入终止状态。进入终止态的进程以后不能在再执行,但是操作系统中任然保留了一个记录,其中保存状态码和一些计时统计数据,供其他进程进行收集。一旦其他进程完成了对其信息的提取之后,操作系统将删除其进程,即将其PCB清零,并将该空白的PCB返回给系统。
linux的内存分页管理
-
内存:简单地说,内存就是一个数据货架。内存有一个最小的存储单位,大多数都是一个字节。内存用内存地址(memory address)来为每个字节的数据顺序编号。因此,内存地址说明了数据在内存中的位置。内存地址从0开始,每次增加1。这种线性增加的存储器地址称为线性地址(linear address)。内存地址空间范围和地址总线的位数直接相关。
-
虚拟内存:
内存的一项主要任务,就是存储进程的相关数据。我们之前已经看到过进程空间的程序段、全局数据、栈和堆,以及这些这些存储结构在进程运行中所起到的关键作用。有趣的是,尽管进程和内存的关系如此紧密,但进程并不能直接访问内存。在Linux下,进程不能直接读写内存中地址为0x1位置的数据。进程中能访问的地址,只能是虚拟内存地址(virtual memory address)。操作系统会把虚拟内存地址翻译成真实的内存地址。这种内存管理方式,称为虚拟内存(virtual memory)。
进程对物理内存的访问,必须经过操作系统的审查。因此,掌握着内存对应关系的操作系统,也掌握了应用程序访问内存的闸门。借助虚拟内存地址,操作系统可以保障进程空间的独立性。只要操作系统把两个进程的进程空间对应到不同的内存区域,就让两个进程空间成为“老死不相往来”的两个小王国。两个进程就不可能相互篡改对方的数据,进程出错的可能性就大为减少。
有了虚拟内存地址,内存共享也变得简单。操作系统可以把同一物理内存区域对应到多个进程空间。这样,不需要任何的数据复制,多个进程就可以看到相同的数据。内核和共享库的映射,就是通过这种方式进行的。
-
内存分页:
- 出发点:翻译虚拟地址至物理地址。虚拟内存地址和物理内存地址的分离,给进程带来便利性和安全性。但虚拟内存地址和物理内存地址的翻译,又会额外耗费计算机资源。在多任务的现代计算机中,虚拟内存地址已经成为必备的设计。那么,操作系统必须要考虑清楚,如何能高效地翻译虚拟内存地址。
- 意义:避免资源浪费。记录对应关系最简单的办法,就是把对应关系记录在一张表中,为了让翻译速度足够地快,这个表必须加载在内存中。不过,这种记录方式惊人地浪费。为避免这种浪费,linux采用了分页的方式记录对应关系。**所谓的分页,就是以更大尺寸的单位页(page)来管理内存。在Linux中,通常每页大小为4KB。**内存分页,可以极大地减少所要记录的内存对应关系。如果把物理内存和进程空间的地址都分成页,内核只需要记录页的对应关系,相关的工作量就会大为减少。由于每页的大小是每个字节的4000倍。因此,内存中的总页数只是总字节数的四千分之一。对应关系也缩减为原始策略的四千分之一。分页让虚拟内存地址的设计有了实现的可能。
- 页编号:无论是虚拟页,还是物理页,一页之内的地址都是连续的。这样的话,一个虚拟页和一个物理页对应起来,页内的数据就可以按顺序一一对应。这意味着,虚拟内存地址和物理内存地址的末尾部分应该完全相同。大多数情况下,每一页有4096个字节。由于4096是2的12次方,所以地址最后12位的对应关系天然成立。我们把地址的这一部分称为偏移量(offset)。偏移量实际上表达了该字节在页内的位置。地址的前一部分则是页编号。操作系统只需要记录页编号的对应关系。
-
多级页表
-
内存分页制度的关键,在于管理进程空间页和物理页的对应关系。操作系统把对应关系记录在分页表(page table)中。这种对应关系让上层的抽象内存和下层的物理内存分离,从而让Linux能灵活地进行内存管理。由于每个进程会有一套虚拟内存地址,那么每个进程都会有一个分页表。为了保证查询速度,分页表也会保存在内存中。分页表有很多种实现方式,最简单的一种分页表就是把所有的对应关系记录到同一个线性列表中,即如图2中的“对应关系”部分所示。
-
这种单一的连续分页表,需要给每一个虚拟页预留一条记录的位置。但对于任何一个应用进程,其进程空间真正用到的地址都相当有限。我们还记得,进程空间会有栈和堆。进程空间为栈和堆的增长预留了地址,但栈和堆很少会占满进程空间。这意味着,如果使用连续分页表,很多条目都没有真正用到。因此,Linux中的分页表,采用了多层的数据结构。多层的分页表能够减少所需的空间。
-
我们来看一个简化的分页设计,用以说明Linux的多层分页表。我们把地址分为了页编号和偏移量两部分,用单层的分页表记录页编号部分的对应关系。对于多层分页表来说,会进一步分割页编号为两个或更多的部分,然后用两层或更多层的分页表来记录其对应关系
在图3的例子中,页编号分成了两级。第一级对应了前8位页编号,用2个十六进制数字表示。第二级对应了后12位页编号,用3个十六进制编号。二级表记录有对应的物理页,即保存了真正的分页记录。二级表有很多张,每个二级表分页记录对应的虚拟地址前8位都相同。比如二级表0x00,里面记录的前8位都是0x00。翻译地址的过程要跨越两级。我们先取地址的前8位,在一级表中找到对应记录。该记录会告诉我们,目标二级表在内存中的位置。我们再在二级表中,通过虚拟地址的后12位,找到分页记录,从而最终找到物理地址。
多层分页表还有另一个优势。单层分页表必须存在于连续的内存空间。而多层分页表的二级表,可以散步于内存的不同位置。这样的话,操作系统就可以利用零碎空间来存储分页表。还需要注意的是,这里简化了多层分页表的很多细节。最新Linux系统中的分页表多达3层,管理的内存地址也比本章介绍的长很多。不过,多层分页表的基本原理都是相同。
为什么要有page cache,操作系统怎么设计的page cache
- 定义:Page cache 也叫页缓冲或文件缓冲,是由好几个磁盘块构成,大小通常为4k,在64位系统上为8k,构成的几个磁盘块在物理磁盘上不一定连续,文件的组织单位为一页, 也就是一个page cache大小,文件读取是由外存上不连续的几个磁盘块,到buffer cache,然后组成page cache,然后供给应用程序。
- 作用及实现
加快从磁盘读取文件的速率。page cache中有一部分磁盘文件的缓存,因为从磁盘中读取文件比较慢,所以读取文件先去page cache中去查找,如果命中,则不需要去磁盘中读取,大大加快读取速度。在 Linux 内核中,文件的每个数据块最多只能对应一个 Page Cache 项,它通过两个数据结构来管理这些 Cache 项,一个是radix tree,另一个是双向链表。Radix tree 是一种搜索树,Linux 内核利用这个数据结构来通过文件内偏移快速定位Cache 项。
Linux虚拟地址
为了防止不同进程同一时刻在物理内存中运行而对物理内存的争夺和践踏,采用了虚拟内存。
虚拟内存使得不同进程在运行过程中,它所看到的是自己独自占有了当前系统的4G内存。所有进程共享同一物理内存,每个进程只把自己目前最需要的虚拟内存空间映射并存储到物理内存上。事实上,在每个进程创建加载时,内核只是为进程“创建”了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好,等到运行到对应的程序时,才会通过缺页异常来拷贝数据。还有进程运行过程中,要动态分配内存,比如malloc时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表做相应设置,当进程真正访问到此数据时,才引发缺页异常。
请求分页系统,请求分段系统和请求段页式系统都是针对虚拟内存的,通过请求实现内存与外存的信息置换。
虚拟内存的好处:
- 扩大地址空间;
- 内存保护:每个进程运行在各自的虚拟内存空间中,互不干扰。虚拟内存还对特定的内存地址提供写保护,可以防止代码或数据被恶意篡改。
- 公平内存分配。采用了虚存之后,每个进程都相当于有了同样大小的虚存空间。
- 当进程通信时,可采用虚存共享的方式实现。
- 在程序需要分配连续的内存空间时,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片。
- 虚拟内存很适合在多道程序设计系统中使用,许多程序的片段同时保存在内存中。当一个程序等待它的一部分读入内存时,可以把CPU交给另一个进程使用。在内存中可以保留多个进程,系统并发度提高。
虚拟内存的代价:
- 虚存的管理需要建立很多数据结构,这些数据结构要占用额外的内存
- 虚拟地址到物理地址的转换,增加了指令的执行时间。
- 页面的换入换出需要磁盘I/O,这是很耗时的
- 如果一页中只有一部分数据,会浪费内存。
操作系统中的程序的内存结构
一个程序本质上都是由BBS段,data段,text段三个组成。可以看到一个可执行程序在存储(没有调入内存时)分为代码段,数据区和未初始化数据区三部分。
- BBS段(未初始化数据区):通常用来存放程序中未初始化的全局变量和静态变量的一块内存区域。BBS段属于静态分配,程序结束后静态变量资源由系统自动释放;
- 数据段:存放程序中已初始化的全局变量的一块内存区域,数据段也属于静态内存分配。
- 代码段:存放程序执行代码的一块内存区域。这部分区域的大小在程序运行前就已经确定,并且内存区域属于只读。在代码段中,也有可能包好一些只读的常数变量。
- text段和data段在编译时已经分配了空间, 而BBS段并不占用可执行文件的大小,它是有连接器来获取内存的。
- bss段(未进行初始化的数据)的内容并不存放在磁盘上的程序文件中。其原因是内核在程序开始运行前将它们设置为0。需要存放在程序文件中的只有正文段和初始化数据段。
- data段(已经初始化的数据)则为数据分配空间,数据保存到目标文件中。
- 数据段包含经过初始化的全局变量以及它们的值。BSS段的大小从可执行文件中得到,然后链接器得到这个大小的内存块,紧跟在数据段的后面。当这个内存进入程序的地址空间后全部清零。包含数据段和BSS段的整个区段此时通常称为数据区。
可执行程序在运行时又多出两个区域:栈区和堆区
- 栈区:由编译器自动释放,存放函数的参数值,局部变量等。每当一个函数被调用时,该函数的返回类型和一些调用的信息被存放到栈中。然后这个被调用的函数在为它的自动变量和临时变量在站上分配空间。每调用一个函数一个新的栈就会被使用。栈区是从高地址想低地址增长的,是一块连续的内存区域,最大容量有系统预先定义好的,申请的栈空间超过这个界限时会提示溢出,用户能从栈中获取的空间较小。
- 堆区:用于动态分配内存,为与BBS和栈中间的地址区域。有程序员申请分配和释放。堆是从低地址想高地址位增长,采用链式存储结构。频繁的malloc/free 造成内存空间的不连续,产生碎片。当申请堆空间时库函数是按照一定的算法搜索可用的足够大的空间。因此堆的效率比栈低。
内存映射
就是将用户空间的一段内存区域映射到内核空间,映射成功后,用户对这段内存区域的修改可以直接反应到内核空间,同样,内核空间对这段区域的修改也直接反应用户空间。 对于内核空间<—>用户空间两者之间需要大量数据传输等操作的效率非常高。如下为一个把普通文件映射到用户空间的内存区域示意图:
mmap系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必调用read(),write()等操作。mmap并不分配空间,只是将文件映射到调用进程的地址空间里(但是会占用虚拟内存),然后可以通过memcpy等操作写文件,不需要write。
缺页中断
malloc 及 mmap等内存分配函数,在分配时只是建立了进程虚拟内存地址空间,并没有分配虚拟内存对应的物理内存。当进程访问这些没有建立映射关系的虚拟内存时,处理器自动触发一个缺页异常。
- 缺页中断:在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在于内存中。每当所要访问的页面不在内存中,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,将其调入内存。
缺页本身是一种中断,与一般中断一样,需要经过四个处理步骤:
- 保护CPU现场;
- 分析中断原因;
- 转入缺页中断处理程序进行处理;
- 恢复CPU现场,继续执行;
但是缺页中断是由于所要访问的页面不在内存时,由硬件所产生的一中特殊中断,因此与一般中断有如下区别:
- 在指令执行期间产生和处理缺页中断;
- 一条指令在执行期间,可能差生多次缺页中断;
- 缺页中断返回时,执行产生中断的一条指令,二一般中断返回的是执行下一条指令。
并发和并行
- 并发(concurrency):指宏观上看起来两个程序在同时运行,比如在单核CPU上的多任务。但是从微观上看两个程序的指令是交织着运行的,你的指令之间穿插着我的指令,我的指令之间穿插着你的,在单个周期内只执行了一个指令。这种并发并不能提高计算机的性能,只能提高效率。
- 并行(parallelism): 值严格物理意义上的同时运行。比如多核CPU,两个程序分贝运行在两个核上,二者之间互不影响,单个周期内每个程序都运行了自己的指令,也就是运行了两条指令。这样确实提高了性能及效率。
僵尸进程
-
正常进程:
正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束,当一个进程完成的它的工作终止之后,它的父进程需要调用wait()/waitpid()系统调用取得子进程的终止状态。
unix 提供了一中机制可以保证只要父进程想知道子进程结束时的状态信息,就可以得到:在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息,直到父进程通过wait/waitpid 来获取时才释放。保存信息包括:
- 进程号
- 退出状态;
- 运行时间;
-
孤儿进程:
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程所收养,并由init进程对他们完成状态收集工作。
-
僵尸进程:
一个进程使用fork创建子进程,如果子进程退出,而父进程没有调用wait/waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。
僵尸进程是一个进程必然经过的过程,这是每个子进程在结束时都要经过的阶段。
如果子进程在exit()之后,父进程没有来得及处理,这是使用PS命令就能看到子进程的状态是’Z’。如果父进程能及时处理,可能用PS命令就来不及看到子进程进入僵尸态。
如果父进程在子进程结束之前退出,子进程将由init接管,init以父进程身份对僵尸状态的子进程进行处理。
- 危害:
如果进程不调用wait/watpid,那么保留的那段信息就不会释放,其进程号会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号二导致系统不能差生新的进程。
- 外部消灭:
通过kill发送sigterm信号消灭产生僵尸的进程,它产生的僵尸进程就变成了孤儿进程,这些孤儿会init接管
- 内部解决:
- 子进程退出时向父进程发送SIGCHILD信号,父进程处理。。。信号。在信号处理函数中调用wait进行处理僵尸进程。
- fork两次,原理是将子进程成为孤儿进程,从而其的父进程节点变为init进程,通过init进程可以处理僵尸进程。
fork和vfork
-
fork:创建一个和当前进程映像一样的进程;pid_t fork(void);
成功调用会创建一个新的进程,它几乎与调用fork的进程一模一样,两个进程都会继续执行。在子进程中,成功的fork调用会返回0.在父进程中fork返回子进程的pid。若出错,返回一个负值。最常见的fork( )用法是创建一个新的进程,然后使用exec( )载入二进制映像,替换当前进程的映像。这种情况下,派生(fork)了新的进程,而这个子进程会执行一个新的二进制可执行文件的映像。这种“派生加执行”的方式是很常见的。
-
vfork:
在实现写时复制之前,Unix的设计者们就一直很关注在fork后立刻执行exec所造成的地址空间的浪费。BSD的开发者们在3.0的BSD系统中引入了vfork( )系统调用。vfork( )会挂起父进程直到子进程终止或者运行了一个新的可执行文件的映像。通过这样的方式,vfork( )避免了地址空间的按页复制。在这个过程中,父进程和子进程共享相同的地址空间和页表项。实际上vfork( )只完成了一件事:复制内部的内核数据结构。因此,子进程也就不能修改地址空间中的任何内存。
-
区别:
- fork( )的子进程拷贝父进程的数据段和代码段;vfork( )的子进程与父进程共享数据段
- fork( )的父子进程的执行次序不确定;vfork( )保证子进程先运行,在调用exec或exit之前与父进程数据是共享的,在它调用exec或exit之后父进程才可能被调度运行。
- vfork( )保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。
写时复制
- 写时复制是用来减少fork时对父进程空间进程整体复制带来的开销。
- 写时复制通过一种惰性优化的方法来避免复制时的系统开销。 解释:如果有多个进程要读取它们自己的那部分资源的副本,则复制时不必要的。每个进程只要保存一个指向这个资源的指针就可以了。这样如果没有进程去修改这部分资源,就可以认为每个进程都在独占那个资源。这样可以避免复制带来的负担。如果一个进程要修改自己的那份资源“副本”,则先复制资源,然后把复制的这部分提供给进程用以修改。其它不修改资源的进程仍共享那份没有修改过的资源。故将此种方式称为写时复制。
- 优点:如进程从来就不需要修改资源,则就不需要进行复制。惰性算法的好处就在于他们尽量推迟代价搞得操作,直到必要的时候才去执行。
句柄
- 什么是句柄:
- 句柄就是一个标识符,只要获得对象的句柄,我们就可以对对象进行任意的操作。
- 句柄不是指针,操作系统用句柄可以找到一块内存,这个句柄可能是标识符,map的key,也可能是指针,看操作系统怎么处理的了。Linux 有相应机制,但没有统一的句柄类型,各种类型的系统资源由各自的类型来标识,由各自的接口操作。
- 在操作系统层面上,文件操作也有类似于FILE的一个概念,在Linux里,这叫做文件描述符(File Descriptor),而在Windows里,叫做句柄(Handle)(以下在没有歧义的时候统称为句柄)。用户通过某个函数打开文件以获得句柄,此后用户操纵文件皆通过该句柄进行。
- 最大句柄数
- Linux默认最大文件句柄数是1024,在linux服务器文件并发量比较大的情况下,系统会报“too many open files"的错误。所以在linux服务器搞并发调优时,往往需要预先调优linux参数,修改最大句柄数。
- 修改最大句柄数:
- ulimit -n <可以同时打开的文件数>, 将当前进程的最大句柄数设置为指定的参数(该方法只对当前进程有效,重新打开shell或者新进程需要重新设置)。
- vi /etc/security/limits.conf 添加
- soft nofile 655536
- hard nofile 65536
对所有进程有效,设置为65536,注销当前用户重新登录即可生效。
源码到可执行文件的步骤
- 预编译:主要处理源代码文件中的以 # 开头的预编译指令,处理规则如下:
- 删除所有的#define, 展开所有的宏定义;
- 处理所有的条件预编译指令,如
#if, #endif, #ifdef, #elif, #else
;
- 处理所有
#include
预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件;
- 删除所有的注释
- 保留所有的#pargma 编译器指令,编译器需要用到它们,如 #pragma once 是为了防止有文件被重复引用;
- 添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生的编译错误或警告能显示行号。
- 编译:把预编译之后生成的文件,进行一系列语法分析及优化后,生成相应的汇编代码文件。
- 词法分析:利用类似于 有限状态机的算法, 将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号;
- 语法分析:语法分析器对由扫描器产生的记号进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树;
- 语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
- 优化:源代码级别的一个优化过程;
- 目标代码的生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列-汇编语言表示;
- 目标代码优化:目标代码优化器对上述目标机器代码进行优化,寻找合适的寻址方式,使用位移来替代乘法运算、删除多余的指令等;
- 汇编:将汇编代码转为机器可执行的机器码;
- 连接:讲不通的源文件产生的目标文件进行连接,从而生成一个可以执行的程序,分为静态连接与动态连接:
- 静态连接:函数和数据被编译进一个二进制文件,在使用静态库的情况下,在编译连接可执行文件时,连接器从库中复制这些函数和数据并把它们和应用程序的其他模块组合起来创建最终的可执行文件。
特点:
- 空间浪费:每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会使一个目标文件在内存中存在多个副本;
- 更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译连接形成可执行程序;
- 运行速度快:在可执行程序中具备了所有可执行程序所需要的任何东西,在执行的时候运行速度快。
- 动态编译:将恒旭按照模块拆分成各个相对独立的部分,在程序运行时将他们连接在一起形成一个完成的程序;
特点
- 共享库:及时需要每个程序都依赖同一个库,但是该库不会像静态连接那样在内存中存在多份副本,而是这多个程序在执行时共享同一个副本;
- 更新方便:只需要替换原来的文件即可。
- 性能损耗:因为把连接推迟到了程序运行时,所以每次执行程序都会进行连接,所以性能会有一定的损失。
用户态和内核态的区别
用户态和内核态是操作系统的两种运行级别,两者最大的区别就是特权级不同。用户态拥有最低的特权级,内核态拥有较高的特权级。运行在用户态的程序不能直接访问操作系统内核数据结构和程序。内核态和用户态之间的转换方式主要包括:系统调用,异常和中断。
用户态到内核态的转化原理
- 用户态切换到内核态的方式
- 系统调用:这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的ine 80h中断。
- 异常:当CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此。异常的内核相关程序中,也就到了内核态,比如缺页异常。
- 外围设备的中断:当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令,转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
- 切换操作:
从出发方式看,可以在认为存在前述3种不同的类型,但是从最终实际完成由用户态到内核态的切换操作上来说,涉及的关键步骤是完全一样的,没有任何区别,都相当于执行了一个中断响应的过程,因为系统调用实际上最终是中断机制实现的,而异常和中断处理机制基本上是一样的,用户态切换到内核态的步骤主要包括
- 从当前进程的描述符中提取其内核栈的ss0及esp0信息。
- 使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈找到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令。
- 将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了。
系统调用过程
为了安全,Linux 中分为用户态和内核态两种运行状态。对于普通进程,平时都是运行在用户态下,仅拥有基本的运行能力。当进行一些敏感操作,比如说要打开文件(open)然后进行写入(write)、分配内存(malloc)时,就会切换到内核态。内核态进行相应的检查,如果通过了,则按照进程的要求执行相应的操作,分配相应的资源。这种机制被称为系统调用,用户态进程发起调用,切换到内核态,内核态完成,返回用户态继续执行,是用户态唯一主动切换到内核态的合法手段(exception 和 interrupt 是被动切换)
- 系统调用原理:
系统调用主要是通过中断门实现的,通过软中断int发出中断信号。由于要支持的系统功能很多,不可能每个系统调用就占用一个中断向量。所以规定了0x80为系统调用的中断向量号,在进行系统调用之前,向累加器(eax)中写入系统调用的子功能号,再进行系统调用的时候,系统就会根据累加器(eax)的值来决定调用哪个中断处理例程。
实现系统调用的过程如下:
- 用中断实现系统调用,通过0x80中断作为系统调用的入口;
- 在中断描述符表(IDT)中安装0x80中断对应的描述符,在该描述符中注册系统调用对应的中断处理例程;
- 建立系统调用子功能表,利用累加器(eax寄存器)中的子功能号在该表中索引相应的处理函数;
- 实现系统调用。(利用宏实现用户空间系统调用接口_syscall,
宏内核和微内核
- 宏内核:
- 特点:除了基本的进程、线程管理、内存管理外,将文件系统,驱动,网络协议等等都集成在内核里面,如linux内核;
- 优点:效率高
- 缺点:稳定性差,开发过程中的bug经常会导致整个系统挂掉。
- 微内核:内核中只有最基本的调度、内存管理。驱动,文件系统等都是用户态的守护进程去实现的。
- 优点:稳定,驱动等错误指挥导致相应的进程死掉,不会导致整个系统崩溃;
- 缺点:效率低;
大端和小端
大端是指低字节存储在高地址;小端存储是指低字节存储在低地址。
各自特点:
- 小端模式:强制转换数据不需要调整字节内容,1、2、4字节的存储方式一样。
- 大端模式:符号位的判定固定为第一个字节,容易判断正负。
判断方法:
short a = 0x1122;
char b = *(char)&a; //通过将short(2字节)强制类型转换成char单字节,b指向a的起始字节(低字节)
if(b = 0x11)
//大端
else
//小端
void IsBigEndian()//原理:联合体union的存放顺序是所有成员都从低地址开始存放,而且所有成员共享存储空间
{
union temp
{
short int a;
char b;
}temp;
temp.a = 0x1234;
if( temp.b == 0x12 )//低字节存的是数据的高字节数据
{
//是大端模式
}
else
{
//是小端模式
}
}
TCP模型
四层TCP/IP模型如下:
- 数据链路层:
- 作用:
- 实现网卡接口的网络驱动,以处理数据在以太网等物理媒介上传输;
- 网络驱动程序隐藏了不同物理网络层的不同电气特性,为上层协议提供一个同一的接口;
- 协议应用:ARP和RAPP(逆地址解析协议),该协议实现了IP地址和物理地址(MAC地址)之间的转换;
- 网络层:
- 作用:网络又分局域网(LAN)和广域网(WAN)。对于后者通常需要使用众多分级的路由器来连接分散的主机或者LAN,及通讯的两台主机一般不是直接连接,而是通过多个中间节点(路由器)连接的,从而形成网络拓扑连接。
- 网络层的任务之一就是选择这些中间节点,以确定两台主机之间的通讯路径;
- 其次在网络层对上层协议隐藏了网络拓扑连接的细节,使得在传输层看来通讯双方是直接相连的;
- 协议应用:
- IP协议:IP协议是网络层最核心的协议,它根据数据包的目的IP地址来决定如何投递该数据包。若数据包不可直接发送给目标主机,那么IP协议就为它寻找一个合适的下一跳路由器,并将数据包交付给该路由器去转发,如此循环直至到达目标主机或者发送失败而丢弃该数据包。
- ICMP协议:ICMP(Internet Control Message Protocol,因特网控制报文协议)是IP协议的补充,用于检测网络的连接状态,如ping应用程序就是ICMP协议的使用。ICMP包发送是不可靠的,所以不能依靠接收ICMP包解决网络问题;ICMP与TCP/UDP不同,他们是传输层协议,虽然都具有类型域和代码域,但是前者和后者不同,ping用到的ICMP协议,不是端口。
- 传输层:
- 作用:传输层的作用是为应用程序提供端对端通讯的“错觉”,即为应用程序隐藏了数据包跳转的细节,负责数据包的收发,链路超时重连等。
- 协议应用:
- TCP:TCP协议(Transmission Control Protocol, 传输控制协议)为应用程序提供可靠的、面向连接的、基于流的服务,具有超时重传、数据确认等方式来确保数据包被正确发送到目的端。因此TCP服务是可靠的,使用TCP协议通讯的双方必须先建立起TCP连接,并在系统内核中为该连接维持一些必要的数据结构,比如连接的状态,读写缓冲区,多个定时器等。当通讯结束时双方必须关闭连接以释放这些内核数据。基于流发送意思是数据是没有长度限制,它可源源不断地从通讯的一段流入另一端。
- UDP:UDP协议(User Datagram Protocol, 用户数据报协议)与TCP协议相反,它为应用程序提供的是不可靠的、无连接的基于数据报的服务。无连接: 通讯双方不保持一个长久的联系,因此应用程序每次发送数据都要明确指定接收方的地址;基于数据报的服务: 这是相对于数据流而言的,每个UDP数据报都有一个长度,接收端必须以该长度为最小单位将其内容一次性读出,否则数据将被截断。 UDP不具有发送时是被重发功能,所以UDP协议在内核实现中无需为应用程序的数据保存副本,当UDP数据报被成功发送之后,UDP内核缓冲区中该数据报就被丢弃了。
- SCTP:SCTP(Stream Control Transmission Protocol, 流控制传输协议)是为了在因特网上传输电话信号而设计的。
- 应用层:
- 作用:前面所述的三层负责处理网络通讯的相关细节,这部分需要稳定高效,因此他们是在操作系统内核空间中,而应用层是在用户空间实现的,负责处理众多业务逻辑,如文件传输,网络管理等。
- 协议应用:
- telne协议: 远程登录协议,它使我们能在本地完成远程任务
- DNS协议: DNS协议(Domain Name Service, 域名服务)提供机器域名到IP地址的转换。
- OSPF协议: OSPF协议(Open Shorttest Path First, 开放最短路径优先)是一种动态路由更新协议,用于路由器之间的通讯,以告知对方自身的路由信息
ARP(Address Resolution Protocol) 地址解析协议
RARP(Reverse Address Resolution Protocol) 逆向地址解析协议
与ARP相反,用于将MAC地址转化为IP地址。
TCP协议
- 特性:
- 提供面向连接的、可靠的字节流服务;
- 在一个TCP连接中,仅有两方进行彼此通信,广播和多播不能用于TCP;
- TCP使用校验和,确认和重传机制来保证可靠传输;
- 给数据分节进行排序,并使用累积确认来保证数据的顺序不变和非重复;
- 使用滑动窗口机制实现流量控制,通过动态改变窗口大小进行拥塞控制;
TCP 如何保证可靠性的,讲述下TCP建立连接和断开连接的过程
-
序列号、确认应答、超时重传
数据到达接收方,接收方需要发出一个确认应答,表示已经收到该数据段,并且确认序号会说明了它下一次需要接受的数据序列号。如果发送方迟迟未收到确认应答,则可能是数据丢失,也可能是确认应答丢失,这是发送方在等待一定时间后会进行重传。这个时间一般是两倍的RTT(报文段往返时间)+一个偏值。
-
窗口控制与高速重发控制、快速重传
TCP会利用窗口控制来提高传输速度,意思是在一个窗口大小内,不用一定要等到应答才能发送下一段数据,窗口大小就是无需等待确认而可以继续发送数据的最大值。如果不适用窗口控制,每一个没收到确认应答的数据都需要重发;
-
拥塞控制:如果把窗口定的很大,发送端连续发送大量的数据,可能会造成网络的拥堵,甚至会造成网络的瘫痪。所以TCP为了防止这种情况添加了拥塞控制;
- 慢启动:定义拥塞窗口,一开始将窗口大小设置为1,之后每次收到确认应答(经过一个rtt),将窗口大小*2;
- 拥塞避免:设置启动阈值,一般开始都设为65536.拥塞避免是指当拥塞窗口大小达到这个阈值,拥塞窗口的值不咋指数上升,而是加法增加(+1),以此来避免拥塞;
将报文段的超市重传看做拥塞,则一旦发生超时重传,我们需要先将阈值设置为当前窗口大小的一半,并将窗口大小设为初值1,然后重新进入慢启动过程;快速重传:在遇到3次重复确认应答(高速重发控制)时,代表收到了3个报文段,但是这之前的1个段丢失了,便对它进行立即重传。然后,先将阈值设为当前窗口大小的一半,然后将拥塞窗口大小设为慢启动阈值+3的大小。这样可以达到:在TCP通信时,网络吞吐量呈现逐渐的上升,并且随着拥堵来降低吞吐量,再进入慢慢上升的过程,网络不会轻易的发生瘫痪。
-
三次握手与四次挥手
三次握手:
- client标志位SYN置1,随机产生一个值seq=J,并将改数据包发送给server,client进入SYN_SENT状态,等待server确认;
- Server收到数据包后由标志位SYN=1知道Client请求建立连接,Server将标志位SYN和ACK都置为1,ack=J+1,随机产生一个值seq=K,并将该数据包发送给Client以确认连接请求,Server进入SYN_RCVD状态。
- Client收到确认后,检查ack是否为J+1,ACK是否为1,如果正确则将标志位ACK置为1,ack=K+1,并将该数据包发送给Server,Server检查ack是否为K+1,ACK是否为1,如果正确则连接建立成功,Client和Server进入ESTABLISHED状态,完成三次握手,随后Client与Server之间可以开始传输数据了。
四次挥手:
由于TCP连接时全双工的,因此,每个方向都必须要单独进行关闭,这一原则是当一方完成数据发送任务后,发送一个FIN来终止这一方向的连接,收到一个FIN只是意味着这一方向上没有数据流动了,即不会再收到数据了,但是在这个TCP连接上仍然能够发送数据,直到这一方向也发送了FIN。首先进行关闭的一方将执行主动关闭,而另一方则执行被动关闭。
- 数据传输结束后,客户端的应用进程发出连接释放报文段,并停止发送数据,客户端进入FIN_WAIT_1状态,此时客户端依然可以接收服务器发送来的数据。
- 服务器接收到FIN后,发送一个ACK给客户端,确认序号为收到的序号+1,服务器进入CLOSE_WAIT状态。客户端收到后进入FIN_WAIT_2状态。
- 当服务器没有数据要发送时,服务器发送一个FIN报文,此时服务器进入LAST_ACK状态,等待客户端的确认。
- 客户端收到服务器的FIN报文后,给服务器发送一个ACK报文,确认序列号为收到的序号+1。此时客户端进入TIME_WAIT状态,等待2MSL(MSL:报文段最大生存时间),然后关闭连接。
UDP协议
UDP是一个简单的传输层协议,和TCP相比,UDP有下面几个显著特征:
- UDP缺乏可靠性。UDP本身不提供确认、序列号、超时重传等机制。UDP数据报可能在网络中被复制,被重新排序。即UDP不保证数据报会到达其最终目的地。也不保证各个数据报的先后顺序,也不保证每个数据报只到达一次;
- UDP数据报是有长度的。每个UDP数据报都有长度,如果一个数据报正确到达目的地,那么该数据报的长度将随数据一起传递给接收方。而TCP协议是一个字节流协议,没有任何(协议上的)记录边界;
- UDP是无连接的。UDP客户和服务器之间不必存在长期的关系。UDP发送数据报之前也不需要经过握手建连接的过程;
- UDP支持多播和广播;
TCP和UDP的区别以及各自的使用场景
- 区别:
- TCP是面向连接的传输层协议,即传输数据之前要建立好连接;而UDP是无连接的;
- 服务对象来说,TCP是点对点的两点间服务,即一条TCP连接只能两个端点;UDP支持一对一,一对多,多对一,多对多的交互通信;
- 可靠性:TCP是可靠交付,无差错,不丢失,不重复,按序到达;UDP是进最大努力交付,不保证交付可靠;
- 拥塞控制,流量控制:TCP拥有拥塞控制可流量控制保证数据传输的安全性,UDP没有拥塞控制,网络拥塞不会影响源主机的发送速度;
- 报文长度:TCP是动态报文长度,即TCP报文长度是根据接收方的窗口大小和当前网络拥塞情况决定的;UDP面向报文,不合并,不拆分,保留上面传下来的报文边界;
- 首部开销:TCP首部开销大,首部20个字节;UDP首部开销小,8字节。(源端口,目的端口,数据长度,校验和)。
*使用场景:
从特点知道,TCP是可靠的但传输速度慢,UDP是不可靠的但传输速度快。因此在选用具体协议通信时,应该根据通信数据的要求而决定。
若通信数据完整性需让位于通信的实时性,则应选择TCP协议(如文件传输,重要状态的更新等);反之,则使用UDP协议(如视频传输,实时通信等)
MAC(Media Access Control)地址
- 或称为物理地址、硬件地址,用来定义互联网中设备的位置。
- 在 TCP/IP 层次模型中,网络层管理 IP 地址,链路层则负责 MAC 地址。因此每个网络位置会有一个专属于它的 IP 地址,而每个主机会有一个专属于它 MAC 地址
TCP连接中的相关概念:
- seq:序号,占四个字节;由于TCP是面向数据流的,传送的数据流中的每个字节都会有一个序号,序号是循环使用的;
- ACK:仅当ACK=1是确认字段才有效,当ACK=0时确认字段无效,且TCP中,在建立连接后所有的传送报文段都必须要把ACK置1;
- SYN:同步序列号。用来发起一个连接,当SYN为1而ACK为0时表明这是一个请求报文段;若对方同意连接,则响应报文中SYN=1,ACK=1;
- FIN:用来释放一个连接,当FIN=1,表示此报文段的发送方已经发送完毕,并要求释放连接。
TCP三次握手四次挥手的过程,为什么TCP连接握手需要三次,以及time-wait的状态
-
TCP连接(三次握手)过程:
- 客户端A:发送SYN连接报文,序列号为x,进入SYNC-SENT状态;
- 服务端B:发送SYN连接确认报文(SYN=1,ACK=1),序列号为y,确认报文x(ack = x+1),进入SYNC-RCVD状态;
- 客户端A:发送ACK确认报文(ACK=1),序列号为x+1(seq = x+1),确认报文y+1(ack = y + 1),进入ESTABLISHED状态。
- 服务端B:接收到后进入ESTABLISHED状态;
-
三次握手的原因:
三次握手是为了防止客户端的请求报文在网络中滞留,客户端超时重传了请求报文,服务端简历连接,传授数据,释放连接之后,服务器又收到了客户端滞留的请求报文,建立连接一直等待客户端发送数据。
服务器对客户端的请求进行回应(第二次握手)后,就会理所当然的认为连接已经建立,而如果客户端并没有收到服务器的回应,此时客户端仍认为连接为建立,服务器会对已建立的而连接保存必要的资源,如果大量的这种情况,服务器就会崩溃。
-
TCP释放(四次挥手)过程:
- 服务器端A:发送FIN报文FIN=1, 序列号为u seq = u,进入FIN=WAIT 1状态;
- 客户端B:发送ACK确认报文,ACK=1;序列号为v seq = v,确认报文u ack = u+1,进入CLOSE-WAIT状态,继续传送数据。
- 服务端A:发送ACK确认报文,ACK=1, 序列号为u+1,确认报文w ack = w+1,进入TIME-WAIT状态,等待2MSL(最长报文寿命),进入CLOSED状态。
- 客户端B:收到上述报文后进入CLOSED状态;
-
为什么TCP终止需要四次?
- 当客户端确认发送完数据且知道服务器已经接收完了,想要关闭发送数据口(当然确认信号还是可以发),就会发FIN给服务器。
- 服务器收到客户端发送的FIN,表示收到了,就会发送ACK回复。
- 但这时候服务器可能还在发送数据,没有想要关闭数据口的意思,所以服务器的FIN与ACK不是同时发送的,而是等到服务器数据发送完了,才会发送FIN给客户端。
- 客户端收到服务器发来的FIN,知道服务器的数据也发送完了,回复ACK, 客户端等待2MSL以后,没有收到服务器传来的任何消息,知道服务器已经收到自己的ACK了,客户端就关闭链接,服务器也关闭链接了。
注:2MSL的意义
- 保证最后一次握手报文能到B,能进行超时重传。
- 2MSL后,这次连接的所有报文都会消失,不会影响下一次连接。
端口号
-
IP 地址是用来发现和查找网络中的地址,但是不同程序如何互相通信呢?这就需要端口号来识别了。如果把 IP 地址比作一间房子,端口就是出入这间房子的门。真正的房子只有几个门,但是端口采用 16 比特的端口号标识,一个 IP 地址的端口可以有 65536(即:216)个之多!
-
服务器的默认程序一般都是通过人们所熟知的端口号来识别的。例如,对于每个 TCP/IP 实现来说,SMTP(简单邮件传输协议)服务器的 TCP 端口号都是 25,FTP(文件传输协议)服务器的 TCP 端口号都是 21,TFTP(简单文件传输协议)服务器的 UDP 端口号都是 69。任何 TCP/IP 实现所提供的服务都用众所周知的 1-1023 之间的端口号。这些人们所熟知的端口号由 Internet 端口号分配机构(Internet Assigned Numbers Authority,IANA)来管理。
-
常用协议对应端口号:
- SSH 22
- FTP 20 和 21
- Telnet 23
- SMTP 25
- TFTP 69
- HTTP 80
- SNMP 161
- Ping 使用 ICMP,无具体端口号
封装与分用
- 封装:当应用程序发送数据的时候,数据在协议层次当中自顶向下通过每一层,每一层都会对数据增加一些首部或尾部信息,这样的信息称之为协议数据单元(Protocol Data Unit,缩写为 PDU),在分层协议系统里,在指定的协议层上传送的数据单元,包含了该层的协议控制信息和用户信息。如下图所示:
- 物理层(一层)PDU 指数据位(Bit)
- 数据链路层(二层)PDU 指数据帧(Frame)
- 网络层(三层)PDU 指数据包(Packet)
- 传输层(四层)PDU 指数据段(Segment)
- 第五层以上为数据(data)
- 分用:当主机收到一个数据帧时,数据就从协议层底向上升,通过每一层时,检查并去掉对应层次的报文首部或尾部,与封装过程正好相反。
IP数据报
缺点:
-
不可靠:IP 协议不能保证数据报能成功地到达目的地,它仅提供传输服务。当发生某种错误时,IP 协议会丢弃该数据报。传输的可靠性全由上层协议来提供。
-
无连接:IP 协议对每个数据报的处理是相互独立的。这也说明,IP 数据报可以不按发送顺序接收。如果发送方向接收方发送了两个连续的数据报(先是 A,然后是 B),每个数据报可以选择不同的路线,因此 B 可能在 A 到达之前先到达。
-
IP地址分类
为了便于寻址以及层次化构造网络,每个 IP 地址可被看作是分为两部分,即网络号和主机号。同一个区域的所有主机有相同的网络号(即 IP 地址的前半部分相同),区域内的每个主机(包括路由器)都有一个主机号与其对应。
-
A类地址:1.0.0.0–127.0.0.0
- A类IP地址范围:1.0.0.0–127.255.255.255
- A类IP的私有地址(只在局域网中使用)范围:10.0.0.–10.255.255.255
- 127.X.X.X是保留地址,用作循环测试用的;
- 因为主机号有24位,所以一个A类网络号可以容纳2^24-2 = 16777214
-
B类地址
- 范围:128.0.0.0–191.255.0.0
- 169.254.X.X 是保留地址;191.255.255.255 是广播地址;
- 因为主机号有 16 位,所以一个 B 类网络号可以容纳 216-2=65534 个主机号。
-
C类地址
- 范围:192.0.0.0—223.255.255.0;
- 因为主机号有 8 位,所以一个 C 类网络号可以容纳 28-2=254 个主机号。
IGMP(Internet Group Management Protocol)组管理协议
- IGMP 是用于管理多播组成员的一种协议,它的作用在于:让其它所有需要知道自己处于哪个多播组的主机和路由器知道自己的状态。只要某一个多播组还有一台主机,多播路由器就会把数据传输出去,这样接受方就会通过网卡过滤功能来得到自己想要的数据。
- 为了知道多播组的信息,多播路由器需要定时的发送 IGMP 查询,各个多播组里面的主机要根据查询来回复自己的状态。路由器来决定有几个多播组,自己要对某一个多播组发送什么样的数据
Socket基本概念
Socket 是对 TCP/IP 协议族的一种封装,是应用层与TCP/IP协议族通信的中间软件抽象层。从设计模式的角度看来,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议。
Socket 还可以认为是一种网络间不同计算机上的进程通信的一种方法,利用三元组(ip地址,协议,端口)就可以唯一标识网络中的进程,网络中的进程通信可以利用这个标志与其它进程进行交互。
Socket 起源于 Unix ,Unix/Linux 基本哲学之一就是“一切皆文件”,都可以用“打开(open) –> 读写(write/read) –> 关闭(close)”模式来进行操作。因此 Socket 也被处理为一种特殊的文件。
写一个简易的 WebServer
一个简易的 Server 的流程如下:
- 建立连接,接受一个客户端连接。
- 接受请求,从网络中读取一条 HTTP 请求报文。
- 处理请求,访问资源。
- 构建响应,创建带有 header 的 HTTP 响应报文。
- 发送响应,传给客户端。
省略流程 3,大体的程序与调用的函数逻辑如下:
- socket() 创建套接字
- bind() 分配套接字地址
- listen() 等待连接请求
- accept() 允许连接请求
- read()/write() 数据交换
- close() 关闭连接
SOCKET编程中服务端和客户端主要用到哪些函数
- 基于TCP的socket:
- 服务器端程序:
- 创建一个socket,用函数socket()
- 绑定IP地址、端口等信息到socket上,用函数bind()
- 设置允许的最大连接数,用函数listen()
- 接收客户端上来的连接,用函数accept()
- 收发数据,用函数send()和recv(),或者read()和write()
- 关闭网络连接
- 客户端程序:
- 创建一个socket,用函数socket()
- 设置要连接的对方的IP地址和端口等属性
- 连接服务器,用函数connect()
- 收发数据,用函数send()和recv(),或read()和write()
- 关闭网络连接
Socket编程的send() recv() accept() socket()函数?
send函数用来向TCP连接的另一端发送数据。客户程序一般用send函数向服务器发送请求,而服务器则通常用send函数来向客户程序发送应答,send的作用是将要发送的数据拷贝到缓冲区,协议负责传输。
recv函数用来从TCP连接的另一端接收数据,当应用程序调用recv函数时,recv先等待s的发送缓冲中的数据被协议传送完毕,然后从缓冲区中读取接收到的内容给应用层。
accept函数用了接收一个连接,内核维护了半连接队列和一个已完成连接队列,当队列为空的时候,accept函数阻塞,不为空的时候accept函数从上边取下来一个已完成连接,返回一个文件描述符。