目录
多路转接之select
select服务器实现
获取连接
handlerEvent
select服务器代码链接
select的优缺点
多路转接之poll
poll的优缺点
多路转接之select
select的作用
I/O的本质 = 等 + 拷贝
多路转接就是通过同时等待多个文件描述符的方式实现效率的提升,这些在五种I/O模型中都详细说过!
select是实现多路复用I/O的一种方式
一个多路复用I/O可以分为两步:
- 同时等待多个文件描述符
- I/O条件就绪,直接进行拷贝
select完成的工作就是I/O等待这一步,即对多个文件描述符进行等待,若有一个或多个文件描述符读写条件就绪,select就会返回!
select返回后,即进行多路复用的第二步拷贝。拷贝是由read/write...这些拷贝函数实现的!
select调用
int select(int nfds, fd_set *readfds,fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
- nfds:表示你需要监管的所有的文件描述符中,最大的文件描述符+1
- readfds:指向fd_set的指针,包含需要监视的可读文件描述符
- writefds:指向fd_set的指针,包含需要监视的可写文件描述符(可选)。
- exceptfds:指向fd_set的指针,包含需要监视的异常文件描述符(可选)。
- timeout:指定select进行等待时的等待规则
- 返回值:若大于0,则表示实际I/O条件就绪的文件描述符的个数。若等于0,则表示超时。若小于0,则表示select发生错误,错误码被设置
- 头文件:sys/select.h
以下是对select参数的详细介绍
timeout参数
struct timeval类型的结构:
struct timeval { long tv_sec; // 秒数 long tv_usec; // 微秒数
};
- 若tv_sec设置为0,且tv_usec设置为0,则表示的是select进行非阻塞等待。即便没有任何一个文件描述符就绪,select依旧会返回0
- 若timeout参数设置为NULL,则表示select进行阻塞等待,在没有任何外部干扰(如信号中断)的情况下,若没有任何一个文件描述符就绪,则select永不返回,直到有一个或多个文件描述符就绪为止
- 若timeout中的tv_sec或tv_usec设置为>0的数,此时的timeout是一个输入和输出型参数。例如timeout设置为5秒0微秒之后传入select,过了两秒以后有一个文件描述符就绪了,那么select返回,timeout被修改为3秒0微秒
fd_set类型
读写条件就绪:
- 若一个文件描述符可以进行读了,即该文件的内核接收缓冲区有数据了,我们称该文件描述符的读条件就绪
- 若一个文件描述符可以进行写了,即该文件的内核发送缓冲区未满,我们称该文件描述符的写条件就绪
fd_set内部是一个对应文件描述符的位图结构
select参数中的fd_set类型参数含义:
- readfds:该文件描述符集中比特位的值为1的文件描述符,select只关心该文件描述符的读条件是否就绪
- writefds:该文件描述符集中比特位的值为1的文件描述符,select只关心该文件描述符的写条件是否就绪
- exceptfds:该文件描述符集中比特位的值为1的文件描述符,select只关心该文件描述符的异常条件是否就绪
- 若需要select同时关心一个文件描述符的读写条件,即把readfds和writefds对应位置的值同时设置为1
- 若无需select关心异常时间,我们把exceptfds设置为空即可!
select参数中的三个文件描述符集都是即是输入型参数,又是输出型参数
- 以readfds为例
- 当readfds作为输入型参数的时候,表示的含义是:用户告诉内核需要关心readfds中所有的fd的读事件
- 当readfds作为输出型参数的时候,表示的含义是:内核告诉用户需要关心的文件描述符中,有哪些已经就绪了
fd_set输入输出时位图的含义:
- 当作为输入型参数的时候,比特位的位置表示的是第几号fd,比特位的值表示是否需要内核关心的文件描述符的xx事件。若比特位的值为0表示无需内核关心该文件描述符的xx事件,若比特位的值为1表示需要内核关心该文件描述符的xx事件
- 当作为输出型参数的时候,比特位的位置表示的是第几号fd,比特位的值表示需要内核关心的文件描述符中,哪些已经就绪了。若比特位的值为0表示未关心或未就绪,若比特位的值为1表示已就绪
文件描述符集的操作
对fd_set的操作只允许使用系统调用,不允许自己去改,哪怕你已经知道了它是个位图
文件描述符集的头文件是:sys/select.h
清空(所有比特位清零)文件描述符集:FD_ZERO
void FD_ZERO(fd_set *set);
- 功能:把set文件描述符集中的所有比特位清零
清零文件描述符集中指定fd的值:FD_CLR
void FD_CLR(int fd, fd_set *set);
- 功能:把set集中fd对应的比特位清零
判断文件描述符集中指定fd的值:FD_ISSET
int FD_ISSET(int fd, fd_set *set);
- 功能:判断set集中fd对应的比特位是否为1
- 返回值:就是set集中fd对应比特位的值。(若在即为1,若不在即为0)
新增(置为1)文件描述符集中指定fd:FD_SET
void FD_SET(int fd, fd_set *set);
- 功能:把set集中fd对应的比特位置为1
select服务器实现
select服务器和一个简单的TCP服务器的区别主要体现在I/O上,所以我们基于我们之前写过的TCP服务器进行修改即可!TCP服务器
- 相同点:都是要创建套接字并基于套接字进行通信,甚至构建套接字的过程都一摸一样
- 不同点:select的I/O方案采用的是多路转接,而之前的TCP服务器是采用阻塞I/O的方式
基于上述原因,所以对于select服务器来说,只需要在TCP服务器的基础上,修改Loop函数即可
获取连接
accept的本质其实也是I/O,I/O = 等 + 拷贝,accept也是等+拷贝。
所以新连接到来时就等价于有了读事件。
读事件可以由select进行监管,即可以把listen套接字添加到readfds中
把listen套接字交由select进行监管要完成的3个步骤:
- 定义fd_set
- 填充fd_set
- 系统调用select
select获取到哪些fd条件就绪,进行返回时,我们要根据select的返回值来分情况处理
- 若select的返回值大于0,表示已经有多少个文件描述符的条件就绪。上层已经可以根据readfds进行处理了
- 若select的返回值等于0,则表示超时。若select是阻塞监管,那么select不会返回0。
- 若select的返回值小于0,则表示select发生错误。
void Loop() // 修改I/O为select{while(true)//需要不断获取连接,打上死循环即可{//定义fd_setfd_set rfds;//填充fd_setFD_ZERO(&rfds);FD_SET(_listensock,&rfds);//调用selectint n = select(_listensock + 1 , &rfds,nullptr,nullptr,nullptr);//select只关心读事件,并且阻塞等待if(n > 0){//表示已经有文件描述符条件就绪了,此时rfds中比特位为1的位置就是已经就绪的文件描述符handlerEvent(rfds);}else if(n == 0){//阻塞等待不会走到这std::cout << "timeout..." << std::endl;}else {std::cerr << "Select Error!" << std::endl;exit(2);}}
handlerEvent
上述,我们已经将listen套接字交给select进行监管了。那么若listen套接字读条件就绪,即有客户端向发送连接时,如何处理呢?
这就由handlerEvent进行处理
由于select监管的文件描述符可能是listen套接字,也可能是普通套接字,所以我们需要对这两种套接字进行分类讨论
对于listen套接字来说:
- listen套接字的读事件就绪,也就意味着有新的客户端连接到来,我们首先要accept新连接
- accept过后会获取一个新的普通套接字,我们需要把普通套接字交给select进行监管
对于普通套接字来说:
- 普通套接字的读事件就绪,也就意味着客户端发来了一条新的I/O数据
- 此时服务器提供服务即可!
细节问题:辅助数组
当用户调用select传入rfds时,此时的rfds是输入型参数
当select返回时,此时的rfds是输出型参数
rfds作为输入型参数和输出型参数时,含义是完全不同的!
举个例子:若我们调用select时设置了1号3号5号文件描述符的读事件。假设select在监管时只有3号文件描述符就绪了,那么select会直接修改rfds,此时没有就绪的文件描述符就被修改了
- rfds被select修改了,但我们之后还需要关心1号和5号文件描述符的读事件!
为了解决这个问题,所以调用select时一般会搭配上一个辅助数据结构来实现!(以数组为例)
辅助数组中一般会存储调用select之前的rfds,方便内核修改了rfds后,用户进行重新设置
在服务器构造的时候,我们可以直接构造好辅助数组,此外,辅助数组我们可以直接设置为成员变量
class TCPServer
{
public:const static int N = sizeof(fd_set) * 8; //辅助数组的大小const static int defaultfd = -1; //辅助数组元素的初始值TCPServer(uint16_t port): _port(port), _isrunning(false){//初始化辅助数组for(int i = 0 ; i < N ; ++i){fd_array[i] = defaultfd;}//listen套接字是必须要的,直接添加进辅助数组}
protected:uint16_t _port;int _listensock;bool _isrunning;int fd_array[N]; //辅助数组,元素的含义是需要内核关心的套接字编号
};
注意:该服务器的listensock是在初始化套接字时创建的,在socket创建listensock的后面再把listen套接字传入到辅助数组中
handlerEvent的实现
通过之前介绍select调用,我们会发现若我们不通知内核需要关心某个fd,那么select返回时这个fd一定不会在rfds中。
- 若我们告诉内核需要关心第n号文件描述符,那么select返回时该文件描述符不一定会就绪
- 若我们不告诉内核需要关心第n号文件描述符,那么select返回时该文件描述符一定不会就绪
除此之外,select返回时,可能不止一个文件描述符就绪了
所以第一步我们需要遍历整个辅助数组
- 若遍历过程中,fd_array[i]的值是默认值,说明该位置还没被使用,直接跳过即可
- 若遍历过程中,fd_array[i]的值不是默认值,意味着该位置存储的一定是需要内核关心的文件描述符的编号,此时需要看这个文件描述符是否在输出型参数rfds中,若在,则该文件描述符就绪。否则,该文件描述符没就绪
- 判断一个文件描述符是否在fd_set中,我们使用的调用是FD_SET
第二步就是分类讨论,区分该套接字是listen套接字还是普通套接字
void handlerEvent(fd_set &rfds){for(int i = 0 ; i < N ; ++i){//若该fd不在辅助数组中,说明该文件描述符一定不会就绪if(fd_array[i] == defaultfd) continue;//走到这,说明该文件描述符被关心,经过rfds判断是否就绪if(!FD_ISSET(fd_array[i],&rfds)) continue; //区分处理if(fd_array[i] == _listensock){//该套接字是listen套接字,完成的工作是处理连接AcceptClient();}else {//该套接字是普通套接字,完成的工作是提供I/O服务Service(fd_array[i]);}}}
处理连接:AcceptClient
到这一步,说明listen套接字的读事件已经就绪
需要进行两步操作:
- 获取新连接,并获取新的套接字
- 把套接字交给select进行监管
如何交给select进行监管?把这个新的套接字设置进辅助数组即可
void AcceptClient(){//1.获取新连接,获取新的套接字int sockfd;Accept(sockfd);//2.把新连接设置进辅助数组int pos = 1;for(; pos < N ; ++pos){//2.1选择一个没被用过的位置 if(fd_array[pos] == defaultfd) break;}if(pos < N){//2.2找到了一个没有被用的空位置fd_array[pos] = sockfd;}else{//pos == N//2.3说明fd_array已经被使用满了close(sockfd);std::cerr << "Server is full!" << std::endl;}}
提供服务:Service
Service注意不要死循环即可!
void Service(int sockfd){//获取客户端的输入char buffer[1024];int n = recv(sockfd, buffer, sizeof(buffer), MSG_WAITALL);if (n > 0){// 接收成功std::cout << "Client say# " << buffer << std::endl;// 服务器把buffer中的数据重新发送给客户端,完成服务//发送客户端的输入send(sockfd, buffer, sizeof(buffer), 0);}else if (n == 0){// 客户端套接字关闭std::cout << "client socket close!" << std::endl;close(sockfd);}else{// recv errorstd::cerr << "recv error!" << std::endl;close(sockfd);exit(5);}}
修改Loop函数
Loop函数的修改主要有几点
- 先把listen套接字放入辅助数组
- 每一次循环都得遍历一遍辅助数组,构建fd_set输入型参数,用于传递select参数
- 遍历过程中找出最大的文件描述符,用于传递select参数
void Loop() // 修改I/O为select{fd_array[0] = _listensock;while(true)//需要不断获取连接,打上死循环即可{//定义fd_setfd_set rfds;//填充fd_setFD_ZERO(&rfds);//max_fd主要用于select参数填写int max_fd = defaultfd;for(size_t i = 0 ; i < N ; ++i){if(fd_array[i] == defaultfd) continue;FD_SET(fd_array[i],&rfds);if(fd_array[i] > max_fd) max_fd = fd_array[i]; }//调用selectint n = select(max_fd + 1 , &rfds,nullptr,nullptr,nullptr);//select只关心读事件,并且阻塞等待if(n > 0){//表示已经有文件描述符条件就绪了,此时rfds中比特位为1的位置就是已经就绪的文件描述符handlerEvent(rfds);}else if(n == 0){//阻塞等待不会走到这std::cout << "timeout..." << std::endl;continue;}else {std::cerr << "Select Error!" << std::endl;continue;}}}
select服务器代码链接
select服务器实现
select的优缺点
优点:
- 与其他I/O模型相比,select可以同时等待多个fd。对I/O性能有着显著提升
- select是最早出现的多路转接的方案,在很多较旧的服务器或系统上,select仍然会被使用
缺点:
- 同时等待的fd的个数,有上限。这一缺点是由于fd_set类型被设置为固定大小的位图。通常为1024个比特位
- select输入输出参数混合。每次都要进行重新设定。通过服务器的编写我们会发现,使用select往往会造成大范围的遍历,而遍历对性能的损耗其实也是蛮大的
- 内核和用户之间的数据拷贝,这个缺点所有的多路转接方案都会存在
- select底层监视多个fd的时候,OS会在内部进行遍历检测所有的fd,至少有一个就绪就返回。如果没有,就按照策略!
由于select的缺点比较多,所有后来提出的多路转接方案都是基于这些缺点进行的改良。
其中,我们接下来介绍的poll就对select的某些缺点进行了改良
多路转接之poll
poll对select进行一定程度的改良
- poll解决了select同时等待fd有上限的问题
- poll解决了select输入输出混合,导致代码中出现多次遍历的问题
poll接口
#include <poll.h>int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- timeout:poll的timeout是输入型参数,poll等待时按照timeout的策略进行等待,可以类比select的timeout参数,poll的timeout的单位是ms(毫秒),若poll在等待了timeout毫秒后还没有任何fd就绪,那么poll会超时返回。特殊的:若timeout被设置为0则表示非阻塞等待。若timeout被设置为-1则表示阻塞等待
- fds和nfds:这两个合起来我们可以看成是一个数组,其中nfds表示的是数组的元素个数,而fds表示数组的首元素地址。数组的元素类型是struct pollfd。
poll如何解决select输入输出参数混合的问题的?
poll的fds实际上也是一个输入且输出型参数!
struct pollfd结构:
struct pollfd {int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */};
- fd:表示需要关心的文件描述符编号
- events:请求事件,这是用于用户告诉内核需要关心fd的哪些事件,若这个参数用户设置为POLLIN,则表示需要关心fd的读事件。若这个参数用户设置为POLLOUT,则表示需要关心fd的写事件。若用户既想关心读,又想关心写,则 POLLOUT | POLLIN即可,也就是说参数可以进行组合
- revents:返回事件,这是内核告诉用户,指定fd的哪些事件好了!若用户想看看该fd的读事件是否就绪,则revents & POLLIN即可!
poll的事件除了读写以外还有哪些:(man手册有详细介绍)
我们可以看出,poll的fds参数虽然还是输入输出型的参数,但用户通知内核和内核通知用户所用的变量不再是同一个,这就是poll解决输入输出混合问题的方案
poll如何解决select同时等待的fd有上限的问题?
从参数上,我们已经可以猜到它的解决方案了
poll的fds参数和nfds参数是配合使用的,fds相当于数据首元素地址,nfds是由用户确定的,换句话来说,只要用户想且内存允许,你的fd想要多少就有多少!不会受到poll接口的影响
poll服务器实现(select服务器改写)
经过select服务器的铺垫,poll服务器改写起来是相对容易的:poll服务器的改写代码
poll的优缺点
优点:
- poll在select的基础上,弥补了select的一些缺点。并且poll也是多路转接方案的一种,与其他四种I/O模型相比,poll的优点是它可以同时接收多个文件描述符
- poll在select的基础上,弥补了select同时等待的fd有上限的问题,poll则是完全由用户自主决定fd的大小。
- poll在select的基础上,弥补了select输入输出参数混合的问题,每次调用poll时,都不再需要重新遍历设定参数!
缺点: