天天看點

如何避免 Go 指令行執行産生“孤兒”程序?

如何避免 Go 指令行執行産生“孤兒”程式?

作者 | 昕希

來源 | 阿裡技術公衆号

在 Go 程式當中,如果我們要執行指令時,通常會使用 exec.Command ,也比較好用,通常狀況下,可以達到我們的目的,如果我們邏輯當中,需要終止這個程序,則可以快速使用 cmd.Process.Kill() 方法來結束程序。但當我們要執行的指令會啟動其他子程序來操作的時候,會發生什麼情況?

一 孤兒程序的産生

測試小程式:

func kill(cmd *exec.Cmd) func() {
    return func() {
    if cmd != nil {
    cmd.Process.Kill()
    }
    }
}

func main() {
    cmd := exec.Command("/bin/bash", "-c", "watch top >top.log")
    time.AfterFunc(1*time.Second, kill(cmd))
    err := cmd.Run()
    fmt.Printf("pid=%d err=%s\n", cmd.Process.Pid, err)
}           

執行小程式:

go run main.go

pid=27326 err=signal: killed           

檢視程序資訊:

ps -j

USER    PID  PPID  PGID   SESS JOBC STAT   TT       TIME COMMAND
king  24324     1 24303      0    0 S    s012    0:00.01 watch top           

可以看到這個 "watch top" 的 PPID 為 1,說明這個程序已經變成了 “孤兒” 程序。

那為什麼會這樣,這并不符合我們預期,那麼可以從 Go 的文檔中找到答案:

如何避免 Go 指令行執行産生“孤兒”程式?

二 通過程序組來解決掉所有子程序

在 linux 當中,是有會話、程序組和程序組的概念,并且 Go 也是使用 linux 的 kill(2) 方法來發送信号的,那麼是否可以通過 kill 來将要結束程序的子程序都結束掉?

linux 的 kill(2) 的定義如下:

如何避免 Go 指令行執行産生“孤兒”程式?

并在方法的描述中,可以看到如下内容:

如何避免 Go 指令行執行産生“孤兒”程式?

如果 pid 為正數的時候,會給指定的 pid 發送 sig 信号,如果 pid 為負數的時候,會給這個程序組發送 sig 信号,那麼我們可以通過程序組來将所有子程序退出掉?改一下 Go 程式中 kill 方法:

func kill(cmd *exec.Cmd) func() {
    return func() {
    if cmd != nil {
    // cmd.Process.Kill()
    syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
    }
    }
}

func main() {
    cmd := exec.Command("/bin/bash", "-c", "watch top >top.log")
    time.AfterFunc(1*time.Second, kill(cmd))
    err := cmd.Run()
    fmt.Printf("pid=%d err=%s\n", cmd.Process.Pid, err)
}           

再次執行:

go run main.go           

會發現程式卡住了,我們來看一下目前執行的程序:

ps -j

USER    PID  PPID  PGID   SESS JOBC STAT   TT       TIME COMMAND
king 27655 91597 27655      0    1 S+   s012    0:01.10 go run main.go
king 27672 27655 27655      0    1 S+   s012    0:00.03 ..../exe/main
king 27673 27672 27655      0    1 S+   s012    0:00.00 /bin/bash -c watch top >top.log
king 27674 27673 27655      0    1 S+   s012    0:00.01 watch top           

可以看到我們 go run 産生了一個子程序 27672(command 那裡是 go 執行的臨時目錄,比較長,是以添加了省略号),27672 産生了 27673(watch top >top.log)程序,27673 産生了 27674(watch top)程序。那為什麼沒有将這些子程序都關閉掉呢?

其實之類犯了一個低級錯誤,從上圖中,我們可以看到他們的程序組 ID 為 27655,但是我們傳遞的是 cmd 的 id 即 27673,這個并不是程序組的 ID,是以程式并沒有 kill,導緻 cmd.Run() 一直在執行。

在 Linux 中,程序組中的第一個程序,被稱為程序組 Leader,同時這個程序組的 ID 就是這個程序的 ID,從這個程序中建立的其他程序,都會繼承這個程序的程序組和會話資訊;從上面可以看出 go run main.go 程式 PID 和 PGID 同為 27655,那麼這個程序就是程序組 Leader,我們不能 kill 這個程序組,除非想“自殺”,哈哈哈。

那麼我們給要執行的程序,建立一個程序組,在 Kill 不就可以了嘛。在 linux 當中,通過 setpgid 方法來設定程序組 ID,定義如下:

如何避免 Go 指令行執行産生“孤兒”程式?

如果将 pid 和 pgid 同時設定成 0,也就是 setpgid(0,0),則會使用目前程序為程序組 leader 并建立新的程序組。

那麼在 Go 程式中,可以通過 cmd.SysProcAttr 來設定建立新的程序組,修改後的代碼如下:

func kill(cmd *exec.Cmd) func() {
    return func() {
    if cmd != nil {
    // cmd.Process.Kill()
    syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
    }
    }
}

func main() {
    cmd := exec.Command("/bin/bash", "-c", "watch top >top.log")
  cmd.SysProcAttr = &syscall.SysProcAttr{
    Setpgid: true,
    }
    
    time.AfterFunc(1*time.Second, kill(cmd))
    err := cmd.Run()
    fmt.Printf("pid=%d err=%s\n", cmd.Process.Pid, err)
}           
go run main.go

pid=29397 err=signal: killed           

再次檢視程序:

ps -j

USER    PID  PPID  PGID   SESS JOBC STAT   TT       TIME COMMAND           

發現 watch 的程序都不存在了,那我們在看看是否還會有孤兒程序:

# 由于我測試的環境是mac,是以這個腳本隻能在mac執行
ps -j | head -1;ps -j | awk '{if ($3 ==1 && $1 !="root"){print $0}}' | head

USER    PID  PPID  PGID   SESS JOBC STAT   TT       TIME COMMAND           

已經沒有孤兒程序了,問題至此已經完全解決。

三 子程序監聽父程序是否退出(隻能在 linux 下執行)

假設要調用的程式也是我們自己寫的其他應用程式,那麼可以使用 Linux 的 prctl 方法來處理, prctl 方法的定義如下:

如何避免 Go 指令行執行産生“孤兒”程式?

這個方法有一個重要的 option:PR_SET_PDEATHSIG,通過這個來接收父程序的退出。

讓我們來再次構造一個有問題的程式。

有兩個檔案,分别為 main.go 和 child.go 檔案,main.go 會調用 child.go 檔案。

main.go 檔案:

package main

import (
        "os/exec"
)

func main() {
        cmd := exec.Command("./child")
        cmd.Run()
}           

child.go 檔案:

package main

import (
    "fmt"
    "time"
)

func main() {
    for {
    time.Sleep(200 * time.Millisecond)
    fmt.Println(time.Now())
    }
}           

在 Linux 環境中分别編譯這兩個檔案:

// 編譯 main.go 生成 main 二進制檔案
go build -o main main.go

// 編譯 child.go 生成 child 二進制檔案
go build -o child child.go           

執行 main 二進制檔案:

./main &           

檢視他們的程序:

ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash
root     11514     1  0 12:12 pts/0    00:00:00 ./main
root     11520 11514  0 12:12 pts/0    00:00:00 ./child           

可以看到 main 和 child 的程序,child 是 main 的子程序,我們将 main 程序 kill 掉,在檢視程序狀态:

kill -9 11514

ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash
root     11520     1  0 12:12 pts/0    00:00:00 ./child           

我們可以看到 child 的程序,他的 PPID 已經變成了 1,說明這個程序已經變成了孤兒程序。

那接下來我們可以使用 PR_SET_PDEATHSIG 來保證父程序退出,子程序也退出,大緻方式有兩種:使用 CGO 調用和使用 syscall.RawSyscall 來調用。

1 使用 CGO

将 child 修改成如下内容:

如何避免 Go 指令行執行産生“孤兒”程式?

程式中,使用 CGO,為了簡單的展示,在 Go 檔案中編寫了 C 的 killTest 方法,并調用了 prctl 方法,然後在 Go 程式中調用 killTest 方法,讓我們重新編譯執行一下,再看看程序:

go build -o child child.go
./main & 
ps -ef 

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash
root     11663     1  0 12:28 pts/0    00:00:00 ./main
root     11669 11663  0 12:28 pts/0    00:00:00 ./child           

再次 kill 掉 main,并檢視程序:

kill -9 11663
ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash           

可以看到 child 的程序也已經退出了,說明 CGO 調用的 prctl 生效了。

2 syscall.RawSyscall 方法

也可以采用 Go 中提供的 syscall.RawSyscall 方法來替代調用 CGO,在 Go 的文檔中,可以檢視到 syscall 包中定義的常量(檢視 linux,如果是本地 godoc,需要指定 GOOS=linux),可以看到我們要用的幾個常量以及他們對應的數值:

// 其他内容省略掉了
const(
    ....
    PR_SET_PDEATHSIG                 = 0x1
    ....
)

const(     
    .....
    SYS_PRCTL                  = 157
    .....
)           

其中 PR_SET_PDEATHSIG 操作的值為 1,SYS_PRCTL 的值為 157,那麼将 child.go 修改成如下内容:

package main

import (
    "fmt"
    "os"
    "syscall"
    "time"
)

func main() {
    _, _, errno := syscall.RawSyscall(uintptr(syscall.SYS_PRCTL), uintptr(syscall.PR_SET_PDEATHSIG), uintptr(syscall.SIGKILL), 0)
    if errno != 0 {
    os.Exit(int(errno))
    }

    for {
    time.Sleep(200 * time.Millisecond)
    fmt.Println(time.Now())
    }
}           

再次編譯并執行:

go build -o child child.go
./main & 
ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash
root     12208     1  0 12:46 pts/0    00:00:00 ./main
root     12214 12208  0 12:46 pts/0    00:00:00 ./child           

将 main 程序結束掉:

kill -9 12208
ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 06:05 pts/0    00:00:00 /bin/bash           

child 程序已經退出了,也達成了最終效果。

四 總結

當我們使用 Go 程式執行其他程式的時候,如果其他程式也開啟了其他程序,那麼在 kill 的時候可能會把這些程序變成孤兒程序,一直執行并滞留在記憶體中。當然,如果我們程式非法退出,或者被 kill 調用,也會導緻我們執行的程序變成孤兒程序,那麼為了解決這個問題,從兩個思路來解決:

  • 給要執行的程式建立新的程序組,并調用 syscall.Kill,傳遞負值 pid 來關閉這個程序組中所有的程序(比較完美的解決方法)。
  • 如果要調用的程式也是我們自己編寫的,那麼可以使用 PR_SET_PDEATHSIG 來感覺父程序退出,那麼這種方式需要調用 Linxu 的 prctrl,可以使用 CGO 的方式,也可以使用 syscall.RawSyscall 的方式。

但不管使用哪種方式,都隻是提供了一種思路,在我們編寫服務端服務程式的時候,需要特殊關注,防止孤兒程序消耗伺服器資源。

技術公開課

《Go語言核心程式設計(2):面向對象、檔案、單元測試、反射、TCP程式設計》

本課程共162課時,包含Go面向對象程式設計、檔案操作、單元測試、goroutine和channel、反射、Redis、TCP程式設計等核心基礎知識,以及家庭收支記賬和客戶管理系統實戰。

點選這裡

,開始學習吧~

繼續閱讀