目录
- 理解5种IO模型的基本概念,重点是IO多路转接。
- 掌握select编程模型,能够实现select版本的TCP服务器。
- 掌握poll编程模型,能够实现poll版本的TCP服务器。
- 掌握epoll编程模型,能够实现epoll版本的TCP服务器。
- 理解epoll的LT模式和ET模式。
- 理解select和epoll的优缺点对比。
五种IO模型
当我们想要读写数据的时候,缓冲区可能没有数据,所以我们可能需要等待,等数据准备就绪,再执行读写操作。
IO = 等待 + 拷贝。
读IO = 等待读事件就绪 + 将数据从内核拷贝到用户的接收缓冲区。
写IO = 等待写事件就绪 + 将数据从用户空间拷贝到内核的发送缓冲区。
- 高级IO,就是高效IO。实际上,只有数据拷贝的过程才对IO做贡献,等待的过程对IO是没有贡献的。
- 高效IO的本质是,尽可能的减少等的比重!!
小栗子:
钓鱼分为两步:等待 + 钓。
- 张三:张三去钓鱼,他死死的看着鱼漂,直到鱼咬钩。
- 李四:李四左手拿着鱼竿,右手拿着《effectiveC++》,他看一会书,就看一眼鱼漂是否有鱼上钩,然后再看书。李四循环这个过程。
- 王五:王五将一个铃铛挂在了鱼竿的顶部,然后就一直看手机。只要铃铛响了,王五才收杆。
- 赵六:赵六是个小土豪,拉着一卡车鱼竿,大概100个左右。赵六在100个鱼竿左右来回徘徊,只要有一个鱼竿动了,就去钓起。赵六就这样轮询检测鱼竿。
- 田七:田七是真正的有钱人,他想吃鱼了,直接让他的司机小李去钓鱼。田七就直接走了,等小刘钓到很多鱼,就给田七打电话。
前4种方式,自己等自己钓鱼的方式,叫做同步IO。
田七的钓鱼方式,田七只是IO的发起者,钓鱼的行为是小刘做了,这叫做异步IO。
select,poll,epoll只负责IO当中的等待。
阻塞IO
- 张三使用的钓鱼方式就是阻塞IO模型。
- 内核如果没有将数据准备好,那么系统调用就会一直阻塞。所有的套接字,默认都是阻塞方式。
非阻塞IO
- 李四采用的就是非阻塞IO模型。
- 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码。
- 非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询。 这对CPU来说是较大的浪费, 一般只有特定场景下才使用。
信号驱动IO
- 王五使用的就是信号驱动IO。
- 遇到IO的时候,进程继续执行。当内核数据准备好时,系统通知进程(发送信号SIGIO,29号信号)去拷贝数据。
- 系统通知的方式一般都是信号。
- 操作系统收到数据时,会向目标进程发送SIGIO,SIGIO的执行动作默认忽略。所以我们需要建立signal的处理函数。
多路转接
- 赵六使用的就是多路转接,也叫做多路复用。
- 我称之为阻塞IO的高级版本(doge)。
- 多路转接最核心的地方在于IO多路转接能够同时等待多个文件描述符的就绪状态。
- 多路转接只负责一件事,就是等待!!
异步IO
- 田七使用的就是异步IO。
- 当内核将数据拷贝完成时,通知进程。(信号驱动是等待完成,就通知进程。)而进程一直在运行。
小结:
- 任何IO过程中,都包含两个步骤. 第一是等待, 第二是拷贝。而且在实际的应用场景中,等待消耗的时间往往都远远高于拷贝的时间。 让IO更高效, 最核心的办法就是让等待的时间尽量少。
高级IO重要概念
同步通信 vs 异步通信
- 简单一句话,在IO种,自己参与的IO叫做同步IO。
- 自己不需要参与的就是异步IO。
- 这里的同步和多线程的同步完全不一样!! 所以查找同步时一定要说清楚背景。
阻塞 vs 非阻塞
阻塞等待就是在调用返回结果前,线程会被挂起;
非阻塞就是调用后立刻返回,不会影响线程执行。
非阻塞IO
文件描述符的默认等待方式都是阻塞等待。但是我们可以手动设置成非阻塞等待。我们使用fcntl函数。
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, … /* arg */ );
- 第一个参数fd代表你要设置的文件描述符。
- cmd代表你要对这个fd执行的操作,cmd不同,后面的可变参数就不同。
- fcntl函数有很多功能,这里介绍修改fd为非阻塞的方法。
/********* 修改标准输入为非阻塞 ********/
1 #include <stdio.h>
2 #include <fcntl.h>
3 #include <unistd.h>
4 #include <errno.h>
5 #include <stdlib.h>
6 void SetNoBlock(int fd){
7 int fl = fcntl(fd, F_GETFL);
8 if(fl < 0){
9 printf("fcntl error\n");
10 exit(1);
11 }
12
13 fcntl(fd, F_SETFL, fl | O_NONBLOCK);
14
15 }
16
17 int main(){
18 SetNoBlock(0);
19 char ch = '\0';
20 for(;;){
21 sleep(1);
22 ssize_t s = read(0, &ch, sizeof(ch)); //有一个究极BUG!!把ssize_t改成size_t后,会出现错误!我怀疑是因为强转类型转换!
23
24 if(s > 0){
25 printf("%c\n", ch);
26 }
27 else if(s < 0 && errno == EAGAIN){
28 printf("read conditon not ok\n");
29 }
30
31 else{
32 printf("read error\n");
33 exit(2);
34 }
35
36 printf("................\n");
37 }
38 }
多路转接之select
- select的作用就是一个字,等。
- select可以同时等待多个文件描述符,这样就相当于并行,提高了单进程服务器的效率。
函数介绍:
- nfds是你想关系的文件描述符中最大的+1。
- 第2,3,4个参数对应读事件,写事件和异常事件的集合。
- 最后一个参数是一个timeval型的参数。
fd_set:
- fd_set是一张位图。它既是输入参数又是输出参数。
- 作为输入参数,表示进程告诉操作系统要关系哪些文件描述符。位图的每一位的下标代表文件描述符,而每一位的内容(0或者1)代表该文件描述符你是否关心。
- 作为输出参数,表示操作系统告诉进程哪些文件描述符已经就绪。位图的每一位的下标表示文件描述符,而每一位的内容表示是否就绪。
- 输出的位图中被设置的位一定在你关心的那些比特位中。不可能你不关心3号文件描述符,而返回3号就绪!
- 而因为是位图,所以系统也有一系列接口使用它:
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
timeval:
- timeval是一个结构体,用来描述一段时间。select在这段时间内进行阻塞等待。
- 注意!!!timeout既是输入参数,也是输出参数!
- 输入代表你想要select阻塞等待的时间。而timeout在输出的时候会清零!
timeout的取值:
- 特定的时间值:表示你希望select阻塞等待的时间,超过这个时间后,select超时返回。在这段时间内有文件描述符就绪,select也会返回。
- NULL:表示你希望select阻塞等待。直到有文件描述符就绪。
- 0:表示非阻塞等待。仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
返回值:
- 大于零的整数数:表示就绪的文件描述符个数。
- 等于0:超时,没有文件描述符就绪。
- 小于0:select错误!比如某个文件描述符已经关闭,而你却还在等待。
socket就绪条件
读就绪
- socket内核中, 接收缓冲区中的字节数, 大于等于低水位标记SO_RCVLOWAT(并不是接收缓冲区只要一有数据,就立马读,而是设置了一个低水位标记,为了保证读取的效率). 此时可以无阻塞的读该文件描述符, 并且返回值大于0;
- socket TCP通信中, 对端关闭连接, 此时对该socket读, 则返回0;
- 监听的socket上有新的连接请求,这个请求是以读事件报告给操作系统的;
- socket上有未处理的错误;
写就绪
- socket内核中, 发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小), 大于等于低水位标记SO_SNDLOWAT, 此时可以无阻塞的写, 并且返回值大于0;
- socket的写操作被关闭(close或者shutdown). 对一个写操作被关闭的socket进行写操作, 会触发SIGPIPE信号;
- socket使用非阻塞connect连接成功或失败之后;
- socket上有未读取的错误;
select还要考虑上层协议
- 由于select是只要读取的字符多余水位线就会返回,那么你在应用层(比如http服务器)读取的时候,可能只读取到一部分http包,那么你就需要在你的代码中自己处理。比如,http包不完整时,先暂存起来,将该文件描述符继续放到select中等待,直到读取一个完整的http报文,再交付给select来等待写事件就绪。
select代码
- 我们的想法是让接收连接的读等待,和处理数据(recv)时候的读等待全部交给select来处理。这样做即高效,又可以实现单进程并行处理多个请求。
/************ sock.hpp **************/
#pragma once
2
3 #include <iostream>
4 #include <string>
5 #include <unistd.h>
6 #include <sys/types.h>
7 #include <sys/socket.h>
8 #include <netinet/in.h>
9 #include <cstring>
10 #define LOGBACK 5
11 using namespace std;
12 class Sock{ // 这个类用于封装socket
13 public:
14 static int Socket(){ //创建套接字
15 int sockfd = socket(AF_INET, SOCK_STREAM, 0);
16 if(sockfd < 0){
17 cerr << "socket error "<< endl;
18 exit(1);
19 }
20 return sockfd;
21 }
22 static void Bind(int sockfd, int port){ //绑定
23 struct sockaddr_in local;
24 local.sin_family = AF_INET;
25 local.sin_port = htons(port);
26 local.sin_addr.s_addr = htonl(INADDR_ANY);
27 int ret = bind(sockfd, (struct sockaddr*)&local, sizeof(local));
28 if(ret < 0){
29 cerr << "bind error" << endl;
30 exit(2);
31 }
32 }
33 static void Listen(int sockfd){ //监听
34 int ret = listen(sockfd, LOGBACK);
35 if(ret < 0){
36 cerr << "listen error" << endl;
37 exit(3);
38 }
39 }
40 static int Accept(int sockfd){ //接收
41 struct sockaddr_in peer;
42 socklen_t len = sizeof(peer);
43 int sock = accept(sockfd, (struct sockaddr*)&peer, &len);
44 if(sock < 0){
45 cerr << "accept error" << endl;
46 return -1; // accept wrong, 服务器继续运行
47 }
48 return sock;
49 }
50 static void Setsockopt(int lsock){ //端口复用
51 int opt = 1;
52 setsockopt(lsock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
53 }
54 };
/*************** SelectServer.hpp *******************/
1 #pragma once
2 #include "sock.hpp"
3
4 #define NUM (sizeof(fd_set)*8)
5 #define EMPTY -1
6
7 class SelectServer{
8 private:
9 int port;
10 int lsock;
11 int fd_array[NUM]; //维护fd_set中的关心文件描述符
12 public:
13 SelectServer(int _port = 8080)
14 :port(_port),lsock(-1){
15 for(auto& e : fd_array){
16 e = EMPTY;
17 }
18 }
19
20 void Init(){
21 //端口复用,基于tcp的服务器挂掉之后,用来立刻重启。
22 Sock::Setsockopt(lsock);
23 lsock = Sock::Socket();
24
25 fd_array[0] = lsock; //将监听套接字放入数组中
26
27 Sock::Bind(lsock, port);
28 Sock::Listen(lsock);
29 }
30 void Start(){
31 int fds = -1;
32 int maxfd = -1;
33 fd_set rfds;
34 struct timeval timeout = {5, 0}; //设置等待时间
35 for(;;){
36 FD_ZERO(&rfds); //如果每次不清零,会有bug!!,因为没有清零的文件描述符(比如4)
37 timeout.tv_sec = 5; //已经被你关闭了,但是你还在告诉系统要使用它!!
38 timeout.tv_usec = 0;
39 for(int i = 0; i < NUM; ++i){
40 if(fd_array[i] != EMPTY){ // fd_array[i]里面有值;
41 FD_SET(fd_array[i], &rfds);
42 if(fd_array[i] > maxfd){
43 maxfd = fd_array[i]; // 维护maxfd
44 }
45 }
46 }
47 ArrayPrint();
48 cout << "select begin ..." << endl;
49 fds = select(maxfd + 1, &rfds, nullptr, nullptr, &timeout);
50 if(fds < 0){
51 cerr << "select error" << endl;
52 exit(4);
53 }
54 else if(fds == 0){
55 cout << "timeout..." << endl;
56 continue;
57 }
58 else{
59 HandleEvents(&rfds); //此处的参数类型也可以是const fd_set&
60 }
61 }
62 }
63 ~SelectServer(){
64 if(lsock > 0){
65 close(lsock);
66 }
67 }
68
69 private:
70 void ArrayPrint(){
71 cout << "fd_array : ";
72 for(int i = 0; i < NUM; ++i){
73 if(fd_array[i] != EMPTY){
74 cout << fd_array[i] << " ";
75 }
76 }
77 cout << endl;
78 }
79 void AddFdToArray(int fd){
80 for(int i = 0; i < NUM; ++i){
81 if(fd_array[i] == EMPTY){
82 fd_array[i] = fd;
83 break;
84 }
85 }
86 }
87
88 void DeleteFdFromArray(int fd){
89 for(int i = 0; i < NUM; ++i){
90 if(fd_array[i] == fd){
91 fd_array[i] = EMPTY;
92 break;
93 }
94 }
95 }
96 void HandleEvents(fd_set* p_rfds){
97 for(int i = 0; i < NUM; ++i){
98 if(fd_array[i] == EMPTY){
99 continue;
100 }
101 else{ // 该文件描述符在我们等待的范围内;
102 if(FD_ISSET(fd_array[i], p_rfds)){ // 如果该文件描述符就绪
103 if(fd_array[i] == lsock){ // 套接字接收到了 连接
104 int sock = Sock::Accept(lsock);
105 cout << "get a new link..." << endl;
106 AddFdToArray(sock);
107 }
108 else{ // 套接字接收到了数据
109 char buf[10240] = {0};
110 ssize_t ss = recv(fd_array[i], buf, sizeof(buf) - 1, 0);
111 if(ss > 0){
112 cout << "client # " << buf << endl;
113 }
114 else if(ss == 0){ //客户端退出
115 cout << "client quit...." << endl;
116 close(fd_array[i]);
117 DeleteFdFromArray(fd_array[i]);
118 }
119 else{
120 cout << "recv error" << endl;
121 exit(5);
122 }
123 }
124 }
125 else{
126 // 文件描述符未就绪,继续select;
127 // donothing
128 }
129 }
130 }
131 }
132 };
/*************** SelectServer.cc ******************/
1 #include "SelectServer.hpp"
2
3 void Usage(char* arg){
4 cout << "Usage: \n\r" << "arg port\n" ;
5 }
6 int main(int argc, char *argv[]){
7 if(argc != 2){
8 Usage(argv[0]);
9 exit(50);
10 }
11
12 SelectServer* ss = new SelectServer(atoi(argv[1]));
13 ss->Init();
14 ss->Start();
15
16 delete ss;
17 }
- select最精彩的地方就在于利用一个数组去维护我们关心的文件描述符。将对位的操作转移到对数组的操作。
- 每次select之前,rfds都要重置,否则会影响结果。
select的优缺点
优点:
- 高效。
缺点:
- fd_set是有长度上限的,这就意味着select可监听的文件描述符是有数量限制的。
- 因为要用到大量的f轮询检测,随着文件描述符的增大,效率会下降。
- 每次都需要将fd_set拷贝到内核,再从内核拷贝到用户,这样很浪费效率。
- select支持的文件描述符太少。