目录
很多同学第一次做守护进程时,都会遇到同一组问题:
- 服务一退出,谁来拉起?
- 怎么和当前终端彻底“断开关系”?
- 父进程和子进程之间,最小可用的协作模型是什么?
这篇文章将分享一套“父子双进程”守护方案:父进程负责拉起和监控,子进程负责干活。 我会先讲清底层机制,再给一份可以直接改造的 Go 示例。
1. 先看实现守护需要的基础能力
不管你用不用第三方库,父子双进程守护都离不开几块基础能力:启动、等待、信号、环境变量、探活。
核心点有四个。
1.1 启动子进程并“忽略 stdio”
核心就是 exec.CommandContext + stdio 重定向到 /dev/null:
func Start(ctx context.Context, cmd string, args ...string) (*exec.Cmd, error) {
c := exec.CommandContext(ctx, cmd, args...)
in, err := os.Open(os.DevNull)
if err != nil {
return nil, err
}
out, err := os.Open(os.DevNull)
if err != nil {
_ = in.Close()
return nil, err
}
errOut, err := os.Open(os.DevNull)
if err != nil {
_ = in.Close()
_ = out.Close()
return nil, err
}
c.Stdin = in
c.Stdout = out
c.Stderr = errOut
if err := c.Start(); err != nil {
_ = in.Close()
_ = out.Close()
_ = errOut.Close()
return nil, err
}
_ = in.Close()
_ = out.Close()
_ = errOut.Close()
return c, nil
}
把 stdin/stdout/stderr 都接到 os.DevNull 这一步非常关键,它的意义是:
- 子进程不会因为父进程终端关闭而被“拖死”;
- 不会意外把日志刷到控制台;
- 进程模型更接近真正 daemon 的运行语义。
1.2 用“进程句柄”管理生命周期
实践里你至少需要三个动作:
- 拿到子进程 PID(cmd.Process.Pid);
- 给子进程发信号(cmd.Process.Signal(…));
- Wait() 等待退出并读取退出码。
其中 Wait() 最关键:把 *exec.ExitError 转成 Unix WaitStatus 后拿到 exit code,上层才能做“按退出码分类策略”。
1.3 环境变量合并
子进程环境变量建议在 os.Environ() 基础上叠加:
- 继承宿主环境,避免 PATH 等基础变量丢失;
- 覆盖关键变量,实现“按实例注入配置”;
- 更适合做 supervisor -> worker 的参数下发。
1.4 监控探活
探活常见手段是 signal 0:
func IsRunning(pid int) bool {
if pid <= 0 {
return false
}
p, err := os.FindProcess(pid)
if err != nil {
return false
}
return p.Signal(syscall.Signal(0)) == nil
}
它不发送真实信号,只做存在性和权限检测,很适合做轻量轮询监控。
2. 父子双进程模型到底怎么跑
我们先统一术语:
- 父进程(supervisor):只负责“拉起 + 监控 + 重启 + 转发退出信号”
- 子进程(worker):只负责业务逻辑
典型状态机如下:
- 父进程启动;
- 父进程拉起子进程;
- 子进程执行工作;
- 子进程异常退出 -> 父进程等待退避后重启;
- 父进程收到 SIGTERM/SIGINT -> 通知子进程优雅退出并回收;
- 父进程自己退出。
这个模型的价值在于:把“业务故障恢复”从业务代码里剥离出来。子进程不需要关心“挂了之后谁来拉起”,只要保证退出语义清晰即可。
3. 一份可运行的 Go 模板(父子双进程)
下面给一个单文件示例,展示如何用“角色切换 + 自拉起”实现双进程。你可以把它改成自己的服务入口。
package main
import (
"context"
"errors"
"fmt"
"log"
"math"
"os"
"os/exec"
"os/signal"
"strconv"
"syscall"
"time"
)
const (
envRole = "DAEMON_ROLE"
roleParent = "parent"
roleChild = "child"
maxBackoff = 30 * time.Second
baseBackoff = 1 * time.Second
)
func main() {
role := os.Getenv(envRole)
switch role {
case roleChild:
runChild()
default:
if err := runParent(); err != nil {
log.Fatalf("parent exit: %v", err)
}
}
}
func runParent() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Println("[parent] supervisor started")
restarts := 0
for {
select {
case <-ctx.Done():
log.Println("[parent] receive stop signal")
return nil
default:
}
env := map[string]string{
envRole: roleChild,
}
child, err := startWithEnv(ctx, env, os.Args[0], os.Args[1:]...)
if err != nil {
return fmt.Errorf("start child failed: %w", err)
}
log.Printf("[parent] child started, pid=%d", child.Process.Pid)
code, waitErr := waitExitCode(child)
if waitErr != nil {
// 如果父 context 结束,通常会带着子进程一起退出
if errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded) {
log.Printf("[parent] wait interrupted by context: %v", waitErr)
return nil
}
log.Printf("[parent] child wait error: %v", waitErr)
}
// 父进程已进入退出流程时,不再进入重启分支。
if ctx.Err() != nil {
return nil
}
// 约定:0 表示正常退出,不再拉起
if code == 0 {
log.Printf("[parent] child exit code=0, stop supervise")
return nil
}
restarts++
backoff := calcBackoff(restarts)
log.Printf("[parent] child exit code=%d, restart after %s", code, backoff)
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return nil
case <-timer.C:
}
}
}
func runChild() {
log.Printf("[child] worker started, pid=%d", os.Getpid())
// 模拟业务主循环:可替换为 HTTP server / queue consumer / scheduler
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(quit)
for {
select {
case <-ticker.C:
log.Printf("[child] doing work")
// 演示异常退出:通过环境变量触发非 0 退出
if n := os.Getenv("FAIL_AFTER"); n != "" {
v, _ := strconv.Atoi(n)
if v > 0 {
v--
_ = os.Setenv("FAIL_AFTER", strconv.Itoa(v))
if v == 0 {
log.Printf("[child] simulate crash")
os.Exit(2)
}
}
}
case sig := <-quit:
log.Printf("[child] receive signal=%s, graceful shutdown", sig)
// 在这里做资源释放:flush、close、drain ...
os.Exit(0)
}
}
}
func calcBackoff(restarts int) time.Duration {
// 指数退避并封顶,避免崩溃风暴
exp := math.Pow(2, float64(restarts-1))
d := time.Duration(exp) * baseBackoff
if d > maxBackoff {
return maxBackoff
}
if d < baseBackoff {
return baseBackoff
}
return d
}
func startWithEnv(ctx context.Context, overlay map[string]string, cmd string, args ...string) (*exec.Cmd, error) {
c := exec.CommandContext(ctx, cmd, args...)
c.Env = mergeEnv(overlay)
in, err := os.Open(os.DevNull)
if err != nil {
return nil, err
}
out, err := os.Open(os.DevNull)
if err != nil {
_ = in.Close()
return nil, err
}
errOut, err := os.Open(os.DevNull)
if err != nil {
_ = in.Close()
_ = out.Close()
return nil, err
}
c.Stdin = in
c.Stdout = out
c.Stderr = errOut
if err := c.Start(); err != nil {
_ = in.Close()
_ = out.Close()
_ = errOut.Close()
return nil, err
}
_ = in.Close()
_ = out.Close()
_ = errOut.Close()
return c, nil
}
func mergeEnv(overlay map[string]string) []string {
m := make(map[string]string)
for _, kv := range os.Environ() {
for i := 0; i < len(kv); i++ {
if kv[i] == '=' {
m[kv[:i]] = kv[i+1:]
break
}
}
}
for k, v := range overlay {
m[k] = v
}
out := make([]string, 0, len(m))
for k, v := range m {
out = append(out, k+"="+v)
}
return out
}
func waitExitCode(c *exec.Cmd) (int, error) {
err := c.Wait()
if err == nil {
return 0, nil
}
var ee *exec.ExitError
if errors.As(err, &ee) {
ws := ee.ProcessState.Sys().(syscall.WaitStatus)
return ws.ExitStatus(), nil
}
return -1, err
}
这份模板的关键点是:
- 用 startWithEnv 给子进程打角色标记;
- 用 waitExitCode 获取可靠退出码;
- 用 context + signal 统一收敛退出链路;
- 父进程负责退避重启,子进程只做业务。
4. 守护进程实现里最容易踩的坑
4.1 不调用 Wait,僵尸进程就会出现
只 Start 不 Wait,子进程退出后资源不会被回收,最终留下 zombie。
所以不管你封不封装,拿到进程句柄后都要在合适时机 Wait()。
4.2 快速重启导致“崩溃风暴”
子进程启动即崩,父进程立刻重启,会把 CPU 打满并刷爆日志。
正确做法就是上面的指数退避 + 上限。
4.3 信号只给父进程,子进程没收到
父进程收到了 SIGTERM,但没有显式转发/收敛,子进程可能继续跑。
建议做法:
- 父进程收到退出信号后,取消 context 或显式 child.Signal(syscall.SIGTERM);
- 等待一段宽限时间后再决定是否 SIGKILL;
- 最后 Wait 回收。
4.4 日志策略混乱
守护模式通常会把 stdio 丢到 /dev/null,这对 daemon 很友好,但前提是你有独立日志链路(文件、journald、ELK、OpenTelemetry 等)。
否则你会遇到“服务跑着,但什么都看不到”。
5. 生产环境怎么选型
如果你跑在 systemd / k8s 这类成熟运行时上,通常不需要自己写父子守护,交给平台重启策略更简单。
但下面场景自己实现仍然有价值:
- 嵌入式或受限环境,没有现成 supervisor;
- 单二进制交付,希望进程自治;
- 需要非常细颗粒度的重启策略和前置检查。
可以把这套模式理解为“进程控制积木”:框架不重要,关键是把启动、等待、信号、重启策略这几块拼好。
6. 小结
父子双进程守护这件事,说白了就三句话:
- 父进程只管生命周期,子进程只管业务。
- 退出码是协作协议,Wait 是协议兑现。
- 脱离终端、信号收敛、退避重启,是可用性的三个底座。
基于这套轻量模式,你可以很快搭出可靠的 daemon 原型,然后再按业务把配置、日志、健康检查、热升级逐步补齐。
7. 最小可运行 Demo:main.go + Makefile + systemd
如果你想马上动手,这里给一套最小目录,复制就能跑。
7.1 目录结构
daemon-demo/
├── go.mod
├── main.go
├── Makefile
└── daemon-demo.service
7.2 main.go
为避免重复,main.go 直接使用第 3 节的完整代码,保存为 daemon-demo/main.go 即可。
7.3 Makefile
APP=daemon-demo
.PHONY: build run clean
build:
go build -o $(APP) .
run: build
./$(APP)
clean:
rm -f $(APP)
7.4 daemon-demo.service
这里用 systemd 启动“父进程”。由于父进程内部已经有子进程守护逻辑,建议 Restart=on-failure 即可,避免和内部重启策略互相“打架”。
[Unit]
Description=Go Parent-Child Daemon Demo
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/daemon-demo
ExecStart=/opt/daemon-demo/daemon-demo
Restart=on-failure
RestartSec=5
User=nobody
Group=nogroup
# 把日志交给 journald;也可改为文件落盘方案
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
7.5 本地验证步骤
# 1) 本地直接跑
make run
# 2) 观察输出:默认持续运行;可用 FAIL_AFTER 模拟异常退出并触发重启
# FAIL_AFTER=5 ./daemon-demo
# 3) Ctrl+C,观察父子一起退出(优雅停止)
如果你要验证 systemd:
sudo mkdir -p /opt/daemon-demo
sudo cp daemon-demo /opt/daemon-demo/
sudo cp daemon-demo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable daemon-demo
sudo systemctl start daemon-demo
sudo systemctl status daemon-demo
sudo journalctl -u daemon-demo -f
跑通这套 demo 后,你再把 runChild 里的“心跳循环”替换成你自己的 HTTP 服务、队列消费或任务调度逻辑,就完成了从原理到业务落地的闭环。