网上有非常多的redis原理的文章,都写得很好。对于一个c语言爱好者来说,研究redis的源码,如同学生初学写作时研读泰斗的文章一样有必要。到了一定火候可能会仿照泰斗的文风去写作。
本文不是单纯的redis源码介绍,而是将源码中事件机制部分代码提取出来,稍作修改作为独立的模块,应用到我们自己的项目中。
redis的事件包括两类,文件事件和时间事件。文件事件主要是socket连接的套接字上发生的可读和可写事件;时间事件用来处理定时任务。
文件事件的底层根据操作系统的不同在epoll、evport、kqueue、select中选择最佳IO复用实现。比如linux系统中就使用epoll作为其IO复用层。源码中使用类似如下的宏来确定操作系统:
#ifdef __linux__
#define AE_EPOLL 1
#endif //epoll可用,如果用linux系统编译,则ae.c中会include “ae_epoll.c”
#if (defined(__APPLE__) && defined(MAC_OS_X_VERSION_10_6)) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined (__NetBSD__)
#define AE__KQUEUE 1
#endif//kqueue可用
#ifdef __sun
#include <sys/feature_tests.h>
#ifdef _DTRACE_VERSION
#define AE__EVPORT 1
#endif
#endif //evport可用
然后根据这些宏在ae_epoll.c、ae_evport.c、ae_kqueue.c、ae_select.c这几个文件选择一个include到ae.c中,比如linux系统就包含ae_epoll.c来实现IO复用。
因为redis源码中使用事件机制时的调用关系比较复杂,初次接触很难理出头绪,所以笔者简化了源码中的调用关系,让这个事件机制独立出来,并写了一个简单的回声服务端代码(aetest.c)来调用这个事件机制的相关方法:
//linux编译指令: gcc -std=c1x -g -o ae src/ae.c aetest.c
/*假设这几个文件都放在xyzy文件夹下:
cd xyzy
gcc -std=c1x -g -o ae src/ae.c aetest.c
编译后执行以下指令
./ae 5678
此外本例为提供客户端,可以另外打开一个终端使用telnet测试:
telnet 127.0.0.1 5678
你好,世界
你好,世界
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <signal.h>
#include "src/ae.h"
#define BUF_SIZE 1024
#define E_SIZE 100
int serv_sock;//listen的套接字需要在事件处理函数中单独处理,所以定义为全局变量
void error_handling(char *buf);
//文件事件处理函数,本示例非常简单,只处理可读事件
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask);
int main(int argc, char *argv[])
{
signal(SIGHUP, SIG_IGN);//忽略这两个信号
signal(SIGPIPE, SIG_IGN);
struct sockaddr_in serv_adr;
if(argc!=2) {//
printf("正确的调用格式:%s <监听的端口>\n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() 错误");
if(listen(serv_sock, 5)==-1)
error_handling("listen() 错误");
//创建一个事件循环器:其内部逻辑参考ae.c。
aeEventLoop *eventLoop=aeCreateEventLoop(E_SIZE);
//为serv_sock添加读事件处理函数,即有客户端连接时启用accept
if (aeCreateFileEvent(eventLoop, serv_sock, AE_READABLE,acceptTcpHandler,NULL) == AE_ERR)
{
error_handling("创建listen fd文件事件处理函数失败");
}
//死循环处理注册的所有事件
aeMain(eventLoop);
//清理资源
aeDeleteEventLoop(eventLoop);
close(serv_sock);
return 0;
}
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}
//具体的事件处理函数,内部逻辑参考ae.c
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask){
if(fd==serv_sock)//单独处理listen的套接字,添加accept
{
socklen_t adr_sz;
struct sockaddr_in clnt_adr;
adr_sz=sizeof(clnt_adr);
int clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
//为每个accept的套接字注册可读事件处理函数
if (aeCreateFileEvent(el,clnt_sock, AE_READABLE,acceptTcpHandler,NULL) == AE_ERR)
{
error_handling("创建accept fd文件事件处理函数失败");
}
}
else //可读的是accept套接字
{
int str_len, i;
char buf[BUF_SIZE];
str_len=read(fd, buf, BUF_SIZE);
if(str_len==0) // 客户端关闭
{
aeDeleteFileEvent(el, fd, mask);//清理此fd相关的事件注册及资源
close(fd);
printf("fd=%d的客户端关闭 \n", fd);
}
else
{
write(fd, buf, str_len);//回声
}
}
}
代码中的:
//创建一个事件循环器:其内部逻辑参考ae.c。
aeEventLoop *eventLoop=aeCreateEventLoop(E_SIZE);
//为serv_sock添加读事件处理函数,即有客户端连接时启用accept
if (aeCreateFileEvent(eventLoop, serv_sock, AE_READABLE,acceptTcpHandler,NULL) == AE_ERR)
{
error_handling("创建listen fd文件事件处理函数失败");
}
在一般的写法中这里就是添加epoll的相关方法了。redis在其事件机制中封装了这些方法。
下面我们详细分析一下这个事件机制源码,首先它定义了一个事件循环器结构体如下:
/*
事件循环器,负责管理事件
*/
typedef struct aeEventLoop {
int maxfd; //当前已注册的最大文件描述符
int setsize; //该事件循环器允许监听的最大文件描述符
long long timeEventNextId; //下一个时间事件id
time_t lastTime;//上一次执行时间事件的时间,用于判断是否发生系统时钟偏移
aeFileEvent *events; //已注册的文件事件表。数组的索引就是文件描述符
aeFiredEvent *fired; //已就绪的事件表,等待事件循环器处理
aeTimeEvent *timeEventHead;//时间事件表的头节点指针
int stop;//事件循环器是否停止
void *apidata; //存放用于IO复用层的附加数据
aeBeforeSleepProc *beforesleep;//进程阻塞前调用的钩子函数
aeBeforeSleepProc *aftersleep;//进程阻塞后调用的钩子函数
/*
标志位:AE_FILE_EVENTS、AE_TIME_EVENTS、AE_ALL_EVENTS、AE_DONT_WAIT、AE_CALL_BEFORE_SLEEP、AE_CALL_AFTER_SLEEP
*/
int flags; //
} aeEventLoop;
这个结构体包含时间事件和文件事件,时间事件使用双向链表来记录。后面会介绍到。
里面的events字段是一个数组,这个数组的索引就是套接字等的文件描述符,比如描述符数字为5的客户端连接套接字,其相关文件事件就存储在events[5]中。它的结构如下:
typedef struct aeFileEvent {
int mask;/*已注册的文件事件类型: AE_(READABLE|WRITABLE|BARRIER)
通过类似下面的语句转换成系统事件: if (mask & AE_READABLE) ee.events |= EPOLLIN;*/
aeFileProc *rfileProc;//AE_READABLE事件处理函数
aeFileProc *wfileProc;//AE_WRITABLE事件处理函数
void *clientData;//附加数据
} aeFileEvent;
当epoll检测到此套接字上的可读事件时就会调用rfileProc方法处理相关可读事件。而这个处理函数的类型定义如下:
typedef void aeFileProc(struct aeEventLoop *eventLoop, int fd, void *clientData, int mask);
我们返回头去看一下上面的回声服务端代码,我们定义了一个函数:
void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask);
这就是实际项目中读事件处理函数。
在这个简单的回声服务端中,我们使用同一个函数来处理listen套接字和accept套接字。
一般的服务端中我们会写一个死循环来不断检查有没有可读事件发生,redis的事件机制也是如此,它定义了一个方法:
/*
循环调用处理函数
*/
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
aeProcessEvents(eventLoop, AE_ALL_EVENTS| AE_CALL_BEFORE_SLEEP| AE_CALL_AFTER_SLEEP);
}
}
方法中不断调用aeProcessEvents函数来处理事件,这个函数中不仅处理文件事件,同时也处理时间事件。其定义如下:
/*
程序运行期间要循环调用下面的事件处理函数
flags参数的含义参考ea.h第47行
*/
int aeProcessEvents(aeEventLoop *eventLoop, int flags)
{
int processed = 0, numevents;
/*不处理时间事件也不处理文件事件,就直接返回0 */
if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
/*有待处理的文件描述符,或者设置了阻塞执行的时间事件*/
if (eventLoop->maxfd != -1 || ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) //AE_DONT_WAIT表示非阻塞
{
int j;
aeTimeEvent *shortest = NULL;
struct timeval tv, *tvp;
if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
shortest = aeSearchNearestTimer(eventLoop);//查找最先到期的时间事件
if (shortest) {
//找到了最近的时间事件,就用它的到期时间计算该进程需要阻塞的时间
long now_sec, now_ms;
aeGetTime(&now_sec, &now_ms);
tvp = &tv;
//还有多少毫秒到期
long long ms =(shortest->when_sec - now_sec)*1000 +shortest->when_ms - now_ms;
if (ms > 0) {
tvp->tv_sec = ms/1000;
tvp->tv_usec = (ms % 1000)*1000;
} else {
tvp->tv_sec = 0;
tvp->tv_usec = 0;
}
} else {//没有找到即将到期的时间事件
/* 如果设置了AE_DONT_WAIT(非阻塞)就把阻塞时间设为0*/
if (flags & AE_DONT_WAIT) {
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
} else {
/* 没设置AE_DONT_WAIT(非阻塞)就把阻塞时间设为NULL表示一直阻塞,等待有事件发生 */
tvp = NULL; /* wait forever */
}
}
/*
上面处理的flags是外部调用传入的flags。下面检查事件循环器自身设置的flags。
如果事件循环器自身的flags设置了非阻塞,则阻塞时间设为0,不管传入的flags是啥
*/
if (eventLoop->flags & AE_DONT_WAIT) {//
tv.tv_sec = tv.tv_usec = 0;
tvp = &tv;
}
/*
如果循环器设置了阻塞前钩子函数。并且标志位中设置了AE_CALL_BEFORE_SLEEP标志位
就执行阻塞前钩子函数
*/
if (eventLoop->beforesleep != NULL && flags & AE_CALL_BEFORE_SLEEP)
eventLoop->beforesleep(eventLoop);//阻塞前钩子函数
/*
调用io复用层的设置函数,设置阻塞 (比如epoll_wait函数)
如果有文件事件就绪,该方法返回就绪文件事件数量。
并且会把相关文件描述符和就绪的事件mask记入eventLoop.fired数组中
如果只是时间到期,则返回0
*/
numevents = aeApiPoll(eventLoop, tvp);
//阻塞时间到了,或者epoll等IO复用层被事件唤醒了
/*
如果循环器设置了阻塞后钩子函数。并且标志位中设置了AE_CALL_AFTER_SLEEP标志位
就执行阻塞后钩子函数
*/
if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
eventLoop->aftersleep(eventLoop);//阻塞后钩子函数
/*有就绪的文件事件,逐个处理*/
for (j = 0; j < numevents; j++) {
int fd = eventLoop->fired[j].fd;//就绪的文件描述符
//先读取该文件描述符上注册的文件事件
aeFileEvent *fe = &eventLoop->events[fd];
int mask = eventLoop->fired[j].mask;//就绪的事件mask
int fired = 0; /* 是否处理过读写事件了 */
/* 该描述符是否设置了先写后读,参考tsae.h第41行*/
int invert = fe->mask & AE_BARRIER;
/* 就绪的事件包含可读事件,并且未设置先写后读,就先调用读文件方法*/
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
fe = &eventLoop->events[fd]; /* Refresh in case of resize. */
}
/* 就绪的事件包含可写事件. */
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {//如果已经处理过读事件,则需要保证读写事件不重复,才继续执行写事件
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
/* 设置了先写后读,则读事件的处理放到写事件后面来处理*/
if (invert) {
fe = &eventLoop->events[fd]; /* Refresh in case of resize. */
if ((fe->mask & mask & AE_READABLE) && (!fired || fe->wfileProc != fe->rfileProc))
{
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
processed++;
}
}//文件事件处理结束
/* 检查并执行时间事件 */
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
return processed; /* return the number of processed file/time events */
}
参数flags的含义如下:
/*
下面标志位主要用于eventLoop.flags
*/
#define AE_FILE_EVENTS 1 //文件事件。(1<<0)就是1
#define AE_TIME_EVENTS 2 //时间事件。源码中写的是(1<<1)这样写为了易读,一看就知道是第二位置1
#define AE_ALL_EVENTS 3 //全部事件:(文件|时间)。源码中写的是(AE_FILE_EVENTS|AE_TIME_EVENTS)
#define AE_DONT_WAIT 4 //是否阻塞进程,设置了此标志位的话就表示不阻塞而立即返回(1<<2)
#define AE_CALL_BEFORE_SLEEP 8//阻塞前是否调用eventLoop.beforesleep钩子函数(1<<3)
#define AE_CALL_AFTER_SLEEP 16//阻塞后是否调用eventLoop.aftersleep钩子函数(1<<4)
代码中对于时间事件的处理逻辑主要是,遍历链表找到离得最近的时间事件到期时间,算出还剩多少毫秒,将这个毫秒数注册到epoll中,如果epoll中没有文件事件发生,也会在这个时间到期时返回0,也就是代码中:
numevents = aeApiPoll(eventLoop, tvp);
这个numevents会是0。(有文件事件发生的话,numevents=文件事件总)那如果没有定义阻塞前后的钩子函数的话,代码就直接执行到:
/* 检查并执行时间事件 */
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
去执行定时任务了。了解定时任务的处理逻辑前,先看一下时间事件链表节点的结构:
/*
一个时间事件的信息
*/
typedef struct aeTimeEvent {
long long id; //时间事件的id
long when_sec; //时间事件下一次执行的秒数(事件戳)
long when_ms; //零的毫秒数
aeTimeProc *timeProc;//时间事件处理函数
aeEventFinalizerProc *finalizerProc;//时间事件终结函数
void *clientData;//客户端传入的附加数据
struct aeTimeEvent *prev;//前一个时间事件
struct aeTimeEvent *next;//后一个时间事件
int refcount; /* 引用计数,防止在递归时间事件调用中释放计时器事件 */
} aeTimeEvent;
然后看一下时间事件的处理逻辑:
/*处理时间事件 */
static int processTimeEvents(aeEventLoop *eventLoop) {
int processed = 0;
aeTimeEvent *te;
long long maxId;
time_t now = time(NULL);
/* 上一次执行时间事件的时间比当前时间还大,说明系统时间由于系统时钟偏移等原因混乱了
这里将所有时间事件的秒数置0,这样会导致时间事件提前执行。之所以这样做,是因为提前执行比延后执行危害小*/
if (now < eventLoop->lastTime) {
te = eventLoop->timeEventHead;
while(te) {
te->when_sec = 0;
te = te->next;
}
}
eventLoop->lastTime = now;//记录本次执行时间
te = eventLoop->timeEventHead;
maxId = eventLoop->timeEventNextId-1;//当前最大的时间事件id
while(te) {
long now_sec, now_ms;
long long id;
/* 在链表中移除已经作了删除标记的事件,参考上面的aeDeleteTimeEvent方法 */
if (te->id == AE_DELETED_EVENT_ID) {
aeTimeEvent *next = te->next;
/* 引用计数不为0的不能移除 */
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);
//清理资源
free(te);
te = next;
continue;//继续处理下一个时间事件
}
/* 我的理解是可能在处理事件期间有正在添加的事件,暂时不处理正在添加的事件 */
if (te->id > maxId) {
te = te->next;
continue;
}
//判断时间有没有到期
aeGetTime(&now_sec, &now_ms);
if (now_sec > te->when_sec ||(now_sec == te->when_sec && now_ms >= te->when_ms))
{
int retval;
id = te->id;
te->refcount++;//引用计数加1,这里考虑要不要改成原子操作
/*
执行时间事件的逻辑,该逻辑返回值为再次执行该操作的间隔时间毫秒数。
如果间隔时间为AE_NOMORE(-1)表示没有下次了,可以清理该事件了。
*/
retval = te->timeProc(eventLoop, id, te->clientData);//执行事件处理函数
te->refcount--;//处理结束引用计数减1
processed++;//处理事件数,这里考虑要不要改成原子操作
if (retval != AE_NOMORE) {//!=-1表示再间隔tetval毫秒还要执行此逻辑,所以就添加一个新的事件到链表中
aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
} else {//retval == AE_NOMORE,标记删除,等引用计数为0时清理该事件
te->id = AE_DELETED_EVENT_ID;
}
}
te = te->next;
}
return processed;
}
这些是整个事件机制的主要逻辑,除此之外还有钩子函数、描述符数组的扩容等功能,本文的源码中都有详细注释,在此不再赘述。
上面的回声服务端只使用了可读文件事件。作为练习,读者可自行实现定时任务做心跳、阻塞前后钩子函数来记录日志等功能。
笔者虽然是工作多年的老猿,但毕竟才疏学浅,又是初次执笔,文中多有弊陋,还望各位朋友海涵,更盼大咖批评指正。
(本文完整的源代码可在我的资源中查看下载)