首頁 > 軟體

一文搞懂Go Exec 殭屍與孤兒程序

2022-02-28 10:00:56

最近,使用 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()系統呼叫後釋放",所說一致。

總結

嚴格地來說,殭屍程序並不是問題的根源,罪魁禍首是產生出大量殭屍程序的那個父程序。

因此,當我們尋求如何消滅系統中大量的殭屍程序時,更應該是在實際的開發過程中,思考如何避免殭屍程序的產生。

參考:

https://pkg.go.dev/syscall

https://cs.opensource.google/go/go/+/refs/tags/go1.17.7:src/syscall/syscall_linux.go;l=279

https://pkg.go.dev/os/exec

到此這篇關於一文搞懂Go Exec 殭屍與孤兒程序 的文章就介紹到這了,更多相關Go Exec 殭屍與孤兒程序內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com