声明:总结主要参考于《Linux高性能服务器编程》这本书,自己总结些许,与君共勉。
I/O多路复用
形成原因:
如果一个I/O流进来,我们就开启一个进程处理这个I/O流。那么假设现在有一百万个I/O流进来,那我们就需要开启一百万个进程一一对应处理这些I/O流(——这就是传统意义下的多进程并发处理)。思考一下,一百万个进程,你的CPU占有率会多高,这个实现方式及其的不合理。所以人们提出了I/O多路复用这个模型,一个线程,通过记录I/O流的状态来同时管理多个I/O,可以提高服务器的吞吐能力。I/O复用使得程序能够监听多个文件描述符,提高程序的性能。
I/O多路复用的实现方式有三种:select、poll、epoll
1、select
select系统调用的用途:在一段指定时间内,监听用户感兴趣的文件描述符上的可读、可写和异常事件;
函数原型:
#include <sys/select.h>
int select( int nfds,fd_set* readfds,fd_set* writefds,fd_set* excptfds,struct timeval* timeout);
- nfds:指定待测试的描述子个数
- readfds,writefds,exceptfds:指定了我们让内核测试读、写和异常条件的描述字
- fd_set:为一个存放文件描述符的信息的结构体,可以通过下面的宏进行设置。
- timeval是一个结构体
struct timeval
{
long tv_sec; //秒数
long tv_usec; //微秒数
};
- timeout:内核等待指定的描述字中就绪的时间长度
- 返回值:失败-1 超时0 成功返回就绪文件描述符总数
这里举一个简单地例子:
# include <stdio.h>
# include <stdlib.h>
# include <assert.h>
# include <string.h>
# include <unistd.h>
# include <sys/types.h>
# include <netinet/in.h>
# include <arpa/inet.h>
# include <sys/time.h>
#define STDIN 0
int main()
{
fd_set fdset;
int fd = STDIN;
while(1)
{
FD_ZERO(&fdset); //清空集合
FD_SET(fd,&fdset);// 把用户关心的描述符添加到集合
struct timeval tv ={5,0};
int n = select(fd+1,&fdset,NULL,NULL,&tv);
if(n == -1)
{
perror("select error");
continue;
}
else if(n == 0)
{
printf("time out\n");//监听,如果没有键盘输入,则每间隔5秒打印一次
continue;
}
else
{
if(FD_ISSET(fd,&fdset))
{
char buff[128]={0};
read(fd,buff,127);
printf("read:%s\n",buff);
}
}
}
}
当没有键盘输入时,阻塞,每隔5秒打印一次time out,直到有键盘输入,打印输入的数据;
2、poll
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,以测试是否有就绪者。
函数原型:
# include <poll.h>
int poll(struct pollfd* fds,nfds_t nfds,int timeout);
- pollfd也是一个结构体:
struct pollfd {
int fd; //文件描述符
short events; //请求的事件(请求哪种操作)
short revents; //返回的事件
};
- 返回值:失败-1 超时0 成功返回就绪文件描述符总数
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#define FDMAXNUM 100
void InitFds(struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
fds[i].fd = -1;
}
}
void DeleteFd(int fd, struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == fd)
{
fds[i].fd = -1;
break;
}
}
}
void AddFd(int fd, struct pollfd *fds)
{
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == -1)
{
fds[i].fd = fd;
fds[i].events = POLLIN | POLLRDHUP;
break;
}
}
}
void GetClientLink(int fd, struct pollfd *fds)
{
struct sockaddr_in cli;
int len = sizeof(cli);
int c = accept(fd, (struct sockaddr*)&cli, &len);
if(c == -1)
{
return;
}
AddFd(c, fds);
}
void DealClientData(int fd, struct pollfd *fds, int rdhup)
{
if(rdhup)
{
close(fd);
DeleteFd(fd, fds);
return;
}
char buff[128] = {0};
int n = recv(fd, buff, 127, 0);
if(n <= 0)
{
DeleteFd(fd, fds);
return;
}
printf("%s\n", buff);
send(fd, "OK", 2, 0);
}
int main()
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd != -1);
struct sockaddr_in ser;
memset(&ser, 0, sizeof(ser));
ser.sin_family = AF_INET;
ser.sin_port = htons(6000);
ser.sin_addr.s_addr = inet_addr("127.0.0.1");
int res = bind(listenfd, (struct sockaddr*)&ser, sizeof(ser));
assert(res != -1);
listen(listenfd, 5);
struct pollfd fds[FDMAXNUM];
InitFds(fds);
fds[0].fd = listenfd;
fds[0].events = POLLIN;
while(1)
{
int n = poll(fds, FDMAXNUM, -1);
if(n <= 0)
{
exit(0);
}
int i = 0;
for(; i < FDMAXNUM; ++i)
{
if(fds[i].fd == -1)
{
continue;
}
if(fds[i].fd == listenfd && fds[i].revents & POLLIN)
{
GetClientLink(fds[i].fd, fds);
}
else if(fds[i].revents & POLLIN)
{
if(fds[i].revents & POLLRDHUP)
DealClientData(fds[i].fd, fds, 1);
else
DealClientData(fds[i].fd, fds, 0);
}
}
}
}
3、epoll
epoll是Linux特有的I/O复用函数。
epoll操作过程中会用到的重要函数:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
- int epoll_create(int size):创建一个epoll的句柄,size表示监听数目的大小。创建完句柄它会自动占用一个fd值,使用完epoll一定要记得close,不然fd会被消耗完。
- int epoll_ctl:这是epoll的事件注册函数,和select不同的是select在监听的时候会告诉内核监听什么样的事件,而epoll必须在epoll_ctl先注册要监听的事件类型。函数成功返回0,失败返回-1,并设置errno。
fd参数是要操作的文件描述符,op参数则指定操作类型,event参数指定事件,它是epoll_event结构指针类型
struct epoll_event
{
_uint 32_t events; //epoll事件,支持的事件与poll基本相同
epoll_data_t data; //用户数据
};
//epoll_data_t 是一个联合体
typedef union epoll_data
{
void * ptr;
int fd;
uint 32_t u32;
uint 64_t u64;
}epoll_data_t;
联合体中使用最多的是fd,它是指定事件所从属的目标文件描述符。ptr成员可用来指定与fd相关的用户数据,因为同处于联合体中,不能同时使用fd和ptr,因此要将两者联合起来,以实现快速访问。比如放弃使用fd成员,而在ptr指向的用户数据中包含fd。
- int epoll_wait:等待事件的发生
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
maxevents参数指定最多监听多少个事件,其值必须大于0。
这里提出几个比较重要的问题
1、epoll与poll、select的差别?
(1)epoll使用一组函数来完成任务,而不是单个函数;
(2)epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,而poll和select每次调用都要重复传入文件描述符集或事件集;
(3)epoll需要使用一个额外的文件描述符,用来唯一标识内核中的事件表。
2、epoll比poll、select更加高效的原因?
这里主要是因为epoll_wait函数,该函数如果检测到事件,就将所有就绪的事件从内核事件表(由epfd参数指定)中复制到它的第二个参数events指向的数组中,这个数组只用于输出epoll_wait 检测到的就绪事件,而不像select和poll的数组参数那样既用于传入用户注册的事件又用于输出内核检测到的就绪事件,这就极大提高了程序索引就绪文件描述符的效率。
3、ET模式与LT模式的区别?
LT(电平触发)模式是默认的工作模式,此时epoll相当于效率较高的poll。当往内核事件表上注册文件描述符上的EPOLLET事件时,epoll将以ET(边沿触发)模式来操作该文件描述符,ET是epoll的高效工作模式。
区别:
LT:延迟处理,当epoll_wait函数检测到有事件发生并通知应用程序,应用程序可以不立即处理该事件。那么下次会再次通知应用程序此事件,直到该事件被处理。
ET:立即处理,当epoll_wait函数检测到有事件发生并通知应用程序,应用程序会立即处理,后续的epoll_wait调用将不会向程序通知这一事件,在很大程度上降低了同一个epoll事件被重复触发的次数。
ET模式减少了epoll被重复触发的次数,效率比LT高。我们在使用ET的时候,必须采用非阻塞套接口,避免某文件句柄在阻塞读或阻塞写的时候将其他文件描述符的任务饿死。
4、3种I/O函数的对比?
前面已经详细介绍了每一个函数,也了解了其大致的用法和功能,因此在这里不做过多的描述,引入一张图片可以进行参考。