Unix 学习笔记:进程间通信、高级 I/O、守护进程、线程控制、线程、信号、进程关系、进程控制、进程环境、标准 I/O 库与系统数据文件

来源:本次会话学习总结
覆盖内容:Unix 标准 I/O 库(第 5 章)、流(Stream)、内存流、系统数据文件与信息(第 6 章)、进程环境(第 7 章)、进程控制(第 8 章)、进程关系(第 9 章)、信号(第 10 章)、线程(第 11 章)、线程控制(第 12 章)、守护进程(第 13 章)、高级 I/O(第 14 章)、进程间通信(第 15 章)

目录

  1. Unix 标准 I/O 库
  2. 什么是流(Stream)
  3. 什么是内存流(Memory Streams)
  4. 系统数据文件与信息(第 6 章)
  5. 进程环境(第 7 章)
  6. 进程控制(第 8 章)
  7. 进程关系(第 9 章)
  8. 信号(第 10 章)
  9. 线程(第 11 章)
  10. 线程控制(第 12 章)
  11. 守护进程(第 13 章)
  12. 高级 I/O(第 14 章)
  13. 进程间通信(第 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/writefread/fwrite/fgets/printf
跨平台仅 POSIXC 标准,跨平台

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 常见的坑

  1. 缓冲导致输出不及时——例如 printf 后直接 fork,缓冲区里的内容会被复制,可能输出两次。解决:fflush 或 setvbuf 关缓冲。
  2. 混用系统 I/O 和标准 I/O 要小心,需用 fdopen/fileno 转换,并注意缓冲同步(用 fflush,fsync 不适用)。
  3. 文本/二进制模式在 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 特性与注意点

特性fmemopenopen_memstream
缓冲区谁提供你自己,大小固定库动态分配,自动增长
写满怎么办报错/截断自动扩容
释放不需要(缓冲区是你的)必须 free()
当前读写位置可 fseek/ftell写位置恒在末尾
缓冲行为全缓冲,需要 fflush/fclose 才生效同上

共同点/坑:

  • 没有文件描述符——fileno() 对它无效,read/write 系统调用也不能用
  • 都是全缓冲,必须 fflush 或 fclose 后数据才真正进入缓冲区
  • 适合把"解析内存数据"写成熟悉的文件 I/O 风格

3.5 典型用途

  1. 拼接字符串:比手动 strcat/sprintf 安全,天然防溢出(open_memstream 自动扩容)
  2. 解析内存数据:从网络收到的报文、解压后的数据,直接用 fscanf/fgets 解析,不用先写临时文件
  3. 序列化/反序列化:把结构体格式化到内存再发送
// 示例:解析内存中的 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/passwdgetpwnam/getpwuidgetpwent/setpwent/endpwent
/etc/groupgetgrnam/getgrgidgetgrent/setgrent/endgrent
/etc/hostsgethostbyname/gethostbyaddrsethostent/endhostent
/etc/networksgetnetbyname/getnetbyaddrgetnetent/setnetent/endnetent
/etc/protocolsgetprotobyname/getprotobynumbergetprotoent/setprotoent/endprotoent
/etc/servicesgetservbyname/getservbyportgetservent/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);

几个易错点:

  1. localtime/gmtime/ctime 返回静态存储区的指针,多线程下要用 localtime_r 等可重入版本
  2. time_t 受 2038 年问题影响(32 位系统),需用 64 位 time_t 或 timespec
  3. strftime 的 %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);             // 释放

常见错误:

  1. 忘记检查返回的 NULL(分配失败)
  2. 释放后继续使用(悬垂指针)
  3. 重复释放同一块内存
  4. 忘记释放 → 内存泄漏
  5. 分配/释放大小不匹配
底层机制: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_CPUCPU 时间上限(秒)
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 共享),所以子进程写文件会影响父进程的读写位置。

典型用途:

  1. 父进程复制自己,父子执行不同代码段(网络服务器:父进程 accept,子进程处理)
  2. 进程要执行另一个程序(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。

常见错误:

  1. wait 返回 -1 且 errno == ECHILD 表示没有子进程可等,循环条件用 > 0 而不是 >= 0
  2. 子进程别用 return,会多走父进程清理逻辑,用 _exit
  3. WEXITSTATUS 只在 WIFEXITED 为真时有效,判断顺序别反
  4. 多子进程时 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 本章速查表

主题关键函数
获取 IDgetpid/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 就是靠这套机制实现的。

作业控制信号:

信号触发默认行为
SIGINTCtrl+C终止前台进程组
SIGQUITCtrl+\终止并产生 core
SIGTSTPCtrl+Z暂停前台进程组
SIGCONTfg/bg/kill -CONT继续被暂停的作业
SIGTTIN后台进程读终端暂停后台进程(通知用户)
SIGTTOU后台进程写终端暂停后台进程(可忽略)

三条铁律:

  1. 只有前台进程组能读终端(后台进程读 → 收到 SIGTTIN 被暂停)
  2. 只有前台进程组能写终端(后台进程写 → 收到 SIGTTOU)
  3. 终端产生的信号(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/SIGHUPCtrl+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内核进程处理函数
能否被进程捕获否否能
是否产生信号否(异常才转信号)否本身是信号

回答常见疑问"硬/软中断产生的都是信号吗"——不是,分三种情况:

  1. 硬中断本身不产生信号:网卡收包、时钟滴答由内核 ISR 处理,进程毫无感知
  2. 硬件"异常"(exception)才产生信号:CPU 执行指令出错,内核捕获后转换成信号发给进程:

    • 除零 → SIGFPE;非法内存访问 → SIGSEGV;非法指令 → SIGILL
    • ⚠️ 术语区别:硬件中断(interrupt)不产生信号,硬件异常(exception)才产生信号
  3. 外设中断可间接产生信号:内核处理中断时顺带发信号:

    • 定时器中断 → 检查 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。处理:

  1. 设置 SA_RESTART 让内核自动重启
  2. 或手动判断 EINTR 重试:
while ((n = read(fd, buf, sizeof(buf))) == -1 && errno == EINTR)
    ;   /* 被信号打断,重试 */

可重入函数(重点): 信号处理函数中调用别的函数时,该函数必须是可重入的——不能使用静态数据、不能调用 malloc/printf 等非异步信号安全的函数。

不可在 handler 中调用可在 handler 中调用
printf/fprintfwrite/read
malloc/freewait/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/SIGQUIT
  • sleep/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 操作:释放一个车位,唤醒等待者
  • 资源 → 计数:初始值 N 表示同时最多 N 个进程进入临界区

    • N = 1 就是互斥锁(binary semaphore)
    • N > 1 是计数信号量(连接池、生产者消费者)
  • 类比:停车场空位显示牌——控制"最多几个人能同时进入"

最关键的混淆点(命名撞车):

信号量的英文是 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);

避免死锁的招数:

  1. 所有线程按同一顺序加锁(锁 A → 锁 B,不要交叉)
  2. 用 trylock 拿不到就释放已有的锁,退回重试
  3. 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);

两个关键坑:

  1. wait 前必须先持有互斥量;wait 会"原子地释放锁 + 阻塞",唤醒后重新获得锁再返回
  2. 条件检查必须用 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
获取/比较 IDpthread_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_setdetachstatePTHREAD_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 / getpwuidgetpwnam_r / getpwuid_r
localtime / gmtimelocaltime_r / gmtime_r
strtokstrtok_r
randrand_r
getlogingetlogin_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 线程与信号(重点)

核心规则(关键):

  1. 每个线程有自己的信号屏蔽字(可单独 pthread_sigmask 设置)
  2. 但信号处理函数是进程级的(所有线程共享,注册一次全进程生效)
  3. 进程收到的信号只递送给一个线程(通常是最先能接收的那个)
#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/nullstdin/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 增删改,不需要反复拷贝集合

三选对比:

selectpollepoll
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/Oaio_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。

术语是什么类比
POSIXIEEE 制定的可移植接口底线行业通用的"最低配置"
XSIX/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/semctlsem_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 VPOSIX
消息队列msgget/msgsnd/msgrcvmq_open/mq_send/mq_receive
信号量semget/semop/semctlsem_open/sem_wait/sem_post
共享内存shmget/shmatshm_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] 写,亲缘进程
FIFOmkfifo,无亲缘进程,打开会阻塞
keyftok(路径 + 项目 ID),ipcs/ipcrm
消息队列msgget/msgsnd/msgrcv/msgctl,有类型消息
System V 信号量semget/semop/semctl,信号量集
共享内存shmget/shmat/shmdt/shmctl,零拷贝,需配信号量
POSIX IPCmq_*/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 章

标签: none

添加新评论