Skip to content
This repository has been archived by the owner on Jun 26, 2023. It is now read-only.

🐛 :kill 时产生孤儿进程,导致无法立刻回收协程的问题 #1

Closed
Euraxluo opened this issue Mar 29, 2022 · 1 comment
Closed

Comments

@Euraxluo
Copy link
Member

Euraxluo commented Mar 29, 2022

代码

相关测试用例在tests/test_kill.go

问题描述

当我们使用go run tests/test_kill.go运行测试代码,
然后使用pstree -p 查看进程树

zsh(29944)───go(5376)─┬─test_kill(5474)─┬─bash(5479)───sleep(5480)
                      │                 ├─{test_kill}(5475)
                      │                 ├─{test_kill}(5476)
                      │                 ├─{test_kill}(5477)
                      │                 └─{test_kill}(5478)
                      ├─{go}(5377)
                      ├─{go}(5378)
                      ├─{go}(5379)
                      ├─{go}(5380)
                      ├─{go}(5381)

可以看到 go 进程 go(30371)─┬─test_kill(30468)─┬─bash(30473)───sleep(30474)
创建了子进程 bash(30473),该进程又创建了子shell去执行 shell 命令 sleep(30474)

TIPS: subshell

所谓子shell,即从当前shell环境新开一个shell环境,这个新开的shell环境就称为子shell(subshell),而开启子shell的环境称为该子shell的父shell

何时产生子shell:
Linux上创建子进程的方式有三种:一种是fork出来的进程,一种是exec出来的进程,一种是clone出来的进程

fork 是复制进程

exec是加载另一个应用程序替换当前运行的进程

为了保证进程安全,若要形成新的且独立的子进程,都会先fork一份当前进程,然后在fork出来的子进程上调用exec来加载新程序替代该子进程。

reference

等待5s后,ctx 理应取消,这时查看进程树

zsh(29944)───go(30371)─┬─test_kill(30468)─┬─{test_kill}(30469)
                       │                  ├─{test_kill}(30470)
                       │                  ├─{test_kill}(30471)
                       │                  └─{test_kill}(30472)
                       ├─{go}(30372)

可以看到 子进程bash(30473) 被kill了,但是其子shell sleep(30474) 没有被回收,而是变成了孤儿进程

~  pstree -p 1 |grep 30474
           |-sleep(30474)

同时我们也有几个协程没有结束,查看pprof 堆栈

goroutine 1 [syscall]:
syscall.Syscall6(0xf7, 0x1, 0x21b5, 0xc0000b3cd0, 0x1000004, 0x0, 0x0, 0xc000026360, 0x710a60, 0x90bca0)
	/usr/local/go/src/syscall/asm_linux_amd64.s:43 +0x5
os.(*Process).blockUntilWaitable(0xc00001a390, 0x3, 0x3, 0x203000)
	/usr/local/go/src/os/wait_waitid.go:32 +0x9e
os.(*Process).wait(0xc00001a390, 0x6efca0, 0x73b368, 0x73b370)
	/usr/local/go/src/os/exec_unix.go:22 +0x39
os.(*Process).Wait(...)
	/usr/local/go/src/os/exec.go:129
os/exec.(*Cmd).Wait(0xc0000ce6e0, 0x0, 0x0)
	/usr/local/go/src/os/exec/exec.go:507 +0x65
os/exec.(*Cmd).Run(0xc0000ce6e0, 0xc000097290, 0xc0000ce6e0)
	/usr/local/go/src/os/exec/exec.go:341 +0x5f
os/exec.(*Cmd).CombinedOutput(0xc0000ce6e0, 0x9, 0xc0000b3f50, 0x2, 0x2, 0xc0000ce6e0)
	/usr/local/go/src/os/exec/exec.go:567 +0x91
main.main()
	/go/src/scheduler/tests/test_kill.go:23 +0x16c

/go/src/scheduler/tests/test_kill.go:23 这里就是我们调用exec的地方,可以发现,在

os.(*Process).Wait(...)
	/usr/local/go/src/os/exec.go:129

处发生了阻塞

代码如下

// Wait releases any resources associated with the Cmd.
func (c *Cmd) Wait() error {
	if c.Process == nil {
		return errors.New("exec: not started")
	}
	if c.finished {
		return errors.New("exec: Wait was already called")
	}
	c.finished = true

	state, err := c.Process.Wait()
	if c.waitDone != nil {
		close(c.waitDone)
	}
	c.ProcessState = state

	var copyError error
	for range c.goroutine {
		if err := <-c.errch; err != nil && copyError == nil {
			copyError = err
		}
	}

	c.closeDescriptors(c.closeAfterWait)

	if err != nil {
		return err
	} else if !state.Success() {
		return &ExitError{ProcessState: state}
	}

	return copyError
}

可以看到一直在等待 c.errch

	var copyError error
	for range c.goroutine {
		if err := <-c.errch; err != nil && copyError == nil {
			copyError = err
		}
	}

直接看源码,可以发现,在 func (c *Cmd) Start() error {} 处有使用该chan

	// Don't allocate the channel unless there are goroutines to fire.
	if len(c.goroutine) > 0 {
		c.errch = make(chan error, len(c.goroutine))
		for _, fn := range c.goroutine {
			go func(fn func() error) {
				c.errch <- fn()
			}(fn)
		}
	}

查看源码 可以发现, c.errch <- fn(),这个 fn 是从 c.goroutine 中遍历得到, goroutine []func() error,

	pr, pw, err := os.Pipe()
	if err != nil {
		return
	}

	c.closeAfterStart = append(c.closeAfterStart, pw)
	c.closeAfterWait = append(c.closeAfterWait, pr)
	c.goroutine = append(c.goroutine, func() error {
		_, err := io.Copy(w, pr)
		pr.Close() // in case io.Copy stopped due to write error
		return err
	})

根据以上代码,可以发现主要用于处理输入输出,所以可以确定,我们的golang协程是阻塞在此处,用于接受shell的输入输出,
goroutine 通过 创建的Pipe管道,在协程中将pr中的数据拷贝到w中.即通过goroutine 读取子进程的输出

但是由于我们之前说的子shell,他会拷贝当前shell的状态,也就是会把pipe一起进行拷贝,此时,但又由于我们kill没有kill子shell,
导致我们的pipe的写入端被子shell持有,我们的协程会等待pipe关闭后才返回,导致我们程序无法5s后结束的问题

问题总结

golang 的 exec 在 使用子进程时会和子进程通过pipe建立连接,通过管道获取子进程的输入输出.在某些情况下,子进程会从当前进程fock出子shell(子子进程),
该子子进程进程会拷贝子进程的pipe,当我们在golang kill 子进程 时,如果没有将 子子进程 一起kill,则会导致pipe 的文件描述符被子子进程持有,而无法关闭.
同时我们的 获取子进程的输入输出 的操作会一致等待 pipe关闭后返回,所以出现 协程无法退出的情况

解决办法

golang 中将子进程相关的进程组一起杀死

@Euraxluo
Copy link
Member Author

相关 issue: golang/go#23019

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant