likes
comments
collection
share

Redis进程启动源码浅析

作者站长头像
站长
· 阅读数 54

注:

  • 所列举代码皆出自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进程启动源码浅析

启动参数处理

      解析参数,大概过程(注意是大概我真的不想看这段代码)如下:

  • 判断redis-check-rdbredis-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根据daemonizesupervised两个配置项决定是否后台执行,默认不需要。一般我们仅仅只需要修改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. 如果返回-1说明fork调用失败,直接结束后续流程;
  2. 如果fork调用成功,父进程返回子进程pid一定不为零;

      子进程会继承父进程的SessionId以及打开的终端,setsid函数会创建一个新的SessionId,摆脱父进程的终端控制,此时关闭终端,Redis进程不会受到影响,如有必要结束进程,执行kill pid命令即可。

      打开"/dev/null"文件,将标准输入、标准输出、标准错误全部重定向到该文件,可以查看Linux /proc/pid/fd来验证一下。 Redis进程启动源码浅析

initServer()

处理信号

      信号在最早的Unix系统中即被引入,用于用户态进程间通信。内核也用信号通知进程系统所发生的事件。 信号是很短的消息,可以被发送到一个或一组进程。使用信号主要两个目的(不互斥):

  • 让进程知道已经发生的一个特定的事件
  • 强迫进程执行他自己代码中的信号处理程序

      Linux下部分信号对照表:

编号信号名称缺省操作解释POSIX
1SIGHUPTerminate挂起控制终端或进程
2SIGINTTerminate来自键盘的中断
4SIGILLTerminate非法指令
10SIGBUSDump总线错误
11SIGSEGVDump无效的内存引用
13SIGPIPETerminate向无读者的管道写
15SIGTERMTerminate进程终止

      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 pagered page,其中yellow page在前。如果预测到新的RSP(x86_64下栈指针寄存器,指向栈顶)的值超过了yellowpage的位置,那就直接抛出栈溢出的异常,否则就去新的方法里处理,当我们的代码访问到yellow page或者red page里的地址的时候,因为这块内存是受保护的,所以会产生SIGSEGV的信号,此时会交给JVM里的信号处理函数来处理。 Redis进程启动源码浅析

共享对象

      创建共享对象、字符串错误信息,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提供getrlimitsetrlimit一对函数用于控制当前进程资源消耗上限,目前支持十三种类型(《深入理解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等诸多方面。 Redis进程启动源码浅析       如果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);
                }   
            }
        }
    }
}

创建事件循环

      鉴于这一块早有大佬珠玉在前,笔者就不狗尾续貂。

      深度解析单线程的Redis

引用

      Redis的启动过程源码到这里基本上重要节点也都分析完了。最后我想以费马的经典梗来结束本文:我还有一篇绝妙的源码分析,但空白处太少,我写不下

转载自:https://juejin.cn/post/7146485403699118111
评论
请登录