Skip to content

Latest commit

 

History

History
672 lines (533 loc) · 23.3 KB

File metadata and controls

672 lines (533 loc) · 23.3 KB

七、处理进程和守护进程

本章将介绍如何使用 Go 标准库处理当前进程的属性,以及如何更改它们。我们还将关注如何创建子进程,并对os/exec包进行概述。

最后,我们将解释什么是守护进程,它们有什么属性,以及如何使用标准库创建它们。

本章将介绍以下主题:

  • 理解过程
  • 子进程
  • 从守护进程开始
  • 创建服务

技术要求

本章要求安装 Go 并设置您最喜爱的编辑器。更多信息请参考第三章Go概述。

理解过程

我们已经了解了进程在 Unix 操作系统中的重要性,因此现在我们将了解如何获取有关当前进程的信息以及如何创建和处理子进程。

当前进程

Go 标准库允许我们获取有关当前流程的信息。这是通过使用os包中提供的一系列函数来实现的。

标准输入

程序可能想知道的前两件事是它的标识符和父标识符,即 PID 和 PPID。这实际上非常简单,os.Getpid()os.Getppid()函数都返回一个带有两个标识符的整数值,如下代码所示:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Current PID:", os.Getpid())
    fmt.Println("Current Parent PID:", os.Getppid())
}

完整示例见https://play.golang.org/p/ng0m9y4LcD5

用户和组 ID

另一个方便的信息是当前用户和流程所属的组。典型的用户案例可能是将它们与特定于文件的权限进行比较。

os包提供以下功能:

  • os.Getuid():返回流程所有者的用户 ID
  • os.Getgid():返回流程所有者的组 ID
  • os.Getgroups():返回流程所有者的其他组 ID

我们可以看到,这三个函数以数字形式返回 ID:

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("User ID:", os.Getuid())
    fmt.Println("Group ID:", os.Getgid())
    groups, err := os.Getgroups()
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Group IDs:", groups)
}

完整示例见https://play.golang.org/p/EqmonEEc_ZI

为了获取用户和组的名称,os/user包中有一些 helper 函数。这些函数(名称不言自明)如下所示:

  • func LookupGroupId(gid string) (*Group, error)
  • func LookupId(uid string) (*User, error)

即使用户 ID 是整数,它也将字符串作为参数,因此需要进行转换。最简单的方法是使用strconv包,它提供了一系列实用程序,可以将字符串转换为其他基本数据类型,反之亦然。

我们可以在以下示例中看到它们的作用:

package main

import (
    "fmt"
    "os"
    "os/user"
    "strconv"
)

func main() {
    uid := os.Getuid()
    u, err := user.LookupId(strconv.Itoa(uid))
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Printf("User: %s (uid %d)\n", u.Username, uid)
    gid := os.Getgid()
    group, err := user.LookupGroupId(strconv.Itoa(gid))
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Printf("Group: %s (uid %d)\n", group.Name, uid)
}

完整示例见https://play.golang.org/p/C6EWF2c50DT

工作目录

进程可以让我们访问的另一个非常有用的信息是工作目录,以便我们可以更改它。在第 4 章使用文件系统时,我们了解了可以使用哪些工具—os.Getwdos.Chdir

在下面的实际示例中,我们将了解如何使用这些函数操作工作目录:

  1. 首先,我们将获取当前工作目录,并使用它来获取二进制文件的路径。
  2. 然后,我们将使用另一个路径连接工作目录,并使用它创建一个目录。
  3. 最后,我们将使用刚刚创建的目录的路径来更改当前工作目录。

请查看以下代码:

// obtain working directory
wd, err := os.Getwd()
if err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Working Directory:", wd)
fmt.Println("Application:", filepath.Join(wd, os.Args[0]))

// create a new directory
d := filepath.Join(wd, "test")
if err := os.Mkdir(d, 0755); err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("Created", d)

// change the current directory
if err := os.Chdir(d); err != nil {
    fmt.Println("Error:", err)
    return
}
fmt.Println("New Working Directory:", d)

完整示例见https://play.golang.org/p/UXAer5nGBtm

子进程

Go 应用程序可以与操作系统交互以创建其他一些进程。os的另一个子包提供了创建和运行新流程的功能。在os/exec包中有Cmd类型,表示命令执行:

type Cmd struct {
    Path string // command to run.
    Args []string // command line arguments (including command)
    Env []string // environment of the process
    Dir string // working directory
    Stdin io.Reader // standard input`
    Stdout io.Writer // standard output
    Stderr io.Writer // standard error
    ExtraFiles []*os.File // additional open files
    SysProcAttr *syscall.SysProcAttr // os specific attributes
    Process *os.Process // underlying process
    ProcessState *os.ProcessState // information on exited processte
}

创建新命令的最简单方法是使用exec.Command函数,该函数采用可执行路径和一系列参数。让我们看一个带有echo命令和一些参数的简单示例:

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := exec.Command("echo", "A", "sample", "command")
    fmt.Println(cmd.Path, cmd.Args[1:]) // echo [A sample command]
}

完整示例见https://play.golang.org/p/dBIAUteJbxI

一个非常重要的细节是标准输入、输出和错误的性质——它们都是我们已经熟悉的接口:

  • 输入是一个io.Reader,可以是bytes.Readerbytes.Bufferstrings.Readeros.File或任何其他实现。
  • 输出和错误为io.Writer,也可以是os.Filebytes.Buffer,也可以是strings.Builder或任何其他写入器实现。

根据父应用程序的需要,有不同的启动流程的方法:

  • Cmd.Run:执行命令,如果子进程执行正确,返回错误nil
  • Cmd.Start :异步执行命令,让父级继续其流程。为了等待子进程完成其执行,还有另一种方法,Cmd.Wait
  • Cmd.Output:执行命令并返回其标准输出,如果Stderr未定义,但标准错误产生了输出,则返回错误。
  • Cmd.CombinedOutput:执行命令并返回标准错误和组合输出,这在需要检查或保存子流程标准输出加标准错误的整个输出时非常有用。

访问子属性

一旦命令开始执行,无论是否同步,底层的os.Process将被填充,并且可以看到其 PID,如下面的示例所示:

package main

import (
    "fmt"
    "os/exec"
)

func main() {
    cmd := exec.Command("ls", "-l")
    if err := cmd.Start(); err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println("Cmd: ", cmd.Args[0])
    fmt.Println("Args:", cmd.Args[1:])
    fmt.Println("PID: ", cmd.Process.Pid)
    cmd.Wait()
}

标准输入

标准输入可用于将一些数据从应用程序发送到子进程。缓冲区可用于存储数据并让命令读取,如以下示例所示:

package main

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

func main() {
    b := bytes.NewBuffer(nil)
    cmd := exec.Command("cat")
    cmd.Stdin = b
    cmd.Stdout = os.Stdout
    fmt.Fprintf(b, "Hello World! I'm using this memory address: %p", b)
    if err := cmd.Start(); err != nil {
        fmt.Println(err)
        return
    }
    cmd.Wait()
}

从守护进程开始

在 Unix 中,所有在后台运行的程序都称为守护进程。它们通常有一个以字母d结尾的名称,如sshdsyslogd,并且它们提供了操作系统的许多功能。

操作系统支持

在 macOS、Unix 和 Linux 中,如果一个进程在其父生命周期内生存,即当父进程终止其执行而子进程继续生存时,则该进程就是守护进程。这是因为进程父进程被更改为init进程,这是一个没有父进程的特殊守护进程,PID 1 由操作系统启动和终止。在进一步讨论之前,我们先介绍两个非常重要的概念——会话过程组

  • 进程组是共享信号处理的进程的集合。该组的第一个流程称为组长。有一个 Unix 系统调用setpgid,它能够更改进程的组,但有一些限制。进程可以更改自己的进程组,也可以在对其执行exec系统调用之前更改其子进程的组。当流程组更改时,会话组需要相应地更改,与目标组的负责人相同。
  • 会话是进程组的集合,允许我们对进程组和其他操作施加一系列限制。一个会话不允许进程组迁移到另一个会话,它阻止进程在不同的会话中创建进程组。如果流程不是流程组长,setsid系统调用允许我们将流程会话更改为新会话。另外,第一个进程组 ID 设置会话 ID。如果该 ID 与正在运行的进程的 ID 相同,则该进程称为会话负责人

现在我们已经解释了这两个属性,我们可以看看创建守护进程所需的标准操作,通常如下所示:

  • 清理环境以删除不必要的变量。

  • 创建一个 fork,以便主进程可以正常终止进程。

  • 使用setsid系统调用,完成以下三个步骤:

    1. 将 PPID 从分叉流程中移除,以便它被init流程采用
    2. 为 fork 创建一个新会话,它将成为会话负责人
    3. 将流程设置为组长
  • fork 的当前目录设置为根目录,以避免使用其他目录,并且父目录打开的所有文件都将关闭(如果需要,子目录将打开它们)。

  • 将标准输入设置为/dev/null并使用一些日志文件作为标准输出和错误。

  • 也可以选择再次分叉,然后退出。第一个叉子将是组长,第二个叉子将有相同的组,允许我们有另一个不是组长的叉子。

这对基于 Unix 的操作系统有效,但 Windows 也支持永久性后台进程,即所谓的服务。服务可以在引导时自动启动,也可以使用名为服务控制管理器SCM的可视化应用程序手动启动和停止。它们也可以通过命令行进行控制,在常规提示符中使用sc命令,并通过 PowerShell 中的Start-ServiceStop-Servicecmdlet 进行控制。

运行中的守护进程

现在我们了解了什么是守护进程以及它是如何工作的,我们可以尝试使用 Go 标准库来创建一个守护进程。Go 应用程序是多线程的,不允许我们直接调用fork系统调用。

我们了解到,os/exec包中的Cmd.Start方法允许我们异步启动流程。第二步是使用release方法关闭当前进程中的所有资源。

以下示例向我们展示了如何执行此操作:

package main

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

var pid = os.Getpid()

func main() {
    fmt.Printf("[%d] Start\n", pid)
    fmt.Printf("[%d] PPID: %d\n", pid, os.Getppid())
    defer fmt.Printf("[%d] Exit\n\n", pid)
    if len(os.Args) != 1 {
        runDaemon()
        return
    }
    if err := forkProcess(); err != nil {
        fmt.Printf("[%d] Fork error: %s\n", pid, err)
        return
    }
    if err := releaseResources(); err != nil {
        fmt.Printf("[%d] Release error: %s\n", pid, err)
        return
    }
}

让我们看看forkProcess函数的作用,创建另一个进程,然后启动它:

  1. 首先,进程工作目录设置为 root,输出流和错误流设置为标准流:
func forkProcess() error {
    cmd := exec.Command(os.Args[0], "daemon")
    cmd.Stdout, cmd.Stderr, cmd.Dir = os.Stdout, os.Stderr, "/"
    return cmd.Start()
}
  1. 然后,我们可以释放资源——不过,首先,我们需要找到当前流程。然后,我们可以调用os.Process方法Release,以确保主流程释放其资源:
func releaseResources() error {
    p, err := os.FindProcess(pid)
    if err != nil {
        return err
    }
    return p.Release()
}
  1. main函数将包含守护进程逻辑,在本例中非常简单–它只需每隔几秒钟打印一次正在运行的内容:
func runDaemon() {
    for {
        fmt.Printf("[%d] Daemon mode\n", pid)
        time.Sleep(time.Second * 10)
    }
}

服务

我们已经看到了从引导到关闭操作系统的第一个进程是如何被称为initinit.d,因为它是一个守护进程。此进程负责处理其他守护进程,并将其配置存储在/etc/init.d目录中。

每个 Linux 发行版都使用自己版本的守护进程控制进程,如 Chrome OS 的upstart或 Arch Linux 的systemd。他们都有相同的目的和相似的行为。

每个守护进程都有一个驻留在/etc/init.d内部的控制脚本或应用程序,并且应该能够将一系列命令解释为第一个参数,例如statusstartstoprestart。在大多数情况下,init.d文件是一个脚本,它在参数上执行开关,并相应地执行操作。

创建服务

有些应用程序能够自动处理它们的服务文件,这就是我们将逐步尝试实现的目标。让我们从一个init.d脚本开始:

#!/bin/sh

"/path/to/mydaemon" $1

这是一个将第一个参数传递给守护进程的示例脚本。二进制文件的路径取决于文件的位置。这需要在运行时定义:

// ErrSudo is an error that suggest to execute the command as super user
// It will be used with the functions that fail because of permissions
var ErrSudo error

var (
    bin string
    cmd string
)

func init() {
    p, err := filepath.Abs(filepath.Dir(os.Args[0]))
    if err != nil {
        panic(err)
    }
    bin = p
    if len(os.Args) != 1 {
        cmd = os.Args[1]
    }
    ErrSudo = fmt.Errorf("try `sudo %s %s`", bin, cmd)
}

main功能将处理不同的命令,如下所示:

func main() {
    var err error
    switch cmd {
    case "run":
        err = runApp()
    case "install":
        err = installApp()
    case "uninstall":
        err = uninstallApp()
    case "status":
        err = statusApp()
    case "start":
        err = startApp()
    case "stop":
        err = stopApp()
    default:
        helpApp()
    }
    if err != nil {
        fmt.Println(cmd, "error:", err)
    }
}

我们如何确保我们的应用程序正在运行?一个非常合理的策略是使用一个*PID文件,它是一个文本文件,包含运行进程的当前 PID。让我们定义两个辅助函数来实现这一点:*

*```go const ( varDir = "/var/mydaemon/" pidFile = "mydaemon.pid" )

func writePid(pid int) (err error) { f, err := os.OpenFile(filepath.Join(varDir, pidFile), os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() if _, err = fmt.Fprintf(f, "%d", pid); err != nil { return err } return nil }

func getPid() (pid int, err error) { b, err := ioutil.ReadFile(filepath.Join(varDir, pidFile)) if err != nil { return 0, err } if pid, err = strconv.Atoi(string(b)); err != nil { return 0, fmt.Errorf("Invalid PID value: %s", string(b)) } return pid, nil }


`install`和`uninstall`功能将负责添加或删除位于`/etc/init.d/mydaemon`的服务文件,并要求我们以 root 权限启动应用程序,因为该文件位于:

```go
const initdFile = "/etc/init.d/mydaemon"

func installApp() error {
    _, err := os.Stat(initdFile)
    if err == nil {
        return errors.New("Already installed")
    }
    f, err := os.OpenFile(initdFile, os.O_CREATE|os.O_WRONLY, 0755)
    if err != nil {
        if !os.IsPermission(err) {
            return err
        }
        return ErrSudo
    }
    defer f.Close()
    if _, err = fmt.Fprintf(f, "#!/bin/sh\n\"%s\" $1", bin); err != nil {
        return err
    }
    fmt.Println("Daemon", bin, "installed")
    return nil
}

func uninstallApp() error {
    _, err := os.Stat(initdFile)
    if err != nil && os.IsNotExist(err) {
        return errors.New("not installed")
    }
    if err = os.Remove(initdFile); err != nil {
        if err != nil {
            if !os.IsPermission(err) {
                return err
            }
       return ErrSudo
        }
    }
    fmt.Println("Daemon", bin, "removed")
    return err
}

创建文件后,我们可以使用mydaemon install命令将应用程序作为服务安装,并使用mydaemon uninstall将其删除。

一旦安装了守护进程,我们就可以使用sudo service mydaemon [start|stop|status]来控制守护进程。现在,我们需要做的就是实施这些行动:

  • status将查找pid文件,读取该文件,并向进程发送信号以检查其是否正在运行。
  • start将使用run命令运行应用程序并写入pid文件。
  • stop将获取pid文件,找到进程,杀死它,然后删除pid文件。

让我们来看看如何实现 TyrT0p 命令。请注意,0信号在 Unix 中不存在,不会触发操作系统或应用程序的操作,但如果进程未运行,操作将失败。这告诉我们进程是否处于活动状态:

func statusApp() (err error) {
    var pid int
    defer func() {
        if pid == 0 {
            fmt.Println("status: not active")
            return
        }
        fmt.Println("status: active - pid", pid)
    }()
    pid, err = getPid()
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    p, err := os.FindProcess(pid)
    if err != nil {
        return nil
    }
    if err = p.Signal(syscall.Signal(0)); err != nil {
        fmt.Println(pid, "not found - removing PID file...")
        os.Remove(filepath.Join(varDir, pidFile))
        pid = 0
    }
    return nil
}

start命令中,我们将按照操作系统支持**部分中介绍的步骤创建守护进程:

  1. 将文件用于标准输出和输入
  2. 将工作目录设置为 root
  3. 异步启动命令

除了这些操作,start命令还将进程的 PID 值保存在一个特定文件中,该文件将用于查看进程是否处于活动状态:

func startApp() (err error) {
    const perm = os.O_CREATE | os.O_APPEND | os.O_WRONLY
    if err = os.MkdirAll(varDir, 0755); err != nil {
        if !os.IsPermission(err) {
            return err
        }
        return ErrSudo
    }
    cmd := exec.Command(bin, "run")
    cmd.Stdout, err = os.OpenFile(filepath.Join(varDir, outFile),  
        perm, 0644)
            if err != nil {
                 return err
            }
    cmd.Stderr, err = os.OpenFile(filepath.Join(varDir, errFile), 
        perm, 0644)
            if err != nil {
                return err
           }
    cmd.Dir = "/"
    if err = cmd.Start(); err != nil {
        return err
    }
    if err := writePid(cmd.Process.Pid); err != nil {
        if err := cmd.Process.Kill(); err != nil {
            fmt.Println("Cannot kill process", cmd.Process.Pid, err)
        }
        return err
    }
    fmt.Println("Started with PID", cmd.Process.Pid)
    return nil
}

最后,stopApp将终止 PID 文件标识的流程,如果存在:

func stopApp() (err error) {
    pid, err := getPid()
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    p, err := os.FindProcess(pid)
    if err != nil {
        return nil
    }
    if err = p.Signal(os.Kill); err != nil {
        return err
    }
    if err := os.Remove(filepath.Join(varDir, pidFile)); err != nil {
        return err
    }
    fmt.Println("Stopped PID", pid)
    return nil
}

现在,应用程序控制所需的所有部分都在那里,缺少的只是主应用程序部分,它应该是一个循环,以便守护进程保持活动状态:

func runApp() error {
    fmt.Println("RUN")
    for {
        time.Sleep(time.Second)
    }
    return nil
}

在本例中,它所做的只是在循环迭代之间休眠一段固定的时间。在主循环中,这通常是一个好主意,因为空的for循环会毫无理由地使用大量资源。假设您的应用程序正在for循环中检查特定条件。如果这一点得到满足,则不断检查这一点将占用大量资源。添加几毫秒的空闲睡眠可以帮助减少 90-95%的空闲 CPU 消耗,因此在设计守护程序时请记住这一点!

第三方软件包

到目前为止,我们已经了解了如何使用init.d服务从头开始实现守护程序。我们的实现非常简单且有限。它可以改进,但是已经有许多包提供相同的功能。它们为不同的提供者提供支持,如init.dsystemd,其中一些还可以跨非 Unix 操作系统(如 Windows)工作。

其中一个比较著名的软件包(GitHub 上有 1000 多个 stars)是kardianos/service,它支持所有主要平台——Linux、macOS 和 Windows。

它定义了一个表示守护进程的主接口,并有两个方法—一个用于启动守护进程,另一个用于停止守护进程。两者都是非阻塞的:

type Interface interface {
    // Start provides a place to initiate the service. The service doesn't not
    // signal a completed start until after this function returns, so the
    // Start function must not take more than a few seconds at most.
    Start(s Service) error

    // Stop provides a place to clean up program execution before it is terminated.
    // It should not take more than a few seconds to execute.
    // Stop should not call os.Exit directly in the function.
    Stop(s Service) error
}

该包已经提供了一些用例,从简单到复杂,在示例中(https://github.com/kardianos/service/tree/master/example 目录。最佳实践是使用主活动循环启动 goroutine。Start方法可用于打开和准备必要的资源,而Stop应用于释放资源,以及其他延迟活动,如缓冲区刷新。

其他一些软件包仅提供与 Unix 系统的兼容性,例如takama/daemonhttps://github.com/takama/daemon ),其工作方式类似。它还提供了一些使用示例。

总结

在本章中,我们回顾了如何获取与当前流程、PID 和 PPID、UID 和 GID 以及工作目录相关的信息。然后,我们看到了os/exec包如何允许我们创建子进程,以及如何读取与当前进程类似的子进程的属性。

接下来,我们看了什么是守护进程,以及各种操作系统如何支持它们。我们验证了使用Cmd.Run通过os/exec执行一个比其父进程更长的进程是多么简单。

然后,我们研究了 Unix 中可用的自动化守护进程管理系统,并创建了一个能够一步一步运行service的应用程序。

在下一章中,我们将通过检查如何使用退出代码以及如何管理和发送信号来改进对子进程的控制。

问题

  1. Go 应用程序中当前流程的可用信息是什么?
  2. 如何创建子进程?
  3. 如何确保子进程在其父进程之后仍然存在?
  4. 您可以访问子属性吗?你如何使用它们?
  5. Linux 中的守护进程是什么?它们是如何处理的?*