(四)Dispatcher模块的实现思路
关于dispatcher, 它应该是反应堆模型里边的核心组成部分 ,因为如果说这个反应堆模型里边有事件需要处理,或者说有事件需要检测,那么是需要通过这个poll、epoll 或者 select来完成的。dispatcher 有三个组成部分,它们并不是互相依存的,而是互斥的 。就是我们在选择的时候,只能任选其一。不管使用哪一个,都可以往这个模型里边添加一个新的待检测事件,或者说把一个已经检测的事件从这个检测模型里边删掉 。**还有一种情况,就是把一个已经被检测得到文件描述符它的事件进行修改,比如原来是读事件,现在改成读写。**也就是说这三种处理方式,每一种处理方式它们都对应一套处理函数,它们都对应一套处理函数。需要解决的问题:如果我们在程序中使用后,在调用这些接口的时候,是不是需要做一个判断?就是在程序中判断
cpp
if(使用的模型是poll){
调用处理方式
}
else if(使用的模型是epoll){
调用处理方式
}
else if(使用的模型是select){
调用处理方式
}
因为这三种处理方式对应的是一套函数,所以在调用添加函数的时候需要做这样的一个的判断;在做删除的时候也需要做这样的一个判断,在做修改操作的时候,也需要做这样的判断。也就意味着咱们编写的程序是非常的冗余。
cpp
if() {
...
}
else if() {
...
}
else if() {
...
}
怎么去精简 呢?有没有一种解决方案可以让代码写起来非常精简呢?
- 对应的解决方案就是使用回调函数
Dispatcher提供了一系列的接口:
- init():做数据初始化
- add():添加一个事件节点
- remove():删除一个事件节点
- modify():修改一个事件节点
dispatch() :用于事件检测的,对于poll 来说,就是调用poll 函数,对于epoll 来说,就是调用epoll_wait 函数,对于select 来说,就是调用select 函数。通过调用dispatch 函数就能够知道检测的这一系列的文件描述符集合里边到底是哪一个文件描述符它所对应的事件被触发了,找到了这个被触发事件的文件描述符,就需要基于它的事件去调用文件描述符注册好的读函数 或者是写函数了。
clear() :内存释放。第一部分:对文件描述符的关闭,第二部分:对申请的堆内存的释放。可以把Dispatcher 设计成是一个结构体,里边有六个成员,类型都是函数指针。函数指针指向的是函数的地址,它指向了这个函数的地址之后,就可以对地址对应的函数进行调用了。首先保存一个函数的地址,然后在适当的时机去调用这个地址对应的函数。因为函数名就是地址。
- 假设说我们把这个函数指针 已经做了初始化,什么时候进行调用呢?比如说客户端和服务器新建立了连接,那么就得到了一个用于通信的文件描述符。得到了通信的文件描述符,就需要调用add 方法。这个add方法它是一个函数指针,它肯定指向一个对应的处理函数,那么这个任务函数动作是什么我就执行对应的那个动作。
- 假设说某一个通信的文件描述符客户端断开了连接,那么就需要把这个文件描述符从检测的模型上删除**(poll、epoll、select),remove**也是一个函数指针,指向一个实际的函数,只要能够找到这个函数,就可以调用这个函数,把对应的文件描述符从检测的模型上删除。
- 关于poll ,也是一样的,分别是pollInit,pollAdd,pollDelete,pollModify,pollDispatch,pollClear。 这些函数它们还是函数指针吗?就不是了吧,这是实实在在的函数,但是这个函数的函数原型也就是它的返回值以及参数。 需要和上边dispatch 这个模型,里边定义的函数,指针的类型是相同的,这样的话,才能够让这个指针指向这个函数的地址。也就说,下边这一系列函数主要是给谁呢?给上边的这个dispatch 结构体里边的函数指针进行实例化的,就是做初始化的。
- 关于epoll ,也是一样的,分别是epollInit,epollAdd,epollDelete,epollModify,epollDispatch,epollClear。
- select 呢,也一样的,只不过是前缀不一样
当把下边的这三个模型里边的函数分别实现了之后,就看用户的选择了。
- 如果用户选择epoll ,那么我们就使用epoll的这组函数去给上面的函数指针进行初始化。
- 如果用户选择select,那么就用这组函数的地址去给这个函数值呢?进行初始化、
- 如果用户选择poll,那么就用这组函数的函数名或者是函数地址
其实都是一样的。给上面的函数指针做初始化。初始化好了之后,在上层调用的时候,只需要使用dispatch 这个结构体里边的这些函数指针的名字,就可以对下边这些已经实现了的函数进行调用了。处理思路说明白之后,再来看一个细节。对于poll 这个模型来说,如果他要处理一系列的文件描述符, 前提条件是需要先把它们存储起来,要存储到一个结构体里边。在调用poll函数的时候,需要用到一个结构体类型
cpp
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
fds 是struct pollfd 类型,这个参数是一个传入传出 参数。在调用这个函数之前,需要先把结构体定义出来,然后对结构体进行初始化 ,告诉他我要检测的文件描述符的值是什么,以及要检测这个文件描述符的什么事件。当我们通过poll 函数委托内核去检测这一系列的文件描述符集合的时候,内核检测到了某些文件,描述符对应的这个事件被触发了。那么,它就会把这个事件写入到revents里边。
那么为什么有一个events 了,还有一个revents 呢?是这个样子的,比如说这个events ,它里边委托内核要检测文件描述符的读写事件。
- 现在只有读事件触发了,所以在revents 里边,就只有读事件。
- 如果对应的写事件触发了,那么这里边就只有写事件。
- 如果读写事件都触发了,那么在这个revents里边,就是读写。
所以通过这个结构体的revents成员就能够非常清晰的知道这个文件描述符它的什么事件被触发了。知道什么事件被触发了,就可以做对应的动作处理了。
cpp
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
在epoll 里边调用了epoll_wait 就能够委托内核帮助我们去检测一系列的文件描述的集合,它所对应的事件是不是触发了?如果这些事件被触发了,那么他就会给我们返回数据,这个数据是保存到了第二个参数里边,第二个参数是一个epoll_event 类型的结构体数组的地址。这个返回值是告诉我们epoll树上有多少个待检测的文件描述符,它对应的事件被激活了。
cpp
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
看一下在调用select 这个函数的时候用到的那些数据成员。在调用select 函数的时候,有三个存储文件描述符集合的参数 分别是readfds 。writefds 以及exceptfds 第三个是异常的集合,关于异常的集合 ,可以不去检测 。我们主要关心的是它的读集合和写集合 类型,是fd_set ,其实它也是传入传出参数 。我们在传入的时候需要往fd_set 里边设置一些合适的值告诉select ,你需要委托内核 帮助我们去检测哪些文件描述符的什么事件?
- 如果把这些文件描述符设置给了readfds ,就是检测它的读事件
- 如果把这些文件描述符设置给了writefds ,那么就是检测这些文件描述符的写事件
关于这个fd_set ,可以把它看成是一个整形的数组 ,它里边一共有1024 个标志位。这个fd_set 这种类型,它里边一共有1024 个标志位。这1024 个标志位,就对应select 能够检测的那1024个文件描述符。
一个Dispatcher 模型,它对应一个DispatcherData, 它们都同时存在于另一个模块里边(EventLoop ),是一个对应关系。我们如果想把这个Dispatcher 对应的data 取出来,那么就需要通过EventLoop 来取了,所以要得到EventLoop 的地址之后,也就能拿到这个Dispatcher 对应的DispatcherData了。
(1)init函数
在我们要实现的这个多反应堆服务器模型里边,Dispatcher 一共有多少个?是一个还是多个呢?来看一下在这个EventLoop 里边, 其实就有Dispatcher ,这个Dispatcher 就是事件分发器,这个事件分发器其实就是要编写的那个poll、 epoll 或者select模块 ,我们在实现Dispatcher 它底层的这三个模型里边,任意一个的时候都需要一个DispatcherData。
现在再来思考,刚才提问的那个问题,在这个多反应堆模型里边需要多少个Dispatcher 呢?一个还是n个呢?其实是n个吧,在这个项目里边有多少个反应堆模型,它就有多少个EventLoop ,那么底层就有多少个Dispatcher 。一个Dispatcher ,它对应的有三块,一块是epoll ,一块是poll ,一块是select 。虽然有三块,前面也说了这三块并不是同时发挥作用,而是三选一。这个Dispatcher 有多少个,那么这个DispatcherData 就有多少个。所以,需要给底层的这个IO多路转接模型提供对应的数据块,有多少个多路lO转接模型,就需要提供多少个DispatcherData。
举一个例子,比如在我们项目中有三个EventLoop ,那么就有三个epoll、 三个poll 、三个select 。那么对应的DispatcherData 有多少个呢?三三得九,是九个。但是对于每一组来说,我们只能从里边选择一个来使用,那么另外两个就用不到了。既然用不到,那么我们需要对它的DispatcherData 进行初始化吗?也就不需要了吧,也就是说,虽然有九个,但是
- 如果你选择了用这个epoll ,那么我就给这个epoll 的data,做初始化;
- 如果你选择了用poll ,那么我就给这个poll 的data,做初始化;
- 如果你选择了用select, 那么我就给这个select 对应的data做初始化
这是一个EventLoop。 剩下的两个EventLoop 也是做同样的选择。
所以,在这个项目中有三个EventLoop ,那么实际被初始化的DispatcherData 有多少个呢?三个,现在就能搞清楚在Dispatcher 这个结构体里边对应的这个回调函数Init() 它是用来干什么的?就是用来初始化epoll 或者是select 或者是poll 对应的那个数据块。要通过这个函数去初始化一个数据块,最后要把这个数据块的内存地址 给到函数的调用者。所以它的返回值肯定是一个指针 ,另外poll、 epoll 和select 他们需要的数据块对应的内存类型一样吗?不一样,如果想要一种类型来兼容三种不同的类型,怎么做到呢? 在C语言里就是使用泛型 ,故返回值类型为void*。
cpp
void* (*init)();
(2)add函数
- EventLoop.h
cpp
#pragma once
#include "Dispatcher.h"
struct EventLoop{
Dispatcher* dispatcher;
void* dispatcherData;
};
看add函数 ,这个add函数 要把待检测的文件描述符添加到poll 、epoll 或者select 上边。我们在添加一个待检测节点 的时候,这个节点 对应的肯定是一个文件描述符。在前面的文章中,已经介绍了把文件描述符封装成Channel 类型。所以这个函数指针对应的参数肯定有一个是Channel 类型。另外还有一个细节,就是我们通过add函数 把Channel 里边的文件描述添加到IO检测模型上去的时候,都需要什么呢?
- 如果是epoll ,就需要epoll 树的根结点。不管是什么类型的结点,都需要把它放到用于检测的这个epoll 树上。关于这个根结点,肯定是需要保存的,可以在初始化的时候把epoll 树的根结点和epoll_event结构体一起保存起来,也就是把这两部分数据做一个包装封装成一个结构体
- 如果是poll, 就需要pollfd对应的那个结构体
- 如果是select ,就需要它的读集合 和写集合
add 函数还有一个EventLoop 类型的evLoop 参数,通过这个结构体,我们就能够取出当前的dispatcher 它在工作的时候需要用到的那一系列的数据。前面说到,select 用到的是文件描述符的集合(fd_set ),epoll 就是epoll_event ,poll 就是pollfd类型的结构体
cpp
// 添加
int (*add)(struct Channel* channel,struct EventLoop* evLoop);
(3)remove函数
- 如果要删除 ,用到的也是Channel 类型和EventLoop类型的参数
cpp
// 删除
int (*remove)(struct Channel* channel,struct EventLoop* evLoop);
(4)modify函数
- 如果要修改 ,用到的也是Channel 类型和EventLoop类型的参数
cpp
// 修改
int (*modify)(struct Channel* channel,struct EventLoop* evLoop);
(5)dispatch函数
cpp
// 事件检测
int (*dispatch)(struct EventLoop* evLoop,int timeout); // 单位:s
这是一个函数指针声明。让我们分解这个声明以更好地理解它:
- dispatch是函数指针的名字
- int是函数的返回类型,表示该函数返回一个整数值
- (*dispatch) 表示 dispatch是一个指向函数的指针
- struct EventLoop* evLoop 是函数的第一个参数,它是一个指向 EventLoop结构体的指针
- int timeout是函数的第二个参数,它是一个整数
(6)clear函数
cpp
// 清除数据(关闭fd或者释放内存)
int (*clear)();
- 综上所述,这个函数指针 dispatch 指向的函数接受一个指向 EventLoop的指针和一个整数作为参数,并返回一个整数
Dispatcher结构体定义与初始化
在先前的介绍中,我们提到了dispatcher 结构体的定义。这个结构体中包含六个成员 ,它们主要是通过函数指针来进行初始化的。这些函数指针对应于epoll 、select等使用的数据。
- 对于select ,需要使用fd_set类型的两个文件描述符集合;
- 对于epoll ,则是使用epoll_event类型的结构体数组
- 对于poll ,则是pollfd类型的结构体数组
不论使用哪种类型的lO多路转接模型 ,它们都需要一个或多个数据块进行工作。因此,在init 的函数中,主要是用来初始化这些数据块的。在实现dispatcher 的底层模型时(无论是哪一个),都需要一个DispatcherData 。这个data 是通过dispatcher 结构体的回调函数init 来初始化的。这个函数主要是用来初始化epoll、select或poll 对应的数据块。关于这个函数的返回值,它是一个指针。这个设计是为了兼容epoll、select或poll的不同类型数据块。
EventLoop 结构体定义EventLoop 结构体中包含一个dispatcher 实例。为了兼容epoll、poll和select ,这个数据块 通过void类型的指针来保存 。这个EventLoop结构体的定义相对简单,主要目的是确保其存在。
回到先前提到的DispatcherData 头文件中,通过这个结构体,我们可以获取当前dispatcher在工作时所需的一系列数据。
总结: 通过以上分析,我们可以看到dispatcher 结构体在系统中的核心作用。它不仅定义了lO 多路转接模型所需的数据块,还提供了初始化这些数据的函数。而EventLoop 结构体则为dispatcher提供了一个工作平台,确保了数据的正确使用和管理。这种模块化的设计使得代码更加清晰、易于维护,同时也为未来的扩展提供了便利。