服务器网络模型(二)IO多路复用

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原理图
epoll保证高效需要处理两个问题

  1. 如何管理大量的fd?
    fd增多后,列表这类的数据结构对于查找显然不再适用。使用红黑树管理需要监控的fd,可以保证大量fd管理的增删改效率。
  2. 数据就绪后如何感知?
    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等技术内容,点击立即学习:
链接

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值