IO多路复用
select()/poll()
select()与poll()用法类似,我们以select()为例讲解。select的作用是等待内核多个事件。调用后会阻塞,返回的条件有两种,一种是当其中任意一个或者多个发生之后返回。另一个是超时之后返回。
// 函数原型
int select(int maxfdp1, // 检查的最大fd + 1
fd_set* r_set, // 读事件
fd_set* w_set, // 写事件
fd_set* except_set, // 异常
const struct timeval* timeout); // 超时时间
select()的工作流程是,把需要监控的fd加入fd_set。fd_set是一个bitmap, 理论最大fd值为1024。调用select()阻塞线程,在有事件发生后返回。遍历fd_set找出有事件的sockfd然后处理。
对比下方的三种模型流程来看,select的作用方式与阻塞IO类似。甚至还多了监视sockfd的流程,效率更差。但select的优势在于可以同时处理多个io请求,可以不断的调用select找到被激活的sockfd来处理。
//select操作函数
FD_ZERO(fd_set* fdset) // 初始化
FD_SET(int fd, fd_set* fd_set) // 开始监听
FD_CLR(int fd, fd_set* fd_set) // 取消监听
FD_ISSET(int fd, fd_set* fd_set) // 是否有事件,调用后会把所有未就绪的fd清空,所以下次select时需要吧关心的位再次设置
下面是select示例,这个例子中,先添加了listenfd用于监听链接,在用selelct()检测,当有新的链接加入时,accpet()建立新的sockfd然后通过select()监听读事件。当读到了数据之后,保存数据到用户缓冲区,然后select()监听写事件(不直接写是因为该fd未必可写,可能tcp发送缓冲区满了)。再一次select()检查到可以写之后,把用户缓冲区中内容发送。
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <errno.h>
#define SERVER_PORT 9999
#define BUFF_LENGTH 1024
int main()
{
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERVER_PORT);
if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf("bind error\n");
return -2;
}
if (listen(listenfd, 10) < 0)
{
perror("listen error");
return -3;
}
// 当前fd最大值
int maxfd = listenfd;
// 读写fdset
fd_set rfds, wfds;
// 加入监listenfd
FD_ZERO(&rfds);
FD_SET(listenfd, &rfds);
FD_ZERO(&wfds);
unsigned char buffer[BUFF_LENGTH] = {0};
int recv_len = 0;
// 循环中用于拷贝处理
fd_set rset, wset;
while (1)
{
rset = rfds;
wset = wfds;
// 调用后会把所有未就绪的fd清空,所以下次select时需要把关心的位再次设置
select(maxfd + 1, &rset, &wset, NULL, NULL);
// new client connection
if (FD_ISSET(listenfd, &rset))
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientfd = accept(listenfd, (struct sockaddr*)&client, &len);
if (clientfd > 0)
{
FD_SET(clientfd, &rfds);
}
if (clientfd > maxfd)
{
maxfd = clientfd;
}
printf("new connection: %d \n", clientfd);
}
// 每次循环之处理 读或者写一件事 读写交替
int connfd;
for (connfd = listenfd + 1; connfd <= maxfd; ++connfd)
{
if (FD_ISSET(connfd, &rset))
{
recv_len = recv(connfd, buffer, BUFF_LENGTH, 0);
if (recv_len == 0)
{
close(connfd);
FD_CLR(connfd, &rfds);
printf("client close:%d \n", connfd);
}
else if (recv_len < 0)
{
printf("recv errro. clientfd:%d errno:%d\n", connfd, errno);
FD_CLR(connfd, &rfds);
close(connfd);
}
else
{
printf("recv:%s, recv_length:%d \n", buffer, recv_len);
// 这里设置的下次遍历才处理
FD_SET(connfd, &wfds);
}
}
if (FD_ISSET(connfd, &wset))
{
send(connfd, buffer, recv_len, 0);
FD_CLR(connfd, &wfds);
}
}
}
}
存在的问题
- select单个线程可以监控的fd上限1024
- 检查是遍历fd是否有事件然后处理,fd增加耗时也是线性增加的
- poll相对于select有一定优化但不多。用链表维护,没有数量限制。但是任然存在效率问题。
epoll
epoll是多路复用IO接口select/poll的增强版本。在存在大量并发连接但是只有少量活跃的场景下,epoll能显著提升cpu利用率。
原理
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);
epoll_create创建一个epoll的文件描述符,同时底层创建一个红黑树和一个链表。红黑树管理需要监控的文件描述符,链表保存就绪的文件描述符。
epoll_ctl添加需要监控的文件描述符,将要fd加入红黑树中。
epoll_wait用于获得就绪描述符链表,返回记录据需fd的链表, epoll_wait是会阻塞当前线程,直到有就绪队列或超时。
epoll保证高效需要处理两个问题
- 如何管理大量的fd?
fd增多后,列表这类的数据结构对于查找显然不再适用。使用红黑树管理需要监控的fd,可以保证大量fd管理的增删改效率。 - 数据就绪后如何感知?
linux把一切设计成文件,epoll在底层硬件读写就绪时通过回调函数把对应fd加入就绪队列,同时回调epoll_wait有就绪的fd存在。在调用epoll_wait时把就绪队列拷贝到用户空间。此时epoll_wail获得的就绪队列就是全部需要处理的,省去了select/poll的遍历查找过程。
对比select
- select每次调用fd_set都要拷贝重置,epoll在添加需要监控的fd之后会一直监控
- 获得就绪列表,select需要遍历fd_set来查找时间复杂度O(n), epoll获得的全是就绪的fd,时间复杂度O(1)
epoll的两种模式
epoll有两种工作模式:LT(水平触发)模式和ET(边缘触发)模式。
epoll_wait时会拷贝rdlist,然后把它清空。
如果是LT模型,如果fd仍有数据可读,epoll会把它加回rdlist,下一次epoll_wait依旧可以读。
如果是ET模式清空后不会有其他操作。因此ET模式下需要一直读到空,下次epoll_wait不会提醒返回。
使用 LT 模式,我们可以自由决定每次收取多少字节或何时接收连接,但是可能会导致多次触发;使用 ET 模式,我们必须每次都要将数据收完或必须理解调用 accept 接收连接,其优点是触发次数少。
epoll实例
#include <stdio.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <errno.h>
#include <string.h>
#define EVENT_LENGTH 128
#define BUFF_LENGTH 1024
#define SERVER_PORT 9999
int main()
{
//epoll也是内核去操作文件,需要县创建fd
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int epfd = epoll_create(1); // 早期形容一次性就绪最大数量,现在大于0即可
// 申请监听时间, 自己申请一个时间
struct epoll_event ev, events[EVENT_LENGTH];
// listened边缘触发,有新的才监听
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
printf("listenfd: %d \n", listenfd);
// 开启监听
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERVER_PORT);
if (-1 == bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)))
{
printf("bind error\n");
return -2;
}
if (listen(listenfd, 10) < 0)
{
perror("listen error");
return -3;
}
char rbuffer[BUFF_LENGTH] = {0};
char wbuffer[BUFF_LENGTH] = {0};
int wlen = 0;
int recv_len = 0;
while (1)
{
int nready = epoll_wait(epfd, events, EVENT_LENGTH, 0);
int idx = 0;
for (idx = 0; idx < nready; ++idx)
{
int clientfd = events[idx].data.fd;
if (listenfd == clientfd)
{
// accept
struct sockaddr_in client;
socklen_t len = sizeof(client);
int connfd = accept(listenfd, (struct sockaddr*)&client, &len);
printf("accept : %d \n", connfd);
// 水平触发: 有数据时一直触发,可以一次recv
// 边缘触发: 只收一次,没读到的数据在缓存, 需要循环读直到没有数据可读.更适合大多数场景
// 设置边缘触发
//ev.events = EPOLLIN | EPOLLET;
ev.events = EPOLLIN ;
ev.data.fd = connfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}
else if (events[idx].events & EPOLLIN)
{
recv_len = recv(clientfd, rbuffer, BUFF_LENGTH, 0);
if (recv_len == 0)
{
printf("client close:%d \n", clientfd);
epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
close(clientfd);
}
else if (recv_len < 0)
{
printf("recv errro. clientfd:%d errno:%d\n", clientfd, errno);
ev.events = EPOLLOUT;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
close(clientfd);
}
else
{
printf("recv:%s, recv_length:%d \n", rbuffer, recv_len);
memcpy(wbuffer, rbuffer, recv_len);
wlen = recv_len;
memset(rbuffer, 0, BUFF_LENGTH);
// 设置为可写
ev.events = EPOLLOUT;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
else if (events[idx].events & EPOLLOUT)
{
int sent = send(clientfd, wbuffer, BUFF_LENGTH, 0);
memset(wbuffer, 0, wlen);
printf("send:%s len:%d \n", wbuffer, sent);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
}
}
总结
select / poll为解决了处理监控多个描述符的问题,而epoll则在select和poll上进一步优化。突破了1024的数量限制,同时在获得就绪fd队列时更加高效。
但epoll的高效也不是绝对的,更适用于需要维护大量fd但是同时只是少量活跃。如果监听fd个数少且都活跃,select/poll相反性能可能更好,因为epoll机制涉及很多函数回调。
荐一个零声学院免费教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
TCP/IP,协程,DPDK等技术内容,点击立即学习:
链接