Linux系统在访问设备的时候,存在以下几种IO模型:
今天我们来分析下IO多路复用机制,在Linux中是通过select/poll/epoll机制来实现的。
请先阅读 Epoll的本质
为了解决大量客户端访问的问题,引入IO复用技术:一个进程可以同时对多个客户请求进行服务,复用一个进程对多个IO进行服务。IO读写的数据多数情况下未准备好,需要通过一个函数监听这些数据状态,一旦有数据可以读写就触发服务。elect,poll,epoll都是IO多路复用的机制,监视多个描述符,一旦某个描述符就绪,通知程序进行操作。
select()的机制中提供一fd_set的数据结构,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(不管是Socket句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪一Socket或文件可读。
#include
#include
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数:
- nfds: 委托内核检测的最大文件描述符的值 + 1
- readfds: select监视的可读文件句柄集合
- 传入传出的参数
- 委托内核检测读缓冲区是不是可以读数据
- writefds: select监视的可写文件句柄集合
- 传入传出的参数
- 委托内核检测写缓冲区是不是还可以写数据(不满就可以写)
- exceptfds: select监视的异常文件句柄集合
- 传入传出的参数
- 委托内核检测哪些文件描述符出现了异常
- timeout:本次select的超时结束时间
struct timeval {
long tv_sec; /* 秒 */
long tv_usec; /* 毫秒 */
};
- NULL: 永久阻塞, 直到检测到了文件描述符有变化
- tv_sec = 0, tv_usec = 0, 不阻塞
- tv_sec > 0 || tv_usec > 0, 阻塞对应的时间长度
返回值:
-1: 失败
>0: 返回已准备好的文件描述符数
0:超时
用户调用select会进入内核空间,并且调用 sys_select() 内核函数,主要完成以下工作。
FD_ZERO(fd_set *)将某一个集合清空,每次select前都需要将集合清空
FD_SET(int, fd_set *)将一个给定的文件描述符加入到集合之中
FD_CLR(int, fd_set *)从集合中删除指定的文件描述符。
FD_ISSET(int, fd_set *)检查集合中指定的文件描述符是否准备好(可读或可写)
#include
#include
#include
#include
#include
#include
int main(int argc, char **argv)
{
//创建监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if(-1 == lfd) {
perror("socket");
exit(0);
}
//绑定IP,PORT
struct sockaddr_in addr;
addr.sin_port = htons(12000);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
perror("bind");
exit(0);
}
//监听
if(-1 == linsten(lfd, 64)) {
perror("listen");
exit(0);
}
//select
fd_set rdset;
int nready = 0, fdsize = 0;
int buff[1024] = {0};
buff[fdsize++] = lfd;
while(1) {
FD_ZERO(&rdset);
for(int i = 0; i < fdsize ; ++i) {
FD_SET(buff[i], &rdset);
}
if( (0 > fdsize) || (1024 < fdsize)) {
break;
}
//因为Linux中分配文件描述符时是从当前未被分配的最小文件描述符来分配的,
//所以在select()函数的第一个参数只需要是buff中的最后一个文件描述符+1,
//即可完成对加入到rset中所有文件描述符的监听
nready = select(buff[fdsize - 1] + 1, &rdset, NULL, NULL, NULL);
if(0 == nready) {
//超时
continue;
}
else if(-1 == nready){
//失败
Error(errno);
for(int i = 1; i < fdsize ; ++i) {
close(buff[i]);
}
break;
}
else {
//通信
for(int i = 0; i < fdsize; ++i) {
if(FD_ISSET(buff[i], &rdset)) {
if(lfd == buff[i]) {
struct sockaddr_in caddr;
int nlen = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr*)&caddr, &nlen);
if(-1 == cfd) {
perror("accept");
exit(0);
}
//新连接加入buff
if(1024 <= fdsize) {
printf("已经达到最大检测数(%d)。", fdsize);
}
else{
buff[fdsize++] = cfd;
}
}
else {
char rbuff[1024] = {0};
int ret = read(buff[i], rbuff, sizeof(rbuff));
if(-1 == ret) {
perror("read");
exit(0);
}
else if(0 == ret) {
printf("client disconnect.......");
close(buff[i]);
for(int j = i--; j < fdsize -1; ++j) {
buff[j] = buff[j + 1];
}
buff[--fdsize] = 0;
}
else {
write(buff[i], rbuff, strlen(rbuff)+1);
}
}
}
}
}
}
close(lfd);
return 0;
}
由于select与poll本质上基本类似,其中select是由BSD UNIX引入,poll由SystemV引入。所以不在介绍poll的实现原理。
#include
struct pollfd {
int fd; /* 委托内核检测的文件描述符 */
short events; /* 委托内核检测文件描述符的什么事件 */
short revents; /* 文件描述符实际发生的事件 */
};
例子:
struct pollfd myfd;
myfd.fd = 5;
myfd.events = POLLIN | POLLOUT;
struct pollfd myfd[100];
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
参数:
- fds: 这是一个struct pollfd数组, 这是一个要检测的文件描述符的集合
- nfds: 这是第一个参数数组中最后一个有效元素的下标 + 1
- timeout: 阻塞时长
0: 不阻塞
-1: 阻塞, 检测的fd有变化解除阻塞
>0: 阻塞时长
返回值:
-1: 失败
>0(n): 检测的集合中有n个文件描述符发送的变化
0:超时
#include
#include
#include
#include
#include
#include
int main(int argc, char **argv)
{
//创建监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if(-1 == lfd) {
perror("socket");
exit(0);
}
//绑定IP,PORT
struct sockaddr_in addr;
addr.sin_port = htons(12000);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
perror("bind");
exit(0);
}
//监听
if(-1 == linsten(lfd, 64)) {
perror("listen");
exit(0);
}
//poll
int nready =0 , fdsize = 0;
struct pollfd events[64];
memset(events, 0, sizeof(events) * 64);
events[fdsize].fd = lfd;
events[fdsize++].events = POLLIN;
while(1) {
if(0 >= fdsize){
break;
}
nready = poll(events, 64, -1);
if(0 == nready) {
//超时
continue;
}
else if(-1 == nready){
//失败
Error(errno);
for(int i = 1; i < fdsize ; ++i) {
close(events[fdsize].fd);
}
break;
}
else {
//通信
for(int i = 0; i < fdsize; ++i) {
if(events[i].revents & (POLLIN | POLLHUP | POLLERR)) {
if(events[i].fd = lfd) {
//新连接到来
struct sockaddr_in caddr;
int nlen = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr*)&caddr, &nlen);
if(-1 == cfd) {
perror("accept");
exit(0);
}
//新连接加入buff
if(64 <= fdsize) {
printf("已经达到最大检测数(%d)。", fdsize);
}
else{
events[fdsize].fd = cfd;
events[fdsize++].events = POLLIN;
}
}
else {
char rbuff[1024] = {0};
int ret = read(events[i].fd, rbuff, sizeof(rbuff));
if(-1 == ret) {
perror("read");
exit(0);
}
else if(0 == ret) {
printf("client disconnect.......");
close(events[i].fd);
for(int j = i--; j < fdsize -1; ++j) {
events[j] = events[j + 1];
}
memset(&events[--fdsize], 0, sizeof(events[--fdsize]));
}
else {
write(events[i].fd, rbuff, strlen(rbuff)+1);
}
}
}
}
}
}
close(lfd);
return 0;
}
要使用 epoll 首先需要调用 epoll_create() 函数创建一个 epoll 的句柄,epoll_create() 函数定义如下:
#include
// 创建一棵红黑树
int epoll_create(int size);
参数:
size: 没意义(参数 size 是由于历史原因遗留下来的,现在不起作用)
返回值;
>0: epoll句柄
<=0: 失败
用户调用epoll_create会进入内核空间,并且调用 sys_epoll_create() 内核函数来创建 epoll 句柄。
sys_epoll_create() 主要完成两件事情:
struct eventpoll {
...
//等待队列,当调用 epoll_wait 时会把进程添加到 eventpoll 对象的 wq 等待队列中
wait_queue_head_t wq;
...
//保存已经就绪的事件列表,通过epoll_wait返回给用户
struct list_head rdllist;
//红黑树的根节点,使用红黑树来管理所有被监听的事件,这颗树中存储着所有添加到epoll中的需要监控的事件
struct rb_root rbr;
...
};
//红黑树被监听的事件通过epitem对象管理
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
通过调用 epoll_ctl() 函数可以向 epoll 添加要监听的事件,其原型如下:
#include
typedef union epoll_data {
void *ptr; // 复杂
int fd; // 简单 一般就使用这个就好,文件描述符传入即可;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll 事件 */
epoll_data_t data; /* 上面这个共用体中,一般使用fd */
};
Epoll检测的事件:
- EPOLLIN :表示对应的文件句柄可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件句柄可以写;
- EPOLLPRI:表示对应的文件句柄有紧急的数据可读;
- EPOLLERR:表示对应的文件句柄发生错误;
- EPOLLHUP:表示对应的文件句柄被挂断;
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
LT(level triggered)是缺省的工作方式,并且同时支持block(阻塞)和no-block (非阻塞)socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后可以对这个就绪的fd进行IO操作。只要有数据,内核会一直通知。
ET(edge-triggered)是高速工作方式,只支持no-block(非阻塞) socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll通知。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)。
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高`。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
// 对epoll树进行管理: 添加节点, 删除节点, 修改已有的节点属性
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
- epfd: epoll_create的返回的epoll句柄
- op: 要进行什么样的操作
EPOLL_CTL_ADD: 注册新节点, 添加到红黑树上
EPOLL_CTL_MOD: 修改检测的文件描述符的属性
EPOLL_CTL_DEL: 从红黑树上删除节点
- fd: 要检测的文件描述符的值
- event: 检测文件描述符的什么事件
epoll_ctl() 函数会调用 sys_epoll_ctl() 内核函数,sys_epoll_ctl() 主要完成以下事情:
把被监听的文件句柄添加到epoll后,就可以通过调用 epoll_wait() 等待被监听的文件状态发生改变。epoll_wait() 调用会阻塞当前进程,当被监听的文件状态发生改变时,epoll_wait() 调用便会返回。
#include
struct epoll_event events[1000];
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
参数:
- epfd: epoll_create的返回的epoll句柄
- events: 从eventpoll rdllist双链表中拷贝出的epitem对象信息
- maxevents: 第二个参数结构体数组的大小
- timeout: 阻塞时间
- 0: 不阻塞
- -1: 一直阻塞, 知道检测的fd有状态变化, 解除阻塞
- >0: 阻塞的时长(毫秒)
返回值:
- 成功: 有多少个文件描述符状态发生了变化 > 0
- 失败: -1
epoll_wait函数会调用sys_epoll_wait()内核函数,sys_epoll_wait() 主要完成以下事情:
epoll 服务器端代码模板示例:
#include
#include
#include
#include
#include
#include
int main(int argc, char **agrv)
{
//创建监听套接字
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == lfd) {
perror("socket");
exit(0);
}
//绑定IP,PORT
struct sockaddr_in addr;
addr.sin_port = htons(12000);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
if(-1 == bind(lfd, (struct sockaddr *)&addr, sizeof(addr))) {
perror("bind");
exit(0);
}
//监听
if(-1 == linsten(lfd, 64)) {
perror("listen");
exit(0);
}
//创建epoll
int epfd = epoll_create(1024);
if(0 >= epfd) {
perror("epoll_create");
exit(0);
}
//将socket 监听文件描述符添加到epoll中
struct epoll_event ev;
ev.events = EPOLLIN; //读
ev.data.fd = lfd;
if(0 > epoll_ctl(epfd, EPOLL_ATL_ADD, lfd, &ev)) {
perror("epoll_ctl");
exit(0);
}
//开始监听网络连接
struct epoll_event events[1024];
while(1) {
int nready = epoll_wait(epfd, events, sizeof(events), -1);
if(0 > nready) {
perror("epoll_wait");
exit(0);
}
//处理连接
for(int i = 0; i < nready; ++i) {
int fd = events[i].data.fd;
if(lfd == fd) {
//新连接到来
struct sockaddr_in caddr;
int nlen = sizeof(caddr);
int cfd = accept(lfd, (struct sockaddr *)&caddr, &nlen);
if(-1 == cfd) {
perror("accept");
exit(0);
}
//若设置为边沿触发,则需设置fd属性为非阻塞
//int flag = fcntl(connfd, F_GETFL);
//flag |= O_NONBLOCK;
//fcntl(connfd, F_SETFL, flag);
//ev.events = EPOLLIN | EPOLLET;
//将新的客户端连接加入epoll
ev.events = EPOLLIN;
ev.data.fd = cfd;
if(0 > epoll_ctl(epfd, EPOLL_ATL_ADD, cfd, &ev)) {
perror("epoll_ctl");
exit(0);
}
}
else {
//与客户端通信
if(events[i].events & EPOLLIN) {
char buff[1024] = {0};
int nread = read(fd, buff, sizeof(buff));
if(0 == nread) {
printf("client disconnect.......");
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
}
else if(-1 == nread) {
perror("read");
exit(0);
}
else {
//发送数据
write(fd, buff, strlen(buf)+1);
}
}
}
}
}
close(lfd);
close(epfd);
return 0;
}
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。
表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善