9UNIX环境高级编程(第3版)

进程关系

进程组与会话

进程组会话作业控制

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第9章:进程关系(Process Relationships)

一、导读

1.1 本章学习目标

第9章讲解进程之间的组织关系:进程组、会话和作业控制。这些概念是shell实现管道、作业控制(Ctrl+Z暂停、fg/bg切换)的基础。在UNIX系统中,进程不是孤立存在的,它们被组织成进程组和会话,形成层次化的结构。理解这些关系,对于编写守护进程、理解shell行为、实现信号的正确分发至关重要。

本章的核心学习目标包括:

  • 理解进程组的概念:进程组ID、进程组组长、进程组的生命周期
  • 掌握会话的概念:会话首进程、会话与进程组的关系、控制终端
  • 学会使用setsid创建新会话:守护进程的标准创建步骤
  • 理解作业控制:前台进程组、后台进程组、信号分发
  • 掌握控制终端的操作:tcgetpgrp/tcsetpgrp获取/设置前台进程组
  • 实现守护进程:通过setsid脱离控制终端的标准方法

1.2 为什么本章如此重要

进程关系是UNIX进程模型的重要组成部分,它决定了信号如何分发、终端输入如何处理、以及shell如何管理多个任务。具体来说:

  • 信号分发:当你在终端按Ctrl+C时,SIGINT信号被发送给前台进程组的所有进程,而不仅仅是当前运行的程序
  • 作业控制:shell通过进程组和会话管理实现Ctrl+Z暂停、fg/bg切换等功能
  • 守护进程:服务器程序(如Web服务器、数据库)通常作为守护进程运行,需要脱离控制终端
  • 安全性:理解进程组可以防止信号被错误的进程接收

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

当你在终端按Ctrl+C时,信号是如何找到正确的进程并终止它的?

为什么shell可以管理多个后台任务?它是如何区分前台和后台进程的?

守护进程为什么要脱离控制终端?如果不脱离会有什么问题?

为什么创建守护进程需要两次fork?一次fork不够吗?

一个会话可以有多少个进程组?前台进程组可以有多少个?

当控制终端断开时(如关闭终端窗口),会发生什么?


二、核心概念详解

2.1 进程组

每个进程都属于一个进程组(process group)。进程组是一组相关进程的集合,它们可以一起接收信号。

c
#include <unistd.h>

pid_t getpgrp(void);          // 获取当前进程的进程组ID
pid_t getpgid(pid_t pid);     // 获取指定进程的进程组ID
int setpgid(pid_t pid, pid_t pgid);  // 设置进程的进程组

进程组由进程组ID(PGID)标识。进程组ID等于该组中某个进程的PID,这个进程称为进程组组长。

c
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("PID: %d\n", getpid());
    printf("PGID: %d\n", getpgrp());
    printf("PPID: %d\n", getppid());
    return 0;
}

2.1.1 进程组的特性

  • 进程组组长可以创建新进程组(使用setpgid)
  • 进程组组长终止后,进程组仍然存在(直到组内所有进程终止)
  • 进程只能加入自己或其子进程创建的进程组
  • 子进程继承父进程的进程组
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    printf("Parent: PID=%d, PGID=%d\n", getpid(), getpgrp());
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程创建新的进程组
        setpgid(0, 0);  // 将自己的PGID设为自己的PID
        printf("Child: PID=%d, PGID=%d\n", getpid(), getpgrp());
        
        // 孙进程继承子进程的进程组
        fork();
        printf("Grandchild: PID=%d, PGID=%d\n", getpid(), getpgrp());
        exit(0);
    }
    
    wait(NULL);
    return 0;
}

2.2 会话

会话(session)是一组进程组的集合。会话由会话首进程(session leader)创建。

c
#include <unistd.h>

pid_t setsid(void);    // 创建新会话
pid_t getsid(pid_t pid);  // 获取会话ID

setsid调用后:

调用进程成为新会话的首进程

调用进程成为新进程组的首进程

调用进程脱离控制终端(如果有的话)

限制:调用setsid的进程不能是进程组组长,否则返回-1(EPERM)。

c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    printf("Before setsid:\n");
    printf("  PID=%d, PGID=%d, SID=%d\n", 
           getpid(), getpgrp(), getsid(0));
    
    // 确保不是进程组组长
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程调用setsid
        setsid();
        
        printf("\nAfter setsid:\n");
        printf("  PID=%d, PGID=%d, SID=%d\n", 
               getpid(), getpgrp(), getsid(0));
        exit(0);
    }
    
    wait(NULL);
    return 0;
}

2.2.1 会话与控制终端

会话可以与一个控制终端(controlling terminal)关联。当终端有输入或输出时,信号被发送给会话的前台进程组。

  • 会话首进程打开一个终端设备时,该终端成为会话的控制终端
  • 一个会话最多有一个控制终端
  • 一个终端最多是一个会话的控制终端
  • 当终端断开(如关闭终端窗口),内核向会话首进程发送SIGHUP信号

2.3 作业控制

作业控制是shell提供的功能,允许用户在多个任务(作业)之间切换。每个作业对应一个进程组。

2.3.1 前台和后台进程组

在任意时刻,一个会话中只有一个前台进程组可以与控制终端交互(读取输入)。其他进程组在后台运行,如果尝试读取终端输入,会收到SIGTTIN信号被暂停。

c
#include <unistd.h>
#include <fcntl.h>

pid_t tcgetpgrp(int fd);     // 获取前台进程组ID
int tcsetpgrp(int fd, pid_t pgrp);  // 设置前台进程组ID
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
    int fd = STDIN_FILENO;
    pid_t fg = tcgetpgrp(fd);
    printf("Foreground process group: %d\n", fg);
    printf("My process group: %d\n", getpgrp());
    
    if (fg == getpgrp()) {
        printf("I am in the foreground\n");
    } else {
        printf("I am in the background\n");
    }
    return 0;
}

2.3.2 作业控制信号

信号来源默认动作
SIGTTIN后台进程组读取终端暂停
SIGTTOU后台进程组写入终端暂停
SIGTSTP终端停止字符(Ctrl+Z)暂停
SIGCONT继续执行继续

2.4 守护进程

守护进程(daemon)是在后台运行且不与控制终端关联的进程。UNIX系统有许多守护进程在后台运行(如sshd、crond、syslogd)。

2.4.1 创建守护进程的标准步骤

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

void daemonize() {
    // 步骤1:第一次fork
    pid_t pid = fork();
    if (pid < 0) exit(EXIT_FAILURE);
    if (pid > 0) exit(EXIT_SUCCESS);  // 父进程退出
    
    // 步骤2:创建新会话
    if (setsid() < 0) exit(EXIT_FAILURE);
    // 现在脱离了控制终端
    
    // 步骤3:捕获SIGHUP并第二次fork
    // 防止守护进程后来获取控制终端
    signal(SIGHUP, SIG_IGN);
    pid = fork();
    if (pid < 0) exit(EXIT_FAILURE);
    if (pid > 0) exit(EXIT_SUCCESS);
    
    // 步骤4:设置文件权限掩码
    umask(0);
    
    // 步骤5:更改工作目录
    chdir("/");
    
    // 步骤6:关闭不需要的文件描述符
    // 重定向标准I/O到/dev/null
    int devnull = open("/dev/null", O_RDWR);
    if (devnull != -1) {
        dup2(devnull, STDIN_FILENO);
        dup2(devnull, STDOUT_FILENO);
        dup2(devnull, STDERR_FILENO);
        if (devnull > 2) close(devnull);
    }
    
    // 步骤7:使用syslog记录日志
    // openlog("mydaemon", LOG_PID, LOG_DAEMON);
}

int main() {
    daemonize();
    
    // 守护进程的主循环
    while (1) {
        // 执行后台任务...
        sleep(60);
    }
    
    return 0;
}

2.4.2 为什么需要两次fork

第一次fork:

  • 父进程终止,子进程成为孤儿,被init收养
  • 子进程不再是进程组组长,可以调用setsid

setsid:

  • 创建新会话,脱离控制终端
  • 成为新会话的首进程和新进程组的首进程

第二次fork:

  • 会话首进程(第一次fork的子进程)可以重新打开控制终端
  • 第二次fork后,孙子进程不是会话首进程,无法获取控制终端
  • 孙子进程终止后,会话仍然存在(因为还有其他进程),但首进程已终止
初始状态:
  PID 100 (shell, session leader, process group leader)
    └── PID 200 (our program)

第一次fork后:
  PID 100 (shell)
    └── PID 200 (our program)
          └── PID 201 (child, will call setsid)
  
  PID 200 退出

setsid后:
  PID 100 (shell, session 1)
  
  PID 201 (session 2 leader, process group leader)
    └── PID 201 (controlling terminal: none)

第二次fork后:
  PID 201 (session 2 leader)
    └── PID 202 (daemon, NOT session leader, cannot acquire terminal)
  
  PID 201 退出

2.5 进程树

UNIX系统中的所有进程形成一个树状结构:

init (PID 1)
├── systemd-logind
├── sshd
│   └── sshd (child)
│       └── bash (session leader)
│           ├── vim
│           ├── gcc
│           └── make
│               └── cc1
├── cron
├── nginx (master)
│   ├── nginx (worker)
│   ├── nginx (worker)
│   └── nginx (worker)
└── mysql

可以使用ps命令查看进程树:

bash
# 查看进程树
ps -ef --forest

# 或使用pstree命令
pstree -p

三、实战案例

3.1 查看进程组和会话信息

c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void print_info(const char *name) {
    printf("%-12s PID=%-6d PGID=%-6d SID=%-6d PPID=%-6d\n",
           name, getpid(), getpgrp(), getsid(0), getppid());
}

int main() {
    print_info("Parent");
    
    pid_t pid1 = fork();
    if (pid1 == 0) {
        print_info("Child1");
        
        // 创建新进程组
        setpgid(0, 0);
        print_info("Child1(new pg)");
        
        exit(0);
    }
    
    pid_t pid2 = fork();
    if (pid2 == 0) {
        print_info("Child2");
        exit(0);
    }
    
    wait(NULL);
    wait(NULL);
    return 0;
}

3.2 简单的作业控制实现

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#include <fcntl.h>

#define MAX_JOBS 64

typedef struct {
    pid_t pgid;
    int status;  // 0=running, 1=stopped, 2=done
    char cmd[256];
} Job;

Job jobs[MAX_JOBS];
int njobs = 0;

void sigchld_handler(int sig) {
    int status;
    pid_t pid;
    while ((pid = waitpid(-1, &status, WNOHANG | WUNTRACED)) > 0) {
        for (int i = 0; i < njobs; i++) {
            if (jobs[i].pgid == pid || jobs[i].pgid == -pid) {
                if (WIFSTOPPED(status)) {
                    jobs[i].status = 1;
                    printf("\n[%d] Stopped  %s\n", i + 1, jobs[i].cmd);
                } else {
                    jobs[i].status = 2;
                    printf("\n[%d] Done     %s\n", i + 1, jobs[i].cmd);
                }
            }
        }
    }
}

int main() {
    signal(SIGCHLD, sigchld_handler);
    
    char line[256];
    while (1) {
        printf("shell> ");
        fflush(stdout);
        
        if (fgets(line, sizeof(line), stdin) == NULL) break;
        line[strcspn(line, "\n")] = '\0';
        if (strlen(line) == 0) continue;
        
        // 检查是否后台运行
        int bg = (line[strlen(line) - 1] == '&');
        if (bg) line[strlen(line) - 1] = '\0';
        
        // 解析命令
        char *args[64];
        int argc = 0;
        args[argc] = strtok(line, " \t");
        while (args[argc] && argc < 63) {
            argc++;
            args[argc] = strtok(NULL, " \t");
        }
        if (args[0] == NULL) continue;
        
        // 内置命令:jobs
        if (strcmp(args[0], "jobs") == 0) {
            for (int i = 0; i < njobs; i++) {
                if (jobs[i].status != 2) {
                    printf("[%d] %s  %s (PGID=%d)\n", i + 1,
                           jobs[i].status == 0 ? "Running" : "Stopped",
                           jobs[i].cmd, jobs[i].pgid);
                }
            }
            continue;
        }
        
        // 创建子进程
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程创建新进程组
            setpgid(0, 0);
            
            if (bg) {
                // 后台进程:重定向stdin
                int fd = open("/dev/null", O_RDONLY);
                dup2(fd, STDIN_FILENO);
                close(fd);
            }
            
            execvp(args[0], args);
            perror(args[0]);
            _exit(127);
        }
        
        // 父进程设置进程组
        setpgid(pid, pid);
        
        if (bg) {
            printf("[%d] %d\n", njobs + 1, pid);
            jobs[njobs].pgid = pid;
            jobs[njobs].status = 0;
            strncpy(jobs[njobs].cmd, args[0], sizeof(jobs[njobs].cmd) - 1);
            njobs++;
        } else {
            // 前台进程:等待完成
            int status;
            waitpid(pid, &status, 0);
        }
    }
    
    return 0;
}

3.3 完整的守护进程实现

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <syslog.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <time.h>
#include <errno.h>

static volatile sig_atomic_t running = 1;

void signal_handler(int sig) {
    if (sig == SIGTERM || sig == SIGINT) {
        running = 0;
    }
}

int daemonize() {
    // 第一次fork
    pid_t pid = fork();
    if (pid < 0) return -1;
    if (pid > 0) return 1;  // 父进程返回1表示成功
    
    // 创建新会话
    if (setsid() < 0) return -1;
    
    // 忽略SIGHUP
    signal(SIGHUP, SIG_IGN);
    
    // 第二次fork
    pid = fork();
    if (pid < 0) return -1;
    if (pid > 0) exit(EXIT_SUCCESS);
    
    // 设置umask
    umask(0);
    
    // 更改工作目录
    if (chdir("/") < 0) return -1;
    
    // 关闭文件描述符并重定向
    int maxfd = sysconf(_SC_OPEN_MAX);
    for (int i = 0; i < maxfd; i++) {
        close(i);
    }
    
    int devnull = open("/dev/null", O_RDWR);
    if (devnull != -1) {
        dup2(devnull, STDIN_FILENO);
        dup2(devnull, STDOUT_FILENO);
        dup2(devnull, STDERR_FILENO);
        if (devnull > 2) close(devnull);
    }
    
    return 0;  // 子进程返回0表示继续运行
}

int main() {
    int ret = daemonize();
    if (ret < 0) {
        exit(EXIT_FAILURE);
    }
    if (ret > 0) {
        exit(EXIT_SUCCESS);  // 父进程退出
    }
    
    // 守护进程主逻辑
    openlog("mydaemon", LOG_PID | LOG_CONS, LOG_DAEMON);
    syslog(LOG_INFO, "Daemon started, PID=%d", getpid());
    
    // 设置信号处理
    signal(SIGTERM, signal_handler);
    signal(SIGINT, signal_handler);
    
    // 创建PID文件
    FILE *pidfile = fopen("/tmp/mydaemon.pid", "w");
    if (pidfile) {
        fprintf(pidfile, "%d\n", getpid());
        fclose(pidfile);
    }
    
    // 主循环
    while (running) {
        time_t now = time(NULL);
        syslog(LOG_INFO, "Daemon running, time=%s", ctime(&now));
        sleep(60);
    }
    
    syslog(LOG_INFO, "Daemon stopping");
    closelog();
    unlink("/tmp/mydaemon.pid");
    
    return 0;
}

四、本章小结

第9章讲解了进程之间的组织关系。核心知识点包括:

进程组:一组相关进程的集合,由PGID标识。进程组组长可以创建新进程组。信号可以发送给整个进程组。

会话:一组进程组的集合,由SID标识。会话首进程通过setsid创建。会话可以关联一个控制终端。

作业控制:shell通过前台/后台进程组管理多个任务。前台进程组可以与终端交互,后台进程组读取终端会被暂停。

控制终端:会话的控制终端断开时,向会话首进程发送SIGHUP。

守护进程:通过两次fork+setsid脱离控制终端。第二次fork防止守护进程获取控制终端。标准步骤包括:fork→setsid→fork→umask→chdir→关闭fd→重定向到/dev/null。


五、思考与练习

思考题

为什么setsid要求调用进程不能是进程组组长?如果允许,会导致什么问题?

当一个终端窗口被关闭时,该终端上的所有进程都会终止吗?为什么?

为什么后台进程写入终端时会被暂停(SIGTTOU)?这个设计的目的是什么?

守护进程的日志应该写到哪里?为什么不能写到标准输出?

编程练习

编写一个程序,打印当前终端的所有进程组和会话信息。

实现一个简化版的nohup命令:运行指定命令,忽略SIGHUP信号。

编写一个程序,创建一个新会话并验证它已脱离控制终端。

实现一个完整的守护进程,包含PID文件管理、信号处理和日志记录。


9/12进程关系
0%