Skip to content

第二十八章:进程家族

筑基中期

涉及内核源码:

林小源在观察 shell 小妹的过程中,发现了一个他之前忽略的结构。

进程不是孤立的。它们有"家族"——进程组和会话。

shell 小妹执行一条命令时,fork 出来的子进程和 shell 属于同一个进程组。进程组有一个组长(通常是 shell),组长的 PID 就是进程组 ID(PGID)。

c
/*
 * 进程的组织结构:
 * 会话 (Session)
 *   └── 前台进程组 (Foreground Process Group)
 *       └── 进程 (Process)
 *   └── 后台进程组 (Background Process Group)
 *       └── 进程 (Process)
 *
 * 关键系统调用:
 * setpgid(pid, pgid)  — 设置进程组
 * setsid()            — 创建新会话
 * tcsetpgrp(fd, pgid) — 设置前台进程组
 */

struct process {
    int pid;
    int pgid;       /* 进程组 ID */
    int sid;        /* 会话 ID */
    char comm[16];
};

struct session {
    int sid;
    int fg_pgid;    /* 前台进程组 */
    struct process *leader;
};

struct process_group {
    int pgid;
    struct process *procs[8];
    int nr_procs;
};

printf("=== 进程组与会话 ===\n\n");

/* shell 小妹的会话 */
struct process shell   = { .pid = 100, .pgid = 100, .sid = 100, .comm = "bash" };
struct process grep    = { .pid = 201, .pgid = 200, .sid = 100, .comm = "grep" };
struct process sort    = { .pid = 202, .pgid = 200, .sid = 100, .comm = "sort" };
struct process vim     = { .pid = 300, .pgid = 300, .sid = 100, .comm = "vim" };

struct session sess = {
    .sid = 100,
    .fg_pgid = 200,  /* grep | sort 是前台 */
    .leader = &shell,
};

printf("会话 SID=100 (leader: %s)\n", shell.comm);
printf("  前台进程组 PGID=200:\n");
printf("    PID %d (%s)\n", grep.pid, grep.comm);
printf("    PID %d (%s)\n", sort.pid, sort.comm);
printf("  后台进程组 PGID=300:\n");
printf("    PID %d (%s)\n", vim.pid, vim.comm);

printf("\n--- Ctrl+C 的旅程 ---\n");
printf("1. 用户按下 Ctrl+C\n");
printf("2. 终端驱动发送 SIGINT\n");
printf("3. SIGINT 发送给前台进程组的所有进程\n");
printf("   前台进程组 PGID=%d\n", sess.fg_pgid);
printf("   → PID %d (%s) 收到 SIGINT\n", grep.pid, grep.comm);
printf("   → PID %d (%s) 收到 SIGINT\n", sort.pid, sort.comm);
printf("   → PID %d (%s) 不受影响(后台)\n", vim.pid, vim.comm);

printf("\n--- fg/bg 命令 ---\n");
printf("fg %%vim:  把 vim 移到前台\n");
printf("  tcsetpgrp(STDIN, 300)\n");
printf("  前台进程组变为 PGID=300\n");
printf("  SIGWINCH → PID 300\n\n");
printf("bg %%vim:  把 vim 移到后台\n");
printf("  tcsetpgrp(STDIN, 100)\n");
printf("  前台进程组恢复为 shell\n");
printf("  vim 继续在后台运行\n");

进程也有"家族"和"领地"。

林小源在研究会话的过程中,理解了一个更深层的设计。

会话(Session)是进程组的"上级"。一个会话通常对应一个终端。会话的创建者是会话领袖(Session Leader)——通常是 shell。

会话领袖有一个特殊的角色:它负责管理终端。当终端关闭时,内核向会话领袖发送 。会话领袖收到 后,通常会向会话中的所有进程组发送 ,通知它们终端已经关闭。

c
/*
 * 会话领袖的职责:
 * 1. 创建会话(setsid())
 * 2. 打开终端,成为控制终端
 * 3. 管理前台进程组
 * 4. 处理终端关闭事件(SIGHUP)
 *
 * 控制终端 (Controlling Terminal):
 * - 每个会话最多有一个控制终端
 * - 终端的输入/输出与会话关联
 * - 前台进程组接收终端的输入
 */

struct terminal {
    int fd;
    int session_id;
    int fg_pgid;
};

struct session_leader {
    int pid;
    int sid;
    struct terminal *tty;
};

printf("=== 会话与终端 ===\n\n");

struct terminal tty = { .fd = 0, .session_id = 100, .fg_pgid = 200 };
struct session_leader shell = { .pid = 100, .sid = 100, .tty = &tty };

printf("会话领袖: PID %d, SID %d\n", shell.pid, shell.sid);
printf("控制终端: fd=%d\n", shell.tty->fd);
printf("前台进程组: PGID=%d\n\n", shell.tty->fg_pgid);

printf("--- 终端关闭时 ---\n");
printf("1. 用户关闭终端窗口\n");
printf("2. 内核检测到终端关闭\n");
printf("3. 内核向会话领袖 (PID %d) 发送 SIGHUP\n", shell.pid);
printf("4. 会话领袖向所有进程组发送 SIGHUP\n");
printf("5. 前台进程组 (PGID=200) 收到 SIGHUP\n");
printf("6. 进程退出\n\n");

printf("--- setsid() 的作用 ---\n");
printf("创建新会话:\n");
printf("  - 调用者成为会话领袖\n");
printf("  - 调用者成为新进程组的组长\n");
printf("  - 没有控制终端(除非后来打开)\n");
printf("\n");
printf("典型用法:守护进程(daemon)\n");
printf("  1. fork() → 子进程\n");
printf("  2. 父进程退出\n");
printf("  3. 子进程调用 setsid()\n");
printf("  4. 子进程与原终端脱离\n");
printf("  5. 子进程在后台独立运行\n");

这下他明白了——守护进程就是靠这套操作跟终端彻底"割席"的。

林小源想起了 cron 老伯——那个安静的定时任务守护进程。他就是通过 创建了自己的会话,与终端彻底脱离,然后在后台默默地运行。

"你见过 cron 老伯吗?"林小源问 shell 小妹。

"当然见过。"shell 小妹一边等待用户输入一边说,"他很安静。每天在后台运行,不和任何人说话。我有时候会收到他的信号——他让我执行定时任务。"

"他是怎么和终端脱离的?"

"fork 一次,父进程退出,子进程调用 。"shell 小妹说得轻描淡写,"这样他就不是任何会话的成员了。没有控制终端,没有 ,终端关闭也影响不到他。"

"听起来很孤独。"

shell 小妹想了想。"也许吧。但他选择了这条路。有些进程注定要离开终端,在后台默默运行。"

林小源在观察 shell 小妹管理进程组的过程中,看到了一个有趣的场景。

用户在 shell 中运行了一条管道命令:ls | grep foo | sort。shell 小妹 fork 了三个子进程,分别执行 lsgrep。这三个子进程属于同一个进程组(PGID 相同),但它们通过管道连接在一起。

"它们是一个家族吗?"林小源问。

"当然是。"shell 小妹说,"它们的 PGID 相同——都是我设置的。在终端看来,它们是一个整体。"

"那 Ctrl+C 呢?"

"Ctrl+C 会杀死整个前台进程组。"shell 小妹的语气变得严肃了一些。"终端驱动发送 ,不是发给某一个进程,而是发给前台进程组的所有进程。lsgrep——一个都跑不掉。"

"那后台的进程呢?"

"后台进程不受影响。"shell 小妹说,"只有前台进程组接收终端的输入信号。——都只发给前台。后台进程可以安全地继续运行。"

林小源看着那三个通过管道连接的子进程——它们共享同一个 PGID,命运绑定在一起。Ctrl+C 一按,全军覆没。

管道命令是一个"命运共同体"。

shell 小妹似乎看出了他的想法,补充道:"但这也是它们的力量。三个进程各司其职,通过管道协作,完成一个单独进程无法完成的任务。家族的意义就在于此——一起活着,一起战斗,必要时一起赴死。"


道藏笔记

内核启示

进程组和会话是进程的两级组织结构。

会话是上面那层,由 创建,会话领袖通常是 shell。每个会话最多挂一个控制终端,终端关闭时会话领袖会收到 。进程组是下面那层,由 设置——同一管道里的进程属于同一个进程组,组长的 PID 就是 PGID,前台进程组负责接收终端输入。

信号怎么分发呢?(Ctrl+C)、(Ctrl+\)、(Ctrl+Z)都只发给前台进程组,后台进程不受影响。 则是终端关闭时发给会话领袖,领袖再往下传。

守护进程的诞生有一套标准流程:先 让父进程退出,然后 创建新会话跟终端脱钩,再 一次防止会话领袖意外获得控制终端,最后改工作目录、关掉不需要的文件描述符。cron 老伯就是这样炼成的。

进程不是孤立的,它们有家族、有领地、有归属。


破关试炼

家族之试

本章讲进程家族和前台进程组时,Ctrl+C 最终会转化为哪一个信号?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道