手机网站一键生成app,大学生个体创业的网站建设,门户网站建设方案的公司,做网站构架用什么软件文章目录 概要一、多路复用I/O模型的诞生1.1 多线程或进程方式1.2 通过数组#xff0c;链表等方式保存socket fd#xff0c;不断轮询 二、select三、poll四、epoll五、小结六、参考 概要
在Unix五种I/O模型一文中#xff0c;提到了I/O多路复用模型#xff0c;其在Linux下有… 文章目录 概要一、多路复用I/O模型的诞生1.1 多线程或进程方式1.2 通过数组链表等方式保存socket fd不断轮询 二、select三、poll四、epoll五、小结六、参考 概要
在Unix五种I/O模型一文中提到了I/O多路复用模型其在Linux下有3种实现方式select、poll、epoll本文主要深入介绍下它们各自特点。
事先说明I/O多路复用模型select和poll核心就是【轮询内核I/O事件就绪通知】,epoll的核心是内核I/O事件就绪通知。 多路多个socket连接即多个客户端连接 复用允许内核监听多个socket描述符一旦发现进程指定的一个或多个scoket的I/O事件就绪TCP三次握手成功、可读可写就通知该进程 要想更好的了解最好根据代码来说下面是代码的基本框架
void main(int argc, char **argv)
{int listenfd, connfd;struct sockaddr_in srv_addr;//创建socket套接字if ((listenfd socket(AF_INET, SOCK_STREAM, 0)) -1){printf(create socket error: %s(errno: %d)\n, strerror(errno), errno);return;}//设置绑定地址的内容memset(srv_addr, 0, sizeof(srv_addr));srv_addr.sin_family AF_INET; //ipv4srv_addr.sin_addr.s_addr htonl(INADDR_ANY);//ip 0.0.0.0srv_addr.sin_port htons(8888); //端口//绑定地址if (bind(listenfd, (struct sockaddr *)srv_addr, sizeof(srv_addr)) -1){printf(bind socket error: %s(errno: %d)\n, strerror(errno), errno);return;}//listenif (listen(listenfd, SOMAXCONN) -1) //指定监听的套接字描述符、TCP半连接和全连接队列大小{printf(listen socket error: %s(errno: %d)\n, strerror(errno), errno);return;}//【在这个区域分别使用阻塞多线程selectpollepoll等多种方式实现连接】close(listenfd); //关闭listen socketreturn;
}一、多路复用I/O模型的诞生
之所以诞生多路复用I/O模型肯定是旧的I/O模型无法满足需要了首先回顾下基础的阻塞I/O模型
代码如下 char buff[MAXLNE];int n;struct sockaddr_in cli_addr;socklen_t len sizeof(client);if ((connfd accept(listenfd, (struct sockaddr *)cli_addr, len)) -1){printf(accept socket error: %s(errno: %d)\n, strerror(errno), errno);return;}while(1){n recv(connfd, buff, MAXLNE, 0); //阻塞if (n 0){//加上字符串的尾部以便显示和转发buff[n] \0;printf(recv msg: %s \n, buff);send(connfd, buff, n, 0);}else if (n 0){close(connfd); //关闭client socket连接}else //n-1{printf(recv errno: %d\n, errno);}}此时while在accept API之后那么只能处理一个client并维持长连接。 那么while在accept API之前会如何呢显而易见此时能处理多个client,但只能处理每个client一条消息不能未出长连接。 如果想与多个client维持长连接该如何做呢于是基础阻塞I/O模型有了以下两种方式
多线程或进程通过数组链表等方式保存socket fd不断轮询
1.1 多线程或进程方式
代码如下(以多进程为例) signal(SIGCHLD, sig_child); //注册子进程退出处理函数pid_t child_pid;while(1){struct sockaddr_in cli_addr;socklen_t len sizeof(client);if ((connfd accept(listenfd, (struct sockaddr *)cli_addr, len)) -1){printf(accept socket error: %s(errno: %d)\n, strerror(errno), errno);return;}if ((child_pid fork()) 0) //为每个client派生一个子进程处理{ close(listenfd);str_echo(connfd);exit(0);}}
子进程处理客户端请求函数
void str_echo(int connfd)
{int n;char buff[MAXLNE];again:while ((n recv(connfd, buf, MAXLINE)) 0){printf(recv msg: %s \n, buff);send(sockfd, buf, n);}if (n 0 errno EINTR){goto again;} else if (n 0){close(connfd); //结束return; //退出进程}else //n-1{printf(recv errno: %d\n, errno);}
}子进程退出函数
void sig_child(int signo)
{pid_t pid;int stat;//等待所有子进程退出while ((pid waitpid(-1, stat, WNOHANG)) 0)printf(child %d exit\n, pid);return;
}但是这方式有个弊端每个client由一个进程/线程去处理系统开销相当大很难维持大量客户端。
1.2 通过数组链表等方式保存socket fd不断轮询
这种模式是将客户端socket fd通过数组链表等方式保存下来然后不断地轮询如果客户端太多轮询也是很慢的。
伪代码如下
int client_fds[FD_SETSIZE];
int sockfd;
while(1)
{int connfd accept() //阻塞for (i 0; i FD_SETSIZE; i) { if (client[i] 0) {client_fds[i] connfd;break;}}for (i 0; i FD_SETSIZE; i) {if ((sockfd client_fds[i]) 0){continue; }n recv(sockfd, buf, MAXLINE)//阻塞if(n 0){printf(recv msg: %s \n, buff);send(sockfd, buf, n);}else if (n 0 ){printf(recv fd:%d, errno: %d\n, sockfd, errno);} else// n 0{close(connfd); //结束client_fds[i] 0; //标记一下} }
}
可以看到通过client_fds数组将客户端连接的描述符保存下来后续对其进行轮询来达到与多个client维持长连接的目的。 但acceptrecv等函数都是阻塞的如果此时I/O事件比如accept的TCP三次握手成功recv的可读send的可写没就绪那岂不永远卡死了。所以我们需要一种机制告诉我们client_fds数组和监听socket listenfd中哪些socket有I/O就绪事件基于此多路复用I/O模型诞生了没错该模型本质就是告诉进程哪些socket有I/O就绪事件然后我们基于此去轮询那些有I/O就绪事件的scoket,这样就不会卡住了。
PS:这种方式是没有实际应用的主要是为了引出多路复用I/O模型。
那select、poll、epoll是如何告诉进程哪些socket有I/O就绪事件呢下面依次探究下吧。
二、select
select api函数(还有个pselect函数不是很常用不过二者核心逻辑是一样的)
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);参数 nfds最大的文件描述符数量加1的值 readfds监听可读集合 writefds监听可写集合 exceptfds监听异常集合 timeout超时时间 返回值 大于0,表示I/O就绪事件的socket描述符个数 等于0表示没有描述符有状态变化并且调用超时 小于0表示出错此时全局变量errno保存错误码。 我们使用Glibc库select或pselect函数时都会走Linux内核do_select函数可以看到本质就是轮询readfds、writefds、exceptfds这三个集合每次调用会轮询两次
第一次会将当前进程(本质是I/O事件就绪时的回调函数)加入到readfds、writefds、exceptfds这三个集合中socket的等待队列中socket有I/O事件就会触发回调函数然后就通过poll_schedule_timeout函数挂起一旦有某个socket描述符I/O事件就绪会立即通知进程就会开始第二次轮询本次轮询会确定readfds、writefds、exceptfds这三个集合中到底是哪些socket描述符有就绪的I/O事件明明两次为啥会有三呢这里主要是在do_select最后会调用一次poll_freewait函数该函数会将当前进程从readfds、writefds、exceptfds这三个集合中socket的等待队列中移除。
从上面描述就可以看出select主要工作维护所有socket的监听添加【步骤1】和移除【步骤三】、判定是否有I/O事件就绪的socket。 该方式虽相比基于阻塞I/O的多进程/线程方式能更便捷的实现与多个客户端维持长连接了但缺点多多
由于无法准确识别哪些socket描述符I/O事件就绪所以会进行无差别轮询时间复杂度O(N)Linux下readfds、writefds、exceptfds这三个集合大小默认1024所以维持长连接的客户端数量是有限的源码如下 typedef __kernel_fd_set fd_set __kernel_fd_set
#undef __FD_SETSIZE
#define __FD_SETSIZE 1024typedef struct {unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;可以看到无论32位还是64位操作系统下fds_bits大小都是1024位注意fd_set集合是通过位运算标识第几个scoket描述符有无I/O事件。
每次调用select函数都需要把所有fd_set从用户空间拷贝到内核空间如果fd_set比较大对性能影响就非常大
优点
相比基于阻塞I/O的多进程/线程方式更便捷的实现与多个客户端维持长连接相比非阻塞I/O主动轮询socket是否有I/O事件调整为等待内核通知这样一次系统调用就实现多个client事件的管理更有优势。
综合来看相比基于阻塞I/O的多进程/线程方式优势并不大。
三、poll
poll api函数
struct pollfd {int fd; //要监听的文件描述符short events; //要监听的事件short revents; //事件结果
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);参数 fds这是一个数组每一个数组元素表示要监听的文件描述符以及相应的事件 nfds数组的个数 timeout超时时间 返回值 大于0表示结构体数组fds中有fd描述符的状态发生变化或可以读取、或可以写入、或出错。并且返回的值表示这些状态有变化的socket描述符的总数量此时可以对fds数组进行遍历以寻找那些revents不空的描述符然后判断这个里面有哪些事件以读取数据。 等于0表示没有描述符有状态变化并且调用超时。 小于0此时表示有错误发生此时全局变量errno保存错误码。 我们使用Glibc库poll函数时会走Linux内核do_sys_poll和do_poll函数可以看到
do_sys_poll函数会将fds数组转化为struct poll_list链表根据代码可知结构如下图 do_poll函数被 do_sys_poll调用其核心逻辑与do_select差不多每次被调用会轮询两次 1第一次会将当前进程(本质是I/O事件就绪时的回调函数)加入到struct poll_list链表中socket的等待队列中socket有I/O事件就会触发回调函数然后就通过poll_schedule_timeout函数挂起 2一旦有某个socket描述符I/O事件就绪会立即通知进程就会开始第二次轮询本次轮询会确定struct poll_list链表中到底是哪些socket描述符有就绪的I/O事件。do_sys_poll调用do_poll函数结束后还有一次循环即poll_freewait函数此时会将当前进程从struct poll_list链表中socket的等待队列中移除。 从上面描述就可以看出相比select,唯一改进的地方在于没有了最大连接数的限制但相应的也需要关注随着客户端连接数增加轮询的效率和会急剧下降的问题。 四、epoll
epoll全名event poll,其api函数有
epoll_create创建一个epoll实例struct eventpoll其实其返回一个int值可以称之为epoll描述符Linux内核会管理该值与具体epoll实例的映射关系。对应内核源码do_epoll_create
//epoll 结构体即epoll实例
struct eventpoll {/* 等待队列头被sys_epoll_wait使用 */wait_queue_head_t wq;/* 保准准备就绪的文件描述符的一个链表 */struct list_head rdllist;/* 红黑树节点epoll使用红黑树存储事件信息 */struct rb_root rbr;...
};
int epoll_create(int size);参数 size:一个int值实际没有任何用只要不小于等于0即可 返回值 小于0表示错误此时全局变量errno保存错误码 epoll_ctl添加、删除、修改事件。对应内核源码do_epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);参数 epfdepoll_create函数的返回值即epoll描述符 op事件类型EPOLL_CTL_ADD(添加事件)、EPOLL_CTL_MOD(修改事件)、EPOLL_CTL_DEL(删除事件) fd: socket描述符 event告诉内核需要监听哪个事件。 返回值 小于0表示错误此时全局变量errno保存错误码 epoll_wait等待I/O事件。对应内核源码do_epoll_wait
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);参数 epfdepoll_create函数的返回值即epoll描述符 events表示准备就绪的事件数组 maxevents: 要等待的最大事件数 timeout超时时间。 返回值 大于0表示事件就绪的socket个数。 等于0表示没有描述符有状态变化并且调用超时。 小于0此时表示有错误发生此时全局变量errno保存错误码。 根据api就可以观察出相比select和pollepoll拆成了三个实际上是两个epoll_ctl和epoll_wait。即select和poll是将维护监听scoket描述符和等待I/O事件合为一体的epoll拆开了epoll_ctl来维护监听scoket描述符epoll_wait来等待I/O事件。
为什么要这样做呢在分析select和epoll缺点时其实主要集中两点 1轮询每次调用select或poll时都需要将进程加入到所有socket的等待队列中等到socket有I/O事件立马唤醒进程此时会轮询整个socket列表以确定哪些socket有I/O事件最后会将进程从每个socket等队列中移除。这里涉及到对socket列表的三次轮询随着socket列表的增加会造成性能急剧下降的。 2复制每次调用select或poll时都要将整个socket列表从用户区复制到内核区也是有一定开销的。 知道了缺点该如何解决呢 1针对每次调用需要将将进程加入到所有socket的等待队列中最后将进程从每个socket等队列中移除的操作这部分交由epoll_ctl处理按需对某个socket进行EPOLL_CTL_ADD、EPOLL_CTL_MOD、EPOLL_CTL_DEL操作; 2针对每次调用都要将整个socket列表从用户区复制到内核区的问题这部分交由epoll_create创建的epoll实例替代epoll_ctl通过EPOLL_CTL_ADD操作会被插入到epoll实例的红黑树中也就是说只会被复制一次 3针对进程被唤醒后还需要轮询整个socket列表以确定哪些socket有I/O事件的问题即不知道哪些socket的I/O事件就绪只能一个个遍历调整成每个socket有I/O事件时会将该socket加入到epoll实例的rdllink双向链表中进程调用epoll_wait时只需判断rdllink双向链表长度即可大于0立即返回等于0就挂起进程等待被某个socketI/O事件唤醒。 所以说epoll通过对select/poll功能的拆分解决了前两者的缺点相对于前两者优点如下
没有最大并发连接的限制当然了还受操作系统限制比如资源、配置等等性能高没有了轮询不会随着socket数量的增加而导致性能下降。epoll支持边缘触发EPOLLET和水平触发EPOLLLT前两者仅支持水平触发。
缺点 目前缺点就是进程去读写I/O事件就绪的socket时还需要将数据从内核区复制到用户区当然了select/poll也有该问题这一点需要异步I/O去解决了。目前epoll是够用的Nginx,Redis都是基于epoll的足以应对处理C10K,C100K问题。 LT(level triggered) 是 缺省 的工作方式 并且同时支持 block 和 no-block I/O。 在这种做法中内核告诉你一个文件描述符是否就绪了然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作下次内核还会继续通知你的所以这种模式编程出错误可能性要小一点。 select/poll 都是这种模型的代表。 ET(edge-triggered) 是高速工作方式 只支持 no-block I/O 。在这种模式下当描述符从未就绪变为就绪时内核通过 epoll 告诉你然后它会假设你知道文件描述符已经就绪并且不会再为那个文件描述符发送更多的就绪通知直到你做了某些操作导致那个文件描述符不再为就绪状态了 ( 比如你在发送接收或者接收请求或者发送接收的数据少于一定量时导致了一个 EWOULDBLOCK 错误。但是请注意如果一直不对这个 socket做 IO 操作 ( 从而使它再次变成未就绪 ) 内核不会发送更多的通知 (only once), 不过在 TCP 协议中 ET 模式的加速效用仍需要更多的 benchmark 确认。 Epoll 工作在 ET 模式的时候必须使用非阻塞I/O以避免由于一个文件句柄的阻塞读 / 阻塞写操作把处理多个文件描述符的任务饿死。 五、小结 从整体来看epoll的实现性能是比select/poll更好的但是在连接数少并且连接都十分活跃的情况下select和poll的性能可能比epoll好毕竟epoll的通知机制需要很多函数回调一般情况下选epoll就对了。 本人研究Redis相关源码时观察到其在Linux下就是通过epollEPOLLET非阻塞I/OReactor设计模式组合来处理I/O事件是其高性能的一个关键点。 六、参考
1]:Linux下实现单客户连接的tcp服务端 2]:从网络I/O模型到Netty先深入了解下I/O多路复用 3]:epoll的本质 4]:深入了解epoll模型 5]:Linux epoll内核源码剖析