epoll实现多路转接¶
约 7966 个字 775 行代码 1 张图片 预计阅读时间 36 分钟
本篇介绍¶
在前面一节已经介绍了select和poll实现多路转接,但是select和poll都存在一些缺陷。而二者最大的缺陷就是都需要内核涉及到遍历操作。所以,为了尽可能减少内核的遍历,就需要用到epoll实现多路转接
epoll接口介绍¶
要使用epoll实现多路转接,需要经过三个步骤:
- 创建
epoll模型 - 设置需要关心的文件描述符和对应事件
- 注册关心的文件描述符和事件
根据这三个步骤,分别使用三个不同的接口:
创建epoll模型
在Linux中,要使用epoll实现多路转接,需要使用epoll_create函数创建一个epoll模型,该函数声明如下:
| C | |
|---|---|
1 | |
尽管该函数存在一个参数,但是在2.6.8内核版本后,该参数已经被忽略,并且内核会使用一个默认值来代替,所以在使用时,该参数可以设置为任意值,一般设置为128或者256
Note
需要注意,尽管size可以设置为任意值,但是必须要保证size大于0
该函数会返回创建的epoll模型对应的文件描述符
设置需要关心的文件描述符和对应事件
调用epoll_create函数创建好epoll模型后,需要使用epoll_ctl函数将需要关心的文件描述符和对应的事件添加到epoll模型中,该函数声明如下:
| C | |
|---|---|
1 | |
epoll模型对应的文件描述符,第二个参数表示操作类型,该参数有三种类型: EPOLL_CTL_ADD:将指定的文件描述符添加到epoll模型中EPOLL_CTL_MOD:修改指定文件描述符对应的事件EPOLL_CTL_DEL:将指定文件描述符从指定的epoll模型中删除
第三个参数表示需要关心的文件描述符,第四个参数表示需要关心的文件描述符对应的事件,该结构定义如下:
| C | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
在epoll_event结构中,第一个字段表示文件描述符对应的事件,有下面的几种类型:
EPOLLIN:表示关心文件描述符的读事件EPOLLOUT:表示关心文件描述符的写事件EPOLLET:表示以边沿触发的方式进行事件通知EPOLLONESHOT:表示文件描述符只能触发一次事件EPOLLRDHUP:表示文件描述符对应的连接被对方关闭EPOLLPRI:表示文件描述符对应的连接有紧急数据可读EPOLLERR:表示文件描述符对应的连接发生错误EPOLLHUP:表示文件描述符对应的连接被挂断
第二个字段表示文件描述符对应的用户数据,一般使用fd字段来表示文件描述符
注册关心的文件描述符和事件
在调用epoll_ctl函数将需要关心的文件描述符和对应的事件添加到epoll模型中后,就可以使用epoll_wait函数来等待事件的发生,该函数声明如下:
| C | |
|---|---|
1 | |
该函数的第一个参数表示目标epoll模型对应的文件描述符,最后一个参数的设置与poll一致,此处不再赘述。接着,第二个参数和第三个参数共同表示一个数组,与poll类似,struct epoll_event *events表示数组的第一个元素的地址,maxevents表示数组的元素个数。但是需要注意的是,这里的第二个参数并不是输入型参数,而是一个输出型参数,该数组中存储的是对应事件已经就绪的文件描述符,而因为事件可能不止一个,也有可能有很多个,所以需要maxevents来控制一次最多可以获取到的事件个数
该函数的返回值与poll一样,因为返回值大于0决定了具体就绪的文件描述符的个数,所以遍历就绪文件描述符数组events时需要用到该返回值
从接口层面对比epoll与select和poll¶
根据上面的接口介绍,可以看到epoll与select和poll最大的区别就在于接口的个数上,epoll只有三个接口,而select和poll根据前面的使用只有一个接口,而epoll三个接口中的后两个分别表示不同的功能:「用户需要内核关心的文件描述符和对应的事件」以及「内核告诉用户有哪些事件已经就绪」,所以从接口层面来看,epoll将这两个操作分离,使得操作变得更加清晰
epoll 原理¶
虽然上面已经对epoll实现多路转接需要用到的接口进行了介绍,但是其中还涉及到一些更加细节的问题无法通过接口的声明来描述,所以除了需要知道epoll需要用的到的接口外,还需要了解epoll的实现原理
在前面已经知道了文件描述符如何与套接字进行关联,接着看epoll实现多路转接的原理:
首先用户调用epoll_create函数创建epoll模型,该函数会在内核中创建一个struct eventpoll类型的对象,该对象中包含一个struct rb_root类型的对象,该对象是一个红黑树,该红黑树的节点类型是struct epitem。这两个结构定义分别如下:
| C | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
| C | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | |
在struct eventpoll结构中,除了有红黑树结构以外,还存在着struct list_head rdllist;,这个链表中存储的就是就绪的文件描述符,而在struct epitem结构中,struct epoll_filefd ffd;表示的就是具体的文件描述符,而struct epoll_event event;表示的就是文件描述符对应的事件
结合前面的三个接口看具体的原理就是首先调用epoll_create函数创建epoll模型,接着调用epoll_ctl函数将需要关心的文件描述符和对应的事件添加到epoll模型中,然后调用epoll_wait函数等待事件的发生,当事件发生后,epoll模型会将对应的文件描述符添加到rdllist链表中,然后epoll_wait函数会返回,用户就可以遍历rdllist链表来获取就绪的文件描述符
但是,这里还涉及到一个问题,就是epoll_wait函数是如何知道事件已经就绪的?这里实际上就是利用到了底层回调机制。所谓底层回调机制,可以理解为内核先准备一个函数指针,但是这个指针一开始指向为空,当存在一个事件就绪时,该指针就会指向一个具体的函数,接着,内核会调用该函数执行其中的函数体,在此处可以简单理解为函数体就是将挂在红黑树上的文件描述符节点添加到rdllist链表中
整个过程示意图如下:
分析完上面的基本原理,下面思考几个问题:
epoll模型中使用到的红黑树结构相当于select和poll模型中的什么结构?- 红黑树本质就是
key-value模型,那么什么元素作为key? - 为什么
epoll_create函数返回的是一个文件描述符? epoll模型为什么比select和poll模型更加高效?
基于上面的问题,下面给出答案:
epoll模型中使用到的红黑树结构相当于select和poll模型中的辅助数组,因为本质都是保存着用户需要关心的文件描述符- 实际上
key就是文件描述符,通过文件你描述符可以快速找到具体的一个红黑树节点,查找效率高 - 在Linux中一切皆文件,而在
struct file中存在一个private_data指针,这个指针在epoll模型中指向struct eventpoll结构 - 首先,从内核查找用户需要关系的文件描述符时,
epoll模型只需要查找红黑树,而select和poll模型需要遍历整个数组,这个时间消耗上比纯数组要低。其次,当有事件就绪时,对应的红黑树节点会被直接添加到rdlist中,这就可以避免了select和poll模型中需要遍历整个数组的过程,而用户在调用epoll_wait函数时获取到的struct epoll_event *events数组一定是包含着就绪的文件描述符的,此时用户就需要关心这个数组中的数据即可。所以,epoll模型比select和poll模型更加高效
epoll实现多路转接¶
上面已经基本介绍了epoll的接口和实现原理,下面结合上面的介绍对前面通过poll实现的TCP服务器进行修改:
首先是初始化部分,因为需要创建epoll模型,所以可以考虑在epollServer的构造函数中进行创建。因为epoll_create函数会返回一个文件描述符表示epoll模型,而且在后面的接口中也会使用到这个文件描述符,且该文件描述符只需要1份,所以考虑创建一个成员变量用于保存该文件描述符:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
接着,因为服务器需要监听,所以需要在构造函数中将监听套接字和对应的写事件添加到epoll模型中:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 | |
启动服务器时,需要epoll模型进行等待,所以在startServer函数中将原来的poll接口替换为epoll_wait接口,另外,本次考虑使用间隔1秒的方式进行等待,所以还需要设置epoll_wait函数的超时时间:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
此时,一旦监听套接字就绪,就说明有对应的客户端进行了连接,此时调用toAccept函数获取到对应的ac_socketfd,并将其添加到epoll模型中,但是epoll模型中可能不只有一个文件描述符和对应的事件,所以需要遍历epoll_wait接口返回的数组,这里因为内核添加内容是按照数组从0下标开始填充,所以可以按照正常遍历数组的方式进行,判断当前是否是监听套接字,如果是监听套接字就调用toAccept函数进行处理,否则就调用recvData函数进行处理:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | |
与select和poll一样,此时不可以直接读取,虽然toAccept此时不会阻塞,但是toRead函数会阻塞,所以需要现将ac_socketfd添加到epoll模型中,再下一次遍历时一旦是ac_socketfd就绪,就可以调用recvData函数进行数据读取
直接编译运行上面的代码会发现,连接客户端没有问题和接收客户端发送的消息时没有问题,但是在客户端退出时会提示:
| Text Only | |
|---|---|
1 | |
出现这个问题的原因是当recvData中的recv函数返回0时,会将对应的文件描述符关闭,如下面的逻辑:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 | |
一旦文件描述符被关闭,那么此时就会造成对应的文件描述符变成无效的文件描述符,而epoll_wait函数如果要操作指定的文件描述符必须要保证该文件描述符是有效的,所以此时就会出现上面的错误提示,所以考虑移除recvData函数中的close函数,此时的代码如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 | |
接着,在调用epoll_ctl函数之后关闭文件描述符:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | |
运行上面的代码可以发现与select和poll一样实现了多路转接
水平触发和边缘触发¶
在epoll模型中,有两种触发方式:
- 水平触发(LT,Level Trigger):当文件描述符对应的事件发生时,会一直通知上层,直到上层将事件处理完毕
- 边缘触发(ET,Edge Trigger):当文件描述符对应的事件发生时,如果没有数据增多时,只会通知上层一次,这就导致了如果上层没有及时处理事件,那么后续的事件就会丢失,所以边缘出发会强制用户一次性读取完所有的数据
实现水平触发和边缘触发的逻辑可以理解为:
-
水平触发(LT):当有事件就绪时,该事件对应的红黑树节点会被添加到就绪队列
rdlist中,只要继续队列中存在节点就会一直通知上层,直到上层将事件处理完毕 -
边缘触发(ET):当有事件就绪时,内核只会在刚就绪时通知一次,除非后续该文件描述符上关心的事件有数据增加。比如从无数据到有数据时触发一次
EPOLLIN,之后即使缓冲区还有数据也不会再次通知
默认情况下,epoll使用的是水平触发模式。但是实际上,边缘触发模式更加高效,但是因为没有多次通知,所以要确保数据被完全读取完毕,就需要循环调用读取函数知道读到返回值为0为止,但是如果返回值为0,那么根据前面的经验,读取函数就会被阻塞,此时尽管epoll没有阻塞,但是读取函数一旦阻塞,服务器还是会卡住针对这个问题,解决方案就是将读取文件描述符设置为非阻塞。那么为什么边缘触发模式更高效?实际上,因为需要上层一次性把所有数据读取完毕,那么只要开始读取对应的接收缓冲区就可以保证越来越大,下一次服务端向客户端返回的窗口大小也会变大,从而提高了IO吞吐量,所以边缘触发模式更加高效
IO吞吐量
IO吞吐量(Input/Output Throughput)是。IO吞吐量指单位时间内系统能处理的数据量,通常以MB/s或GB/s为单位,常用于衡量系统IO性能的关键指标,特别是在高并发网络编程中。在网络编程中,它反映了服务器处理网络数据的能力
在Linux下可以使用iftop命令查看IO吞吐量,但是首先需要安装iftop工具:
| Bash | |
|---|---|
1 | |
再使用下面的命令查看IO吞吐量:
| Bash | |
|---|---|
1 | |
基于边缘触发模式的epoll实现基本TCP服务器结构¶
基本思路¶
本次为了后面实现方便,首先对使用epoll实现多路转接的接口进行封装,接着,为了保证低耦合度,考虑将每一个客户端与服务端的连接设计为一个连接结构Connection的对象,这样可以保证在EpollServer看来,只有一个一个的连接对象而不是各种文件描述符。但是,除了有用于客户端和服务端进行数据通信的文件描述符外,还有监听套接字对应的文件描述符,而对于监听套接字来说,实际上其只关心读时间,所以可以考虑将监听套接字对应的文件描述符和普通的文件描述符看做一类连接结构对象,只是需要实现的方法不同
上面的问题解决了底层EpollServer和上层连接之间的关系,但是上层连接有两种情况:
- 文件描述符对应的是监听套接字,执行的行为是建立客户端与服务端的连接
- 文件描述符对应的是普通的文件描述符,执行的行为是与客户端进行数据通信
所以对于这一点,可以考虑设计两个类,一个类是Listener,表示的是监听套接字和其对应接口的封装,另一个类是IOService,表示的是普通的文件描述符和其对应接口的封装
实现Connection类基本结构¶
首先是Connection类,该类需要管理每一次的连接,所以需要有一个成员变量用于保存对应的文件描述符。另外,在前面不论是select与poll还是epoll实现的TCP服务器都存在着一个问题:读操作不能保证读取到的是完整的数据。因为前面的实现中缓冲区都是一个临时变量,一旦离开了当前读取的过程就会被销毁,为了解决读取到完整的数据,就必须对上一次读取的数据进行缓存,再次读取时将该数据与上一次的数据进行拼接直到有完整的数据,所以考虑在Connection类中还需要添加in_buffer_成员,同样的再提供一个out_buffer_成员。接着,因为底层的EpollServer管理的是连接结构对象,所以为了可以看到客户端的信息,还需要提供一个用于保存客户端信息结构的成员变量,这个类型即为前面封装的SockAddrIn类,所以当前Connection类的结构如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
除了上面的信息外,为了保证当前Connection类既可以表示普通的文件描述符,还可以表示监听套接字,这里可以考虑将Connection作为基类,提供三个纯虚函数由子类进行实现,分别表示读、写和异常,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 | |
接着,为了保证子类可以访问到Connection类的成员变量,这里可以将Connection类的成员变量设置为protected,这样子类就可以直接访问到父类的成员变量,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 | |
因为EpollServer类会管理每一个Connection对象,而EpollServer类会对每一个文件描述符进行关心,但是具体关心哪种事件当前在Connection类中并没有体现,所以还需要在Connection添加一个成员变量用于表示当前Connection关心的事件,类型为uint32_t,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 | |
同样的,可以添加一个成员变量revents表示就绪的事件:
| C++ | |
|---|---|
1 2 3 4 5 6 7 | |
接下来需要对一些成员变量进行初始化,本次考虑在Connection类的构造函数中进行初始化操作:
| C++ | |
|---|---|
1 2 3 | |
接着,提供一些设置函数,如下:
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
最后,提供一些获取函数,如下:
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
| C++ | |
|---|---|
1 2 3 4 5 | |
实现EpollServer类基本结构¶
接着是EpollServer类,该类需要管理每一个Connection对象,因为EpollServer类需要对具体的描述符进行关心,而根据Connection类的设计:包含需要关心的事件,所以可以考虑在EpollServer类中创建一张哈希表存储文件描述符和Connection类对象的映射关系,本次考虑实现文件描述符和Connection类对象指针进行映射的方式如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 | |
接着,因为EpollServer类会接收epoll_wait返回的就绪事件数组,所以可以考虑在EpollServer类中创建一个数组用于存储,这里使用定长数组,在构造时初始化对应的指针,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
实现epoll接口封装类Epoll基本结构¶
因为epoll的设置接口和等待接口都会使用到epoll模型对应的文件描述符,所以考虑在Epoll类中创建一个成员变量存储该文件描述符,接着,既然是封装接口,就没有必要单独提供一个创建epoll模型的接口,所以可以考虑在Epoll类的构造函数中创建epoll模型,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
接着在EpollServer中添加一个成员变量指针用于表示Epoll类对象,这样在EpollServer类中就可以调用封装后的接口。在构造函数初始化列表中对该指针进行初始化:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
设计Epoll类和EpollServer类¶
在下面进行设计之前先回顾之前的思路:
在本次实现中,Epoll接口封装类是最底层的类,而EpollServer类是Epoll接口封装类的上层类,这个服务器用于处理IO,而网络服务本质都是IO,所以网络服务相关的类(客户端与服务端建立连接和客户端与服务端进行数据通信)都在EpollServer类的上层,但是为了统一EpollServer视角,利用到了Connection类的中间层
首先既然要将文件描述符添加到epoll模型中,那么首先需要的就是在Epoll类中提供添加文件描述符到epoll模型的接口,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
接着,在EpollServer类提供一个将Connection类对象添加到epoll模型中的接口,但是参数是Connection类对象的指针,因为EpollServer类中管理的是Connection类对象的指针,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
因为EpollServer类需要等待每一个文件描述符就绪,所以需要Epoll提供一个等待接口,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
接着,在EpollServer类中实现服务器启动的函数,对于EpollServer来说,其主要任务就是等待文件描述符就绪,为了后续方便添加新功能,可以考虑将等待行为抽取到一个函数loopOnce中,而启动服务器函数就是启动服务器并持续执行loopOnce函数。为了标识服务器已经启动,可以使用一个成员变量isRunning_,在构造函数中初始化为false,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
同样,可以提供函数用于停止服务器:
| C++ | |
|---|---|
1 2 3 4 5 | |
接着,实现单次循环函数loopOnce,该函数就是等待已经存在于epoll模型中的文件描述符,并对具体的就绪事件进行处理。参考思路:单次循环中需要调用Epoll类中的epollWait函数,该函数会返回已经就绪事件的个数和数组,遍历数组获取到每一个继续的文件描述符和对应的事件,如果返回的事件是错误事件,为了处理方便,将该返回事件修改为读写事件就绪EPOLLIN | EPOLLOUT,交给上层的读写函数处理,这一点具体作用在后面会提及,此处不过多解释。接着就是正常情况,即要么是读事件就绪,要么是写事件继续,要么就是二者依次就绪,所以需要两个判断分别处理,但是此处不能只通过判断返回的就绪事件类型是否是或者写就决定执行某一种分支,而是还要判断对应的文件描述符是否存在。在执行就绪事件对应的逻辑分支中,因为哈希表的value是Connection对象指针类型,只需要调用父类的方法即可,如果是监听套接字,那么就会执行创建连接,否则就是正常的读写。根据这个思路,需要额外实现一个函数判断当前文件描述符是否存在于哈希表中:
| C++ | |
|---|---|
1 2 3 4 5 | |
接着实现loopOnce函数:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | |
实现Listener类基本结构¶
因为下层是通过Connection类对象来管理每一个链接,所以Listener类只需要作为Connection类的子类:
| C++ | |
|---|---|
1 2 3 | |
接着,因为Listener类用于处理客户端和服务器端的链接,所以考虑在Listener类中添加一个成员表示TCP套接字,在构造函数初始化列表中进行初始化,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 | |
因为Listener类是Connection类的子类,所以需要重写父类的纯虚函数,但是目前不具体实现,如下:
| C++ | |
|---|---|
1 2 3 | |
| C++ | |
|---|---|
1 2 3 | |
| C++ | |
|---|---|
1 2 3 | |
接着,在构造函数中进行初始化操作,包括创建套接字、绑定地址信息和设置监听操作。另外,因为每个Connection类对象都需要用到文件描述符,所以在构造函数中还需要将监听套接字设置到当前子类对象Listener中,便于使用Connection类对象可以获取到监听套接字,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
设计Listener类和IOService类¶
因为客户端和服务端建立连接本质就是服务端读取客户端的请求信息,所以Listener类只需要实现recvData函数即可,但是需要注意的是,本次实现的是边缘触发模式,虽然能确定accept函数一定不会阻塞,但是不能保证accept函数一定只读取一次,也就是说需要将accept函数当做read等读取接口看待,所以需要循环监听。另外,如果当做IO函数看待,那么必然会出现多读一次用于判断是否读取到结尾,但是这多读的一步会导致服务器阻塞,所以还需要将对应的监听套接字设置为非阻塞,所以首先需要将监听套接字设置为非阻塞
基于上面的思路,首先提供一个函数用于将文件描述符设置为非阻塞,因为这个函数在后续正常读取信息时也会用到,所以考虑将该函数放在一个工具类中:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
接着,在获取到listen_socketfd之后将其设置为非阻塞,在BaseSocket类中的initSocket函数中添加如下代码:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
接着,为了拿到accept函数的错误码,可以考虑在toAccept函数的参数部分添加一个输出型参数out_errno:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
基于上面的思路,实现recvData函数基本结构:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | |
接着思考recvData函数的正常情况如何处理,在前面都是直接将指定的文件描述符添加到epoll模型中,但是在本次实现中,当前Listener类和EpollServer类之间存在一个Connection类,而EpollServer类只能看到Connection类对象,所以需要考虑将获取到的ac_socketfd封装为Connection类对象,再将该对象添加到EpollServer类中。但是,此处遇到两个问题:
- 如何将获取到的
ac_socketfd封装为Connection类对象 - 如何将该对象添加到
EpollServer类中
对于第一个问题,既然已经有了关于监听套接字的子类,那么自然还需要一个处理普通文件描述符的子类:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
对于第二个问题,这里需要在Connection类中添加一个成员变量指针,用于表示EpollServer类对象,这样在Connection类中就可以调用封装后的接口。这里就需要考虑如何在Connection类中访问到EpollServer类,又如何在Connection类中拿到EpollServer类对象
对于第一个问题,最直接的做法就是在Connection类所在文件中包含EpollServer类所在的文件,如下:
| C++ | |
|---|---|
1 2 | |
但是这种做法有一个弊端,就是如果在EpollServer类所在的文件中包含了Connection类所在的文件,就会出现头文件循环包含问题,所以这种做法是不可取的。所以需要考虑使用前置声明的方式,如下:
| C++ | |
|---|---|
1 2 | |
头文件循环包含问题
在Connection类所在的文件中需要用到EpollServer类,所以需要在Connection类所在的文件中添加EpollServer类的前置声明,但是注意,如果在EpollServer类所在文件中包含了Connection类所在文件,就不要在Connection类所在的文件中再添加包含EpollServer类所在的文件,防止出现头文件循环包含问题
但是,使用前置声明还会遇到一个问题:如果将前置声明放在Connection类所在的命名空间connectionModule中,会出现如下错误:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
对于这种情况能想到的直接方案就是将前置声明放在命名空间connectionModule中,但是这样还会出现第二个问题:当前EpollServer的声明是在Connection类所在的命名空间connectionModule,而实际上EpollServer类的声明是在命名空间epollServerModule中,如果直接使用上面的方式:std::weak_ptr<EpollServer> ep_svr_,一旦在Listener中将该指针转换为shared_ptr类型,就会发现指针类型是std::shared_ptr<connectionModule::EpollServer>,而不是std::shared_ptr<epollServerModule::EpollServer>,而作为前置声明的connectionModule::EpollServer并没有完整的实现,这就导致访问不到epollServerModule::EpollServer中的成员。所以正确的做法是,将EpollServer类的前置声明放在EpollServer类所在的命名空间中,再将该前置声明整体放在Connection类所在文件的全局,并在使用到EpollServer的地方使用指定命名空间的方式使用EpollServer。为了防止出现循环引用问题,需要使用weak_ptr而不再是shared_ptr,如下:
上面提到的循环引用问题
EpollServer中管理了多个Connection对象指针,该指针是shared_ptr类型,如果再在Connection中使用shared_ptr就会出现Connection对象与EpollServer互指,一旦Connection类对象需要析构,就需要EpollServer先析构,而EpollServer要析构就需要Connection类对象析构导致循环引用问题
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
但是,有这个指针还不够,还需要对这个指针进行初始化,为了防止忘记初始化该指针导致的空指针错误,考虑在构造函数中添加参数用于初始化该指针,如下:
| C++ | |
|---|---|
1 2 3 4 | |
但是,Connection是Listener类的父类,所以在Listener类中的成员初始化之前需要先调用父类的构造函数初始化父类成员(除非父类构造函数是全缺省或者无参),所以需要在Listener类的构造函数的初始化列表同样添加该参数,如下:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 | |
最后,不要遗忘还有一个子类IOService,同样,在其构造函数的初始化列表中同样添加该参数,如下:
| C++ | |
|---|---|
1 2 3 | |
接着,在Connection类中添加一个函数用于获取EpollServer类对象,如下:
| C++ | |
|---|---|
1 2 3 4 5 | |
解决了上面的两个问题后,回到Listener类中的recvData函数编写剩下的逻辑。注意,要实现边缘触发模式一定要使用EPOLLET并且将对应的文件描述符设置为非阻塞:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
在上面的代码中,因为getEpollServer函数返回的是weak_ptr类型,所以需要调用lock函数将其转换为shared_ptr类型再使用。这里是临时提升为shared_ptr类型,所以使用完之后会自动释放,主要原因如下:getEpollServer持有的指针是对EpollServer类对象的弱引用,一旦ep变量离开作用域,哪怕ep变量是shared_ptr类型对EpollServer类对象的强引用,其引用计数器也不会等到EpollServer销毁再减1
最后,需要修改前面TcpSocket类中的toAccept函数,该函数中存在对ac_socketfd < 0的判断逻辑,但是这个逻辑已经在loopOnce函数处理了,所以需要删除toAccept函数中关于这部分的逻辑:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
第一阶段测试¶
首先,在IOService类中的recvData函数中添加一条日志:
| C++ | |
|---|---|
1 2 3 4 | |
接着,创建一个主函数:
| C++ | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
编译运行上面的代码使用一个客户端连接就可以发现一个进程就可以处理多个连接,并且只要客户端向服务端发送内容就会打印类似下面的内容:
| Text Only | |
|---|---|
1 | |
当前阶段已经完成了任务派发,但是还没有进行IO处理,下一节将继续完成IO处理
