2.7 使用gdb调试多进程程序——以调试Nginx为例
这里说的多进程程序指的是一个进程使用 Linux 系统调用 fork 函数产生的子进程,没有相互关联的进程调试指的是gdb调试单个进程,前面已经详细讲解过了。
在实际应用中,有一类应用会通过 Linux 函数 fork 出新的子进程。以 Nginx 为例,Nginx对客户端的连接采用了多进程模型,在接受客户端的连接后,会创建一个新的进程来处理该连接上的信息来往,新产生的进程与原进程互为父子关系。那么如何用gdb调试这样的父子进程呢?一般有两种方法,下面详细讲解。
1.方法一
先在一个Shell窗口中用gdb调试父进程,等子进程被fork出来后,再新开一个Shell窗口使用gdb attach命令将gdb attach到子进程上。
这里以调试Nginx服务为例。从Nginx官网下载最新的Nginx源码(本书采用的版本是1.18.0),然后编译和安装:
注意:使用make命令编译时,我们为了让生成的Nginx带有调试符号信息同时关闭编译器优化,设置了“-g-O0”选项。
启动Nginx:
如上所示,Nginx默认开启两个进程,在笔者的机器上以root用户运行的Nginx进程是父进程,进程号是5246,以nobody用户运行的进程是子进程,进程号是5247。我们在当前窗口中使用gdb attach 5246命令将gdb attach到Nginx主进程上:
此时就可以调试Nginx父进程了,例如使用bt命令查看当前调用堆栈:
使用f 1命令切换到当前调用堆栈#1,可以发现Nginx父进程的主线程挂起在src/core/nginx.c:382处。
此时可以使用c命令让程序继续运行,也可以添加断点或者进行其他调试操作。
再开一个Shell窗口,使用gdb attach 5247命令将gdb attach到nginx子进程上:
使用bt命令查看子进程的主线程的当前调用堆栈:
可以发现,子进程挂起在src/event/modules/ngx_epoll_module.c:800的epoll_wait函数处。我们在epoll_wait函数返回后(src/event/modules/ngx_epoll_module.c:804)加一个断点,然后使用c命令让Nginx子进程继续运行:
接着在浏览器中访问Nginx网站,这里的 IP地址是笔者的云主机地址,读者在实际调试时将其改成自己的 Nginx 服务器所在的地址即可,如果是本机,那么地址就是127.0.0.1,由于默认端口是80,所以不用指定端口号:
此时回到nginx子进程的调试界面,发现断点被触发:
使用bt命令可以获得此时的调用堆栈:
使用info threads命令可以查看子进程的所有线程信息,发现Nginx子进程只有一个主线程:
Nginx父进程不处理客户端的请求,处理客户端请求的逻辑在子进程中,当单个子进程的客户端请求数达到一定数量时,父进程会重新 fork 一个新的子进程来处理新的客户端请求,也就是说子进程数量可以有多个,我们可以开多个 Shell 窗口,使用 gdb attach到各个子进程上调试。
总之,我们可以使用这种方法添加各种断点调试Nginx的功能,慢慢地就能熟悉Nginx的各个内部逻辑了。
然而,该方法存在一个缺点,即程序已经启动了,我们只能使用gdb观察程序在这之后的行为,如果想调试程序从启动到运行的执行流程,则可能不太适用。有些读者可能会说:用gdb attach到进程后,加好断点,然后使用run命令重启进程,这样就可以调试程序从启动到运行的执行流程了。问题是这种方法并不通用,因为对于多进程服务模型,有些父子进程有一定的依赖关系,是不方便在运行过程中重启的。这时方法二就比较合适了。
2.方法二
gdb调试器提供了一个follow-fork选项,通过set follow-fork mode设置一个进程fork出新的子进程时,gdb是继续调试父进程(取值是parent)还是继续调试子进程(取值是child),默认继续调试父进程(取值是parent):
可以使用show follow-fork mode查看当前值:
还是以调试nginx为例,先进入 nginx 可执行文件所在的目录,将方法一中的 Nginx服务停下来:
在Nginx源码中存在这样的逻辑,这个逻辑会在程序main函数处被调用:
如以上代码中的注释所示,为了不让主进程退出,我们在Nginx的配置文件中增加一行,这样Nginx就不会调用ngx_daemon函数了:
接下来执行gdb nginx,通过设置参数将配置文件nginx.conf传给待调试的Nginx进程:
接着输入run命令尝试运行Nginx:
如前文所述,gdb遇到fork指令时默认会attach到父进程,因此在以上输出中有一行提示“Detaching after fork from child process 7509”,我们按Ctrl+C组合键将程序中断,然后输入 bt 命令查看当前调用堆栈,输出的堆栈信息和我们在方法一中看到的父进程的调用堆栈一样,说明gdb在程序fork之后确实attach到父进程了:
如果想让gdb在fork之后attach到子进程,则可以在程序运行之前设置set follow-fork child,然后使用run命令重新运行程序:
接着按Ctrl+C组合键将程序中断,然后使用bt命令查看当前线程的调用堆栈,结果显示它确实是方法一中子进程的主线程所在的调用堆栈,这说明gdb确实attach到子进程了。
我们可以利用方法二调试程序 fork之前和之后的任何逻辑,这是一种较为通用的多进程调试方法,建议掌握。