Linux --进程间关系和守护进程

为什么需要理解"进程间关系"?

我们平时写程序,习惯了一个进程"单打独斗"。但在 Linux/Unix 系统中,进程从来不是孤立的:

  • 你在终端敲下 ls | grep txt,Shell 会创建两个进程 ,它们属于同一个进程组;

  • 你按下 Ctrl+C,信号不是发给某一个进程,而是发给整个前台进程组;

  • 你关闭终端,后台跑着的服务却依然活着------这就是守护进程的功劳。

理解进程组、会话、控制终端、作业控制 这几层关系,是写出健壮后台服务(比如 TCP 服务器)的基础。而**守护进程(Daemon)**正是这些概念的集大成者。

下面我们从最底层的"进程组"开始,逐层向上剖析。

进程组(Process Group)

什么是进程组?

每一个进程除了有 PID(进程 ID) 之外,还属于某个进程组。

  • 进程组:一个或多个进程的集合。

  • PGID(进程组 ID) :每个进程组有唯一的 ID,类型为 pid_t,本质是一个正整数。

可以通过 ps 命令查看:

bash 复制代码
ps -eo pid,pgid,ppid,comm | grep test

输出示例:

bash 复制代码
PID   PGID  PPID  COMMAND
2830  2830  2259  test
  • -e:every,显示所有进程

  • -o:以逗号分隔指定要输出的列

可以看到 test 进程的 PID 和 PGID 相同,说明它是组长进程。

组长进程(Process Group Leader)

组长进程的 PID 等于其 PGID。

看一个例子:

bash 复制代码
ps -o pid,pgid,ppid,comm | cat

输出:

bash 复制代码
PID   PGID  PPID  COMMAND
2806  2806  2805  bash
2880  2880  2806  ps
2881  2880  2806  cat

分析:

  • ps 进程的 PID = PGID = 2880,所以 ps 是组长;

  • cat 的 PGID 也是 2880,所以它和 ps 属于同一个进程组;

  • 管道 | 会把 ps 和 cat 编成一个进程组。

组长的作用:

  • 可以创建一个进程组;

  • 可以创建该组中的进程。

进程组的生命周期:

  • 从进程组创建开始,到其中最后一个进程离开为止;

  • 注意:只要组内还有任意一个进程存在,进程组就存在,与组长是否终止无关。组长死了,组还在,只是"群龙无首"。

  • 组长进程终止后,进程组并不会消失,但该组不再有组长。此时组内其他进程的 PGID 依然是原来的值。

会话(Session)

什么是会话?

会话是一个或多个进程组的集合 。每个会话有唯一的 SID(会话 ID)。

用一张图表示层级关系:

bash 复制代码
会话 (Session, SID)
 ├── 进程组 1 (PGID=100)
 │    └── proc1
 ├── 进程组 2 (PGID=200)
 │    ├── proc2
 │    └── proc3
 └── 进程组 3 (PGID=300)
      ├── proc4
      ├── proc5
      └── proc6

通常我们用管道 把几个进程编成一个进程组,再用 & 放到后台:

bash 复制代码
proc2 | proc3 &
proc4 | proc5 | proc6 &

& 表示将进程组放在后台执行。

实验:观察进程组与会话

bash 复制代码
sleep 100 | sleep 200 | sleep 300 &
ps axj | head -n1
ps axj | grep sleep | grep -v grep

输出:

bash 复制代码
PPID  PID   PGID  SID   TTY    TPGID  STAT  UID   TIME  COMMAND
2806  4223  4223  2780  pts/2  4229   S     1000  0:00  sleep 100
2806  4224  4223  2780  pts/2  4229   S     1000  0:00  sleep 200
2806  4225  4223  2780  pts/2  4229   S     1000  0:00  sleep 300

观察:

  • 三个 sleep 的 PGID 相同(4223),说明属于同一个进程组;

  • 它们的 SID 相同(2780),说明属于同一个会话;

  • TTY 都是 pts/2,说明共享同一个控制终端。

参数说明:

  • a:显示所有用户进程;

  • x:显示没有控制终端的进程;

  • j:显示与作业控制相关的信息(PGID、SID、TTY、TPGID 等)。

如何创建会话?------ setsid()

bash 复制代码
#include <unistd.h>

/*
 * 功能:创建会话
 * 返回值:成功返回 SID,失败返回 -1
 */
pid_t setsid(void);

调用 setsid() 后会发生三件事:

  1. 调用进程变成新会话的会话首进程,此时新会话中只有它一个进程;

  2. 调用进程变成新进程组的组长,新 PGID = 当前进程 PID;

  3. 该进程失去控制终端。如果之前有控制终端,调用后联系被切断。

前提 :调用进程不能是进程组的组长,否则会报错。

常见用法 :先 fork() 出子进程,父进程退出,子进程继续执行。因为子进程会继承父进程的 PGID,但 PID 是新分配的,所以它不是组长 ,可以安全调用 setsid()。

bash 复制代码
if (fork() > 0) exit(0);  // 父进程退出
setsid();                 // 子进程创建新会话

会话 ID(SID)

  • 会话首进程:创建会话的那个进程,具有唯一的 PID;

  • SID = 会话首进程的 PID;

  • 又因为会话首进程总是它所在进程组的组长,所以 SID 也等于该进程组的 PGID,两者等价。

控制终端(Controlling Terminal)

什么是控制终端?

在 Linux 系统中,用户登录后得到一个 Shell 进程 ,这个终端就成了 Shell 的控制终端。

控制终端信息保存在 PCB(进程控制块) 中。fork() 会复制 PCB,所以 Shell 启动的子进程默认也以同一个终端为控制终端。

默认情况下(无重定向):

  • 标准输入 → 控制终端(读键盘)

  • 标准输出 / 标准错误 → 控制终端(写屏幕)

会话、进程组与控制终端的关系

  • 一个会话最多有一个控制终端;

  • 会话首进程打开终端后,该终端成为该会话的控制终端;

  • 建立与控制终端连接的会话首进程称为控制进程;

  • 一个会话中的进程组分为:

    • 前台进程组(1 个)

    • 后台进程组(0 个或多个)

  • 终端产生的中断信号发给前台进程组的所有进程;

  • 如果终端检测到调制解调器(或网络)断开,会向控制进程发送挂断信号(SIGHUP)。

结构示意图:

bash 复制代码
                会话 (Session)
 ┌───────────────────────────────────────────────┐
 │  后台进程组             后台进程组     前台进程组  │
 │  [登录 shell]         [proc1 proc2]  [proc3 proc4]│
 │  会话首进程=控制进程                    [proc5]  │
 └───────────────────────────────────────────────┘
        ↑                                  ↑
   挂断信号(SIGHUP)              终端输入和终端产生的信号
        │                                 │
        └──────────── 控制终端 ────────────┘
                          ↑
                       键盘输入

作业控制(Job Control)

作业与作业控制

  • 作业(Job):从用户角度看,是为完成某项任务而启动的进程集合。一个作业可以是一个进程,也可以是一组协作的进程(通常是管道)。

  • 作业控制(Job Control) :Shell 分前后台控制的是作业/进程组,而不是单个进程。

一个前台作业可以由多个进程组成,一个后台作业也可以。Shell 可以同时运行1 个前台作业 + 任意多个后台作业。

例如:

bash 复制代码
cat /etc/filesystems | head -n 5

这就是一个作业,包含两个进程(cat 和 head),在前台运行。

作业号

在命令后加 & 可让 Shell 把它放到后台:

bash 复制代码
cat /etc/filesystems | grep ext &

输出:

bash 复制代码
[1] 2202
ext4
ext3
ext2
[1]+ 完成    cat /etc/filesystems | grep --color=auto ext
  • [1]:作业号

  • 2202:进程号(PGID)

  • +:默认作业

  • 完成后会显示"完成"

默认作业规则:

  • +:默认作业(最近一次提交的)

  • -:即将成为默认作业的(倒数第二个)

  • 无符号:其他作业

  • 一个用户同时只能有一个 + 和一个 -

作业状态

状态 含义
Running 后台作业(&),正在执行
Done 作业完成,返回码为 0
Done(code) 作业完成并退出,返回码非 0
Stopped 前台作业被 Ctrl+Z 挂起
Terminated 作业被终止

作业的挂起与切回

(1)挂起:Ctrl+Z

运行一个死循环程序:

cpp 复制代码
#include <stdio.h>
int main() {
    while (1) {
        printf("hello\n");
    }
    return 0;
}

运行 ./test,按 Ctrl+Z:

cpp 复制代码
[1]+ 已停止    ./test

(2)切回前台:fg

cpp 复制代码
fg %%

fg 参数表:

参数 含义
%n 作业号 n
%string 以 string 开头的命令对应的作业
%?string 包含 string 的命令对应的作业
%+ 或 %% 最近提交的作业
%- 倒数第二个提交的作业

不带参数时,默认把 + 作业切到前台。

查看后台/挂起作业:jobs

cpp 复制代码
jobs      # 查看作业
jobs -l   # 显示详细信息(含 PID)
jobs -p   # 只显示 PID

示例:

cpp 复制代码
sleep 300 &
./test
# Ctrl+Z
jobs -l

输出:

cpp 复制代码
[1]- 2265 运行中    sleep 300 &
[2]+ 2267 停止      ./test

作业控制相关的信号

按键 信号 含义
Ctrl+C SIGINT 中断字符
Ctrl+\ SIGQUIT 退出字符
Ctrl+Z SIGTSTP 挂起字符

关键点 :这些信号只会发送给前台进程组中的所有进程,后台进程组不受影响。

终端的 I/O(标准输入/输出)和终端产生的信号,总是与前台进程组关联。

守护进程(Daemon)

什么是守护进程?

守护进程是在后台长期运行、脱离控制终端 的进程。典型例子:sshd、httpd、mysqld。

它的特点:

  • 没有控制终端;

  • 独立于任何会话终端;

  • 通常随系统启动而启动,随系统关闭而终止;

  • 名字通常以 d 结尾(daemon)。

创建守护进程的步骤

cpp 复制代码
#pragma once
#include <iostream>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

const char *root = "/";
const char *dev_null = "/dev/null";

void Daemon(bool ischdir, bool isclose) {
    // 1. 忽略可能引起程序异常退出的信号
    signal(SIGCHLD, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);

    // 2. 让自己不要成为组长
    if (fork() > 0) exit(0);

    // 3. 设置让自己成为一个新的会话(子进程继续执行)
    setsid();

    // 4. 是否将当前进程的 CWD 更改为根目录
    if (ischdir) chdir(root);

    // 5. 已经变成守护进程,不再和用户的输入输出、错误关联
    if (isclose) {
        close(0);
        close(1);
        close(2);
    } else {
        // 一般建议用这种:重定向到 /dev/null
        int fd = open(dev_null, O_RDWR);
        if (fd > 0) {
            dup2(fd, 0);
            dup2(fd, 1);
            dup2(fd, 2);
            close(fd);
        }
    }
}

主程序使用:

cpp 复制代码
int main(int argc, char *argv[]) {
    if (argc != 2) {
        std::cout << "Usage : " << argv[0] << " port" << std::endl;
        return 0;
    }
    uint16_t localport = std::stoi(argv[1]);

    Daemon(false, false);
    std::unique_ptr<TcpServer> svr(new TcpServer(localport, HandlerRequest));
    svr->Loop();
    return 0;
}

每一步为什么这么做?

步骤 操作 原因
1 忽略 SIGCHLD、SIGPIPE 防止子进程退出或写管道出错导致进程异常终止
2 fork() 后父进程退出 确保子进程不是进程组组长 ,为 setsid() 做准备
3 setsid() 创建新会话,脱离原控制终端
4 chdir("/") 避免占用可卸载的文件系统(如 U 盘),防止 CWD 被删除导致问题
5 重定向 0/1/2 到 /dev/null 守护进程不需要终端输入输出,避免资源浪费和意外阻塞

更完整的守护进程

实际工程中,守护进程通常还会:

  1. 二次 fork :确保进程不是会话首进程,从而永远不会获得控制终端。

  2. 设置文件权限掩码 :umask(0),避免继承的掩码影响创建文件。

  3. 写 PID 文件 :/var/run/xxx.pid,方便管理。

  4. 日志系统 :用 syslog 替代 stdout/stderr。

二次 fork 的代码示例:

cpp 复制代码
if (fork() > 0) exit(0);
setsid();
if (fork() > 0) exit(0);  // 二次 fork,确保不是会话首进程
// 继续执行守护进程逻辑

总结与知识地图

cpp 复制代码
进程
 ├── PID(进程 ID)
 ├── 属于某个进程组
 │    ├── PGID
 │    └── 组长进程:PID == PGID
 ├── 属于某个会话
 │    ├── SID
 │    └── 会话首进程:PID == SID
 └── 可能拥有控制终端
      ├── 前台进程组 ← Ctrl+C / Ctrl+\ / Ctrl+Z
      └── 后台进程组 ← & 启动

作业控制
 ├── jobs / fg / bg
 ├── Ctrl+Z 挂起
 └── 信号:SIGINT、SIGQUIT、SIGTSTP

守护进程
 ├── fork → setsid → chdir → 重定向
 ├── 脱离控制终端
 └── 长期后台运行
相关推荐
AIgorithmGEEK34 分钟前
[Linux]HTTP 应用层协议全解(下篇)
linux·网络·网络协议·http·cookie·无连接·无状态
名誉寒冰40 分钟前
我是怎么理解 eBPF、AppArmor 和 Seccomp 的?
linux·网络·内核
H.莓飛1 小时前
【Linux】命令行参数、环境变量与程序地址空间
linux·c语言·chrome·后端·centos
weixin_403810131 小时前
EasyClick iOS 免越狱自动化脚本怎么写:从环境判断到跑通第一个脚本
运维·ios·自动化
程序猿老A1 小时前
GPU云服务器怎么安装AI
运维·服务器·人工智能
小白的码BUG之路1 小时前
Docker -- No space left on device
运维·docker·容器
‎ദ്ദിᵔ.˛.ᵔ₎1 小时前
linux 进程间通信
linux·网络
Ruiery1 小时前
Linux 6.6内核 CPU 深度解析(八):percpu 变量 — 每个 CPU 一份数据,无锁访问
linux·运维·服务器
91刘仁德1 小时前
传输层协议深度拆解:端口寻址、UDP 报文与 TCP 可靠性机制全解析
linux·网络·c++·网络协议·tcp/ip·udp·c