2019年10月25日09:30:07
注:I/O复用技术对于我们开发高性能服务器至关重要,这一部分内容主要详见《Linux高性能服务器编程》的第9章
背景说明
IO复用技术使得程序能够 同时监听多个文件描述符,这对提高程序的性能至关重要。Linux下实现I/O复用的系统调用主要有select、poll、epoll。一般情况,网络程序会在下面的几种情形下使用到I/O复用技术:
- client程序要同时处理多个socket。如 非阻塞connect技术
- client程序要同时处理用户输入和网络连接。如 聊天室程序
- TCP服务器要同时处理监听socket和连接socket。这是I/O复用使用最多的场合,我下面给大家展示的代码示例 也是这种情形
- 服务器要同时处理TCP请求和UDP请求。如 回射服务器
- 服务器要同时监听多个端口,或者处理多种服务。如 xinetd服务器
注:I/O复用虽然可以同时监听多个文件描述符,但它本身是阻塞的。 详细原因,下面解释。而且当多个文件描述符同时就绪时,假如不采取额外的措施,程序就只能按顺序依次进行处理其中的这每一个文件描述符,这使得服务器程序看起来像是在串行工作。而若要实现并发,只能使用多线程或多进程等编程手段。
这里首先说明的是:select。其用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件。
select API
select系统调用 函数原型如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
下面详细分析其每一个参数:
- nfds:指定了被监听的文件描述符的总数。它通常被设置为select监听的文件描述符:readfds,writefds,exceptfds这三个描述符集中的最大描述符值(即编号) 加1,因为文件描述符是从0开始计数的。这主要是基于提高底层效率设计的。
- readfds、writefds和exceptfds:分别指向可读、可写和异常事件对应的文件描述符集合。 应用程序调用select函数时,通过这3个参数传入自己感兴趣的文件描述符,轮询等待这些描述符有事件产生。select调用返回时,内核将修改 它们来通知应用程序哪些文件描述符已经就绪。这3个参数是fd_set结构指针类型。fd_set结构体的定义如下:
- 如上图所示:fd_set结构体仅包含一个整形数组(在这里,我们可以认为是32个元素),该数组的每个元素的每一位(bit)标记一个文件描述符(于是这样的位就有1024个)。fd_set能容纳文件描述符的数量由FD_SETSIZE(即1024)指定,所以这就限制了select能同时处理的文件描述符的总量。
- 而且因为位操作过于繁琐,我们应该使用下面的一系列宏来访问fd_set结构体中的位:
#include<sys/select.h>
void FD_ZERO(fd_set *set); /* 清除fdset的所有位 */
void FD_SET(int fd, fd_set *set); /* 设置fdset的位fd */
void FD_CLR(int fd, fd_set *set); /* 清除fdset的位fd */
int FD_ISSET(int fd, fd_set *set); /* 测试fdset的位fd是否被设置 */
- timeout:这个参数用来设置select函数的超时时间。它是一个timeval结构类型的指针,采用指针参数是因为内核将修改它以告诉应用程序select等待了多久。不过我们不能完全信任select调用返回后的timeout值,比如调用失败时timeout的值是不确定的。timeval结构体的定义如下:
struct timeval
{
long tv_sec; //秒数
long tv_usec; //微秒数
};
由以上定义可见,select给我们提供了一个微秒级的定时方式:
- 若是给timeval的这两个成员分量都赋值0,则select将立即返回
- 若是给此处的timeout传入NULL,则select将一直阻塞,直到某个文件描述符就绪。
select成功时返回就绪(可读、可写和异常)的文件描述符的总数;而如果在超时时间内没有任何描述符就绪,select返回0;select失败返回-1并设置errno。如果在select等待期间,程序收到信号,则select立即返回-1,并设置errno为EINTR。
文件描述符就绪条件
哪些情况下文件描述符可以被认为是可读、可写或者出现异常,这对于select的使用非常关键。
在网络编程中,下列情况下socket可读:
- socket内核接收缓存区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时我们可以无阻塞地读该socket,并且读操作返回的字节数大于0。
- socket通信的对方关闭连接。此时对该socket读操作将返回0。
- 监听socket上有新的连接请求。
- socket上有未处理的错误。此时我们可以使用getsockopt来读取和清除该错误。
在网络编程中,下列情况下socket可写:
- socket内核发送缓冲区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT。此时我们可以无阻塞写该socket,并且写操作返回的字节数大于0。
- socket的写操作被关闭。对 写操作被关闭的socket 执行写操作将触发一个SIGPIPE信号。
- socket使用非阻塞connect连接成功或者失败(超时)之后。
- socket上有未处理的错误。此时我们可以使用getsockopt来读取和清除该错误。
在网络编程中,下列情况下socket处理异常情况:
- socket上接收到带外数据。
使用select处理示例
从STDIN获取键盘输入
下面的这个代码主要就是展示:select方法的使用方法,程序从键盘读取输入的数据(即标准输入stdin——其文件描述符为0),超时时间timeout设置为5s。它只有在输入就绪时才读取键盘。也即:执行这个程序时,每隔5s检测没有文件描述符就绪即打印一个 time out 超时信息。而如果在键盘上键入字符,则就会从标准输入中读取数据并打印出来这个数据的内容。用select调用来检查标准输入的状态,程序通过事先设置的超时时间,即每隔5秒打印一条 time out 超时信息(这是通过select系统调用返回0来判断的),具体的程序效果如下:
详细代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
#define SIDIN 0//一般 :标准输入的文件描述符为0
int main()
{
int fd=SIDIN;//于是 fd为标准输入文件描述符 0
fd_set fdset;//fd_set结构体 变量(里面有1024位)
while(1)
{
FD_ZERO(&fdset);//清空集合 全部置0
FD_SET(fd,&fdset);//把文描fd添加到集合中
struct timeval tv={5,0};//每次阻塞5秒:即在标准输入stdin上最多等待5s
//select返回值为n,表示有n个状态发生变化的描述符
int n=select(fd+1,&fdset,NULL,NULL,&tv);//这里只是监听了 可读事件
if(n== -1)//select失败返回-1而且会设置errno
{
perror("select error");
continue;
}
else if(n==0)//select为0表示文件描述符在5s内都没有变化
{
printf("time out\n");//于是就打印出超时信息
continue;
}
else
{
//通过FD_ISSET方法判断参数fd指向的文件描述符是否是
//由参数fdset指向的fd_set结构体集合中的一个元素
if(FD_ISSET(fd,&fdset))
{
//既然是被修改过,则从标准输入stdin读取数据到buff中
char buff[128]={0};
read(fd,buff,127);
printf("read:%s\n",buff);
}
}
}
}
基于select实现的服务器 连接多客户端
之前实现的服务器—多客户端的方式都是借助于 多线程和多进程的方式,而我们这里的实现方式:借助于select调用来同时处理多个客户端即可。
具体实现为:服务器可以让select系统调用同时检查监听socket和客户的连接socket。一旦select调用指示有活动发生,就可以用FD_ISSET来遍历一下所有可能的文件描述符,以检查确定是哪个文件描述符上面有事件发生。具体的程序效果如下:
select实现了多客户访问的详细代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAX 10
void fds_init(int fds[])//fds集合初始化
{
int i=0;
for(;i<MAX;++i)
{
fds[i]=-1;//全置为 -1无效的文描
}
}
void fds_add(int fds[],int fd)//向集合fds中添加文件描述符fd
{
int i=0;
for(;i<MAX;++i)
{
if(fds[i]==-1)
{
fds[i]=fd;//找到个位置就放进去
break;
}
}
}
void fds_del(int fds[],int fd)//在集合fds中删除文件描述符fd
{
int i=0;
for(;i<MAX;++i)
{
if(fds[i]==fd)
{
fds[i]=-1;
break;
}
}
}
//专门创建sockfd函数
int create_socket()
{
//创建监听套接字(socket描述符),指定协议族ipv4,字节流服务传输
int sockfd=socket(AF_INET,SOCK_STREAM,0);
if(sockfd == -1)
{
return -1;
}
//socket专用地址信息进行注册
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port= htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
//命名套接字,将socket专用地址信息绑定到socket描述符上
int res=bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if(res == -1)
{
return -1;
}
listen(sockfd,5);//创建启动监听队列
return sockfd;
}
int main()
{
int fds[MAX];
fds_init(fds);//初始化文件描述符集合fds
int sockfd=create_socket();
assert(sockfd != -1);
fds_add(fds,sockfd);//首先把sockfd添加到文件描述符集合fds中
fd_set fdset;//fd_set结构体 变量(里面有1024位)
while(1)
{
int maxfd=-1;
int i=0;
FD_ZERO(&fdset);//fdset初始化为空集合,清除fdset上的所有位
for(;i<MAX;++i)//这里循环遍历以找到最大的文件描述符
{
if(fds[i]!=-1)
{
FD_SET(fds[i],&fdset);//设置fdset的fds[i]位
if(fds[i]>maxfd)
{
maxfd=fds[i];
}
}
}
struct timeval tv={5,0};//每次阻塞5秒
//select成功时返回就绪文件描述符的总数为n
int n=select(maxfd+1,&fdset,NULL,NULL,&tv);
if(n==-1)//select失败返回-1并设置errno
{
perror("select error");
continue;
}
else if(n==0)//select为0表示文件描述符在5s内都没有变化
{
printf("time out\n");//于是就打印出超时信息
continue;
}
else//n有效 遍历所有可能的文件描述符,以检查是哪个上面有事件发生。
{
int i=0;
for(;i<MAX;++i)
{
if(fds[i]==-1)//无效的 直接忽略
{
continue;
}
//有效的文描
if(FD_ISSET(fds[i],&fdset))//fdset的fds[i]位已经set,即有数据
{
//下面是监听队列中有连接待处理,使用accept取出一个连接
//这说明正有一个客户端试图建立连接,此时就直接可以调用accept
//而不用担心发生阻塞的问题
if(fds[i]==sockfd)
{
struct sockaddr_in caddr;
int len=sizeof(caddr);
//接收一个套接字已建立的连接,得到连接套接字c值
int c=accept(sockfd,
(struct sockaddr*)&caddr,&len);
if(c<0)
{
continue;
}
printf("accept c=%d\n",c);
//同理:将连接套接字c,添加到fds文件描述集合中 监听
fds_add(fds,c);
}
else//else 没有新连接,直接使用recv接收客户端数据
{
char buff[128]={0};
//recv用来接收客端数据
int n=recv(fds[i],buff,5,0);
//接收服务器端的数据是0,说明客户端已经关闭
if(n<=0)
{
close(fds[i]);//先将文件描述符fds[i]关闭
fds_del(fds,fds[i]);//移除出fds数组
printf("one client over\n");
continue;
}
else
{
//打印客户端发来的数据,并向客户端发送回复
printf("read(%d)=%s\n",fds[i],buff);
send(fds[i],"over",4,0);
}
}
}
}
}
}
}
select的特点小结
上面也说过了,select: 关注可读、可写 、异常事件。其记录每种事件的结构 fd_set (在数组按位来记录关注的文件描述符上的事件),但是每次最多可以监听1024个文件描述符,并且其最大值1023。毕竟底层只是一个int型32位数组大小。
此外,select函数返回时,通过传递的结构体变量将结果带回 (就绪的和未就绪的文件描述符),主要由内核修改用户变量。全混在一起的话,每次都必须循环探测哪些文件描述符是就绪的 时间复杂度为O(n);每次调用select之前都必须重新设置三个结构体变量(上面的代码里面的FD_ZERO方法)。
用户传入的文件描述符和内核返回的文件描述符都是通过select的参数实现的,所以每次调用select之前必须重新设置结构体,内核会将所有的文件描述符返回,所以用户探测就绪文件描述符的时间复杂度O(n)。这其中的用户态和内核态交互,内核修改后再传递回用户态。