I/O复用可同时监听多个文件描述符发生事件,很大程度提高了程序的性能。I/O复用适用的场合一般有如下几点:
- 客户端程序要同时处理多个socket;
- 客户端程序要同时处理用户输入和网络连接。如聊天室程序;
- TCP服务器同时监听socket和连接socket。如服务器连接负载均衡;
- 服务器要同时处理TCP请求和UDP请求。如回射服务器;
- 服务器需要同时监听多个端口,或者处理多种服务。如xinetd服务器;
I/O复用本身是阻塞的,即没有文件描述符有事件发生就为阻塞状态。并且当多个文件描述符同时就绪时,如果不采取其他措施,I/O复用也只能按顺序对一个一个文件描述符进行处理,即服务器是串行工作的。可以采取多进程或多线程的方式来实现并发。Linix下I/O复用的系统调用主要是select、poll和epoll,以下是介绍select系统调用的简单使用。
1. select API
select系统调用的原型如下:
#include<sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
1)nfds参数设置要监听的文件描述符总数,一般为最大文件描述符的值加1.
2)readfds、writes和exceptfds参数分别指向可读、可写和异常事件对应的文件描述符集合。用户需要自定义这三个参数,传入想要监听的文件描述符。select调用返回时,内核会修改它们并通知哪些文件描述符已经就绪。fd_set结构体的定义如下:
fd_set相当于一个位数组,每一位都标记一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的数量。系统为用户提供了如下接口来访问和操作fd_set结构体:
3)timeout参数是设置select的超时时间的。一般给0,即阻塞等待事件发生。
2. 文件描述符的就绪条件
即就是发生哪些情况文件描述符被认为是可读情况,哪些被认为是可写情况。
可读情况:
- socket内核接收缓存区中的字节数大于或等于其低水位标记SO_SNDLOWAT(即接受到了一定的消息)。此时可以无阻塞地读该socket,并且读操作返回的字节数大于0。
- socket通信的对方关闭连接。此时对该socket的读操作返回0。
- 监听socket上有新的连接请求。
- socket上有未处理的错误。可用getsockopt来读取和清除该错误。
可写情况:
- socket内核发送缓存区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT。
- socket上的写操作被关闭。并触发一个SIGPIPE信号。
- socket使用非阻塞connect连接成功或失败时。
- socket上有未处理的错误
select能处理的异常情况只有socket上接收到的带外数据。
3. 简单运用select
void Init_fd(int *fds, int size) //初始化存放文件描述符的数组fds
{
for(int i = 0; i < size; ++i)
{
fds[i] = -1;
}
}
void Insert_fd(int *fds, int fd, int size) //向fds数组中添加文件描述符
{
for(int i = 0; i < size; ++i)
{
if(-1 == fds[i])
{
fds[i] = fd;
break;
}
}
}
void Delete_fd(int *fds, int fd, int size) //删除指定的文件描述符
{
for(int i = 0; i < size; ++i)
{
if(fds[i] == fd)
{
fds[i] = -1;
break;
}
}
}
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
assert(sockfd);
struct sockaddr_in ser,cli;
ser.sin_family = AF_INET;
ser.sin_port = htons(6000);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(sockfd, (struct sockaddr *)&ser, sizeof(ser));
assert(res);
listen(sockfd, 10);
int fds[100]; //存放文件描述符
Init_fd(fds, 100); //初始化fds
Insert_fd(fds, sockfd, 100); //插入sockfd
fd_set readfds; //定义可读的fd_set结构体
while(1)
{
int maxfd = -1; //最大文件描述符
FD_ZERO(&readfds); //初始化readfds可读事件的结构体
int i = 0;
for(; i < 100; ++i)
{
if(fds[i] != -1)
{
if(fds[i] > maxfd)
{
maxfd = fds[i];
}
FD_SET(fds[i], &readfds); //将所有文件描述符加入到可读readfds中
}
}
int n = select(maxfd + 1, &readfds, NULL, NULL, NULL); //启动select监听
if(n <= 0)
{
printf("no event!\n");
continue;
}
for(i = 0; i < 100; ++i)
{
if(fds[i] != -1 && FD_ISSET(fds[i], &readfds)) //FD_ISSET用来判断文件描述符是否被系统修改,修改则有时间发生
{
if(fds[i] == sockfd) //若就绪的文件描述符为sockfd即是有新的客户端连接
{
int len = sizeof(cli);
int clifd = accept(sockfd, (struct sockaddr *)&cli, &len);
assert(clifd != -1);
Insert_fd(fds, clifd, 100);
}
else //否则为其他可读事件发生
{
char buff[128] = {0};
int len = recv(fds[i], buff, 127, 0);
if(len <= 0)
{
close(fds[i]);
Delete_fd(fds, fds[i], 100); //若事件为断开连接则删除文件描述符
continue;
}
printf("%d %s\n", fds[i], buff);
send(fds[i], "ok", 2, 0);
}
}
}
}
}
4. select的缺陷
- select可监听的最大文件描述符数可能会受到限制。
- 每次调用都需要重复传入文件描述符集(即上的fd_set位数组)或事件集,消耗较大,效率也会降低。
- select采用的是轮询的方式对socket进行扫描,即是依次扫描,对fd_set遍历,效率低,浪费cpu时间。
- 在内核态,内核需要把fd_set从用户态拷到内核态,开销大。