有段时间我经常要处理自动化测试设备的残留进程问题。跑完Monkey压测后设备上偶尔会留下几个monkey进程拖慢后续任务。一开始清理方式很粗暴用kill -9硬杀。结果某次批量跑完测试后新启动的任务频繁报端口冲突和日志目录被占用。后来我把清场命令从pkill -9 monkey换成了pkill -15 monkey配合等待逻辑情况明显好转。这背后其实是15号信号SIGTERM和9号信号SIGKILL的差异。如果你经常和adb shell、Linux命令、自动化测试打交道这篇内容会很实用。我会把SIGTERM信号本身的机制讲透说清楚pkill -15 monkey这条命令到底匹配了什么、信号发出后发生了什么、为什么它比直接kill -9更值得推荐以及怎么把它正确写进测试脚本里。1. 为什么偏偏是15SIGTERM和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 默认动作和可捕获性SIGTERM15的默认动作是终止进程。注意终止不等于强制抹杀。进程可以安装处理器handler来捕获这个信号从而执行自己的清理动作比如关闭文件、保存状态、通知监控系统自己马上要退出甚至可以暂时忽略它、暂不处理。SIGKILL9则完全不同。它的默认动作是立即强制终止进程而且不可捕获、不可阻塞、不可忽略。内核收到针对某个进程的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], stdoutsubprocess.DEVNULL) # 再做日志归档等收尾工作 collect_logs() sys.exit(130) # 128 SIGTERM def collect_logs(): print(collecting crash logs and moving to archive..., filesys.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 21; then break fi sleep 1 done超过15秒还活着才用SIGKILL兜底。这个等待窗口后来成了所有测试清理步骤的标配。回头看这三种事故都指向同一个结论理解信号的语义工程上才会少踩坑。5. 更稳的工程做法把SIGTERM写进测试的退场流程5.1 一个结构化的优雅停止脚本我现在常用的一套停止monkey逻辑按顺序分四步检查目标、发送SIGTERM、等待优雅退出、超时后SIGKILL兜底。#!/bin/bash # 目标进程模式串尽量带完整包名别用裸monkey PATTERNcom.android.commands.monkey # 第一步确认目标进程是否存在 if ! adb shell pgrep -f $PATTERN /dev/null 21; 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 21; 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..., filesys.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下次写清理脚本的时候你会给自己多留一层余地。