疑问出现
之前写过一篇关于僵尸进程和孤儿进程,建议先理解僵尸进程效果会好些。
偶然间想到了一个问题,Redis可以通过RDB进行数据的持久化,使用bgsave命令会fork出来一个子进程,当持久化结束之后,子进程会退出。退出之后,需要由父进程进行“收尸”,避免成为僵尸进程,这就免不了使用wait系统调用。
但是C库中wait函数是阻塞的,如果直接使用wait会造成父进程等待子进程持久化结束再回收,而Redis单线程的缘故,等待意味着阻塞,使用wait等待无异于脱裤子放屁,不如直接在自己执行流中进行持久化操作,开销还小。
猜测与验证
想到了一个解决方式,使用信号异步通知的方式。
父进程先注册某个信号处理函数(SIGCHLD),子进程完成持久化操作后,向父进程发送一个信号,然后退出。
父进程在收到信号之前继续自己的事情,收到信号后执行先前注册信号(SIGCHLD)处理函数,在信号处理函数中执行wait函数回收子进程,防止僵尸进程的出现,执行流大致如下图所示。
在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函数。
回想到wait族的函数并不止wait(),还有waitpid(),然后在代码中搜寻waitpid()函数,可以看到好多匹配项,猜测是通过waitpid函数进行回收的;
查阅资料得知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回收子进程没有使用信号处理的方式,而是使用非阻塞忙轮询的方式解决。大致流程如下图所示:
Reference
- 《Redis中的时间循环》draveness.me/redis-event…
- 《Linux/UNIX系统编程手册》26章:监控子进程
- Redis-6.2.3 源码