有段时间我经常要处理自动化测试设备的残留进程问题。跑完Monkey压测后,设备上偶尔会留下几个monkey进程,拖慢后续任务。一开始清理方式很粗暴,用kill -9硬杀。结果某次批量跑完测试后,新启动的任务频繁报端口冲突和日志目录被占用。后来我把清场命令从pkill -9 monkey换成了pkill -15 monkey,配合等待逻辑,情况明显好转。这背后其实是15号信号SIGTERM和9号信号SIGKILL的差异。
如果你经常和adb shell、Linux命令、自动化测试打交道,这篇内容会很实用。我会把SIGTERM信号本身的机制讲透,说清楚pkill -15 monkey这条命令到底匹配了什么、信号发出后发生了什么、为什么它比直接kill -9更值得推荐,以及怎么把它正确写进测试脚本里。
1. 为什么偏偏是"15":SIGTERM和SIGKILL的本质差别
1.1 信号编号是怎么一回事
在Linux和Android终端里敲一下kill -l,会输出一整屏信号列表,能看到 1) SIGHUP、2) SIGINT、9) SIGKILL、15) SIGTERM。Python的signal模块、Java里的信号处理机制,其实映射的都是同一套系统信号。对类Unix系统来说,进程之间、用户态和内核态之间,一个核心的异步通知机制就是"信号"。
信号编号是历史沿革的产物,15在绝大多数POSIX系统上固定给SIGTERM。为什么会是15而不是别的数字,纯粹是历史原因,不用深究。真正需要理解的是每个编号对应信号背后的语义。就像错误码一样,只有理解了状态码的含义,才知道怎么处理。
1.2 默认动作和可捕获性
SIGTERM(15)的默认动作是终止进程。注意"终止"不等于"强制抹杀"。进程可以安装处理器(handler)来捕获这个信号,从而执行自己的清理动作,比如关闭文件、保存状态、通知监控系统自己马上要退出,甚至可以暂时忽略它、暂不处理。
SIGKILL(9)则完全不同。它的默认动作是立即强制终止进程,而且不可捕获、不可阻塞、不可忽略。内核收到针对某个进程的SIGKILL投递后,会直接把进程从调度器中摘除,释放资源,进程没有任何机会运行用户态的收尾代码。
打一个比方:SIGTERM是公司通知你"这个月底做完交接再离开",离职流程、文件夹整理、工作交接都还能走;SIGKILL是保安直接拔掉你电脑电源、收回门禁卡,一切程序都不给你走。前者给了进程体面的退场机会,后者没有任何缓冲。
1.3 命令行里的 -15、-TERM、-SIGTERM 有什么区别
kill、pkill指定信号有三种等价写法:-15、-TERM、-SIGTERM。在Linux和较新版本的Android toybox里,三者效果一致。脚本里之所以习惯写数字-15,是因为很多精简嵌入式环境(包括部分Android ROM)对信号名称的支持不完整,数字写法最兼容,也是一种跨平台保险。
所以pkill -15 monkey的意思就是:向所有匹配"monkey"的进程发送15号信号SIGTERM。如果你希望进程有机会整理现场,就用15;如果你只希望它立刻消失、什么都不管,才考虑9。
注意:如果进程没有安装任何SIGTERM处理器,默认情况下收到SIGTERM和SIGKILL,在"程序有没有机会运行自己的清理代码"这个层面上,差别其实没有想象中那么大。真正要想让日志完整、现场可回溯,需要配合代码里的handler。这一点后面会详细展开。
2. pkill -15 monkey 匹配范围:进程名、命令行和误伤风险
2.1 进程名和命令行不是一回事
pkill默认匹配的是进程名,也就是内核里的comm字段,通常来自可执行文件名,而且会被截断到15个字符左右。这里有个非常容易翻车的细节:你在终端里执行adb shell monkey -p com.xxx 5000,写的是命令行,但设备上实际出现的进程名可能是com.android.commands.monkey,也可能是脚本包装下的临时子进程。
这种情况下,你执行pkill monkey默认按进程名匹配,就可能一个都匹配不到,或者匹配到一堆名字里带"monkey"的无关进程。想在动手之前看清楚目标,最好的方式是用pgrep,它可以同时显示进程名和完整命令行:
# 列出所有进程名或命令行中包含 monkey 的进程 pgrep -af monkey输出里左侧是PID,右侧是完整命令行。看到实际内容,你才会知道当前设备上的monkey到底长什么样。
2.2 -f 子串匹配:命中率更高,误伤范围也更大
因为monkey测试通常是通过adb shell启动的,很多情况下进程的完整命令行里都会带上"monkey"这个字符串。于是有经验的人会改用:
pkill -15 -f monkey-f参数让pkill基于/proc/pid/cmdline做全命令行匹配,命中率大幅提升。但它是子串匹配,只要命令行里包含"monkey"就会被视为目标。如果设备上有一个进程在跑monkey_helper.sh,或者某个守护进程的命令行里恰好带了"monkey"字样,它也会收到SIGTERM。
更隐蔽的情况是:某些统计代理程序、推送服务的进程名和业务字符串里包含"monkey",或者你自己写过一个检查工具叫check_monkey_status,这些都会被-f monkey误伤。
我的建议是:能用进程名精确匹配就不要轻易加-f;必须加-f时,尽量用更长的、唯一的模式串。比如:
# 更精确的匹配,避免误伤 pkill -15 -f "com.android.commands.monkey" pkill -15 -f "monkey -p com.business.app"如果同一设备上有多个monkey实例并行(多模块压测、多设备联动测试),光靠进程名匹配非常危险。最好在模式串里带上测试包名、端口号等唯一标识,把批量操作限制在真正想要的目标上。
2.3 先 pgrep 后 pkill:动手前先看清敌我
我给自己定的一条操作纪律是:任何 pkill 清场之前,必须先跑一次 pgrep 确认目标范围,执行完 pkill 之后再跑一次 pgrep 验证结果。完整链路如下:
# 第一步:查看当前匹配到的所有进程,确认目标 pgrep -af monkey # 第二步:确认范围无误后,发送 SIGTERM pkill -15 -f "com.android.commands.monkey" # 第三步:等待几秒,再检查是否已经退出 sleep 3 pgrep -af monkey如果你更习惯用PID精确清理,也可以这样做:
adb shell "kill -15 $(adb shell pidof com.android.commands.monkey)"不过不同Android设备的pidof支持情况不完全一样,很多老设备上用ps -A | grep ... | awk '{print $2}'更稳妥。本质上,pkill越精确,环境越安全。
3. 信号发出之后:从SIGTERM到进程消亡的内核路径
3.1 没有处理器时,SIGTERM默认退出路径是怎样的
进程收到SIGTERM且没有安装handler时,内核会在进程从内核态返回用户态时执行do_exit逻辑。这个退出路径会释放进程占用的内存、关闭已打开的文件描述符、通知父进程子进程状态发生变化。相比SIGKILL的直接调度摘除,SIGTERM的默认动作更接近"进程自己正常退出"的回收路径。
但这里有个常见误解:很多人以为SIGTERM一定能保存日志。实际上,如果程序没有显式捕获SIGTERM,用户态缓冲区里的数据(比如还没flush的stdout输出)不一定会被写入文件。内核只负责系统层面的资源回收,不会帮你运行C库和业务代码里的flush逻辑。
在Android设备上,monkey进程通常是没有自定义handler的普通进程,收到SIGTERM后的默认动作就是退出。它的stdout输出管道、日志文件句柄会由内核关闭,内核侧写缓冲会尽量落盘,但如果你程序中还有自己的内存缓冲,那一部分仍然可能丢失。这就是为什么单纯依赖SIGTERM保证不了日志完整性的原因。
3.2 程序装了信号处理器之后,一切才开始有意义
如果你在monkey测试的主控脚本、runner或者监控进程里写了SIGTERM的捕获处理器,收到信号时会先执行清理代码,然后进程才退出。这样pkill -15 monkey就成了一个触发"优雅下线"的开关。
Python里通常这样写:
import signal import subprocess import sys def cleanup(signum, frame): # 先温和地停掉monkey子进程 subprocess.run(["adb", "shell", "pkill", "-15", "-f", "monkey"], stdout=subprocess.DEVNULL) # 再做日志归档等收尾工作 collect_logs() sys.exit(130) # 128 + SIGTERM def collect_logs(): print("collecting crash logs and moving to archive...", file=sys.stderr) # 拷贝、重命名、压缩崩溃堆栈等 signal.signal(signal.SIGTERM, cleanup)在Java/Kotlin里,可以通过注册shutdown hook实现类似效果:
Runtime.getRuntime().addShutdownHook(Thread { // 关闭输出流、归档日志、释放资源 })注意,JVM正常情况下对SIGTERM的响应就是运行shutdown hook,这对长时间运行的测试服务很有用。外部pkill -15打过来时,进程不是直接被干掉,而是先执行这些收尾逻辑。
3.3 信号可能被挂起、也可能"假成功"
有几个边界情况在自动化脚本里特别容易踩到。
第一,进程处于D状态(不可中断睡眠,比如正在等待块设备I/O完成)时,信号不会立即处理,而是排在pending队列里。此时pkill -15返回0,但进程仍然存在。
第二,进程变成僵尸(Z状态)后,信号完全无效,只能等它的父进程调用wait()回收。你向僵尸进程发送任何信号都不会改变它的状态。
第三,权限不足会导致信号投递失败。比如非root用户向root进程发送SIGTERM,会得到Operation not permitted。Android设备上常见的情况是:adb shell默认是shell用户,向system进程发信号不一定成功,需要root权限。这些边界情况决定了我们不能只发信号不管后续,必须在脚本里加上等待和验证逻辑。
4. 真实测试里我遇到过的三种信号事故
4.1 事故一:用-9清场,崩溃日志残缺
某个版本的压力测试脚本,停止monkey时用的是pkill -9 monkey。连跑一周后,我发现部分崩溃堆栈文件只有几百KB,内容戛然而止。最开始的排查方向放在抓取逻辑上,日志明明抓到了目录里,怎么会写一半就断了?后来把日志文件的生成时间、大小和monkey进程的退出时间放到一起比对,才确认是SIGKILL导致写日志线程瞬间消失,输出缓冲区没有落盘。
模拟项目X里,我们把停止流程改成"先发SIGTERM给主控进程,让主控进程的handler主动刷新输出流、关闭文件、归档崩溃现场,然后再退出",日志完整性的问题基本消失。真正的功臣不是SIGTERM信号本身,而是我们利用SIGTERM的"请求语义",在代码里写出了配套的收尾逻辑。
4.2 事故二:匹配太宽,把无关进程一起带走了
有一次设备上同时跑着monkey压测和一个推送代理,推送代理的进程名本身没问题,但它的命令行里带了"monkey"字样。我执行的pkill -15 -f monkey把两个进程一起放倒了。测试环境里推送能力临时不可用,后续大量用例超时,排查了很久才发现是-f子串匹配把无辜进程带走了。
从那以后,我在批量清理命令里养成了一个习惯:模式串尽量带完整包名或进程名,不直接在-f后面放裸关键字。比如pkill -15 -f "com.android.commands.monkey",而不是pkill -15 -f monkey。多打几个字符,避免一次大事故。
4.3 事故三:发完信号立即清目录,撞上退出窗口期
另一个自动化平台上,清理任务执行完pkill -15后马上就删除日志目录。结果日志目录被进程占用,删除失败,整个执行单元被判FAIL。排查后发现:SIGTERM只是请求进入退出流程,进程的退出需要时间。如果外部立刻去操作它占用的文件,就会撞上这个窗口期。
后面我在所有pkill之后都加了一段"优雅退出等待"逻辑:
for i in $(seq 1 15); do if ! pgrep -f "com.android.commands.monkey" > /dev/null 2>&1; then break fi sleep 1 done超过15秒还活着,才用SIGKILL兜底。这个等待窗口后来成了所有测试清理步骤的标配。回头看,这三种事故都指向同一个结论:理解信号的语义,工程上才会少踩坑。
5. 更稳的工程做法:把SIGTERM写进测试的退场流程
5.1 一个结构化的优雅停止脚本
我现在常用的一套停止monkey逻辑,按顺序分四步:检查目标、发送SIGTERM、等待优雅退出、超时后SIGKILL兜底。
#!/bin/bash # 目标进程模式串,尽量带完整包名,别用裸monkey PATTERN="com.android.commands.monkey" # 第一步:确认目标进程是否存在 if ! adb shell pgrep -f "$PATTERN" > /dev/null 2>&1; then echo "no target process" exit 0 fi # 第二步:发送SIGTERM,给进程体面退出的机会 adb shell "pkill -15 -f '$PATTERN'" # 第三步:等待优雅退出,最多给15秒 for i in $(seq 1 15); do if ! adb shell pgrep -f "$PATTERN" > /dev/null 2>&1; then echo "process exited after ${i}s" exit 0 fi sleep 1 done # 第四步:超时未退出,兜底SIGKILL echo "graceful exit timeout, force kill" adb shell "pkill -9 -f '$PATTERN'"这里有几个设计意图:SIGTERM是首选,等待窗口给了handler执行时间,兜底SIGKILL保证清理流程不会无限阻塞。如果你完全相信SIGTERM而不写兜底,一旦进程在handler里卡住,整个清理流程就停摆。有了兜底,最坏情况也只是损失最后几秒的缓冲数据。
5.2 让主控脚本捕获SIGTERM做真正的清理
外部pkill -15打过来时,如果目标是裸的monkey进程,它通常只会默默退出;要让15号信号发挥最大价值,主控脚本必须能响应它。Python主控脚本里通常这样做:
- 捕获SIGTERM;
- 先温和地停掉monkey子进程;
- 把崩溃日志、ANR trace移动到独立归档目录;
- 释放测试用到的端口、锁文件;
- 以非零状态退出,让外层识别这是"外部终止"。
一个带日志采集骨架的写法:
import signal import subprocess import sys import shutil def graceful_shutdown(signum, frame): print("received SIGTERM, start cleanup...", file=sys.stderr) # 1. 让monkey进程也优雅停止 subprocess.run(["adb", "shell", "pkill", "-15", "-f", "com.android.commands.monkey"]) # 2. 把崩溃日志保存到独立目录 try: shutil.move("/sdcard/crash_log/", "/data/archive/crash_log_backup/") except Exception: pass sys.exit(130) signal.signal(signal.SIGTERM, graceful_shutdown)这样一来,pkill -15 monkey就不只是"杀进程",而是触发了整个测试系统的下线流程。这套思路在长时间跑Monkey、需要保留崩溃现场的场景里非常实用。
5.3 日志和临时文件的保护思路
日志完整性的根本保障,不靠信号魔法,而是让数据及时落盘。几个实操技巧:
- 用
stdbuf -oL启动monkey,让输出行及时写入日志文件。Android shell对stdbuf支持不一,能用就用,不能用就在代码里主动flush。 - 代码里定期flush输出流,关闭文件前务必flush。不要只依赖进程退出时的隐式行为。
- 日志不要长期留在临时目录,清理流程里先把归档拷贝完成,再删除原目录。顺序搞反是常见翻车原因。
即使最后SIGKILL兜底,损失也被限制在最近几秒的缓冲数据内,这对压力测试来说完全可接受。
5.4 一点个人体会
用pkill -15 monkey的次数多了,我越来越把信号看成一种"治理机制":SIGTERM用来给程序主动响应外部控制的机会,SIGKILL用来在治理失灵时强制兜底。在Android自动化测试这种多进程、多日志、多临时文件的场景里,稳妥的做法从来不是单选某一个信号,而是把SIGTERM、等待窗口、SIGKILL兜底组合起来,设计成完整的退出流程。
最后再分享一个小习惯:清理脚本执行完后,我会顺手把pgrep的结果记录到日志文件里留着复盘。出现问题时,回看"哪些进程收到了信号、什么时间退出的、有没有等满15秒",比凭感觉排查高效得多。理解了信号15,下次写清理脚本的时候,你会给自己多留一层余地。