Redis进程启动源码浅析
注:
- 所列举代码皆出自Redis-5.0.13
- 只分析关键路径,如有错误,欢迎指正
尽管现在Redis的资料浩如烟海,但是整体的、通俗的且以Javaer视角娓娓道来的源码解析,却少之又少。技术博客的撰写常常是一个令人沮丧的折衷过程,其中不乏知识点覆盖面、遣词造句的质量、行文风格等诸多方面,笔者能力有限只能尽量顾全。
所有程序都一样,Redis梦开始的地方自然也是main函数,笔者就以作者的原话来开篇:以下是启动 Redis 服务器的最重要步骤。
initServerConfig()
设置server
结构的默认值。initServer()
分配操作、设置监听套接字等所需的数据结构。aeMain()
启动监听新连接的事件循环。
初始化配置
initServerConfig()
函数就是对Redis的各种配置信息设置一个初始值。如果没有指定配置文件或调用命令修改配置信息,此函数设置的信息会作为配置的默认值。这个函数名让人有些误解,其实除了上述功能,该函数还会设置一些Redis全局信息,CommandTable也是此时创建的。该函数功能如下:
- 申请三个mutex互斥锁,但是笔者查了当前源码发现好像并没有使用过
pthread_mutex_init(&server.next_client_id_mutex, NULL);
pthread_mutex_init(&server.lruclock_mutex, NULL);
pthread_mutex_init(&server.unixtime_mutex, NULL);
- 获取全局状态下获取unix时间的缓存值,按照官方解释:随着虚拟内存和老化,每次对象访问时都会将当前时间存储在对象中,并且不需要准确性。访问全局变量比调用time(NULL)快得多。
updateCachedTime(1);
- 生成RunId,一个SHA1大小的随机值。Server结构体声明RunId时候特别多声明一字节,char runid[CONFIG_RUN_ID_SIZE + 1],最后一位添加'\0'结束标识。
getRandomHexChars(server.runid, CONFIG_RUN_ID_SIZE);
server.runid[CONFIG_RUN_ID_SIZE] = '\0';
- 使用新的随机复制ID更改当前实例复制ID。
changeReplicationId();
clearReplicationId2();
- 茫茫多的配置属性设置默认值
- 初始化命令表,针对经常查找的命令提供快捷访问的指针
server.commands = dictCreate(&commandTableDictType, NULL);
server.orig_commands = dictCreate(&commandTableDictType, NULL);
populateCommandTable();
server.delCommand = lookupCommandByCString("del");
server.multiCommand = lookupCommandByCString("multi");
server.lpushCommand = lookupCommandByCString("lpush");
server.lpopCommand = lookupCommandByCString("lpop");
server.rpopCommand = lookupCommandByCString("rpop");
server.zpopminCommand = lookupCommandByCString("zpopmin");
server.zpopmaxCommand = lookupCommandByCString("zpopmax");
server.sremCommand = lookupCommandByCString("srem");
server.execCommand = lookupCommandByCString("exec");
server.expireCommand = lookupCommandByCString("expire");
server.pexpireCommand = lookupCommandByCString("pexpire");
server.xclaimCommand = lookupCommandByCString("xclaim");
server.xgroupCommand = lookupCommandByCString("xgroup");
Redis中专门有一个结构体用于描述各种命令,在服务启动之初会立刻初始化redisCommandTable[]
,里面放置了所有支持的命令。
/* 详情见server.c文件 */
struct redisCommand redisCommandTable[] = {
...
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
...
{"mset",msetCommand,-3,"wm",0,NULL,1,-1,2,0,0},
...
}
struct redisCommand {
char *name;
/* 命令对应的回调函数,如setCommand */
redisCommandProc *proc;
/* 限制命令个数 -N表示至少N个参数,包含命令本身 */
int arity;
/* 字符串形式设置命令属性,可以标记为这个命令只读,或者可读可写 */
char *sflags; /* Flags as string representation, one char per flag. */
/* 将sflags转化成整形,方便各种属性之间的逻辑运算,程序内部自动解析, 见populateCommandTable函数 */
int flags; /* The actual flags, obtained from the 'sflags' field. */
redisGetKeysProc *getkeys_proc;
/* What keys should be loaded in background when calling this command? */
int firstkey; /* The first argument that's a key (0 = no keys) */
int lastkey; /* The last argument that's a key */
int keystep; /* The step between first and last key */
/* 命令总耗时,命令执行总次数 */
long long microseconds, calls;
};
全部放到数组,会有一个明显的短板,就是每次查找一个命令处理函数的时间复杂度都是O(n),而且这是最频繁的操作,显然无法接受。为了尽可能的提速,populateCommandTable
函数会遍历该数组建立Hash结构,key是命令名称,value就是相对应的redisCommand对象。key的hash算法参见dictSdsCaseHash
函数,采用拉链法解决Hash碰撞问题。如此一来,命令处理函数的查找复杂度降低到O(1)。
启动参数处理
解析参数,大概过程(注意是大概我真的不想看这段代码)如下:
- 判断
redis-check-rdb
、redis-check-aof
是不是第一个参数的字串决定是否以redis-check-rdb/aof模式启动,该模式下检查完rdb、aof文件进程会立即退出,因为对应的函数结尾都会执行exit(0)
if (strstr(argv[0], "redis-check-rdb") != NULL)
redis_check_rdb_main(argc, argv, NULL);
else if (strstr(argv[0], "redis-check-aof") != NULL)
redis_check_aof_main(argc, argv);
- 如果指明配置文件,通过
getAbsolutePath
函数获取文件相对路径设置到configfile
属性中,然后执行loadServerConfigloadServerConfig
函数,该函数会判断configfile是否存在,如果存在则读取该文件,修改对应配置信息。
后台执行
Redis Server根据daemonize
、supervised
两个配置项决定是否后台执行,默认不需要。一般我们仅仅只需要修改daemonize
即可达到后台运行的目的。
/*
* supervised_mode默认SUPERVISED_NONE
* redisIsSupervise此时会返回0,daemonize是决定性因素
*/
server.supervised = redisIsSupervised(server.supervised_mode);
int background = server.daemonize && !server.supervised;
if (background) daemonize();
主要分析怎么怎么让一个应用后台执行:
void daemonize(void) {
int fd;
/*
* fork调用一次,会在父子两个进程各自返回一次
* 父进程:成功:返回子进程pid; 失败:返回-1
* 子进程:成功:返回0; 失败:返回-1
* 如果子进程想要知道自己的pid,linux提供getpid() system call
*/
if (fork() != 0) exit(0);
/* 创建一个新的会话 */
setsid();
/*
* 重定向stdin、stdout、stderr
*/
if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
int newStdIn = dup2(fd, STDIN_FILENO);
int newStdOut = dup2(fd, STDOUT_FILENO);
int newStdErr = dup2(fd, STDERR_FILENO);
if (fd > STDERR_FILENO) close(fd);
}
}
上述代码可知,结局不重要当前进程都一定会退出:
- 如果返回-1说明fork调用失败,直接结束后续流程;
- 如果fork调用成功,父进程返回子进程pid一定不为零;
子进程会继承父进程的SessionId以及打开的终端,setsid函数会创建一个新的SessionId,摆脱父进程的终端控制,此时关闭终端,Redis进程不会受到影响,如有必要结束进程,执行kill pid命令即可。
打开"/dev/null"文件,将标准输入、标准输出、标准错误全部重定向到该文件,可以查看Linux /proc/pid/fd来验证一下。
initServer()
处理信号
信号在最早的Unix系统中即被引入,用于用户态进程间通信。内核也用信号通知进程系统所发生的事件。 信号是很短的消息,可以被发送到一个或一组进程。使用信号主要两个目的(不互斥):
- 让进程知道已经发生的一个特定的事件
- 强迫进程执行他自己代码中的信号处理程序
Linux下部分信号对照表:
编号 | 信号名称 | 缺省操作 | 解释 | POSIX |
---|---|---|---|---|
1 | SIGHUP | Terminate | 挂起控制终端或进程 | 是 |
2 | SIGINT | Terminate | 来自键盘的中断 | 是 |
4 | SIGILL | Terminate | 非法指令 | 是 |
10 | SIGBUS | Dump | 总线错误 | 否 |
11 | SIGSEGV | Dump | 无效的内存引用 | 是 |
13 | SIGPIPE | Terminate | 向无读者的管道写 | 是 |
15 | SIGTERM | Terminate | 进程终止 | 是 |
Redis忽略了SIGHUP、SIGPIPE这两个信号,然后重新定义了SIGTERM、SIGINT、SIGSEGV、SIGBUS、SIGFPE、SIGILL这几个信号的处理行为。
/**
* #define SIG_IGN (void (*)(int))1
* void (*)(int)表示返回类型为void,参数类型为int的函数指针
* (void (*)(int))1表示将整数1,强转为上述类型
* 在signal系统调用中SIG_IGN这个宏表示忽略
*/
void initServer(void) {
int j;
signal(SIGHUP, SIG_IGN);
signal(SIGPIPE, SIG_IGN);
setupSignalHandlers();
......
}
void setupSignalHandlers(void) {
struct sigaction act;
/* When the SA_SIGINFO flag is set in sa_flags then sa_sigaction is used.
* Otherwise, sa_handler is used. */
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
act.sa_handler = sigShutdownHandler;
sigaction(SIGTERM, &act, NULL);
sigaction(SIGINT, &act, NULL);
#ifdef HAVE_BACKTRACE
sigemptyset(&act.sa_mask);
act.sa_flags = SA_NODEFER | SA_RESETHAND | SA_SIGINFO;
act.sa_sigaction = sigsegvHandler;
sigaction(SIGSEGV, &act, NULL);
sigaction(SIGBUS, &act, NULL);
sigaction(SIGFPE, &act, NULL);
sigaction(SIGILL, &act, NULL);
#endif
return;
}
Javaer经常与Linux的信号机制打交道,只不过可能有些开发并不知情。Linux下Java线程栈是从高地址往低地址方向走的,在栈尾(低地址)会预留两块受保护的内存区域,分别叫做yellow page
和red page
,其中yellow page
在前。如果预测到新的RSP
(x86_64下栈指针寄存器,指向栈顶)的值超过了yellowpage
的位置,那就直接抛出栈溢出的异常,否则就去新的方法里处理,当我们的代码访问到yellow page
或者red page
里的地址的时候,因为这块内存是受保护的,所以会产生SIGSEGV的信号,此时会交给JVM里的信号处理函数来处理。
共享对象
创建共享对象、字符串错误信息,redis启动时创建完毕,常驻内存,其中大致包含如下几个方面:
- 常见的操作成功、失败提示
- 心跳测试Ping、Pong
- 使用频率高的命令对象
void createSharedObjects(void) {
shared.ok = createObject(OBJ_STRING, sdsnew("+OK\r\n"));
shared.err = createObject(OBJ_STRING, sdsnew("-ERR\r\n"));
......
shared.pong = createObject(OBJ_STRING, sdsnew("+PONG\r\n"));
......
shared.wrongtypeerr = createObject(OBJ_STRING, sdsnew(
shared.syntaxerr = createObject(OBJ_STRING, sdsnew( "-ERR syntax error\r\n"));
shared.sameobjecterr = createObject(OBJ_STRING, sdsnew(
"-ERR source and destination objects are the same\r\n"));
shared.outofrangeerr = createObject(OBJ_STRING, sdsnew("-ERR index out of range\r\n"));
......
shared.colon = createObject(OBJ_STRING, sdsnew(":"));
shared.plus = createObject(OBJ_STRING, sdsnew("+"));
......
shared.del = createStringObject("DEL", 3);
shared.unlink = createStringObject("UNLINK", 6);
shared.rpop = createStringObject("RPOP", 4);
shared.lpop = createStringObject("LPOP", 4);
shared.lpush = createStringObject("LPUSH", 5);
......
}
调整打开文件限制
Linux提供getrlimit
、setrlimit
一对函数用于控制当前进程资源消耗上限,目前支持十三种类型(《深入理解Linux内核》一书中明确指出是十三种,但是Mac OS下貌似只有十种)。对当前进程的资源限制存放在current->signal->rlim
即进程的信号描述符的一个字段,该字段是rlimit结构的数组,每个资源对应如下一个元素:
struct rlimit {
/* 当前资源限制 */
unsigned long rlim_cur;
/* 资源限制允许的最大值 */
unsigned long rlim_max;
}
只有超级用户(更准确的说,具有CAP_SYS_RESOURCE权能的用户)才可以调整rlim_max的值,或者直接跨过rlim_max将rlim_cur设置为一个更大的值。
adjustOpenFilesLimit
函数主要是为了调控RLIMIT_NOFILE
类型资源rlim_cur的上限,因为在Linux的哲学中一切皆文件,所以这个允许打开的文件的最大值,会影响例如Socket、File、Pipe等诸多方面。
如果
getrlimit
调用失败,则将允许打开的文件数降低到1024,同时为持久性、侦听套接字、日志文件等额外操作保留了许多文件描述符(默认32),那么此时最多支持1024 - 32个连接。
调用成功就能感知当前进程的限制,如果系统限制 > 10000 + 32不需要后续处理,如果小于则反复调用setrlimit
函数试探,简而言之该函数会努力提高资源限制上限。假设很不幸该进程允许打开的文件数甚至小于32,那么启动过程终止,该进程立即退出。
void adjustOpenFilesLimit(void) {
/**
* server.maxclients = CONFIG_DEFAULT_MAX_CLIENTS; 默认10000
* CONFIG_MIN_RESERVED_FDS默认32
*/
rlim_t maxfiles = server.maxclients + CONFIG_MIN_RESERVED_FDS;
struct rlimit limit;
if (getrlimit(RLIMIT_NOFILE, &limit) == -1) {
server.maxclients = 1024 - CONFIG_MIN_RESERVED_FDS;
}
else {
rlim_t oldlimit = limit.rlim_cur;
if (oldlimit < maxfiles) {
rlim_t bestlimit;
int setrlimit_error = 0;
/* 从最大开始尝试,每次减少16 */
bestlimit = maxfiles;
while(bestlimit > oldlimit) {
rlim_t decr_step = 16;
limit.rlim_cur = bestlimit;
limit.rlim_max = bestlimit;
/* setrlimit出错直接跳出 */
if (setrlimit(RLIMIT_NOFILE, &limit) != -1) break;
setrlimit_error = errno;
if (bestlimit < decr_step) break;
bestlimit -= decr_step;
}
/* 假设最后一次尝试获得的上限还更小了,那么最初获得的限制仍然有效 */
if (bestlimit < oldlimit) bestlimit = oldlimit;
if (bestlimit < maxfiles) {
unsigned int old_maxclients = server.maxclients;
server.maxclients = bestlimit - CONFIG_MIN_RESERVED_FDS;
if (bestlimit <= CONFIG_MIN_RESERVED_FDS) {
exit(1);
}
}
}
}
}
创建事件循环
鉴于这一块早有大佬珠玉在前,笔者就不狗尾续貂。
引用
- 深入理解Linux内核-中文第三版
- Unix网络编程系列源代码
Redis的启动过程源码到这里基本上重要节点也都分析完了。最后我想以费马的经典梗来结束本文:我还有一篇绝妙的源码分析,但空白处太少,我写不下
转载自:https://juejin.cn/post/7146485403699118111