Catalog
  1. 1. C++
  2. 2. C和C++有什么区别
    1. 2.1. new和malloc的8个区别
  3. 3. 指针和引用的区别
  4. 4. Const和Static的区别
    1. 4.1. 扩展 关于const指针
    2. 4.2. 关于static
  5. 5. vector 和 list的区别
    1. 5.1. vector
    2. 5.2. list
    3. 5.3. 迭代器
  6. 6. lambda表达式
    1. 6.1. 什么是lambda表达式
  7. 7. 继承访问权限
    1. 7.1. 公有继承(public)
    2. 7.2. 保护继承(protected)
    3. 7.3. 私有继承(private)
  8. 8. 析构函数原理以及步骤
    1. 8.1. 什么是析构函数?
    2. 8.2. 析构函数完成什么工作
    3. 8.3. 什么时候会调用析构函数
    4. 8.4. 为什么需要虚析构函数
  9. 9. 类对象的内存存储形式
    1. 9.1. 空类
    2. 9.2. 含有成员变量的类
    3. 9.3. 含有成员变量和成员函数的类
    4. 9.4. 总结
  10. 10. C++进程内存空间分布
    1. 10.1. 文本段
    2. 10.2. 初始化数据段
    3. 10.3. 未初始化数据段
    4. 10.4.
    5. 10.5.
  11. 11. 虚函数以及虚函数的作用
    1. 11.1. 什么是虚函数
    2. 11.2. 虚函数的实现原理
    3. 11.3. 虚函数表存放的位置
  12. 12. volatile关键字
  13. 13. 多重继承
    1. 13.1. 多重继承构造函数的问题
  14. 14. extern C
  15. 15. Linux
  16. 16. 五种IO模型
    1. 16.1. 阻塞I/O:(blocking IO)
    2. 16.2. 非阻塞I/O模型 (nonblocking IO)
    3. 16.3. IO多路复用模型
    4. 16.4. 信号驱动I/O模型
    5. 16.5. 异步I/O模型(asynchronous IO)
    6. 16.6. 总结
      1. 16.6.1. blocking和non-blocking的区别
      2. 16.6.2. synchronous IO和asynchronous IO的区别
  17. 17. select, poll, epoll
    1. 17.1. 用户空间和内核空间
    2. 17.2. 进程切换
    3. 17.3. 进程的阻塞
    4. 17.4. 文件描述符fd
    5. 17.5. IO 模式
    6. 17.6. 多路复用
    7. 17.7. select
    8. 17.8. poll
    9. 17.9. epoll
    10. 17.10. epoll
      1. 17.10.1. epoll的操作过程
        1. 17.10.1.1. int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
      2. 17.10.2. 工作事件
      3. 17.10.3. epoll 总结
    11. 17.11. 三者区别
C++ 后台开发总结

C++

C和C++有什么区别

  1. C是面向过程的语言,CPP是面向对象的语言
  2. Cpp兼容了大部分C的规则,并且CPP在C的基础上扩展出继承等新特性。
  3. Cpp和C对于内存的管理方式不同,Cpp通过newdelete管理内存,C是通过mallocfree管理内存
  4. newdelete属于操作符,mallocfree属于函数
  5. Cpp中对于类来说有一个默认初始值的概念,而C中的结构体不存在这个概念
  6. Cpp中struct和class除了默认访问权限与默认继承权限不同之外无其他差别,并且支持继承,而C中的struct只有结构体的概念

newmalloc的8个区别

  1. 申请内存所在的位置不同
  2. 返回类型不同
    new的返回类型是对象的指针,而malloc返回类型是一个void *的指针
  3. 分配失败的返回值不同
    new分配失败是抛出一个bad_alloc的异常,而malloc是返回一个NULL指针
  4. 是否需要指定内存大小
    malloc需要指定,而new无需指定
  5. 是否调用构造函数/析构函数
  6. 对数组的处理方式
    new对于数组的申请需要必须调用 delete []来释放
  7. 不可以相互调用
  8. new可以被重载,malloc不可以被重载

指针和引用的区别

  1. 指针是一个变量,只不过存储的是地址,指向内存的一个存储单元;引用是所引用变量的别名。
  2. const指针,但没有const引用。
  3. 指针可以有多级;引用只能有一级。
  4. 指针可以为空;引用不可以,在定义时必须初始化。
  5. 指针在初始化之后可以改变;但引用在初始化之后不能改变。
  6. 指针sizeof后的大小是指针大小;而引用sizeof的大小所指变量/对象的大小。
  7. 指针和引用的自增也不同。
  8. 指针和引用作为参数传递也有区别。

Const和Static的区别

  1. const 定义的常量出其作用域之后,空间会被释放;static定义的静态常量在执行后不会被释放。
  2. C++中,static静态成员变量不能在类内初始化。在类的内部只是声明,定义必须在类定义提的外部。并且static是属于类的,不是属于对象的。
  3. C++中,const变量必须在定义的时候进行初始化,且不能修改。
  4. C++中,const可以在类内声明常量成员函数,该常量成员函数返回的this指针是一个不可修改的,显示的向程序员表示,该函数不可改变类的成员。
  5. C++中,static成员函数目的是作为类作用域的全局函数,且不能访问非静态成员变量,没有this指针。

扩展 关于const指针

根据《C++ primer》所说到的。

用名词顶层const(top-level const)表示指针本身是个常量,用名词底层const(low-level const)表示指针所指的对象是一个常量。顶层const可以表示任意的对象是常量。

1
2
3
4
5
6
int i = 0;
int *const p1 = &i; //不能改变p1的值,这是一个顶层const
const int ci =42; //不能改变ci的值,这是一个顶层const
const int *p2 = &ci; //允许改变p2的值,这是一个底层const
const int *const p3 = p2; //靠右的const是顶层const,靠左的是底层const
const int &r = ci; //用于声明引用的const都是底层const

关于static

当全局变量用static修饰时,表示静态全局变量,意味着该变量只在这个源文件中可用


vector 和 list的区别

vector

底层通过数组实现,支持随机访问,查找效率高,时间复杂度为O(1)

当capacity不够时,需要重新开辟空间

list

底层通过循环双链表实现,遵循STL前闭后开的原则。

底层没有连续的空间,只能通过指针来访问,查找数据需要遍历,时间复杂度为O(n)

迭代器

std::vector::iterator支持++=<等操作符

std::list::iterator则不支持


lambda表达式

我们可以向算法传递任何类别的可调用对象。对于一个对象或一个 表达式,如果可以对其使用调用运算法,则称它为可调用的。如,e是一个可调用的表达式,则我们可以编写代码e(args),其中args是一个逗号分隔的一个或多个参数的列表。

什么是lambda表达式

lambda表达式表示一个可调用的代码单元。可以将其理解为一个未命名的内联函数。

[capture list] (parameter list) -> return type { function body }

其中,capature list是一个lambda所在函数中定义的局部变量的列表,return typeparameter listfunction body与普通函数一样,分别表示返回类型、参数列表和函数体。lambda表达式必须使用尾置返回来指定返回类型。


继承访问权限

继承访问权限对于子类成员来说是没有影响的,影响的是子类的对象.

公有继承(public)

父类的public成员对于子类来说还是public的,子类的对象不可以访问父类的private成员,可以访问父类的public成员

保护继承(protected)

父类的public成员对于子类来说是protected的,子类成员可以访问父类的protected和public成员,但是子类的对象对于父类的所有成员都没有访问权限

私有继承(private)

父类的public成员对于子类来说是private的,子类的成员可以访问父类的protected和public成员,但是子类的对象对于父类的所有成员都没有访问权限

析构函数原理以及步骤

什么是析构函数?

​ 构造函数初始化对象的非static数据成员,还可能做一些其他的工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。

​ 析构函数是类的一个成员函数,名字由波浪号接类名构成。没有返回值,也不接受参数:

1
2
3
4
class Foo{
public :
~Foo(); //析构函数
};

由于析构函数不接受参数,因此不能被重载。对于一个给定类,只会有唯一一个析构函数。

析构函数完成什么工作

在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照他们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员是按初始化顺序的逆序销毁。

通常,析构函数释放对象在生存期分配的所有资源。

成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。

隐式地销毁一个内置指针类型成员,不会delete它所指向的对象

内置指针指的是非智能指针

什么时候会调用析构函数

无论何时一个对象被销毁,就会自动调用其析构函数:

  • 变量在离开其作用域时被销毁
  • 当一个对象被销毁时,其成员被销毁
  • 容器被销毁时,其元素被销毁
  • 对于动态分配的对象,当对指向他的指针应用delete运算符时被销毁
  • 对于临时对象,当创建它的完整表达式结束时被销毁。

当指向一个对象的引用或者指针离开作用域时,析构函数不会执行。

为什么需要虚析构函数

虚析构函数是为了解决父类指针指向子类对象时,释放子类对象的资源时,释放不完全,造成的内存泄漏问题。


类对象的内存存储形式

空类

1
2
3
4
5
6
class Test {

};
Test t0;
std::cout << sizeof(t0) <<std::endl;
// 运行结果: 1
  • 空类,没有任何成员变量和成员函数,编译器是支持空类实例化对象的,对象必须要被分配内存空间才有意义,这里编译器默认分配了 1Byte 内存空间(不同的编译器可能不同),标准禁止对象大小为 0,因为两个不同的对象需要不同的地址表示。

含有成员变量的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// test 1
class Test {
private:
int i;
char c;
double d;
};

Test t11;
std::cout << sizeof(t11) << std::endl;
// 运行结果: 16
class Test {
private:
int i;
double d;
char c;
};

Test t11;
std::cout << sizeof(t11) << std::endl;
//运行结果24
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ====== 测试二 ======
class A{};

class Test {
private:
int i;
char c;
double d;
A a;
};

Test t12;
std::cout << sizeof(t12) << std::endl;
// 运行结果:24
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ====== 测试三 ======
class A {
private:
double dd;
int ii;
int *pp;
};

class Test {
private:
int i;
A a;
double d;
char *p;
};

Test t13;
std::cout << sizeof(t13) << std::endl;
// x86目标平台运行结果:40;x64目标平台下运行结果:48

内存对齐的三条规则

  1. 数据成员对齐规则,结构体(struct)(或联合(union))的数据成员,第一个数据成员存放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员(只要该成员有子成员,比如数组、结构体等)大小的整数倍开始(如:int 在 64bit 目标平台下占用 4Byte,则要从4的整数倍地址开始存储)
  2. 结构体作为成员,如果一个结构体里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储
  3. 结构体的总大小,即sizeof的结果,必须是其内部最大成员长度(即前面内存对齐指令中提到的有效值)的整数倍,不足的要补齐

含有成员变量和成员函数的类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// ====== 测试一 ======
class Test {
private:
int n;
char c;
short s;
};

Test t21;
cout << sizeof(t21) << endl;
// 运行结果:8

// ====== 测试二 ======
class Test {
public:
Test() {

}

int func0() {
return n;
}

friend int func1();

int func2() const {
return s;
}

inline void func3() {
cout << "inline function" << endl;
}

static void func4() {
cout << "static function" << endl;
}

~Test() {

}

private:
int n;
char c;
short s;
};

int func1() {
Test t;
return t.c;
}

Test t22;
cout << sizeof(t22) << endl;
// 运行结果:8

// ====== 测试三 ======
class Test {
public:
Test() {

}

int func0() {
return n;
}

friend int func1();

int func2() const {
return s;
}

inline void func3() {
cout << "inline function" << endl;
}

static void func4() {
cout << "static function" << endl;
}

virtual void func5() {
cout << "virtual function" << endl;
}

~Test() {

}

private:
int n;
char c;
short s;
};

int func1() {
Test t;
return t.c;
}

Test t23;
cout << sizeof(t23) << endl;
// x86目标平台运行结果:12;x64目标平台下运行结果:16

解释:

  • 因 C++中成员函数和非成员函数都是存放在代码区的,故类中一般成员函数、友元函数,内联函数还是静态成员函数都不计入类的内存空间,测试一和测试二对比可证明这一点
  • 测试三中,因出现了虚函数,故类要维护一个指向虚函数表的指针,分别在 x86目标平台和x64目标平台下编译运行的结果可证明这一点

总结

  • C++编译系统中,数据和函数是分开存放的(函数放在代码区;数据主要放在栈区和堆区,静态/全局区以及文字常量区也有),实例化不同对象时,只给数据分配空间,各个对象调用函数时都都跳转到(内联函数例外)找到函数在代码区的入口执行,可以节省拷贝多份代码的空间

  • 类的静态成员变量编译时被分配到静态/全局区,因此静态成员变量是属于类的,所有对象共用一份,不计入类的内存空间

  • 静态成员函数和非静态成员函数都是存放在代码区的,是属于类的,类可以直接调用静态成员函数,不可以直接调用非静态成员函数,两者主要的区别是有无this指。


C++进程内存空间分布

内存分为5个部分,从高地址到低地址依次为 栈区(stack), 堆区(heap),未初始化数据段(uninitialized data),初始化数据段(initialize data),代码段(text)

栈是从高到低分配,堆是从低到高分配

文本段

文本段也叫代码段,是对象文件或内存中程序的一部分,其中包含可执行指令。文本段在堆栈下面,是防止堆栈溢出覆盖它。通常代码段是共享的,对于经常执行的程序,只有一个副本需要存储在内存中,代码段是只读的,以防止程序以外修改指令。

初始化数据段

通常称为数据段,是程序虚拟地址空间的一部分。它包含有程序员初始化的全局变量和静态变量,可以进一步划分为只读区域和读写区域。

例如,C中的char=“hello world”的全局字符串,以及main(例如全局)之外的int debug=1这样的C语句,将被存储在初始的读写区域中。而像const char字符串=“hello world”这样的全局C语句常量字符串文字“hello world”被存储在初始化的只读区域中,并在初始化的读写区域中存储字符指针变量字符串。

未初始化数据段

通常称为bss段,通常用来存放程序中未初始化的全局变量和静态变量。这个段的数据在程序开始之前就内核初始化为0,包含所有初始化为0和没有显示初始化的全局变量和静态变量。

堆是动态内存分配通常发生的部分。内存分配由低到高,分配方式类似于数据结构中的链表。堆区域是从BSS段的末尾开始,从那里逐渐增加到更大的地址。堆是由程序员自己分配的。

存放自动变量,以及每次调用函数时保存的信息。每当调用一个函数时,返回到的地址和关于调用者环境的某些信息的地址。是由编译器从高到低分配的。


虚函数以及虚函数的作用

什么是虚函数

在C++语言中,基类必须将它的两种成员函数区分开来:

  1. 基类希望派生类进行覆盖的函数
  2. 基类希望派生类直接继承而不要改变的函数

其中第一种称为虚函数(virtual)。当我们使用指针或者引用调用虚函数时,该调用将动态绑定。根据引用和指针所绑定的对象类型不同,该调用可能执行基类的版本,也可能执行某个派生类的版本。

虚函数的实现原理

虚函数是通过一个叫做虚函数表的机制实现的。

每个包含了虚函数的类都包含一张虚函数表。虚表是一个指针数组,其元素是虚函数的指针,每个元素对应一个虚函数的函数指针。

虚函数表是属于类的,不是属于对象的,也就是说一个类只有一张虚函数表。

虚函数表具体存放位置是由编译器决定的

如果normalize()是一个虚成员函数,那么以下的调用:

ptr->normalize();

将会被转换成

(*ptr->vptr[1])(ptr)

虚函数表存放的位置

参考虚函数表存放在哪里

  1. 虚函数表是全局共享的元素,即全局仅有一个。

  2. 虚函数表类似一个数组,编译期会为每个类对象创建一个vptr(虚表指针)指针,指向虚函数表

  3. 虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,即虚函数表的大小可以确定。

虚函数表存放在全局数据区。

虚函数表是编译器来选择实现的,编译器的种类不同,可能实现方式不一样,就像前面我们说的vptr在一个对象的最前面,但是也有其他实现方式,不过目前gcc 和微软的编译器都是将vptr放在对象内存布局的最前面。


volatile关键字

参考自C/C++中volatile关键字详解

C++中的volatile关键字和const一样是用来修饰变量的,遇到这个关键字的变量,编译器对访问该变量的代码将不做优化。

volatile用来保证编译器不会对代码进行优化,每次访问都从该变量的源地址进行访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int main(void)
{
int i = 10;
int a = i;
printf("i = %d\n", a);
// 下面汇编语句的作用就是改变内存中 i 的值
// 但是又不让编译器知道
__asm {
mov dword ptr [ebp-4], 20h
}

int b = i;
printf("i = %d\n", b);

return 0;
}

然后,在 Release 版本模式运行程序,输出结果如下:

1
2
i = 10
i = 10

输出的结果明显表明,Release 模式下,编译器对代码进行了优化,第二次没有输出正确的 i 值。下面,我们把 i 的声明加上 volatile 关键字,看看有什么变化:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main(void)
{
volatile int i = 10;
int a = i;
printf("i = %d\n", a);
__asm {
mov dword ptr [ebp-4], 20h
}
int b = i;
printf("i = %d\n", b);
}

在Release 版本运行程序,输出是:

1
2
i = 10
i = 32

Visual Studio中,release和debug所分配的栈空间大小不同,在debug模式下的要写成[epb - 8]


多重继承

多重继承(multiple inheritance)是指从多个基类中产生派生类的能力。

在给定的派生类列表中,同一个基类只能出现一次。

多重继承构造函数的问题

子类初始化的时候会先初始化父类的对象,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
struct A {
A() { std::cout << "A()" << std::endl; }
~A() { std::cout << "~A()" << std::endl; }
};

struct B: public A {
B() { std::cout << "B()" << std::endl; }
~B() { std::cout << "~B()" << std::endl; }
};

struct C: public B {
C() { std::cout << "C() " << std::endl; }
~C() { std::cout << "~C()" << std::endl; }
};

struct X {
X() { std::cout << "X()" << std::endl; }
~X() { std::cout << "~X()" << std::endl; }
};

struct Y {
Y() { std::cout << "Y()" << std::endl; }
~Y() { std::cout << "~Y()" << std::endl; }
};

struct Z: X, Y {
Z() { std::cout << "Z()" << std::endl; }
~Z() { std::cout << "~Z()" << std::endl; }
};

struct MI: C, Z {
MI() { std::cout << "MI()" << std::endl; }
~MI() { std::cout << "~MI()" << std::endl; }
};

int main(int argc, char **argv)
{
MI mi;

return 0;
}

输出结果如下:

在初始化类的对象的时候,是先初始化基类的成员,再初始化派生类的成员;而在调用析构函数的时候,是先释放子类成员,再释放父类成员。


extern C

参考:extern “C”的作用详解

extern “C” 的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern “C”后,会指示编译器这部分代码按C语言(而不是C++)的方式进行编译。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern “C”就是其中的一个策略。

这个功能主要用在下面的情况

  1. C++代码调用C语言代码
  2. 在C++的头文件中使用
  3. 在多个人协同开发时,可能有的人比较擅长C语言,而有的人擅长C++,这样的情况下也会有用到
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//moduleA头文件
#ifndef __MODULE_A_H //对于模块A来说,这个宏是为了防止头文件的重复引用
#define __MODULE_A_H
int fun(int, int);
#endif

//moduleA实现文件moduleA.C //模块A的实现部分并没有改变
#include"moduleA"
int fun(int a, int b)
{
return a+b;
}

//moduleB头文件
#idndef __MODULE_B_H //很明显这一部分也是为了防止重复引用
#define __MODULE_B_H
#ifdef __cplusplus //而这一部分就是告诉编译器,如果定义了__cplusplus(即如果是cpp文件,
extern "C"{ //因为cpp文件默认定义了该宏),则采用C语言方式进行编译
#include"moduleA.h"
#endif
//其他代码

#ifdef __cplusplus
}
#endif
#endif

//moduleB实现文件 moduleB.cpp //B模块的实现也没有改变,只是头文件的设计变化了
#include"moduleB.h"
int main()
{
  cout<<fun(2,3)<<endl;
}

extern “C”包含双重含义,从字面上可以知道,首先,被它修饰的目标是”extern”的;其次,被它修饰的目标代码是”C”的。

  • 被extern “C”限定的函数或变量是extern类型的

extern是C/C++语言中表明函数和全局变量的作用范围的关键字,该关键字告诉编译器,其申明的函数和变量可以在本模块或其他模块中使用。

extern “C”的使用要点总结

1,可以是如下的单一语句:

1
extern "C" double sqrt(double);

2,可以是复合语句, 相当于复合语句中的声明都加了extern “C”

1
2
3
4
5
extern "C"
{
double sqrt(double);
int min(int, int);
}

3,可以包含头文件,相当于头文件中的声明都加了extern “C”

1
2
3
4
extern "C"
{
#include <cmath>
}
  • 不可以将extern “C” 添加在函数内部
  • 如果函数有多个声明,可以都加extern “C”, 也可以只出现在第一次声明中,后面的声明会接受第一个链接指示符的规则。
  • 除extern “C”, 还有extern “FORTRAN” 等。

Linux

五种IO模型

参考于IO五种模型

阻塞I/O:(blocking IO)

应用程序调用一个IO函数,导致应用程序阻塞,如果数据已经准备好,从内核拷贝到用户空间,否则一直等待下去。

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

非阻塞I/O模型 (nonblocking IO)

我们把一个套接口设置为非阻塞就是告诉内核,当所请求的I/O操作无法完成时,不要将进程睡眠,而是返回一个错误。这样我们的I/O操作函数将不断的测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。在这个不断测试的过程中,会大量的占用CPU的时间

当一个应用程序像这样对一个非阻塞描述符循环调用recvfrom时,我们称之为轮循(polling)。

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

1
2
3
4
5
6
while true {  
for i in stream[]; {
if i has data
read until unavailable
}
}

IO多路复用模型

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。select,poll,epoll这个function也会使进程阻塞,但是和阻塞I/O所不同的的,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作,多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。

信号驱动I/O模型

我们也可以用信号,让内核在描述符就绪时发送SIGIO信号通知我们。通过sigaction系统调用安装一个信号处理函数。该系统调用将立即返回,我们的进程继续工作,也就是说它没有被阻塞。当数据报准备好读取时,内核就为该进程产生一个SIGIO信号。

优势:等待数据报到达期间进程不被阻塞。主循环可以继续执行,只要等待来自信号处理函数的通知:既可以是数据已准备好被处理,也可以是数据报已准备好被读取。

异步I/O模型(asynchronous IO)

告知内核启动某个操作,并让内核在整个操作(包括将内核复制到我们自己的缓冲区)完成后通知我们。

与信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步模型是由内核通知我们I/O操作何时完成。

调用aio_read(Posix异步I/O函数以aio_或lio_开头)函数,给内核传递描述字、缓冲区指针、缓冲区大小(与read相同的3个参数)、文件偏移以及通知的方式,然后系统立即返回。我们的进程不阻塞于等待I/0操作的完成。当内核将数据拷贝到缓冲区后,再通知应用程序。

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

总结

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。


select, poll, epoll

参考于Linux IO模式及 select、poll、epoll详解

用户空间和内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。

为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。
  2. 更新PCB信息。
  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
  4. 选择另一个进程执行,并更新其PCB。
  5. 更新内存管理的数据结构。
  6. 恢复处理机上下文。

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

IO 模式

对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备 (Waiting for the data to be ready)
  2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。见《I/O模型之一:Unix的五种I/O模型

  • - 阻塞 I/O(blocking IO)
  • - 非阻塞 I/O(nonblocking IO)
  • - I/O 多路复用( IO multiplexing)
  • - 信号驱动 I/O( signal driven IO)
  • - 异步 I/O(asynchronous IO)

多路复用

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。


select

select是1983年的4.2BSD提出。

系统在select用32*32=1024位来进行查询。返回的时候数组如readfds是已经处理过的了,返回时只有准备好事件的fd。所以需要轮训(要用FD_ISSET挨个比较)和重新赋值。FD_ISSET(fd,&readfds)

原型

1
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout)

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

使用方法总共分三步:

  1. 三个fd_set初始化,用FD_ZERO FD_SET
  2. 调用select
  3. 用fd遍历每一个fd_set使用FD_ISSET。如果成功就处理。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout)

不同与select使用三个位图来表示三个fdset的方式,poll使用一个pollfd的指针实现。

1
2
3
4
5
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};

pollfd结构体包含了要监视的event和发生的event,不再使用select”参数-值”传递的方式。同时,pollfd并没有最大数量的限制,但是数量过大的话性能也是会下降。

和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态。因此随着fd的增长,其效率也会线性下降。

<<<<<<< HEAD

  1. pollfd初始化,绑定sock,设置事件event,revent。设置时间限制。
  2. 调用poll
  3. 遍历看他的事件是否发生了,发生了就置0

epoll

epoll是2.6内核提出的,是select和poll的增强版本。而且只在linux下支持。相对于select和poll来说,epoll更加灵活,没有描述符的限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件放到内核的一个事件表中,这样在用户空间和内核空间的copy只需要一次。

epoll是直接在内核里的,用户调用系统调用去注册,因此省去了每次的复制和轮询的消耗。这里用了三个系统调用,epollcreate只要每次调用开始调用一次创造一个epoll就可以了。然后用epoll_ctl来进行添加事件,其实就是注册到内核管理的epoll里。然后直接epoll_wait就可以了。系统会返回系统调用的。

epoll操作过程需要三个接口,分别如下:

  1. Polled 初始化,绑定sock,设置事件event,revent。设置时间限制。
  2. 调用poll
  3. 遍历看他的事件是否发生,如果发生了就置为0

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。而且只在linux下支持。相对于select和poll来说,epoll更加灵活,没有描述符的限制。epoll使用一个文件描述符来管理多个描述符,将用户关系的文件描述符的时间存放于内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

epoll是直接在内核理的,用户通过系统调用去注册,省去了每次的肤质和轮询的消耗。epoll_create只要每次调用开始调用一次创造一个epoll就可以了。然后用epoll_ctl来进行添加事件,其实就是注册到内核管理的epoll里。然后直接epoll_wait就可以了。系统会返回系统调用的。

  1. 通过调用epoll_create 构建epoll描述符
  2. 将上下文数据指针初始化
  3. 调用epoll_ctl 添加文件描述符
  4. 1调用epoll_wait每次处理20个事件。
  5. 遍历返回的数据

epoll的操作过程

eoll有以下三个接口

947bb0b2294229b75040d31938a8ca42dc7e7ac5

1
2
3
4
int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
<<<<<<< HEAD

=======

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

#### int poll_create(int size)

创建一个epoll句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听描述符的最大个数,只是对内核部分初始化分配内部数据结构的一个建议。

当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

#### int poll_ctl(int epfd, int op, struct epoll_event *event);

函数是对指定描述符fd执行op操作。

- epfd: 是epoll_create()的返回值

- op:表示operate操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加,删除和修改对应的fd监听事件。

- fd:是需要监听的文件描述符

- Poll_event:是告诉内核需要监听什么事,struct epoll_event结构如下

```cpp
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
//EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
//EPOLLOUT:表示对应的文件描述符可以写;
//EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
//EPOLLERR:表示对应的文件描述符发生错误;
//EPOLLHUP:表示对应的文件描述符被挂断;
//EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
//EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)

等待epfd上的io事件,最多返回maxevents个事件。

参数events用来从内核得到事件的集合,maxevents告诉内核这个events有多,这个maxevents的值不能大于创建epoll_create()的大小,参数timeout事超时时间(0会理解返回,-1将不确定),如返回0表示已经超时。

工作事件

epoll对文件描述符的操作有两种模式:LT(Leve Trigger)和ET(Edge Trigger)。LT是默认模式。

  • LT模式:当epoll_wait监测到描述符事件发生并将此事件通知到应用程序。应用程序可以不立即处理该事件。下次调用epoll_wait()时,会再次响应应用程序并通知此事件。
  • ET模式:当epoll_wait监测到描述符事件发生并将此事件通知到应用程序。应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait()时,不会再次响应应用程序并通知此事件。

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

epoll 总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll的优点主要是一下几个方面:

  1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。
  2. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。
  3. 如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

三者区别

select的缺点:

  1. 单个进程能够监视的文件描述符的数量存在最大限制,通常是1024,当然可以更改数量,但由于select采用轮询的方式扫描文件描述符,文件描述符数量越多,性能越差;(在linux内核头文件中,有这样的定义:#define __FD_SETSIZE 1024)
  2. 内核 / 用户空间内存拷贝问题,select需要复制大量的句柄数据结构,产生巨大的开销;
  3. select返回的是含有整个句柄的数组,应用程序需要遍历整个数组才能发现哪些句柄发生了事件;
  4. select的触发方式是水平触发,应用程序如果没有完成对一个已经就绪的文件描述符进行IO操作,那么之后每次select调用还是会将这些文件描述符通知进程。

poll:

优势:

1.无上限1024。
2.由于它不修改pollfd里的数据,所以它可以不用每次都填写了。
3.方便的知道远程的状态比如宕机

缺点:

1、还要轮巡
2、不能动态修改set。
其实大多数client不用考虑这个,除非p2p应用。一些server端用不用考虑这个问题。
大多时候他都比select更好。甚至如下场景比epoll还好:

  • 你要跨平台,因为epoll只支持linux。
  • socket数目少于1000个。
  • 大于1000但是是socket寿命比较短。
  • 没有其他线程干扰的时候。

相比select模型,poll使用链表保存文件描述符,因此没有了监视文件数量的限制,但select三个缺点依然存在。

拿select模型为例,假设我们的服务器需要支持100万的并发连接,则在__FD_SETSIZE 为1024的情况下,则我们至少需要开辟1k个进程才能实现100万的并发连接。除了进程间上下文切换的时间消耗外,从内核/用户空间大量的无脑内存拷贝、数组轮询等,是系统难以承受的。因此,基于select模型的服务器程序,要达到10万级别的并发访问,是一个很难完成的任务。

epoll:

优点:

1.只返回触发的事件。少了拷贝消耗,迭代轮训消耗。
2.可以绑定更多上下文,不仅仅是socket。
3.任何时间处理socket。这些问题都是有内核来处理。了。这个还需要继续学习啊。
4.可以边缘触发。
5.多线程可以在同一个epoll wait里等待。

缺点:

1.读写状态变更之类的就要麻烦些,在poll里只要改一个bit就可以了。在这里面则需要改更多的位数。并且都是system call。
2.创建socket也需要两次系统调用,麻烦。
3.只有linux下可以使用
4.复杂难调试

适合场景

1.多线程,多连接。在单线程还不如poll
2.大量线程监控1000上,
3.相对长寿命的连接。系统调用会很耗时。
4.linux依赖的事情。


Author: Jsthcit
Link: http://jsthcitpizifly.com/2020/09/21/C-back-end/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
  • 支付寶

Comment