第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)。进程组是一组相关进程的集合,它们可以一起接收信号。
#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,这个进程称为进程组组长。
#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) - 进程组组长终止后,进程组仍然存在(直到组内所有进程终止)
- 进程只能加入自己或其子进程创建的进程组
- 子进程继承父进程的进程组
#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)创建。
#include <unistd.h>
pid_t setsid(void); // 创建新会话
pid_t getsid(pid_t pid); // 获取会话IDsetsid调用后:
调用进程成为新会话的首进程
调用进程成为新进程组的首进程
调用进程脱离控制终端(如果有的话)
限制:调用setsid的进程不能是进程组组长,否则返回-1(EPERM)。
#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信号被暂停。
#include <unistd.h>
#include <fcntl.h>
pid_t tcgetpgrp(int fd); // 获取前台进程组ID
int tcsetpgrp(int fd, pid_t pgrp); // 设置前台进程组ID#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 创建守护进程的标准步骤
#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命令查看进程树:
# 查看进程树
ps -ef --forest
# 或使用pstree命令
pstree -p三、实战案例
3.1 查看进程组和会话信息
#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 简单的作业控制实现
#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 完整的守护进程实现
#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文件管理、信号处理和日志记录。