Go语言中Kill子进程的正确姿势

作者: adm 分类: go 发布时间: 2022-08-18

* 问题场景
我们在编写部署系统的时候,通常需要在机器上部署一个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进程并没有残留,目标达成。

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!