写在最前面:重新开学去学习一些经典的开源系统,通过学习这些经典和常用的开源系统来提升自己的技术能力和技术思维。这些开源系统也可能是我们工作中经常遇到的,通过学习他们的实现原理和设计思路,能够更好的驾驭这些开源系统,当然更重要的是学习他的思想,通过学习这些思想可以帮助我们提供系统的设计能力。
以前也学习过很多开源系统的源代码,但是都是从一个已经很复杂的系统开始的,往往只学习了一部分或者某一个模块。很难全面的掌握一个完整的开源系统,因为一个很成熟的开源系统功能已经很复杂,而且代码量很大,很难一下子全部掌握,并且随着时间的推移很多都没有全部坚持下来,很大内容可能也是学习到一知半解。没有从根本上进行掌握开源系统的精髓。所以这一次选择从最小版本开始学习,先把最基础和最重要的功能学会了,掌握了整体的架构。然后在跟进代码的更新记录进行组建的深入学习,力求掌握每一个深入的细节。
1.Makefile文件
这个文件就是帮助我们编译和建立最终可执行文件的,通过make工具来执行这个文件里面定义的建立指令。这个文件很简单,我们只需要掌握最基本的make语法就能够完全看懂,随着以后代码和功能的增加,我们可以慢慢学习和分析,代码如下:
DEBUG?= -g
CFLAGS?= -O2 -Wall -W -DSDS_ABORT_ON_OOM
CCOPT= $(CFLAGS)
OBJ = adlist.o ae.o anet.o dict.o redis.o sds.o picol.o
PRGNAME = redis-server
all: redis-server
# Deps (use make dep to generate this)
picol.o: picol.c picol.h
adlist.o: adlist.c adlist.h
ae.o: ae.c ae.h
anet.o: anet.c anet.h
dict.o: dict.c dict.h
redis.o: redis.c ae.h sds.h anet.h dict.h adlist.h
sds.o: sds.c sds.h
redis-server: $(OBJ)
$(CC) -o $(PRGNAME) $(CCOPT) $(DEBUG) $(OBJ)
@echo ""
@echo "Hint: To run the test-redis.tcl script is a good idea."
@echo "Launch the redis server with ./redis-server, then in another"
@echo "terminal window enter this directory and run 'make test'."
@echo ""
.c.o:
$(CC) -c $(CCOPT) $(DEBUG) $(COMPILE_TIME) $<
clean:
rm -rf $(PRGNAME) *.o
dep:
$(CC) -MM *.c
test:
tclsh test-redis.tcl
通过上面的文件代码可以看出,最终的目标就是建立一个redis-server的二进制可运行的文件,这就是我们需要构建的最终的可执行程序。redis-server目标又依赖obj,也就是各个c语言代码编译生成的二进制文件,最终把这些二进制文件通过连接程序把它们连接起来。
2.redis.c文件
这个文件就是main函数所在的文件,我们都知道c语言的入口函数就是main函数,所以从这个入口函数分析代码是最好的了,只需要根据程序的执行流程去读代码,可能需要很多数据结构,不过没有关系,等我们在程序运行的过程中使用到的时候在分析就ok。那么现在我们就开始分析这个main函数,也就是启动函数,先看下它的代码:
int main(int argc, char **argv) {
initServerConfig();
initServer();
if (argc == 2) {
ResetServerSaveParams();
loadServerConfig(argv[1]);
redisLog(REDIS_NOTICE,"Configuration loaded");
} else if (argc > 2) {
fprintf(stderr,"Usage: ./redis-server [/path/to/redis.conf]\n");
exit(1);
}
redisLog(REDIS_NOTICE,"Server started");
if (loadDb("dump.rdb") == REDIS_OK)
redisLog(REDIS_NOTICE,"DB loaded from disk");
if (aeCreateFileEvent(server.el, server.fd, AE_READABLE,
acceptHandler, NULL, NULL) == AE_ERR) oom("creating file event");
redisLog(REDIS_NOTICE,"The server is now ready to accept connections");
aeMain(server.el);
aeDeleteEventLoop(server.el);
return 0;
}
怎么样?很简单吧,整个main函数不过20多一点行,还包括一些容错处理的代码。main函数也很直接,第一行代码就直接调用函数initServerConfig进行服务器端的配置初始化,那么看看这个函数:
static void initServerConfig() {
server.dbnum = REDIS_DEFAULT_DBNUM;//初始化redis的数据库个数16
server.port = REDIS_SERVERPORT;//端口6379
server.verbosity = REDIS_DEBUG;//日志级别debug
server.maxidletime = REDIS_MAXIDLETIME;//最大空闲时间60*5,也是客户端的超时时间
server.saveparams = NULL;//没有持久化策略
server.logfile = NULL; /* NULL = log on standard output */
ResetServerSaveParams();
appendServerSaveParams(60*60,1); /* save after 1 hour and 1 change */
appendServerSaveParams(300,100); /* save after 5 minutes and 100 changes */
appendServerSaveParams(60,10000); /* save after 1 minute and 10000 changes */
}
这个函数首先就使用了一个server的变量,那么看一下这个变量在哪儿定义,代码如下:
static struct redisServer server; /* server global state */
可以看出定义的是一个redisServer的全局变量,那么现在就需要看一下redisServer的结构体是怎么定义的了。代码如下:
/* Global server state structure */
struct redisServer {
int port; /* 启动监听的端口,对外提供服务的端口 */
int fd;
dict **dict;//存放数据的哈希表
long long dirty; /* changes to DB from the last save */
list *clients;//客户端链表
char neterr[ANET_ERR_LEN];//网络错误信息存储,最多256个char
aeEventLoop *el;//事件循环结构体
int verbosity;
int cronloops;
int maxidletime;//最大空闲时间
int dbnum;//数据库的数量
list *objfreelist; /* A list of freed objects to avoid malloc() */
int bgsaveinprogress;
time_t lastsave;//最后持久化时间
struct saveparam *saveparams;//持久化参数
int saveparamslen;
char *logfile;//日志文件路径
};
这个结构体就代表了运行的一个redis服务器,里面字段是什么意思就看代码里面的注释(英文注释是原来代码里面就有的,中文是我自己理解添加上的)。其中里面有涉及到aeEventLoop、dict、saveparam、list、time_t结构体,那下面需要把这些结构体也需要详细学习。先看dict吧,因为它在结构体里面最先出现,如下:
typedef struct dict {
dictEntry **table;//存放哈希表key/value数据
dictType *type;//操作函数
unsigned int size;//哈希表的大小
unsigned int sizemask;
unsigned int used;//已经使用的
void *privdata;
} dict;
真麻烦,里面又有新的结构体,谁叫我们要全面无死角的掌握源码呢?不要怕麻烦,这也是学习的过程,看看优秀的源代码是怎样定义数据结构的,而且掌握这些结构体的数据结构也是后面阅读其他代码必不可少的。这里面还有只有两个新结构体出现(是不是又害怕出现新结构体,不要怕,越多越好,学习就越多涩),还是按照顺序来学习,那么就是dictEntry,看它的定义如下:
typedef struct dictEntry {
void *key;
void *val;
struct dictEntry *next;
} dictEntry;
尼玛,终于没有新结构体了吧。不过里面有一个嵌套自己的结构体字段。这个结构体很简单吧,只有三个字段,前两个字段是void类似的指针(不懂指针,那就是不懂c语言,不懂c语言那就干脆不要看redis,想看?那么自己花时间去补补c语言的基础语法知识吧,其中指针最难懂也最难使用,不过这个就是c语言的精髓了),这两个字段分别代表什么意思呢?从字段名称很容易看出涩,分别表示key和value涩。key和value就很容易想到这是什么数据结构了吧(这个就是字典数据结构的一项),就是可以很容易通过key来存储和获取value,那么为什么这个两个字段都是要void类型的指针呢?大家都知道void*是c语言中的万能数据类型指针,这样key和value都是可以是任意的数据类型指针了涩,那么我们就可以使用这个数据类型来存放任意数据类型的键值对了。第三个字段是指向下一个dictEntry结构体的指针,这样就可以形成单向的链表了,毕竟要存放很多key/value涩。下面继续看下一个结构体dictType:
typedef struct dictType {
unsigned int (*hashFunction)(const void *key);//hash函数
void *(*keyDup)(void *privdata, const void *key);//key复制
void *(*valDup)(void *privdata, const void *obj);//value复制
int (*keyCompare)(void *privdata, const void *key1, const void *key2);//key比较
void (*keyDestructor)(void *privdata, void *key);//key销毁
void (*valDestructor)(void *privdata, void *obj);//value销毁
} dictType;
这个结构体没有字段,全部是一些函数指针,这些函数指针干什么使用的?刚才不定义了dictEntry的数据结构涩,那么总还需要一些函数或方法来操作这些数据结构涩(如果学习过数据结构就很好理解了)。具体每一个函数指针的含义看上面代码里面的注释。好了,然后在回过头看dict结构体就很清晰,具体情况上面的注释。
然后继续回到redisServer结构体,下一个结构体就是list了,看名字也很容易看出这是一个链表,用这个链表来存储所有连接到这个服务器端的客户端。我们来看看redis最初是怎么定义和实现list的,如下:
typedef struct list {
listNode *head;//链表的头结点
listNode *tail;//链表的尾节点
void *(*dup)(void *ptr);//复制链表
void (*free)(void *ptr);//释放内存
int (*match)(void *ptr, void *key);//匹配
int len;//链表的长度,方便知道有多少节点,而不用遍历来计算了。
} list;
可以看到list里面分别有头尾节点,我们先看看链表节点listNode的定义:
typedef struct listNode {
struct listNode *prev;//指向前一个节点
struct listNode *next;//指向下一个节点
void *value;//存储具体的值,void指针可以存储任意类型数据
} listNode;
这个listNode有那个字段,具体含义看注释,通过这个节点就可以组成我们非常熟悉的双向链表了,并且每一个节点都可以存储一个任意类型的数据类型,不过通常一个链表存储的数据类型都是一样的,这样方便存储和取出并且转换成相应的数据类型(使用链表的人肯定知道里面存储的是什么样的数据类型)。在看list,通过头结点和尾节点来组织一个完整的链表,然后通过定义一些函数指针来操作这个链表。
继续看redisServer中包含的下一个结构体aeEventLoop,
/* State of an event based program */
typedef struct aeEventLoop {
long long timeEventNextId;//基于时间的下一个id
aeFileEvent *fileEventHead;//基于文件事件头
aeTimeEvent *timeEventHead;//基于时间事件头
int stop;//是否停止事件循环处理
} aeEventLoop;
里面又包含其他两个结构体,先看第一个aeFileEvent:
/* File event structure */
typedef struct aeFileEvent {
int fd;//文件句柄
int mask; /* one of AE_(READABLE|WRITABLE|EXCEPTION) */
aeFileProc *fileProc;//文件事件的处理过程函数
aeEventFinalizerProc *finalizerProc;//文件事件完成以前执行函数
void *clientData;//存放客户端数据
struct aeFileEvent *next;//下一个文件事件,通过这个字段可以组成文件事件链表
} aeFileEvent;
文件事件结构体主要就是记录基于文件发生的一些事件,例如可读、可写等事件,每一个事件发生的时候都有可能需要触发一些操作,所谓的事件响应,所以结构体里面定义了一个处理函数来关联这个文件事件,看一下这个函数指针的定义:
typedef void aeFileProc(struct aeEventLoop *eventLoop, int fd, void *clientData, int mask);
这个函数指针指向的函数有四个参数,分别是上面我们看过的aeEventLoop结构体、文件句柄fd、客户端数据和mask。后面三个感觉就是文件事件结构体里面的三个字段,按道理有结构体就可以得到这些数据了,为什么还有单独通过函数参数进行传递呢?现在也不太清楚,后面看具体使用的时候可能会清楚,先留一个疑问在这里。除了文件事件处理过程函数指针,还有一个aeEventFinalizerProc函数指针,这个函数指针主要用于处理文件事件介绍前的一些操作吧,例如close文件、释放内存等事件收尾工作吧,类似C++里面的析构函数吧,看一下它的定义:
typedef void aeEventFinalizerProc(struct aeEventLoop *eventLoop, void *clientData);
这个函数指针就只有两个参数了,除了文件事件结构体就是客户端数据了,看样子就是要方式这个数据内存。刚才介绍的两个函数指针具体实现还是需要看具体事件的处理逻辑了,只有真正赋值给这两个函数指针的时候才能知道具体的实现。这个有点类似java里面的接口,只定义了方法的签名,而没有具体的实现。java里面通过实现接口来实现具体的方法逻辑,c语言里面就是通过动态的给具体的函数指针赋值来觉得具体的函数逻辑。下面继续看另一个时间事件结构体:
/* Time event structure */
typedef struct aeTimeEvent {
long long id; /* time event identifier. */
long when_sec; /* seconds */
long when_ms; /* milliseconds */
aeTimeProc *timeProc;
aeEventFinalizerProc *finalizerProc;
void *clientData;
struct aeTimeEvent *next;
} aeTimeEvent;
和文件事件结构体产不多,都有一个事件id,还有两个函数指针、客户端数据和指向下一个时间事件的指针。不同的是处理过程函数指针定义不一样,看一下:
typedef int aeTimeProc(struct aeEventLoop *eventLoop, long long id, void *clientData);
和文件事件过程处理函数指针定义差不多。下面理解一下文件事件和时间事件的不同,首先文件事件肯定是基于一个文件的,一个文件可读或者可写了可以产生一个事件,那么时间事件是什么呢?我们通常有一些定时任务,那么这些任务就可以用时间事件来完成,所以时间事件是基于时间的。文件事件有一个字段是fd标示唯一的一个文件,但是这个唯一的文件可能产生多个不同的事件。我们知道在linux里面socket其实也是通过文件句柄来标示的,所以我们在接收到数据了就产生可以读的事件提示我们去接收并且处理数据了,那么发送的时候其实也是有发送缓冲区的,当缓冲区满了我们是不能再写数据了,知道发送缓冲区清空产生可写事件。所以redis实现把两种事件分开来定义,并且都可以组成事件链表,这样保证事件产生很大时可以依次处理。所以结构体aeEventLoop就维持了所有的文件和时间事件,组成一个事件循环的结构体,有事件时就依次进行处理,没有就等待。通过猜想,后面肯定有专门的线程来遍历这个结构体中的事情并且调用这些事件里面所对应的函数指针所指向的函数。具体怎么处理我们后面用到的时候在看。下面继续看redisServer结构体里面的下一个结构体saveparam。
struct saveparam {
time_t seconds;
int changes;
};
这个结构体很简单了,但是也看不出具体的用途,那么久后面具体使用的时候看了。不过尅先猜想一下,总共两个字段,一个是描述时间的,一个是描述改变的。那肯定是和时间变化相关的功能了。
ok,到现在redis里面所有嵌套的结构体我们都分析清楚了,在回过头看看这个结构体,每一个字段的含义就看上面代码里的注释了。我们现在整体分析一下,redisServer通过名称很容易就看出是代表或者描述一个redis的服务器,动态的话就可以说成是一个redis服务器的进程,那么肯定只有一个这个结构体的实例。
/* Global vars */
static struct redisServer server; /* server global state */
这个结构体里面包含了很多信息,例如有服务器监听端口、存放具体数据的哈希表、所有连接到服务器的客户端、网络错误信息、事件循环、日志文件路径等。当然因为是第一版肯定功能简单,以后功能变复杂、这个结构体肯定也会跟着变复杂。不过没有关系,现在把基础框架搭建好了,慢慢的添砖加瓦就很容易了,而且现在这些功能都是最核心和基础的功能。
现在继续回到函数initServerConfig,这个函数主要作用就是初始化刚才我们分享的redis服务器结构体,具体函数里面的代码可以看上面代码的注释,看一下这个函数里面调用的第一个函数ResetServerSaveParams,
static void ResetServerSaveParams() {
free(server.saveparams);
server.saveparams = NULL;
server.saveparamslen = 0;
}
这个函数从名称看是重置SaveParams,也就是持久化方案策略相关的参数,但是看看前面的代码已经有server.saveparams = NULL;这里又来一次free是不是多此一举,但是free了就必须又要server.saveparams = NULL;但是又想一想当时作者为什么要放这么一个多此一举的函数呢?我能够想到的就是为以后的扩张和以后的在线重新初始化redis服务器。在总结一下,就是首先把server.saveparams设置为NULL,然后free,然后在设置为NULL。这样做的的目的前面已经说过了可能是为了扩展性,但是为什么要先设置为NULL呢?因为不设置为NULL,那么server.saveparams就是执行的内存是不确定的,free不确定的内存是很危险的,但是free了以后又指向不确定的内存了,所以又需要重新设置为NULL。并且把在把长度设置为0。
下面继续看下一个函数,也就是真正的初始化刚才reset的server.saveparams参数的函数了,如下:
static void appendServerSaveParams(time_t seconds, int changes) {
server.saveparams = realloc(server.saveparams,sizeof(struct saveparam)*(server.saveparamslen+1));//分配内存,失败就oom
if (server.saveparams == NULL) oom("appendServerSaveParams");
server.saveparams[server.saveparamslen].seconds = seconds;
server.saveparams[server.saveparamslen].changes = changes;
server.saveparamslen++;//长度加1
}
这个函数就是根据传递尽量的时间(秒)和改变的次数来初始化和server.saveparams,首先通过realloc分配内存,失败就oom处理,成功就保持时间和改变次数的值,最后长度自增1.这里为什么使用realloc,因为可能多次调用这个函数,就会多次分配内存,在原来已有内存的基础上改变分配,具体参考realloc函数说明。initServerConfig调用了三次,那么现在是时候解说一下这个字段的具体函数安逸。其实这个字段主要表达的意思是redis把内存中保存的数据持久化的磁盘的策略:就是在多长时间内改变多少次就执行持久化。initServerConfig中分别以(60*60,1)(300,100)(60,10000)调用,那么就是分别表示一小时内有一次改变、5分钟内有100次改变、1分钟内有10000次就执行持久化。为什么会有这么奇怪的策略呢?这个主要还是在性能和数据安全性的折中考虑,因为持久化很耗性能,但是存放在内存数据又容易丢失。所以才有这么灵活的持久化策略,这样用户可以根据自己的应用场景选择最合适的方案。当然性能是redis最大的优势,持久化只是附带功能。后面随着redis功能的强调还会有更多的优化和考虑。到此为止终于把服务器配置初始化函数分析完了,代码很少,但是涉及到的知识点还是很多的。继续回到main函数就是下一个函数了,看下一节。