Unix 学习笔记:进程间通信、高级 I/O、守护进程、线程控制、线程、信号、进程关系、进程控制、进程环境、标准 I/O 库与系统数据文件
来源:本次会话学习总结
覆盖内容:Unix 标准 I/O 库(第 5 章)、流(Stream)、内存流、系统数据文件与信息(第 6 章)、进程环境(第 7 章)、进程控制(第 8 章)、进程关系(第 9 章)、信号(第 10 章)、线程(第 11 章)、线程控制(第 12 章)、守护进程(第 13 章)、高级 I/O(第 14 章)、进程间通信(第 15 章)
目录
- Unix 标准 I/O 库
- 什么是流(Stream)
- 什么是内存流(Memory Streams)
- 系统数据文件与信息(第 6 章)
- 进程环境(第 7 章)
- 进程控制(第 8 章)
- 进程关系(第 9 章)
- 信号(第 10 章)
- 线程(第 11 章)
- 线程控制(第 12 章)
- 守护进程(第 13 章)
- 高级 I/O(第 14 章)
- 进程间通信(第 15 章)
一、Unix 标准 I/O 库
1.1 概述
标准 I/O 库(Standard I/O Library)是 C 语言标准库的一部分(stdio.h),在 Unix 上它是对系统调用 I/O(open/read/write/close)的一层封装,核心特点是带缓冲。
1.2 与系统 I/O 的对比
| | 系统调用 I/O | 标准 I/O |
|---|
| 头文件 | <unistd.h> | <stdio.h> |
| 操作对象 | 文件描述符 int fd | 流 FILE *fp |
| 缓冲 | 无缓冲 | 有缓冲(默认) |
| 常用函数 | read/write | fread/fwrite/fgets/printf |
| 跨平台 | 仅 POSIX | C 标准,跨平台 |
1.3 三种缓冲方式(关键概念)
- 全缓冲:缓冲区满了才写(普通文件默认)
- 行缓冲:遇到换行符才写(终端上
stdout 默认) - 无缓冲:立即写(
stderr 默认)
1.4 三个标准流
stdin、stdout、stderr,在程序启动时自动打开
1.5 常用函数族
- 打开/关闭:
fopen、fclose - 读字符:
getc/fgetc/getchar - 写字符:
putc/fputc/putchar - 行 I/O:
fgets/fputs - 格式化:
printf/fprintf/sprintf、scanf/fscanf/sscanf - 二进制:
fread/fwrite - 刷新:
fflush - 定位:
fseek/ftell/rewind - 缓冲控制:
setvbuf/setbuf
1.6 为什么标准 I/O 更快?
系统调用开销大(每次都要进入内核),标准 I/O 通过用户态缓冲区攒够一批再调一次 write。
// 对比:每字符一次系统调用 vs 缓冲区批量写入
for (int i = 0; i < 1000000; i++) {
write(STDOUT_FILENO, "a", 1); // 慢:100万次系统调用
fputc('a', stdout); // 快:攒满缓冲区才写一次
}
1.7 常见的坑
- 缓冲导致输出不及时——例如
printf 后直接 fork,缓冲区里的内容会被复制,可能输出两次。解决:fflush 或 setvbuf 关缓冲。 - 混用系统 I/O 和标准 I/O 要小心,需用
fdopen/fileno 转换,并注意缓冲同步(用 fflush,fsync 不适用)。 - 文本/二进制模式在 Unix 上无区别,在 Windows 上有区别(换行符转换)。
二、什么是流(Stream)
2.1 一句话理解
流 = 一条字节数据通道。程序通过流读写数据,流的底层绑定着一个文件描述符,标准 I/O 库在中间自动加上一层缓冲区。
程序 <——> FILE 流(含缓冲区) <——> 文件描述符 fd <——> 内核/文件
2.2 基本使用
#include <stdio.h>
int main(void) {
FILE *fp = fopen("test.txt", "r"); // 打开文件 -> 建立一条"输入流"
char buf[100];
fgets(buf, 100, fp); // 从流中读取一行
fclose(fp); // 关闭流
return 0;
}
2.3 流内部装了什么?
标准库维护的 FILE 结构体(实现相关)大致包含:
- 文件描述符(
int fd)—— 流与内核的桥梁 - 缓冲区指针和长度 —— 缓冲 I/O 的关键
- 当前读写位置(在缓冲区内)
- 错误标志、EOF 标志、读写方向标记(是读流还是写流)
// 示意(不同系统实现不同):
struct _FILE {
int _fd; // 底层文件描述符
char *buf_start; // 缓冲区起始
char *buf_ptr; // 当前读/写位置
int buf_len; // 缓冲区大小
int flags; // 错误/EOF/方向等标志
};
typedef struct _FILE FILE;
2.4 流的分类
按方向:只读流、只写流、读写流(fopen 的 "r"/"w"/"w+" 等模式决定)
按内容解释方式(fopen 的 b 标志):
- 文本流:按文本处理,行尾可能被转换(Windows 下
\n ↔ \r\n) - 二进制流:字节原样读写
- 注意:在 Unix 上两者没有区别,因为换行符就是
\n
三个标准流(程序启动时自动打开):
stdin(标准输入)、stdout(标准输出)、stderr(标准错误)
2.5 流的缓冲控制
setvbuf(stdout, NULL, _IONBF, 0); // 把 stdout 设为无缓冲
setvbuf(fp, NULL, _IOLBF, 100); // 把 fp 设为行缓冲,缓冲区 100 字节
fflush(stdout); // 立即把缓冲区内容冲到内核
2.6 区分两个"流"概念
| 语境 | 含义 |
|---|
| 标准 I/O 的"流" | FILE *,带缓冲的用户态抽象 |
| 网络编程的"流" | 面向连接的字节流(如 TCP,SOCK_STREAM) |
两者名字相同但完全是两回事。
三、什么是内存流(Memory Streams)
3.1 概述
内存流(Memory Streams) 是 POSIX.1-2008 引入的一组函数,让标准 I/O 的读写不经过磁盘文件,而是在内存缓冲区上进行。属于 APUE 第 5 章的进阶内容。
3.2 三个核心函数
#define _GNU_SOURCE
#include <stdio.h>
// 1. fmemopen:在"你指定的固定缓冲区"上创建流
FILE *fp = fmemopen(buf, size, "r+");
// 2. open_memstream:动态增长缓冲区,fclose 后给你指针和大小
FILE *fp = open_memstream(&ptr, &size);
// 3. open_wmemstream:宽字符版
FILE *fp = open_wmemstream(&wptr, &wsize);
3.3 用法示例
fmemopen —— 把内存当文件读/写:
#include <stdio.h>
#include <string.h>
int main(void) {
char data[] = "hello 123 456";
char out[64] = {0};
// 把 data 当作"文件"打开,可读可写
FILE *fp = fmemopen(data, strlen(data), "r");
char word[16];
while (fscanf(fp, "%s", word) != EOF)
printf("读到单词: %s\n", word);
fclose(fp);
// 写入:从偏移 6 开始写
FILE *wp = fmemopen(out, sizeof(out), "w");
fprintf(wp, "world %d", 2026); // 标准 I/O 函数全部可用
fclose(wp);
printf("结果: %s\n", out);
return 0;
}
open_memstream —— 自动增长的缓冲区(最常用):
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *buf = NULL;
size_t size = 0;
// 注意:写入总是从"当前位置"开始,且位置永远在末尾(追加语义)
FILE *fp = open_memstream(&buf, &size);
fprintf(fp, "name=%s ", "alice");
fprintf(fp, "age=%d ", 18);
fprintf(fp, "score=%.1f", 95.5);
fclose(fp); // 必须 fclose 后 buf/size 才是最终值
printf("拼接结果(%zu字节): %s\n", size, buf);
free(buf); // 别忘了释放
return 0;
}
3.4 特性与注意点
| 特性 | fmemopen | open_memstream |
|---|
| 缓冲区谁提供 | 你自己,大小固定 | 库动态分配,自动增长 |
| 写满怎么办 | 报错/截断 | 自动扩容 |
| 释放 | 不需要(缓冲区是你的) | 必须 free() |
| 当前读写位置 | 可 fseek/ftell | 写位置恒在末尾 |
| 缓冲行为 | 全缓冲,需要 fflush/fclose 才生效 | 同上 |
共同点/坑:
- 没有文件描述符——
fileno() 对它无效,read/write 系统调用也不能用 - 都是全缓冲,必须
fflush 或 fclose 后数据才真正进入缓冲区 - 适合把"解析内存数据"写成熟悉的文件 I/O 风格
3.5 典型用途
- 拼接字符串:比手动
strcat/sprintf 安全,天然防溢出(open_memstream 自动扩容) - 解析内存数据:从网络收到的报文、解压后的数据,直接用
fscanf/fgets 解析,不用先写临时文件 - 序列化/反序列化:把结构体格式化到内存再发送
// 示例:解析内存中的 CSV 数据,全程不碰磁盘
FILE *fp = fmemopen(csv_data, csv_len, "r");
while (fgets(line, sizeof(line), fp)) {
// 逐行处理...
}
四、系统数据文件与信息(第 6 章)
主题:系统数据文件 与 时间与日期,是 APUE 第 6 章内容。
4.1 口令文件 /etc/passwd
存储用户账户信息,一行一条,7 个字段用冒号分隔:
root:x:0:0:root:/root:/bin/bash
alice:x:1000:1000:Alice:/home/alice:/bin/bash
字段含义:用户名 : 口令 : UID : GID : 注释 : 家目录 : shell
常用 API:
#include <pwd.h>
struct passwd *getpwuid(uid_t uid); // 按 UID 查
struct passwd *getpwnam(const char *name); // 按用户名查
struct passwd *getpwent(void); // 逐条遍历(配合 setpwent/endpwent)
4.2 阴影口令 /etc/shadow
加密口令不放在 passwd(全局可读)里,而是放到仅 root 可读的 /etc/shadow:
#include <shadow.h>
struct spwd *getspnam(const char *name); // 需要 root 权限
4.3 组文件 /etc/group
#include <grp.h>
struct group *getgrgid(gid_t gid);
struct group *getgrnam(const char *name);
struct group *getgrent(void);
4.4 用户与用户组的关系(一对多)
Unix 里用户(user)和组(group)是多对多关系,但每个用户有严格结构:1 个主组 + N 个附加组。
┌─── 主组(primary group): 1 个,必需
用户 alice ────────┤
└─── 附加组(supplementary groups): 0~N 个,可选
│
├── group1
├── group2
└── group3
主组 —— 记录在 /etc/passwd 的第 4 字段:
alice:x:1000:1000:Alice:/home/alice:/bin/bash
└─┘
主组 GID = 1000(组 alice)
每个用户必须且只能有一个主组,登录后默认的有效组 ID 就是它。
附加组 —— 记录在 /etc/group 的 gr_mem 成员列表:
webdev:x:1001:alice,bob,carol
└────────┘
属于该组的用户列表(组内除主成员外的附加成员)
struct group {
char *gr_name; /* 组名 */
char *gr_passwd; /* 组口令 */
gid_t gr_gid; /* 组 ID */
char **gr_mem; /* 属于该组的用户名字符串数组(附加成员) */
};
关键: gr_mem 只列"附加成员"。alice 的主组是 alice 组,但她额外加入了 webdev、docker、sudo 等组——这就是"一个用户对多个组"。
命令行验证:
$ id alice
uid=1000(alice) gid=1000(alice) groups=1000(alice),27(sudo),1001(webdev),999(docker)
└─ 主组 ─┘ └──────── 附加组 ────────┘
$ groups alice
alice sudo webdev docker
查询进程的全部组(代码):
#include <unistd.h>
#include <grp.h>
int main(void) {
gid_t groups[32];
int n = getgroups(32, groups); // 返回"主组 + 附加组"全部 ID
for (int i = 0; i < n; i++)
printf("组 ID: %d\n", groups[i]);
return 0;
}
注意:getgroups 返回的数组里包含主组 GID(Linux 上的行为),一次调用即可拿到用户的全部组。
管理命令:
# 把一个用户加入多个附加组(一个组一个组加)
usermod -aG sudo alice # alice 加入 sudo 组
usermod -aG docker alice # alice 加入 docker 组
usermod -aG webdev alice # alice 加入 webdev 组
-aG 是追加(append),不带 -a 会覆盖掉原有的附加组!这是最常见的坑。
反向关系(一个组对多个用户): 一个组也可以包含多个用户:
组 webdev ──────── alice
├─────── bob
└─────── carol
这在文件权限上很实用:chown :webdev file 把文件属组设为 webdev,组内所有用户都能按组权限位访问。
权限判断逻辑(为什么一对多有意义): 进程访问文件时,内核判断"是否按组权限访问":
进程的有效组 ID(egid) == 文件的属组 GID ?
或者 进程的任一附加组 ID == 文件的属组 GID ?
↓ 是
按文件"组权限位"处理(rwx 中间三位)
// 简化示意
bool can_access_as_group(struct inode *file, int egid, gid_t *sup, int n) {
if (egid == file->gid) return true; // 主组匹配
for (int i = 0; i < n; i++)
if (sup[i] == file->gid) return true; // 附加组匹配
return false;
}
总结表:
| 概念 | 数量 | 记录位置 | 用途 |
|---|
| 主组 | 1 个(必需) | /etc/passwd 第 4 字段 | 登录默认身份 |
| 附加组 | 0~N 个(可选) | /etc/group 的 gr_mem | 共享文件/目录访问权限 |
| 组内用户 | 0~N 个 | /etc/group 的 gr_mem | 批量授权 |
核心一句话: 用户与组是多对多关系;每个用户有一个固定主组(记录在 passwd),外加若干附加组(记录在 group 的成员列表),两者一起决定进程能按哪些组的权限访问文件。
附加组查询 API:
#include <unistd.h>
int getgroups(int gidsetsize, gid_t grouplist[]); // 查询
int setgroups(int n, const gid_t grouplist[]); // 设置(root)
int initgroups(const char *user, gid_t group); // 初始化
4.5 其他数据文件(统一 API 模式)
几乎所有系统数据文件都遵循同一套函数命名规律:
| 数据文件 | 主查询函数 | 遍历函数 |
|---|
/etc/passwd | getpwnam/getpwuid | getpwent/setpwent/endpwent |
/etc/group | getgrnam/getgrgid | getgrent/setgrent/endgrent |
/etc/hosts | gethostbyname/gethostbyaddr | sethostent/endhostent |
/etc/networks | getnetbyname/getnetbyaddr | getnetent/setnetent/endnetent |
/etc/protocols | getprotobyname/getprotobynumber | getprotoent/setprotoent/endprotoent |
/etc/services | getservbyname/getservbyport | getservent/setservent/endservent |
规律:getXXX 返回单条记录;getXXXent 遍历;setXXXent 回到开头;endXXXent 关闭文件。
4.6 登录记账
/var/run/utmp:当前登录的用户(who 命令读它)/var/log/wtmp:历次登录/注销记录(last 命令读它)- 结构体
struct utmp,通过 getutent 系列函数读取
4.7 系统标识
#include <sys/utsname.h>
int uname(struct utsname *name); // 内核名、主机名、版本、release、machine
#include <unistd.h>
int gethostname(char *name, int namelen); // 仅主机名
4.8 时间与日期
时间的数据类型:
time_t(日历时间):从 1970-01-01 00:00:00 UTC 至今的秒数clock_t(进程时间):CPU 时间struct timespec / struct timeval:秒 + 纳秒/微秒,更精确
获取时间:
#include <time.h>
#include <sys/time.h>
time_t time(time_t *t); // 秒级日历时间
int clock_gettime(clockid_t cid, struct timespec *tp); // 纳秒级
int gettimeofday(struct timeval *tv, NULL); // 微秒级(常用)
clock_t clock(void); // 进程 CPU 时间
int clock_getres(...); // 时钟分辨率
时间转换(重点):
time_t(秒) ──localtime──> struct tm(本地)
│ │
└──gmtime──> struct tm(UTC) struct tm ──mktime──> time_t
│
strftime(格式化字符串)
time_t now = time(NULL);
struct tm *lt = localtime(&now); // 本地时间(注意返回值是静态区,不可重入)
// 格式化输出:2026-08-30 14:30:00
char buf[64];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", lt);
// 反解析:把字符串转成 time_t(输入解析)
struct tm tm2 = {0};
strptime("2026-08-30", "%Y-%m-%d", &tm2);
time_t t2 = mktime(&tm2);
几个易错点:
localtime/gmtime/ctime 返回静态存储区的指针,多线程下要用 localtime_r 等可重入版本time_t 受 2038 年问题影响(32 位系统),需用 64 位 time_t 或 timespecstrftime 的 %j(年积日)、%U(周数)等格式符很实用
4.9 本章核心工具函数速查
| 用途 | 函数 |
|---|
| 查用户信息 | getpwnam/getpwuid/getpwent |
| 查组信息 | getgrnam/getgrgid/getgrent |
| 附加组 | getgroups/initgroups |
| 系统标识 | uname/gethostname |
| 获取时间 | time/gettimeofday/clock_gettime |
| 转换时间 | localtime/gmtime/mktime |
| 格式化/解析 | strftime/strptime |
五、进程环境(第 7 章)
主题:C 程序从启动到终止的完整生命周期。进程环境(Process Environment)。
5.1 main 函数与进程启动
程序入口是 main,内核通过 exec 启动程序时,C 启动例程(crt0.o / crt1.o)先执行,完成初始化后调用 main:
int main(int argc, char *argv[]);
argc:命令行参数个数argv:参数字符串数组,argv[0] 是程序名,argv[argc] 恒为 NULL
5.2 进程终止(重点)
8 种终止方式:5 种正常 + 3 种异常
| 类型 | 方式 |
|---|
| 正常 | main 中 return |
| 正常 | 调用 exit() |
| 正常 | 调用 _exit() / _Exit() |
| 正常 | 最后一个线程的 return |
| 正常 | 最后一个线程调用 pthread_exit |
| 异常 | abort() |
| 异常 | 收到信号(如 SIGTERM、SIGINT) |
| 异常 | 最后一个线程响应取消请求 |
exit vs _exit 的区别(关键):
#include <stdlib.h>
#include <unistd.h>
void cleanup(void) { printf("清理函数被调用\n"); }
int main(void) {
atexit(cleanup); // 登记终止处理程序
printf("main 执行\n");
exit(0); // ① 调用 atexit 处理程序 + 刷新 stdio 缓冲
// _exit(0); // ② 直接进入内核,跳过上述步骤!
}
exit:先调用所有 atexit 处理函数 → 冲洗(fflush)所有标准 I/O 流 → 再调用 _exit_exit / _Exit:直接进入内核,跳过终止处理程序和缓冲冲洗
atexit 登记终止处理程序:
- 由
exit 调用,注册顺序与调用顺序相反(后进先出) - 最多可注册 32 个
#include <stdlib.h>
void atexit(void (*func)(void));
退出状态: exit(0) 或 return 0 表示正常结束;非 0 表示出错。shell 可用 echo $? 查看。
5.3 命令行参数与环境表
// 命令行参数:main 的 argv
int main(int argc, char *argv[]) { ... }
// 环境表:全局变量 environ
extern char **environ; // 指向指针数组,每个元素是 "NAME=value"
环境表就是字符串数组:
environ --> "HOME=/home/alice"
"PATH=/usr/bin:/bin"
"SHELL=/bin/bash"
NULL
环境变量操作 API:
#include <stdlib.h>
char *getenv(const char *name); // 查询
int putenv(char *str); // 设置 "NAME=value"(不复制,直接指向)
int setenv(const char *name, const char *value, int overwrite); // 设置
int unsetenv(const char *name); // 删除
5.4 C 程序的存储空间布局(重点)
高地址
+------------------+ 栈(向下增长,局部变量/函数调用)
| 栈 |
| ↓ |
|------------------|
| 空闲区域 |
|------------------|
| ↑ |
| 堆 | (malloc 从这里分配,向上增长)
|------------------|
| BSS(未初始化数据)| (bss 段,gcc 编译后自动清零)
|------------------|
| 已初始化数据段 | (data 段,如 int x = 1;)
|------------------|
| 文本段(只读) | (机器指令,text 段)
+------------------+ 低地址
| 段 | 内容 | 说明 |
|---|
| 文本段 | 机器指令 | 只读,可共享 |
| 初始化数据段 | 已初始化的全局/静态变量 | 如 int x = 10; |
| BSS 段 | 未初始化的全局/静态变量 | 如 int y;,不占磁盘空间,运行时清零 |
| 堆 | 动态分配的内存 | malloc/free,向上增长 |
| 栈 | 局部变量、函数调用 | 向下增长 |
用 size 命令可以查看各段大小:
$ size a.out
text data bss dec hex filename
1512 608 8 2128 850 a.out
5.5 共享库
- 编译期用
-static 静态链接(libc.a 全部拷入可执行文件) - 默认动态链接共享库(
libc.so),文件小、升级方便、内存可共享 gcc 默认使用动态链接,可用 ldd 查看依赖
5.6 存储空间分配(堆管理)
#include <stdlib.h>
void *malloc(size_t size); // 分配 size 字节,未初始化
void *calloc(size_t nobj, size_t size); // 分配并清零
void *realloc(void *ptr, size_t newsize); // 重新调整大小
void free(void *ptr); // 释放
常见错误:
- 忘记检查返回的 NULL(分配失败)
- 释放后继续使用(悬垂指针)
- 重复释放同一块内存
- 忘记释放 → 内存泄漏
- 分配/释放大小不匹配
底层机制:malloc 内部通过 sbrk/brk 系统调用(或 mmap)向内核要内存。每个 malloc 块前有头部记录大小,所以 free 时不用告诉大小。
5.7 setjmp 和 longjmp(非局部跳转)
用于跳过正常函数返回的机制,典型场景:深层嵌套中的错误处理。
#include <setjmp.h>
static jmp_buf jbuf;
int main(void) {
if (setjmp(jbuf) == 0) { // 第一次调用:返回 0,建立跳转点
printf("进入正常流程\n");
do_work(); // 可能出错
printf("正常结束\n");
} else { // longjmp 跳回来时:返回非 0
printf("错误发生,跳转回来\n");
}
return 0;
}
关键注意点(易错):
- 触发
longjmp 后,自动变量(非 volatile、非 register)的值不确定,可能已被"回滚" - 需使用
volatile 声明,确保值保持:
static volatile int flag; // 这样 longjmp 后值才可靠
- 函数调用栈会被破坏性"回卷",因此已经打开的流不会被自动关闭,已 malloc 的内存不会自动释放——需要自行处理
5.8 资源限制(getrlimit/setrlimit)
每个进程有资源限制,可通过 ulimit 命令查看:
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit *rlptr);
int setrlimit(int resource, const struct rlimit *rlptr);
struct rlimit {
rlim_t rlim_cur; // 软限制(当前)
rlim_t rlim_max; // 硬限制(上限)
};
常用资源:
| 常量 | 含义 |
|---|
RLIMIT_CPU | CPU 时间上限(秒) |
RLIMIT_FSIZE | 最大文件大小 |
RLIMIT_DATA | 数据段最大值 |
RLIMIT_STACK | 栈最大值 |
RLIMIT_NOFILE | 最多打开文件数 |
RLIMIT_AS | 虚拟内存总量 |
规则:软限制可提高到不超过硬限制;硬限制只能降不能升(普通用户)。
5.9 本章速查表
| 主题 | 关键内容 |
|---|
| 进程启动 | C 启动例程 → main |
| 进程终止 | exit/_exit/atexit |
| 环境表 | environ、getenv/setenv |
| 存储布局 | 文本/数据/BSS/堆/栈 |
| 内存分配 | malloc/calloc/realloc/free |
| 非局部跳转 | setjmp/longjmp + volatile |
| 资源限制 | getrlimit/setrlimit |
六、进程控制(第 8 章)
主题:进程的创建、执行和终止。三大主角:fork、exec、wait。进程控制(Process Control)。
6.1 进程标识符
#include <unistd.h>
pid_t getpid(void); // 进程 ID
pid_t getppid(void); // 父进程 ID
uid_t getuid(void); // 实际用户 ID
uid_t geteuid(void); // 有效用户 ID
gid_t getgid(void); // 实际组 ID
gid_t getegid(void); // 有效组 ID
进程 ID 特点: 每个进程唯一、非负、可复用;PID 0 是调度进程(交换进程),PID 1 是 init 进程(孤儿进程的收养者)。
6.2 fork 函数(重点)
创建新进程的唯一途径:
#include <unistd.h>
pid_t fork(void);
返回值(关键):
- 父进程中返回子进程的 PID(> 0)
- 子进程中返回 0
- 出错返回 -1
#include <stdio.h>
#include <unistd.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork 失败");
} else if (pid == 0) {
printf("我是子进程,PID=%d,父进程 PID=%d\n", getpid(), getppid());
} else {
printf("我是父进程,PID=%d,我创建的子进程 PID=%d\n", getpid(), pid);
}
return 0;
}
fork 的行为:
- 子进程是父进程的副本(复制数据段、BSS、堆、栈),但文本段共享
- Linux 采用写时复制(Copy-On-Write):fork 后不立即复制物理内存,只有写入时才复制,效率高
- fork 之后父子进程的执行顺序不确定(由内核调度决定)——这是竞态条件的根源
父子进程的区别:
| 相同 | 不同 |
|---|
| 全局变量副本(值相同) | 返回的 fork 值不同 |
| 打开的文件描述符 | PID / PPID 不同 |
| 进程组、会话 | 未决信号不同 |
| 环境、资源限制 | 定时器(alarm 不继承) |
关键点:父、子进程各自拥有文件描述符的副本,共享同一个文件偏移量(file table entry 共享),所以子进程写文件会影响父进程的读写位置。
典型用途:
- 父进程复制自己,父子执行不同代码段(网络服务器:父进程 accept,子进程处理)
- 进程要执行另一个程序(fork 后 exec)
6.3 vfork 函数
fork 的早期变体,与 fork 的区别:
- 不复制父进程的地址空间,子进程在父进程空间中运行
- 父进程被阻塞,直到子进程调用
exec 或 exit - 保证子进程先运行(fork 不保证)
pid_t vfork(void);
现代系统不建议用 vfork,除非对性能极端敏感(如 exec 前,因写时复制已让 fork 很快)。
6.4 wait 和 waitpid(重点)
问题:子进程终止后,如果父进程不调用 wait,子进程会变成僵尸进程(已死但残留进程表项)。而子进程先于父进程终止且父进程没 wait,子进程会被 init 收养并自动回收。
#include <sys/wait.h>
pid_t wait(int *statloc); // 阻塞等待任一子进程
pid_t waitpid(pid_t pid, int *statloc, int options); // 指定子进程
wait:等任意一个子进程,没有就阻塞waitpid:可指定 PID、可设置选项(WNOHANG 非阻塞轮询)、可等待特定子进程
判断终止状态的宏(关键):
| 宏 | 含义 |
|---|
WIFEXITED(status) | 是否正常终止 |
WEXITSTATUS(status) | 正常终止的退出码 |
WIFSIGNALED(status) | 是否被信号杀死 |
WTERMSIG(status) | 杀死它的信号编号 |
WIFSTOPPED(status) | 是否被暂停 |
WSTOPSIG(status) | 暂停它的信号 |
int status;
pid_t pid = wait(&status);
if (WIFEXITED(status))
printf("子进程 %d 正常退出,退出码 %d\n", pid, WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("子进程 %d 被信号 %d 杀死\n", pid, WTERMSIG(status));
孤儿进程 vs 僵尸进程:
- 孤儿进程:父进程先死,被 init 收养,无危害
- 僵尸进程:子进程死了父进程没 wait,占着进程表项,有危害。解决:父进程
wait 或忽略 SIGCHLD
wait 实战(4 个典型场景):
配套可运行示例见 examples/ 目录(wait_basic.c/wait_multi.c/wait_waitpid.c/wait_sigchld.c)。
① 基本用法(等一个子进程):
int status;
pid_t ret = wait(&status); // 阻塞等任意子进程
if (WIFEXITED(status)) // 先判断,再取退出码
printf("退出码 = %d\n", WEXITSTATUS(status));
else if (WIFSIGNALED(status))
printf("被信号 %d 杀死\n", WTERMSIG(status));
② 多个子进程(循环 wait): wait 一次只收一个,收 N 次要循环 N 次:
int status;
pid_t pid;
while ((pid = wait(&status)) > 0) // 收完所有子进程后返回 -1
printf("回收了子进程 %d\n", pid);
注意:子进程里用 _exit 而不是 return/exit,避免刷新父进程 stdio 缓冲区导致输出重复。
③ waitpid 指定子进程 + 非阻塞轮询:
pid_t ret = waitpid(pid2, &status, 0); // 精确等 pid2,阻塞
while ((ret = waitpid(pid1, &status, WNOHANG)) == 0) // 非阻塞轮询 pid1
/* 还没结束,继续做别的事 */;
waitpid 的 pid 参数:
| pid 值 | 等待对象 |
|---|
pid > 0 | 指定 PID 的子进程 |
pid == -1 | 任意子进程(同 wait) |
pid == 0 | 同进程组的任意子进程 |
pid < -1 | 指定进程组(\ | pid\ | )内的任意子进程 |
options 参数:0 阻塞;WNOHANG 非阻塞(没结束立即返回 0);WUNTRACED 捕获暂停的子进程;WCONTINUED 捕获从暂停恢复的子进程。
④ 优雅防僵尸(SIGCHLD 信号自动回收):
void sigchld_handler(int sig) {
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) // 循环回收
printf("回收子进程 %d\n", pid);
}
struct sigaction sa = {0};
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sigaction(SIGCHLD, &sa, NULL); // 注册信号处理
最简单的防僵尸招数:signal(SIGCHLD, SIG_IGN);——内核自动回收,父进程无需 wait。
常见错误:
wait 返回 -1 且 errno == ECHILD 表示没有子进程可等,循环条件用 > 0 而不是 >= 0- 子进程别用
return,会多走父进程清理逻辑,用 _exit WEXITSTATUS 只在 WIFEXITED 为真时有效,判断顺序别反- 多子进程时
wait 谁先结束先收谁(非创建顺序)
6.5 exec 函数族(重点)
exec 并不创建新进程,而是用新的程序替换当前进程的映像(代码、数据、堆栈都被替换,但 PID 不变)。
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ...); // l: 参数列表
int execv(const char *pathname, char *const argv[]); // v: 参数数组
int execlp(const char *file, const char *arg0, ...); // p: 用 PATH 搜索
int execvp(const char *file, char *const argv[]);
int execle(const char *pathname, const char *arg0, ..., char *const envp[]); // e: 指定环境
int execve(const char *pathname, char *const argv[], char *const envp[]); // 真正的系统调用
记忆口诀:
l = list(参数一个一个列)v = vector(参数用数组)p = PATH(按 PATH 搜索可执行文件,否则用绝对/相对路径)e = environment(自己指定环境表)
// fork + exec 是"运行另一个程序"的标准套路
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid == 0) {
execl("/bin/ls", "ls", "-l", NULL); // 子进程替换为 ls
perror("execl 失败"); // 只有失败才会执行到这里
return 1;
}
wait(NULL); // 父进程回收子进程
return 0;
}
注意:exec 成功后不返回(程序被替换了);只有失败才返回 -1。所以 exec 后面紧跟错误处理是惯例。
6.6 竞态条件(Race Condition)
fork 后父子进程谁先运行不确定。如果需要子进程先执行完(如等它 exec),标准做法是父进程 wait 等子进程退出,而不是猜测时序:
if (pid == 0) {
// 子进程要做的初始化
} else {
waitpid(pid, NULL, 0); // 父进程等子进程,避免竞态
}
6.7 更改用户 ID 和组 ID
#include <unistd.h>
int setuid(uid_t uid); // 设置实际/有效/保存 ID
int setgid(gid_t gid);
int seteuid(uid_t uid); // 只改有效 ID
int setegid(gid_t gid);
int setreuid(uid_t ruid, uid_t euid); // 同时设置实际+有效
int setregid(gid_t rgid, gid_t egid);
三个 ID 概念:
- 实际 ID(real UID):你是谁——登录时确定的身份,进程运行期间通常不变
- 有效 ID(effective UID):你现在有什么权限——内核检查权限时看的是它(通常等于实际 ID,setuid 程序执行时变为文件属主)
- 保存的设置用户 ID(saved set-user-ID):exec 后保留,用于在特权/非特权之间切换
实际 ID vs 有效 ID(重点区分):
实际 ID(身份) 有效 ID(权限)
alice(1000) ──→ alice(1000) ← 普通进程,两者相同
alice(1000) ──→ root(0) ← 运行 setuid 程序时,两者不同!
为什么需要两个 ID? 以 passwd 命令为例:它要修改仅 root 可写的 /etc/shadow。文件拥有 setuid 权限位(rwsr-xr-x 中的 s),普通用户执行时有效 ID 被临时置为文件属主(root),从而获得写权限;而实际 ID 始终是 alice,身份没变。
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 63960 /usr/bin/passwd ← s 权限位(setuid)
| ID | 含义 | 怎么变 |
|---|
| 实际 ID(real) | 登录身份,你是谁 | setuid(仅 root 可任意改) |
| 有效 ID(effective) | 决定权限,内核查它 | seteuid/setuid/exec setuid 程序 |
| 保存的设置用户 ID(saved) | 执行 setuid 程序时的有效 ID 快照 | 仅由 exec 设置,用于"临时降权再恢复" |
最小权限原则的典型写法(先特权操作,后放弃特权):
// 假设有效 ID = 0(root),实际 ID = 1000(alice)
seteuid(0); // 临时获得 root 权限(保存的 ID 允许)
/* 执行特权操作,如写 /etc/shadow */
seteuid(getuid()); // 权限降回 alice,防止后续被利用
快速验证:
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("实际 UID = %d, 有效 UID = %d\n", getuid(), geteuid());
return 0;
}
- 普通运行:
实际 1000, 有效 1000 - 给程序加上 setuid 位(属主 root)后运行:
实际 1000, 有效 0
类比: 实际 ID 像你的身份证(你是谁,不变);有效 ID 像你当前带的工牌(权限等级,可能临时变)。
典型应用: setuid 程序(如 passwd)启动时有效 ID = root,完成特权操作后用 seteuid(getuid()) 放弃特权。
6.8 其他内容
解释器文件(shebang): 以 #! 开头的脚本文件,如 #!/bin/bash。内核识别后调用解释器执行,exec 可执行它们。
system 函数: 执行 shell 命令字符串的简单接口:
#include <stdlib.h>
int system(const char *cmdstring);
进程会计(accounting): 内核记录进程使用资源(CPU 时间、内存等),getacct/acct 相关,accton 开启。
进程调度: 改变进程优先级:
#include <sys/resource.h>
int nice(int incr); // 调整 nice 值
int getpriority(int which, id_t who);
int setpriority(int which, id_t who, int value);
进程时间: times 函数获取进程及子进程的 CPU 时间(与第 7 章 clock 相关)。
6.9 本章速查表
| 主题 | 关键函数 |
|---|
| 获取 ID | getpid/getppid/getuid/geteuid |
| 创建进程 | fork/vfork |
| 等待子进程 | wait/waitpid |
| 替换程序 | execl/execv/execlp/execvp/execle/execve |
| 改用户/组 | setuid/setgid/seteuid/setreuid |
| 执行命令 | system |
| 调度 | nice/getpriority/setpriority |
| 时间 | times |
七、进程关系(第 9 章)
主题:进程组、会话、控制终端、作业控制——进程之间如何组织、shell 如何管理前后台作业。进程关系(Process Relationships)。
7.1 核心概念层级(重点)
┌─────────────────────────────────────────┐
│ 会话 (Session) │
│ ┌───────────────────────────────────┐ │
│ │ 进程组 (Process Group) 组长 │ │
│ │ └── 进程 │ │
│ │ └── 进程 │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 进程组 (后台) │ │
│ │ └── 进程 │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
三层结构:进程 → 进程组 → 会话
进程组(Process Group):
#include <unistd.h>
pid_t getpgrp(void); // 获取进程组 ID(= 组长 PID)
int setpgid(pid_t pid, pid_t pgid); // 加入/创建进程组
pid_t getpgid(pid_t pid);
- 每个进程属于且仅属于一个进程组
- 进程组 ID = 组首进程(组长)的 PID
- 组长的 PID 就是组 ID,组长进程可以创建组、接收信号;组长死了组还在(只要还有组员)
- 进程调用
setpgid 可加入已有组或创建新组,子进程只能设置自己或子进程的组
会话(Session):
#include <unistd.h>
pid_t setsid(void); // 创建新会话(调用进程成为会话首进程 + 组长)
pid_t getsid(pid_t pid);
setsid 的前提与效果:
- 前提:调用进程不能是进程组组长
- 效果:① 成为新会话首进程 ② 成为新进程组组长 ③ 没有控制终端(脱离终端)
控制终端(Controlling Terminal):
- 会话首进程第一次打开一个终端设备时,该终端成为会话的控制终端
- 建立连接的进程叫控制进程(通常是登录 shell)
- 控制终端上能产生前台进程组和若干后台进程组
会话
├── 控制进程(登录 shell)
├── 前台进程组(当前能读写终端)
├── 后台进程组 1
└── 后台进程组 2
前后台切换 API:
#include <unistd.h>
#include <termios.h>
pid_t tcgetpgrp(int fd); // 查询终端的前台进程组
int tcsetpgrp(int fd, pid_t pgrpid); // 把某进程组设为前台
pid_t tcgetsid(int fd); // 获取会话首进程 PID
7.2 作业控制(Job Control,重点)
shell 的 Ctrl+Z、fg、bg、jobs 就是靠这套机制实现的。
作业控制信号:
| 信号 | 触发 | 默认行为 |
|---|
SIGINT | Ctrl+C | 终止前台进程组 |
SIGQUIT | Ctrl+\ | 终止并产生 core |
SIGTSTP | Ctrl+Z | 暂停前台进程组 |
SIGCONT | fg/bg/kill -CONT | 继续被暂停的作业 |
SIGTTIN | 后台进程读终端 | 暂停后台进程(通知用户) |
SIGTTOU | 后台进程写终端 | 暂停后台进程(可忽略) |
三条铁律:
- 只有前台进程组能读终端(后台进程读 → 收到
SIGTTIN 被暂停) - 只有前台进程组能写终端(后台进程写 → 收到
SIGTTOU) - 终端产生的信号(
Ctrl+C 等)只发给前台进程组
shell 交互演示:
$ sleep 100 & # 后台作业
[1] 1234
$ jobs # 查看作业
[1]+ Running sleep 100 &
$ fg %1 # 调到前台
$ Ctrl+Z # 暂停,变后台暂停状态
[1]+ Stopped sleep 100
$ bg %1 # 后台继续运行
7.3 终端登录与网络登录
终端登录流程(Linux): 内核 → init → getty(显示 login:)→ login(验证口令)→ 登录 shell
init → getty → login → shell(登录 shell,控制进程)
网络登录流程: inetd/xinetd 监听端口 → 收到连接 → fork + exec(telnetsd 等)→ 分配伪终端(pty)→ 在伪终端上运行登录流程
网络连接 → inetd → fork/exec 守护进程 → 打开伪终端 → login → shell
关键点:伪终端对进程来说就像普通终端,所以网络登录也能做作业控制。
7.4 shell 执行程序的方式
交互式 shell 每执行一条命令都会 fork 一个子进程,并把它放进一个新进程组(每条命令一个组)。管道命令更是如此:
$ ps -j # 观察进程组
PID PPID PGID SESS TT
123 122 123 ??? pts/0 # 登录 shell(组长)
456 123 456 ??? pts/0 # 命令1(自己的组)
457 123 457 ??? pts/0 # 命令2(自己的组)
- 每个前台命令一个进程组,前台进程组 ID = 该命令第一个进程的 PID
- 这就是为什么
Ctrl+C 能一次杀掉整个管道中的所有进程(它们同组)
7.5 孤儿进程组(Orphaned Process Group)
定义: 进程组中所有成员的父进程要么属于其他组、要么已终止——即"组里没人能接收 SIGTTIN/SIGTTOU 了"。
POSIX.1 规则: 孤儿进程组里的暂停进程在组变孤儿时收到 SIGHUP + SIGCONT。
产生场景: 后台作业的父进程(shell)退出,把整个后台进程组留在终端。
// 典型:daemon 化进程——父进程 fork 后退出
if (fork() != 0) exit(0); // 父进程退出
setsid(); // 子进程成为会话首进程,脱离控制终端
7.6 本章速查表
| 主题 | 关键内容 |
|---|
| 进程组 | getpgrp/setpgid,组 ID = 组长 PID |
| 会话 | setsid/getsid,脱离终端 |
| 控制终端 | 前台/后台进程组,tcgetpgrp/tcsetpgrp |
| 作业控制 | SIGTTIN/SIGTTOU/SIGTSTP/SIGCONT |
| 登录流程 | init → getty → login → shell |
| 网络登录 | inetd + 伪终端 pty |
| 孤儿进程组 | SIGHUP + SIGCONT |
核心一句话: 进程按「进程 → 进程组 → 会话」组织,控制终端把会话分成前后台进程组,作业控制信号保证"只有前台能碰终端"——这就是 shell 作业控制(&、Ctrl+Z、fg/bg)的底层原理。
八、信号(第 10 章)
主题:信号机制——异步事件通知、处理、阻塞与屏蔽。信号(Signals)是全书最核心、最复杂的章节之一。
8.0 为什么需要信号(解决的什么问题)
核心动机: 进程无法预知外部事件何时发生,又不能一直傻等。没有信号就只能轮询(浪费 CPU 或错过事件),信号提供中断驱动方案:进程专心干活,事件来了由内核主动打断通知。
轮询方案(没有信号的苦日子):
while (1) {
if (用户按了 Ctrl+C) break; // 每毫秒检查一次?
if (时间到了) break;
// 检查本身浪费时间,还占 CPU
}
解决的具体问题:
| 问题 | 信号 | 场景 |
|---|
| 异步事件通知 | SIGINT/SIGALRM/SIGCHLD/SIGHUP | Ctrl+C、定时器、子进程退出、挂断 |
| 硬件错误通知 | SIGSEGV/SIGFPE/SIGILL/SIGBUS | 空指针、除零、非法指令(唯一通知途径) |
| 进程控制(跨进程) | kill(pid, SIGTERM/KILL/STOP/CONT) | 优雅退出、强制杀、暂停、继续 |
| 程序自我控制 | alarm + 信号 | 超时保护、防止 read 永久阻塞 |
信号 vs 其他机制(为什么不可替代):
| 机制 | 同步性 | 有无数据 | 适用 |
|---|
| 信号 | 异步(随时打断) | 无(只带信号编号) | 事件通知、异常、控制 |
| 管道/FIFO | 同步(读写阻塞) | 有数据流 | 大量数据传输 |
| 共享内存 | 同步(自己锁) | 有 | 高频数据交换 |
| 信号量/锁 | 同步 | 无 | 互斥与同步 |
类比: 轮询 = 每隔几分钟看手机有没有消息(浪费、可能漏);信号 = 来消息响铃打断你(异步通知);信号处理函数 = 你收到消息后的响应动作。
信号不是免费的——引入的复杂问题由配套机制解决:
| 信号带来的问题 | 解决方案 |
|---|
| 处理期间可能被新信号打断 | 阻塞信号集 sigprocmask |
| 处理中调用非安全函数会崩溃 | 可重入/异步信号安全函数 |
| 解除阻塞与等待之间有竞态窗口 | sigsuspend 原子操作 |
| 信号会打断慢系统调用 | SA_RESTART / EINTR 重试 |
| 处理函数不知在哪个点打断 | volatile sig_atomic_t 标记 |
总结: 信号存在的意义 = 让进程在不轮询、不阻塞的前提下,被动接收外部事件和异常通知,同时提供最轻量的进程控制手段(杀、停、续、定时)。
8.1 信号基础概念
信号 = 软中断,是内核通知进程"发生了某个事件"的异步机制。
⚠️ 这里的"软中断"只是比喻(指它异步打断进程),与操作系统的硬中断、内核 softirq 是不同层级的概念,辨析如下。
硬中断 vs 软中断 vs 信号(前置概念辨析):
三者分属不同层级:
硬件设备(时钟/键盘/网卡)
│ ① 硬中断:硬件 → CPU(内核)
▼
内核(中断处理)
│ ② 异常/软中断:内核内部处理
│ ③ 某些硬件事件由内核转成信号
▼
进程(信号处理函数)
| 维度 | 硬中断 | 软中断(softirq / int 指令) | 信号 |
|---|
| 触发源 | 硬件设备 | 软件指令 / 内核下半部 | 内核(进程级) |
| 打断对象 | CPU(内核态) | CPU(内核态) | 进程(用户态) |
| 是否异步 | 异步 | 异步/同步(int 指令同步) | 异步 |
| 处理者 | 内核 ISR | 内核 | 进程处理函数 |
| 能否被进程捕获 | 否 | 否 | 能 |
| 是否产生信号 | 否(异常才转信号) | 否 | 本身是信号 |
回答常见疑问"硬/软中断产生的都是信号吗"——不是,分三种情况:
- 硬中断本身不产生信号:网卡收包、时钟滴答由内核 ISR 处理,进程毫无感知
硬件"异常"(exception)才产生信号:CPU 执行指令出错,内核捕获后转换成信号发给进程:
- 除零 →
SIGFPE;非法内存访问 → SIGSEGV;非法指令 → SIGILL - ⚠️ 术语区别:硬件中断(interrupt)不产生信号,硬件异常(exception)才产生信号
外设中断可间接产生信号:内核处理中断时顺带发信号:
- 定时器中断 → 检查
alarm 到点 → 发 SIGALRM 给进程 - 键盘中断 → tty 驱动 → 用户按 Ctrl+C → 发
SIGINT 给前台进程组
一句话: 硬/软中断发生在「内核与硬件」层面,进程看不见;信号发生在「内核与进程」层面。只有硬件异常才会被内核转换成信号——"信号=软中断"只是比喻其异步打断进程的性质,与内核的硬/软中断不是一回事。
生命周期(关键):
产生(generation) → 未决(pending) → 递送(delivery)
│ │ │
└── 进程可阻塞信号,阻塞时信号"挂起"──┘
- 产生:事件发生(Ctrl+C、
kill、除零、alarm 超时等) - 未决:信号已产生但还没被进程处理
- 递送:进程真正处理信号
三种处理方式:
| 方式 | 写法 | 说明 |
|---|
| 默认 | SIG_DFL | 终止/忽略/暂停等系统默认动作 |
| 忽略 | SIG_IGN | 丢弃信号(SIGKILL/SIGSTOP 不可忽略) |
| 捕获 | 自定义函数 | 注册信号处理函数 |
8.2 signal vs sigaction(重点)
signal(旧接口,不可靠):
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
sigaction(现代标准接口,可靠):
#include <signal.h>
struct sigaction {
void (*sa_handler)(int); /* 处理函数或 SIG_DFL/SIG_IGN */
sigset_t sa_mask; /* 处理期间要阻塞的额外信号 */
int sa_flags; /* 标志位 */
void (*sa_sigaction)(int, siginfo_t *, void *); /* 扩展版 */
};
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
sa_flags 常用标志:
SA_RESTART:被信号中断的系统调用自动重启(避免 EINTR)SA_SIGINFO:使用 sa_sigaction,能拿到详细信息(发送者 PID、原因等)SA_RESETHAND:处理一次后恢复默认
// sigaction 标准写法
struct sigaction sa = {0};
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask); // 处理期间不额外阻塞信号
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGINT, &sa, NULL);
8.3 中断的系统调用(EINTR)与可重入函数
慢系统调用(如 read 等待终端输入)被信号打断时返回 -1 且 errno = EINTR。处理:
- 设置
SA_RESTART 让内核自动重启 - 或手动判断
EINTR 重试:
while ((n = read(fd, buf, sizeof(buf))) == -1 && errno == EINTR)
; /* 被信号打断,重试 */
可重入函数(重点): 信号处理函数中调用别的函数时,该函数必须是可重入的——不能使用静态数据、不能调用 malloc/printf 等非异步信号安全的函数。
| 不可在 handler 中调用 | 可在 handler 中调用 |
|---|
printf/fprintf | write/read |
malloc/free | wait/waitpid |
getpwnam 等非可重入库函数 | _exit/kill/signal/sigaction |
rand | 系统调用(异步信号安全) |
8.4 信号集(sigset_t)与阻塞/未决
信号集操作函数:
#include <signal.h>
int sigemptyset(sigset_t *set); // 清空集合
int sigfillset(sigset_t *set); // 填满所有信号
int sigaddset(sigset_t *set, int signum); // 加入一个信号
int sigdelset(sigset_t *set, int signum); // 删除一个信号
int sigismember(const sigset_t *set, int signum); // 是否在集合中
阻塞与未决查询:
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
/* how: SIG_BLOCK(并集) / SIG_UNBLOCK(去除) / SIG_SETMASK(直接设置) */
int sigpending(sigset_t *set); // 查询哪些信号处于未决状态
阻塞 ≠ 忽略: 阻塞只是延迟递送,信号还挂在未决队列里;解除阻塞后立即递送。未决信号若多次产生,标准信号只保留一个(不排队);实时信号可排队。
8.5 发送与等待信号
#include <signal.h>
#include <unistd.h>
int kill(pid_t pid, int signum); // 向进程/进程组发信号
int raise(int signum); // 给自己发信号(等价 kill(getpid(), signum))
unsigned int alarm(unsigned int seconds); // 定时发 SIGALRM(进程唯一)
int pause(void); // 挂起进程直到收到一个信号
void abort(void); // 给自己发 SIGABRT(异常终止)
// 经典:用 alarm 实现定时器
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout(int sig) {
printf("时间到!\n");
}
int main(void) {
signal(SIGALRM, timeout);
alarm(3); // 3 秒后发 SIGALRM
pause(); // 挂起等信号
return 0;
}
8.6 sigsetjmp/siglongjmp(信号安全的跳转)
普通 setjmp/longjmp 不保存信号屏蔽字。信号版本的:
#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savemask); // savemask!=0 时保存屏蔽字
void siglongjmp(sigjmp_buf env, int val);
典型场景: 程序在信号处理中设置了阻塞某些信号,siglongjmp 跳回后要恢复原来的屏蔽状态。
8.7 sigsuspend(原子挂起)
问题: sigprocmask 解除阻塞和 pause 是两个调用,中间有窗口期(信号可能在这期间丢失)。
#include <signal.h>
int sigsuspend(const sigset_t *sigmask);
sigsuspend 原子完成两件事: ① 把屏蔽字设为 sigmask ② 挂起等信号。信号处理函数返回后,屏蔽字恢复。
// 经典:等待特定信号(如 SIGINT)
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigsuspend(&mask); // 原子地:只阻塞 SIGINT + 挂起
8.8 其他内容
system 函数与信号:实现时阻塞 SIGCHLD、忽略 SIGINT/SIGQUITsleep/nanosleep/clock_nanosleep:进程挂起,其中 nanosleep 不受信号处理影响(被信号中断时返回剩余时间)abort:先解除 SIGABRT 阻塞再发送,确保能终止kill 的 pid 参数语义:pid > 0 指定进程;pid == 0 同进程组;pid == -1 除自身外的所有进程;pid < -1 指定进程组
8.9 本章速查表
| 主题 | 关键函数 |
|---|
| 注册处理 | signal(旧)/ sigaction(新) |
| 信号集 | sigemptyset/sigaddset/sigismember |
| 阻塞/未决 | sigprocmask/sigpending |
| 发送信号 | kill/raise |
| 定时 | alarm/pause/sleep/nanosleep |
| 终止 | abort |
| 跳转 | sigsetjmp/siglongjmp |
| 原子挂起 | sigsuspend |
8.10 信号 vs 信号量(易混淆辨析)
名字只差一个字,本质完全两回事:信号是"通知",信号量是"锁/计数器"。
| | 信号(signal) | 信号量(semaphore) |
|---|
| 本质 | 事件通知(异步软中断) | 同步原语(计数器 + 等待队列) |
| 干什么 | 通知进程"发生了某件事" | 控制多个进程对共享资源的访问(互斥/同步) |
| 谁产生 | 内核(kill、除零、定时器等) | 程序自己创建(semget/sem_open) |
| 携带数据 | 只带一个信号编号,无数据 | 携带一个计数值 |
| 操作方式 | 内核异步递送,进程被动接收 | sem_wait/sem_post 显式操作(P/V) |
| 处理者 | 进程的信号处理函数 | 内核阻塞等待的进程队列 |
信号 —— "敲门"式的异步通知:
进程 A 执行 kill(B, SIGINT) 内核登记
定时器到点 → SIGALRM
硬件异常(除零)→ SIGFPE
↓
进程 B 下次被调度时处理(打断当前流程)
- 事件 → 通知:内核单向通知进程,进程不用主动做什么
- 本质是"异步打断",带的信息量只有编号
- 类比:电话铃响了——只告诉你"有电话",不负责协调
信号量 —— "车位"式的计数锁:
sem_init(&sem, 0, 3); // 停车场有 3 个空位
...
sem_wait(&sem); // P 操作:申请一个车位(无位则阻塞等待)
/* 进入临界区,访问共享资源 */
sem_post(&sem); // V 操作:释放一个车位,唤醒等待者
最关键的混淆点(命名撞车):
信号量的英文是 semaphore,两个操作是 P/V(荷兰语 Proberen 测试 / Verhogen 增加):
- POSIX 命名:
sem_wait(P)、sem_post(V) - 但早期 System V 教科书里 V 操作写作
signal(sem) ——这个 signal 和信号(signal)只是历史命名撞名,完全不是一回事,很多人混淆就栽在这里
总结一句话:
信号 signal: "有人按门铃" —— 通知你来,你被动响应,不协调资源
信号量 semaphore:"车位还剩几个"—— 你主动申请,控制访问数量,天然互斥
信号是异步、无数据,用于通知与进程控制(杀/停/续/定时);信号量是同步、有计数,用于互斥与协调(临界区保护)。两者常配合使用:如进程处理完信号后发信号量唤醒消费者。APUE 中信号是第 10 章,信号量属于第 15 章(进程间通信 IPC),届时会讲 semget/semop(System V)与 sem_open/sem_wait/sem_post(POSIX)。
核心一句话: 信号是异步软中断;用 sigaction 可靠注册处理函数;sigprocmask 控制阻塞、sigpending 查未决;处理函数里只能用异步信号安全函数;SA_RESTART/sigsuspend 解决竞态与 EINTR。
九、线程(第 11 章)
主题:线程的创建、终止与同步。线程(Threads)与信号并列为 APUE 的两大重点章节。
9.1 线程概念(为什么需要线程)
多进程虽然隔离好,但有三大痛点:fork 开销大、进程间通信复杂、切换成本高。线程解决的是"同一进程内多个执行流":
| | 进程 | 线程 |
|---|
| 本质 | 资源分配单位 | CPU 调度单位 |
| 创建/切换开销 | 大 | 小 |
| 共享数据 | 需要 IPC(管道/共享内存等) | 天然共享(直接读写同一变量) |
| 隔离性 | 强(地址空间独立) | 弱(共享地址空间,一荣俱荣) |
线程共享(同一进程内):代码段、数据段、堆、打开的文件描述符、信号处理、当前目录、用户/组 ID
线程独有:线程 ID、栈(局部变量)、寄存器上下文、errno、信号屏蔽字
⚠️ errno 是线程独有的!每个线程有自己的错误码,所以多线程代码直接用 errno 而不是全局错误变量。
9.2 线程标识与创建
#include <pthread.h>
pthread_t pthread_self(void); // 获取自身线程 ID
int pthread_equal(pthread_t t1, pthread_t t2); // 比较(可能是结构体指针,不能直接 ==)
int pthread_create(pthread_t *tidp, const pthread_attr_t *attr,
void *(*start_rtn)(void *), void *arg);
- 新线程从
start_rtn 开始执行,arg 是传给它的参数(注意是 void *) attr 传 NULL 用默认属性- 创建后新线程和调用者谁先运行不确定(和
fork 一样) - 编译必须加
-pthread 链接选项
#include <stdio.h>
#include <pthread.h>
void *worker(void *arg) {
printf("子线程参数: %d\n", *(int *)arg);
return NULL;
}
int main(void) {
pthread_t tid;
int val = 42;
pthread_create(&tid, NULL, worker, &val); // 注意传的是 arg 的指针
pthread_join(tid, NULL); // 等待子线程结束
return 0;
}
9.3 线程终止(三种方式)
| 方式 | 说明 |
|---|
线程函数 return | 正常返回,返回值即退出状态 |
pthread_exit(void *rval_ptr) | 中途主动退出,可带返回值 |
pthread_cancel(tid) | 请求取消(默认是延迟取消) |
void pthread_exit(void *rval_ptr); // 退出线程,返回 rval_ptr
int pthread_join(pthread_t thread, void **rval_ptr); // 等待线程终止(类似 waitpid)
int pthread_cancel(pthread_t thread); // 请求取消一个线程
pthread_join 关键点:
- 阻塞等待指定线程终止,回收其资源(类似
waitpid) - 通过
rval_ptr 拿到线程的退出值(return 的值或 pthread_exit 的参数) - 不能 join 一个已
detach(分离)的线程;detach 的线程终止时资源自动回收
9.4 线程同步(本章核心)
问题来源:线程共享内存,多个线程同时读写同一变量 → 竞态条件。count++ 不是原子操作(读取→加 1→写回),可能丢失更新。
① 互斥量(Mutex)—— 最常用:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 静态初始化
pthread_mutex_lock(&mutex); // 加锁(拿不到就阻塞等待)
pthread_mutex_trylock(&mutex); // 尝试加锁(拿不到立即返回 EBUSY)
pthread_mutex_unlock(&mutex); // 解锁
// 动态初始化(属性可定制):
pthread_mutex_init(&mutex, NULL);
pthread_mutex_destroy(&mutex);
避免死锁的招数:
- 所有线程按同一顺序加锁(锁 A → 锁 B,不要交叉)
- 用
trylock 拿不到就释放已有的锁,退回重试 pthread_mutex_timedlock 设超时,超时返回不阻塞
② 读写锁(Read-Write Lock)—— 读多写少场景:
多个读者 ── 可以同时持有(并发读)
一个写者 ── 独占(写的时候谁都不能碰)
pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;
pthread_rwlock_rdlock(&rwlock); // 读锁(可多个)
pthread_rwlock_wrlock(&rwlock); // 写锁(独占)
pthread_rwlock_unlock(&rwlock);
③ 条件变量(Condition Variable)—— "等条件成立"而非"等锁":
与互斥量配合使用,解决"线程要等某个条件(如队列非空)"的问题:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_cond_wait(&cond, &mutex); // 原子地:释放 mutex + 阻塞等待被唤醒
pthread_cond_signal(&cond); // 唤醒一个等待的线程
pthread_cond_broadcast(&cond); // 唤醒所有等待的线程
经典生产者-消费者模式(必考):
// 消费者等待队列非空
pthread_mutex_lock(&mutex);
while (queue_empty) // ⚠️ 必须用 while,不是 if!
pthread_cond_wait(&cond, &mutex); // 释放锁并睡眠,被唤醒后重新拿锁
/* 此时队列非空,取出数据 */
pthread_mutex_unlock(&mutex);
// 生产者放入数据后通知
pthread_mutex_lock(&mutex);
enqueue(data);
pthread_cond_signal(&cond); // 唤醒一个消费者
pthread_mutex_unlock(&mutex);
两个关键坑:
wait 前必须先持有互斥量;wait 会"原子地释放锁 + 阻塞",唤醒后重新获得锁再返回- 条件检查必须用
while 循环——防止"虚假唤醒"和多个消费者同时被唤醒后抢条件
④ 自旋锁(Spinlock)与屏障(Barrier):
| 机制 | 特点 | 适用 |
|---|
| 自旋锁 | 拿不到锁就忙等(自旋),不睡眠 | 锁持有时间极短、多核 CPU |
| 屏障(barrier) | 所有线程到达屏障点才一起放行 | 并行计算分阶段同步 |
pthread_spin_lock(&spin); // 忙等版互斥
pthread_barrier_wait(&bar); // 等所有线程到齐
9.5 多线程进程与 fork/exec 的交互(易错)
fork 后子进程里只有调用 fork 的那个线程存活,其他线程消失- 多线程进程
fork 后立即 exec 是安全的(新程序覆盖地址空间,锁状态无所谓) - 若不
exec,其他线程锁着的东西在子进程里没人解锁 → 死锁。可用 pthread_atfork 注册处理函数修复
9.6 同步的三大问题
| 问题 | 含义 | 解决 |
|---|
| 数据竞争(race) | 多线程同时读写同一数据 | 加锁 |
| 死锁(deadlock) | 互相等对方持有的锁 | 锁顺序一致 / trylock / 超时 |
| 饿死(starvation) | 某线程永远抢不到资源 | 公平调度、读写锁避免写者饿死 |
9.7 本章速查表
| 功能 | 函数 |
|---|
| 创建线程 | pthread_create |
| 获取/比较 ID | pthread_self / pthread_equal |
| 退出线程 | pthread_exit |
| 等待线程 | pthread_join |
| 取消线程 | pthread_cancel |
| 互斥量 | pthread_mutex_lock/unlock/trylock |
| 读写锁 | pthread_rwlock_rdlock/wrlock |
| 条件变量 | pthread_cond_wait/signal/broadcast |
| 自旋锁 | pthread_spin_lock/unlock |
| 屏障 | pthread_barrier_wait |
核心一句话: 线程 = 同一进程内的多个执行流,天然共享内存所以省去 IPC,但也因此必须靠互斥量/读写锁/条件变量同步;pthread_join 回收线程类似 waitpid;编译加 -pthread;while 检查条件 + 统一锁顺序是防坑两大法则。
十、线程控制(第 12 章)
主题:线程属性的精细控制 + 线程与重入/信号/fork 的交互。第 11 章讲"怎么用线程",这一章讲"怎么精细地控制和避坑"。
10.1 线程限制(Thread Limits)
用 sysconf 查询系统对线程的各项限制:
#include <unistd.h>
long n = sysconf(_SC_THREAD_THREADS_MAX); // 最多线程数
long s = sysconf(_SC_THREAD_STACK_MIN); // 栈最小字节数
常用限制:_SC_THREAD_KEYS_MAX(每线程键值数)、_SC_THREAD_STACK_MIN/MAX、_SC_THREAD_DESTRUCTOR_ITERATIONS 等。
10.2 线程属性(pthread_attr_t,重点)
线程不是只有默认属性,可以定制:
#include <pthread.h>
pthread_attr_t attr;
pthread_attr_init(&attr); // 初始化属性对象
pthread_attr_destroy(&attr); // 销毁
pthread_create(&tid, &attr, func, arg); // 用定制属性创建线程
三个常用属性:
| 属性 | 函数 | 取值 |
|---|
| 分离状态 | pthread_attr_setdetachstate | PTHREAD_CREATE_JOINABLE(可 join)/ PTHREAD_CREATE_DETACHED(分离) |
| 栈大小 | pthread_attr_setstacksize | 自定义栈大小(默认可能很小,深递归会爆) |
| 警戒区 | pthread_attr_setguardsize | 栈溢出时保护页大小 |
分离线程(detached)的意义:
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
pthread_create(&tid, &attr, func, NULL); // 分离线程
// 或线程内调用:
pthread_detach(pthread_self());
- 分离线程终止时资源自动回收,不需要也不能
pthread_join - 适合"只管创建,不关心结束"的后台线程(如服务器每连接一个线程)
10.3 同步属性(关键扩展)
第 11 章的互斥量/读写锁/条件变量都可以带属性,最重要的两个:
① 进程共享(process-shared):
pthread_mutexattr_t mattr;
pthread_mutexattr_init(&mattr);
pthread_mutexattr_setpshared(&mattr, PTHREAD_PROCESS_SHARED); // 默认 PRIVATE
pthread_mutex_init(&mutex, &mattr);
- 默认
PTHREAD_PROCESS_PRIVATE:互斥量只在进程内线程间用 - 设为
PTHREAD_PROCESS_SHARED:可以放在共享内存里,跨进程互斥(IPC 的替代方案)
② 互斥量类型(type):
| 类型 | 行为 |
|---|
PTHREAD_MUTEX_NORMAL | 普通;重复加锁 = 死锁 |
PTHREAD_MUTEX_ERRORCHECK | 检错;重复加锁返回错误(不锁死) |
PTHREAD_MUTEX_RECURSIVE | 递归;同一线程可重复加锁,需解锁同样次数 |
PTHREAD_MUTEX_DEFAULT | 默认(系统定义) |
pthread_mutexattr_settype(&mattr, PTHREAD_MUTEX_RECURSIVE);
递归锁适合"函数 A 加锁后又调用函数 B,B 也要加同一把锁"的场景。
读写锁、条件变量、屏障也有对应的 pthread_rwlockattr_t/pthread_condattr_t/pthread_barrierattr_t,同样支持 setpshared。
10.4 重入(Reentrancy,易错重点)
问题:很多库函数用静态存储区存结果,多线程调用会互相覆盖:
struct passwd *getpwnam(const char *name); // 返回静态区指针,下一个调用会覆盖
struct tm *localtime(const time_t *t); // 同上
char *strtok(char *str, const char *delim); // 内部静态指针记录位置
解决办法:_r 后缀的可重入版本(重点记忆):
| 非线程安全 | 线程安全(可重入) |
|---|
getpwnam / getpwuid | getpwnam_r / getpwuid_r |
localtime / gmtime | localtime_r / gmtime_r |
strtok | strtok_r |
rand | rand_r |
getlogin | getlogin_r |
// _r 版本通常需要调用者提供缓冲区
struct passwd pwd, *result;
char buf[1024];
getpwnam_r("alice", &pwd, buf, sizeof(buf), &result);
标准 I/O 与线程: FILE 流内部有锁(每个流的操作是原子的),但 printf/fprintf 一次调用一个锁,多次调用有开销。可以显式锁住一个流做整段操作:
flockfile(fp); // 锁住流
/* 连续多次 fprintf/fputs,不会被其他线程插入 */
funlockfile(fp); // 解锁
10.5 线程与信号(重点)
核心规则(关键):
- 每个线程有自己的信号屏蔽字(可单独
pthread_sigmask 设置) - 但信号处理函数是进程级的(所有线程共享,注册一次全进程生效)
- 进程收到的信号只递送给一个线程(通常是最先能接收的那个)
#include <signal.h>
#include <pthread.h>
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset); // 设置本线程屏蔽字
int pthread_kill(pthread_t thread, int signum); // 向指定线程发信号
int sigwait(const sigset_t *set, int *signum); // 等待指定信号(阻塞)
推荐的"信号线程"模式——让专门的线程统一处理信号:
// 主线程阻塞所有信号,创建信号处理线程
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
pthread_sigmask(SIG_BLOCK, &set, NULL); // 主线程屏蔽 SIGINT
// 信号处理线程
void *sig_thread(void *arg) {
int sig;
while (1) {
sigwait(&set, &sig); // 阻塞等待,取到的信号不经过处理函数
printf("收到信号 %d\n", sig); // 可安全调用任何函数(无信号打断)
}
}
⚠️ 多线程里避免用 signal/sigaction 注册处理函数——无法确定信号递送给哪个线程,处理函数里又不能用非安全函数。用 sigwait + 专用线程最可靠。
10.6 线程与 fork(pthread_atfork)
fork 后子进程只有调用 fork 的那个线程,其他线程及其持有的锁全消失 → 锁状态残留。
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
prepare:fork 前在父进程调用(先拿锁)parent:fork 后父进程中调用(父进程解锁)child:fork 后子进程中调用(子进程解锁)
// 典型用法:确保 fork 后锁状态干净
pthread_atfork(prepare_lock, parent_unlock, child_unlock);
最稳妥的做法:多线程程序 fork 后立即 exec(覆盖地址空间,什么都不用管)。
10.7 本章速查表
| 主题 | 关键函数/内容 |
|---|
| 线程限制 | sysconf(_SC_THREAD_*) |
| 线程属性 | pthread_attr_init/setdetachstate/setstacksize |
| 分离线程 | PTHREAD_CREATE_DETACHED / pthread_detach |
| 进程共享 | pthread_mutexattr_setpshared(PTHREAD_PROCESS_SHARED) |
| 互斥量类型 | NORMAL / ERRORCHECK / RECURSIVE |
| 可重入版本 | *_r:localtime_r/strtok_r/getpwnam_r/rand_r |
| 流加锁 | flockfile/funlockfile |
| 线程信号 | pthread_sigmask/pthread_kill/sigwait |
| fork 处理 | pthread_atfork |
核心一句话: 第 12 章 = 属性定制(分离/栈/递归锁/进程共享)+ 避坑(_r 可重入版本、sigwait 信号线程、pthread_atfork 锁恢复)——第 11 章讲"会开线程",这一章讲"开得优雅、开得安全"。
十一、守护进程(第 13 章)
主题:什么是守护进程、怎么编写一个规范的守护进程、以及日志记录。守护进程(Daemon Processes)。
11.1 守护进程的特征
守护进程(daemon)= 生命周期长的后台进程,脱离终端、脱离父进程。
| 特征 | 说明 |
|---|
| 长生命周期 | 系统启动时启动,系统关闭时终止 |
| 无控制终端 | 后台运行,不响应 Ctrl+C |
| 无父进程 | 父进程已退出,被 init(PID 1)收养 |
| 典型例子 | sshd、cron、syslogd、httpd |
验证:ps -ef 里看守护进程,PPID 都是 1,TTY 都是 ?:
$ ps -ef | grep sshd
root 1234 1 0 ... ? 00:00:00 /usr/sbin/sshd
└ PPID=1(init 收养)└ TTY=?(无终端)
11.2 守护进程的编写规则(本章核心,7 步)
以"让它无法再获得终端、无法被中断"为目标,标准步骤如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <syslog.h>
#define MAXFD 64
void daemonize(const char *cmd) {
int i, fd0, fd1, fd2;
pid_t pid;
struct sigaction sa;
/* ① 清除文件创建屏蔽字:让守护进程创建的文件权限不受调用者 umask 影响 */
umask(0);
/* ② fork 后父进程退出:使子进程成为孤儿,被 init 收养 */
if ((pid = fork()) < 0) { perror(cmd); exit(1); }
if (pid != 0) exit(0); // 父进程退出
/* ③ setsid:创建新会话,成为会话首进程,彻底脱离控制终端 */
setsid();
/* ④ 忽略 SIGHUP 并二次 fork(可选但常见):
确保不是会话首进程,永远无法重新获得控制终端 */
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGHUP, &sa, NULL);
if ((pid = fork()) < 0) { perror(cmd); exit(1); }
if (pid != 0) exit(0); // 第一次 fork 的子进程退出
/* ⑤ chdir("/"):工作目录改为根,避免占用文件系统导致无法卸载 */
chdir("/");
/* ⑥ 关闭所有打开的文件描述符(0、1、2…) */
for (i = 0; i < MAXFD; i++) close(i);
/* ⑦ 把 stdin/stdout/stderr 重定向到 /dev/null */
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);
}
int main(void) {
daemonize("mydaemon");
/* 守护进程主体:不依赖终端,用 syslog 记日志 */
while (1) {
/* 干活... */
}
}
7 步逐一解释:
| 步骤 | 目的 |
|---|
① umask(0) | 清除文件创建屏蔽字,保证创建的文件权限正确 |
② fork + 父进程退出 | 子进程成为孤儿,被 init 收养(不再有父进程) |
③ setsid() | 创建新会话,成为会话首进程,脱离控制终端 |
| ④ 二次 fork | 不再是会话首进程,永远无法重新获得控制终端(System V 防御性做法) |
⑤ chdir("/") | 工作目录不占用某个文件系统,保证可卸载 |
| ⑥ 关闭文件描述符 | 释放占用的文件/终端 |
⑦ 重定向到 /dev/null | stdin/stdout/stderr 有合法去处,避免意外写入终端 |
11.3 日志记录(syslog)
守护进程没有终端,printf 没用,标准做法是写 syslog:
#include <syslog.h>
openlog("mydaemon", LOG_PID | LOG_CONS, LOG_DAEMON); // 打开日志:ident、选项、facility
syslog(LOG_ERR, "启动失败: %m"); // %m 会替换成 errno 对应的字符串
closelog();
facility(设施,来源分类): LOG_USER(用户)、LOG_DAEMON(守护进程)、LOG_MAIL、LOG_LOCAL0~LOG_LOCAL7(自定义)
level(级别,优先级从高到低):
LOG_EMERG(0) > LOG_ALERT(1) > LOG_CRIT(2) > LOG_ERR(3)
> LOG_WARNING(4) > LOG_NOTICE(5) > LOG_INFO(6) > LOG_DEBUG(7)
- 只有级别小于等于配置阈值的日志才会被记录(如配置
*.info,则 LOG_INFO 及以上都记录) - 日志配置文件:
/etc/syslog.conf 或 /etc/rsyslog.conf,消息最终进 /var/log/messages 等文件 - 日志文件实时查看:
tail -f /var/log/messages
⚠️ %m 是 syslog 特有的格式符,等价于 strerror(errno),非常实用。
11.4 单实例守护进程(文件记录锁)
防止同一守护进程被启动两次,用 fcntl 的记录锁锁一个固定文件:
#include <fcntl.h>
int lockfile(int fd) {
struct flock fl = { F_WRLCK, SEEK_SET, 0, 0, 0 };
return fcntl(fd, F_SETLK, &fl); // 非阻塞加写锁
}
int main(void) {
int fd = open("/var/run/mydaemon.pid", O_RDWR | O_CREAT, 0666);
if (lockfile(fd) < 0) {
printf("已有实例在运行!\n");
exit(1); // 加锁失败 → 另一个实例已持有锁
}
/* 把本进程 PID 写入该文件(惯例) */
/* 正常启动守护进程... */
}
- 进程退出时锁自动释放,所以不怕残留锁文件
- 惯例:锁文件放
/var/run/xxx.pid,内容写 PID
11.5 守护进程的约定(重读配置)
- PID 文件:
/var/run/xxx.pid - 日志文件:
/var/log/xxx.log - 重读配置:很多守护进程用
SIGHUP 信号通知"重新读取配置文件"(不退出进程)——例如修改配置后 kill -HUP <pid>,无需重启
用户改配置 → kill -HUP (PID) → 守护进程收到 SIGHUP → 重读配置文件 → 继续服务
11.6 本章速查表
| 主题 | 关键内容 |
|---|
| 特征 | 长生命周期、无终端、被 init 收养 |
| 编写规则 | umask(0) → fork → setsid → 二次 fork → chdir("/") → 关 fd → 重定向 /dev/null |
| 日志 | openlog/syslog/closelog,facility + level,%m |
| 单实例 | fcntl 记录锁锁 /var/run/xxx.pid |
| 重读配置 | SIGHUP 信号 |
核心一句话: 守护进程 = 通过「umask(0)→fork→setsid→二次 fork→chdir("/")→关 fd→重定向 /dev/null」彻底脱离终端和父进程的后台进程;没有终端就用 syslog 记日志,用文件锁保证单实例,用 SIGHUP 重读配置。
十二、高级 I/O(第 14 章)
主题:突破"读-处理-写"同步模型的四种技术:非阻塞 I/O、记录锁、I/O 多路复用、异步 I/O,以及 mmap。是网络编程的地基。
12.1 非阻塞 I/O(Nonblocking I/O)
阻塞的问题:read 没数据就一直睡、write 缓冲区满就一直睡。
#include <fcntl.h>
#include <unistd.h>
// 方式一:open 时指定
int fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
// 方式二:fcntl 修改(通用,最常用)
int flags = fcntl(fd, F_GETFL); // 先取现有标志
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 加上非阻塞位
要点:
- 非阻塞后
read 没数据会立即返回 -1,errno = EAGAIN(或 EWOULDBLOCK),而不是阻塞 - ⚠️ 对普通文件无效(普通文件永远"可读"),主要对终端、管道、socket 有效
- 轮询式使用(自己循环检查)浪费 CPU,所以引出下面的多路复用方案
12.2 记录锁(Record Locking,重点)
用途:多个进程同时读写文件的同一区域时防冲突(如数据库、配置文件)。
#include <fcntl.h>
struct flock {
short l_type; // F_RDLCK(读锁) / F_WRLCK(写锁) / F_UNLCK(解锁)
short l_whence; // SEEK_SET / SEEK_CUR / SEEK_END(起始参考点)
off_t l_start; // 相对 whence 的偏移
off_t l_len; // 锁定长度(0 = 到文件末尾)
pid_t l_pid; // 持有锁的进程(F_GETLK 时返回)
};
int fcntl(int fd, int cmd, struct flock *lock);
// cmd: F_GETLK(查询) / F_SETLK(加锁,非阻塞) / F_SETLKW(加锁,阻塞)
// 加写锁:阻塞直到拿到锁
struct flock fl = { F_WRLCK, SEEK_SET, 0, 100, 0 }; // 锁文件前 100 字节
if (fcntl(fd, F_SETLKW, &fl) == 0) {
/* 独占访问该区域 */
/* 用完解锁 */
fl.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &fl);
}
锁的规则:
- 读锁(共享)可多个共存;写锁(独占)只能一个
- 锁与进程 + 文件绑定:进程退出或关闭 fd 时自动释放
F_SETLKW 等不到就阻塞;F_SETLK 等不到立即返回 EACCES/EAGAIN- 建议性锁(advisory):只是"约定",别的进程不检查照样能读写——Linux 默认是建议性锁
12.3 I/O 多路复用(I/O Multiplexing,重中之重)
问题:一个进程要同时监听多个 fd(如服务器管几千个连接),怎么等?
阻塞 read(fd1) → fd2 的数据来了也没人理 ✗ 不行
轮询所有 fd → 浪费 CPU ✗ 不行
select/poll/epoll → 内核替你监视,哪个就绪返回哪个 ✓
① select(可移植,老牌):
#include <sys/select.h>
fd_set rfds;
FD_ZERO(&rfds); // 清空集合
FD_SET(fd1, &rfds); // 加入 fd1
FD_SET(fd2, &rfds); // 加入 fd2
int n = select(maxfd+1, &rfds, NULL, NULL, NULL); // 阻塞等
// 返回后检查:
if (FD_ISSET(fd1, &rfds)) { /* fd1 可读 */ }
if (FD_ISSET(fd2, &rfds)) { /* fd2 可读 */ }
- 返回就绪的 fd 个数;就绪集会原地修改,所以每次要重新设置集合
- 限制:fd 数量受
FD_SETSIZE(常 1024)限制;每次要线性遍历 pselect 是 select 的安全版本,可带信号屏蔽字,超时精度更高
② poll(无数量限制):
#include <poll.h>
struct pollfd fds[2] = {
{ fd1, POLLIN, 0 },
{ fd2, POLLIN, 0 },
};
int n = poll(fds, 2, -1); // 阻塞等(-1 = 无限)
// 检查:
if (fds[0].revents & POLLIN) { /* fd1 可读 */ }
- 用
pollfd 数组,不受 1024 限制,但仍是遍历式
③ epoll(Linux 高效事件驱动):
#include <sys/epoll.h>
int epfd = epoll_create1(0); // 创建 epoll 实例
struct epoll_event ev = { .events = EPOLLIN, .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 注册关注的事件
struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1); // 阻塞等,返回就绪事件
for (int i = 0; i < n; i++)
handle(events[i].data.fd); // 直接拿到就绪的 fd
- 只返回就绪的 fd,不用遍历全部 → 连接数巨大时性能好
- 内核维护事件表,
epoll_ctl 增删改,不需要反复拷贝集合
三选对比:
| | select | poll | epoll |
|---|
| fd 数量限制 | 1024(FD_SETSIZE) | 无 | 无 |
| 效率 | 每次全量遍历 | 全量遍历 | 只返回就绪的 |
| 可移植性 | 所有平台 | 所有平台 | 仅 Linux |
| 适用 | 连接数少、跨平台 | 连接数中等 | 高并发服务器(主流) |
12.4 异步 I/O(AIO)
思想:发起读写后立即返回,内核完成后通知你(信号或回调),彻底不阻塞。
#include <aio.h>
struct aiocb cb = {0};
cb.aio_fildes = fd;
cb.aio_buf = buf;
cb.aio_nbytes = 100;
aio_read(&cb); // 发起异步读,立即返回
/* 继续干别的活 */
aio_error(&cb); // 检查完成状态(EINPROGRESS = 进行中)
aio_suspend(&cb, 1, NULL); // 等待完成(可多个)
aio_return(&cb); // 获取结果
- 适用于磁盘 I/O 密集场景(数据库、文件服务器)
- 相比多路复用:多路复用还是"你等它",AIO 是"它完成再叫你"
12.5 分散读 / 聚集写(readv / writev)
一次读写多个不连续缓冲区,避免多次系统调用:
#include <sys/uio.h>
struct iovec iov[2] = {
{ buf1, 100 },
{ buf2, 200 },
};
ssize_t n = writev(fd, iov, 2); // 一次把两块数据连续写出
ssize_t m = readv(fd, iov, 2); // 一次读到两块缓冲区
12.6 存储映射 I/O(mmap,重点)
把文件映射到进程地址空间,之后像访问内存一样访问文件,免去 read/write 系统调用和用户/内核间拷贝,速度极快:
#include <sys/mman.h>
int fd = open("data.bin", O_RDWR);
off_t len = 4096;
char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); }
p[0] = 'A'; // 直接写"内存",会反映到文件
printf("%c\n", p[10]); // 直接读文件内容
msync(p, len, MS_SYNC); // 把修改同步回文件(MAP_SHARED 时需要)
munmap(p, len); // 解除映射
close(fd);
关键点:
MAP_SHARED:修改会写回文件,可与其他进程共享映射MAP_PRIVATE:修改不会写回文件(写时复制,像私有副本)- 父子进程
fork 后共享同一映射 → 一种共享内存的实现方式 - ⚠️ 写入文件不一定立即落盘,需要
msync;文件大小变化要 ftruncate 配合
与普通 read/write 对比:
read/write:用户缓冲区 ⇄ 内核 ⇄ 磁盘 (每次系统调用 + 数据拷贝)
mmap: 内存直接映射到文件 (像访问数组,零拷贝感)
12.7 本章速查表
| 主题 | 关键函数 |
|---|
| 非阻塞 I/O | `fcntl(fd, F_SETFL, flags \ | O_NONBLOCK),EAGAIN` |
| 记录锁 | fcntl + struct flock(F_RDLCK/F_WRLCK/F_UNLCK) |
| I/O 多路复用 | select/pselect/poll/epoll |
| 异步 I/O | aio_read/aio_write/aio_error/aio_suspend/aio_return |
| 分散/聚集 | readv/writev + struct iovec |
| 存储映射 | mmap/munmap/msync(MAP_SHARED/MAP_PRIVATE) |
核心一句话: 第 14 章是 I/O 性能优化工具箱——非阻塞避免卡死、记录锁解决文件并发、select/poll/epoll 让一个进程管成千上万连接、AIO 彻底异步、mmap 把文件当内存用。epoll + mmap 是 Linux 高性能服务器的两大法宝。
十三、进程间通信(第 15 章)
主题:进程之间如何交换数据——管道、FIFO、System V IPC(消息队列/信号量/共享内存)、POSIX IPC。全书最重的一章,把前面所有知识串起来。进程间通信(Interprocess Communication, IPC)。
13.1 先搞清楚两个术语:XSI 与 POSIX
两个词都是"标准"的名字,不是某个具体系统:
POSIX 是"基础可移植标准",XSI 是"比 POSIX 更全的扩展标准",XSI ⊇ POSIX。
POSIX(IEEE 1003.1,Portable Operating System Interface):
- 由 IEEE 制定,目标是"同一份代码能在不同 Unix 系统上编译运行"
- 定义最小必备 API:进程(
fork/exec/wait)、信号、管道、文件 I/O、线程等 - 我们学的
pipe、kill、pthread_create 都是 POSIX 定义的 - 特性测试宏:
_POSIX_C_SOURCE
XSI(X/Open System Interface,X/Open 系统接口):
- X/Open 是商业标准组织(后并入 The Open Group),在 POSIX 基础上加码,定义更完整的接口
- 只有同时满足 POSIX + XSI 扩展的系统才有资格叫"UNIX"(UNIX 商标在 Open Group 手里)
- 特性测试宏:
_XOPEN_SOURCE
两者关系:
ISO C(语言标准,最基本)
│
POSIX(IEEE,可移植接口的"底线")
│ ⊂
XSI(X/Open 扩展,接口的"完整版")
│
Single UNIX Specification(SUS)= POSIX + XSI 合订本
一个系统想自称 UNIX 就得满足 XSI;只满足 POSIX 只能叫"类 Unix"。Linux 接口基本满足 POSIX,且大部分满足 XSI。
为什么 APUE 里叫"XSI IPC": 消息队列、信号量、共享内存最初是 System V(AT&T 的 Unix 版本)发明的,所以也叫"System V IPC";后来被 X/Open 收编进 XSI 规范成为标准接口。同一个东西两个名字:
XSI IPC = System V IPC = 消息队列 + 信号量 + 共享内存
(正式叫法,按标准归属) (旧叫法,按出身)
对照的另一组叫 POSIX IPC(sem_open/mq_open/shm_open),是后来按 POSIX 风格重新设计的接口:用字符串名字、不用 ftok。
| 术语 | 是什么 | 类比 |
|---|
| POSIX | IEEE 制定的可移植接口底线 | 行业通用的"最低配置" |
| XSI | X/Open 在 POSIX 上的完整扩展 | 想挂"UNIX"招牌的"认证标准" |
| XSI IPC | 被收编进 XSI 的 System V IPC | 一套接口的两个名字 |
核心: POSIX 和 XSI 都是标准组织的规范;XSI 是 POSIX 的超集。第 15 章的"XSI IPC"就是 System V 的消息队列/信号量/共享内存,只是按标准归属命名的正式叫法。
13.2 IPC 全景图(先建立地图)
进程间通信(IPC)
├── 管道(pipe) —— 亲缘关系进程,半双工
├── FIFO(命名管道) —— 无亲缘关系,有路径名
├── XSI IPC(System V) —— 用"键 key"标识
│ ├── 消息队列 —— 有类型的数据块
│ ├── 信号量 —— 同步/互斥(回顾 8.10)
│ └── 共享内存 —— 最快,零拷贝
├── POSIX IPC —— 用"名字"标识(更新、更好用)
│ ├── POSIX 消息队列
│ ├── POSIX 信号量
│ └── POSIX 共享内存(mmap)
└── socket(第 16 章) —— 跨主机/无亲缘关系进程
13.3 管道(Pipes)—— 最经典的 IPC
本质:内核中的一段缓冲区,半双工(单向数据流)。
#include <unistd.h>
int fd[2];
pipe(fd); // fd[0] 读端,fd[1] 写端
只能在有亲缘关系的进程间使用(父子的 fd 通过 fork 继承)。经典父子通信模式:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main(void) {
int fd[2];
pid_t pid;
char line[128];
pipe(fd); // 先建管道
pid = fork(); // 再 fork(子进程继承两个 fd)
if (pid == 0) { // 子进程:读
close(fd[1]); // ① 关闭用不到的写端
read(fd[0], line, sizeof(line));
printf("子进程读到: %s\n", line);
close(fd[0]);
} else { // 父进程:写
close(fd[0]); // ① 关闭用不到的读端
write(fd[1], "hello pipe", 10);
close(fd[1]);
wait(NULL);
}
return 0;
}
关键规则:
| 情形 | 结果 |
|---|
写端全部关闭,读端 read | 返回 0(EOF) |
读端全部关闭,写端 write | 收到 SIGPIPE(默认终止进程) |
写入 ≤ PIPE_BUF(4096)字节 | 原子(不会被其他写者拆开) |
⚠️ 两个致命坑:① 管道只能单向;② fork 后必须关闭自己用不到的端,否则"写端没全关"会导致读不到 EOF,进程挂死(经典 bug)。
popen/pclose:管道 + fork + exec + wait 的封装,执行 shell 命令并读输出:
#include <stdio.h>
FILE *fp = popen("ls -l", "r"); // 执行命令并建立读管道
char buf[256];
while (fgets(buf, sizeof(buf), fp)) fputs(buf, stdout);
pclose(fp);
13.4 FIFO(命名管道)
管道只能亲缘关系进程用(靠继承 fd),FIFO 有路径名,任何进程都能打开:
#include <sys/stat.h>
mkfifo("/tmp/myfifo", 0644); // 创建命名管道(可命令行 mkfifo)
int fd = open("/tmp/myfifo", O_WRONLY); // 写者
int fd = open("/tmp/myfifo", O_RDONLY); // 读者
- 打开规则:
open 读端会阻塞直到有写者打开,反之亦然(除非用 O_NONBLOCK) - 用途:服务器-客户端通信、跨 shell 进程通信(
cat a > fifo 等)
13.5 XSI IPC(System V)公共特性
消息队列、信号量、共享内存统称 XSI IPC,共用一套机制:
key(键)→ 内核创建 → 返回标识符(id)→ 后续操作都用 id
用 ftok 生成 key(路径 + 项目 ID):
#include <sys/ipc.h>
key_t key = ftok("/tmp/somefile", 'A'); // 路径必须存在,'A' 是项目 ID
生命周期(和管道最大的不同):
管道:进程退出 → 管道消失(随进程)
XSI IPC:进程退出 → 对象还在!必须用 ipcrm 或程序显式删除
管理命令:
ipcs # 查看消息队列/信号量/共享内存
ipcrm -m id # 删除共享内存(-q 队列、-s 信号量)
13.6 消息队列(Message Queues)
管道是字节流,消息队列是"有类型、有边界"的消息块:
#include <sys/msg.h>
// 1. 创建/获取:msgget(key, IPC_CREAT|0666)
int msqid = msgget(ftok("/tmp/f", 'M'), IPC_CREAT | 0666);
// 2. 定义消息结构(第一个成员必须是 long 类型):
struct msg { long mtype; char mtext[100]; } msg;
// 3. 发送
msg.mtype = 1;
strcpy(msg.mtext, "hello");
msgsnd(msqid, &msg, sizeof(msg.mtext), 0);
// 4. 接收(第三个参数 mtype:>0 取该类型第一条,0 取队首,<0 取类型最小值)
msgrcv(msqid, &msg, sizeof(msg.mtext), 1, 0);
// 5. 删除
msgctl(msqid, IPC_RMID, NULL);
和管道的区别: ① 按类型读取(如类型 1 给进程 A、类型 2 给进程 B);② 消息有边界;③ 生命周期独立于进程。
13.7 System V 信号量(Semaphores)
回顾 8.10:信号量是计数器 + P/V 操作。System V 版本的特点是"信号量集"(一组计数器):
#include <sys/sem.h>
// 1. 创建含 1 个信号量的集合(semget)
int semid = semget(ftok("/tmp/f", 'S'), 1, IPC_CREAT | 0666);
// 2. 初始化(semctl 的 SETVAL)
int val = 1;
semctl(semid, 0, SETVAL, val);
// 3. P/V 操作(semop)
struct sembuf op;
op.sem_num = 0; // 集合中第 0 个信号量
op.sem_op = -1; // -1 = P(申请,减 1);+1 = V(释放,加 1)
op.sem_flg = 0;
semop(semid, &op, 1); // 阻塞执行(SEM_UNDO 可防进程崩溃残留)
// 4. 删除
semctl(semid, 0, IPC_RMID, NULL);
与 POSIX 信号量对比:
| | System V 信号量 | POSIX 信号量 |
|---|
| 标识 | key + 标识符 | 名字(字符串) |
| 操作 | semget/semop/semctl | sem_open/sem_wait/sem_post |
| 集合 | 一组信号量 | 单个计数器 |
| 线程间 | 也可以 | 更常用(sem_init) |
13.8 共享内存(Shared Memory)—— 最快的 IPC
多个进程的地址空间映射到同一块物理内存,直接读写,零拷贝:
#include <sys/shm.h>
// 1. 创建:shmget(key, 大小, IPC_CREAT|0666)
int shmid = shmget(ftok("/tmp/f", 'H'), 4096, IPC_CREAT | 0666);
// 2. 挂接(把共享内存映射进本进程地址空间):
void *p = shmat(shmid, NULL, 0);
strcpy((char *)p, "写入共享内存"); // 直接当指针用
// 3. 脱接:shmdt(p)
// 4. 删除:shmctl(shmid, IPC_RMID, NULL)
为什么快?
管道/消息队列:进程A → 内核 → 进程B(两次拷贝)
共享内存: 进程A 和 进程B 直接读写同一块内存(0 次拷贝)
⚠️ 共享内存自身不同步! 多个进程同时写会乱,必须配合信号量做互斥(经典组合:共享内存传数据 + 信号量做同步)。
13.9 POSIX IPC(更现代的选择)
| 功能 | System V | POSIX |
|---|
| 消息队列 | msgget/msgsnd/msgrcv | mq_open/mq_send/mq_receive |
| 信号量 | semget/semop/semctl | sem_open/sem_wait/sem_post |
| 共享内存 | shmget/shmat | shm_open + mmap + ftruncate |
POSIX 优势:名字是字符串(不用 ftok)、信号量可用于线程间、共享内存和 mmap 统一、接口更简洁。
// POSIX 共享内存(本质 = shm_open 打开 + mmap 映射)
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/stat.h>
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666); // 打开命名共享内存
ftruncate(fd, 4096); // 设置大小
char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(p, "hello"); // 直接写
13.10 IPC 选型对比表
| IPC | 亲缘要求 | 方向 | 持久性 | 数据模型 | 速度 |
|---|
| 管道 | 必须亲缘 | 单向 | 随进程 | 字节流 | 中 |
| FIFO | 无 | 单向 | 随进程 | 字节流 | 中 |
| 消息队列 | 无 | 双向 | 不随进程 | 有类型消息 | 中 |
| System V 信号量 | 无 | — | 不随进程 | 计数器 | 快 |
| 共享内存 | 无 | 双向 | 不随进程 | 裸内存 | 最快 |
| socket | 无 | 双向 | 随连接 | 字节流 | 中 |
13.11 本章速查表
| 主题 | 关键内容 |
|---|
| 管道 | pipe/popen/pclose,fd[0] 读 / fd[1] 写,亲缘进程 |
| FIFO | mkfifo,无亲缘进程,打开会阻塞 |
| key | ftok(路径 + 项目 ID),ipcs/ipcrm |
| 消息队列 | msgget/msgsnd/msgrcv/msgctl,有类型消息 |
| System V 信号量 | semget/semop/semctl,信号量集 |
| 共享内存 | shmget/shmat/shmdt/shmctl,零拷贝,需配信号量 |
| POSIX IPC | mq_*/sem_*/shm_open+mmap,字符串名字 |
核心一句话: IPC 选型口诀——有亲缘用管道,无亲缘用 FIFO,要传"带类型消息"用消息队列,要互斥同步用信号量,要传大数据用共享内存(配信号量),跨主机只能用 socket。System V 和 POSIX 是两套接口,POSIX 更新更推荐。
附:内容出处
以上内容来自本次会话的学习问答,对应《Unix 环境高级编程》(APUE)相关内容:
- 标准 I/O 库、流、内存流 → 第 5 章
- 系统数据文件与时间信息 → 第 6 章
- 进程环境 → 第 7 章
- 进程控制 → 第 8 章
- 进程关系 → 第 9 章
- 信号 → 第 10 章
- 线程 → 第 11 章
- 线程控制 → 第 12 章
- 守护进程 → 第 13 章
- 高级 I/O → 第 14 章
- 进程间通信 → 第 15 章