IO多路复用之select
函数原型:
#include <sys/select>
int select(int nfds,fd_set *readfds,fd_set *writefds,
fd_set *exceptfds,struct timeval *timeval);
参数解释:
第一个参数nfds表示需要监视的最大文件描述符加一,即后面三个位图结构中保存的最大文件描述的数值加一。为啥要设置这么一个参数呢?
有这么一种情况:假设fd_set保存的最大位数是4096位,若没有nfds这个参数,遍历位图上哪些位为1,就需要从0遍历到4096。如果有了nfds,遍历位图上哪些位为1,只需从0遍历到nfds就可以。这样大大提高了遍历效率,节省时间。
fd_set:文件描述符的集合,它是基于位图实现的结构体,省空间,位图中对应的位表示要监视的文件描述符。
中间三个参数readfds,writefds,exceptfds是指向文件描述符集的指针,即一组文件描述符,这三个文件描述符集分别关心是否读就绪,写就绪和异常就绪,他们在位图中为每个可能的文件描述符各保持了一位,如图:
这三个参数既是输入型参数,也是输出型参数,调用时表示关注哪些文件描述符的读写异常就绪,select返回时哪些文件描述符的就绪了就通过这些参数返回回来了。
Socket就绪条件:
1.读就绪
- socket内核中, 接收缓冲区中的字节数, ⼤大于等于低⽔水位标记SO_RCVLOWAT. 此时可以⽆无阻塞的读该文件描述符, 并且返回值⼤大于0;即数据比较多值得一读就读一次。
- socket TCP通信中,对端关闭连接断开,此时应该对socket读,返回0。
- 监听的socket上有新的连接请求时。
- socket上有未读取的错误;
2.写就绪
- socket内核中,发送缓冲区的可用字节数即空闲位置大小,大于等于低水位标记,此时可以无阻塞的写,返回值大于0。
- socket的写操作被关闭(close或者shutdown). 对⼀一个写操作被关闭的socket进⾏行写操作, 会触发SIGPIPE信号;
- socket使用非阻塞connect连接成功或失败之后;
- socket上有未读取的错误;
3.异常就绪
- socket上收到带外数据. 关于带外数据, 和TCP紧急模式相关
由于访问IO的开销很大,远远超过访问内存,因此设置各种缓冲区来减少访问IO的次数,提高效率
有一组fd_set的接口,方便我们来操作位图:
- 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这个结构体指定愿意等待的时间
该参数的设置有三种情况:
- NULL:永远等待。当有一个文件描述符就绪或是被信号打断才会返回,若捕捉到信号,则select返回-1,errno设置为EINTR。
- tv_sec和tv_usec都为0:表示不等待。检测所有指定的文件描述符并立刻返回,并不等待就绪
- tv_sec或tv_usec不为0:等待指定的时间当有一个文件描述符就绪或是超过指定时间就立刻返回。若超时还没有文件描述符准备好返回值为0。同样,这种等待也会被信号打断返回。
等待过程交给select,一旦select返回,就可以立刻拷贝数据
select一个函数可以等待多个文件描述符,
read既可以等又可以拷贝数据,但是read同一时刻只能等待一个文件描述符,若有多个文件描述符需要read,就要创建多个线程来进行,但多个线
IO多路复用的本质:
本来read即可以完成等,又能完成拷贝,但是read有一个重要缺陷:一次read只能等待一个文件描述符,如果想同时等待多个,就要配合多个线程。
但是线程如果太多,开销也很大,于是就想办法让一个线程也能等待多个文件描述符,于是也就有了IO多路复用。
下面代码使用select完成对标准输入读状态就绪的等待:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/select.h>
int main()
{
while(1)
{
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(0,&readfds);
//等待的过程交给select
int ret=select(1,&readfds,NULL,NULL,NULL);
if(ret<0)
{
perror("select");
return 1;
}
if(!FD_ISSET(0,&readfds))
{
printf("readfds error!\n");
return 1;
}
char buf[1024]={0};
//拷贝的过程交给read
ssize_t read_size=read(0,buf,sizeof(buf)-1);
if(read_size<0)
{
perror("read");
return 1;
}
if(read_size==0)
{
printf("read done!\n");
return 0;
}
buf[read_size]='\0';
printf("buf=%s\n",buf);
}
return 0;
}
实现一个select版本的回显服务器echo_server
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
//基于fd_set进行简单的封装,最重要的目的是为了
//能够随时获取到文件描述符集中最大的文件描述符是多少
typedef struct FdSet
{
fd_set set;
int max_fd; //set之中最大的文件描述符的数值
}FdSet;
//初始化文件描述符的位图集
void FdSetInit(FdSet* fds)
{
fds->max_fd=-1;//0本身是一个合法的文件描述符
FD_ZERO(&fds->set);
}
//向这个位图集中添加要关注的文件描述符
void FdSetAdd(FdSet* fds,int fd)
{
FD_SET(fd,&fds->set);
//时刻保证max_fd为当前位图集中最大的文件描述符
if(fd>fds->max_fd)
{
fds->max_fd=fd;
}
}
//清除位图中的某个文件描述符
void FdSetDel(FdSet* fds,int fd)
{
FD_CLR(fd,&fds->set);
int i=0;
int max_fd=-1;
for(;i<fds->max_fd;i++)
{
if(!FD_ISSET(i,&fds->set))
{
continue;
}
if(i>max_fd)
{
max_fd=i;
}
}
//此时max_fd就是新文件描述符集中的最大值
fds->max_fd=max_fd;
//正向遍历效率比较低,反向遍历相对更加高效
}
int ServerInit(const char* ip,short port)
{
int listen_socket=socket(AF_INET,SOCK_STREAM,0);
if(listen_socket<0)
{
perror("socket");
return -1;
}
sockaddr_in addr;
addr.sin_family=AF_INET;
addr.sin_addr.s_addr=inet_addr(ip);
addr.sin_port=htons(port);
int ret=bind(listen_socket,(sockaddr*)&addr,sizeof(addr));
if(ret<0)
{
perror("bind");
return -1;
}
ret=listen(listen_socket,5);
if(ret<0)
{
perror("listen");
return -1;
}
return listen_socket;
}
int ProcessRequest(int new_sock)
{
char buf[1024]={0};
ssize_t read_size=read(new_sock,buf,sizeof(buf)-1);
if(read_size<0)
{
perror("read");
return -1;
}
if(read_size==0)
{
printf("[client %d] disconnect\n",new_sock);
close(new_sock);
return 0;
}
buf[read_size]='\0';
printf("[client %d] say %s\n",new_sock,buf);
//memset(buf,0x00,sizeof(buf));
write(new_sock,buf,strlen(buf));
return 1;
}
//以下代码为select_server
int main(int argc,char* argv[])
{
if(argc!=3)
{
printf("Usage ./server [ip] [port]");
return 1;
}
int listen_socket=ServerInit(argv[1],atoi(argv[2]));
if(listen_socket<0)
{
printf("serverInit failed!\n");
return 1;
}
printf("ServerInit Ok!\n");
FdSet fds;
FdSetInit(&fds);
FdSetAdd(&fds,listen_socket);
while(1)
{
//对于普通的TCP服务器来说,使用accept完成等待的过程
//此处我们使用select
//,把所有文件描述符的等待都交给select来处理
//另外,accept返回的new_sock,也加入到select中进行等待
//一旦select返回,根据返回的文件描述符的种类,分别处理
//a).listen_sock就绪,就调用accept
//b).new_sock就绪,就调用read/write进行读写
//文件描述符如果出现一些变化(新增或关闭),也需要更新select关注的位图的对应状态
//此时我们要使用select来完成所有文件描述符就绪的等待
//按照下面的代码来实现,其实还有一个致命缺陷
//例如:调用select的时候fds.set包含了100个文件描述符
//说明需要关注100个文件描述符的就绪状态
//当select返回时,假设此时只有一个文件描述符就绪,fds.set也就只包含一位为1了
//
//此时如果循环结束,第二次再调用select,fds.set里面就只有一个文件描述符为1
//因此在使用前需要备份每次要关注的文件描述符集。
FdSet output_fds=fds;//这里的备份就是为了解决上面描述的问题
int ret=select(fds.max_fd+1,&output_fds.set,NULL,NULL,NULL);
if(ret<0)
{
perror("select");
continue;//不能因为一次失败就退出服务器进程
}
if(FD_ISSET(listen_socket,&output_fds.set))
{
//就绪的文件描述符是listen_socket,此时意味着有新的客户端连接上了服务器
//就应该调用accept把连接获取到
sockaddr_in peer;
socklen_t len=sizeof(peer);
int new_sock=accept(listen_socket,(sockaddr*)&peer,&len);
if(new_sock<0)
{
perror("accept");
continue;
}
FdSetAdd(&fds,new_sock);
printf("[client %d] connect!\n",new_sock);
}
else
{
//i用于遍历文件描述符集,i的含义也就是表示一个文件描述符
int i=0;
for(;i<=output_fds.max_fd;++i)
{
if(!FD_ISSET(i,&output_fds.set))
{
//当前的文件描述符不在返回的文件描述符集中(未就绪)
continue;
}
//此时发现某个new_sock读就绪
//从new_sock中读取一次请求,进行计算并进行相应
//不循环读取,只读取一次数据,处理一次请求,
//因为客户端只发送了一次请求,不知道客户端下次什么时候再发请求
//因此在这个时间间隔我们要返回 继续让select来等,
int ret=ProcessRequest(i);
//此时需要判定ret 如果是0,表示对方断开连接,
//此时就应该把对应的socket从select的位图集上进行删除
if(ret==0)
{
FdSetDel(&fds,i);
}//end if(ret=0)
}//end for(;i<=fds.max_fd;++i)
}//end if(FD_ISSET(listen_socket,&fds.set))
}//end while(1)
close(listen_socket);
return 0;
}
select的缺点
- 接口使用不方便,每次select返回后,文件描述符集中的内容就被置为只有就绪的文件描述符的位置为1,再次掉用selsect时,就需要重新设置fd集合。
- 每次调用select都要把文件描述符的集合从用户态拷贝到内核态,开销很大
- 拷贝到内核态后,还要在内核中遍历该集合,不仅如此,再拷贝回用户态后,也要进行遍历,开销很大
- select支持的文件描述符数量有限,取决于fd_set这个结构体的大小。经过测试,我的机器上的fd_set的大小为128个字节,因此其能表示的文件描述符的大小为128*8=1024,当客户端的数量非常多时,就超出了能表示的最大范围。(若要修改fd_set的大小,就要修改操作系统,重新编译)