事件驱动:客户端消息是如何处理的
事件驱动:客户端消息是如何处理的
Redis 系列目录:https://www.cnblogs.com/binarylei/p/11721921.html
现在,我们开始学习新的请求处理模块。相信大家一定都听说过,Reids 之所以快,一个重要的原因就是 NIO 网络模型和单线程处理,我们就带着这个问题,看一下 Redis 是如何实现的。
在这个模块,我们重点分析请求是如何处理的,包括事件驱动机制、请求解析处理的过程。今天,我们先来学一下 Redis 是如何实现事件驱动的。
我们知道,NIO 是一种 IO 多路复用技术,网络 IO 操作都会触发读写事件,那如何处理这些事件呢?Redis 统一封装了一个轻量级的事件驱动库 - src/ae.h 和 ae.c。这是一个易于复用的库,可以脱离 Redis 而存在。事实上,ae.c 除封装文件事件,还是时间事件来处理内部的定时任务。
AE 库
AE(async event) 库实现类似 Netty 中的 event loop 机制,并支持多种 IO 多路复用。在深入学习 AE 库的实现之前,我们先整体看一下整体的工作逻辑。
可以看到,ae 库由两大部分组成:aeEventLoop 事件处理器和 epoll 管理器。
aeEventLoop 事件处理器,定义在 ae.c 和 ae.h 文件中,是 AE 库的核心,它存放了所有的文件事件 event 和 时间事件 timeEventHead。
epoll 管理器,即 polling API,统一封装了 IO 多路复用,给 ae.c 文件调用。包括 ae_epoll.c、 ae_select.c、 ae_kqueue.c、 ae_evport.c 这 4 个文件,他们的功能是完全一样的,提供一致的 API 接口。
这两个部分的交互点在于,aeEventLoop 将这些文件事件 event 注册到 epoll 管理器上,epoll 管理器上不断地通轮询拉取触发的文件事件到 fired 中。
同时,aeEventLoop 事件处理器除了文件事件外,还管理了时间事件。
aeEventLoop
前面说了,aeEventLoop 是整个 AE 库最核心的结构体。它虽然是用 c 开发的,但却采用了面向对象的设计。下面我用注释的方式说明各字段的作用。
typedef struct aeEventLoop {
int maxfd; /* 当前注册的最大fd */
int setsize; /* 监视的fd的最大数量 */
long long timeEventNextId; /* 下一个时间事件的ID */
time_t lastTime; /* 上次时间事件处理时间 */
aeFileEvent *events; /* 已注册文件事件数组 */
aeFiredEvent *fired; /* 就绪的文件事件数组 */
aeTimeEvent *timeEventHead; /* 时间事件链表的头 */
int stop; /* 是否停止(0:否;1:是)*/
void *apidata; /* 各平台polling API所需的特定数据 */
aeBeforeSleepProc *beforesleep; /* 事件循环休眠开始的处理函数 */
aeBeforeSleepProc *aftersleep; /* 事件循环休眠结束的处理函数 */
} aeEventLoop;
其中,我们重点要关注 events、fired、timeEventHead 这几个字段。
-
events
:保存了所有的文件事件,这些文件事件需要注册具体的读写事件到 epoll 上。events是一个数组结构,数组的下标是文件句柄 fd,数组的大小是 setsize。那 events 需要多大合适呢? maxclients 参数表示 Redis 服务器最大的客户端连接数,默认是 10000;同时,Liunx ulimit -n 可以查看每个进程自大的句柄数,默认为 1024。实际 Reids 的最大连接数受这两个参数限制。
-
fired
:注册的 event 事件触发读写后,epoll 会先将事件保存到 fired 字段中。 fired 也是一个数组结构,大小和 events 相同。fired 保存了触发读写事件的句柄 fd,以及触发的事件类型,这样通过 fd 就可以找到具体的事件 events 了。 -
timeEventHead
:就是时间事件,这是一个链表结构。每次轮询时,都会遍历这个链表,如果已经到了执行时间,就会触发任务执行,我想着就是为啥叫它时间事件的原因了。
两种事件
前面一直提到文件事件和时间事件,这到底是什么呢?我稍微解释一下:
- 文件事件:也就是 IO 读写事件。因为 Linux 中一切皆文件,所以这里把网络 IO 读写事件统一称为文件事件。
- 时间事件:可以理解为 Redis 内部的定时任务,最主要的定时任务就是 service.c#serverCron,默认每 100ms 执行一次。根据执行时间判断是否被触发,所以称为时间事件。
接下来,我们看一下,文件事件和时间事件的定义,它们都是在 ae.h 文件中。
aeFileEvent
文件事件 aeFileEvent 定义了事件的类型和处理函数。这里你可能会问,为啥没有句柄 fd?聪明的你可能已经想到了,aeEventLoop.events 的下标就是句柄 fd。
其中 AE_READABLE 是读事件,包括了 Accept 和 Read 事件。rfileProc 定义了对应的读事件处理函数,Accept 事件是 network.c#acceptTcpHandler,Read 事件是 network.c#readQueryFromClient。AE_WRITABLE 是写事件,对应的处理函数是 network.c#sendReplyToClient。
typedef struct aeFileEvent {
int mask; // 事件类型 AE_(READABLE|WRITABLE|BARRIER)
aeFileProc *rfileProc; // 读事件处理:network.c#readQueryFromClient or acceptTcpHandler
aeFileProc *wfileProc; // 写事件处理:network.c#sendReplyToClient
void *clientData; // 对应的client
} aeFileEvent;
我们再看一下文件事件触发的定义。也很简单,保存了文件句柄和事件类型。通过下标 fd 找到对应的事件,再调用对应的事件处理函数。
typedef struct aeFiredEvent {
int fd; // 句柄
int mask; // 触发的事件类型
} aeFiredEvent;
aeTimeEvent
时间事件就不做过多解释了,大家看下代码注释就明白了。
typedef struct aeTimeEvent {
monotime when; // 执行时间
aeTimeProc *timeProc; // 执行方法,返回下次执行的事件间隔,如果不为-1则表示是周期执行
struct aeTimeEvent *prev;
struct aeTimeEvent *next;
} aeTimeEvent;
事件处理流程
ae.c 库处理事件驱动的入口是 aeMain,它不断调用 aeProcessEvents 和 processTimeEvents 函数分别处理文件事件和时间事件。我画了一张图,包含这两种事件的处理流程,如下图所示:
- aeMain:函数入口,不断轮询,调用 aeProcessEvents 函数执行。
- aeProcessEvents:调用 polling API 拉取已触发的文件事件,并执行文件事件和时间事件。
- processTimeEvents:执行时间事件。遍历所有的时间事件链表,如果执行时间 when 大于当前时间,则触发任务执行;如果执行返回的下一次执行事件间隔不为 -1,则更新该事件的下次执行时间 when,否则删除该任务。
aeProcessEvents
aeProcessEvents 函数是这个 AE 库的核心,下面我用代码注释的方式,说明事件处理的大体逻辑。
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
int j;
struct timeval tv, *tvp;
int64_t usUntilTimer = -1;
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
usUntilTimer = usUntilEarliestTimer(eventLoop);
if (usUntilTimer >= 0) {
tv.tv_sec = usUntilTimer / 1000000;
tv.tv_usec = usUntilTimer % 1000000;
tvp = &tv;
} else {
if (flags & AE_DONT_WAIT) {
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
} else {
tvp = NULL;
}
}
if (eventLoop->flags & AE_DONT_WAIT) {
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
}
// 1.1 eventLoop->beforesleep,调用 server.c->beforesleep
if (eventLoop->beforesleep != NULL && flags & AE_CALL_BEFORE_SLEEP)
eventLoop->beforesleep(eventLoop);
// 1.2 select->poll,底层NIO事件轮询
numevents = aeApiPoll(eventLoop, tvp);
// 1.3 eventLoop->aftersleep,调用 server.c->aftersleep
if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
eventLoop->aftersleep(eventLoop);
// 2 文件事件处理 aeFileEvent
for (j = 0; j < numevents; j++) {
aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
int mask = eventLoop->fired[j].mask;
int fd = eventLoop->fired[j].fd;
int fired = 0; /* Number of events fired for current fd. */
int invert = fe->mask & AE_BARRIER;
// 2.1 处理读事件 AE_READABLE
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
fe = &eventLoop->events[fd]; /* Refresh in case of resize. */
}
// 2.2 处理写事件 AE_WRITABLE
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
processed++;
}
}
// 3. 时间事件处理 aeTimeEvent
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
return processed;
}
代码看上去有点多,但其实这个方法做的事情却很简单。aeProcessEvents 函数先处理了网络 IO 读写事件(文件事件),再处理时间事件。
我们先看一下文件事件的处理。先调用 aeApiPoll 函数轮询这段时间触发的 IO 事件,将触发的事件放到 fired 数组中,然后一次遍历这个 fired 数组,分别处理读事件和写事件。
那你可能会想,既然 aeMain 函数会不断调用 aeProcessEvents 函数,会不会陷入死循环当中啊?先告诉你答案,并不会,因为 aeApiPoll 函数会没有网络读写时会阻塞的。那阻塞多长时间合适呢?这就和时间事件有关了,Redis 会计算最近一个时间事件的触发时间,最多阻塞到这个时间点。这也很好理解,保证即使没有网络读写,时间事件也能正常处理。usUntilEarliestTimer 函数,就是用来计算这个触发时间的。
processTimeEvents
时间事件执行就简单多了,依次遍历 timeEventHead 链表,如果到了执行时间就触发任务执行。timeProc 执行函数返回下次的执行时间间隔,单位是毫秒;如果不为 -1,则更新该任务的下次执行时间,否则标记任务为删除状态。
static int processTimeEvents(aeEventLoop *eventLoop) {
int processed = 0;
aeTimeEvent *te;
long long maxId;
te = eventLoop->timeEventHead;
maxId = eventLoop->timeEventNextId-1;
monotime now = getMonotonicUs();
while(te) {
long long id;
// 删除废弃的任务
if (te->id == AE_DELETED_EVENT_ID) {
aeTimeEvent *next = te->next;
if (te->refcount) {
te = next;
continue;
}
if (te->prev)
te->prev->next = te->next;
else
eventLoop->timeEventHead = te->next;
if (te->next)
te->next->prev = te->prev;
if (te->finalizerProc) {
te->finalizerProc(eventLoop, te->clientData);
now = getMonotonicUs();
}
zfree(te);
te = next;
continue;
}
if (te->id > maxId) {
te = te->next;
continue;
}
// 核心:如果定时任务过期,则调用timeProc方法执行任务。
// 如果该方法返回值不为AE_NOMORE=-1,则设置下一次执行时间
if (te->when <= now) {
int retval;
id = te->id;
te->refcount++;
retval = te->timeProc(eventLoop, id, te->clientData);
te->refcount--;
processed++;
now = getMonotonicUs();
if (retval != AE_NOMORE) {
te->when = now + retval * 1000;
} else {
te->id = AE_DELETED_EVENT_ID;
}
}
te = te->next;
}
return processed;
}
epoll管理器
Reids 统一封装了 epoll API,提供了 IO 读写事件的注册及轮询功能。
- aeApiAddEvent:网络读写事件注册。
- aeApiDelEvent:网络读写事件取消注册。
- aeApiPoll:轮询已触发的读写事件。
- aeApiCreate:创建 epoll 管理器。
polling API 定义在 ae_epoll.c、 ae_select.c、 ae_kqueue.c、 ae_evport.c 文件中,分别兼容不同的平台。以 ae_epoll.c 为例,最终都是调用最底层的 epoll_create(创建epoll)、epoll_ctl(注册感兴趣的事件)、epoll_wait(阻塞等待)方法。
aeApiAddEvent
// 注册读或写事件到 epoll 管理器
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0};
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;
ee.events = 0;
mask |= eventLoop->events[fd].mask;
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
return 0;
}
aeApiPoll
// 轮询触发的文件事件,保存到 fired 数组
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + (tvp->tv_usec + 999)/1000) : -1);
if (retval > 0) {
int j;
numevents = retval;
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;
if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE|AE_READABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE|AE_READABLE;
eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
return numevents;
}
总结
今天,我重点分析了 Reids 事件驱动原理,我们复习一下。
AE 库提供了一个轻量级的事件驱动库,提供了文件事件和时间事件的处理逻辑。文件事件就是 NIO 网络读写事件,时间事件则是内部的定时任务等。AE 库可以分为两步部分,一是 aeEventLoop 事件处理器;二是 epoll 管理器。
- aeEventLoop 事件处理器:定义在 ae.c 文件中,保存了文件事件和时间事件。最核心的逻辑是,不断调用 epoll 管理器,轮询已经触发的事件,并依次处理读事件和写事件。
- epoll 管理器:统一封装了 polling API,提供创建 epoll、注册感兴趣的事件、取消事件注册,及已触发的事件轮询等方法。
最后,我用一张思维导图来帮助你理解和记忆事件驱动的逻辑:
参考
https://zhuanlan.zhihu.com/p/517974884?utm_id=0
每天用心记录一点点。内容也许不重要,但习惯很重要!