如何在Golang中实现Fork()

本文最后更新于:2022年11月22日 下午

背景

最近在跟随《自己动手写 Docker》实现一个简易版本的 docker 时,注意到书中创建容器进程时采取了一种很 weird 的方法。mydocker run Command后使用exec.Command调用/proc/self/exe(即进程本身),但会修改参数,使得其相当于调用了mydocker init Command,然后再完成与容器相关的初始化工作。

书中使用了cli库来编写命令行相关的代码,因此会注册initCommand,但很可惜的是initCommand是一个内部调用,即它应该由创建容器进程的程序本身来调用,而非用户通过输入init来使用,而使用mydocker help却会对外暴露这个命令,这样不太好。

所以我比较奇怪为什么作者没有采用类似 C 中 fork 那样的方式,返回后在父子进程中执行不同的函数。

原因

在这篇博客[1]中可以找到一些解释,Golang 提倡使用协程 goroutine来进行并发编程,为我们屏蔽了线程和进程的概念。同时鉴于在多数fork+exec情景下,可以很好地使用 Golang 中的 syscall.ForkExecexec.Command来进行代替[2]

如果我们想要在 Golang 中使用类似 C 里面 fork 的行为实现拷贝当前进程,并在父子进程中执行不同的函数,这需要一些技巧

解决

参考一下 docker 的 reexec的实现,其实跟书中很类似,但优点胜在它并没有直接把内部调用的命令暴露给用户。

Example

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
package main

import (
"MyDocker/reexec"
"fmt"
"log"
"os"
)

func init() {
fmt.Printf("os.Args = %+v\n", os.Args)
reexec.Register("childProcess", childProcess)
if reexec.Init() {
os.Exit(0)
}
}

func childProcess() {
fmt.Println("ChildProcess")
}

func main() {
cmd := reexec.Command("childProcess")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Start(); err != nil {
log.Fatal(err)
}
fmt.Println("ParentProcess")
cmd.Wait()
os.Exit(0)
}

结果如下

1
2
3
4
5
6
# zyc @ DESKTOP-KK42M35 in /mnt/d/GoProject/src/MyDocker on git:master x [17:29:55] 
$ go run main.go
os.Args = [/tmp/go-build3043439324/b001/exe/main]
ParentProcess
os.Args = [childProcess]
ChildProcess

Explanation

reexec 主要由reexec.go以及command_${os}.go两个文件组成

reexec.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
package reexec // import "github.com/docker/docker/pkg/reexec"

import (
"fmt"
"os"
"os/exec"
"path/filepath"
)

var registeredInitializers = make(map[string]func())

// Register adds an initialization func under the specified name
func Register(name string, initializer func()) {
if _, exists := registeredInitializers[name]; exists {
panic(fmt.Sprintf("reexec func already registered under name %q", name))
}

registeredInitializers[name] = initializer
}

// Init is called as the first part of the exec process and returns true if an
// initialization function was called.
func Init() bool {
initializer, exists := registeredInitializers[os.Args[0]]
if exists {
initializer()

return true
}
return false
}

调用者使用Register注册函数,将名字与其相关联。使用Init查询注册的函数,如果存在则调用它

command_linux.go文件如下

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

import (
"os/exec"
)

// Self returns the path to the current process's binary.
// Returns "/proc/self/exe".
func Self() string {
return "/proc/self/exe"
}

// Command returns *exec.Cmd which has Path as current binary. Also it setting
// This will use the in-memory version (/proc/self/exe) of the current binary,
// it is thus safe to delete or replace the on-disk binary (os.Args[0]).
func Command(args ...string) *exec.Cmd {
return &exec.Cmd{
Path: Self(),
Args: args,
}
}

reexec.Command通过封装逻辑返回一个*exec.Cmd对象,Self()返回的/proc/self/exe指向的是当前进程.

一般而言,os.Args的第一个参数是可执行文件的名称,如前面例子中的/tmp/go-build3043439324/b001/exe/main,但到了新创建的进程中,第一个参数变成了我们设置的childProcess,而childProcess是我们注册的函数,并非是一个真正的可执行文件。不过这种情况,一般是我们在命令行中启动程序。

在 Golang 中,cmd.Start()会将cmd.Path作为程序启动的路径,这也是为什么我们需要在reexec.Command函数中设置Path/proc/self/exe.

在这里需要提到一点,如果我们的 reexec.Command实现如下:

1
2
3
func Command(args ...string) *exec.Cmd {
return exec.Command(Self(), args...)
}

输出会是无尽的递归:

1
2
3
4
5
6
7
8
9
10
11
os.Args = [/proc/self/exe childProcess]
ParentProcess
os.Args = [/proc/self/exe childProcess]
ParentProcess
os.Args = [/proc/self/exe childProcess]
ParentProcess
os.Args = [/proc/self/exe childProcess]
ParentProcess
os.Args = [/proc/self/exe childProcess]
ParentProcess
......

exec.Command同样返回的是一个*exec.Command对象,但是我们看一下它的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func Command(name string, arg ...string) *Cmd {
cmd := &Cmd{
Path: name,
Args: append([]string{name}, arg...),
}
if filepath.Base(name) == name {
if lp, err := LookPath(name); err != nil {
cmd.lookPathErr = err
} else {
cmd.Path = lp
}
}
return cmd
}

它将Path设置为Self()的同时,又将其作为第一个参数,这遵循了我们提到的在命令行中启动程序的惯例。

reexec.go > Init()会根据第一个参数(即/proc/self/exe,而非我们希望的childProcess)去查找注册的函数,由于查找失败,所以会进入main函数继续执行 。

当然我们可以选择修改Init()的实现,让它根据第二个参数来进行查找,然而这就要求当前的父进程至少要有两个参数(其中一个为当前进程启动的程序名),这样并不太好…

参考


如何在Golang中实现Fork()
https://flaglord.com/2021/10/16/如何在Golang中实现Fork/
作者
flaglord
发布于
2021年10月16日
许可协议