从源码角度剖析 golang 如何fork一个进程
从源码角度剖析 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
}