探索Go守护进程的实现方法

开发 前端
在Go中实现守护进程化,虽然因为语言运行时的特性而具有挑战性,但通过社区开发的库和谨慎的实现是可以实现的。

在后端开发的世界里,守护进程(daemon)这个概念与Unix系统一样古老。守护进程是在后台运行的长期服务程序,不与任何终端关联。尽管现代进程管理工具如systemd[1]和supervisor[2]等让应用转化为守护进程变得十分简单,我们甚至可以使用以下命令来在后台运行程序:

nohup ./your_go_program &

但在某些情况下,程序的原生转化为守护进程的能力仍然是有必要的。比如分布式文件系统juicefs cli的mount子命令,它就支持以-d选项启动,并以守护进程方式运行:

$juicefs mount -h
NAME:
   juicefs mount - Mount a volume

USAGE:
   juicefs mount [command options] META-URL MOUNTPOINT

... ...

OPTIONS:
   -d, --background  run in background (default: false)
   ... ...
... ...

这种自我守护化的能力会让很多Go程序受益,在这一篇文章中,我们就来探索一下Go应用转化为守护进程的实现方法。

1. 标准的守护进程转化方法

[W.Richard Stevens]( "W.Richard Stevens")的经典著作《UNIX环境高级编程[3]》中对将程序转化为一个守护进程的 (daemonize) 步骤进行了详细的说明,主要步骤如下:

  • 创建子进程并终止父进程

通过fork()系统调用创建子进程,父进程立即终止,保证子进程不是控制终端的会话组首领。

  • 创建新的会话

子进程调用setsid()来创建一个新会话,成为会话组首领,从而摆脱控制终端和进程组。

  • 更改工作目录

使用chdir("/") 将当前工作目录更改为根目录,避免守护进程持有任何工作目录的引用,防止对文件系统卸载的阻止。

  • 重设文件权限掩码

通过umask(0) 清除文件权限掩码,使得守护进程可以自由设置文件权限。

  • 关闭文件描述符

关闭继承自父进程的已经open的文件描述符(通常是标准输入、标准输出和标准错误)。

  • 重定向标准输入/输出/错误

重新打开标准输入、输出和错误,重定向到/dev/null,以避免守护进程无意输出内容到不应有的地方。

注:fork()系统调用是一个较为难理解的调用,它用于在UNIX/Linux系统中创建一个新的进程。新创建的进程被称为子进程,它是由调用fork()的进程(即父进程)复制出来的。子进程与父进程拥有相同的代码段、数据段、堆和栈,但它们是各自独立的进程,有不同的进程ID (PID)。在父进程中,fork()返回子进程的PID(正整数),在子进程中,fork()返回0,如果fork()调用失败(例如系统资源不足),则返回-1,并设置errno以指示错误原因。

下面是一个符合UNIX标准的守护进程转化函数的C语言实现,参考了《UNIX环境高级编程》中的经典步骤:

// daemonize/c/daemon.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <syslog.h>
#include <signal.h>

void daemonize()
{
    pid_t pid;

    // 1. Fork off the parent process 
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    // If we got a good PID, then we can exit the parent process.
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 2. Create a new session to become session leader to lose controlling TTY
    if (setsid() < 0) {
        exit(EXIT_FAILURE);
    }

    // 3. Fork again to ensure the process won't allocate controlling TTY in future
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 4. Change the current working directory to root.
    if (chdir("/") < 0) {
        exit(EXIT_FAILURE);
    }

    // 5. Set the file mode creation mask to 0.
    umask(0);

    // 6. Close all open file descriptors.
    for (int x = sysconf(_SC_OPEN_MAX); x>=0; x--) {
        close(x);
    }

    // 7. Reopen stdin, stdout, stderr to /dev/null
    open("/dev/null", O_RDWR); // stdin
    dup(0);                    // stdout
    dup(0);                    // stderr

    // Optional: Log the daemon starting
    openlog("daemonized_process", LOG_PID, LOG_DAEMON);
    syslog(LOG_NOTICE, "Daemon started.");
    closelog();
}

int main() {
    daemonize();

    // Daemon process main loop
    while (1) {
        // Perform some background task...
        sleep(30); // Sleep for 30 seconds.
    }

    return EXIT_SUCCESS;
}

注:这里省略了书中设置系统信号handler的步骤。

这里的daemonize函数完成了标准的守护化转化过程,并确保了程序在后台无依赖地稳定运行。我们编译运行该程序后,程序进入后台运行,通过ps命令可以查看到类似下面内容:

$ ./c-daemon-app 
$ ps -ef|grep c-daemon-app
root     28517     1  0 14:11 ?        00:00:00 ./c-daemon-app

我们看到c-daemon-app的父进程是ppid为1的进程,即linux的init进程。我们看到上面c代码中转化为守护进程的函数daemonize进行了两次fork,至于为何要做两次fork,在我的《理解Zombie和Daemon Process[4]》一文中有说明,这里就不赘述了。

那么Go是否可以参考上述步骤实现Go程序的守护进程转化呢?我们接着往下看。

2. Go语言实现守护进程的挑战

关于Go如何实现守护进程的转换,在Go尚未发布1.0之前的2009年就有issue提到,在runtime: support for daemonize[5]中,Go社区与Go语言的早起元老们讨论了在Go中实现原生守护进程的复杂性,主要挑战源于Go的运行时及其线程管理方式。当一个进程执行fork操作时,只有主线程被复制到子进程中,如果fork前Go程序有多个线程(及多个goroutine)在执行(可能是由于go runtime调度goroutine和gc产生的线程),那么fork后,这些非执行fork线程的线程(以及goroutine)将不会被复制到新的子进程中,这可能会导致后续子进程中线程运行的不确定性(基于一些fork前线程留下的数据状态)。

理想情况下是Go runtime提供类似的daemonize函数,然后在多线程启动之前实现守护进程的转化,不过Go团队至今也没有提供该机制,而是建议大家使用如systemd的第三方工具来实现Go程序的守护进程转化。

既然Go官方不提供方案,Go社区就会另辟蹊径,接下来,我们看看目前Go社区的守护进程解决方案。

3. Go社区的守护进程解决方案

尽管面临挑战,Go社区还是开发了一些库来支持Go守护进程的实现,其中一个star比较多的解决方案是github.com/sevlyar/go-daemon。

go-daemon库的作者巧妙地解决了Go语言中无法直接使用fork系统调用的问题。go-daemon采用了一个简单而有效的技巧来模拟fork的行为:该库定义了一个特殊的环境变量作为标记。程序运行时,首先检查这个环境变量是否存在。如果环境变量不存在,执行父进程相关操作,然后使用os.StartProcess(本质是fork-and-exec)启动带有特定环境变量标记的程序副本。如果环境变量存在,执行子进程相关操作,继续执行主程序逻辑,下面是该库作者提供的原理图:

图片图片

这种方法有效地模拟了fork的行为,同时避免了Go运行时中与线程和goroutine相关的问题。下面是使用go-daemon包实现Go守护进程的示例:

// daemonize/go-daemon/main.go

package main

import (
 "log"
 "time"

 "github.com/sevlyar/go-daemon"
)

func main() {
 cntxt := &daemon.Context{
  PidFileName: "example.pid",
  PidFilePerm: 0644,
  LogFileName: "example.log",
  LogFilePerm: 0640,
  WorkDir:     "./",
  Umask:       027,
 }

 d, err := cntxt.Reborn()
 if err != nil {
  log.Fatal("无法运行:", err)
 }
 if d != nil {
  return
 }
 defer cntxt.Release()

 log.Print("守护进程已启动")

 // 守护进程逻辑
 for {
  // ... 执行任务 ...
  time.Sleep(time.Second * 30)
 }
}

运行该程序后,通过ps可以查看到对应的守护进程:

$make
go build -o go-daemon-app 
$./go-daemon-app 

$ps -ef|grep go-daemon-app
  501  4025     1   0  9:20下午 ??         0:00.01 ./go-daemon-app

此外,该程序会在当前目录下生成example.pid(用于实现file lock),用于防止意外重复执行同一个go-daemon-app:

$./go-daemon-app
2024/09/26 21:21:28 无法运行:daemon: Resource temporarily unavailable

虽然原生守护进程化提供了精细的控制且无需安装和配置外部依赖,但进程管理工具提供了额外的功能,如开机自启[6]、异常退出后的自动重启和日志记录等,并且Go团队推荐使用进程管理工具来实现Go守护进程。进程管理工具的缺点在于需要额外的配置(比如systemd)或安装设置(比如supervisor)。

4. 小结

在Go中实现守护进程化,虽然因为语言运行时的特性而具有挑战性,但通过社区开发的库和谨慎的实现是可以实现的。随着Go语言的不断发展,我们可能会看到更多对进程管理功能的原生支持。同时,开发者可以根据具体需求,在原生守护进程化、进程管理工具或混合方法之间做出选择。

本文涉及的源码可以在这里[7]下载。

参考资料

[1] systemd: https://tonybai.com/2016/12/27/when-docker-meets-systemd

[2] supervisor: http://supervisord.org

[3] UNIX环境高级编程: https://book.douban.com/subject/25900403/

[4] 理解Zombie和Daemon Process: https://tonybai.com/2005/09/21/understand-zombie-and-daemon-process/

[5] runtime: support for daemonize: https://github.com/golang/go/issues/227

[6] 开机自启: https://tonybai.com/2022/09/12/how-to-install-a-go-app-as-a-system-service-like-gitlab-runner

[7] 这里: https://github.com/bigwhite/experiments/tree/master/daemonize

[8] Gopher部落知识星球: https://public.zsxq.com/groups/51284458844544

[9] 链接地址: https://m.do.co/c/bff6eed92687

责任编辑:武晓燕 来源: TonyBai
相关推荐

2024-08-29 13:23:04

WindowsGo语言

2010-06-28 14:52:30

cron进程

2011-03-29 12:45:07

Zabbix进程

2017-04-11 16:00:40

Linuxsyslog进程

2010-03-02 16:37:53

Linux Quagg

2010-03-16 13:41:09

Python进程

2024-02-21 08:33:27

GoReadDir性能

2010-07-15 15:54:10

Perl守护进程

2009-11-24 11:35:59

2023-11-30 08:09:02

Go语言

2012-11-08 09:36:10

Google Go

2013-01-15 15:18:46

Linux守护进程

2012-05-08 11:01:45

linux守护进程

2015-10-20 17:06:52

2010-07-15 15:47:46

Perl守护进程

2011-08-08 10:02:55

iPhone开发 进程 通信

2021-07-26 09:47:38

Go语言C++

2018-01-02 16:39:04

2020-06-02 16:19:09

华为

2021-07-15 12:44:25

Shell编程进程
点赞
收藏

51CTO技术栈公众号