本文章是基于《Linux高性能服务器编程》这本书的知识进行学习和分析的,大家有兴趣可以看看这本书的第九章。
一、I/O复用的介绍
I/O复用使得程序能同时监听多个文件描述符,这对提高程序的性能至关重要。 比如:
- 客户端程序要同时处理多个socket,例如非阻塞的connect技术。
- 客户端程序要同时处理用户输入和网络连接。例如聊天室程序。
- 服务器要同时处理TCP和UDP请求,比如:回射服务器。
- 服务器要同时监听多个端口,或者处理多种服务,比如xinetd服务器。
- 服务器要同时监听socket和连接socket,这是I/O复用使用最多的场合。
需要说明的是,I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的,并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的,如果要实现并发,只能使用多进程或多线程、进程池或线程池等手段。
I/O复用:一个进程或者一个线程,能够同时对多个文件描述符(socket)提供服务
服务器上的线程或者进程,如何将多个文件描述符统一监听,当任意一个文件描述符上有事件发生,其都能及时处理。它的三种手段: select poll epoll(linux独有)。今天我们就基于单线程单进程同时处理多个文件描述符来展开讲解select。
二、select的函数原型
select系统调用的原型如下:
1.nfds: 最大文件描述符值+1(提高效率); 比如:2 3 4 5 就传5+1 6从表示第6的位数位置开始检测
2.fd_set 记录文件描述符 监听的文件描述符
readfds: 可读事件的文件描述符集合 不但需要它往内核传递关注的文件描述符,也需要返回
writefds: 可写事件的文件描述符集合 就绪的文件描述符,将就绪的和未就绪的都返回。
exceptfds:异常事件的文件描述符集合
3 .timeout :设置超时时间
结构体如下:
有三种可能:
1) timeout=NULL(阻塞:直到有一个fd位被置为1,函数才返回)
2) timeout所指向的结构设为非零时间(等待固定时间:有一个fd位被置为1或者时间耗尽,函数均返回)
3) timeout所指向的结构,时间设为0(非阻塞:函数检查完每个fd后立即返回)
4. select的返回值 :-1 出错
==0 超时
>0 就绪的文件描述符个数
也许大家对nfds为什么是最大文件描述符值加1,是因为我们是通过位fd进行标志文件描述符的, 32位 有1024个位来描述文件描述符 从0到1023 下标和个数差一 。如下图:
我们可以用将最大文件描述符的值加1传递给select函数,这样在探测就绪的文件描述符时,就可以节省时间,只探测最大描述符+1之后的位置,既保证了正确性又提高了效率。
看完select的函数原型后,我们需要考虑以下两个问题:
- 如何将文件描述符分别设置到readfds writefds exceptfds ?
- select返回后,如何知道那些文件描述符是就绪的?
带着问题我们来看看fd_set这个结构体到底是什么吧~
可看出fd_set 其实是一个int 型的数组:
typedef struct
{
int fds_bits[32];
}fd_set;
由以上定义可见,fd_set结构体仅包含了一个整型数组,该数组的每个元素的每一位(bit)标记一个文件描述符、fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量。由于位操作过于繁琐,我们应该使用下面的一系列宏来访问fd_set结构体中的位:
有上图可看出,fd_set 的4个宏函数可以解决上面两个问题
1.前三个函数可以解决“如何设置到readfds writefds exceptfds ”问题:
FD_ZERO可以在每一轮开始的时候清除fdset的所有位,类似于初始化;
FD_SET可以设置位fd;
FD_CLR是可以清除位fd。
2.最后一个函数可以解决“select返回后,如何知道那些文件描述符是就绪的?”的问题。
FD_ISSET函数就是用来检测fdset的位fd是否被设置
select是I/O复用中最麻烦又相比效率不高的一个函数,但是它是I/O复用的入门和理解,让我们明白什么是I/O复用。学完select原型和分析后,我们要明白以下几点。
- select 函数原型
- fd_set 结构体的定义 ----> int fds[32]; 按位表示关注的文件描述符(不用考虑是否超过32位,宏函数已经写好,有兴趣的可以深入看看) 如何设置 前三个宏函数
- 如何返回就绪文件描述符 ?内核仅仅将fd_set结构体变量中将就绪文件描述符的位 修改 1----> 0 a |= 1 << n (将第几位设置为1)
- 应用程序如何探测就绪的文件描述符? 循环 fd_isset() 就绪和非就绪的都会返回,所以得都探测查看一下
- 每次调用select之前重新设置读、写、异常事件
三、select代码实现
此代码是在linux环境下编写的,首先我先给出伪代码:
int main()
{
- TCP服务器设置 socket bind listen
- 将sockfd添加到fds中
- 启动while循环
3.1将fds中的文件描述符设置到readfds上
3.2启动select 完成监听
3.3循环探测那些文件描述符就绪
3.3.1 Sockfd ---》 有客户端完成了三次握手 accept insert_fd
3.3.2 连接 fd (就是c) ---》客户端有数据到达 recv的值
c <= 0 close fd ,delete_fd
c >0 处理数据
}
接着我将实现一个简单的只关注读事件的select代码,如下:
//ser代码
int maxfd = -1;
int socketfds[1024]; //这里也可以将1024用define宏表示
void Initfds()
{
int i = 0;
for(; i < 1024;++i)
{
socketfds[i] = -1;//0也算一个文件描述符,所以设置为-1
}
}
int Addfd(int fd)
{
int i = 0;
for(; i < 1024; ++i)
{
if(socketfds[i] == -1)
{
socketfds[i] = fd;
return 1;
}
}
return 0;
}
int Deletefd(int fd)
{
int i = 0;
for(;i < 1024; ++i)
{
if(socketfds[i] == fd)
{
socketfds[i] = -1;
return 1;
}
}
return 0;
}
int CreateSocket(int port,char *ip)
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);//协议簇 TCP协议
assert(sockfd != -1);
struct sockaddr_in ser,cli;
memset(&ser,0,sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_addr.s_addr = inet_addr(ip); //IP地址
ser.sin_port = htons(port);
int res = bind(sockfd,(struct sockaddr *)&ser,sizeof(ser));
assert(res != -1);//绑定失败 1.IP地址不对 2.端口号被占用或者没有权限使用
listen(sockfd,5); //size = 5 内核维护的已经完成链接客户端的文件描述符个数(6)实际会加一
Addfd(sockfd);
return sockfd;
}
int main()
{
Initfds();
int sockfd = CreateSocket(6888,"127.0.0.1");
fd_set readfds;//只关注读事件
while(1)
{
FD_ZERO(&readfds);
int i = 0;
for(; i < 1024; ++i)
{
if(socketfds[i] != -1)
{
FD_SET(socketfds[i],&readfds);
if(socketfds[i] > maxfd)
{
maxfd = socketfds[i];
}
}
}
int n = select(maxfd + 1,&readfds,NULL,NULL,NULL);//最大描述符加一
if(n <= 0)
{
printf("error\n");
exit(0);
}
for(i = 0;i < 1024;++i)
{
int fd = socketfds[i];
if(fd != -1 && FD_ISSET(fd,&readfds))
{
if(fd == sockfd) //客户链接
{
struct sockaddr_in cli;
int len = sizeof(cli);
int c = accept(fd,(struct sockaddr*)&cli,&len);
if(c == -1)
{
printf("link is error\n");
continue;
}
Addfd(c);
}
else //有事件(数据)发生
{
char buff[128] = {0};
int n = recv(fd,buff,127,0);
if(n <= 0) //异常 客户端有异常
{
close(fd);
Deletefd(fd);
continue;
}
printf("%d: recv: %s\n",fd,buff);
send(fd,"ok",2,0);
}
}
}
}
}
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
//cli的代码
int main()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
assert(sockfd != -1);
struct sockaddr_in ser,cli;//服务器的IP地址 端口号
memset(&ser,0,sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(6888);//服务器上对应服务进程的端口号
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = connect(sockfd,(struct sockaddr*)&ser,sizeof(ser));
assert(res != -1);
while(1)
{
printf("please input:");
char buff[128] = {0};
fgets(buff,128,stdin);
if(strncmp(buff,"end",3) == 0)
{
close(sockfd);
break;
}
send(sockfd,buff,strlen(buff)-1,0);
char recvbuff[128] = {0};
int n = recv(sockfd,recvbuff,127,0);
if(n <= 0)
{
close(sockfd);
break;
}
printf("client recv data: %s\n",recvbuff);
}
}
结果如下:
四、select的特点
select: 关注可读、可写 、异常事件
1.记录每种事件的结构 (在数组按位来记录关注的文件描述符上的事件)
2.每次做多可以监听1024个文件描述符,并且其最大值1023。因为底层是一个int型32位数组
3.select函数返回时,通过传递的结构体变量将结果带回 (就绪的文件和未就绪的文件描述符),并且内核会修改用户变量
a.每次都必须循环探测那些文件描述符就绪 时间复杂度为O(n)
b.每次调用select之前都必须重新设置三个结构体变量
4.select函数第一个参数 最大的文件描述符值+1 提高底层效率
用户传递的文件描述符和内核反馈的文件描述符都是通过select参数,所以每次调用select之前必须重新设置结构体,内核会将所有的文件描述符返回,所以用户探测就绪文件描述符的时间复杂度O(n)。用户态和内核态交互,内核修改后再传递给用户态。如下图:
如果要处理业务,可以使用多进程,多线程 ,高效的处理事件模式reactor模式,另一个是proactor模式。大家感兴趣的话可以了解一下,在书中的第八章~