【网络编程】poll和epoll服务器的设计

发布时间:2023年12月21日

文章目录

  • 前言
  • 一、poll
  • 二、epoll
    • 1.epoll初识
    • 2.epoll服务器的设计
    • 3.epoll的工作原理
    • 4.epoll的优点
    • 5.epoll的工作模式


前言

poll和select一样,也是一种linux中的多路转接的方案。而poll解决了select的两个问题:

1.select的文件描述符有上限的问题。

2.每次调用都要重新设置需要关心的文件描述符。


一、poll

首先我们认识一下poll的接口:

?第一个参数是一个结构体,我们可以将这个参数想象为一个new/malloc出来的动态数组,这个数组中每个元素的类型是一个结构体,结构体中有文件描述符,监听的事件集合,返回的事件集合三部分。

fds是一个poll函数监听的结构列表. 每一个元素中, 包含了三部分内容: 文件描述符, 监听的事件集合, 返回的事件集合.

第二个参数是刚刚那个fds数组的长度。

第三个参数和我们学select中的timeval结构体差不多,时间单位是ms,当等于0时表示非阻塞等待,小于0表示阻塞式等待,大于0表示前面阻塞式等待,时间到了非阻塞返回一次。

这个函数的返回值和select一模一样,大于0表示有几个文件描述符的事件就绪了,等于0表示超时返回,小于0表示poll函数出现错误。

所以poll函数中,输入的时候看fd和events,输出的时候看fd和revents,通过将事件分为输出事件和返回事件来解决select中每次需要对文件描述符集重新设定的操作。

下面我们看看eventsrevents的取值都有哪些:

?虽然很多实际上我们能用到的很少,这些宏值实际上对应的在events和revents中的位图,只要我们设置在底层位图中就会将某个事件标记为1,我们讲几个常用的:POLLIN事件就是看哪些的文件描述符可以读了或者用户告诉操作系统帮我们关心某个文件描述符的读事件。POLLOUT事件就是看哪些的文件描述符可以写了或者用户告诉操作系统帮我们关心某个文件描述符的写事件。

POLLPRI与TCP的紧急指针相对应。

下面我们实现poll服务器:

首先我们创建poll所需要的struct?pollfd类型数组,然后对数组初始化,这里我们可以写成扩容版数组,但是为了演示我们就用2018这个定值。

 void initServer()
        {
            _listensock = Sock::createSock();
            if (_listensock == -1)
            {
                logMessage(NORMAL,"createSock error");
                return;
            }
            Sock::Bind(_listensock,_port);
            Sock::Listen(_listensock);
            _rfds = new struct pollfd[max_num];
            for (int i = 0;i<max_num;i++)
            {
                _rfds[i].fd = defaultfd;
                _rfds[i].events = 0;
                _rfds[i].revents = 0;
            }
            _rfds[0].fd = _listensock;
            _rfds[0].events = POLLIN;
        }

初始化服务器还是需要遍历数组将数组内的文件描述符设置为非法状态,并且事件初始化。然后我们将listensock放到数组第一个位置并且让系统帮我们监视listen文件描述符的读事件。

void start()
        {
            int timeout = -1;
            for (;;)
            {
                int n = poll(_rfds,max_num,timeout);
                switch (n)
                {
                    case 0:
                        logMessage(NORMAL,"time out.....");
                        break;
                    case -1:
                        logMessage(WARNING,"select error,code: %d,err string: %s",errno,strerror(errno));
                        break;
                    default:
                        //说明有事件就绪了
                        //logMessage(NORMAL,"get a new link");
                        HanderEvent();
                        break;
                }
            }
        }

启动的时候我们不需要再像select那样每次先重新设置文件描述符集并且把数组中合法fd读到文件描述符集中并且select还需要知道最大文件描述符,这些在poll中统统不要,只需要将数组传进去系统会帮我们管理,我们设置的时间为-1表示阻塞式监视。

 void HanderEvent()
        {
            for (int i = 0;i<max_num;i++)
            {
                //过滤掉非法的文件描述符
                if (_rfds[i].fd == defaultfd) 
                    continue;
                //过滤掉没有设置读事件的文件描述符
                if (!(_rfds[i].events & POLLIN)) 
                    continue;
                //如果是listensock事件就绪,就去监听新连接获取文件描述符,如果不是listensock事件,那么就是普通的IO事件就绪了 
                if (_rfds[i].fd == _listensock && (_rfds[i].revents & POLLIN))
                {
                    Accepter(_listensock);
                }
                else if ((_rfds[i].revents & POLLIN))
                {
                    Recver(i);
                }
                else 
                {

                }
            }
        }

在hander函数中,因为我们目前演示的服务器只涉及读数据,所以我们先过滤数组中非法的文件描述符,然后再过滤不关心读事件的文件描述符,然后判断listensock文件描述符的读事件是否就绪,如果就绪了就去执行监听新连接的函数,如果是其他文件描述符的读事件就绪,那么就进行数据读取和发送响应的函数。

void Accepter(int listensock)
        {
            // listensock必然就绪
            std::string clientip;
            uint16_t clientport = 0;
            int sock = Sock::Accept(listensock, &clientip, clientport);
            if (sock < 0)
            {
                return;
            }
            logMessage(NORMAL, "accept success [%s:%d]", clientip.c_str(), clientport);
            // 开始进行服务器的处理逻辑
            // 将accept返回的文件描述符放到自己管理的数组中,本质就是放到了select管理的位图中
            int i = 0;
            for (i = 0; i < max_num; i++)
            {
                if (_rfds[i].fd != defaultfd)
                {
                    continue;
                }
                else
                {
                    break;
                }
            }
            if (i == max_num)
            {
                logMessage(WARNING, "server is full ,please wait");
                close(sock);
            }
            else
            {
                _rfds[i].fd = sock;
                _rfds[i].events = POLLIN;
                _rfds[i].revents = 0;
            }
            print();
        }
        void Recver(int pos)
        {
            //注意:这样的读取有问题,由于没有定协议所以我们不能确定是否能读取一个完整的报文,并且还有序列化反序列化操作...
            //由于我们只做演示所以不再定协议,在TCP服务器定制的协议大家可以看看
            char buffer[1024];
            ssize_t s = recv(_rfds[pos].fd,buffer,sizeof(buffer)-1,0);
            if (s>0)
            {
                buffer[s] = 0;
                logMessage(NORMAL,"client# %s",buffer);
            }
            else if (s == 0)
            {
                //对方关闭文件描述符,我们也要关闭并且下次不让select关心这个文件描述符了
                close(_rfds[pos].fd);
                _rfds[pos].fd = defaultfd;
                _rfds[pos].events = 0;
                _rfds[pos].revents = 0;
                logMessage(NORMAL,"client quit");
            }
            else 
            {
                //读取失败,关闭文件描述符
                close(_rfds[pos].fd);
                _rfds[pos].fd = defaultfd;
                _rfds[pos].events = 0;
                _rfds[pos].revents = 0;
                logMessage(ERROR,"client quit: %s",strerror(errno));
            }
            //2.处理 request
            std::string response = func(buffer);

            //3.返回response
            write(_rfds[pos].fd,response.c_str(),response.size());
        }

这两个函数只需要记得当读取失败的时候需要重置数组中文件描述符结构体。

下面我们运行起来:

可以看到也是没问题的。?

poll的优缺点:

poll 的优点:
不同与 select 使用三个位图来表示三个 fdset 的方式, poll 使用一个 pollfd 的指针实现 .
pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式. 接口使用比
select更方便.
poll并没有最大数量限制 (但是数量过大后性能也是会下降)
poll 的缺点:
poll 中监听的文件描述符数目增多时:
和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符.
每次调用poll都需要把大量的pollfd结构从用户态拷贝到内核中.
同时连接的大量客户端在一时刻可能只有很少的处于就绪状态, 因此随着监视的描述符数量的增长, 其效率也会线性下降.

二、epoll

1.epoll初识:

epoll可以理解为是增强版的poll,按照man手册的说法:epoll是为了处理大批量句柄而作了改进的poll.

epoll有三个系统调用:

epoll_create:
int epoll_create(int size);
创建一个 epoll 的句柄 .
自从linux2.6.8之后,size参数是被忽略的,但是我们必须传一个大于0的数字
用完之后, 必须调用close()关闭

如果创建成功了会给我们返回一个文件描述符,如果失败返回-1。实际上epoll_create就是创建一个epoll模型。

epoll_ctl:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll 的事件注册函数 .
它不同于select()是在监听事件时告诉内核要监听什么类型的事件, 而是在这里先注册要监听的事件类型.
第一个参数是epoll_create()的返回值(epoll的句柄).
第二个参数表示动作,用三个宏来表示,分别是增加,修改,删除。比如我们想让操作系统帮我们新增一个需要检视的文件描述符,这个时候选项就是增加的选项。

第二个参数的取值:

EPOLL_CTL_ADD :注册新的fd到epfd中;

EPOLL_CTL_MOD :修改已经注册的fd的监听事件;

EPOLL_CTL_DEL :从epfd中删除一个fd
如下图:

?第三个参数是需要监听的fd.

第四个参数是告诉内核需要监听什么事,与poll中struct pollfd类似。下面我们看看epoll _event的结构:

?

?如上图:uint32_t events就是宏的集合,如果文件描述符是读事件那么就会设置为EPOLLIN,data由用户定义,可以是指针可以是文件描述符等。

如果函数成功则返回0,否则返回-1.

epoll_wait:
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
收集在 epoll 监控的事件中已经发送的事件 .
参数events是分配好的epoll_event结构体数组.
epoll将会把发生的事件赋值到events数组中 (events不可以是空指针,内核只负责把数据复制到这个events数组中,不会去帮助我们在用户态中分配内存).
maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size.
参数timeout是超时时间 (毫秒,0会立即返回,-1是永久阻塞).
如果函数调用成功,返回对应I/O上已准备好的文件描述符数目,如返回0表示已超时, 返回小于0表示函数失败.
此函数的返回值和select/poll一模一样,0表示超时,小于0表示错误,大于0表示有几个文件描述符事件就绪。

2.epoll服务器的设计

首先作为一款服务器,端口号肯定是必须要有的,并且我们还需要监听套接字和epoll对象模型,这个epoll对象模型我们讲过,实际上就是一个int变量。

void initServer()
        {
            //1.创建socket
            _listensock = Sock::createSock();
            Sock::Bind(_listensock,_port);
            Sock::Listen(_listensock);
            //2.创建epoll模型
            _epfd = epoll_create(size);
            if (_epfd < 0)
            {
                logMessage(FATAL,"epoll create error: %s",strerror(errno));
                exit(EPOLL_CREATE_ERR);
            }
            //3.添加listensock到epoll中
            struct epoll_event ev;
            ev.events = EPOLLIN;
            ev.data.fd = _listensock;  //当事件就绪被重新捞取上来的时候我们要知道是哪一个fd就绪了
            epoll_ctl(_epfd,EPOLL_CTL_ADD,_listensock,&ev);
            //4.申请就绪事件的空间
            _revs = new struct epoll_event[_num];
            logMessage(NORMAL,"init server success");
        }

我们在初始化服务器的时候首先创建套接字然后创建epoll模型(创建epoll模型的参数只要大于0即可,我们添加一个变量size用来使用),然后我们需要将监听套接字添加到epoll中,epoll_ctl的参数我们已经讲过了,第一个就是epoll创建成功返回的句柄,第二个参数是选项可以是添加也可以是删除或者修改,第三个参数就是要添加的文件描述符,第四个参数是对此文件描述符事件的设置,要设置监听套接字的读事件,首先创建一个struct epoll_event对象,然后把事件设置为读事件,把监听套接字放入data中的fd,这一步操作非常重要,因为我们后续事件就绪捞出事件时需要知道这个事件是哪个文件描述符。设置成功后我们还需要创建一个struct epoll_event的数组(这个数组用来存放所有的struct epoll_event),有了这个数组后我们就可以开辟空间了,这里的空间大家可以改为扩容版,我们为了演示就用了固定大小:

void start()
        {
            int timeout = -1;
            for (;;)
            {
                int n = epoll_wait(_epfd,_revs,_num,timeout);
                switch (n)
                {
                    case 0:
                        logMessage(NORMAL,"timeout......");
                        break;
                    case -1:
                        logMessage(WARNING,"epoll_wait error: %s",strerror(errno));
                        break;
                    default:
                        logMessage(NORMAL,"have event ready");
                        HanderEvent(n);
                        break;
                }
            }
        }

?我们启动服务器的时候就进行epoll_wait,这个函数与select和epoll的返回值一模一样,第一个参数是epoll创建的句柄,第二个参数是struct epoll_events数组,这个数组epoll会帮我们管理,数组大小就是我们前面定义的num,时间设置为阻塞式。当返回值大于0说明有几个事件就绪了,我们就去调用事件处理函数:

 void HanderEvent(int readynum)
        {
            for (int i = 0;i<readynum;i++)
            {
                int sock = _revs[i].data.fd;
                uint32_t events = _revs[i].events;
                if (sock == _listensock && (events & EPOLLIN))
                {
                    //listen读事件就绪,获取新连接
                    std::string clientip;
                    uint16_t clientport;
                    int fd = Sock::Accept(_listensock,&clientip,clientport);
                    if (fd<0)
                    {
                       logMessage(WARNING,"accept error"); 
                       continue;
                    }
                    //获取fd成功不可以直接读取,要放入epoll
                    struct epoll_event ev;
                    ev.events = EPOLLIN;
                    ev.data.fd = fd;
                    epoll_ctl(_epfd,EPOLL_CTL_ADD,fd,&ev);
                     
                }
                else if ((events & EPOLLIN))
                {
                    char buffer[1024];
                    //普通读事件就绪
                    int n = recv(sock,buffer,sizeof(buffer)-1,0);
                    if (n>0)
                    {
                        buffer[n] = 0;
                        logMessage(NORMAL,"client# %s",buffer); 
                        std::string response = _func(buffer);
                        send(sock,response.c_str(),response.size(),0);
                    }
                    else if (n==0)
                    {
                        epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
                        close(sock);
                        logMessage(NORMAL,"client quit"); 
                    }
                    else 
                    {
                        epoll_ctl(_epfd, EPOLL_CTL_DEL, sock, nullptr);
                        close(sock);
                        logMessage(ERROR,"recv error: %s",strerror(errno)); 
                    }
                }
                else 
                {

                }
            }
        }

首先遍历事件数组,然后记录每个结构体的fd和事件,当是监听套接字并且设置了读事件,那么我们就监听新连接,监听成功后要将用于通信的套接字设置进epoll中。如果不是监听套接字并且设置了读事件那么就是普通事件了,我们需要读数据,注意:我们在select,poll,epoll中演示的读取和发送都是有问题的,不仅仅要保证数据是一个完整的报文,而且还得考虑一次读完数据或者一次读不完数据的情况,并且还有序列化和反序列化等。我们只是演示多路转接对于事件的推送机制。

数据读成功后我们就发送,如果客户端关闭了文件描述符或者recv函数失败,则先将此文件描述符在epoll中删除,然后再关闭文件描述符。

下面我们运行起来:

?可以看到是没问题的,这就是我们epoll服务器的测试代码。

3.epoll的工作原理

首先epoll的底层有红黑树和双向链表,当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体中有两个成员与epoll的使用方式密切相关:

struct eventpoll{ 
 .... 
 /*红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件*/ 
 struct rb_root rbr; 
 /*双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件*/ 
 struct list_head rdlist; 
 .... 
};

?红黑树用来存储所有添加到epoll中的需要监控的事件,而双向链表的作用是当用户调用epoll_wait时给用户返回事件就绪的节点。

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件.
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度).
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当响应的事件发生时会调用这个回调方法.
这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中.
在epoll中,对于每一个事件,都会建立一个epitem结构体。
struct epitem{ 
 struct rb_node rbn;//红黑树节点 
 struct list_head rdllink;//双向链表节点 
 struct epoll_filefd ffd; //事件句柄信息 
 struct eventpoll *ep; //指向其所属的eventpoll对象 
 struct epoll_event event; //期待发生的事件类型 
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可.
如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户. 这个操作的时间复杂度是O(1).
总结一下, epoll的使用过程就是三部曲:
调用epoll_create创建一个epoll句柄;
调用epoll_ctl, 将要监控的文件描述符进行注册;
调用epoll_wait, 等待文件描述符就绪

4.epoll的优点

epoll 的优点 select 的缺点对应
接口使用方便: 虽然拆分成了三个函数, 但是反而使用起来更方便高效. 不需要每次循环都设置关注的文件描述符, 也做到了输入输出参数分离开来。
数据拷贝轻量: 只在合适的时候调用 EPOLL_CTL_ADD 将文件描述符结构拷贝到内核中, 这个操作并不频繁(而select/poll都是每次循环都要进行拷贝)
事件回调机制: 避免使用遍历, 而是使用回调函数的方式, 将就绪的文件描述符结构加入到就绪队列中, epoll_wait 返回直接访问就绪队列就知道哪些文件描述符就绪. 这个操作时间复杂度O(1). 即使文件描述符数目很多, 效率也不会受到影响.
没有数量限制: 文件描述符数目无上限

注意:有种说法是:epoll中使用了内存映射机制

内存映射机制: 内核直接将就绪队列通过mmap的方式映射到用户态. 避免了拷贝内存这样的额外性能开销。
这种说法是不准确的 . 我们定义的 struct epoll_event 是我们在用户空间中分配好的内存 . 势必还是需要将内核的数据拷贝到这个用户空间的内存中的。

5.epoll的工作模式

例子:

我们已经把一个tcp socket添加到epoll描述符,这个时候socket的另一端被写入了2KB的数据
调用epoll_wait,并且它会返回. 说明它已经准备好读取操作,然后调用read, 只读取了1KB的数据
继续调用epoll_wait......

1.LT(水平触发)

epoll 默认状态下就是 LT 工作模式 .
当epoll检测到socket上事件就绪的时候, 可以不立刻进行处理. 或者只处理一部分.
如上面的例子, 由于只读了1K数据, 缓冲区中还剩1K数据, 在第二次调用 epoll_wait 时, epoll_wait
仍然会立刻返回并通知socket读事件就绪.
直到缓冲区上所有的数据都被处理完, epoll_wait 才不会立刻返回.
支持阻塞读写和非阻塞读写。
我们之前的select和poll都是水平触发模式,也就是说如果你没将就绪的事件中的数据处理完就会一直提醒你事件就绪。

2.ET(边缘触发)

如果我们在第 1 步将 socket 添加到 epoll 描述符的时候使用了 EPOLLET 标志 , epoll 进入 ET 工作模式 .
当epoll检测到socket上事件就绪时, 必须立刻处理.
如上面的例子, 虽然只读了1K的数据, 缓冲区还剩1K的数据, 在第二次调用 epoll_wait 的时候,
epoll_wait 不会再返回没读完的事件就绪.
也就是说, ET模式下, 文件描述符上的事件就绪后, 只有一次处理机会.
ET的性能比LT性能更高( epoll_wait 返回的次数少了很多). Nginx默认采用ET模式使用epoll.
只支持非阻塞的读写
select poll 其实也是工作在 LT 模式下 . epoll 既可以支持 LT, 也可以支持 ET
对于ET模式,如果通知你一次没有处理数据,那么只有当数据增加或减少时ET才会再通知你一次。
3. 对比 LT ET
LT epoll 的默认行为 . 使用 ET 能够减少 epoll 触发的次数 . 但是代价就是强逼着程序猿一次响应就绪过程中就把所有的数据都处理完.
相当于一个文件描述符就绪之后 , 不会反复被提示就绪 , 看起来就比 LT 更高效一些 . 但是在 LT 情况下如果也能做到
每次就绪的文件描述符都立刻处理 , 不让这个就绪被重复提示的话 , 其实性能也是一样的 .
另一方面 , ET 的代码复杂程度更高了。
4.理解 ET 模式和非阻塞文件描述符
使用 ET 模式的 epoll, 需要将文件描述设置为非阻塞. 这个不是接口上的要求, 而是 "工程实践" 上的要求.
假设这样的场景: 服务器接受到一个10k的请求, 会向客户端返回一个应答数据. 如果客户端收不到应答, 不会发送第二个10k请求。
如果服务端写的代码是阻塞式的read, 并且一次只 read 1k 数据的话(read不能保证一次就把所有的数据都读出来, 参考 man 手册的说明, 可能被信号打断), 剩下的9k数据就会待在缓冲区中。此时由于 epoll 是ET模式, 并不会认为文件描述符读就绪. epoll_wait 就不会再次返回. 剩下的 9k 数据会一直在缓冲区中. 直到下一次客户端再给服务器写数据. epoll_wait 才能返回。 但是问题来了:只有服务器全部读取完数据才会给客户端发送确认应答,客户端收到服务器全部读取完毕的确认应答后才会发送新的数据,这就产生了鸡生蛋,蛋生鸡的问题。
所以 , 为了解决上述问题 ( 阻塞 read 不一定能一下把完整的请求读完 ), 于是就可以使用非阻塞轮训的方式来读缓冲区 , 保证一定能把完整的请求都读出来.
而如果是 LT 没这个问题 . 只要缓冲区中的数据没读完 , 就能够让 epoll_wait 返回文件描述符读就绪
当然我们也可以用一个更简单的方式理解:首先ET模式只通知一次,所以这就强迫程序员必须一次将数据全部取走,而要想一次取走全部数据就必须要循环式读取,只有循环式读取读取不到数据了才说明把缓冲区的数据取完了,如果这个时候我们的文件描述符是阻塞式读取,那么一旦读取不到数据了就会阻塞住(阻塞式读取如果没有数据则阻塞等待数据就绪),一旦阻塞就完蛋了,因为多乱转接是单进程的,这就导致整个进程都阻塞住了,所以为了解决这个问题必须将文件描述符设置为非阻塞读取。(还记得我们以前是如何保证读取完数据的吗?我们当时定制了协议,数据前面有数据长度的字段,循环读取数据一旦读取的数据大小是一个完整报文的长度我们就会退出循环)
5. epoll 的使用场景
epoll的高性能, 是有一定的特定场景的. 如果场景选择的不适宜, epoll的性能可能适得其反.
对于多连接, 且多连接中只有一部分连接比较活跃时, 比较适合使用epoll.
例如, 典型的一个需要处理上万个客户端的服务器, 例如各种互联网APP的入口服务器, 这样的服务器就很适合epoll.
如果只是系统内部, 服务器和服务器之间进行通信, 只有少数的几个连接, 这种情况下用epoll就并不合适. 具体要根据需求和场景特点来决定使用哪种IO模型。
文章来源:https://blog.csdn.net/Sxy_wspsby/article/details/132122662
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。