文章目录
- 一、五种IO模型的基本理念
- 二、IO重要概念
- 1.同步通信与异步通信的对比
- 2.阻塞VS非阻塞
- 三丶非阻塞IO的代码演示
- 四丶IO多路转接select
- 总结
一、五种IO模型的基本理念
首先IO就是 等 + 数据拷贝,还记得我们之前实现服务器用的read/recv接口的,当时我们就说过,这个接口如果有数据,那么read/recv会拷贝完成之后进行返回,如果没有数据,则会阻塞式等待,等待的目的就是等待资源就绪一旦有资源就进行数据拷贝。
1.阻塞IO
当进程调用recvfrom进行系统调用来读取内核中的数据,如果数据还没有准备好,recv就会直接阻塞等待数据就绪,一旦数据准备好就将数据从内核拷贝到用户空间,拷贝完成会返回成功的指示。
2.非阻塞IO
当进程调用recvfrom进行系统调用来读取内核中的数据,如果数据没有准备好那么recv就会返回错误码,因为是非阻塞的所以需要过一段时间就来询问内核数据是否准备好,在其他时间可以让这个进程干一些其他的事情比如打印日志什么的,只需要隔一段时间去询问数据是否准备好,如果没准备好还是发送错误码,准备好就把数据从内核拷贝到用户空间并且返回成功指示。
3.信号驱动IO
当数据还没有准备好时,我们可以让进程对sigaction做捕捉,一旦准备好了我们就捕捉到这个信号去拷贝数据。在没有准备好期间依旧可以干一些其他的事情。
4.IO多路转接
注意:多路转接的原理是一次可以等待多个文件描述符,所以以前的接口不可以使用了,必须使用新的select系统调用。而select以及poll和epoll都是IO中等的那一步,一旦等成功了那么还是调用recvfrom进行数据拷贝即可。并且多路转接中recvfrom不会再阻塞,只要select等待成功recvfrom会直接进行数据的拷贝。
5.异步IO
异步IO的原理就是让系统去等待数据,有数据了就给我拷贝到我指定的缓冲区当中,我只负责在缓冲区拿数据。这就相当于前面几个IO都是关注如何做饭,而异步IO只关注如何吃饭,对饭是怎么来的并不关心。
二、IO重要概念
1.同步通信 vs 异步通(synchronouscommunication/asynchronous communication)
2.阻塞 vs 非阻塞
三.非阻塞IO的代码演示
首先我们认识一下fcntl接口:
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
void setNonBlock(int fd)
{int n = fcntl(fd,F_GETFL);if (n<0){std::cerr<<"fcntl: "<<strerror(errno)<<std::endl;return;}fcntl(fd, F_SETFL, n | O_NONBLOCK);
}
F_GETFD获取文件描述符的状态标记位,函数返回-1表示设置失败
F_SETFL可以设置文件描述符的状态标记位,比如设置读或者设置写,如下图:
最后的O_NONBLOCK就是设置为非阻塞的选项。 当我们将设置文件描述符为非阻塞的函数写好后,先演示阻塞状态的结果,在演示非阻塞状态的结果:
int main()
{char buffer[1024];while (true){printf(">>> ");fflush(stdout);ssize_t s = read(0,buffer,sizeof(buffer)-1);if (s>0){buffer[s] = 0;std::cout<<"echo# "<<buffer<<std::endl;}else if (s == 0){std::cout<<"read end"<<std::endl;break;}else {}}return 0;
}
我们直接死循环式的读取,首先创建一个缓冲区,然后将0号标准输入文件描述符内的数据读到我们自己的缓冲区,读取成功时在文件结尾放上\0然后打印即可。看到结果我们可知这是阻塞式读取,因为一旦我们不向标准输入文件描述符内打印内容就会阻塞在read函数,下面我们看看非阻塞的结果:
int main()
{char buffer[1024];setNonBlock(0);while (true){printf(">>> ");fflush(stdout);ssize_t s = read(0,buffer,sizeof(buffer)-1);if (s>0){buffer[s] = 0;std::cout<<"echo# "<<buffer<<std::endl;}else if (s == 0){std::cout<<"read end"<<std::endl;break;}else {}sleep(1);}return 0;
}
首先将0号描述符设置为非阻塞,因为测试的时候打印>>>太快了为了演示我们sleep1秒:
可以看到即使我们不向0号文件描述符输入函数依旧会死循环的执行,体现的结果就是如果不输入就会持续打印>>>符号,并且我们输入的过程中也会打印>>>符号,这就是非阻塞!我们不用再阻塞到read接口等待数据输入了。
还记得刚开始我们说非阻塞IO如果数据没有准备好就返回错误码吗,我们知道read接口读取失败返回-1,下面我们验证一下:
从结果上我们可以看到确实返回了错误码-1,下面我们把报错原因打印出来:
可以看到虽然返回了-1,但是并不是错误而是说资源没有就绪。实际上操作系统是给我们准备了一些错误码的:
比如EAGAIN就是资源未就绪,EINTR是数据没读完被中断了,并不算错误:
所以说实际上正确的写法是上面这样,因为这样我们才能知道此时没有出错只是资源没就绪。
以上就是非阻塞IO的代码演示了,下面我们介绍IO多路转接的select接口。
四.IO多路转接select
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
因为select可以一次等待多个文件描述符,而文件描述符的本质就是数组下标,所以第一个参数就是需要检视的最大的文件描述符+1,+1是因为底层会遍历文件描述符。
readfds和writefds和exceptfds分别是读文件描述符集合,写文件描述符集合,异常文件描述符集合。
timeout是一个结构体,是用来设置select的等待时间的。下面我们看看timeval是什么:
什么意思呢。比如说我们传timeout={0,0}表示非阻塞的监视文件描述符,timeout=nullptr表示阻塞式的监视文件描述符,timeout={5,0}表示5s内阻塞式监视文件描述符,超过5秒非阻塞返回,并且后续timeout{5,0}变成{0,0}。
就比如刚开始演示的阻塞式读取代码中,如果用select设置5,0就会是5s内只显示>>>等待用户输入,5s后返回错误码,返回后就和非阻塞一样的持续打印>>>
select返回值如果大于0表示有几个文件描述符就绪了,如果返回值等于0表示超时返回,如果返回值小于0说明select调用出现错误。
实际上我们的fd_set类型就是一个位图,当某个文件描述符读事件就绪,那么位图中的这个文件描述符的位置被置为1,写事件和异常事件同理,如下图:
当我们调用的时候传入表示用户告诉内核哪些文件描述符需要被关心。
当函数执行完,位图中哪个比特位被置为1就代表哪个文件描述符的事件已经就绪了。
下面是操作位图的接口:
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的全部位
认识了以上接口后我们就实现一下select服务器:
首先我们将创建套接字,绑定,监听,获取新链接四步分别封装成函数:
enum
{SOCKET_ERR = 2,USE_ERR,BIND_ERR,LISTEN_ERR
};
const uint16_t gport = 8080;
class Sock
{
private:public:const static int gbacklog = 32;static int createSock(){// 1.创建文件套接字对象int sock = socket(AF_INET, SOCK_STREAM, 0);if (sock == -1){logMessage(FATAL, "create socket error");exit(SOCKET_ERR);}logMessage(NORMAL, "socket success %d",sock);int opt = 1;setsockopt(sock,SOL_SOCKET,SO_REUSEADDR | SO_REUSEPORT,&opt,sizeof(opt));return sock;}static void Bind(int sock,uint16_t port){struct sockaddr_in local;bzero(&local, sizeof(local));local.sin_family = AF_INET;local.sin_port = htons(port);local.sin_addr.s_addr = INADDR_ANY; // INADDR_ANY绑定任意地址IPif (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0){logMessage(FATAL, "bind socket error");exit(BIND_ERR);}logMessage(NORMAL, "bind socket success");}static void Listen(int sock){if (listen(sock, gbacklog) < 0){logMessage(FATAL, "listen socket error");exit(LISTEN_ERR);}logMessage(NORMAL, "listen socket success");}static int Accept(int listensock,std::string *clientip,uint16_t& clientport){struct sockaddr_in peer;socklen_t len = sizeof(peer);// sock是和client通信的fdint sock = accept(listensock, (struct sockaddr *)&peer, &len);// accept失败也无所谓,继续让accept去获取新链接if (sock < 0){logMessage(ERROR, "accept error,next");}else {logMessage(NORMAL, "accept a new link success");*clientip = inet_ntoa(peer.sin_addr);clientport = ntohs(peer.sin_port);}return sock;}
};
上面所有关于服务器的函数接口我们在实现TCP服务器的时候都讲过,不懂得可以去看看:
namespace select_ns
{static const int defaultport = 8080;class SelectServer{private:int _port;int _listensock;public:SelectServer(int port = defaultport):_port(port),_listensock(-1){}void initServer(){_listensock = Sock::createSock();Sock::Bind(_listensock,_port);Sock::Listen(_listensock);}void start(){for (;;){fd_set rfds;FD_ZERO(&rfds);// 把lsock添加到读文件描述符集中FD_SET(_listensock, &rfds);struct timeval timeout = {1, 0};int n = select(_listensock+1,&rfds,nullptr,nullptr,&timeout);switch (n){case 0:logMessage(NORMAL,"time out.....");break;case -1:logMessage(WARNING,"select error,code: %d,err string: %s",errno,strerror(errno));break;default://说明有事件就绪了logMessage(NORMAL,"get a new link");break;}sleep(1);/* std::string clientip;uint16_t clientport = 0;int sock = Sock::Accept(_listensock,&clientip,clientport);if (sock<0){continue;}//开始进行服务器的处理逻辑 */}}~SelectServer(){if (_listensock != -1){close(_listensock);}}};
}
上面是我们利用封装好的接口实现一个select服务器的框架,在服务器启动的函数中,我们需要创建文件描述符位图读对象,然后用FD_ZERO初始化为0,注意我们作为演示只演示如何读取,实际上写和异常都是和读一样的。设置1秒内阻塞式读取,我们通过select的返回值分为3种情况,1.select超时2.select错误3.检测到有事件就绪,一旦有事件就绪我们就打印一下。下面我们运行起来:
没连接的时候肯定是打印time_out,当有连接时就打印get new:
那么为什么会打印这么多get a new 呢?这是因为我们没有处理这个select获取到的文件描述符,导致位图中这个文件描述符的值一直为1所以一直打印,下面我们写一个处理函数专门处理已经就绪的文件描述符:
void HanderEvent(fd_set &rfds){if (FD_ISSET(_listensock, &rfds)){//listensock必然就绪std::string clientip;uint16_t clientport = 0;int sock = Sock::Accept(_listensock, &clientip, clientport);if (sock < 0){return;}logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);}}
当listen文件描述符读事件就绪,我们就获取新连接并且打印客户端的ip和端口号:
下面我们运行起来:
我们可以看到一旦获取新连接成功这次就不像之前那样重复打印获取到新连接,而是继续等待新连接,这是因为读事件就绪我们处理了这个事件。
处理了这一点后,我们思考一下如何让select处理其他的文件描述符呢,比如我们现在要用accept返回的文件描述符通信,当客户端发送数据我们服务器显示这个数据即可,实际上一般要使用select,是需要程序员自己维护一个保存所有合法fd的数组,下面我们就实现一下:
首先我们创建一个数组和一个默认值,这个默认值用来初始化数组内的所有元素:
fd_num代表这个数组所能存放的最大文件描述符个数,这个个数就是fd_set*8这么大。
void initServer(){_listensock = Sock::createSock();if (_listensock == -1){logMessage(NORMAL,"createSock error");return;}Sock::Bind(_listensock,_port);Sock::Listen(_listensock);fdarray = new int[fd_num];for (int i = 0;i<fd_num;i++){fdarray[i] = defaultfd;}fdarray[0] = _listensock;}
我们在初始化的时候需要开空间并且初始化所有值为-1(为什么是负数呢?因为文件描述符从0开始,如果是正数有可能影响某个文件描述符),既然开了空间那么不用了肯定是要析构的,所以还有析构函数,当然我们的监听套接字一定要在初始化的时候放在数组中管理起来:
~SelectServer(){if (_listensock != -1){close(_listensock);}if (fdarray){delete[] fdarray;fdarray = nullptr;}}
在start函数中当某个事件就绪了我们就执行hander函数,因为我们现在是用一个数组管理所有的文件描述符,所以hander方法变成了下面这样:
void HanderEvent(fd_set &rfds){if (FD_ISSET(_listensock, &rfds)){//listensock必然就绪std::string clientip;uint16_t clientport = 0;int sock = Sock::Accept(_listensock, &clientip, clientport);if (sock < 0){return;}logMessage(NORMAL,"accept success [%s:%d]",clientip.c_str(),clientport);// 开始进行服务器的处理逻辑// 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中int i = 0;for (i = 0; i < fd_num; i++){if (fdarray[i] != defaultfd){continue;}else{break;}}if (i == fd_num){logMessage(WARNING, "server is full ,please wait");close(sock);}else{fdarray[i] = sock;}print();}}
第一步首先判断监听套接字的读事件是否就绪,只有就绪了我们才做下面的操作。再得到新连接返回的用于通信套接字时,我们要将这个套接字放到select中的位图管理起来,所以首先遍历数组找到合法的文件描述符(如果使用的是默认值那么说明是非法的),找到合法描述符后我们首先判断刚刚遍历的过程中是否到数组结尾,如果到数组结尾说明数组中所有文件描述符都是合法的,这个时候需要记录日志数组已满,需要等待。如果没有到数组结尾则把刚刚accept返回的新文件描述符放到数组指定位置即可。后面我们加了一个打印函数为了方便看到结果:
void print(){std::cout << "fd list: ";for (int i = 0; i < fd_num; i++){if (fdarray[i] != defaultfd){std::cout << fdarray[i] << " ";}}std::cout << std::endl;}
这个函数只会打印合法的文件描述符,当然还有一处地方没有修改,还记得select的第一个参数吗,这个参数是最大文件描述符+1,所以修改如下:
首先假设最大文件描述符是监听套接字,然后去遍历数组,找到合法文件描述符把这个合法的文件描述符添加到读位图中,然后判断是否大于maxfd.下面我们看看效果吧:
可以看到是没有问题的,每次新连接到来都会给我们把新连接的文件描述符添加到数组中,最后数组会将这些合法的文件描述符放到select中监视。
下面我们继续修改代码让我们的select服务器支持正常的IO通信:
因为我们需要处理所有文件描述符,所以我们在hander函数中将accept部分封装起来,然后根据不同的文件描述符实现对应的功能:
void HanderEvent(fd_set &rfds){for (int i = 0;i<fd_num;i++){//过滤掉非法的文件描述符if (fdarray[i] == defaultfd) continue;//如果是listensock事件就绪,就去监听新连接获取文件描述符,如果不是listensock事件,那么就是普通的IO事件就绪了 if (FD_ISSET(fdarray[i], &rfds) && fdarray[i] == _listensock){Accepter(_listensock);}else if (FD_ISSET(fdarray[i], &rfds)){Recver(fdarray[i],i);}else {}}}
当是listensock文件描述符就绪时,我们就调用accept函数去处理监听新连接,如果是普通文件描述符就绪那么就执行读数据函数:
void Accepter(int listensock){// listensock必然就绪std::string clientip;uint16_t clientport = 0;int sock = Sock::Accept(listensock, &clientip, clientport);if (sock < 0){return;}logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);// 开始进行服务器的处理逻辑// 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中int i = 0;for (i = 0; i < fd_num; i++){if (fdarray[i] != defaultfd){continue;}else{break;}}if (i == fd_num){logMessage(WARNING, "server is full ,please wait");close(sock);}else{fdarray[i] = sock;}print();}
accept就是刚刚hander函数内的代码,我们直接讲解如何处理数据:
void Recver(int sock,int pos){//注意:这样的读取有问题,由于没有定协议所以我们不能确定是否能读取一个完整的报文,并且还有序列化反序列化操作...//由于我们只做演示所以不再定协议,在TCP服务器定制的协议大家可以看看char buffer[1024];ssize_t s = recv(sock,buffer,sizeof(buffer)-1,0);if (s>0){buffer[s] = 0;logMessage(NORMAL,"client# %s",buffer);}else if (s == 0){//对方关闭文件描述符,我们也要关闭并且下次不让select关心这个文件描述符了close(sock);fdarray[pos] = defaultfd;logMessage(NORMAL,"client quit");}else {//读取失败,关闭文件描述符close(sock);fdarray[pos] = defaultfd;logMessage(ERROR,"client quit: %s",strerror(errno));}//2.处理 requeststd::string response = func(buffer);//3.返回responsewrite(sock,response.c_str(),response.size());}
首先我们对数据的处理是有问题的,因为正常情况下需要定制协议保证读到的是一个完整的报文,并且还要序列化和反序列化,今天为了演示我们就不再做这些工作。读取到数据后我们在服务端进行一个回显打印,如果读取失败或者客户端关闭文件描述符,这个时候我们服务器也应该关闭对应的文件描述符,并且我们要将数组中的这个文件描述符设置为非法状态,这样的话下次select就不会再监视这个文件描述符了。拿到客户端的消息后我们直接调用一个func函数去处理,func是我们新加的一个用于演示的函数,如下图:
可以看到我们就只是简单的将客户端的消息进行返回,而实际上这个函数的作用是处理客户端请求并且有一个响应经过序列化和反序列化后发送给客户端。
拿到响应后我们直接write写回到用于通信的文件描述符中。这样我们就将代码修改完毕,下面运行起来看看:
可以看到程序运行起来也是没有问题的。
总结
下面我们总结一下select服务器的特点:
1.select能同时等待的文件描述符是有上限的,改内核只能提高一点上限,并不能完全解决。
2.select服务器必须借助第三方数组来维护合法的文件描述符。
3.select的大部分参数是输入输出型的,调用select前,要重新设置所有的文件描述符,调用之后,我们还要检查更新所有的文件描述符,这带来的就是遍历的成本。
4.select的第一个参数为什么是最大文件描述符+1呢?这是因为在内核层面也需要遍历文件描述符
5.select采用位图,所以会频繁的从内核态切换为用户态,再从用户态切换为内核态来回的进行数据拷贝,是有拷贝成本的问题的。
那么如何解决上面的问题呢?后面的poll和epoll服务器会解决这个问题。