原文地址:Go Exec 殭屍與孤兒程式
最近,使用 golang
去管理本地應用的生命週期,期間有幾個有趣的點,今天就一起看下。
場景一
我們來看看下面兩個指令碼會產生什麼問題:
建立兩個 shell 指令碼
- start.sh
#!/bin/sh
sh sub.sh
- sub.sh
#!/bin/sh
n=0
while [ $n -le 100 ]
do
echo $n
let n++
sleep 1
done
執行指令碼
輸出結果
$ ./start.sh
0
1
2
...
程式關係
檢視程式資訊
ps -j
USER PID PPID PGID SESS JOBC STAT TT TIME COMMAND
root 31758 31346 31758 0 1 S+ s000 0:00.00 /bin/sh ./start.sh
root 31759 31758 31758 0 1 S+ s000 0:00.01 sh sub.sh
sub.sh
的 父程式(PPID)為start.sh
的程式id(PID)sub.sh
和start.sh
兩個程式的PGID
是同一個,( 屬一個程式組)。
刪除 start.sh
的程式
kill -9 31758
# 再檢視程式組
ps -j
## 返回
USER PID PPID PGID SESS JOBC STAT TT TIME COMMAND
root 31759 1 31758 0 0 S s000 0:00.03 sh sub.sh
start.sh
程式不在了sub.sh
程式還在執行sub.sh
程式的PID
變成了 1
問題1:
那sub.sh
這個程式現在屬於什麼?
場景二
假設sub.sh
是實際的應用, start.sh
是應用的啟動指令碼。
那麼,golang
是如何管理他們的呢? 我們繼續看看下面 關於golang
的場景。
在上面兩個指令碼的基礎上,我們用golang
的 os/exec
庫去呼叫 start.sh
指令碼
package main
import (
"context"
"log"
"os"
"os/exec"
"time"
)
func main() {
cmd := exec.CommandContext(context.Background(), "./start.sh")
// 將 start.sh 和 sub.sh 移到當前目錄下
cmd.Dir = "/Go/src/go-code/cmd/"
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("cmd.Start error %+v \n", err)
}
for {
select {
default:
log.Println(cmd.Process.Pid)
time.Sleep(2 * time.Second)
}
}
}
執行程式
go run ./main.go
檢視程式
ps -j
USER PID PPID PGID SESS JOBC STAT TT TIME COMMAND
root 45458 45457 45457 0 0 Ss+ s004 0:00.03 ...___1go_build_go_code_cmd
root 45462 45458 45457 0 0 S+ s004 0:00.01 /bin/sh ./start.sh
root 45463 45462 45457 0 0 S+ s004 0:00.03 sh sub.sh
發現 go
、 start.sh
、sub.sh
三個程式為同一個程式組(同一個 PGID)
父子關係為: main.go
-> start.sh
-> sub.sh
刪除 start.sh
的程式
實際場景,有可能啟動程式掛了,導致我們無法監聽到執行程式的情況,刪除start.sh
程式,模擬下場景 :
kill -9 45462
再檢視程式
ps -j
USER PID PPID PGID SESS JOBC STAT TT TIME COMMAND
root 45458 45457 45457 0 0 Ss+ s004 0:00.03 ...___1go_build_go_code_cmd
root 45462 1 45457 0 0 S+ s004 0:00.01 (bash)
root 45463 45462 45457 0 0 S+ s004 0:00.03 sh sub.sh
- 發現沒,
start.sh
的PPID
為1 - 即使
start.sh
的PPID
變成了1 ,log.Println(cmd.Process.Pid)
還持續的輸出 .
問題2:
那如果 PPID
為1 ,golang
程式不就無法管理了嗎? 即使 sub.sh 退出也不知道了,那要如何處理?
問題分析
- 兩個場景中, 都有一個共同的點,就是
PPID
為1,這妥妥的成為沒人要的娃了——孤兒程式
- 場景二中,如果
cmd
的沒有程式沒有被回收,go
程式也無法管理,那麼start.sh
就成為了佔著茅坑不拉屎的子程式——殭屍程式
那究竟什麼是孤兒程式
和 殭屍程式
?
孤兒程式
在類 UNIX
作業系統中,孤兒程式(Orphan Process)指:是在其父程式執行完成或被終止後仍繼續執行的一類程式。
為避免孤兒程式退出時無法釋放所佔用的資源而僵死,任何孤兒程式產生時都會立即為系統程式 init
或 systemd
自動接收為子程式,這一過程也被稱為收養
。在此需注意,雖然事實上該程式已有init
作為其父程式,但由於建立該程式的程式已不存在,所以仍應稱之為孤兒程式
。孤兒程式會浪費伺服器的資源,甚至有耗盡資源的潛在危險。
解決&預防
- 終止機制:強制殺死孤兒程式(最常用的手段);
- 再生機制:伺服器在指定時間內查詢呼叫的客戶端,若找不到則直接殺死孤兒程式;
- 超時機制:給每個程式指定一個確定的執行時間,若超時仍未完成則強制終止之。若有需要,亦可讓程式在指定時間耗盡之前申請延時。
- 程式組:因為父程式終止或崩潰都會導致對應子程式成為孤兒程式,所以也無法預料一個子程式執行期間是否會被“遺棄”。有鑑於此,多數類UNIX系統都引入了程式組以防止產生孤兒程式。
殭屍程式
在類 UNIX
作業系統中,殭屍程式(zombie process)指:完成執行(通過exit系統呼叫,或執行時發生致命錯誤或收到終止訊號所致),但在作業系統的程式表中仍然存在其程式控制塊,處於"終止狀態"的程式。
正常情況下,程式直接被其父程式 wait
並由系統回收。而殭屍程式與正常程式不同,kill
命令對殭屍程式無效,並且無法回收,從而導致資源洩漏。
解決&預防
收割殭屍程式的方法是通過 kill
命令手工向其父程式傳送SIGCHLD訊號。如果其父程式仍然拒絕收割殭屍程式,則終止父程式,使得 init
程式收養殭屍程式。init
程式週期執行 wait
系統呼叫收割其收養的所有殭屍程式。
檢視程式詳情
# 列出程式
ps -l
- USER:程式的所屬使用者
- PID:程式的程式ID號
- RSS:程式佔用的固定的記憶體量 (Kbytes)
- S:檢視程式狀態
- CMD:程式對應的實際程式
程式狀態(S)
- R:執行 Runnable (on run queue) 正在執行或在執行佇列中等待
- S:睡眠 Sleeping 休眠中,受阻,在等待某個條件的形成或接受到訊號
- I:空閒 Idle
- Z:僵死 Zombie(a defunct process) 程式已終止,但程式描述符存在, 直到父程式呼叫wait4()系統呼叫後釋放
- D:不可中斷 Uninterruptible sleep (ususally IO) 收到訊號不喚醒和不可執行, 程式必須等待直到有中斷髮生
- T:終止 Terminate 程式收到SIGSTOP、SIGSTP、 SIGTIN、SIGTOU訊號後停止執行執行
- P:等待交換頁
- W:無駐留頁 has no resident pages 沒有足夠的記憶體分頁可分配
- X:死掉的程式
Go解決方案
採用 殺掉程式組(kill process group,而不是隻 kill 父程式,在 Linux 裡面使用的是 kill -- -PID
) 與 程式wait方案,結果如下:
package main
import (
"context"
"log"
"os"
"os/exec"
"syscall"
"time"
)
func main() {
ctx := context.Background()
cmd := exec.CommandContext(ctx, "./start.sh")
// 設定程式組
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
cmd.Dir = "/Users/Wilbur/Project/Go/src/go-code/cmd/"
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Printf("cmd.Start error %+v \n", err)
}
// 監聽程式wait
errCmdCh := make(chan error, 1)
go func() {
errCmdCh <- cmd.Wait()
}()
for {
select {
case <-ctx.Done():
log.Println("ctx.done")
pid := cmd.Process.Pid
if err := syscall.Kill(-1*pid, syscall.SIGKILL); err != nil {
return
}
case err := <-errCmdCh:
log.Printf("errCmdCh error %+v \n", err)
return
default:
log.Println(cmd.Process.Pid)
time.Sleep(2 * time.Second)
}
}
}
剖析 cmd.Wait()
原始碼
在 os/exec_unix
下:
var (
status syscall.WaitStatus
rusage syscall.Rusage
pid1 int
e error
)
for {
pid1, e = syscall.Wait4(p.Pid, &status, 0, &rusage)
if e != syscall.EINTR {
break
}
}
進行了 syscall.Wait4
對系統監聽,正如"僵死 Zombie(a defunct process) 程式已終止,但程式描述符存在, 直到父程式呼叫wait4()系統呼叫後釋放",所說一致。
總結
嚴格地來說,殭屍程式並不是問題的根源,罪魁禍首是產生出大量殭屍程式的那個父程式。
因此,當我們尋求如何消滅系統中大量的殭屍程式時,更應該是在實際的開發過程中,思考如何避免殭屍程式的產生。