* 问题场景
我们在编写部署系统的时候,通常需要在机器上部署一个agent,用来执行部署脚本,为了防止部署脚本写的有问题,长时间hang住,我们通常会为脚本的执行设置一个超时时间,到了时间之后就kill掉该脚本的进程。如果是Go语言实现,脑袋里应该立马浮现出os/exec包,cmd.Process.Kill()这样的手段。但是,如果部署脚本中又调用了其他脚本,即子进程又fork出更多子进程的时候,这招就不好使了
* 代码验证
下面我们写段代码来简单验证一下
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
cmd := exec.Command("sleep", "5")
start := time.Now()
time.AfterFunc(3*time.Second, func() { cmd.Process.Kill() })
err := cmd.Run()
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err)
}
输出:
[work@vm killproc]$ go run foo1.go
pid=8584 duration=3.00026958s err=signal: killed
[work@vm killproc]$ ps -jl
F S UID PID PPID PGID SID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 1000 3570 3569 3570 2519 0 80 0 - 28870 wait pts/0 00:00:00 bash
0 R 1000 8611 3570 8611 2519 0 80 0 - 30319 - pts/0 00:00:00 ps
程序按照预期在跑,到了3s的时候被kill,没有残留进程。下面我们让子进程继续fork子进程,看看效果
package main
import (
"fmt"
"os/exec"
"time"
)
func main() {
cmd := exec.Command("/bin/sh", "-c", "watch date > date.txt")
start := time.Now()
time.AfterFunc(3*time.Second, func() { cmd.Process.Kill() })
err := cmd.Run()
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err)
}
输出:
[work@vm killproc]$ go run foo2.go
pid=8753 duration=3.000296177s err=signal: killed
[work@vm killproc]$ ps -jl
F S UID PID PPID PGID SID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 1000 3570 3569 3570 2519 0 80 0 - 28870 wait pts/0 00:00:00 bash
0 S 1000 8754 1 8730 2519 0 80 0 - 31323 hrtime pts/0 00:00:00 watch
0 R 1000 8767 3570 8767 2519 0 80 0 - 30319 - pts/0 00:00:00 ps
程序仍然是3s退出,/bin/sh被kill,但是残留了watch这个子进程,该子进程的PPID已经是1,即被init进程接管了
为什么会这样?
Go是使用kill(2)向sh进程的PID发了一个KILL信号,但没有发给watch进程,sh进程被kill之后,导致watch进程变成孤儿进程。实际这是unix编程语言的一个非常正常的行为,只是…在很多场景下确实不适用。
* 解决方案
kill(2)不但支持向单个PID发送信号,还可以向进程组发信号,传递进程组PGID的时候要使用负数的形式。我们只要把sh进程及其所有子进程放到一个进程组里,就可以批量Kill了。关键是PGID的设置,默认情况下,子进程会把自己的PGID设置成与父进程相同,所以,我们只要设置了sh进程的PGID,所有子进程也就相应的有了PGID。
package main
import (
"fmt"
"os/exec"
"syscall"
"time"
)
func main() {
cmd := exec.Command("/bin/sh", "-c", "watch date > date.txt")
// Go会将PGID设置成与PID相同的值
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
start := time.Now()
time.AfterFunc(3*time.Second, func() { syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) })
err := cmd.Run()
fmt.Printf("pid=%d duration=%s err=%s\n", cmd.Process.Pid, time.Since(start), err)
}
输出:
[work@vm killproc]$ go run foo3.go
pid=17358 duration=3.000300985s err=signal: killed
[work@vm killproc]$ ps -jl
F S UID PID PPID PGID SID C PRI NI ADDR SZ WCHAN TTY TIME CMD
4 S 1000 17156 17155 17156 17136 0 80 0 - 28845 wait pts/0 00:00:00 bash
0 R 1000 17364 17156 17364 17136 0 80 0 - 30319 - pts/0 00:00:00 ps
如我们所愿,watch进程并没有残留,目标达成。