2

从源码角度剖析 golang 如何fork一个进程 - 蓝胖子的编程梦

 11 months ago
source link: https://www.cnblogs.com/hobbybear/p/17449737.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

从源码角度剖析 golang 如何fork一个进程

创建一个新进程分为两个步骤,一个是fork系统调用,一个是execve 系统调用,fork调用会复用父进程的堆栈,而execve直接覆盖当前进程的堆栈,并且将下一条执行指令指向新的可执行文件。

在分析源码之前,我们先来看看golang fork一个子进程该如何写。(👉严格的讲是先fork再execve创建一个子进程)

cmd := exec.Command("/bin/sh")
		cmd.Env = os.Environ()
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err = cmd.Run()

上述代码将fork一个子进程,然后子进程将会调用execve系统调用,使用新的可执行文件/bin/sh代替当前子进程的程序。并且当前的标准输入输出也传递给了子进程。

我们将着重看下golang是如何创建和将父进程的文件描述符传递给子进程的。

cmd.Run() 会调用到cmd.Start 方法,里面有一段逻辑和标准输入输出流的传递相关,我们来看看。

// /usr/local/go/src/os/exec/exec.go:625 
func (c *Cmd) Start() error {
......
childFiles := make([]*os.File, 0, 3+len(c.ExtraFiles))
   // 创建子进程的stdin 标准输入
	stdin, err := c.childStdin()
	if err != nil {
		return err
	}
	childFiles = append(childFiles, stdin)
	// 创建子进程的stdout 标准输出
	stdout, err := c.childStdout()
	if err != nil {
		return err
	}
	childFiles = append(childFiles, stdout)
	// 创建子进程的stderr 标准错误输出
	stderr, err := c.childStderr(stdout)
	if err != nil {
		return err
	}
	// 此时childFiles 已经包含了上述3个标准输入输出流
	childFiles = append(childFiles, stderr)
	childFiles = append(childFiles, c.ExtraFiles...)

	env, err := c.environ()
	if err != nil {
		return err
	}
   // os.StartProcess 将会启动一个子进程并从childFiles继承父进程的放入其中的文件描述符
	c.Process, err = os.StartProcess(c.Path, c.argv(), &os.ProcAttr{
		Dir:   c.Dir,
		Files: childFiles,
		Env:   env,
		Sys:   c.SysProcAttr,
	})
	.....
}

如上所述,cmd.Start 会分别调用childStdin,childStdout,childStderr创建用于子进程的标准输入输出。来看看其中一个childStdin实现原理,其余childStdout,childStderr 实现原理也是和它类似的。

// /usr/local/go/src/os/exec/exec.go:489
func (c *Cmd) childStdin() (*os.File, error) {
	
	.....
	
	pr, pw, err := os.Pipe()
	if err != nil {
		return nil, err
	}

	c.childIOFiles = append(c.childIOFiles, pr)
	c.parentIOPipes = append(c.parentIOPipes, pw)
	// pw 写入的数据 来源于 c.Stdin  父进程会启动一个协程复制c.Stdin 到 pw
	c.goroutine = append(c.goroutine, func() error {
		_, err := io.Copy(pw, c.Stdin)
		if skipStdinCopyError(err) {
			err = nil
		}
		if err1 := pw.Close(); err == nil {
			err = err1
		}
		return err
	})
	....
	return pr, nil
}

childStdin 实际上是创建了一个管道,管道有返回值 pw,pr , 由pw写入的数据可以由pr进行读取,w 写入的数据 来源于 c.Stdin 父进程会启动一个协程复制c.Stdin 到 pw ,而c.Stdin 在我们最开的演示代码那里赋值为了标准输入。

cmd := exec.Command("/bin/sh")
		cmd.Env = os.Environ()
		cmd.Stdin = os.Stdin
		cmd.Stdout = os.Stdout
		cmd.Stderr = os.Stderr
		err = cmd.Run()

而pr 则返回由父进程通过os.StartProcess的childFiles 传递给了子进程,并作为子进程的标准输入,当子进程启动后将会从pr中获取标准输入终端的数据。

看到这里,你应该能明白了,子进程是如何获取获取父进程的终端信息的了,通过建立了一个管道,然后将管道的一端传递给了子进程便能让父子进程进行通信了

让我们再回到创建进程的主流程上,刚刚仅仅是分析出了,父进程将会为子进程创建它自己的标准输入输出流,虽然是通过管道包装的,但还没详细分析出os.StartProcess 方法究竟通过了哪些手段来让父进程的文件描述符传递给子进程。

注意下,golang中 fork 和execve 创建子进程 的过程 被封装成了一个统一的方法forkExec,它能够控制子进程,只继承特定的文件描述符,而对其他文件描述符则进行关闭。而内核fork系统调用则是会对父进程的所有文件描述符进行复制,那么golang又是如何做到只继承特定的文件描述符的呢?这个也是接下来分析的重点

接下来,让我们深入os.StartProcess 方法,看看golang是如何办到只继承父进程通过childFiles传递过来的文件描述符进行fork和execve调用的

os.StartProcess 底层会调用到 forkAndExecInChild1 方法,由于代码比较长,我这里只列出了关键步骤,并对其进行了注释。

func forkAndExecInChild1(argv0 *byte, argv, envv []*byte, chroot, dir *byte, attr *ProcAttr, sys *SysProcAttr, pipe int) (r1 uintptr, err1 Errno, p [2]int, locked bool) {
    ...
    //  fork 调用前 会将attr.Files 里的数据复制到fd数组,我们传递给子进程的是childFiles,当代码执行到这里的时候,childFiles已经转化成了文件描述符存到attr.Files了。nextfd是为了后续再进行复制文件描述符时,不会对子进程要用到的文件描述符进行覆盖,会在接下来步骤1进行详细说明
    nextfd = len(attr.Files)
	for i, ufd := range attr.Files {
		if nextfd < int(ufd) {
			nextfd = int(ufd)
		}
		fd[i] = int(ufd)
	}
	nextfd++
   .....
   // 这里便进行了fork调用创建新进程了,不过可以看到这里用的是clone系统调用,其实它和fork类似,不过区别在于clone系统调用可以通过flags指定新进程 对于 父进程的哪些属性需要继承,哪些属性不需要继承,比如子进程需要新的网络命名空间,则需要指定flags为syscall.CLONE_NEWNS
   r1, err1 = rawVforkSyscall(SYS_CLONE, flags, 0)
   ....
   
   // 步骤1: 总之经过上面clone系统调用,已经产生了子进程了,下面两个步骤都是子进程才会进行的步骤,父进程在上述clone系统调用后,通过判断err1 != 0 || r1 != 0  便返回了。
  //  这里将fd[i] < i 的文件描述符 通过dup 系统调用复制到了一个新的文件描述符,因为后续步骤2里我们需要将复制 fd[i] 到第i个文件描述符 ,如果fd[i] < i ,那么将会导致复制的fd[i] 是子进程已经产生复制行为的文件描述符,而不是父进程真正传递过来的文件描述符,所以要通过nextfd将这样的文件描述符复制到fd数组外,并且设置O_CLOEXEC,这样在后续的execve系统调用后,将会对它进行自动关闭。
     	for i = 0; i < len(fd); i++ {
		if fd[i] >= 0 && fd[i] < i {
			....
			_, _, err1 = RawSyscall(SYS_DUP3, uintptr(fd[i]), uintptr(nextfd), O_CLOEXEC)
			if err1 != 0 {
				goto childerror
			}
			fd[i] = nextfd
			nextfd++
		}
	}
   ....
   // 步骤2 : 遍历fd 让 子进程fd[i] 个文件描述符复制给第i个文件描述符 ,注意这里就没有设置O_CLOEXEC了,因为这里的文件描述符我们希望execve后还存在
	for i = 0; i < len(fd); i++ {
		....
		_, _, err1 = RawSyscall(SYS_DUP3, uintptr(fd[i]), uintptr(i), 0)
		if err1 != 0 {
			goto childerror
		}
	} 
	
	....
    // 进行execve 系统调用
	_, _, err1 = RawSyscall(SYS_EXECVE,
		uintptr(unsafe.Pointer(argv0)),
		uintptr(unsafe.Pointer(&argv[0])),
		uintptr(unsafe.Pointer(&envv[0])))
}

可以看出,golang在execve前, 通过dup系统调用达到了继承父进程文件描述符的目的,最终达到的效果是继承attr.Files 参数里的文件描述符,期间由于dup的使用 产生的多余的文件描述符也标记为了O_CLOEXEC,在SYS_EXECVE 系统调用时,便会关闭掉。

但是仅仅看到这里,并不能说明golang会对attr.Files外的文件描述符也进行关闭,因为fork系统调用时,子进程会自动继承父进程的所有文件描述符,这些继承的文件描述符会在execve后自动关闭吗? 答案是默认是会的。

golang的 os.open 函数底层会调用下面的代码对文件进行打开操作,可以看到打开时固定设置了syscall.O_CLOEXEC flag,所以,子进程进行execve时变会自动对这些文件描述符进行关闭了。

func openFileNolog(name string, flag int, perm FileMode) (*File, error) {
	setSticky := false
	if !supportsCreateWithStickyBit && flag&O_CREATE != 0 && perm&ModeSticky != 0 {
		if _, err := Stat(name); IsNotExist(err) {
			setSticky = true
		}
	}

	var r int
	for {
		var e error
		r, e = syscall.Open(name, flag|syscall.O_CLOEXEC, syscallMode(perm))
		if e == nil {

监听的socket文件也是默认开启了syscall.SOCK_NONBLOCK参数

// descriptor as nonblocking and close-on-exec.
func sysSocket(family, sotype, proto int) (int, error) {
	s, err := socketFunc(family, sotype|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, proto)
	if err != nil {
		return -1, os.NewSyscallError("socket", err)
	}
	return s, nil
}


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK