0%

一个问题后总结孤儿进程和僵尸进程

操作系统里面很早就讲了孤儿进程和僵尸进程,几年前刚学的时候用 C++ 写过一点 demo
这次在实际写代码过程中遇到,整理再总结一下,写出对应的例子进行演示

事情原因

在开发的过程中用到了定时任务,定时任务有兴趣可以看看 golang cron v3 定时任务
在每到 cron 的规定时间,对应的任务通过 exec 包执行 rsync 命令,在执行完后退出
在更新代码之后重新部署的时候,先 kill 父进程,然后重新启动,排查问题时发现有很多没有结束的 rsync 进程,都变成了孤儿进程
想到了因为父进程 kill 掉,子进程还没有执行结束,所以被 init 接管,借这次机会整理总结一下

文章大部分概念性内容转自 参考资料 中的 孤儿进程与僵尸进程[总结]

基本概念

我们知道在 unix/linux 中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。当一个进程完成它的工作终止之后,它的父进程需要调用 wait() 或者 waitpid() 系统调用取得子进程的终止状态。

  • 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作。
  • 僵尸进程:一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。

孤儿进程

孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了 init 进程身上,init 进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤儿进程的父进程设置为 init,而 init 进程会循环地 wait() 它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init 进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。

孤儿进程-子进程内容

子进程我写了一个循环打印的 sleep.go,代码很简单,build 成可执行文件放到 testgo/orphan

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"time"
)

func main() {
for i := 1; i <= 50; i++ {
<-time.After(time.Second * 2)
fmt.Printf("now: %d\n", i)
}
}

孤儿进程-主进程内容

testgo/orphanorphan.go 代码如下,执行 $ go build orphan.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"os/exec"
"time"
)

func main() {
p := "/root/testgo/orphan/sleep"
CreateOrphan(p)
time.Sleep(time.Minute)
}

func CreateOrphan(p string) error {
cmd := exec.Command(p, ">log")
fmt.Println(cmd.String())
if err := cmd.Run(); err != nil {
return err
}
return nil
}

如果父进程比子进程先结束也是可以的,将 main 改为以下代码就不用手动 kill 了

1
2
3
4
5
func main() {
p := "/root/testgo/orphan/sleep"
go CreateOrphan(p)
time.Sleep(time.Second)
}

孤儿进程-运行解释

testgo/orphan 下执行 ./orphan,主进程会调用 exec 来执行 sleep 这个程序
此时通过 pid 可以看到,orphan 的进程编号为 5891,sleep 进程的进程编号为 5895,父进程编号为 orphan 的进程编号 5891

orphan-before-kill

手动 kill 父进程,可以看到启动 orphan 的终端中显示已经停止,再次查看 sleep 的进程号,发现进程编号没有变化,但是父进程变成 1 也就是被 init 进程所接管

orphan-after-kill

如果你想要 kill 父进程时一同杀死子进程,可以使用

1
$ kill -9 `ps -ef |grep PID|awk '{print $2}' `

僵尸进程

unix 提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号 the process ID,退出状态 the termination status of the process,运行时间 the amount of CPU time taken by the process 等)。直到父进程通过 wait / waitpid 来取时才释放。但这样就导致了问题,如果进程不调用 wait / waitpid 的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵尸进程,将因为没有可用的进程号而导致系统不能产生新的进程。此即为僵尸进程的危害,应当避免。

任何一个子进程(init 除外)在 exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在 exit()之后,父进程没有来得及处理,这时用 ps 命令就能看到子进程的状态是“Z”。如果父进程能及时处理,可能用 ps 命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。如果父进程在子进程结束之前退出,则子进程将由 init 接管。init 将会以父进程的身份对僵尸状态的子进程进行处理。

僵尸进程危害场景:
例如有个进程,它定期的产生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵尸进程,倘若用 ps 命令查看的话,就会看到很多状态为 Z 的进程。严格地来说,僵尸进程并不是问题的根源,罪魁祸首是产生出大量僵尸进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵尸进程时,答案就是把产生大量僵尸进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL 信号啦)。枪毙了元凶进程之后,它产生的僵尸进程就变成了孤儿进程,这些孤儿进程会被 init 进程接管,init 进程会 wait()这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵尸的孤儿进程就能瞑目而去了。

僵尸进程-子进程内容

子进程跟孤儿进程中的一样

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"time"
)

func main() {
for i := 1; i <= 50; i++ {
<-time.After(time.Second * 2)
fmt.Printf("now: %d\n", i)
}
}

僵尸进程-主进程内容

主进程写了三个函数,分别使用 cmd.Start()cmd.Run() 这两种方式的区别就是主进程是否调用了 Wait() 来处理子进程的信号,该文件在 /root/testgo/zombie/zombie.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main

import (
"flag"
"fmt"
"os/exec"
"time"
)

func main() {
choice := flag.String("c", "1", "选择运行函数,1: CreateChild, 2: WaitChild, 3: CreateZombie, 4: go CreateZombie")
flag.Parse()
fmt.Println("you choose:", *choice)
p := "/root/testgo/zombie/sleep"
if *choice == "1" {
CreateChild(p)
} else if *choice == "2" {
CreateWait(p)
} else if *choice == "3" {
CreateZombie(p)
} else if *choice == "4" {
go CreateZombie(p)
}
fmt.Println("before sleep")
time.Sleep(time.Minute)
}

func CreateChild(p string) error {
cmd := exec.Command(p, ">log")
fmt.Println(cmd.String())
if err := cmd.Run(); err != nil {
return err
}
return nil
}

func CreateWait(p string) error {
cmd := exec.Command(p, ">log")
fmt.Println(cmd.String())
if err := cmd.Start(); err != nil {
return err
}
fmt.Println("after Start before wait")
if err := cmd.Wait(); err != nil {
return err
}
return nil
}

func CreateZombie(p string) error {
cmd := exec.Command(p, ">log")
fmt.Println(cmd.String())
if err := cmd.Start(); err != nil {
return err
}
// 这里的 kill 是为了结束子进程
// 如果使用的子进程完成后就退出(比如只输出一个 hello 就结束),那么不 kill 也可以
if err := cmd.Process.Kill(); err != nil {
return err
}
return nil
}

僵尸进程-运行解释

首先解释一下 cmd.Start()cmd.Run() 的区别,从 cmd.Run() 的代码中可以看到,cmd.Run() 就是执行了 cmd.Start() 然后 cmd.Wait(),所以会等待这个子进程执行完成之后退出。运行表现就是在 cmd.Run() 这一行阻塞,等待执行完成之后才执行下面的内容
cmd.Start() 这一行执行之后就不会有阻塞的行为,会继续执行下面的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Run starts the specified command and waits for it to complete.
//
// The returned error is nil if the command runs, has no problems
// copying stdin, stdout, and stderr, and exits with a zero exit
// status.
//
// If the command starts but does not complete successfully, the error is of
// type *ExitError. Other error types may be returned for other situations.
//
// If the calling goroutine has locked the operating system thread
// with runtime.LockOSThread and modified any inheritable OS-level
// thread state (for example, Linux or Plan 9 name spaces), the new
// process will inherit the caller's thread state.
func (c *Cmd) Run() error {
if err := c.Start(); err != nil {
return err
}
return c.Wait()
}

choice 1

第一个 choice 运行之后,主进程等待子进程执行完之后才执行下面内容
由于 cmd.Run() 中有 cmd.Wait() 处理了子进程结束时发出的信号,所以不会有僵尸进程
zombie-choice-1

choice 2

可以看到在启动之后,在 cmd.Start() 执行后开始子进程,在 cmd.Wait() 处阻塞等待
在子进程结束后 cmd.Wait() 处理了信号也不会出现僵尸进程
zombie-choice-2

choice 3

使用 Start 启动子进程之后,直接 Kill,子进程的信号没有得到处理
可以看到进程中 sleep 后面出现了 <defunct>,在 top 命令中也可以看到有一个 zombie 进程
zombie-choice-3

待主进程执行完 time.sleep() 后主进程也退出,对应的僵尸进程也就消失了

zombie-choice-3-done

choice 4

由于这次是通过一个 goroutine 来启动的,和 choice 3 相比较还是同一个进程启动,所以表现和 choice3 一样

僵尸进程-处理和避免

要注意僵尸进程一定是在父进程没有结束的时候存在的,如果父进程结束或者变成孤儿进程,那么僵尸进程的问题就不存在了
僵尸进程的处理方式很简单,从产生原因入手即可

  • 在运行时直接 kill 掉僵尸进程的父进程,那么僵尸进程也就不复存在
  • 因为父进程没有处理子进程的信号,在代码中使用 cmd.Run() 或者在 cmd.Start() 之后调用 cmd.Wait()
  • fork 两次,《Unix 环境高级编程》8.6 节说的非常详细。原理是将子进程成为孤儿进程,从而其的父进程变为 init 进程,通过 init 进程可以处理僵尸进程,这种方式有点套娃

参考资料

如果对您有帮助,请我喝杯咖啡?