Python subprocess shell=True导致子进程无法正确终止的排查与解决
本文深入分析Python中使用subprocess模块时设置shell=True导致的子进程无法正确终止及资源泄漏问题。当shell=True时,实际执行的是Shell解释器而非命令本身,导致直接杀死子进程ID无法释放其继承的文件描述符(如监听端口)。通过对比shell=False的使用方式,并解析进程继承机制,提供了避免此类坑位的具体代码修改建议及排查思路。
0x01 问题现象
在开发过程中,程序通过subprocess模块创建子进程以执行外部程序,并在子进程执行完毕后进行后续处理。
在调用subprocess时使用了shell=True参数,用于判断用户程序是否退出的代码如下:
while self.proc.poll() is None:
do_something
在检查子进程是否运行结束时,发现程序在子进程完成后并未继续向下执行,而是停滞在该循环中。
0x02 原因分析
查阅相关资料后,对shell参数的解释如下:
当设置shell=True时,subprocess.Popen接受字符串类型的变量作为命令,并调用Shell去执行该字符串;而当shell=False时,subprocess.Popen仅接受数组变量作为命令,并将数组的第一个元素视为命令,其余元素均作为该命令的参数。
通过查看服务器上的进程状态,发现仍有相关进程存在,具体进程信息如下:

这是由Shell运行的程序,由此可以推断,当shell=True时,子进程执行完毕后,Shell本身并未退出,而是卡在Shell命令中,这可以通过进程列表观察到。

补充:Python踩坑之旅其一杀不死的Shell子进程
1.1 踩坑案例
涉及的程序是一个常驻的Agent类管理进程,负责执行包括但不限于以下类型的任务:
a. 多线程的网络通信包处理
与控制Master节点进行交互
拥有固定的Listen端口
b. 定期作业任务,通过subprocess.Pipe执行Shell命令
c. 其他任务
发现该问题的过程颇具趣味性:
a. 重启Agent后发现端口被占用
=> 首先想到可能是进程未被彻底杀死,怀疑停止脚本存在问题
=> 经过排查排除此可能性,确认Agent进程确实已死亡
=> 通过执行 netstat -tanop|grep port_number 命令发现端口确实仍被占用
=> 在调试环境中直接杀死了占用该端口的进程,从而错失了首次发现问题的机会
b. 问题在一段时间后重现,重启后端口依然被占用
最终定位问题出现在一个名为xxxxxx.sh的脚本中,该脚本占用了Agent使用的端口
=> 感到疑惑,一个xxx.sh脚本为何会使用这个特殊的端口(大于60000的端口,有兴趣的读者可以思考为什么Agent默认使用6W+的端口)
=> 审查该脚本,并未发现进行端口监听的代码
灵光一现,推测c. 进程共享了父进程的资源
=> 追溯该脚本,发现它确实是Agent启动的任务中的脚本之一
=> 问题基本定位,该脚本属于Agent调用的子脚本
=> 该Agent继承了原Agent的资源文件描述符(FD),即该端口对应的FD
=> 虽然该脚本由于超时被动触发了terminate机制,但terminate并未真正杀死该子进程
=> 该脚本进程的父进程ID(ppid)被重置为了1
d. 问题****出在脚本进程超时kill逻辑
1.2 填坑解法
通过代码审查,找到Shell具体执行的库代码如下:
self._subpro = subprocess.Popen(
cmd, shell=True, stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
preexec_fn=_signal_handle
)
# 重点是shell=True !
将上述代码修改为:
self._subpro = subprocess.Popen(
cmd.split(), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, preexec_fn=_signal_handle
)
# 重点是去掉了shell=True
1.3 坑位分析
Agent会在一个新创建的threading线程中执行这段代码,如果线程执行时间超时(xx seconds),会调用 self._subpro.terminate() 终止该脚本。
表面正常:
启用新线程执行该脚本
如果出现问题,执行超时防止其他任务挂起,调用terminate杀死进程
深层问题:
在Python 2.7.x中,subprocess.Pipe如果设置shell=True,会默认将相关的pid设置为Shell(sh/bash/etc)本身(即执行命令的Shell父进程),而非执行cmd任务的那个实际进程
子进程由于会复制父进程打开的文件描述符表(FD表),导致即使被杀死,依然保留了拥有该Listen Port FD
这样虽然杀死了Shell进程(未必完全死亡,可能进入defunct状态),但实际的执行进程却还活着。于是1.1中的坑就被结实地踩上了。
1.4 坑后扩展
1.4.1 扩展知识
本节扩展知识包括两个部分:
Linux系统中,子进程一般会继承父进程的哪些信息
Agent这种常驻进程选择大于60000端口的意义
扩展知识留到下篇末尾讲述,感兴趣的读者可以自行搜索
1.4.1 技术关键字
Linux系统进程
Linux随机端口选择
程序多线程执行
Shell执行
1.5 填坑总结
1. 子进程会继承父进程的资源信息
2. 如果只kill某进程的父进程,集成了父进程资源的子进程会继续占用父进程的资源不释放,包括但不限于
listened port
opened fd
etc
3. Python Popen在使用上,shell的bool状态决定了进程kill的逻辑,需要根据场景选择使用方式
以上为个人经验,希望能给大家一个参考,也希望大家多多支持本站。如有错误或未考虑完全的地方,望不吝赐教。
本站声明
本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。













