epoll
实现多路转接¶
约 8373 个字 911 行代码 2 张图片 预计阅读时间 39 分钟
本篇介绍¶
在前面一节已经介绍了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
的实现原理
在前面不论是编写UDP服务器和编写TCP服务器第一步都需要创建套接字,而这个套接字本质还是一个文件描述符,那么文件描述符是如何与套接字产生关联的?
为了解决这个问题,首先需要看struct file
结构,该结构定义如下:
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 |
|
在该结构中有一个指针private_data
,这个指针的类型是void *
,所以可以指向任意类型的数据。在网络部分,一旦创建了套接字,那么就会创建一个struct socket
结构,该结构定义如下:
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 |
|
而要实现文件描述符与套接字的关联,就需要将struct socket
结构体对象的地址赋值给struct file
结构的private_data
指针
另外,在struct socket
结构中,存在着一个成员struct sock *sk
,这个成员表示具体的某一个套接字类型,既可以是UDP套接字也可以是TCP套接字,该类型的定义如下:
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 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
|
在这个结构中就存在着UDP和TCP需要用到的缓冲区成员
如果看得到UDP套接字和TCP套接字的相关结构定义:
C | |
---|---|
1 2 3 4 5 6 |
|
C | |
---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
可以发现,在tcp_sock
结构和udp_sock
中都存在着一个成员struct inet_sock inet
,在这个类型中:
C | |
---|---|
1 2 3 4 5 |
|
第一个成员就是struct sock
类型,所以可以得出下图:
这就是在网络套接字部分实现的继承和多态,这一点在前面操作系统管理System V标准中三种资源的方式也有类似的结构形式
知道了文件描述符如何与套接字进行关联后,接着看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处理