从内核角度看待Redis回收子进程

疑问出现

之前写过一篇关于僵尸进程和孤儿进程,建议先理解僵尸进程效果会好些。

偶然间想到了一个问题,Redis可以通过RDB进行数据的持久化,使用bgsave命令会fork出来一个子进程,当持久化结束之后,子进程会退出。退出之后,需要由父进程进行“收尸”,避免成为僵尸进程,这就免不了使用wait系统调用。

但是C库中wait函数是阻塞的,如果直接使用wait会造成父进程等待子进程持久化结束再回收,而Redis单线程的缘故,等待意味着阻塞,使用wait等待无异于脱裤子放屁,不如直接在自己执行流中进行持久化操作,开销还小。

image.png

猜测与验证

想到了一个解决方式,使用信号异步通知的方式。

父进程先注册某个信号处理函数(SIGCHLD),子进程完成持久化操作后,向父进程发送一个信号,然后退出。

父进程在收到信号之前继续自己的事情,收到信号后执行先前注册信号(SIGCHLD)处理函数,在信号处理函数中执行wait函数回收子进程,防止僵尸进程的出现,执行流大致如下图所示。

image.png

在Linux中,子进程退出时候本身就会向父进程发送一个SIGCHLD信号,通知父进程。如果父进程不注册这个信号的处理函数,这个信号就会被忽略掉。

根据这种想法,使用strace验证一下Redis是否是这个解决思路。

$ strace -f -p 16809
#首先clone系统调用,是fork底层的系统调用函数,创建了一个子进程,子进程用于执行dump持久化,返回值是子进程的pid,这里是40的原因是由于使用容器部署,返回容器命名空间内的pid,实际对应到过来的pid是17626
[pid 16809] clone(strace: Process 17626 attached
child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f6b5ab89250) = 40
[pid 17626] set_robust_list(0x7f6b5ab89260, 24) = 0
#中间省略一些dump持久化的过程
......
#dump结束,执行exit系统调用退出进程,但是在父进程回收之前,该进程以僵尸进程的形式存在
[pid 17626] exit_group(0) = ?
[pid 17626] +++ exited with 0 +++
#在这里我们看到主进程收到一个SIGCHLD信号
[pid 16809] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=40, si_uid=999, si_status=0, si_utime=0, si_stime=0} ---
......
#然后wait函数执行了wait4系统调用,回收子进程的“尸体”
[pid 16809] wait4(-1, [{WIFEXITED(s) && WEXITSTATUS(s) == 0}], WNOHANG, NULL) = 40
#主进程继续进入自己的主循环
[pid 16809] getpid() = 1
[pid 16877] <... futex resumed> ) = 0
[pid 16809] getpid() = 1
复制代码

在这个时候,我就认为Redis回收子进程的策略如我猜想一样,因为系统调用显示的信息与推断大致是一样的,就准备把源码拉下来看下(版本6.2.3),但是看了源码之后证明我还是太嫩。

在代码中没有出现丝毫SIGCHLD信号的处理函数,其他的信号处理倒是很多。

之后搜寻wait函数,也没有找到C库中标准的wait函数。

image.png

回想到wait族的函数并不止wait(),还有waitpid(),然后在代码中搜寻waitpid()函数,可以看到好多匹配项,猜测是通过waitpid函数进行回收的;

image.png

查阅资料得知waitpid函数的用法:

pid_t waitpid(pid_t pid,int *status,int options)
复制代码

该方法有三个参数,相较于wait函数只有status多了两个参数,一个pid,一个options。

pid主要四个选择:

  • pid大于0时候,等待进程id为pid的进程退出;
  • 等于0时,等待同一个进程组中的任何子进程;
  • 等于-1时,等待任意一个子进程退出;
  • 小于-1,等待进程组标识符与 pid 绝对值相等的所有子进程。

options可以按位或操作,有三个标志:

  • 使用WNOHANG时,如果指定的子进程状态不变,函数不会等待阻塞,直接返回;
  • 使用WCONTINUED,返回那些因收到 SIGCONT 信号而恢复执行的已停止子进程的状态信息。
  • 使用WUNTRACED,除了返回终止子进程的信息外,还返回因信号而停止的子进程;

status用于传入指针并使得用户获取进程状态。

Redis的做法

通过阅读源码得知,Redis正是使用了该函数,使用方式是通过如下方式:

waitpid(-1, &statloc, WNOHANG)
复制代码

它会在自己的控制循环aeEventLoop中循环调用aeTimeEvent,aeTimeEvent中注册了serverCron,其中包含了对子进程的检查,它会首先检查该进程是否拥有子进程,如果有的话会调用该函数查询子进程是否退出;

部分实现源码如下:

//初始化过程
void initServer(void) {
    ......
    //注册计时器回调函数,加入serverCron
    if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        serverPanic("Can't create event loop timers.");
        exit(1);
    }
}

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ......
    //如果有子进程则进入if分支
    if (hasActiveChildProcess() || ldbPendingChildren())
    {
        run_with_period(1000) receiveChildInfo();
        //检查子进程是否退出
        checkChildrenDone();
        .......
    }
    .......
}

//判断是否有子进程,如果有返回true
int hasActiveChildProcess() {
    return server.child_pid != -1;
}

//检查子进程状态
void checkChildrenDone(void) {
    int statloc = 0;
    pid_t pid;
  //检查子进程是否退出,并回收
    if ((pid = waitpid(-1, &statloc, WNOHANG)) != 0) {
        //其他后续处理
        ....
复制代码

再次验证

既然会循环检查,那就在代码上做点手脚,然后通过strace工具验证一下是否会出现wait系统调用:

方法是延长子进程运行时间(因为现实中子进程往往会很快执行结束,看不到父进程轮询wait系统调用),使用strace查看父进程是否会循环执行wait系统调用:

最简单粗暴的办法,在fork后的子进程执行流中加上sleep函数挂起一段时间;

int redisFork(int purpose) {
    ...
    if ((childpid = fork()) == 0) {
        /* 子进程 */
        sleep(100);//这里我增加睡眠时间100秒
        server.in_fork_child = purpose;
        setOOMScoreAdj(CONFIG_OOM_BGCHILD);
        setupChildSignalHandlers();
        closeChildUnusedResourceAfterFork();
    } else {
        /* 父进程 */
        .....
复制代码

编译并运行Redis,执行bgsave命令;

$ cd src/
$ make
$ ./redis-server XXXXredis.conf
$ ./redis-cli -h localhost -p 6379
$ localhost> bgsave
复制代码

查看系统调用情况:

$ strace -f -p 30708
#fork子进程,子进程pid是30834
[pid 30708] clone(strace: Process 30834 attached
child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fef4bf09250) = 30834
.......
#子进程sleep挂起
[pid 30834] nanosleep({tv_sec=100, tv_nsec=0}, <unfinished ...>
#主线程继续进入自己的事件循环
[pid 30708] write(8, "+Background saving started\r\n", 28) = 28
[pid 30708] epoll_wait(5, [], 10128, 95) = 0
......
[pid 30708] <... futex resumed> ) = 1
#可以看到在事件循环中有了wait系统调用
[pid 30708] wait4(-1, <unfinished ...>
......
[pid 30708] close(11) = 0
[pid 30708] wait4(-1, 0x7ffd5793a6dc, WNOHANG, NULL) = 0
[pid 30708] epoll_wait(5, [], 10128, 100) = 0
[pid 30708] getpid() = 30708
[pid 30708] open("/proc/30708/stat", O_RDONLY) = 11
[pid 30708] read(11, "30708 (redis-server) R 19236 307"..., 4096) = 329
[pid 30708] close(11) = 0
[pid 30708] wait4(-1, 0x7ffd5793a6dc, WNOHANG, NULL) = 0
[pid 30708] epoll_wait(5, [], 10128, 100) = 0
[pid 30708] getpid() = 30708
[pid 30708] open("/proc/30708/stat", O_RDONLY) = 11
[pid 30708] read(11, "30708 (redis-server) R 19236 307"..., 4096) = 329
[pid 30708] close(11) = 0
[pid 30708] wait4(-1, 0x7ffd5793a6dc, WNOHANG, NULL) = 0
[pid 30708] epoll_wait(5, [], 10128, 100) = 0
[pid 30708] getpid() = 30708
[pid 30708] open("/proc/30708/stat", O_RDONLY) = 11
[pid 30708] read(11, "30708 (redis-server) R 19236 307"..., 4096) = 329
[pid 30708] close(11) = 0
[pid 30708] wait4(-1, 0x7ffd5793a6dc, WNOHANG, NULL) = 0
[pid 30708] epoll_wait(5, [], 10128, 100) = 0
........
复制代码

在这里我们看到在子进程出现之后父进程会在控制循环中加上wait系统调用,如果子进程结束之后会被循环中的wait回收掉,这样避免僵尸进程的出现。

小结

该文章从问题引出开始,记录了从疑问出现到设想再到解决的过程。总体来说,Redis回收子进程没有使用信号处理的方式,而是使用非阻塞忙轮询的方式解决。大致流程如下图所示:

image.png

Reference

  • 《Redis中的时间循环》draveness.me/redis-event…
  • 《Linux/UNIX系统编程手册》26章:监控子进程
  • Redis-6.2.3 源码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享