子进程随着父进程关闭调研

在 linux 中,父进程创建了子进程,例如 exec.Command() 命令启动一个子命令。当父进程因为意外崩溃退出,没来得及控制子进程的生命周期,此时子进程成为孤儿进程继续运行。

本文调研的内容是控制当父进程因为任何原因退出时,子进程必须立即结束。

进程组

进程组确实是一组相关进程的集合,每个进程都有唯一的进程 ID(pid),同时属于一个进程组,进程组有唯一的进程组 ID(pgid)。通常,进程组由一个 “领头进程” 创建,其pid会成为该进程组的pgid

kill 命令的参数是 pgid 时,kill 会向进程组内的所有进程发送信号,进程可以捕获或忽略信号(SIGTERM)。

  • 进程组仅支持 linux,windows 是没有进程组的概念
  • 主进程崩溃,子进程还是会成为孤儿进程运行
 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
35
36
37
38
39
40
41
import (
	"fmt"
	"os/exec"
	"syscall"
)

func main() {
	// 创建一个命令
	cmd := exec.Command("sleep", "1000") // 示例命令,让进程休眠 1000 秒

	// 设置 SysProcAttr 以创建新进程组
	cmd.SysProcAttr = &syscall.SysProcAttr{
		Setpgid: true,  // 创建新进程组
		Pgid:    0,     // 0 表示使用子进程的 PID 作为进程组 ID
	}

	// 启动命令
	if err := cmd.Start(); err != nil {
		fmt.Printf("启动进程失败: %v\n", err)
		return
	}

	// 获取进程组 ID(等于子进程的 PID)
	pgid := -cmd.Process.Pid // 在 Linux 中,负 PID 表示进程组

	fmt.Printf("子进程 PID: %d\n", cmd.Process.Pid)
	fmt.Printf("进程组 ID: %d\n", pgid)

	// 向进程组发送信号(例如 SIGTERM)
	if err := syscall.Kill(pgid, syscall.SIGTERM); err != nil {
		fmt.Printf("发送信号失败: %v\n", err)
		return
	}

	fmt.Println("已向进程组发送终止信号")

	// 等待命令执行完成
	if err := cmd.Wait(); err != nil {
		fmt.Printf("命令执行完成: %v\n", err)
	}
}

尝试解决方案 异步监听标准输入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 // interrupt 用于阻塞
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() { 
    _, err := io.Copy(io.Discard, os.Stdin)  // 持续读取标准输入(但丢弃数据)
    slog.Info("main -> Copy", "err", err)    // 记录读取结束时的错误(如 EOF)
    interrupt <- syscall.SIGTERM             // 发送终止信号
}()

<- interrupt

父进程通过 exec.Command() 启动子进程,子进程持续从标准输入读取数据,并直接丢弃。如果输入流关闭(终端关闭,管道断开等), io.Copy 会立即结束,并发送终止信号。

通过监听标准输入的关闭事件,触发程序的优雅退出机制。

但如果是以服务方式启动,子进程会立即退出,服务管理器通过会将标准输入设置为空设备 (/dev/null) 或直接关闭,导致子进程的 os.Stdin 在启动时已经处于 EOF 状态。

1
2
3
4
5
6
7
8
// 判断是否在服务模式下运行(stdin 指向 /dev/null 或已关闭)
func isServiceMode() bool {
    info, err := os.Stdin.Stat()
    if err != nil {
        return true // 无法获取状态,假设为服务模式
    }
    return info.Mode()&os.ModeCharDevice == 0 // 非终端设备(如 /dev/null)
}

既然可以监听标准输入的方式判断父进程是否存活,那么通过进程通信管道呢?

 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package main

import (
	"bytes"
	"log/slog"
	"os"
	"os/exec"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	// 创建用于通信的管道
	r, w, err := os.Pipe()
	if err != nil {
		slog.Error("创建管道失败", "err", err)
		return
	}
	defer w.Close()

	// 启动子进程,并将管道写入端传递给子进程
	cmd := exec.Command("./child") // 假设子进程程序名为 child
	cmd.Stdin = r                  // 子进程从管道读取数据
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	// 启动子进程
	if err := cmd.Start(); err != nil {
		slog.Error("启动子进程失败", "err", err)
		return
	}
	slog.Info("子进程已启动", "pid", cmd.Process.Pid)

	// 父进程定期向管道写入数据(心跳)
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for range ticker.C {
			_, err := w.Write([]byte("heartbeat\n"))
			if err != nil {
				slog.Warn("写入管道失败,子进程可能已退出", "err", err)
				break
			}
		}
	}()

	// 处理父进程退出信号
	sigCh := make(chan os.Signal, 1)
	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
	<-sigCh

	slog.Info("父进程正在退出...")
	w.Close() // 关闭管道,通知子进程

	// 等待子进程退出
	if err := cmd.Wait(); err != nil {
		slog.Error("等待子进程失败", "err", err)
	}
	slog.Info("父进程已退出")
}

子进程

 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package main

import (
	"io"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	interrupt := make(chan os.Signal, 1)
	signal.Notify(interrupt, syscall.SIGTERM, syscall.SIGINT)

	// 获取父进程 PID
	parentPID := os.Getppid()
	slog.Info("父进程 PID", "pid", parentPID)

	// 监听管道关闭事件(父进程崩溃或正常退出)
	go func() {
		// 从 stdin 读取数据(实际连接到父进程创建的管道)
		_, err := io.Copy(io.Discard, os.Stdin)
		if err != nil {
			slog.Info("管道关闭", "err", err)
		} else {
			slog.Info("管道正常关闭")
		}
		interrupt <- syscall.SIGTERM // 触发优雅退出
	}()

	// 定期检查父进程是否存活(双重保险)
	go func() {
		ticker := time.NewTicker(2 * time.Second)
		defer ticker.Stop()

		for range ticker.C {
			// 尝试向父进程发送 0 信号(仅检查进程是否存在)
			err := syscall.Kill(parentPID, 0)
			if err != nil {
				slog.Info("父进程已退出", "err", err)
				interrupt <- syscall.SIGTERM // 触发优雅退出
				break
			}
		}
	}()

	slog.Info("子进程运行中...")
	<-interrupt // 阻塞直到收到退出信号
	slog.Info("子进程优雅退出")
}

总结

主要有两个方案

  1. 子进程定期检测父进程是否存活,可以监听固定端口也好,pid 状态也好,网络通信也好。(子进程跟随父进程生命周期)
  2. 父进程启动子进程时,记录子进程的 id,下次启动时,根据需要再停止子进程或继续复用子进程。(允许子进程存活,并在下次启动时接管)
Licensed under CC BY-NC-SA 4.0
本文阅读量 次, 总访问量 ,总访客数
Built with Hugo .   Theme Stack designed by Jimmy