碰到这样一个问题——用Java程序来控制shell脚本的运行和停止。具体来讲,这个Java程序至少要有三个功能:
运行Shell脚本;
等待Shell脚本执行结束;
停止运行中的Shell程序;
从功能需求来看,似乎是比较容易做到的。尽管没有写过类似功能的程序,Google一下,很快就有答案了。
用Runtime或者ProcessBuilder可以运行程序,而Process类的waitFor()和destroy()方法分别满足功能2和3。
import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; public class ShellRunner extends Thread { private Process proc; private String dir; private String shell; public ShellRunner(String dir, String shell) { super(); this.proc = null; this.dir = dir; this.shell = shell; } @Override public void run() { try { ProcessBuilder builder = new ProcessBuilder("sh", dir + shell); builder.directory(new File(dir)); proc = builder.start(); System.out.println("Running ..."); int exitValue = proc.waitFor(); System.out.println("Exit Value: " + exitValue); } catch (IOException e) { e.getLocalizedMessage(); } catch (InterruptedException e) { e.getLocalizedMessage(); } } public void kill() { if (this.getState() != State.TERMINATED) { proc.destroy(); } } public static void main(String args[]) { ShellRunner runner = new ShellRunner("/tmp/", "run.sh"); runner.start(); InputStreamReader inputStreamReader = new InputStreamReader(System.in); BufferedReader reader = new BufferedReader(inputStreamReader); try { String line = null; while ( (line = reader.readLine()) != null ) { if (line.equals("kill")) { runner.kill(); } else if (line.equals("break")) { break; } else { System.out.println(runner.getState()); } } reader.close(); inputStreamReader.close(); } catch (IOException e) { e.printStackTrace(); } } }
跑一下上面这个测试程序,waitFor()方法可以正确等待shell程序退出,但是destroy()方法并没有结束shell脚本相关的进程。
为什么呢?
这是一个BUG。
JDK-bug-4770092:Process.destroy() 不能结束孙子进程(grandchildren)。上述例子中,java程序的子进程是”sh run.sh”,而shell脚本中的任何命令都是”sh run.sh”这个进程的子进程(也可能有孙子进程,或者更远的后代进程)。所以shell脚本中执行的命令并不能随着 Process.destroy()结束。这是一个很老的BUG,但是出于各个平台兼容性的考虑,官方并不准备修复这个BUG。似乎依赖Java程序来完 成功能3的路已经断了。
现在剩下的问题可以归结为:如何结束一颗进程树上的所有进程?其中的某些进程可能已经退出,也就是说进程树的某些分支可能已经断开了。
一个比较自然的想法是:记录所有以”sh run.sh”这个进程为根进程的所有进程号,需要的时候统一kill。这需要一点Linux进程的相关知识:
Linux下每个进程有很多ID属性:PID(进程号)、PPID(父进程号)、PGID(进程组号)、SID(进程所在session的ID)
子进程会继承父进程的进程组信息和会话信息
一个进程只能创建从属于(和它自身)同一个会话的进程组,除非使用setsid系统调用的方式新建一个会话
进程组不能在不同的会话中迁移,进程所属的进程组可以变,但是仅限于同一个会话中的进程组
还要一点Linux命令工具的知识:
用 “kill -9 -1234”可以杀死进程组号为1234的所有进程
strace命令可以跟踪某个进程以及它fork出来的所有子进程产生的系统调用
有了这些知识,思路就比较清晰了:用strace跟踪setsid这个系统调用,记下所有伴随系统调用产生的SID;在需要杀死这棵进程树上的所有进程时,用ps命令把进程树上还没退出的进程组全部找出,一并kill。
形成代码就是:
import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; public class ShellRunner2 extends Thread { private Process proc; private String dir; private String shell; private File tmpFile; public ShellRunner2(String dir, String shell) throws IOException { super(); this.proc = null; this.dir = dir; this.shell = shell; this.tmpFile = createTempFile(dir, shell); } @Override public void run() { try { ProcessBuilder builder = new ProcessBuilder("sh", "-c", "strace -o " + tmpFile.getPath() + " -f -e trace=setsid setsid sh " + dir + shell); builder.directory(new File(dir)); proc = builder.start(); System.out.println("Running ..."); int exitValue = proc.waitFor(); System.out.println("Exit Value: " + exitValue); } catch (IOException e) { e.getLocalizedMessage(); } catch (InterruptedException e) { e.getLocalizedMessage(); } } public void kill() { if (this.getState() != State.TERMINATED) { try { ProcessBuilder builder = new ProcessBuilder("sh", "-c", "ps -o sid,pgid ax | " + "grep $(grep -e \"setsid()\" -e \"<... setsid resumed>\" " + tmpFile.getPath() + " | awk '{printf \" -e\" $NF}') | awk {'print $NF'} | " + "sort | uniq | sed 's/^/-/g' | xargs kill -9 2>/dev/null"); builder.directory(new File(dir)); Process proc = builder.start(); proc.waitFor(); } catch (IOException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String args[]) throws IOException { ShellRunner2 runner = new ShellRunner2("/tmp/", "a.sh"); runner.start(); InputStreamReader inputStreamReader = new InputStreamReader(System.in); BufferedReader reader = new BufferedReader(inputStreamReader); try { String line = null; while ( (line = reader.readLine()) != null ) { if (line.equals("kill")) { runner.kill(); } else if (line.equals("break")) { break; } else { System.out.println(runner.getState()); } } reader.close(); inputStreamReader.close(); } catch (IOException e) { e.printStackTrace(); } } private File createTempFile(String dir, String prefix) throws IOException { String name = "." + prefix + "-" + System.currentTimeMillis(); File tempFile = new File(dir, name); if (tempFile.createNewFile()) { return tempFile; } throw new IOException("Failed to create file " + tempFile.getPath() + "."); } }
设计一个针对性的测试:
下面这个程序fork子程序并设置PGID,测试过成中编译为可执行文件pgid_test。
#include <unistd.h> #include <sys/types.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> int main(int argc, void *argv[]) { pid_t pid; int slp = 10; if (argc > 1) { int tmp = atoi(argv[1]); if (tmp > 0 && tmp < 120) { slp = tmp; } } if ((pid = fork()) != 0) { if (setpgid(pid, 0) != 0) { fprintf(stderr, "setpgid() error - %s", strerror(errno)); } } sleep(slp); return 0; }
{a,b,c,d}.sh这几个shell脚本之间的调用模拟了一个进程树的生成,并且这些进程有着不同的SID或者PGID。
a.sh
sh b.sh &(sleep 15)&sleep 10
b.sh
(sleep 12)&sh c.sh &
c.sh
./pgid_test 20 &setsid sh d.sh &setsid sh d.sh &
d.sh
./pgid_test 30 &(sleep 30; echo "bad killer " `date` >> /tmp/bad_killer) &
运行java程序,调用kill()后,所有子进程都被成功结束了。
这个方法并不那么优美,也有可能存在问题:
strace的输出sesion id的形式可能不止那么两种;不同版本的strace的输出可能不一样。
shell程序可能以其他用户身份启动了一些进程,而kill又没有权限杀死那些进程。
在ps命令执行之后,kill执行之前,正好有setsid调用,这个调用产生的session的相关进程会被漏掉。这个问题可以通过多次执行解决。