LWLeon.
LEON / COMP2017

系统编程:C、进程与并发

把 C 与系统编程拆成能够跟上的小步骤。从小程序开始,画清内存和资源所有权,再用文件、进程和线程完成实用工具。

24 个主题 · 中英双语 · 57 份可运行代码

LEON / LEARNING MAP

先看学习路线

按箭头顺序学习。点击一个阶段进入第一节;完整知识框架列出了每一个主题。

  1. 01

    C 对象与数据

    先理解值如何存放,以及指针能在何时访问哪些对象。再用这些规则组织文本、结构和动态数据。

    8 个主题 ↗
  2. 02

    构建与执行程序

    沿着源代码到运行进程的路线,理解预处理、链接、信号和子进程的生命周期。

    5 个主题 ↗
  3. 03

    通信与同步

    先选择数据如何传递,再定义谁能修改共享状态,以及参与者应在什么条件下等待或继续。

    8 个主题 ↗
  4. 04

    扩展与验证

    把正确的程序划分成并行任务。用相同的工作量、可重复的测量和调试证据检验性能与正确性。

    3 个主题 ↗
LEON / FROM IDEAS TO IMPLEMENTATION

继续深入:完整案例

先读对应章节,再跟着故事预测输出、运行完整程序、核对解释。每个案例都在原有概念上增加一个实际问题。

  1. 01

    安全扩容自己拥有的数组

    把所有权、溢出检查和摊还成本连起来。

    故事 · 代码 · 推理
    先读对应章节: 动态分配与对象生命周期
  2. 02

    从字节流中读出完整消息

    区分短读、正常 EOF 和正文截断。

    故事 · 代码 · 推理
    先读对应章节: 管道、FIFO 端点与字节分帧
  3. 03

    定义文件格式,而不是直接写出结构体

    设计、编码、校验并测试小型二进制格式。

    故事 · 代码 · 推理
    先读对应章节: 文件流、二进制记录与错误
LEON / COMP2017 / START HERE

从 C 入门到完整系统的循序路径

不必一次记住 24 章。按六轮学习:阅读小例子、预测输出、实际运行、解释一次修改,再完成一个工具。工程使用原创模拟数据,并提供完整讲解。

  1. 01

    先让一个程序运行

    构建、运行并修改一个小程序。区分源文件、目标文件和可执行文件。

    开始这一轮 →
    章节与过关检查

    过关标准:你能解释为什么修改 .c 文件后要重新构建,以及非零退出状态为什么表示失败。

  2. 02

    使用内存前先画清楚

    画出对象、箭头和有效范围。先掌握数组与所有权;链表和回调可以在第一个工程之后回来补学。

    开始这一轮 →
    章节与过关检查

    过关标准:不运行程序,你也能指出一个缓冲区的所有者、容量、已初始化长度和释放时机。

  3. 03

    完成一个实用文件工具

    连接读取、解析、统计和输出。把无效数据与输入输出失败分开处理。现在构建日志分析器。

    开始这一轮 →
    章节与过关检查

    过关标准:你能测试空文件、格式错误的行、不存在的文件和最后一个合法输入边界。

    动手完成工程 ↗
  4. 04

    画清进程边界

    先在进程运行器里组合 fork、exec、pipe 和 wait,再深入信号与其他进程间通信方式。

    开始这一轮 →
    章节与过关检查

    过关标准:你能列出每个进程持有的管道端点,并解释读取者何时收到 EOF。

    动手完成工程 ↗
  5. 05

    保护一个共享不变量

    先让工作线程各自拥有结果,再加入有界队列、等待条件和关闭规则,完成并发流水线。

    开始这一轮 →
    章节与过关检查

    过关标准:你能解释哪把锁保护 count、为什么 wait 外层是 while,以及队列关闭时要唤醒谁。

    动手完成工程 ↗
  6. 06

    解释清楚正确性后再改进

    先建立串行参考和相同输入。每次只改变一个设计选择,测量整个任务,再比较结果。

    开始这一轮 →
    章节与过关检查

    过关标准:你能区分正确性结论、实验测量的时间和理论加速比上界。

LEON / ENGINEERING WORKSHOPS

从小例子走向完整工具

打开工程,阅读需求、结构、分步讲解、完整源码、调试过程与测试。

下载全部三个完整工程 ↓

阅读字号
下载全部 Python、Java 和 C 示例 ↓
第一次运行这些示例?

编译器把 C 源代码转换成可执行程序。使用 macOS 或 Linux 上的 C11 编译器。进程、信号和线程示例需要 POSIX 系统接口;Windows 可使用 WSL 中的 Linux 环境。

下载一个示例,在它所在的文件夹中打开终端。终端是输入命令、查看文字结果的窗口。把 filename.c 换成实际文件名。先编译,再运行 ./demo:./ 表示当前文件夹,demo 是生成的程序。

-std=c11 选择 C11;-Wall、-Wextra 和 -pedantic 打开更多诊断;-pthread 启用线程支持;-lm 链接数学库;-o demo 指定输出程序名称。结果应与页面的预期输出一致。

cc -std=c11 -Wall -Wextra -pedantic -pthread filename.c -lm -o demo
./demo

Clang 官方编译器说明 ↗

开始学习之前

包含 24 章、57 份独立程序的 C11 与 POSIX 学习路径。六轮学习计划带你完成三个完整讲解的工程,逐步理解需求、接口、完整源码、调试与测试。深入分支可随时回看。

开始前需要了解

  • 掌握变量、循环和函数等基本编程知识。
  • 了解最基本的二进制计算。
  • 能够阅读简短的 C 表达式和终端命令。

你将学到什么

  • 跟踪 C 对象、指针和生命周期,避免依赖未定义行为。
  • 解释编译、链接以及资源由谁负责管理。
  • 选择合适的 Unix 进程与进程间通信机制,并说明选择理由。
  • 用明确的不变式和同步操作协调线程。
  • 选择合适的测量方法,评价并行程序的性能。

把知识连接起来

按阶段循序渐进。也可以点击任一主题,阅读故事、方法与例子。

COMP2017 / 01Leon

位、C 类型与明确的控制流程

核心基础

先建立一个简单模型

对象有类型、数值和有效范围。

把它用在工程决策里

运算之前先问结果是否在类型范围内。某次成功运行不能证明溢出行为合法。

7/2 为什么不同于 7.0/2?

前者使用整数除法,得到 3;后者使用浮点运算,得到 3.5。

01 / 从一个故事开始

一张货运标签上的同一串数字,可能表示数量、商品编号,也可能表示价格。计算机中的位也一样:要结合表示方式和类型,才知道它们的含义。一个字节只是存储空间,本身没有要求我们把它读成字母或有符号数。C 要求程序员明确这些选择,再用控制流程决定执行哪些操作。

函数收到的是实参的副本。这就像办事员拿到一份复印表格。修改复印件不会改变原件;不过,如果表格里记着一个可用的原件地址,就可以通过这个地址找到并修改原件。

02 / 理解原理

位值制把每一位数字乘以对应的基数幂,再把结果相加。C 整数类型的取值范围有限。无符号算术按“最大可表示值加一”取模。有符号算术溢出则是未定义行为。现代 C 的整数除法向零截断;需要小数结果时,应让至少一个操作数采用浮点类型。

sizeof 返回对象占用的 C 字节数,结果类型是 size_t。一个字节含有 CHAR_BIT 位,并不是在所有实现上都固定为八位。自动对象必须先初始化再读取。条件中的零表示假,非零表示真。&& 和 || 会短路求值,按位运算符则不同。循环需要明确的边界,也要保证能够向结束条件前进。函数要声明参数和返回值类型。实参按值传递;接口还应清楚说明返回的是结果还是错误。

先认识这些词

无符号值
不含负数范围的整数值,其算术使用规定模数。
未定义行为
C 标准不规定行为要求的情况。
不变式
在选定程序边界始终保持的事实。

03 / 逐项读懂公式

B 进制数字 aₘ…a₀ 的值 = Σᵢ aᵢBⁱ
含 w 个值位的无符号范围:0 … 2ʷ−1
无符号结果 = 数学结果 mod 2ʷ
循环不变式示例:sum = Σⱼ₌₀ⁱ⁻¹ a[j]

B 是进制的基数。aᵢ 是零到 B−1 之间的一位数字,i 是从右侧开始计数的位置。无符号公式中的 w 只计算用于表示值的位,不包括填充位。它不能用于解释有符号溢出。

循环不变式中的 i 表示已经处理的数组元素个数,sum 是这些元素的累加值。这里假设每次加法的结果都能被所用类型表示。不变式说明在每次循环边界处始终成立的事实。它可以帮助我们证明结果正确,也可以帮助检查访问边界。

04 / 先看一个完整例子

二进制数 101101 等于 32+8+4+1=45,写成十六进制是 2D。如果一个实现的 unsigned char 是八位,把数学值 260 转换成这个类型会得到 4。相反,给最大的有符号 int 加一,没有可移植的数值答案。

对三个元素的数组 {2,4,7},令 sum=0、i=0。每轮把 a[i] 加入 sum,再把 i 增加一。sum 依次变成 2、6、13。条件 i<3 阻止了无效的第四次访问。表达式 13/3 得到整数 4;13.0/3 得到约为 4.3333 的浮点结果。

一步步想清楚

  1. 成功运行能证明有符号溢出安全吗?

    不能。一次观察结果不能使未定义行为合法,应在算术前检查。

  2. 为什么除法前转换?

    转换已经完成的整数商无法恢复舍弃的小数。

  3. sizeof 统计什么?

    统计 C 字节,CHAR_BIT 指明该实现每字节的位数。

运行一个完整示例 · 01

观察有定义回绕,保护有符号加法

使用实现极限,不执行有符号溢出。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>

int main(void) {
    unsigned int largest = UINT_MAX;
    printf("unsigned wrap=%u\n", largest + 1u);
    int value = INT_MAX;
    if (value > INT_MAX - 1)
        puts("signed addition rejected");
    else
        printf("signed sum=%d\n", value + 1);
    printf("integer quotient=%d\n", 13 / 3);
    printf("floating quotient=%.4f\n", 13.0 / 3.0);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread representation-control-c-defined-integer-arithmetic.c -lm -o demo
./demo

预期输出

unsigned wrap=0
signed addition rejected
integer quotient=4
floating quotient=4.3333

读懂这里的语法

unsigned int
声明采用模算术的非负整数类型。
UINT_MAX
从 limits.h 读取 unsigned int 最大值,不假定宽度。
printf("%u\n", value)
打印 unsigned int,格式必须匹配实参类型。
#include <stdio.h>
调用 printf 等标准输入输出函数前,包含其声明。
int main(void)
定义程序入口。int 是状态返回类型;这里 void 表示无参数。花括号包围语句块。
return 0;
以成功进程状态结束 main。分号结束语句;本组示例用非零状态表示失败。

按执行顺序理解

  1. 从头文件读取最大值,并用无符号算术相加。

  2. 在有符号加法越界前拒绝。

  3. 比较整数与浮点除法。

跟踪状态的变化
操作结果
UINT_MAX + 1u0
13 / 34
13.0 / 3.04.3333

逐步读懂代码

UINT_MAX 加一在所有符合要求的 unsigned int 表示中都回绕到零。有符号分支先检查极限,因此没有执行有符号溢出。

最后一对在除法前改变操作数类型。四位小数是输出格式,不表示二进制浮点表示精确。

运行一个完整示例 · 02

跟踪前缀和与复制形参

把循环不变式与独立函数调用联系起来。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

static int replace_copy(int value) {
    value = 9;
    return value;
}
int main(void) {
    int values[] = {2, 4, 7};
    int sum = 0;
    size_t count = sizeof values / sizeof values[0];
    for (size_t i = 0; i < count; i++) {
        sum += values[i];
        printf("processed=%zu sum=%d\n", i + 1, sum);
    }
    int original = 5;
    printf("returned=%d\n", replace_copy(original));
    printf("caller=%d\n", original);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread representation-control-c-prefix-and-pass-by-value.c -lm -o demo
./demo

预期输出

processed=1 sum=2
processed=2 sum=6
processed=3 sum=13
returned=9
caller=5

按执行顺序理解

  1. 在数组声明所在位置推导元素数。

  2. 每次更新后打印前缀不变式。

  3. 比较返回值与未改变的调用者变量。

逐步读懂代码

处理下标 i 前,sum 恰好覆盖之前元素。这里 values 是真实数组,不是调整后的函数形参,所以长度表达式有效。

replace_copy 修改自己的形参并返回九。调用者打印返回值,却没有赋给 original,因此 original 仍为五。固定小输入让所有和都可表示。

Leon 的进一步思考

区分表示与含义

数字标识与数量可能共享整数类型,却需要不同验证。类型范围只是领域范围的外边界。

有意选择溢出行为

无符号回绕适合模运算,但分配大小或项目数量回绕通常是缺陷。行为有定义仍可能违反预期契约。

注意这个边界. 不能因为一台机器上观察到了某种溢出结果,就把它当作语言保证。无符号回绕有明确定义;有符号溢出没有。
检查自己的理解: 函数参数是 int x 时,为什么在函数内执行 x=9 不能改变调用者的整数?

参数是一个独立对象,用实参的值初始化。可以返回新值,让调用者接收;也可以传入指向调用者对象的指针,再通过该指针修改那个对象。

↑ 返回知识框架
COMP2017 / 02Leon

指针、数组与多维布局

核心基础

先建立一个简单模型

指针是地址值,它指向的对象是另一件事。

把它用在工程决策里

每个对象画一个方框。复制指针只是多了一个访问同一对象的途径,不会复制对象或延长其生命周期。

两个指针指向同一个整数,通过其中一个写入会改变另一个读到的值吗?

会,前提是对象仍存活且两次访问都有效。它们指向同一目标,不是两个副本。

01 / 从一个故事开始

公寓目录记录大楼地址、楼层和房间号。地址只有在大楼确实存在、房间也属于这栋楼时才有用。C 指针同样需要有效对象;解引用时,对象的生命周期还必须没有结束。指针加一按所指类型的对象大小前进,就像向上走一层,而不是移动一厘米。

二维数组像一栋各层大小相同的楼。指针数组则像一组路线说明,可能通向互不相关的大楼。两种写法看起来相似,背后的布局却可能完全不同。

02 / 理解原理

指针保存一个指针值。只有满足类型、对齐、生命周期和边界要求,解引用才能访问它指定的对象。数组的元素连续存放,但数组对象本身不是一个指针变量。大多数表达式会把数组转换成指向首元素的指针;sizeof 和一元 & 是两个重要例外。

指针运算可以在同一数组内进行,也可以形成恰好指向数组末尾之后一个位置的指针。这个指针可以形成,但不能解引用。两个指针相减要求它们属于同一数组,结果类型是 ptrdiff_t。真正的 T[R][C] 是由 R 个行数组组成的数组,所以它通常转换成“指向含 C 个元素的一行”的指针,而不是 T**。数组形式的函数参数会被调整成指针参数,因此还要显式传入长度。

先认识这些词

所指对象
指针所指定的活动对象。
尾后一位指针
可作为数组边界标记、但不可解引用的指针。
行指针
所指类型为整行数组的指针。

03 / 逐项读懂公式

对有效的 i:a[i] ≡ *(a+i)
数组字节数 = N·sizeof(T)
a[r][c] 按行存储的字节偏移量 = (r·C+c)·sizeof(T)
int a[R][C] 转换为 int (*)[C],不是 int **

N 是一维数组的元素个数,T 是元素类型。按行存储公式中的 r 和 c 分别是从零开始的行号和列号,C 是列数。所有下标都必须在范围内。

这个偏移量描述对象在字节层面的布局,并不允许用任意一个标量指针跨越子数组边界。指向一行的指针每增加一,前进的是完整一行。数组转成指针时,不会把数组长度一起传进函数。

04 / 先看一个完整例子

假设 sizeof(int)=4,并声明 int grid[2][3]={{2,4,6},{8,10,12}}。整个数组占 2×3×4=24 字节。grid[1][2] 相对数组起点的字节偏移量是 (1×3+2)×4=20。声明 int (*row)[3]=grid 后,row 增加一会前进 12 字节;row[1][2] 访问到的值是 12。

相反,int *p=grid[0] 指向第一行的三个元素之内。p+3 恰好位于这一行的末尾之后,不能把解引用它当作可移植的下一行访问方式。应使用能表达“行”的指针类型和下标。即使各行在字节存储中相邻,这个区别仍然重要。

一步步想清楚

  1. 指针记得数组长度吗?

    本身不会。应同时传长度,或另行表达有效边界。

  2. 为什么 int ** 不是二维数组类型?

    它指向 int 指针对象。真实网格转换为行数组指针,布局与步长不同。

  3. 结束指针可以作为元素读取吗?

    不可以。它能标记边界,但解引用数组尾后一位无效。

运行一个完整示例 · 01

从这里开始:副本与地址

先跟踪一个整数,再学习数组的指针运算。先预测每一行输出。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
static void change_copy(int value) {
    value = 9;
    printf("inside copy=%d\n", value);
}
static void change_object(int *address) {
    *address = 9;
}
int main(void) {
    int original = 4;
    int copy = original;
    int *alias = &original;
    change_copy(original);
    printf("after value call: original=%d copy=%d\n", original, copy);
    change_object(alias);
    printf("after address call: original=%d copy=%d\n", original, copy);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pointers-arrays-c-warmup-copy-and-alias.c -lm -o demo
./demo

预期输出

inside copy=9
after value call: original=4 copy=4
after address call: original=9 copy=4

读懂这里的语法

int *alias = &original
保存地址,而不是另一个整数值。
*address = 9
通过有效指针写入它指向的对象。
change_copy(original)
把实参值复制给形参。

按执行顺序理解

  1. 创建两个初值相同、彼此独立的 int 对象。

  2. 传入数字副本,只改变函数自己的参数。

  3. 传入地址副本,解引用后访问调用者的原对象。

跟踪状态的变化
步骤状态
调用之前original=4, copy=4
change_copy 之后original=4, copy=4
change_object 之后original=9, copy=4

逐步读懂代码

想象两张纸条。把数字抄到第二张纸上,不会让两张纸自动关联。指针携带的则是第一张纸的位置。两个函数的参数都是按值传递。第二个函数收到的是地址的副本,再通过它改变原对象。

&original 表示 original 的地址。*address 表示该有效地址处的整数。int *alias 中的星号属于声明;*address = 9 中的星号是解引用表达式。两次调用期间原对象都还存在。这个辅助函数要求传入指向 int 的有效非空指针。

运行一个完整示例 · 02

使用起止指针遍历

把结束指针当哨兵,不当元素。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int values[] = {3, 5, 8};
    size_t count = sizeof values / sizeof values[0];
    const int *begin = values;
    const int *end = values + count;
    int total = 0;
    for (const int *cursor = begin; cursor != end; cursor++) {
        total += *cursor;
    }
    printf("elements=%td\n", end - begin);
    printf("sum=%d\n", total);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pointers-arrays-c-pointer-range.c -lm -o demo
./demo

预期输出

elements=3
sum=16

读懂这里的语法

const int *begin
指向通过此指针只读的整数。const 不延长生命周期。
end = values + count
在允许的数组算术范围内形成尾后边界。
*cursor
访问有效范围内指针所指元素。

按执行顺序理解

  1. 从同一数组建立起点与尾后终点。

  2. 只在游标不同于终点时解引用。

  3. 用同数组指针相减得到遍历数量。

跟踪状态的变化
游标下标动作
0..2读取元素
3停止,不读取

逐步读懂代码

数组转换提供首元素指针。加 count 得到尾后一位。先比较再解引用,因此不会读取哨兵。

相减的两指针属于同一数组。差值统计元素而非字节,%td 匹配 ptrdiff_t。固定输入的和不会溢出。

运行一个完整示例 · 03

使用真实行类型

通过三元素行的指针遍历矩形数组。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int grid[2][3] = {{2, 4, 6}, {8, 10, 12}};
    int (*row)[3] = grid;
    for (size_t r = 0; r < 2; r++) {
        int total = 0;
        for (size_t c = 0; c < 3; c++)
            total += row[r][c];
        printf("row %zu sum=%d\n", r, total);
    }
    printf("second row last=%d\n", (*(row + 1))[2]);
    printf("row elements=%zu\n", sizeof *row / sizeof(*row)[0]);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pointers-arrays-c-row-aware-grid.c -lm -o demo
./demo

预期输出

row 0 sum=12
row 1 sum=30
second row last=12
row elements=3

按执行顺序理解

  1. 声明带行宽的指针。

  2. 分别使用行与列边界。

  3. 独立检查行步进与行长度。

逐步读懂代码

括号让 *row 先于 [3] 结合,所以 row 指向含三个 int 的数组。加一前进一整行,第二下标仍在该行内。

最后的 sizeof 比值统计行元素,不假设 sizeof(int)。程序没有用第一行标量指针跨过其尾后边界。

Leon 的进一步思考

让边界伴随地址

地址本身不是完整缓冲区契约。数量、容量、所有权含义不同,必要时要分别传递。

用类型保留行结构

行指针让编译器正确缩放算术。只有明确采用一维表示时才展平,不应忽视子数组边界。

注意这个边界. 函数参数写成 int a[] 时,函数里的 sizeof(a) 测量的是指针大小,不能由此得到调用者数组的元素个数。
检查自己的理解: 指向五元素数组末尾之后一个位置的指针,可以用于循环比较吗?

可以。它可以充当结束标记,并与同一数组内的指针进行符合规则的比较。但是不能解引用这个结束标记。

↑ 返回知识框架
COMP2017 / 03Leon

字符串、有界输入与解析

核心基础

先建立一个简单模型

容量、文本长度和结束零字节是三个不同事实。

把它用在工程决策里

先写明解析器接受哪些字符串。验证通过后再修改输出对象,使解析失败后的状态明确。

四字节数组能装下 C 字符串 ABCD 吗?

不能。四个字母加结束零字节需要五个字节。只保存四字节原始记录是另一种契约。

01 / 从一个故事开始

把字符想成一节节车厢。列车需要清楚的结束标记,工作人员才知道读到哪里停下。C 字符串用零字节作为结束标记;缓冲区实际能容纳多少字节,是另一件事。如果一直向前寻找一个不存在的标记,就可能走出已分配的轨道。

输入让问题更复杂:新来的列车可能比轨道长。安全的文本处理要记录容量,检查实际到达了多少内容,并决定如何处理剩余输入。它还要区分“表示数字的字符”和数字值本身。

02 / 理解原理

C 字符串是一段以空字符结束的字符序列。逻辑长度不包括终止符,但存储空间必须把它算进去。strlen 要求输入确实是一个有效且已终止的字符串。strcmp 比较内容,而不是指针地址。不能修改字符串字面量;用字面量初始化的独立字符数组,则可以在边界内修改。

fgets 限制存入的字符数,可能保留换行符,也可能只读到长行的一部分。需要完整逻辑行时,就要检查是否截断。scanf 的 %s 转换需要指定宽度,并检查成功转换的项目数。strtol 提供结束指针和范围报告,能支持经过验证的数字转换;不能依赖未经检查的 atoi。ctype.h 的函数只接受 EOF 或能表示为 unsigned char 的值。分类前,应把可能为负的 char 值转换为 unsigned char。

先认识这些词

空字符终止符
标记 C 字符串结束的零字符。
容量
目的缓冲区可用的总字节数。
结束指针
转换返回的、指向第一个未消费字符的指针。

03 / 逐项读懂公式

所需容量 ≥ strlen(text)+1
当 C>1 时,fgets(buffer,C,stream) 最多存入 C−1 个字符,再加终止符
有效整数解析 = 已读到数字 ∧ 后缀符合规定 ∧ 没有范围错误

C 是目标缓冲区的字节容量。strlen 统计第一个空字符之前的字节数,不一定等于用户看到的 Unicode 字符个数。加一是给终止符留空间。

解析表达式是一份检查清单,不是数量公式。要确认至少读取了一些数字,剩余字符被规则允许,并且转换没有超出目标范围。限制读取长度可以保护目标缓冲区,却不能单独证明已经读完一整行。

04 / 先看一个完整例子

char name[6] 最多存放五个普通单字节姓名字符,再加终止符。输入 Maple 后接换行符,调用 fgets(name,6,stdin) 会存入 M、a、p、l、e、零字节。换行符还留在流中。下一次按逻辑行读取前,程序必须读走或用其他明确方式处理这一行的剩余内容。

对输入 "42x",以 10 为基数(十进制)调用 strtol 会得到 42,并让结束指针指向 x。严格的整数解析器应拒绝这个后缀。输入 " 42 " 可以被接受,但前提是规则允许两侧空白,而且已经检查剩余字符全是空白。

一步步想清楚

  1. strlen 是缓冲区容量吗?

    不是。它数首个终止符前的字节,前提是已存在有效终止符。

  2. strtol 返回 42 后为什么拒绝 42x?

    数字前缀有效,但后缀违反严格整串整数契约。

  3. 为什么 isspace 前转换 char?

    除 EOF 外的负 char 超出该函数允许实参范围。

运行一个完整示例 · 01

从这里开始:字符串需要结束字节

先观察一个短字符数组,再使用解析器。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
    char message[6] = "Hi";
    size_t length = strlen(message);
    printf("capacity=%zu length=%zu\n", sizeof message, length);
    for (size_t i = 0; i < length; i++)
        printf("character %zu=%c\n", i, message[i]);
    printf("terminator is zero=%s\n", message[length] == '\0' ? "yes" : "no");
    message[1] = 'o';
    printf("after edit=%s\n", message);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread strings-parsing-c-warmup-string-bytes.c -lm -o demo
./demo

预期输出

capacity=6 length=2
character 0=H
character 1=i
terminator is zero=yes
after edit=Ho

读懂这里的语法

char message[6] = "Hi"
预留六个字节,并初始化带结束符的字符串。
strlen(message)
在有效且有结束符的存储上,计算首个零之前的字节数。
'\0'
用来结束 C 字符串的零字符,不是打印出来的数字字符零。

按执行顺序理解

  1. 区分容量六和有效字符长度二。

  2. 只检查两个字符及范围内的结束符。

  3. 修改一个字符,同时保留结束符。

跟踪状态的变化
步骤状态
初始化H i \0 \0 \0 \0
修改下标 1H o \0 \0 \0 \0

逐步读懂代码

一排六个座位可以只坐两个人。容量是可用存储空间;字符串长度统计第一个零字节之前的字符。字符串字面量初始化 H、i 和结束符。数组剩余元素也会初始化为零。

strlen 假设在有效存储范围内能够找到结束符。它不会替你检查缓冲区容量。修改 message[1] 把 i 换成 o,不移动结束符。这里的 sizeof message 有效,因为当前作用域中 message 就是数组对象。写成 char message[] 的函数形参会被调整为指针。

运行一个完整示例 · 02

把成功状态和结果分开返回

在学习 strtol 之前,先构建一个明确约定失败行为的小解析器。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
static int parse_digit(const char *text, int *result) {
    if (text[0] < '0' || text[0] > '9')
        return 0;
    if (text[1] != '\0')
        return 0;
    *result = text[0] - '0';
    return 1;
}
int main(void) {
    const char *inputs[] = {"0", "7", "12", "", "x"};
    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
        int result = -1;
        int accepted = parse_digit(inputs[i], &result);
        printf("text='%s' accepted=%d result=%d\n", inputs[i], accepted, result);
    }
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread strings-parsing-c-warmup-status-and-output.c -lm -o demo
./demo

预期输出

text='0' accepted=1 result=0
text='7' accepted=1 result=7
text='12' accepted=0 result=-1
text='' accepted=0 result=-1
text='x' accepted=0 result=-1

读懂这里的语法

int *result
调用者拥有的输出对象,与返回状态分开。
return 0
拒绝输入,并保持 *result 不变。
text[0] - '0'
把已验证的一个数字字符转换成整数值。

按执行顺序理解

  1. 先定义接受的格式:一个数字字符,后面紧跟结束符。

  2. 所有检查通过之前,不修改输出对象。

  3. 对于拒绝的输入,同时检查状态标志和保留的原值。

跟踪状态的变化
步骤状态
'0'accepted=1, result=0
'12'accepted=0, result=-1
''accepted=0, result=-1

逐步读懂代码

有效结果本身也可能是零。因此零不能同时表示“解析出的数字是零”和“解析失败”。这个接口返回成功标志,只有成功时才通过另一个指针写入数字。无效文本不会改变调用者的结果。

第一个判断也处理空字符串:首字节是零,不在数字字符范围内,所以在读取 text[1] 之前就返回。若首字节是数字,有效的带结束符字符串就还有下一个字节可检查。C 保证十进制数字字符连续排列,因此减去字符零可得到数字值。契约只接受恰好一个数字,不接受正负号或空格。两个指针都必须有效,text 必须有结束符。

运行一个完整示例 · 03

区分文本长度与存储容量

观察有终止符副本,并明确检测格式化结果截断。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char text[] = "lamp";
    text[0] = 'L';
    printf("text=%s length=%zu capacity=%zu\n", text, strlen(text), sizeof text);
    char small[4];
    int needed = snprintf(small, sizeof small, "%s", "Maple");
    if (needed < 0)
        return 1;
    printf("stored=%s required=%d\n", small, needed);
    printf("truncated=%s\n", (size_t)needed >= sizeof small ? "yes" : "no");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread strings-parsing-c-string-capacity.c -lm -o demo
./demo

预期输出

text=Lamp length=4 capacity=5
stored=Map required=5
truncated=yes

读懂这里的语法

char text[] = "lamp"
创建可修改数组,包含四字母及零终止符。
sizeof text
测量真实数组的字节容量,包含终止符。
snprintf(destination, sizeof destination, ... )
按容量限制写入,并返回不受限制时本会产生的长度。

按执行顺序理解

  1. 查看数组大小与已终止文本长度。

  2. 使用有界格式化函数写入。

  3. 比较返回所需长度与容量。

跟踪状态的变化
缓冲区容量存储文本
text5Lamp
small4Map

逐步读懂代码

text 数组拥有五字节,允许修改首字母。strlen 不含终止符,因此报告四。

容量四时,snprintf 存三个字母及终止符。返回五表示原本完整长度,不含终止符。与容量比较即可发现截断,不会默默把 Map 当完整名字。

运行一个完整示例 · 04

验证整个整数输入

接受周围空白,拒绝后缀、空文本及范围失败。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>
#include <ctype.h>
static int parse_int(const char *text, int *output) {
    char *end;
    errno = 0;
    long value = strtol(text, &end, 10);
    if (end == text || errno == ERANGE || value < INT_MIN || value > INT_MAX)
        return 0;
    while (isspace((unsigned char)*end))
        end++;
    if (*end != '\0')
        return 0;
    *output = (int)value;
    return 1;
}
int main(void) {
    const char *inputs[] = {"42x", " 42 ", "", "99999999999999999999999999999999"};
    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
        int value = 0;
        if (parse_int(inputs[i], &value))
            printf("input %zu accepted=%d\n", i, value);
        else
            printf("input %zu rejected\n", i);
    }
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread strings-parsing-c-strict-integer-parser.c -lm -o demo
./demo

预期输出

input 0 rejected
input 1 accepted=42
input 2 rejected
input 3 rejected

按执行顺序理解

  1. 重置 errno,并记录首个未消费字符。

  2. 同时检查转换范围与目标类型范围。

  3. 整串文本接受后才提交输出。

逐步读懂代码

strtol 读取数字前缀并暴露停止位置。辅助函数在转换为 int 前拒绝没有数字、long 溢出及 int 越界,随后只允许尾部空白。

全部检查成功后才更新输出指针,因此拒绝保留调用者旧值。辅助函数假设指针非 null,分别指向有效终止字符串和可写 int。

Leon 的进一步思考

有界操作仍可能丢失记录

防止缓冲区溢出不保证收到完整逻辑行。应决定拒绝、累积还是有意截断长记录。

解析是策略边界

选择库函数前,先决定是否接受符号和周围空白。转换成功不代表整个领域有效。

注意这个边界. 当输入填满长度上限时,strncpy 不会自动保证结果具有终止符。必须明确复制规则,并检查容量和终止条件。
检查自己的理解: char *p="lamp" 与 char a[]="lamp" 为什么不同?

p 指向不能修改的字符串字面量。a 是一个独立、可修改的五元素数组,里面有四个字母和一个空字符终止符。

↑ 返回知识框架
COMP2017 / 04Leon

结构体、对齐、联合体与类型标签

核心基础

先建立一个简单模型

结构体把字段放在一起,复制结构体会复制字段值。

把它用在工程决策里

复制后的指针字段仍指向同一分配块。要先决定复制意味着共享、转移所有权,还是深复制。

把一个结构体赋给另一个,会复制指向的堆缓冲区吗?

不会,只复制指针值。复制缓冲区需要明确分配并复制内容。

01 / 从一个故事开始

工具箱把尺子、锤子和螺丝刀放成一套,虽然它们形状不同。struct 也把不同类型的成员组成一个对象。工具之间的保护间隔,类似为对齐而加入的填充;箱子的大小可能超过各件工具大小之和。union 则更像一个使用可替换内衬的盒子:不同表示方式共享同一块空间。共享空间之外的标签记录当前打算使用哪一种内衬。

复制一张工具清单,并不等于把所有借来的工具都复制一遍。同样,复制指针成员不会复制指针指向的对象。

02 / 理解原理

struct 在一个聚合对象内,为具名成员提供各自的存储空间。对结构体对象用点运算符,对结构体指针用箭头运算符。结构体赋值会复制各成员的值,包括内嵌数组;但指针成员仍然指向原来的对象,形成别名。对齐由实现规定,并不总是等于类型大小。成员之间和末尾都可能有填充,因此要用 sizeof 和 offsetof,而不是猜布局。

union 提供足以容纳最大成员且满足对齐要求的空间。改存另一个成员时,联合体大小不会变化。可以配合 enum 标签,说明当前打算使用哪一种有效表示。比较结构体值应逐个比较成员,因为填充让原始 memcmp 不适合一般的值相等判断。可移植的持久化需要明确的数据格式,不能直接把内存内容倒进文件。

先认识这些词

成员
结构体或联合体中的命名组成部分。
填充
实现为满足布局要求而插入的存储字节。
带标签联合体
共享成员存储配上说明预期表示的标签。

03 / 逐项读懂公式

p->member ≡ (*p).member
典型的含填充大小 = round_up(最后一个成员的结束位置, 对齐要求)
带标签的值的不变式:tag 指明已初始化的联合体成员

箭头等价式要求 p 有效地指向一个生命周期尚未结束、且包含该成员的结构体或联合体。大小表达式是解释常见实现的布局模型,不是适用于所有 C ABI 的公式。对齐和成员偏移量必须从实现中获取。

标签这一行描述不变式,不是算术关系。enum 提供具名的整数常量。程序仍然必须让标签与已经初始化、并准备读取的联合体成员保持一致。

04 / 先看一个完整例子

假设 char 的大小和对齐都是 1,int 的大小和对齐都是 4,结构体对齐是 4。对 struct Packet { char kind; int count; char flag; },kind 位于偏移 0,偏移 1–3 是填充,count 占偏移 4–7,flag 位于偏移 8。末尾填充让总大小达到 12。

如果重排为 int count; char kind; char flag;,在这些假设下,典型大小可以是 8。一个包含这个 int 和 20 字节字符数组的联合体,至少需要 20 字节;为满足对齐,也可能更多。存入 int 不会把它缩小成四字节。

一步步想清楚

  1. 结构体赋值会复制指针指向的对象吗?

    不会。内嵌数组会复制,指针成员值仍指向同一对象。

  2. memcmp 能通用地判断结构体值相等吗?

    不能。填充与表示细节可能不同,应明确比较逻辑成员。

  3. 谁保持联合体标签一致?

    程序本身。枚举标签不会自动控制初始化了哪个联合体成员。

运行一个完整示例 · 01

复制内嵌数据,共享指向数据

一次赋值后比较两种结构体成员。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

struct Record {
    char label[4];
    int *borrowed;
};
int main(void) {
    int shared[] = {4, 7};
    struct Record original = {{'o', 'a', 'k', '\0'}, shared};
    struct Record copy = original;
    copy.label[0] = 'O';
    copy.borrowed[0] = 9;
    printf("original label=%s value=%d\n", original.label, original.borrowed[0]);
    printf("copy label=%s value=%d\n", copy.label, copy.borrowed[0]);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread structured-data-c-structure-copy-and-alias.c -lm -o demo
./demo

预期输出

original label=oak value=9
copy label=Oak value=9

读懂这里的语法

struct Record
声明把命名字段放在不同存储中的类型。
struct Record copy = original
复制结构体值,包括内嵌数组,但指针只复制值。
copy.borrowed[0]
沿指针成员访问共享对象,结构体复制没有创建新所指对象。

按执行顺序理解

  1. 创建内嵌标签与共享数据指针。

  2. 复制结构体,再分别修改两类成员。

  3. 比较哪些修改跨越了复制边界。

跟踪状态的变化
成员复制后
label独立字节
borrowed同一所指对象

逐步读懂代码

结构体赋值把内嵌 label 数组复制到独立存储,因此修改副本标签不会改变原标签的小写。

borrowed 指针值也被复制,但所指整数数组没有复制。两个结构体都观察到九。被借用数组在整个 main 内有效,本例两个结构体都不拥有堆分配。

运行一个完整示例 · 02

依据标签读取联合体

让一个标签与一个已初始化成员一致。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

enum Kind { COUNT, TEMPERATURE };
struct Measurement {
    enum Kind kind;
    union {
        int count;
        double temperature;
    } data;
};
static void show(const struct Measurement *value) {
    switch (value->kind) {
    case COUNT:
        printf("count=%d\n", value->data.count);
        break;
    case TEMPERATURE:
        printf("temperature=%.1f\n", value->data.temperature);
        break;
    }
}
int main(void) {
    struct Measurement value = {COUNT, {.count = 7}};
    show(&value);
    value.data.temperature = 18.5;
    value.kind = TEMPERATURE;
    show(&value);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread structured-data-c-tagged-measurement.c -lm -o demo
./demo

预期输出

count=7
temperature=18.5

按执行顺序理解

  1. 一起初始化计数成员及标签。

  2. 通过标签 switch 选择成员。

  3. 再次读取前同时改变表示与标签。

逐步读懂代码

指定初始化器选择 count 为初始联合体成员,COUNT 标签记录此选择。访问器先选择匹配成员,再解释值。

切换为温度时,先写新表示并在下次调用前修改标签。这是单线程更新;并发读取者需要保护整个变更的同步。联合体存储不会随变体缩小或增长。

Leon 的进一步思考

持久保存含义而非地址

文件格式应按明确宽度与字节序编码逻辑字段。进程指针和填充不适合作为可移植保存数据。

让表示变化局部化

带标签值的访问函数可先检查标签再读取。集中检查能降低新增变体后旧读取者访问错误成员的风险。

注意这个边界. 把原始结构体写入磁盘会一并写入填充和机器相关的表示。保存的指针值,也不能让以后的进程重建它曾指向的数据。
检查自己的理解: 如果结构体 A 含有 int *data,b=a 复制了什么?

它复制指针值,因此两个结构体起初都引用同一块分配空间。若要分别拥有独立数据,就需要明确的深复制;若要共享,则需要明确的共享归属规则。

↑ 返回知识框架
COMP2017 / 05Leon

文件流、二进制记录与错误

核心基础

先建立一个简单模型

读取操作报告实际取得的数据量;EOF 是通过读取发现的状态。

把它用在工程决策里

区分完整输入、被拒绝的输入和读取失败。仅凭短读,不能判断是文件结束还是错误。

为什么不在读取前用 !feof(file) 作为循环条件?

只有读取遇到文件结尾后才会设置 EOF 标志。应先检查读取结果,再按需区分 EOF 与错误。

01 / 从一个故事开始

笔记本和内存中的草稿区都能存信息,但失败方式不同。用覆盖模式打开笔记本,可能还没写新内容就先擦掉旧内容。一支带缓冲装置的笔看起来已经写完,墨水却可能还在装置里等待流出。C 文件流也把逻辑操作与底层设备分开。

一次短读取可能表示笔记本到头了,也可能表示发生错误。最后关闭时,还可能出现延迟的写入错误。认真编写文件代码,就要把每一步当作有返回结果的操作,而不是走形式。

02 / 理解原理

fopen 成功时返回流指针,失败时返回 NULL。模式决定读取、截断后写入或追加等行为。标准流是 stdin、stdout 和 stderr。格式化输入输出转换文本;fread 和 fwrite 传输元素。它们的返回值是完整元素的数量,只有元素大小为一时才等于字节数。fgetc 返回 int,才能把所有 unsigned char 值与 EOF 区分开。

输入循环应由读取结果驱动,然后用 feof 和 ferror 区分文件结束与错误。可定位的二进制文件可以按固定记录偏移量访问;文本流定位有更严格的可移植性限制。缓冲意味着输出成功时,数据也许还没到达存储设备。数据重要时,要检查 fflush 和 fclose 的结果。可跨程序、跨机器的二进制格式必须明确字段大小、字节序和编码。

先认识这些词

流
C 库管理缓冲输入输出的对象。
文件结束指示
区别于全部 unsigned char 值的特殊输入结果。
完整元素
fread 或 fwrite 请求大小所定义的一个完整元素。

03 / 逐项读懂公式

请求字节数 = 元素大小 × 元素个数
固定记录偏移量 = 记录下标 × 记录大小
读取循环记忆法:尝试读取 → 检查结果 → 处理有效数据 → 区分 EOF 与错误

元素大小和个数分别是 fread 或 fwrite 的第二、第三个参数。两者乘积是请求传输的字节数,但返回值报告的是已传输的完整元素个数。

记录下标从零开始。偏移量公式要求数据采用固定大小的二进制格式、流能够定位,而且偏移量可以被所用类型表示。读取循环这一行是控制流程记忆法。EOF 是返回状态,不是每个文件末尾实际保存的一个字符。

04 / 先看一个完整例子

某种二进制日志格式规定每条记录恰好 12 字节,不依赖任何 C 结构体布局。读取从零计数的第 4 条记录时,先定位到 4×12=48 字节处,再请求一个 12 字节元素。返回 1 说明完整记录已经读到。返回 0 时,解析前必须检查是错误,还是最后一条记录不完整。

如果改为请求 12 个一字节元素,返回 7 就表示读到了七字节,但仍缺少一条完整记录。用 "wb" 打开现有日志会截断它;这里仅需读取,应使用 "rb"。

一步步想清楚

  1. 为什么用 int 接收 fgetc?

    结果必须容纳全部 unsigned char 值及独立 EOF 指示。

  2. 短读总表示错误吗?

    不一定,也可能输入结束。要检查 ferror 及格式是否要求更多数据。

  3. 写成功后为什么仍检查关闭?

    缓冲输出可能直到关闭期间刷新才失败。

  4. 直接 fwrite(&record, sizeof record, 1, file) 能定义同样可移植的格式吗?

    不能。对象布局、填充和字节序都可能不同。应按文件约定显式编码字段。

运行一个完整示例 · 01

由读取操作报告结束

包含零字节,说明文件输入不是 C 字符串遍历。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *stream = tmpfile();
    if (stream == NULL)
        return 1;
    const unsigned char bytes[] = {'A', 0, 'B'};
    if (fwrite(bytes, 1, sizeof bytes, stream) != sizeof bytes) {
        fclose(stream);
        return 1;
    }
    if (fseek(stream, 0, SEEK_SET) != 0) {
        fclose(stream);
        return 1;
    }
    size_t count = 0;
    int ch;
    while ((ch = fgetc(stream)) != EOF) {
        printf("byte %zu=%d\n", count, ch);
        count++;
    }
    if (ferror(stream)) {
        fclose(stream);
        return 1;
    }
    printf("count=%zu ended=%s\n", count, feof(stream) ? "yes" : "no");
    return fclose(stream) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread file-streams-c-byte-input-and-eof.c -lm -o demo
./demo

预期输出

byte 0=65
byte 1=0
byte 2=66
count=3 ended=yes

读懂这里的语法

FILE *stream = tmpfile()
创建自己拥有的临时更新流。使用前检查 NULL,关闭会移除临时文件。
int ch = fgetc(stream)
使用能区分二者的结果类型,读取一个字节值或 EOF。
ferror(stream)
读取循环后检查错误标志,EOF 本身不解释原因。

按执行顺序理解

  1. 向自己拥有的临时流写入已知三字节序列。

  2. 定位后,用读取返回值作循环条件。

  3. 区分零字节、输入结束与输入错误。

跟踪状态的变化
读取含义
65A
0数据字节
66B
EOF结束指示

逐步读懂代码

流收到三个字节,中间包含零。定位操作把写入切换到读取,并回到开头。fgetc 对零字节返回零,它仍是普通数据。

只有读取返回 EOF 才停止循环。随后检查错误与结束标志。临时流中可能失败的写入操作都被检查,关闭也属于结果。

运行一个完整示例 · 02

分别统计字节与完整记录

用两种元素大小读取同一七字节输入。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *stream = tmpfile();
    if (stream == NULL)
        return 1;
    const unsigned char bytes[] = {1, 2, 3, 4, 5, 6, 7};
    if (fwrite(bytes, 1, sizeof bytes, stream) != sizeof bytes) {
        fclose(stream);
        return 1;
    }
    if (fseek(stream, 0, SEEK_SET) != 0) {
        fclose(stream);
        return 1;
    }
    unsigned char record[12] = {0};
    size_t records = fread(record, sizeof record, 1, stream);
    if (ferror(stream)) {
        fclose(stream);
        return 1;
    }
    printf("complete records=%zu\n", records);
    if (fseek(stream, 0, SEEK_SET) != 0) {
        fclose(stream);
        return 1;
    }
    size_t received = fread(record, 1, sizeof record, stream);
    if (ferror(stream)) {
        fclose(stream);
        return 1;
    }
    printf("received bytes=%zu\n", received);
    puts(received == sizeof record ? "record accepted" : "truncated record rejected");
    return fclose(stream) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread file-streams-c-partial-record-units.c -lm -o demo
./demo

预期输出

complete records=0
received bytes=7
truncated record rejected

按执行顺序理解

  1. 请求一个完整十二字节记录。

  2. 用受检查定位回到开头,再请求十二个单字节元素。

  3. 所需记录字节未齐全时拒绝输入。

逐步读懂代码

七个可用字节无法完成十二字节元素,因此第一次返回数量为零。这不表示底层没有读取字节。程序不解析不完整记录。

重新定位会清除文件结束标志。第二次按单字节元素请求,显示收到七字节。但格式契约仍要求十二,因此拒绝记录。

运行一个完整示例 · 03

定义文件格式,而不是直接写出结构体

米娜在一台电脑保存传感器读数,再在另一台读取。直接写出结构体会让文件依赖填充和字节序。她改为四个明确定义的字节:标记、版本、高位字节、低位字节。这样在编写文件操作前,就能准确写出读取器应该接受什么。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <assert.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#if CHAR_BIT != 8
#error This teaching format requires eight-bit bytes
#endif
static void encode(uint16_t value, unsigned char out[4]) {
    out[0] = 0x4c; out[1] = 1;
    out[2] = (unsigned char)(value >> 8);
    out[3] = (unsigned char)(value & 0xffu);
}
static int decode(const unsigned char *in, size_t size, uint16_t *value) {
    if (size != 4 || in[0] != 0x4c || in[1] != 1) return 0;
    uint16_t result = (uint16_t)(((unsigned)in[2] << 8) | in[3]);
    *value = result;
    return 1;
}
int main(void) {
    unsigned char wire[4]; encode(513, wire);
    printf("wire: %u %u %u %u\n", (unsigned)wire[0], (unsigned)wire[1],
           (unsigned)wire[2], (unsigned)wire[3]);
    FILE *file = tmpfile();
    if (file == NULL) return 1;
    if (fwrite(wire, 1, sizeof wire, file) != sizeof wire ||
        fseek(file, 0, SEEK_SET) != 0) { fclose(file); return 1; }
    unsigned char readback[5];
    size_t count = fread(readback, 1, sizeof readback, file);
    int failed = ferror(file);
    int closed = fclose(file);
    if (failed || closed != 0) return 1;
    uint16_t value = 999;
    int valid = decode(readback, count, &value);
    assert(valid && value == 513);
    printf("decoded: %u\n", (unsigned)value);
    wire[1] = 2;
    int wrong_version = decode(wire, 4, &value);
    int incomplete = decode(wire, 3, &value);
    assert(!wrong_version && !incomplete && value == 513);
    puts("bad version and short record rejected; output unchanged");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread file-streams-portable-record-format.c -lm -o demo
./demo

预期输出

wire: 76 1 2 1
decoded: 513
bad version and short record rejected; output unchanged

读懂这里的语法

uint16_t
在提供该类型的平台上,精确的 16 位无符号整数。
value & 0xffu
取最低八位。
ferror(file)
区分 I/O 错误和文件结束造成的正常短读。

按执行顺序理解

  1. 定义每个字节。数值标记 0x4c 等于 76;版本 1 表示唯一支持的布局。

  2. 把 513 拆成高位 2、低位 1,再用 value=256×high+low 还原。

  3. 检查写入、定位、读取错误和关闭结果。单记录格式最多读五字节,还能发现多出的一个字节。

  4. 先校验长度、标记和版本,再给调用者的输出赋值;无效输入保留原输出。

逐步读懂代码

结构体描述内存中的对象;文件格式描述写入者与读取者之间的约定。本例记录恰好是四个八位字节,读数是 0 到 65535 的无符号整数。高位字节先写,字节序明确;结构体填充和本机字节序不会进入文件。

value >> 8 去掉低八位;value & 255 只保留低八位。解码时把高位移回原处,再用按位或合并互不重叠的部分。513=2×256+1,因此编码得到 [2,1]。

这是最小格式,不是损坏检测器。很多被改坏的正文仍会成为有效记录。真实格式可能需要校验和、长度上限以及版本迁移说明。校验和可检测部分意外损坏,却不能认证恶意发送者。本例使用临时文件,不会覆盖学习者自己的文件。

Leon 的进一步思考

明确拥有临时资源

这些例子用 tmpfile 创建自己的匿名临时流,退出前关闭。不需预先准备文件,也不会覆盖用户数据。

有意识地选择传输单位

按字节读能看到截断记录的字节数。按大元素读只报告完整元素,不能把不足字节当有效记录。

注意这个边界. while (!feof(fp)) 不是正确的输入循环。EOF 标志通常在到达末尾后的读取失败时才被设置,因此旧数据可能被处理两次。
检查自己的理解: 为什么 fprintf 成功后,fclose 失败仍然值得重视?

缓冲输出可能推迟真正的写入。fclose 随后的刷新可能失败,因此先前格式化写入成功,并不能证明所有数据都已到达文件。

↑ 返回知识框架
COMP2017 / 06Leon

动态分配与对象生命周期

核心基础

先建立一个简单模型

区分指针变量和它拥有的分配块。

把它用在工程决策里

明确谁负责释放,以及每条失败路径保留什么。成功的 realloc 可能搬移分配块;非零大小的 realloc 失败会保留原分配块。

为什么 realloc 要先赋给临时指针?

如果把失败结果直接赋给唯一所有者,就会丢失旧地址。成功后再提交新指针。

01 / 从一个故事开始

设备借用处按需要出租编号储物柜。柜号只有在租用有效时才有用;复印柜号不会产生第二个柜子。归还柜子后,所有持有这个号码的人都失去了使用权。原租用者划掉自己的号码,也不能处理别人手里的副本。动态内存有同样的归属问题。

调整空间大小时,物品可能搬到新柜子,旧路线随即失效。严谨的程序会明确谁分配、谁借用、谁能调整大小,以及谁最终释放对象。这比记住四个分配函数的名字更重要。

02 / 理解原理

malloc 获得未初始化的空间,calloc 把所分配的字节清零,realloc 改变已有分配,free 释放它。分配前要检查大小运算,防止乘法回绕。在 C 中,把分配结果赋给对象指针不需要强制转换,但必须包含正确的头文件。按字节清零并不能可移植地保证所有指针表示都是空指针,也不能保证所有浮点表示都是数值零。

对非零大小的 realloc 请求,失败时原分配仍然有效。应使用临时指针,仅在成功时更新归属。realloc 成功后,指向旧分配内部的指针都失效,即使地址看起来没变。所有借用结束后,只能对自己负责的分配起始地址调用一次 free。自动对象的生命周期会在对应执行作用域结束时结束。常见的栈与堆示意图是实现模型,不是所有机器都遵循的布局保证。

先认识这些词

所有者
负责恰好释放一次分配的代码。
借用者
在对象生命周期与契约有效时获准使用对象的代码。
悬空指针
关联到生命周期已结束对象的指针。

03 / 逐项读懂公式

分配前提:n ≤ SIZE_MAX / sizeof(*p)
请求字节数 = n × sizeof(*p)
归属不变式:每块分配空间都有明确且唯一的释放责任
生命周期:分配 → 初始化 → 使用 → 释放

n 是希望存放的元素个数,SIZE_MAX 是 size_t 能表示的最大值。先做除法形式的检查,就能在乘法发生前排除溢出。sizeof(*p) 使用所指类型;对普通的非变长数组类型,它不会真正解引用 p。

归属与生命周期描述设计不变式,不是数值定律。分配器仍可能返回 NULL,因此算术合法只是第一项检查。释放后,一个指针值可能还保存在变量中,但这不会延长旧对象的生命周期。

04 / 先看一个完整例子

假设 int 占四字节。六元素动态缓冲区请求 6×4=24 字节。初始化后,要扩展到十个元素,就通过临时 realloc 结果请求 40 字节。如果结果是 NULL,原来的六元素缓冲区仍然存在,以后还必须释放它。

如果成功,就使用返回的指针,并在读取前初始化元素 6–9,因为新增字节不会自动初始化。先前指向元素 2 的另一个指针,必须根据新起点重新计算。释放空间并把所有者的指针设为 NULL,也不会修复其他已经失效的别名。

一步步想清楚

  1. free 会修复全部别名吗?

    不会。所有者释放或设为 NULL,不会让其他保存指针变安全。

  2. malloc 前为什么检查乘法?

    大小回绕可能请求过少存储,即使分配成功也危险。

  3. 非零大小 realloc 失败后保留什么?

    原分配仍有效。应把其指针保存在独立所有者变量中。

  4. 为什么必须在 reserve 成功后才增加 length?

    length 承诺对应元素存在且已初始化。若先增加它再分配失败,元数据就会声称存在实际上未存入的数据。

运行一个完整示例 · 01

从这里开始:一次分配,一个所有者,一次释放

先跟踪三个元素的分配,再加入扩容或链式结构。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
static void release_values(int **owner) {
    free(*owner);
    *owner = NULL;
}
int main(void) {
    size_t count = 3;
    int *values = malloc(count * sizeof *values);
    if (values == NULL) {
        fputs("allocation failed\n", stderr);
        return 1;
    }
    for (size_t i = 0; i < count; i++)
        values[i] = (int)(i + 1) * 10;
    printf("values=%d,%d,%d\n", values[0], values[1], values[2]);
    release_values(&values);
    printf("owner cleared=%s\n", values == NULL ? "yes" : "no");
    release_values(&values);
    puts("releasing a null owner is allowed");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread allocation-ownership-c-warmup-owner-release.c -lm -o demo
./demo

预期输出

values=10,20,30
owner cleared=yes
releasing a null owner is allowed

读懂这里的语法

sizeof *values
计算一个 int 的大小,不会读取未初始化元素。
int **owner
指向调用者的 int 指针,使辅助函数能够将它清空。
free(*owner)
归还该分配块;*owner 为 NULL 时也合法。

按执行顺序理解

  1. 计算元素数量,使用指向类型的 sizeof。

  2. 初始化或读取元素之前,先检查分配成功。

  3. 通过所有者释放,只观察清空后的指针,不再访问已释放数据。

跟踪状态的变化
步骤状态
malloc 之后已分配,内容尚未初始化
循环之后10,20,30
释放之后values=NULL

逐步读懂代码

程序先预留空间,检查成功,初始化每个元素,再使用并释放。malloc 返回的存储尚未初始化。这里固定数量为三,字节数乘法不会溢出;通用分配器还需要检查乘法范围。

release_values 收到调用者指针变量的地址。一层星号读取该指针,再多一层才到达 int。辅助函数释放分配块,然后清空调用者的所有者指针。free 可以接收 NULL。清空一个所有者不会清空其他别名;本例有意不创建其他别名。辅助函数要求传入有效的指针变量地址,该变量要么为 NULL,要么拥有可交给 free 的分配块。

运行一个完整示例 · 02

分配、初始化、使用与释放

分别检查大小算术与分配结果。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main(void) {
    size_t n = 6;
    int *values = NULL;
    if (n > SIZE_MAX / sizeof *values)
        return 1;
    values = malloc(n * sizeof *values);
    if (values == NULL)
        return 1;
    int total = 0;
    for (size_t i = 0; i < n; i++) {
        values[i] = (int)i + 1;
        total += values[i];
    }
    printf("count=%zu sum=%d\n", n, total);
    free(values);
    values = NULL;
    puts("owner cleared");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread allocation-ownership-c-owned-dynamic-array.c -lm -o demo
./demo

预期输出

count=6 sum=21
owner cleared

读懂这里的语法

n > SIZE_MAX / sizeof *values
拒绝会使字节大小乘法超过 size_t 的元素数量。
malloc(n * sizeof *values)
为 n 个所指类型元素分配存储,使用前检查返回指针。
free(values)
全部使用完成后结束所拥有分配的生命周期。

按执行顺序理解

  1. 在不产生溢出的情况下验证请求字节数。

  2. 每个元素先初始化再读取。

  3. 释放分配,并停止使用旧地址。

跟踪状态的变化
阶段允许使用
已分配先写后读
已初始化边界内读取
已释放不得访问

逐步读懂代码

sizeof *values 使用所指 int 类型;对这种普通非变长数组类型,不会通过初始空指针读取。除法检查在乘法之前执行。

分配成功后,每个元素先写入再参与求和。一个所有者恰好释放基址一次。随后设为 NULL 有助于避免本地误用,但不会修复其他别名。

运行一个完整示例 · 03

调整成功后才提交新所有者

非零调整失败时保持旧分配可访问。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

int main(void) {
    size_t old_count = 3, new_count = 5;
    int *values = malloc(old_count * sizeof *values);
    if (values == NULL)
        return 1;
    for (size_t i = 0; i < old_count; i++)
        values[i] = (int)i + 1;
    if (new_count > SIZE_MAX / sizeof *values) {
        free(values);
        return 1;
    }
    int *resized = realloc(values, new_count * sizeof *values);
    if (resized == NULL) {
        free(values);
        return 1;
    }
    values = resized;
    for (size_t i = old_count; i < new_count; i++)
        values[i] = (int)i + 1;
    for (size_t i = 0; i < new_count; i++)
        printf("%s%d", i ? " " : "", values[i]);
    putchar('\n');
    free(values);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread allocation-ownership-c-realloc-commit.c -lm -o demo
./demo

预期输出

1 2 3 4 5

按执行顺序理解

  1. 初始化原分配。

  2. 使用临时结果,成功后才提交所有权。

  3. 初始化新元素,再释放返回的分配基址。

逐步读懂代码

临时 resized 接收结果。非零请求失败时,values 仍可用于清理。成功后先用返回指针替换所有者,再进行任何访问。

前面三个已初始化值被保留。新增两个元素明确初始化。程序不假设存储是否移动,也不让内部别名跨越调整操作。

运行一个完整示例 · 04

安全扩容自己拥有的数组

传感器先产生两个读数,之后还会继续产生。米娜准备在空间不足时扩容。她最初把 realloc 的结果直接赋给唯一的指针,一旦分配失败,就再也找不到旧内存。她把操作改成一次小事务:检查所需大小,用临时指针申请内存,成功之后才更新拥有者。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
struct Vector { int *data; size_t length; size_t capacity; };
static int reserve(struct Vector *v, size_t need) {
    const size_t limit = SIZE_MAX / sizeof *v->data;
    if (need <= v->capacity) return 1;
    if (need > limit) return 0;
    size_t next = v->capacity ? v->capacity : 2;
    while (next < need) {
        if (next > limit / 2) { next = need; break; }
        next *= 2;
    }
    int *temporary = realloc(v->data, next * sizeof *v->data);
    if (temporary == NULL) return 0;
    v->data = temporary;
    v->capacity = next;
    return 1;
}
static int push(struct Vector *v, int value) {
    if (v->length == SIZE_MAX || !reserve(v, v->length + 1)) return 0;
    v->data[v->length++] = value;
    return 1;
}
static void destroy(struct Vector *v) {
    free(v->data);
    *v = (struct Vector){0};
}
int main(void) {
    struct Vector values = {0};
    for (int n = 1; n <= 5; ++n) {
        if (!push(&values, n * 10)) { destroy(&values); return 1; }
        printf("length=%zu capacity=%zu\n", values.length, values.capacity);
    }
    int rejected = !reserve(&values, SIZE_MAX);
    assert(rejected && values.length == 5 && values.data[0] == 10);
    printf("oversized request rejected: %d\n", rejected);
    printf("last value: %d\n", values.data[values.length - 1]);
    destroy(&values);
    assert(values.data == NULL && values.length == 0 && values.capacity == 0);
    puts("owner reset after free");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread allocation-ownership-growing-owned-vector.c -lm -o demo
./demo

预期输出

length=1 capacity=2
length=2 capacity=2
length=3 capacity=4
length=4 capacity=4
length=5 capacity=8
oversized request rejected: 1
last value: 50
owner reset after free

读懂这里的语法

sizeof *v->data
按指针指向的元素类型计算字节数。
int *temporary
保存可能的新分配,同时保留旧拥有者。
(struct Vector){0}
复合字面量,把各成员重置为零。

按执行顺序理解

  1. 保持 length <= capacity;只有 length 之前的元素才是已初始化读数。

  2. 乘法前检查 need 是否超过 SIZE_MAX / sizeof(元素),翻倍前检查溢出。

  3. realloc 成功之前保留旧指针;失败时旧分配和元数据仍描述同一数组。

  4. 只有 reserve 成功后才追加。释放一次,再重置拥有者,明确恢复空状态。

逐步读懂代码

Vector 拥有分配的内存。reserve 只是临时借用拥有者,不会制造第二个拥有者。成功的 realloc 可能搬迁存储,因此扩容成功后,原有元素指针必须视为失效。若需要跨扩容记住位置,应保存下标,再重新取得指针。

五次追加后的容量为 2、2、4、4、8。在每次增长都复制旧元素的成本模型下,后两次增长复制 2+4=6 个元素。翻倍使 n 次追加的总复制量为 O(n),所以追加的摊还成本为 O(1);某一次追加仍可能花费 O(n)。这只是成本模型,不表示 realloc 每次都一定复制。

SIZE_MAX 请求被拒绝,测试的是整数保护,不是真正的系统内存耗尽。更完整的测试应注入分配失败,并核对内容不变。本例只执行非零分配,假定 Vector 由本接口正确创建,也不是并发容器。

Leon 的进一步思考

区分数量与容量

分配可容纳十个值,却只有六个已初始化。在初始化前读取新增容量,与是否处于分配边界内,是两个不同问题。

调整大小使借用位置失效

realloc 成功后,应从返回基址与已保存下标重建内部指针。不要比较或复用旧指针来证明别名仍有效。

注意这个边界. 返回局部自动变量的地址,会让调用者拿到一个生命周期已经结束的对象的指针。应返回值,或明确安排一个确实能存活更久的对象及其归属。
检查自己的理解: 当 new_size 非零时,为什么 p=realloc(p,new_size) 有风险?

失败时,它会用 NULL 覆盖唯一保存的分配地址,导致仍然存在的内存块无法访问。临时指针可以在确认成功之前保留原来的所有者指针。

↑ 返回知识框架
COMP2017 / 07Leon

链表与结构不变式

深入分支 · 可在第一个工程后回看

先建立一个简单模型

链接是保存的地址,改变它就改变了哪些节点可以到达。

把它用在工程决策里

解除链接或释放节点之前,先保存之后需要的地址。每次修改后画出剩余链表。

为什么释放当前节点前要保存 next?

free 之后再读 current->next 就是在访问生命周期已经结束的对象。

01 / 从一个故事开始

寻宝路线在每张线索卡上写着下一站。把新卡插入两张旧卡之间,只需改路线,不必挪动后面所有卡。但如果没人知道插入点,仍要沿路线寻找。双向路线还保存回程方向;环形路线则回到起点,没有末尾标记。如果探险者一直等到看见结束标记,就会永远绕圈。

链表编程的关键,是每次更新都保持明确的结构不变式。销毁一张卡之前,还要先保存它指向下一站的路线。

02 / 理解原理

单链表节点包含数据和 next 指针。程序维护 head,也可以维护 tail,并规定空表的表示方式。在已知节点后插入,只需固定次数的指针更新;查找位置通常需要线性时间。删除时要重新连接前驱,或更新 head,再在保存必要链接之后释放被删节点。指向指针的指针可以统一表示“通向当前节点的链接”,包括 head 本身。

双向链表同时维护相互对应的 next 与 previous 链接。节点和归属明确时,可以常数时间删除已知节点。环形链表应按起点或哨兵判断何时停止。分配失败时,旧结构仍须有效。持久化时保存数据,之后重建链接;进程中的原始地址不是可移植的链表文件格式。

先认识这些词

头链接
指向首节点或 NULL 的指针变量。
链接调整
在已知位置改变固定数量链接,以插入或删除节点。
二级指针
用于访问并更新另一指针变量的指针。

03 / 逐项读懂公式

单链表不变式:从 head 出发,每个有效节点只经过一次,最后到 NULL
双链表不变式:当 x->next 存在时,x->next->prev = x
查找:O(n);在已知有效链接处接入或移除:O(1)
反转步骤:保存 next;改写 current->next;继续前进

n 是节点个数,复杂度假设普通指针访问具有常数成本。在位置已知后,接入或移除只改变固定数量的链接;这个成本不包括查找位置。等式和步骤描述不变式与记忆法,不是数量定律。

双链表条件要求相关节点有效且非空,也有对应的 previous 再到 next 的条件。环形结构要用明确的环或哨兵不变式代替 NULL 结尾规则,遍历和销毁也必须采用相应方法。

04 / 先看一个完整例子

从单链表 4→7→9→NULL 开始。删除 7 时,保留指向 4 的指针,以及指向 7 的临时指针。保存 7 的 next,把 4 的 next 改为指向 9,再释放 7。结果是 4→9→NULL。

反转时先令 previous=NULL、current=4。保存 9,把 4.next 改为 NULL,再向前移动。接着保存 NULL,把 9.next 改为 4,再向前移动。最后把 head 设为 previous,得到 9→4→NULL。每次改写之前都先保存 next,尚未处理的后半段才不会丢失。

一步步想清楚

  1. free 前为什么保存 next?

    释放后读取被删节点链接也无效,应先保存所需值。

  2. 按值删除是常数时间吗?

    通常不是。即使最后链接更新为常数时间,搜索仍为线性。

  3. 分配失败必须保留什么?

    已有可达节点和链接必须仍有效,所有权仍清楚。

运行一个完整示例 · 02

反转链接而不丢失后缀

使用自动节点,专注链接顺序而不涉及分配。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int value;
    struct Node *next;
};
int main(void) {
    struct Node third = {9, NULL};
    struct Node second = {7, &third};
    struct Node first = {4, &second};
    struct Node *head = &first;
    struct Node *previous = NULL;
    while (head != NULL) {
        struct Node *next = head->next;
        head->next = previous;
        previous = head;
        head = next;
    }
    head = previous;
    for (const struct Node *node = head; node != NULL; node = node->next)
        printf("%d -> ", node->value);
    puts("NULL");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread linked-structures-c-list-reverse.c -lm -o demo
./demo

预期输出

9 -> 7 -> 4 -> NULL

按执行顺序理解

  1. 分别维护已反转与未处理部分指针。

  2. 改向前保存下一链接。

  3. 后缀为空后,把反转前缀作为新头。

逐步读懂代码

previous 不拥有分配,只指示已反转前缀。head 指示未处理后缀。先保存 next,让当前边改向后仍可到达后缀。

三个节点都是 main 返回前有效的自动对象,不能传给 free。反转改变链接,不改变载荷值或存储所有权。

Leon 的进一步思考

分离搜索与修改

先确定需要改变的链接,再用明确前后不变式完成小更新。二级指针能让头节点情况纳入同一操作。

终止规则改变会影响全部遍历

循环链表不能原样复用 NULL 终止销毁循环。实现遍历、反转和清理前,应先选择结构不变式。

注意这个边界. 常数时间删除的说法,假设已经知道所需链接或前驱。在单链表中按值寻找节点,仍然是 O(n)。
检查自己的理解: 为什么删除头节点时,必须更新调用者的 head 变量?

旧的首节点已经不属于链表。应返回新 head,或传入 head 指针的地址。只改变函数中的局部副本,会让调用者仍然指向已移除的节点。

↑ 返回知识框架
COMP2017 / 08Leon

函数指针与作为参数的行为

深入分支 · 可在第一个工程后回看

先建立一个简单模型

函数指针携带具有准确签名的行为。

把它用在工程决策里

当遍历不变而某个操作需要变化时,可用回调。把参数、结果和生命周期要求写在接口旁边。

函数签名不同,强制转换后就能安全调用吗?

不能。转换不会修复不兼容的调用契约,应提供签名匹配的包装函数。

01 / 从一个故事开始

剧场有许多灯光控制器。每个控制器接受一张小小的动作卡。同一个控制器可以调暗灯光、改变颜色,也可以关闭灯光。控制器不必预先知道所有可能的动作。

02 / 理解原理

函数指针可以被保存、传递和调用,因此能实现回调和分发表。括号决定声明的是“指向函数的指针”,还是“返回指针的函数”。被调用函数的返回值和参数类型,必须与调用时使用的函数指针类型兼容。强制转换掉不匹配,并不能让调用变合法。typedef 可以使回调声明更好读。

qsort 的比较器接收指向元素的指针,并根据一致的顺序返回负数、零或正数。比较整数时,应使用关系运算符,避免可能溢出的直接减法。通过表调用前,要验证下标,也要确认函数指针非空。ISO C 一般不把对象指针与函数指针等同起来;动态加载依赖额外的平台保证,超出了可移植 ISO C 的范围。

先认识这些词

回调
作为行为传给另一操作并由其调用的函数。
函数指针
用于指定并调用兼容函数的有类型指针。
比较器
按顺序关系返回负、零或正的回调。

03 / 逐项读懂公式

int (*op)(int,int):指向一个接收两个 int 并返回 int 的函数
int *op(int,int):接收两个 int、返回 int 指针的函数
安全的整数比较结果:(a > b) − (a < b)

括号让 *op 先于函数参数列表结合,因此 op 是函数指针。没有这些括号时,op 是函数名,它返回一个 int 指针。

比较表达式中的 a、b 是整数值,每个关系表达式只产生零或一。将这两个小结果相减,就能安全得到 −1、0 或 1。这样定义了数值升序,却不必计算可能无法用 int 表示的 a−b。

04 / 先看一个完整例子

计算器定义了两个类型兼容的函数 sum(int,int) 和 difference(int,int),再把它们放进函数指针表。操作数为 17 和 6 时,分派下标 0 调用 sum,得到 23;下标 1 调用 difference,得到 11。表只有两项,因此必须在访问前拒绝下标 2。

在 int 为 32 位的实现上,排序整数 −2000000000 和 2000000000 时,直接减法的差值不能用 int 表示。关系比较器则对这个顺序计算 0−1=−1,正确说明负数应在前面,既不溢出,也不依赖回绕假设。

一步步想清楚

  1. 转换能修复不兼容回调签名吗?

    不能。通过不兼容函数类型调用可能产生未定义行为。

  2. 整数比较器为什么避免 a-b?

    两个输入各自可表示,数学差仍可能溢出。

  3. 为什么先检查分派下标?

    调用函数前,越界访问表本身已经无效。

运行一个完整示例 · 01

从这里开始:把一个操作传入循环

把“逐个访问元素”和“对元素做什么”分开。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
typedef int (*Transform)(int);
static int double_small(int value) {
    return value * 2;
}
static int add_one_small(int value) {
    return value + 1;
}
static void print_transformed(const int *values, size_t count, Transform action) {
    for (size_t i = 0; i < count; i++)
        printf("%s%d", i == 0 ? "" : " ", action(values[i]));
    putchar('\n');
}
int main(void) {
    const int values[] = {1, 2, 3};
    print_transformed(values, 3, double_small);
    print_transformed(values, 3, add_one_small);
    printf("original=%d,%d,%d\n", values[0], values[1], values[2]);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread callbacks-c-warmup-behavior-parameter.c -lm -o demo
./demo

预期输出

2 4 6
2 3 4
original=1,2,3

读懂这里的语法

typedef int (*Transform)(int)
给这类兼容函数指针起一个易读名称。
action(values[i])
用当前元素调用传入的函数。
const int *values
本函数通过此指针读取输入,不修改输入。

按执行顺序理解

  1. 先规定循环需要的准确函数签名。

  2. 让同一遍历逻辑执行两种不同操作。

  3. 打印未变的输入,区分计算新值与修改原数据。

跟踪状态的变化
步骤状态
每个元素加倍2 4 6
每个元素加一2 3 4
检查输入1 2 3

逐步读懂代码

循环像依次访问三个地址的邮递员,回调则规定每到一处要做什么。循环把一个整数传给提供的函数,并打印返回值。原数组保持不变。

Transform 表示指向“接收 int、返回 int 的函数”的指针。*Transform 两侧括号把函数指针与其他声明区分开。两个回调都匹配该类型。调用者需要提供非空且类型兼容的函数,以及有效数组范围。固定小输入使算术安全;这些回调并不是对所有 int 都防溢出的变换。

运行一个完整示例 · 02

通过受检查的表选择行为

使用一种兼容函数指针类型,并拒绝非法下标。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

typedef int (*Operation)(int, int);
static int sum(int a, int b) {
    return a + b;
}
static int difference(int a, int b) {
    return a - b;
}
int main(void) {
    Operation operations[] = {sum, difference};
    size_t count = sizeof operations / sizeof operations[0];
    for (size_t index = 0; index <= count; index++) {
        if (index >= count) {
            puts("invalid operation");
            continue;
        }
        printf("operation %zu=%d\n", index, operations[index](17, 6));
    }
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread callbacks-c-checked-dispatch-table.c -lm -o demo
./demo

预期输出

operation 0=23
operation 1=11
invalid operation

读懂这里的语法

typedef int (*Operation)(int, int)
给接收两个 int、返回 int 的函数指针起可读名字。
Operation operations[]
声明兼容函数指针数组。
operations[index](17, 6)
验证下标后调用所选函数。

按执行顺序理解

  1. 声明统一回调类型。

  2. 读取表条目前验证下标。

  3. 使用有界操作数调用所选行为。

跟踪状态的变化
下标结果
023
111
2拒绝

逐步读懂代码

两个函数与 typedef 完全匹配。表保存其地址,通过条目调用时传入同样的两个实参类型。

循环故意到达第一个非法下标。检查在访问表之前拒绝它,从而区分操作选择与回调实际执行的算术。

运行一个完整示例 · 03

排序时不相减任意整数

安全比较极值,再排序含重复项的小数组。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
static int compare_int(const void *left, const void *right) {
    int a = *(const int *)left;
    int b = *(const int *)right;
    return (a > b) - (a < b);
}
int main(void) {
    int minimum = INT_MIN, maximum = INT_MAX;
    printf("extreme comparison=%d\n", compare_int(&minimum, &maximum));
    int values[] = {4, -7, 0, -7};
    size_t count = sizeof values / sizeof values[0];
    qsort(values, count, sizeof values[0], compare_int);
    for (size_t i = 0; i < count; i++)
        printf("%s%d", i ? " " : "", values[i]);
    putchar('\n');
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread callbacks-c-safe-qsort-comparator.c -lm -o demo
./demo

预期输出

extreme comparison=-1
-7 -7 0 4

按执行顺序理解

  1. 从通用指针恢复实际元素类型。

  2. 使用关系比较结果,不相减原数值。

  3. 排序并检查包含重复项的完整结果。

逐步读懂代码

qsort 通过通用指针接口传递真实 int 元素的地址。比较器先转换回正确元素指针类型再读取。

关系表达式各为零或一,因此差值只在负一至一之间。它不会计算整数本身可能溢出的差。重复相等值返回零。qsort 不保证相等元素稳定排序。

Leon 的进一步思考

行为也是 API 的一部分

回调契约不只包含 C 类型,还包括实参生命周期、允许副作用及顺序一致性。类型兼容仍可能违反算法假设。

把错误策略放在算术之外

分派表选择行为,却不自动验证算术范围。本例固定小数值安全,外部输入还需按操作检查溢出。

注意这个边界. 把签名不匹配的函数地址强制转换为预期回调类型,再通过它调用,可能产生未定义行为。
检查自己的理解: 为什么比较器应对它认为等价的值返回零?

排序算法依赖一致的顺序关系。前后矛盾的回答会破坏算法假设,使结果失去意义。

↑ 返回知识框架
COMP2017 / 09Leon

预处理、宏与条件构建

核心基础

先建立一个简单模型

预处理器先处理记号,再由 C 编译器检查程序。

把它用在工程决策里

行为通常优先写成有类型的函数。宏可用于头文件保护和明确的构建选项,但要注意实参被重复求值。

给 SQUARE(i++) 加括号就安全吗?

不安全。括号改变分组,不会消除实参被使用两次导致的重复求值。

01 / 从一个故事开始

印刷厂在编辑检查句子之前,先插入标准段落并展开缩写。C 预处理器也在类型检查之前做类似的事情。它并不理解:同一个缩写重复两次,可能让某个副作用也发生两次。条件段落决定哪些源文本能进入编译器,头文件保护宏则防止同一翻译单元重复插入相同声明。

遇到意外行为时,要想清楚展开后的文档是什么样。检查编译器实际收到的内容,不要想当然地把宏当作已经算好各个实参值的普通函数调用。

02 / 理解原理

预处理负责包含文件、替换宏、条件编译和源位置控制。对象式宏替换名称,函数式宏替换实参记号。适当时,要给每个参数和整个结果加括号;但括号不能防止重复求值,也不能安排没有先后顺序的副作用。需要类型检查且每个实参只求值一次的计算,优先考虑 static inline 函数。

头文件保护宏防止一个翻译单元内部的重复包含,不能解决不同翻译单元之间的多个外部定义。#ifdef 检查宏是否存在,#if 可以检查其数值。__FILE__ 和 __LINE__ 有助于诊断;__func__ 是函数作用域内的预定义标识符,不是预处理宏。#line 改变诊断使用的源位置。除非已经标准化,pragma 指令属于实现相关功能,应区分可移植源代码与编译器扩展。

先认识这些词

宏展开
普通 C 类型检查之前的预处理记号替换。
翻译单元
源文件及其包含文本经过预处理后形成的源代码。
多次求值
实参记号被复制,导致表达式求值多次。

03 / 逐项读懂公式

SQUARE(x) 定义为 ((x)*(x))
SQUARE(a+1) 展开为 ((a+1)*(a+1))
SQUARE(i++) 展开为 ((i++)*(i++)) —— 在 C 中属于未定义行为
记忆法:预处理 → 检查展开结果 → 类型检查

SQUARE 是宏名,x 是记号参数,不是运行时的局部变量。括号保留 a+1 这样的实参原本的分组方式。重复 i++ 会重复修改 i,而乘法没有规定这两次修改的先后顺序,因此产生未定义行为。

最后一行是工作流程记忆法,不是等式。内联函数会对调用者传入的一个实参求值一次。不过,调用者仍可能在多个函数实参之间写出没有规定先后顺序的冲突操作。

04 / 先看一个完整例子

令 a=4。不带括号的宏 #define BAD(x) x*x 会把 BAD(a+1) 展开为 a+1*a+1,结果是 4+4+1=9。加好括号的版本得到 (4+1)×(4+1)=25。

对 #define ENABLE_CACHE 0,#ifdef ENABLE_CACHE 会包含其分支,因为这个名字已经定义。#if ENABLE_CACHE 会排除其分支,因为数值是零。若 static inline int square(int x) 返回 x*x,初始 i=4 时,square(i++) 只执行一次实参自增,返回 16,并留下 i=5。这里假设乘积能被类型表示,而且没有其他冲突表达式访问 i。

一步步想清楚

  1. 括号能防止重复求值吗?

    不能。括号控制分组,不控制实参副本的求值次数。

  2. 值为零的宏是未定义吗?

    不是。#ifdef 检查名字已定义,#if 检查数值条件。

  3. 小算术为什么使用 static inline?

    它提供类型检查,调用中每个实参只求值一次,但仍受普通算术范围限制。

运行一个完整示例 · 01

比较记号替换与函数调用

展示分组错误,但绝不执行不安全的重复自增。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

#define BAD(x) x *x
#define SQUARE(x) ((x) * (x))
static inline int square(int x) {
    return x * x;
}
int main(void) {
    int a = 4;
    printf("ungrouped=%d\n", BAD(a + 1));
    printf("grouped=%d\n", SQUARE(a + 1));
    int i = 4;
    int result = square(i++);
    printf("function=%d next i=%d\n", result, i);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread preprocessing-c-macro-grouping.c -lm -o demo
./demo

预期输出

ungrouped=9
grouped=25
function=16 next i=5

读懂这里的语法

#define BAD(x) x * x
把实参记号替换两次但不分组,这是故意错误的算术宏。
#define SQUARE(x) ((x) * (x))
保护分组,但仍复制实参记号,不能传副作用表达式。
static inline int square(int x)
声明具有翻译单元内部链接的有类型函数,形参是一个已求值的值。

按执行顺序理解

  1. 求值前先手工展开错误宏。

  2. 比较带括号的算术结果。

  3. 对有副作用实参使用真实函数。

跟踪状态的变化
形式结果
BAD(4+1)9
SQUARE(4+1)25
square(i++)16; i=5

逐步读懂代码

BAD(a+1) 展开为 a+1*a+1,乘法先结合,因此得到九。SQUARE 为两个副本分组,得到二十五。

自增只传给真实函数,它接收旧值四一次,并让 i 变五。SQUARE(i++) 会产生未排序修改,故意不执行。仅加括号不会使它安全。

运行一个完整示例 · 02

区分存在与数值为真

已定义为零的特性标志回答两个不同问题。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

#define ENABLE_CACHE 0
int main(void) {
#ifdef ENABLE_CACHE
    puts("macro exists");
#else
    puts("macro absent");
#endif
#if ENABLE_CACHE
    puts("cache enabled");
#else
    puts("cache disabled");
#endif
    printf("active function=%s\n", __func__);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread preprocessing-c-conditional-macro-value.c -lm -o demo
./demo

预期输出

macro exists
cache disabled
active function=main

按执行顺序理解

  1. 明确把特性标志定义为零。

  2. 比较 #ifdef 与 #if 选中的代码。

  3. 在普通运行输出中查看预定义函数名。

逐步读懂代码

预处理器因名字存在而选择第一处分支,又因数值为零而选择第二处 else。编译器只接收选中的语句。

__func__ 是预定义函数作用域标识符,提供外围函数名。它不是用户宏,输出也无需猜文件路径或行号。

Leon 的进一步思考

检查展开后的程序

编译器预处理输出能暴露记号替换。先用它解释宏故障,再考虑无关编译选项。

明确配置语义

数值特性宏可用零表示关闭,存在性宏用已定义或不存在表示。混用约定会默默改变构建。

注意这个边界. 头文件保护宏只在各自的预处理过程中生效。即使头文件有保护宏,在其中定义具有外部链接的全局对象,仍可能让每个包含它的 .c 文件都产生一个定义。
检查自己的理解: 为什么括号能修复 BAD(a+1),却不能修复 SQUARE(i++)?

括号修复表达式的分组,但不会把重复的记号变成只求值一次的值,也不会给两次自增安排先后顺序。

↑ 返回知识框架
COMP2017 / 10Leon

翻译单元、符号与程序库

核心基础

先建立一个简单模型

声明是一项承诺,定义才提供对象或函数。

把它用在工程决策里

共享接口放在头文件,实现在 .c 文件。编译器检查各个翻译单元,链接器再连接生成的符号。

为什么编译成功后,链接仍可能失败?

声明可以让函数调用类型正确,但参与链接的文件中可能没有该函数的定义。

01 / 从一个故事开始

一台戏由几位作者分别写剧本。每份剧本可以提到由别人提供的演员,但开演前,制作人必须为每个角色找到合适的人。编译检查单个翻译单元,链接则把外部引用与定义连接起来。私有辅助函数像只在一个排练室内部认识的演员。

共享库通过运行时系统提供演员;静态库则把被选中的目标代码加入可执行文件。节目单写了演员名字,不等于已经聘请演员。同样,包含头文件不一定会链接对应实现。

02 / 理解原理

典型 C 工具链先预处理源代码,再编译成汇编,接着汇编成目标文件,最后链接成程序。头文件提供声明和共享类型。一个非内联的外部函数,通常要由某个翻译单元或库提供一个定义。extern 声明可以引用别处定义的全局对象;文件作用域的 static 则赋予内部链接。作用域、链接属性和存储期是三个不同概念。

目标文件以平台相关的格式保存代码、数据、重定位信息和符号表。静态库是归档文件,链接器可以从中选取需要的成员;命令行顺序可能影响结果。链接共享库会记录运行时依赖。显式动态加载则在支持的平台上使用 dlopen、dlsym 等机制。不要先猜编译选项;先分清错误是缺失定义、重复定义,还是声明不兼容。

先认识这些词

声明
向编译器介绍标识符及类型的语句。
定义
同时提供对象或函数实现的声明。
链接属性
决定跨作用域或翻译单元的声明是否代表同一实体的规则。

03 / 逐项读懂公式

记忆法:.c → 预处理后的源代码 → 汇编 → .o → 可执行文件
声明:int add(int,int);
定义:int add(int a,int b) { return a+b; }
编译驱动的停止阶段:-E 预处理;-S 汇编;-c 目标文件

箭头概括常见工具链,并不规定每一种编译器的内部设计。声明告诉当前翻译单元 add 的名称和类型;定义还提供函数体。加法示例假设结果可以被所用类型表示。

GCC 风格的 -E、-S、-c 让驱动程序停在不同阶段。目标文件可以含有尚未解析的外部引用,直到链接时才需要解决。包含头文件是在插入源文本,不是在命令系统加载程序库代码。

04 / 先看一个完整例子

假设 main.c 包含 math_ops.h,头文件只有声明 int add(int,int),main.c 调用 add(8,5)。把 main.c 编译成 main.o 可以成功,因为编译器已经知道接口。但单独链接 main.o 无法提供 add 的实现。再编译定义 add 的 math_ops.c,并链接两个目标文件,程序就能算出 13。

如果两个 .c 文件都定义了具有外部链接的 int total=0,头文件保护宏不能消除重复定义。应在一个 .c 文件中保留唯一的定义,并在共享头文件中写 extern int total 声明。如果源码组织与链接器消息看起来不一致,可以检查目标文件的符号。

一步步想清楚

  1. 包含头文件会提供全部函数主体吗?

    不会。头文件通常提供接口,所需实现仍必须链接。

  2. 作用域与生命周期相同吗?

    不同。局部 static 对象具有块作用域,却在整个程序执行期间保留值。

  3. 文件作用域 static 隐藏什么?

    它给予标识符内部链接,与其他翻译单元同名实体区分。

运行一个完整示例 · 01

在定义前使用接口

在可运行翻译单元中区分声明、定义与私有辅助函数。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int add(int, int);
extern int total;
static int helper(int left, int right) {
    return left + right;
}
int add(int left, int right) {
    return helper(left, right);
}
int total = 0;
int main(void) {
    total = add(8, 5);
    printf("total=%d\n", total);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread compilation-linkage-c-declarations-and-definitions.c -lm -o demo
./demo

预期输出

total=13

读懂这里的语法

int add(int, int);
在看见主体前声明函数接口,分号结束声明。
extern int total
声明程序其他位置定义一次的外部链接对象。本例定义位于同一文件后面。
static int helper(...)
给予辅助函数内部链接,避免名字成为共享外部定义。

按执行顺序理解

  1. 把原型读作类型承诺。

  2. 找到满足承诺的唯一定义。

  3. 调用公开函数并观察对象值。

跟踪状态的变化
名字职责
add外部函数
helper内部辅助函数
total一个已定义对象

逐步读懂代码

原型说明参数与返回类型,后面的函数定义提供可执行行为。extern 声明引用唯一 total 定义,而不是创建第二个初始化对象。

helper 具有内部链接,支持 add 却不成为其公开接口。本独立文件包含全部定义,没有假装复现缺失目标文件的链接失败。

运行一个完整示例 · 02

区分局部作用域与存储期

通过重复调用比较自动局部对象与静态局部对象。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

static void inspect(void) {
    int automatic = 0;
    static int persistent = 0;
    automatic++;
    persistent++;
    printf("automatic=%d persistent=%d\n", automatic, persistent);
}
int main(void) {
    inspect();
    inspect();
    inspect();
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread compilation-linkage-c-scope-and-storage-duration.c -lm -o demo
./demo

预期输出

automatic=1 persistent=1
automatic=1 persistent=2
automatic=1 persistent=3

按执行顺序理解

  1. 观察二者具有相同词法作用域。

  2. 追踪哪个初始化每次调用都发生。

  3. 区分对象存储期与函数链接属性。

逐步读懂代码

两个局部名字都只在 inspect 内可见。自动对象每次调用初始化为零,静态局部对象只初始化一次并保留值。

inspect 前面的 static 作用不同:它给予函数内部链接。同一关键字在不同声明上下文参与不同规则。这些调用为单线程,未同步并发自增需要另一设计。

Leon 的进一步思考

共享一个接口声明

多文件项目中,调用者和实现都应包含同一头文件,让编译器比较实现与公开类型。

独立示例有明确边界

第一个程序把声明与定义放在同一文件,方便直接运行。真实分离编译诊断还需编译及链接不同翻译单元。

注意这个边界. 编译成功并不能证明分别编译的文件之间,声明一定相容。把共享接口放在头文件中,实现该接口的文件也要包含这个头文件。
检查自己的理解: 文件作用域的辅助函数前写 static,有什么作用?

它让函数名具有内部链接,把函数的身份限定在本翻译单元内。另一个翻译单元可以用同样的辅助函数名,定义自己的另一个函数。

↑ 返回知识框架
COMP2017 / 11Leon

信号与异步安全

深入分支 · 可在第一个工程后回看

先建立一个简单模型

信号处理器会打断普通控制流程。

把它用在工程决策里

让处理器尽量简单,把普通工作移到合适位置。不能因为熟悉某函数,就假定它在异步信号处理中安全。

最小信号处理器应该用 printf 格式化报告吗?

不应该。使用允许的标志或其他合法的信号安全设计,再在普通流程中格式化报告。

01 / 从一个故事开始

舞台经理几乎随时都可以摇铃,要求停止演出。表演者最好先记下请求,再完成一次安全过渡。不能在某位工人被打断、工具状态未知时,立刻重建舞台。信号处理函数面对同样的问题:它可能在库函数修改内部数据到一半时运行。

很多时候,设置一个小标志就够了,清理工作交给正常控制流程。铃声也不一定能准确统计每个按铃的观众。标准信号同样可能合并,多次事件不一定逐个排队。

02 / 理解原理

信号通知进程或线程发生了异步事件,例如中断、终止请求或子进程状态变化。在 POSIX 系统上,sigaction 能明确安装处理函数并设置屏蔽规则。SIGKILL 和 SIGSTOP 不能捕获,也不能忽略。处理函数必须遵守异步信号安全规则。普通的 printf、malloc 和互斥锁加锁不适合放在这里,因为中断时可能遇到未完成的内部状态,或造成死锁。

一个简单处理函数可以给 volatile sig_atomic_t 标志赋值,让正常代码之后处理。这个特定用法并不说明 volatile 是通用线程同步工具。信号屏蔽字控制信号递送。可靠的等待协议会使用 sigsuspend 等原子地切换屏蔽字并等待的操作,防止丢失唤醒。系统调用被中断后,可能返回 EINTR,也可能重启,取决于操作和配置。POSIX 系统之间应使用符号形式的信号名;具体编号可能不同。

先认识这些词

信号处理器
捕获信号被递送时进入的函数。
信号屏蔽集
当前被阻止递送的信号集合。
异步信号安全
即使打断了正在执行的操作,信号契约仍允许的行为。

03 / 逐项读懂公式

记忆法:处理函数记录请求 → 主流程检查标志 → 正常执行清理
容易竞争的顺序:检查标志;pause()
更安全的等待模式:屏蔽信号;检查条件;用临时屏蔽字调用 sigsuspend

箭头描述控制协议,不保证具体耗时。标志是 volatile sig_atomic_t 对象,只能以信号处理函数允许的方式使用。重复请求效果相同的停止标志,比统计事件次数更简单。

条件表达式告诉正常代码:是否有工作可做,或是否要求关闭。屏蔽字决定哪些信号暂时不能递送。等待时原子地改变屏蔽字,可以关闭“检查完毕但尚未入睡”这个信号可能到达的空隙。

04 / 先看一个完整例子

工作程序先令 stop_requested=0,安装 SIGTERM 处理函数。处理函数只做 stop_requested=1。每完成一个工作项,正常代码就检查该标志。若终止请求在处理第 7 项时到达,处理函数很快返回。程序完成第 7 项后看到标志,再在正常流程中关闭流并释放分配空间。

如果循环先检查标志再调用 pause,信号可能恰好在两步之间到达。之后若没有新信号,程序就可能永远睡下去。“屏蔽—检查—sigsuspend”模式用于解决这个顺序空隙。关闭请求是幂等的,所以无需统计多次 SIGTERM 的次数。

一步步想清楚

  1. 处理器中为什么避免 printf?

    它可能打断不可安全重入的库状态。应记录允许的标志,在普通流程打印。

  2. volatile 会同步线程吗?

    不会。信号标志模式有特定规则,volatile 不能替代线程同步。

  3. 普通信号能统计每个事件吗?

    不能可靠保证。重复待决普通信号可能合并,计数应采用适当排队协议。

运行一个完整示例 · 01

记录信号,在普通流程处理

确定性地产生一个信号,把输出留在处理器之外。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
static volatile sig_atomic_t requested = 0;
static void record_request(int signal_number) {
    (void)signal_number;
    requested = 1;
}
int main(void) {
    struct sigaction action = {0};
    struct sigaction previous;
    action.sa_handler = record_request;
    if (sigemptyset(&action.sa_mask) == -1)
        return 1;
    if (sigaction(SIGUSR1, &action, &previous) == -1)
        return 1;
    if (raise(SIGUSR1) != 0)
        return 1;
    printf("request observed=%s\n", requested ? "yes" : "no");
    puts("cleanup in normal flow");
    if (sigaction(SIGUSR1, &previous, NULL) == -1)
        return 1;
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread signals-c-minimal-signal-handler.c -lm -o demo
./demo

预期输出

request observed=yes
cleanup in normal flow

读懂这里的语法

volatile sig_atomic_t
使用专门标量类型,让信号处理器作简单赋值并由普通流程观察。
struct sigaction action = {0}
先把动作对象零初始化,再设置处理器、屏蔽集和标志。
(void)signal_number
明确标记故意不用的处理器形参,不读取无关状态。

按执行顺序理解

  1. 用 sigaction 安装最小处理器。

  2. 不依赖时序假设,产生一个已知信号。

  3. 在异步上下文之外观察和清理。

跟踪状态的变化
上下文操作
处理器requested = 1
普通流程打印并恢复

逐步读懂代码

安装的处理器只给允许的标志赋一,不打印也不分配。本单线程演示中,raise 返回前递送未屏蔽信号,因此输出确定。

普通流程读取并报告标志,再执行清理。随后恢复原信号处理方式。这是请求标志,不是事件计数器,也不是通用多线程发布机制。

运行一个完整示例 · 02

同步接受待决信号

在产生前屏蔽,避免先检查后睡眠的空隙。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>

int main(void) {
    sigset_t selected, previous, pending;
    if (sigemptyset(&selected) == -1 || sigaddset(&selected, SIGUSR1) == -1)
        return 1;
    if (sigprocmask(SIG_BLOCK, &selected, &previous) == -1)
        return 1;
    if (raise(SIGUSR1) != 0)
        return 1;
    if (sigpending(&pending) == -1)
        return 1;
    int member = sigismember(&pending, SIGUSR1);
    if (member == -1)
        return 1;
    printf("pending before wait=%s\n", member ? "yes" : "no");
    int received = 0;
    if (sigwait(&selected, &received) != 0)
        return 1;
    printf("accepted expected signal=%s\n", received == SIGUSR1 ? "yes" : "no");
    if (sigprocmask(SIG_SETMASK, &previous, NULL) == -1)
        return 1;
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread signals-c-blocked-signal-wait.c -lm -o demo
./demo

预期输出

pending before wait=yes
accepted expected signal=yes

按执行顺序理解

  1. 可能产生信号之前先屏蔽。

  2. 确认产生的信号已待决。

  3. 通过 sigwait 接受,再恢复原屏蔽集。

逐步读懂代码

屏蔽发生在 raise 前,因此信号进入待决状态,不执行默认动作。sigwait 接受所选待决信号,并通过 received 返回身份。

无需处理器或任意睡眠。接受后恢复原屏蔽集。本例刻意为单线程,引入线程时应使用 pthread_sigmask 及协调一致的屏蔽策略。

Leon 的进一步思考

同步接受可简化设计

专门信号等待流程可屏蔽选定信号并用 sigwait 接受,避免这些信号的异步处理器。多线程程序应在创建工作线程前安排屏蔽集。

等待是协议,不是延迟

sleep 无法关闭检查标志与开始等待间的空隙。应使用屏蔽加原子等待设计,或明确归属的同步信号接受。

注意这个边界. 一个函数可以安全地在普通多线程代码中调用,不代表它在异步信号处理函数中也安全。线程安全与异步信号安全是两种不同约定。
检查自己的理解: 为什么 sleep 不能可靠地替代等待某个信号条件?

sleep 只是推迟执行,没有把条件检查与信号递送原子地协调起来。时序一变,同样的通知丢失竞争仍会出现。

↑ 返回知识框架
COMP2017 / 12Leon

进程、虚拟内存与 fork

核心基础

先建立一个简单模型

fork 创建另一条执行路径,并具有独立地址空间。

把它用在工程决策里

立即明确父子进程的职责。普通变量彼此独立,但继承的描述符仍可能指向共享的底层资源。

改变子进程的普通变量会改变父进程副本吗?

不会。普通内存彼此独立;显式共享内存属于另一种情况。

01 / 从一个故事开始

两位乐手复印同一份乐谱,再分别写上不同批注。页码仍然相同,批注却各不相同。fork 之后,父子进程对应变量显示的虚拟地址也可能相等,但普通内存的修改彼此独立。

有些资源更像共享谱架,而不是各自的复印页:继承的文件描述符可能引用同一个底层打开文件描述,从而共享偏移量。因此,要明确哪些状态被复制、哪些资源仍然共享,以及哪个执行上下文继续运行,不能笼统地说一切都相同或一切都独立。

02 / 理解原理

进程是正在执行的程序,带有地址空间和操作系统资源。fork 创建子进程,子进程从调用之后继续执行。它在子进程中返回零,在父进程中返回子进程 PID;失败时只在父进程返回 −1,不创建子进程。普通可写内存在逻辑上彼此独立,实现上经常使用写时复制页。显式共享内存映射是例外。

继承的描述符引用同一个底层打开文件描述,可能共享文件偏移量和状态标志。stdio 缓冲数据位于用户内存中,也可能被复制。如果两份缓冲都刷新,就可能出现重复输出。进程调度顺序没有规定。多线程进程 fork 后,子进程只保留调用 fork 的线程。在 exec 之前有严格限制,因为继承的锁可能原本由已经消失的线程持有。

先认识这些词

进程
拥有地址空间与操作系统资源的执行上下文。
fork 返回值
子进程中为零,父进程中为子 PID,失败为负一。
写时复制
在保留普通内存独立语义时延迟物理复制的实现技术。

03 / 逐项读懂公式

fork 返回值:−1 表示失败;0 表示当前是子进程;父进程得到正的子进程 PID
若每轮所有现存进程都成功 fork:r 轮后进程数 = 2ʳ
记忆法:相同虚拟地址 ≠ 共享普通可写对象

r 是轮数,每轮都要求当前所有进程执行并成功完成 fork。只有父进程继续创建的循环、提前退出的子进程、失败的调用,都不能直接套用这个指数计数。PID 标识进程,不是内存地址。

虚拟地址在各自进程的地址空间里解释,所以打印出的指针值相等,不能证明写入是共享的。最后一行区分两个概念,不是在比较地址的算术大小。

04 / 先看一个完整例子

单线程程序令 x=5,然后成功调用一次 fork。子进程把自己的 x 改为 12,父进程把自己的 x 改为 8。之后各自读取自己的值,与谁先运行无关。如果两个进程都再次成功 fork,就存在四个进程。每个新子进程继承各自父进程当时的值。

相反,如果每个子进程都立即退出,只有最初父进程继续执行三次成功的循环,就一共创建三个子进程,加上父进程共四个,而不是八个。分析输出时,还必须考虑第一次 fork 之前写进缓冲区的文本。

一步步想清楚

  1. wait 会把子内存合并进父内存吗?

    不会。它报告子状态并建立完成顺序,共享结果需要明确 IPC。

  2. 固定输出示例为什么不打印进程号?

    进程号取决于系统运行情况。应展示角色与状态,不承诺特定编号。

  3. 为什么只有父进程继续的循环创建线性数量子进程?

    每个子进程在下一轮前退出,所以只有一个进程继续创建子进程。

运行一个完整示例 · 01

修改普通状态的两个副本

仅让父进程在回收子进程后打印。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(void) {
    int value = 5;
    pid_t child = fork();
    if (child == -1)
        return 1;
    if (child == 0) {
        value = 12;
        _exit(value);
    }
    value = 8;
    int status = 0;
    if (!wait_child(child, &status) || !WIFEXITED(status))
        return 1;
    printf("parent value=%d\n", value);
    printf("child reported=%d\n", WEXITSTATUS(status));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread process-fork-c-fork-separate-state.c -lm -o demo
./demo

预期输出

parent value=8
child reported=12

读懂这里的语法

pid_t child = fork()
创建子进程,在执行其他工作前区分各返回路径。
if (child == 0)
选择新创建子进程运行的代码。
_exit(value)
终止子进程,不执行普通标准流刷新或退出处理器,仅报告小型状态。

按执行顺序理解

  1. 分开失败、子与父返回路径。

  2. 修改各进程自己的普通对象。

  3. 先解码已完成子状态,再报告。

跟踪状态的变化
进程初始自己的最终值
父58
子512

逐步读懂代码

两个进程从 fork 继续,各自的普通 value 初始为五。子进程把自己的副本改为十二,父进程改为八。

父进程只通过子进程明确选择的退出状态获得十二,不是读取合并内存。退出状态仅用于这个微型演示,大型结构化结果需 IPC。wait 后打印得到稳定顺序。

运行一个完整示例 · 02

统计只有父进程继续的创建循环

让子进程立即退出,使创建数量线性增长。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(void) {
    int created = 0, status_sum = 0;
    for (int i = 0; i < 3; i++) {
        pid_t child = fork();
        if (child == -1)
            return 1;
        if (child == 0)
            _exit(i + 1);
        int status = 0;
        if (!wait_child(child, &status) || !WIFEXITED(status))
            return 1;
        created++;
        status_sum += WEXITSTATUS(status);
    }
    printf("children created=%d\n", created);
    printf("distinct processes=%d\n", created + 1);
    printf("status sum=%d\n", status_sum);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread process-fork-c-parent-only-creation.c -lm -o demo
./demo

预期输出

children created=3
distinct processes=4
status sum=6

按执行顺序理解

  1. 保证子进程不能继续创建循环。

  2. 处理等待中断并回收每个子进程。

  3. 区分累计进程数与同时并发数。

逐步读懂代码

只有原父进程到达下一轮。每个子进程报告轮次后退出,父进程先回收它再创建下一个。三轮因此创建三个子进程,不是七个。

总数四统计整个运行中不同进程,不是四个同时活动进程。状态和验证三份完成记录均被收集,顺序不依赖延迟。

Leon 的进一步思考

缓冲会改变可观察输出

fork 前用户空间缓冲输出会复制。应先刷新,或明确谁负责输出,尤其要处理子进程错误退出路径。

单线程 fork 示例有边界

多线程进程 fork 后,子进程继承的状态可能含由消失线程持有的锁。应遵循 exec 前的受限契约,不把本例当通用子工作模板。

注意这个边界. 写时复制是实现独立地址空间的一种技术。它不会把父子进程的普通变量变成通信渠道。
检查自己的理解: 子进程改变一个全局变量后,父进程只要等待它结束,就能看到这个改变吗?

不能。等待只能协调终止的先后关系,不会合并彼此独立的普通内存。应通过管道、共享映射或其他显式进程间通信机制传回结果。

↑ 返回知识框架
COMP2017 / 13Leon

执行新程序并回收子进程

核心基础

先建立一个简单模型

exec 替换进程映像,wait 收集子进程最终状态。

把它用在工程决策里

成功的 exec 不会返回。父进程要用对应宏解释 wait 状态,并规定工具如何报告失败。

wait 状态可以直接当成子进程退出码吗?

不可以。先检查 WIFEXITED 或 WIFSIGNALED,再用对应的提取宏。

01 / 从一个故事开始

同一间剧场可以换上全新的剧目,而房间编号不变。exec 替换进程的程序映像,却保留其进程身份。fork 先提供第二间房,所以 shell 可以启动别的程序,同时保留自己。

演出结束后,会留下一张供负责人收取的完成单。这像僵尸进程:演出已经结束,但退出状态尚未被回收。仍在演出的孩子失去了原负责人,则是另一种情况,更像转交给新主管,而不是留下未收走的完成记录。

02 / 理解原理

exec 家族替换当前进程的程序映像。调用成功就不会返回。不同变体在列表参数或向量参数、PATH 查找、显式环境传递等方面有区别。参数向量必须以空指针结尾。常见启动流程是先 fork,再安排子进程描述符,执行新程序。失败时用 _exit,避免再次刷新继承的用户态缓冲。

父进程检查 waitpid,在 EINTR 后按需要重试,并只用合适的宏解释状态。先用 WIFEXITED 确认正常结束,才读取 WEXITSTATUS;先用 WIFSIGNALED 确认因信号结束,才读取 WTERMSIG。WNOHANG 返回零,可能表示所选子进程还没有可报告的状态变化。已终止但未回收的子进程是僵尸;父进程退出后仍活着的子进程,则按操作系统规则被重新指定父进程。进程池可以复用工作进程,但仍需要真实的任务分发和关闭协议。

先认识这些词

程序映像
成功 exec 替换的代码与普通数据。
回收子状态
通过等待操作收集已终止子进程状态。
编码状态
必须通过宏解释、不能直接当退出码的等待结果。

03 / 逐项读懂公式

记忆法:fork → 子进程设置 → exec;父进程 → waitpid → 解释状态
waitpid(...,WNOHANG):>0 有子进程状态可报告;0 暂时没有;−1 错误
exec 成功:进程身份相同,程序映像更新

箭头是生命周期记忆法,不要求父进程一定在子进程之后运行。waitpid 返回的 PID 指明这次记录了哪个子进程的状态。使用 status 前先检查返回值。非阻塞情况下的零不是退出码。

exec 成功后替换代码和普通程序数据,同时保留规定的进程属性。描述符一般仍然存在,除非设置了执行时关闭。判断成功的一个关键是:旧程序不会继续执行调用之后的代码。

04 / 先看一个完整例子

启动器创建一个子进程,这里用 PID 4200 作为示意编号。子进程调用 execvp,参数为 {"printf","ready",NULL}。若执行成功,旧程序中的错误处理分支就不会运行。父进程调用 waitpid(4200,&status,0)。

等待成功后,假设 WIFEXITED(status) 为真,WEXITSTATUS(status) 为 0,父进程就报告正常完成。如果命令无法执行,子进程进入错误路径并调用 _exit(127)。父进程可能据此得到约定的失败码 127,但提取该数值之前,仍必须确认子进程属于正常退出。

一步步想清楚

  1. exec 成功后旧映像的下一句执行什么?

    不执行。成功会替换映像;exec 返回表示失败。

  2. 为什么先检查 WIFEXITED?

    WEXITSTATUS 只对正常退出有意义,不适用于所有子终止状态。

  3. 僵尸进程仍执行应用代码吗?

    不执行。它已终止,只留下等待收集的完成状态。

运行一个完整示例 · 01

用本程序的新副本替换子进程

用子模式参数让成功替换可见。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <string.h>
static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(int argc, char **argv) {
    if (argc == 2 && strcmp(argv[1], "child") == 0) {
        puts("replacement image running");
        return 7;
    }
    if (argc < 1 || argv[0] == NULL || argv[0][0] == '\0')
        return 1;
    pid_t child = fork();
    if (child == -1)
        return 1;
    if (child == 0) {
        execl(argv[0], argv[0], "child", (char *)NULL);
        _exit(127);
    }
    int status = 0;
    if (!wait_child(child, &status) || !WIFEXITED(status))
        return 1;
    printf("replacement exit=%d\n", WEXITSTATUS(status));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread exec-wait-c-exec-self-replacement.c -lm -o demo
./demo

预期输出

replacement image running
replacement exit=7

读懂这里的语法

execl(argv[0], argv[0], "child", (char *)NULL)
用本可执行路径及空指针终止的参数列表替换映像。
WIFEXITED(status)
检查子进程是否通过正常退出路径终止。
WEXITSTATUS(status)
确认正常退出后才提取低位退出状态。

按执行顺序理解

  1. 识别新映像的子模式参数。

  2. 仅在 fork 出的子进程调用 exec。

  3. 原父进程等待并解码正常退出。

跟踪状态的变化
路径后续行为
exec 成功新 main,退出 7
exec 失败旧子进程退出 127

逐步读懂代码

初始映像只 fork 一次。子进程要求 exec 用额外参数启动相同可执行文件,新映像因此进入子分支,不再 fork。其正常返回刷新自己的输出并报告状态七。

execl 后的旧子代码只在替换失败时执行。父进程先等待再打印,因此两行顺序稳定。exec 保留进程身份,但输出不依赖旧映像变量。

运行一个完整示例 · 02

处理 exec 失败并避免重复缓冲

空可执行路径提供受控失败情况。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(void) {
    pid_t child = fork();
    if (child == -1)
        return 1;
    if (child == 0) {
        char *arguments[] = {"unused", NULL};
        execv("", arguments);
        _exit(127);
    }
    int status = 0;
    if (!wait_child(child, &status))
        return 1;
    if (WIFEXITED(status))
        printf("launcher failure status=%d\n", WEXITSTATUS(status));
    else
        return 1;
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread exec-wait-c-exec-failure-status.c -lm -o demo
./demo

预期输出

launcher failure status=127

按执行顺序理解

  1. 请求刻意无效的可执行路径。

  2. 通过专门错误路径终止失败子进程。

  3. 使用带前提检查的宏解释等待状态。

逐步读懂代码

空路径不能命名可执行文件,因此 execv 返回失败。子进程用明确选择的状态 127 调用 _exit,避免普通退出处理器或继承标准流缓冲刷新。

父进程先确认 waitpid 报告了所选子进程,再确认正常终止后提取退出值。程序不会把状态整数本身当退出码打印。

Leon 的进一步思考

自执行让示例独立完整

第一个程序用子模式参数执行自己保存的可执行文件,无需 shell、下载程序或准备数据文件。应通过可用的可执行路径启动。

退出约定不是通用诊断

本启动器选择 127 表示 exec 失败,其他程序也可正常以 127 退出。实际启动器可另用执行时关闭的错误管道区分情况。

注意这个边界. 等待状态是编码后的信息,不是可直接当作退出码的整数。把原始整数当作退出码打印,可能误报结果。
检查自己的理解: 为什么 shell 在自身进程里调用 exec 后,不能再回到原来的提示符?

exec 成功时会替换 shell 的程序映像。如果 shell 要在子进程执行命令的同时保留自己的控制流程,就要先 fork。

↑ 返回知识框架
COMP2017 / 14Leon

管道、FIFO 端点与字节分帧

核心基础

先建立一个简单模型

管道是字节流,不是一组消息对象。

把它用在工程决策里

画出 fork 后读写端的归属。关闭无用副本,按需规定分帧方式;若写入者可能阻塞,就要先持续读取输出,再等待其结束。

子进程关闭写端后,读取者为什么仍可能一直等待?

可能还有另一个写端副本未关闭,例如父进程自己的副本。所有写端关闭且缓冲数据读完后才会出现 EOF。

01 / 从一个故事开始

两间工坊通过窄滑道传送零件。滑道保留顺序,但接收者一次可能只取到半批,也可能同时取到几批。只有所有装入口都关好,接收者才能确定不会再有零件到来;哪怕留下一个不用的装入口,也无法得出这个结论。

管道同样不在读取时保留应用消息的边界。是否遇到文件结束,取决于所有写描述符是否关闭。FIFO 给滑道一个公开名称,让没有亲缘关系的工坊也能打开它。这个名称是访问入口,并不是在所有用户离开后还保存昨天零件的普通文件。

02 / 理解原理

pipe 创建字节流的读端和写端。fork 后,每个进程都要关闭不用的端点,包括继承来的重复端点。管道为空但仍有写端时,阻塞读取会等待。缓冲数据读完且所有写端关闭后,读取返回零。没有读端时写入,可能触发 SIGPIPE,并以 EPIPE 失败。部分读取和写入需要循环、检查计数,并适当处理 EINTR。

大小不超过 PIPE_BUF 的写入,保证不会与其他写者的写入交错;但读取依然没有消息边界。应用层要自行规定分隔符、固定长度或长度前缀。dup2 支持重定向和流水线。FIFO 名称改变的是发现端点和打开的行为,不会改变基本的字节流模型。

先认识这些词

端点
用于管道读端或写端的描述符。
分帧
从字节流重建记录的应用规则。
部分传输
成功传输的字节少于请求字节。

03 / 逐项读懂公式

剩余字节数 = 请求字节数 − 已传输字节数
EOF 条件:缓冲区为空 且 没有任何打开的写端点
分帧记忆法:积累字节 → 找出完整帧 → 解析 → 保留剩余部分

已传输计数来自 read 或 write 成功时的返回值,可能小于请求数量。read 返回零专门表示 EOF;−1 表示错误。EOF 条件要计算相关进程中的所有打开写端,不能只计算程序员认为“正式写者”持有的那一个。

PIPE_BUF 是平台规定的原子管道写入上限,不是管道的总容量。分帧这一行是解析协议,还必须规定允许接收的最大帧大小。

04 / 先看一个完整例子

写者发送字节序列 "3:cat4:bird",表示两条带长度前缀的记录。读者第一次可能收到 "3:ca",第二次收到 "t4:bi",第三次收到 "rd"。它先解析长度,再等待三个数据字节,交出 cat,并把剩余字节留给下一条记录。不能假设一次 read 就对应一次 write。

在父进程向子进程发送数据的管道中,父进程关闭自己的读端,子进程关闭自己的写端。父进程写完并关闭最后一个写端后,子进程读完缓冲数据,最终读取到零。如果子进程忘了关闭继承的写端,就不会到达这个 EOF 条件。

一步步想清楚

  1. 空管道何时返回 EOF?

    全部写端关闭时。遗忘的继承写端可能让读取者一直等待。

  2. PIPE_BUF 定义消息边界吗?

    不定义。它约束写入者之间交错,不决定读取如何划分字节。

  3. 大型写入者为什么应先排空输出再等待?

    子进程可能因管道已满而阻塞,不读取就等待会阻止它完成。

  4. 为什么一次成功的 write 不保证一次对应的 read?

    读取器自己指定请求大小,流读取也可能只返回当前可用的部分字节。需要由应用协议和循环重建完整消息。

运行一个完整示例 · 01

关闭写端后排空管道

用小型独立数据流,并跨多次读取累计字节。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
static int write_all(int fd, const void *data, size_t length) {
    const unsigned char *bytes = data;
    size_t sent = 0;
    while (sent < length) {
        ssize_t amount = write(fd, bytes + sent, length - sent);
        if (amount == -1 && errno == EINTR)
            continue;
        if (amount <= 0)
            return 0;
        sent += (size_t)amount;
    }
    return 1;
}
int main(void) {
    int endpoints[2];
    if (pipe(endpoints) == -1)
        return 1;
    const char message[] = "hello";
    if (!write_all(endpoints[1], message, 5)) {
        close(endpoints[0]);
        close(endpoints[1]);
        return 1;
    }
    if (close(endpoints[1]) == -1) {
        close(endpoints[0]);
        return 1;
    }
    char result[6];
    size_t total = 0;
    for (;;) {
        char chunk[2];
        ssize_t amount = read(endpoints[0], chunk, sizeof chunk);
        if (amount == -1 && errno == EINTR)
            continue;
        if (amount < 0) {
            close(endpoints[0]);
            return 1;
        }
        if (amount == 0)
            break;
        if ((size_t)amount > 5 - total) {
            close(endpoints[0]);
            return 1;
        }
        for (ssize_t i = 0; i < amount; i++)
            result[total++] = chunk[i];
    }
    result[total] = '\0';
    printf("received=%s bytes=%zu\n", result, total);
    puts("EOF after writer closed");
    return close(endpoints[0]) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pipes-fifos-c-pipe-bytes-and-eof.c -lm -o demo
./demo

预期输出

received=hello bytes=5
EOF after writer closed

读懂这里的语法

int endpoints[2]
预留两个描述符位置,pipe 将零号设为读端、一号设为写端。
close(endpoints[1])
关闭此写端,让缓冲字节消费完后可观察 EOF。
ssize_t amount
使用有符号字节计数类型,让负一可表示错误。

按执行顺序理解

  1. 创建两端并写入已知有界字节。

  2. 检测流结束前关闭唯一写端。

  3. 累计每次成功读取,并为最终文本添加终止符。

跟踪状态的变化
状态读取行为
尚有字节返回可用字节
为空且无写端0 / EOF

逐步读懂代码

演示的五字节足够小,同一进程可先写后读。随后关闭写端,使最终 EOF 成为可能。

读取者每次最多请求两字节,但不依赖具体分组。它累计已检查数量,保留一字节给字符串终止符,并在零读取时停止。这只适用于微小有界载荷,大型同时传输需并发排空。

运行一个完整示例 · 02

重建两个长度前缀帧

子进程发送有界记录,父进程先解析再回收。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
static int write_all(int fd, const void *data, size_t length) {
    const unsigned char *bytes = data;
    size_t sent = 0;
    while (sent < length) {
        ssize_t amount = write(fd, bytes + sent, length - sent);
        if (amount == -1 && errno == EINTR)
            continue;
        if (amount <= 0)
            return 0;
        sent += (size_t)amount;
    }
    return 1;
}
static int read_exact(int fd, void *data, size_t length) {
    unsigned char *bytes = data;
    size_t received = 0;
    while (received < length) {
        ssize_t amount = read(fd, bytes + received, length - received);
        if (amount == -1 && errno == EINTR)
            continue;
        if (amount < 0)
            return -1;
        if (amount == 0)
            return received == 0 ? 0 : -1;
        received += (size_t)amount;
    }
    return 1;
}

static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(void) {
    int endpoints[2];
    if (pipe(endpoints) == -1)
        return 1;
    pid_t child = fork();
    if (child == -1) {
        close(endpoints[0]);
        close(endpoints[1]);
        return 1;
    }
    if (child == 0) {
        close(endpoints[0]);
        int ok = write_all(endpoints[1], "3cat4bird", 9);
        close(endpoints[1]);
        _exit(ok ? 0 : 1);
    }
    close(endpoints[1]);
    int valid = 1, frames = 0;
    for (;;) {
        char digit;
        int header = read_exact(endpoints[0], &digit, 1);
        if (header == 0)
            break;
        if (header < 0 || digit < '0' || digit > '8') {
            valid = 0;
            break;
        }
        size_t length = (size_t)(digit - '0');
        char payload[9];
        if (read_exact(endpoints[0], payload, length) != 1) {
            valid = 0;
            break;
        }
        payload[length] = '\0';
        printf("frame=%s\n", payload);
        frames++;
    }
    close(endpoints[0]);
    int status = 0;
    if (!wait_child(child, &status) || !WIFEXITED(status) || WEXITSTATUS(status) != 0 ||
        !valid)
        return 1;
    printf("frames=%d\n", frames);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pipes-fifos-c-pipe-length-framing.c -lm -o demo
./demo

预期输出

frame=cat
frame=bird
frames=2

按执行顺序理解

  1. 关闭本进程不用的继承端点。

  2. 读取并验证长度,再读取载荷。

  3. 排空完整帧,关闭管道并回收写入者。

逐步读懂代码

这个刻意简化的协议用一位十进制长度,后接相应字节,载荷最多八字节。read_exact 循环读齐当前部分,并区分正常 EOF 与截断。

各进程关闭不用的端点。父进程先排空帧再等待,避免先等后读的设计错误。写端发送含两位长度的九字节,解析器只打印完整有界载荷。

运行一个完整示例 · 03

从字节流中读出完整消息

两个小程序交换单词。米娜以为一次 read 就能读出一个完整单词。第一次成功,后来某次读取只有两个字节,程序就出错了。她给每个单词添加一个字节的长度头。读取器不断收集,直到达到声明的长度;还要区分下一条消息开始前的正常结束,与单词只收到一半时的异常结束。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <assert.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
enum Result { COMPLETE, CLEAN_EOF, TRUNCATED, IO_ERROR };
static enum Result read_exact(int fd, unsigned char *out, size_t need) {
    size_t used = 0;
    while (used < need) {
        size_t request = need - used;
        if (request > 2) request = 2; /* Deliberately fragment reads. */
        ssize_t n = read(fd, out + used, request);
        if (n > 0) { used += (size_t)n; continue; }
        if (n == 0) return used == 0 ? CLEAN_EOF : TRUNCATED;
        if (errno == EINTR) continue;
        return IO_ERROR;
    }
    return COMPLETE;
}
static int write_all(int fd, const unsigned char *data, size_t size) {
    size_t sent = 0;
    while (sent < size) {
        ssize_t n = write(fd, data + sent, size - sent);
        if (n > 0) sent += (size_t)n;
        else if (n < 0 && errno == EINTR) continue;
        else return 0;
    }
    return 1;
}
int main(void) {
    int channel[2];
    if (pipe(channel) == -1) return 1;
    const unsigned char wire[] = {4, 'p', 'i', 'n', 'g', 3, 'o', 'k'};
    int sent = write_all(channel[1], wire, sizeof wire);
    int closed = close(channel[1]);
    if (!sent || closed == -1) { close(channel[0]); return 1; }
    unsigned char length, payload[16];
    enum Result header = read_exact(channel[0], &length, 1);
    if (header != COMPLETE || length == 0 || length > sizeof payload) {
        close(channel[0]); return 1;
    }
    enum Result body = read_exact(channel[0], payload, length);
    if (body != COMPLETE) { close(channel[0]); return 1; }
    printf("frame: %.*s\n", (int)length, (char *)payload);
    header = read_exact(channel[0], &length, 1);
    if (header != COMPLETE || length == 0 || length > sizeof payload) {
        close(channel[0]); return 1;
    }
    body = read_exact(channel[0], payload, length);
    assert(body == TRUNCATED);
    puts("second frame: truncated payload");
    enum Result end = read_exact(channel[0], &length, 1);
    assert(end == CLEAN_EOF);
    puts("next header: clean EOF");
    return close(channel[0]) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread pipes-fifos-framed-short-reads.c -lm -o demo
./demo

预期输出

frame: ping
second frame: truncated payload
next header: clean EOF

读懂这里的语法

ssize_t
有符号返回类型,可表示字节数或 -1。
out + used
从已收到字节之后继续填充。
errno == EINTR
该调用被中断时重试,不假设已经增加了字节。

按执行顺序理解

  1. 先写入八个字节,再关闭写端。这个很小的样例能装进管道,无须创建子进程。

  2. 分别保存已读 used 和目标 need。正数短读会推进已读量及下次写入位置。

  3. 写入 payload 前先校验长度,最多接受 16 字节。

  4. 第二个头声明三个字节,实际只剩两个,必须报告截断,不能当成完整消息。

逐步读懂代码

字节流保留字节顺序,不保留应用消息的边界。循环不变量是:out[0:used] 恰好保存这次请求已经收到的字节。每次正数读取都会减少 need-used;EINTR 重试时不改变 used;EOF 或其他错误则结束循环。

若当前请求一个字节也没读到,辅助函数返回 CLEAN_EOF。调用者必须结合上下文解释它:新消息头之前的零字节可表示正常结束;已收到正长度头之后的零字节却表示正文缺失,也必须拒绝。本例拒绝所有非 COMPLETE 的正文结果。

长度为 n 的正文需要 O(n) 字节传输工作,本例使用有上限的缓冲区。真实阻塞流可能无限等待剩余字节,因此网络服务还需要超时策略、对端关闭处理和分配前的长度上限。这个确定性管道示例没有实现这些策略;打印用的文本正文也不是通用二进制解码器。

Leon 的进一步思考

FIFO 改变发现方式,不改变分帧

命名 FIFO 让无亲缘进程找到端点,其字节流仍需与匿名管道相同的长度、分隔符与关闭规则。

限制每个宣布长度

接收者必须在分配或读入固定缓冲区前拒绝过大帧,并区分帧间正常 EOF 与帧内截断。

注意这个边界. 在读空管道之前先等子进程结束,可能造成死锁:子进程正等着向已满的管道继续写。应协调读取与等待,使双方都不会阻止对方前进。
检查自己的理解: 一次五字节的原子管道写入,能保证下一次读取恰好返回五字节吗?

不能。原子性防止这一段写入与其他写者交错,不会为读取建立消息边界。一次读取可能少于五字节,也可能包含多次写入的数据。

↑ 返回知识框架
COMP2017 / 15Leon

消息、Unix 套接字与 IPC 选择

深入分支 · 可在第一个工程后回看

先建立一个简单模型

不同进程间通信工具提供不同的消息边界和通信方向。

把它用在工程决策里

依据需求选择机制:进程关系、消息边界、双向通信、发现方式和清理方式。只因为熟悉某机制,并不足以作为设计理由。

Unix 流式套接字会把一次 write 保留为一次 read 吗?

不会。若消息边界重要,流通信也需要应用层分帧规则,与管道类似。

01 / 从一个故事开始

信箱接收一封封独立的信,电话则传送连续对话,需要听者自己划分句子。消息队列像信封,流式套接字像电话。一个本地服务该选哪种模型,取决于它交换的是固定请求、大块数据流,还是独立通知。

选择进程间通信机制,也是在选择参与者如何找到端点、谁负责清理,以及对方消失时怎么办。传输再快,也不能弥补含糊的协议。接收者仍要知道响应对应哪个请求、允许多长的数据,以及错误的含义。

02 / 理解原理

System V 消息队列保留消息边界,通过 msgget、msgsnd、msgrcv、msgctl 操作,并支持按正的消息类型进行选择。大小参数表示开头 long 类型字段之后的数据字节数,不是整个消息对象的大小。队列容量和最大消息大小有限。接收缓冲区太短时如何处理,必须结合标志明确决定。ftok 能生成键,但不保证全局唯一。参与者退出后,队列对象可以继续存在,直到被移除;这不代表消息已经持久写入磁盘。

Unix 域套接字支持本地客户—服务器通信。流式套接字使用 socket、bind、listen、accept 和 connect;数据报套接字保留数据报边界。流式读取需要分帧和部分传输处理。套接字路径由谁拥有、权限如何设置、谁负责清理,都属于协议。应根据通信拓扑、消息边界、数据量、可移植性和生命周期要求,选择管道、FIFO、队列、套接字或共享内存。

先认识这些词

消息类型
System V 队列用于分类或选择消息的正 long 值。
数据报
边界由传输机制保留的有界消息。
套接字对
同时创建、彼此连接的两个本地通信端点。

03 / 逐项读懂公式

System V 消息布局:正的 long 类型字段 | 数据字节
消息大小参数 = 已定义的数据长度,不包括类型字段
流式服务端记忆法:socket → bind → listen → accept → 交换数据 → close

竖线表示布局分隔,不是按位运算。正的类型字段支持队列选择规则。数据长度必须对应程序确实初始化并打算发送的字节,避免意外发送填充或旧数据。

服务端顺序描述面向连接的本地套接字生命周期。监听套接字与每个已接受连接是不同的描述符,也有不同责任。路径名标识端点,本身并不能认证或授权每一个对端。

04 / 先看一个完整例子

消息队列结构以 long type 开头,后面是 char payload[16]。若发送不带终止符的五字节单词 "hello",应设置正的类型,并传入数据长度 5,而不是整个结构体的 sizeof。接收者使用返回长度;只有还有容量且确实想把数据当作 C 字符串时,才补一个终止符。

另一个 Unix 流服务用四字节长度前缀声明后面有 100 字节数据。读到前缀后,只收到 37 字节数据很正常。程序必须再收集 63 字节才能解析。明确最大长度,可以防止对方要求无限扩大的分配。

一步步想清楚

  1. 消息大小包含开头 long 吗?

    对 System V msgsnd 和 msgrcv,大小描述类型字段之后的载荷字节。

  2. 接收的字节数组自动成为字符串吗?

    不会。使用返回长度,只有容量有余量时才添加终止符。

  3. 本地套接字是网络下载吗?

    不是。AF_UNIX 套接字对在本程序创建的本地端点之间通信。

运行一个完整示例 · 01

只发送已定义载荷字节

创建、使用并移除一个私有 System V 消息队列。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
struct Message {
    long type;
    char payload[16];
};
int main(void) {
    int queue = msgget(IPC_PRIVATE, 0600);
    if (queue == -1)
        return 1;
    struct Message sent = {2, {'h', 'e', 'l', 'l', 'o'}};
    if (msgsnd(queue, &sent, 5, IPC_NOWAIT) == -1) {
        msgctl(queue, IPC_RMID, NULL);
        return 1;
    }
    struct Message received = {0, {0}};
    ssize_t length = msgrcv(queue, &received, sizeof received.payload, 2, IPC_NOWAIT);
    if (length == -1) {
        msgctl(queue, IPC_RMID, NULL);
        return 1;
    }
    printf("type=%ld bytes=%zd text=%.*s\n", received.type, length, (int)length,
           received.payload);
    if (msgctl(queue, IPC_RMID, NULL) == -1)
        return 1;
    puts("queue removed");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread queues-sockets-c-system-v-message-boundary.c -lm -o demo
./demo

预期输出

type=2 bytes=5 text=hello
queue removed

读懂这里的语法

msgget(IPC_PRIVATE, 0600)
创建通过返回编号访问的新队列。IPC_PRIVATE 请求新建,不是可复用公共键。
long type
在载荷字节前放置所需正数消息类型。
msgctl(queue, IPC_RMID, NULL)
从内核移除所拥有队列及剩余消息。

按执行顺序理解

  1. 创建本次运行拥有的新队列。

  2. 发送正类型及恰好五个载荷字节。

  3. 使用返回载荷长度,再移除队列。

跟踪状态的变化
部分包含于大小 5?
long type否
hello是
终止符与剩余字节否

逐步读懂代码

大小五不含开头 long,只发送 hello,不含终止符。接收者选择类型二,从 msgrcv 获取载荷长度。受限打印使用该长度,不越过已初始化文本寻找结束。

IPC_NOWAIT 让容量或消息缺失明确失败,避免同进程演示出现无限等待。成功及操作失败都移除私有队列。System V IPC 是可选平台设施,本程序已在所述 macOS 工具链测试。

运行一个完整示例 · 02

保留两个本地数据报边界

用 socketpair,不需文件系统路径或网络服务。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

int main(void) {
    int sockets[2];
    if (socketpair(AF_UNIX, SOCK_DGRAM, 0, sockets) == -1)
        return 1;
    if (send(sockets[0], "cat", 3, 0) != 3 || send(sockets[0], "bird", 4, 0) != 4) {
        close(sockets[0]);
        close(sockets[1]);
        return 1;
    }
    for (int i = 0; i < 2; i++) {
        char payload[16];
        ssize_t length = recv(sockets[1], payload, sizeof payload, 0);
        if (length < 0) {
            close(sockets[0]);
            close(sockets[1]);
            return 1;
        }
        printf("datagram %d bytes=%zd text=%.*s\n", i, length, (int)length, payload);
    }
    int first = close(sockets[0]);
    int second = close(sockets[1]);
    return first == 0 && second == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread queues-sockets-c-local-datagram-boundaries.c -lm -o demo
./demo

预期输出

datagram 0 bytes=3 text=cat
datagram 1 bytes=4 text=bird

按执行顺序理解

  1. 创建两个相连本地数据报端点。

  2. 按明确长度发送两条记录。

  3. 接收每条有界记录,并关闭两端。

逐步读懂代码

两个连接好的 AF_UNIX 数据报端点在本地创建。每次成功 send 提供一条记录。接收缓冲区大于两个已知载荷,因此每次接收取到完整数据报。

程序打印长度三和四,不假定空字符终止。若把类型换成 SOCK_STREAM,记录边界保证消失,需要分帧循环。两个微小发送适合本有界演示;实际代码还需处理中断与反压。

Leon 的进一步思考

清理内核拥有的对象

创建者退出后 System V 队列可能仍存在。本例所有完成路径都要移除私有队列,不能把进程退出当清理协议。

保留记录仍需大小限制

数据报保留边界,但小接收缓冲区仍可能截断。流套接字需要明确分帧,两种传输都不验证应用载荷含义。

注意这个边界. System V 队列由内核保留,并不表示消息能在重启后存活,也不能据此把它当作持久数据库。
检查自己的理解: 流式套接字和消息队列中,哪一种天然保留每条记录的边界?

消息队列保留消息边界。流式套接字保留有序字节,因此应用程序必须自己表示并重建记录边界。

↑ 返回知识框架
COMP2017 / 16Leon

共享内存与信号量协议

深入分支 · 可在第一个工程后回看

先建立一个简单模型

共享字节与协调访问是两件不同的事。

把它用在工程决策里

明确一个信号量在数什么。许可可以表示空闲位置、可用任务或其他资源;共享数据仍需要正确的顺序协议。

共享内存会自动防止竞争吗?

不会,它只提供共享存储,同步和生命周期规则需要另外设计。

01 / 从一个故事开始

几位编辑共用一块白板。共享白板省去了往各自笔记本复制内容的步骤,却没有规定谁能擦除句子,也没有告诉读者何时可以信任新段落。共享内存提供白板,同步提供编辑协议。

计数信号量像一盘许可牌。拿走一枚表示预留容量,放回一枚表示资源可用。许可可以由不同参与者领取和归还;互斥锁则通常由持锁线程解锁。若编辑在修改一半时消失,仅归还一枚许可,也不能修好损坏的段落。

02 / 理解原理

共享映射让多个进程访问共同存储,但不会自动让复合更新变成原子操作,也不会自动建立应用层交接。System V 共享内存使用 shmget、shmat、shmdt、shmctl。检查 shmat 失败要与 (void*)−1 比较,而不是 NULL。共享段可能映射到不同虚拟地址,因此跨进程引用应使用偏移量或经过设计的表示,不能直接依赖原始指针。IPC_RMID 把共享段标记为待移除;按适用系统规则,现有连接通常会保留到分离为止。

信号量计算可用资源:成功 wait 消耗一个许可,post 增加一个。POSIX 和 System V 的信号量 API 在初始化、操作和清理上不同。进程间共享的同步对象需要正确的放置位置和属性。System V 的 SEM_UNDO 会在退出时调整已记录的信号量操作,却不能修复应用数据的半次更新。必须分别定义归属、初始化和故障恢复。

先认识这些词

共享段
多个进程附加到同一底层字节的存储。
许可
成功信号量等待所消耗的一单位可用性。
静止边界
没有参与者持有尚未完成预留或更新的时刻。

03 / 逐项读懂公式

有界缓冲区在无操作进行时的不变式:empty + full = capacity
生产者:wait(empty) → 加锁 → 插入 → 解锁 → post(full)
消费者:wait(full) → 加锁 → 移除 → 解锁 → post(empty)

empty 统计尚未预留的空位置,full 统计已经发布、可供取用的项目。capacity 是固定槽位数。总和不变式只在没有操作处于进行中的静止点陈述,因为参与者执行过程中可能暂时持有一个预留许可。

锁保护共享下标和数据一致性,与计数信号量是不同机制。箭头是协议记忆法。真实程序还要处理等待被中断、操作失败和关闭,避免丢失许可或把锁留在持有状态。

04 / 先看一个完整例子

共享环形缓冲区容量为 3,起初为空,初始化 empty=3、full=0。生产者成功等待 empty,把它减到 2。随后加锁、写入 17、把写下标按模 3 前进、解锁,再 post full,使它变成 1。

消费者等待 full,加锁取出 17,前移读下标,解锁,再 post empty,使其回到 3。完成操作的边界处,计数与缓冲区占用一致。一定要先预留空位再加互斥锁。如果生产者持有锁时等待空位,消费者也需要同一把锁,就无法取走项目来腾出位置。

一步步想清楚

  1. 共享字节会保护更新吗?

    不会。仍需独立顺序或同步协议。

  2. 成功 sem_trywait 消耗什么?

    一个可用许可。没有许可时报告 EAGAIN,而不阻塞。

  3. 为什么保存偏移而不是原始共享指针?

    同一段在不同进程中可能映射到不同地址。

运行一个完整示例 · 01

子进程完成后观察明确共享存储

比较共享整数与普通进程局部整数。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h>
#include <sys/ipc.h>
#include <sys/shm.h>
static int wait_child(pid_t child, int *status) {
    pid_t result;
    do {
        result = waitpid(child, status, 0);
    } while (result == -1 && errno == EINTR);
    return result == child;
}
int main(void) {
    int segment = shmget(IPC_PRIVATE, sizeof(int), 0600);
    if (segment == -1)
        return 1;
    int *shared = shmat(segment, NULL, 0);
    if (shared == (void *)-1) {
        shmctl(segment, IPC_RMID, NULL);
        return 1;
    }
    if (shmctl(segment, IPC_RMID, NULL) == -1) {
        shmdt(shared);
        return 1;
    }
    *shared = 4;
    int local = 4;
    pid_t child = fork();
    if (child == -1) {
        shmdt(shared);
        return 1;
    }
    if (child == 0) {
        *shared = 17;
        local = 9;
        _exit(local == 9 ? 0 : 1);
    }
    int status = 0;
    if (!wait_child(child, &status) || !WIFEXITED(status) || WEXITSTATUS(status) != 0) {
        shmdt(shared);
        return 1;
    }
    printf("shared=%d parent local=%d\n", *shared, local);
    return shmdt(shared) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread shared-memory-semaphores-c-shared-segment-handoff.c -lm -o demo
./demo

预期输出

shared=17 parent local=4

读懂这里的语法

shmat(segment, NULL, 0)
附加共享段,失败比较对象是 (void *)-1,不是 NULL。
shmctl(segment, IPC_RMID, NULL)
标记共享段,在剩余附加消失后移除。
shmdt(shared)
全部使用完成后,分离本进程附加。

按执行顺序理解

  1. 附加自己拥有的共享存储,并安排延后移除。

  2. 让子进程分别更新共享与普通状态。

  3. 读取共享结果前等待,随后分离。

跟踪状态的变化
对象等待后父进程观察
共享段整数17
普通局部变量4

逐步读懂代码

共享段在 fork 前附加,并被子进程继承。它立即标记移除,因此最后附加消失后不会残留。普通局部变量仍复制到独立进程内存。

fork 后只有子进程写共享整数,父进程在子进程成功完成后读取,因此这是顺序交接,不是并发更新算法。共享内存本身不会使两个同时自增安全。

运行一个完整示例 · 02

消耗与归还信号量许可

使用立即 unlink 的命名 POSIX 信号量,兼容 macOS。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <semaphore.h>
#include <errno.h>

int main(void) {
    char name[64];
    int length = snprintf(name, sizeof name, "/glv3-%ld", (long)getpid());
    if (length < 0 || (size_t)length >= sizeof name)
        return 1;
    sem_t *permits = sem_open(name, O_CREAT | O_EXCL, 0600, 2);
    if (permits == SEM_FAILED)
        return 1;
    if (sem_unlink(name) == -1) {
        sem_close(permits);
        return 1;
    }
    if (sem_trywait(permits) == -1 || sem_trywait(permits) == -1) {
        sem_close(permits);
        return 1;
    }
    puts("two permits acquired");
    errno = 0;
    if (sem_trywait(permits) != -1 || errno != EAGAIN) {
        sem_close(permits);
        return 1;
    }
    puts("third request has no permit");
    if (sem_post(permits) == -1 || sem_trywait(permits) == -1) {
        sem_close(permits);
        return 1;
    }
    puts("returned permit acquired again");
    return sem_close(permits) == 0 ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread shared-memory-semaphores-c-semaphore-permit-accounting.c -lm -o demo
./demo

预期输出

two permits acquired
third request has no permit
returned permit acquired again

按执行顺序理解

  1. 创建两个许可,并立即移除临时名字。

  2. 非阻塞消耗许可,并检查为空情况。

  3. 归还一个许可,再次获取并关闭对象。

逐步读懂代码

信号量初始有两个许可。两次非阻塞等待消耗许可,第三次报告 EAGAIN。post 恢复一个许可,下一次等待再次消耗。程序不把近似 sem_getvalue 观察当同步决策。

基于进程的名字以排他方式创建,并立即从名字空间移除。已打开对象直到 close 前仍可用。本例在单进程演示真实许可计数;生产者消费者设计还需共享数据、顺序与关闭协议。

Leon 的进一步思考

区分移除名字与当前访问

标记移除 System V 共享内存不会立即使已有附加失效。命名信号量 unlink 移除名字,打开的引用仍可使用对象直到关闭。

许可不会修复应用数据

信号量能计数可用性,但更新中途失败可能留下无效载荷。恢复需要另外设计所有权与一致性协议。

注意这个边界. 共享字节不等于已同步的字节。volatile 访问、等待了一段时间,或相同的虚拟地址,都不能建立安全的生产者—消费者交接。
检查自己的理解: 为什么把原始指针放进共享内存段,另一个进程可能无法使用?

另一个进程可能把共享段映射到不同地址,而且指针所指对象也可能根本不共享。应保存相对映射起点的偏移量,或另一种明确可用的引用。

↑ 返回知识框架
COMP2017 / 17Leon

创建线程、等待结束与取消

核心基础

先建立一个简单模型

线程共享内存,但每个线程都需要有效参数和结束规则。

把它用在工程决策里

为每个工作线程准备稳定的参数记录。读取结果或让参数存储失效之前,要先 join。

为什么不能把同一个不断变化的循环下标地址传给所有线程?

工作线程可能读到之后的不同值,未同步的并发访问还可能形成竞争。应各自提供稳定记录。

01 / 从一个故事开始

餐厅增添几位厨师。他们共用食材库,却各自保留当前任务和草稿。线程也共享进程地址空间和描述符,同时拥有自己的执行状态和栈。经理必须让指令卡一直保留到厨师读完,也不能在厨师还要使用时清空食材库。

join 是完成工作的交接。detach 允许系统在线程结束时自动回收线程管理信息,不代表工作已经完成。取消则是在协议之下提出停止请求。它不能代替对未完成工作和已占用资源的清理设计。

02 / 理解原理

POSIX 线程从 void *worker(void *arg) 这样的函数开始。pthread_create 直接返回错误编号,因此后续只能 join 成功创建的线程。参数对象在被使用期间必须一直存活,也不能在没有同步时被并发修改。每个可等待线程都要按照归属方案,恰好 join 一次,或者 detach。

从线程入口函数返回,只结束该线程。从 main 返回或调用 exit,则结束进程及其中的线程。不能返回指向工作线程已结束生命周期的自动对象的指针。线程属性可以控制分离状态、栈大小等,但受平台限制。pthread_cancel 提出取消请求;延迟取消在取消点发生,清理处理函数必须维持资源不变式。通过受保护的标志和唤醒协议协作关闭,往往更容易推理。

先认识这些词

线程启动函数
pthread_create 使用的 void *worker(void *) 形式函数。
汇合等待
等待可汇合线程结束并收集其结果的操作。
所有权转移
明确改变最终负责释放对象的代码。

03 / 逐项读懂公式

记忆法:初始化参数 → 创建线程 → 工作线程使用参数 → join → 回收共享资源
线程入口类型:void *(*)(void *)
可等待线程的生命周期:运行中 → 已终止 → 已等待并回收

这个顺序规定必要的生命周期依赖,不保证创建之后立即出现某种固定调度顺序。新工作线程可能在创建者继续运行前就开始执行。void 指针参数携带一个地址;实际对象类型和归属由程序规定。

join 等待终止,并建立相关的完成同步关系。已分离线程不能再 join;它的管理信息在终止时被回收,但应用数据仍需另外安排安全的生命周期。

04 / 先看一个完整例子

协调者准备两个参数记录,分别保存不会再修改的区间 [0,50) 和 [50,100),然后启动两个工作线程。两个记录放在协调者的数组里,一直保留到两次 join 都完成。工作线程分别求和,把结果写入各自的结果字段。join 成功后,协调者才读取并合并结果。

如果改为传入同一个循环变量的地址,工作线程可能与循环发生竞争,甚至都读到循环的最终值。如果第二次创建失败,就只有第一个线程存在。协调者应等待第一个线程结束,按明确的备用方案处理失败任务,不能拿未初始化的线程标识符去 join。

一步步想清楚

  1. pthread_create 返回前工作线程可能开始吗?

    可能。创建前初始化参数,并保留到全部使用结束。

  2. pthread 错误如何报告?

    多数 pthread 操作直接返回错误号,不采用负一加 errno 的方式。

  3. 谁释放工作线程返回的堆结果?

    由程序所有权契约决定。本例成功 join 后把该责任交给等待者。

运行一个完整示例 · 01

为每个工作线程保留参数记录

划分小范围,汇合后才读取结果。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

struct Job {
    int begin;
    int end;
    int sum;
};
static void *worker(void *argument) {
    struct Job *job = argument;
    int total = 0;
    for (int i = job->begin; i < job->end; i++)
        total += i;
    job->sum = total;
    return NULL;
}
int main(void) {
    struct Job jobs[2] = {{0, 5, 0}, {5, 10, 0}};
    pthread_t threads[2];
    size_t created = 0;
    for (; created < 2; created++) {
        int error = pthread_create(&threads[created], NULL, worker, &jobs[created]);
        if (error != 0)
            break;
    }
    for (size_t i = 0; i < created; i++)
        check_thread(pthread_join(threads[i], NULL));
    if (created != 2)
        return 1;
    printf("first=%d second=%d total=%d\n", jobs[0].sum, jobs[1].sum,
           jobs[0].sum + jobs[1].sum);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread thread-lifecycle-c-stable-thread-arguments.c -lm -o demo
./demo

预期输出

first=10 second=35 total=45

读懂这里的语法

void *worker(void *argument)
匹配 POSIX 启动函数类型,把通用指针转回约定参数结构体。
pthread_create(&threads[i], NULL, worker, &jobs[i])
使用默认属性及一个稳定参数记录启动可汇合工作线程。
pthread_join(threads[i], NULL)
读取工作线程结果或结束参数生命周期前,先等待完成。

按执行顺序理解

  1. 启动任何线程前初始化两个任务记录。

  2. 记录成功创建,只等待这些线程。

  3. 完成后组合互不重叠的结果。

跟踪状态的变化
任务范围结果
0[0,5)10
1[5,10)35

逐步读懂代码

两个记录一直存在到成功汇合结束。每个工作线程读取自己的不变范围,只写自己的结果字段,因此这些分离对象不需互斥锁。

创建循环记录成功线程数量。若创建失败,程序只等待已存在的线程再报告失败。正常输出只在完成同步后使用结果。

运行一个完整示例 · 02

通过 join 转移堆结果所有权

工作线程消费一个堆参数,并返回另一个有所有权的结果。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static void *worker(void *argument) {
    int input = *(int *)argument;
    free(argument);
    int *result = malloc(sizeof *result);
    if (result == NULL)
        return NULL;
    *result = input * input;
    return result;
}
int main(void) {
    int *argument = malloc(sizeof *argument);
    if (argument == NULL)
        return 1;
    *argument = 7;
    pthread_t thread;
    int error = pthread_create(&thread, NULL, worker, argument);
    if (error != 0) {
        free(argument);
        return 1;
    }
    void *raw_result = NULL;
    check_thread(pthread_join(thread, &raw_result));
    if (raw_result == NULL)
        return 1;
    int *result = raw_result;
    printf("result=%d\n", *result);
    free(result);
    puts("result released by joiner");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread thread-lifecycle-c-thread-result-ownership.c -lm -o demo
./demo

预期输出

result=49
result released by joiner

按执行顺序理解

  1. 定义创建成功前后的所有权。

  2. 返回生命周期超过工作线程局部作用域的分配。

  3. 汇合后接收并释放结果。

逐步读懂代码

创建成功前 main 拥有参数分配,创建失败就释放。成功后,工作线程复制其值,并恰好释放该参数一次。

结果是独立堆分配,所以工作线程返回后仍有效。join 把指针交给 main,由它读取和释放。若返回自动局部对象地址,就没有这样的生命周期。固定输入七保证乘法可表示。

Leon 的进一步思考

创建失败不会产生等待目标

记录成功创建数量,只等待这些标识,并在处理失败任务时保留其参数对象。

所拥有状态优先协作关闭

取消可在取消点打断资源协议。受保护停止条件与唤醒路径,通常更容易说明及测试清理。

注意这个边界. detach 不会让栈上参数在所属函数返回后继续有效。线程管理信息的回收,与应用对象的生命周期是两件事。
检查自己的理解: 工作线程可以安全地返回局部 int 的地址,作为结果吗?

不能。线程函数返回时,这个对象的生命周期已经结束。可以使用调用者提供且一直存活到 join 完成的存储空间,也可以使用堆分配,并明确转移归属。

↑ 返回知识框架
COMP2017 / 18Leon

数据竞争、互斥锁与原子性

核心基础

先建立一个简单模型

共享不变量常常跨越多次读写。

把它用在工程决策里

用同一把互斥锁保护整个检查并更新事务。只锁最后的写入,不能让之前的判断具有原子性。

只剩一个物品时,哪些操作必须放在一起?

检查是否可用、决定成功与否、减少库存,属于同一个受保护事务。

01 / 从一个故事开始

两位店员各自读到库存是十件,卖出一件后,都写回九件。各人的算术没错,但合在一起却丢失了一笔销售。互斥锁让一位店员独占整个“读取—修改—写回”操作。

不过,在 C 中,未受保护的冲突访问比这个简图更严重:语言规则允许的编译器变换,可能让整个程序进入未定义行为。丢失更新的故事说明为什么需要协调,却没有列完所有可能结果。正确性必须来自同步关系,不能依靠某次演示碰巧有利的时序。

02 / 理解原理

C 数据竞争涉及不同线程中的冲突访问,其中至少一个不是原子访问,而且所需的 happens-before 关系没有规定两者先后。它会导致未定义行为。如果所有相关访问都遵循同一加锁规则,互斥锁可以提供互斥和内存同步。应保护完整的不变式,包括检查和更新,而不是只锁住零散的赋值。临界区应尽量小,但不能把逻辑上不可分割的操作拆开。

互斥锁要先初始化再使用,只有确认没有线程还会使用或等待它时才能销毁。C 原子对象支持规定的不可分割操作,但多个原子步骤仍可能产生更高层的竞争条件。volatile 既不能把自增变成原子操作,也不能在线程之间安全发布普通数据。使用原子操作时,仍要明确选择内存顺序规则。

先认识这些词

数据竞争
跨线程未被所需顺序约束的冲突访问,至少一次不是原子访问。
临界区
持有保护不变式的锁时执行的代码区域。
原子事务
其他参与者不能观察到中途状态的完整逻辑操作。

03 / 逐项读懂公式

受保护的不变式示例:stock ≥ 0
事务:加锁 → 若 stock>0 则 stock−− → 解锁
原子计数器更新:atomic_fetch_add(&count,1)
C 中的数据竞争 ⇒ 未定义行为

stock 是由一把约定好的互斥锁保护的共享状态。条件判断与递减必须在同一个临界区完成,防止两个线程都花掉最后一件库存。atomic_fetch_add 表示对正确声明的原子对象执行原子读—改—写,并不能替代所有跨多个变量的不变式保护。

happens-before 是一种同步顺序关系,不等同于按钟表时间观察到“似乎先执行了”。数据竞争导致未定义行为是语言规则,因此仅把机器指令逐条交错,也不足以证明程序正确。

04 / 先看一个完整例子

库存只剩一件,两个工作线程各尝试卖出一件。使用互斥锁时,第一个线程看到 stock=1,记下一次销售并把 stock 改为 0。第二个线程看到零,拒绝销售。恰好一次成功。

再看另一个版本:stock 是 C 原子整数,但各线程在锁外检查,只在递减时加锁。两个线程可能都先通过检查,之后两次原子递减让结果成为 −1。这里没有 C 数据竞争,却仍然破坏了不变式。如果 stock 还是普通非原子存储,那么锁外检查还会允许数据竞争,不能保证任何特定结果。

一步步想清楚

  1. 丢失更新跟踪是竞争的唯一可能结果吗?

    不是。跟踪只是解释模型,真实 C 数据竞争具有未定义行为。

  2. 为什么检查与更新一起加锁?

    另一参与者可能在分开的临界区之间使条件失效。

  3. 给写入者加锁能保护未加锁读取者吗?

    不能。全部冲突访问都必须遵循适当共同同步规则。

运行一个完整示例 · 01

不制造 C 数据竞争地模拟丢失更新

把选定交错写成明确顺序步骤。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int shared = 10;
    int read_a = shared;
    int read_b = shared;
    shared = read_a - 1;
    printf("after A writes=%d\n", shared);
    shared = read_b - 1;
    printf("after B writes=%d\n", shared);
    printf("two completed decrements should give=%d\n", 10 - 2);
    puts("sequential model, not an executed data race");
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread data-races-mutexes-c-lost-update-model.c -lm -o demo
./demo

预期输出

after A writes=9
after B writes=9
two completed decrements should give=8
sequential model, not an executed data race

读懂这里的语法

int read_a = shared
把模拟观察保存在普通顺序变量中,本程序不创建竞争线程。
shared = read_a - 1
模拟基于先前保存观察值的写入。
puts(...)
打印模型结果说明,不表示未定义竞争行为保证。

按执行顺序理解

  1. 应用更新前保存两份观察。

  2. 写入基于过时局部观察的结果。

  3. 与独立顺序契约比较。

跟踪状态的变化
步骤共享值
初始读取10
A 写入9
B 写入9

逐步读懂代码

两个模拟参与者都在写入前保存十,各自从保存值算出九。第二次写把九替换成九,因此丢失一次逻辑递减。

实际语句都在单线程执行,行为有定义。若两个线程这样访问真实未保护 int,会产生 C 数据竞争,其行为不限于这个示意。

运行一个完整示例 · 02

保护最后一项的完整事务

两个工作线程在同一互斥锁下竞争一个物品。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int stock = 1;
struct Attempt {
    int success;
};
static void *sell(void *argument) {
    struct Attempt *attempt = argument;
    check_thread(pthread_mutex_lock(&lock));
    if (stock > 0) {
        stock--;
        attempt->success = 1;
    }
    check_thread(pthread_mutex_unlock(&lock));
    return NULL;
}
int main(void) {
    struct Attempt attempts[2] = {{0}, {0}};
    pthread_t threads[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_create(&threads[i], NULL, sell, &attempts[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(threads[i], NULL));
    printf("successful requests=%d\n", attempts[0].success + attempts[1].success);
    printf("remaining stock=%d\n", stock);
    check_thread(pthread_mutex_destroy(&lock));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread data-races-mutexes-c-mutex-check-and-update.c -lm -o demo
./demo

预期输出

successful requests=1
remaining stock=0

按执行顺序理解

  1. 给每个工作线程独立结果字段。

  2. 把共享检查与更新放在同一加锁区域。

  3. 读取最终状态及销毁锁前先汇合。

逐步读懂代码

锁同时覆盖可用性检查与递减。一个工作线程看到一并成功,另一个看到零并保留状态。谁获胜不确定,因此输出只报告确定的总数。

每个 success 字段归一个工作线程,汇合后才读取。最终 stock 也在两个线程结束后读取。只有此时没有使用者或等待者,才能安全销毁锁。

Leon 的进一步思考

解释缺陷而不执行未定义行为

第一例在单线程明确模拟保存的读取值,解释丢失更新机制,不声称有竞争的 C 程序结果可预测。

原子字段不自动保护关系

两个各自原子的计数器分别更新,仍可能违反共同总量不变式。同步边界应匹配完整规则,不只是单次赋值。

注意这个边界. 只给写者加锁,不能保护同时读取的非原子读者。每一次冲突访问都要遵循适当的同步规则。
检查自己的理解: 如果 count 是原子的,if(count>0) count-- 会自动成为一个原子事务吗?

不会。读取和递减是两个独立原子操作,中间可以插入其他线程的操作。对于整个“检查再更新”,应使用互斥锁,或正确设计的比较交换循环。

↑ 返回知识框架
COMP2017 / 19Leon

线程安全接口与读写锁

核心基础

先建立一个简单模型

返回指针可能在解锁后继续暴露受保护状态。

把它用在工程决策里

调用者需要稳定数据时,优先使用清楚的所有权约定或独立快照。单个操作线程安全,不代表任意组合都安全。

为什么返回配置副本,而不是共享缓冲区的指针?

调用者使用自己的副本,就不会与之后对原对象的写入竞争。但复制过程本身仍需要相应保护。

01 / 从一个故事开始

图书馆可以允许许多读者同时查看目录,但编辑重排条目时需要独占访问。读写锁表达这种约定。不过,接口设计仍然重要。如果馆员交出一个条目指针后就释放锁,另一个编辑可能在读者查看前删除它。一个辅助函数若把查找位置记在共用笔记本里,也可能混淆两个同时到来的读者。

因此,线程安全不只是给函数内部加一把锁。还要考虑对象生命周期、返回数据、隐藏状态,以及多个调用怎样组合。

02 / 理解原理

线程安全意味着接口在允许的并发调用下,仍遵循自己的约定。可变的全局或静态状态,会让简单辅助函数也不安全。应按情况选择调用者拥有的状态、不可变数据、线程局部存储或加锁。strtok 保存内部解析状态;POSIX 的 strtok_r 把状态显式交给调用者,但调用者仍须正确管理彼此独立、可写的输入缓冲区。

读写锁允许多个读者同时进入,写者则独占。公平性和写者饥饿取决于实现及策略。持有读锁不代表可以修改数据。不能假设可移植地把读锁原子升级成写锁;释放后再获取,可能需要重新检查条件。返回引用时要保护其生命周期,或者返回安全副本。单个操作线程安全,不代表多个操作组合后自动成为原子的“检查并行动”。

先认识这些词

可重入性
在所述契约下再次进入操作,不破坏隐藏未完成状态的能力。
读写锁
允许多个读取者或一个独占写入者的锁。
快照
受保护访问结束后仍有明确生命周期的独立副本。

03 / 逐项读懂公式

读写锁不变式:writers ∈ {0,1};writers=1 ⇒ readers=0
接口设计记忆法:受保护状态 + 归属 + 生命周期 + 调用组合约定
读取占多数的工作负载 ≠ 保证读写锁加速

readers 和 writers 统计当前已经被锁策略允许进入的线程,不是正在等待的线程。只有没有写者占用时,不变式才允许多个读者。接口这一行是设计检查清单,不是算术。

归属说明谁能修改或释放对象,生命周期说明对象何时有效,组合约定说明多个调用之间是否可能插入其他操作。读取较多时,读写锁可能有利;但加锁成本、竞争和公平性影响,也可能超过并行读取带来的收益。

04 / 先看一个完整例子

配置对象在读写锁保护下保存字符串 "blue"。三个读者可以同时获取读锁,把包含终止符的五字节内容复制到各自缓冲区。写者等所有读者释放锁后,再独占访问,把配置替换成 "green"。返回的副本之后仍可使用。

如果返回内部字符串的指针后就解锁,写者随后释放或重新分配字符串,就会产生生命周期风险。另外,两个使用 strtok_r 的并发分词器,各自都需要独立的保存位置指针和独立的可写输入缓冲区。仅换掉函数名,并不会自动满足这些要求。

一步步想清楚

  1. 把 strtok 改成 strtok_r 能修复共享缓冲区吗?

    不能。并发解析仍需各自可写缓冲区及保存指针,或进行同步。

  2. 读取者可在读锁下修改数据吗?

    不可以。并发读取者依赖读访问不修改受保护数据的承诺。

  3. 为什么解锁前复制?

    解锁后写入者可能立即替换或释放内部存储。

运行一个完整示例 · 01

给并发解析器独立状态

为 strtok_r 使用分离可写缓冲区与保存指针。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

struct ParseJob {
    char text[16];
    int sum;
};
static void *parse(void *argument) {
    struct ParseJob *job = argument;
    char *save = NULL;
    char *token = strtok_r(job->text, ",", &save);
    while (token != NULL) {
        if (token[0] < '0' || token[0] > '9' || token[1] != '\0')
            exit(EXIT_FAILURE);
        job->sum += token[0] - '0';
        token = strtok_r(NULL, ",", &save);
    }
    return NULL;
}
int main(void) {
    struct ParseJob jobs[2] = {{{'1', ',', '2', ',', '3', '\0'}, 0},
                               {{'4', ',', '5', ',', '6', '\0'}, 0}};
    pthread_t threads[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_create(&threads[i], NULL, parse, &jobs[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(threads[i], NULL));
    printf("first total=%d second total=%d\n", jobs[0].sum, jobs[1].sum);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread thread-safe-design-c-independent-tokenizers.c -lm -o demo
./demo

预期输出

first total=6 second total=15

读懂这里的语法

strtok_r(job->text, ",", &save)
使用调用者拥有的可写缓冲区及明确保存状态开始分词。
strtok_r(NULL, ",", &save)
使用保存位置继续同一次解析。NULL 表示继续,不表示空字符串。
struct ParseJob
把每个工作线程缓冲区与输出放在一起,让所有权可见。

按执行顺序理解

  1. 创建工作线程前分配不同文本数组。

  2. 把分词续接状态保留在各次调用内部。

  3. 两个汇合完成后才读取私有总数。

跟踪状态的变化
工作线程可写输入总数
01,2,36
14,5,615

逐步读懂代码

每个工作线程收到不同结构体,其中有自己的可写文本。save 是该次调用的自动局部变量。strtok_r 可替换缓冲区分隔符,却不会触及另一任务字节。

解析器刻意只接受每个返回词元为一位十进制数字。它不是完整 CSV 解析器,strtok_r 会跳过空字段。空字段有意义的格式需另一策略。

运行一个完整示例 · 02

从受保护配置返回副本

允许读取者在读锁下复制,再在写锁下更新。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER;
static char configuration[16] = "blue";
struct Snapshot {
    char text[16];
};
static void *copy_configuration(void *argument) {
    struct Snapshot *snapshot = argument;
    check_thread(pthread_rwlock_rdlock(&lock));
    strcpy(snapshot->text, configuration);
    check_thread(pthread_rwlock_unlock(&lock));
    return NULL;
}
int main(void) {
    struct Snapshot snapshots[2] = {{{0}}, {{0}}};
    pthread_t readers[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(
            pthread_create(&readers[i], NULL, copy_configuration, &snapshots[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(readers[i], NULL));
    check_thread(pthread_rwlock_wrlock(&lock));
    strcpy(configuration, "green");
    check_thread(pthread_rwlock_unlock(&lock));
    printf("first snapshot=%s second snapshot=%s\n", snapshots[0].text,
           snapshots[1].text);
    check_thread(pthread_rwlock_rdlock(&lock));
    printf("current=%s\n", configuration);
    check_thread(pthread_rwlock_unlock(&lock));
    check_thread(pthread_rwlock_destroy(&lock));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread thread-safe-design-c-reader-writer-snapshots.c -lm -o demo
./demo

预期输出

first snapshot=blue second snapshot=blue
current=green

按执行顺序理解

  1. 持读锁时复制各自快照。

  2. 替换时使用独占写锁。

  3. 比较独立快照与新的受保护值。

逐步读懂代码

两个读取者可同时持有读锁。它们把已知有界字符串复制到不同十六字节数组,解锁后快照仍有效。

协调者先汇合读取者再写入,使显示序列确定。现有快照保持 blue,受保护当前值变为 green。真实服务可交错阶段,但仍必须保护全部访问并定义快照时机。

Leon 的进一步思考

返回指针会扩展契约

函数即使正确加锁,仍可能返回悬空视图。应说明调用者得到副本、借用生命周期还是保留所有权。

读者优先不是通用保证

读写锁可能比互斥锁更贵,公平性也可能依实现而定。不能因读取者多就假定更快,应测量真实任务。

注意这个边界. 可重入性、线程安全和异步信号安全是不同性质。内部有互斥锁的函数可能线程安全,却不能安全地用于信号处理函数。
检查自己的理解: 为什么释放读锁、再取得写锁之后,还必须重新检查状态?

其他写者可能在间隙中改变了状态。之前的观察已经不足以证明当前更新仍然应该执行。

↑ 返回知识框架
COMP2017 / 20Leon

条件变量与生产者—消费者等待

核心基础

先建立一个简单模型

条件变量帮助等待,受保护的条件表达式才决定能否继续。

把它用在工程决策里

先学单线程队列,再保护状态、用 while 循环等待,并加入关闭状态,让空队列上的消费者能够结束。

醒来后为什么还要再次检查 count?

唤醒不保证某个任务属于你。虚假唤醒或其他消费者都可能使条件仍为假。

01 / 从一个故事开始

顾客在面包店等面包。铃声只说明情况可能改变,并没有替这位顾客预留一条面包。另一个顾客可能先拿走最后一条,也可能铃响了却没有面包。正确做法是再次检查货架,并在能够安全检查和取走面包的保护下行动。

条件变量就是通知机制,共享条件才是货架状态。只有协议保护了条件,并关闭“检查以后、进入等待以前”的间隙,通知才真正有用。

02 / 理解原理

条件变量让线程休眠,直到受保护的条件可能变成真。先获取对应互斥锁,在 while 循环里检查条件;条件不满足时调用 pthread_cond_wait。等待会原子地释放锁并阻塞,返回之前会重新获取锁。必须重新检查,因为可能发生虚假唤醒,也可能另一个线程先取走资源。

signal 至少唤醒一个符合条件的等待者,broadcast 唤醒当前所有等待者;两者都不会替未来的等待者储存许可。共享状态要在同一把锁下修改,并使用一致的通知规则。有界队列通常分别需要“非空”和“未满”条件。关闭过程会增加另一个条件,通常要广播,让睡眠中的工作线程能够退出。所有使用者和等待者结束后,才能销毁同步对象。

先认识这些词

等待条件
决定是否允许进展的持久共享事实。
伪唤醒
等待返回,却不证明条件为真的情况。
广播唤醒
让全部当前等待者有机会恢复并重新检查的通知。

03 / 逐项读懂公式

消费者可继续的条件:queue_size > 0 或 shutdown
while (queue_size == 0 && !shutdown) wait(condition,mutex)
唤醒后:持有互斥锁,重新检查条件
队列不变式:0 ≤ queue_size ≤ capacity

queue_size 是共享的占用数量,shutdown 是类似布尔值的共享状态,两者都由同一把锁保护。while 条件描述当前无法继续工作的情况。唤醒本身不能证明有数据;互斥锁保护重新检查和随后的取出操作。

这里 capacity 固定。生产者也要在队列已满时按相反条件等待。伪调用 wait 表示 pthread_cond_wait 的原子解锁与等待,以及返回前重新加锁,不是普通的分开解锁再睡眠。

04 / 先看一个完整例子

两个消费者都发现队列为空,于是各自在循环里等待并释放互斥锁。生产者获取锁,放入一个项目,把 size 从 0 改成 1,发送通知后解锁。假设两个消费者最终都醒来,原因可能是广播,也可能是虚假唤醒。

第一个重新获得锁的消费者取走项目,使 size 回到 0。第二个重新获得锁后检查条件,再次等待。若把 while 改为 if,第二个就可能在空队列上继续取出。关闭时,在锁下设置标志并广播。醒来的消费者就能分清“已经关闭的空队列”和“以后还可能收到工作的空队列”。

一步步想清楚

  1. 为什么用 while 而不是 if?

    唤醒不预留数据,重新获得锁时条件仍可能为假。

  2. 已完成工作的记忆存在哪里?

    在受保护条件与载荷中,不在条件变量信号中。

  3. 关闭时为什么广播?

    多个睡眠者可能都需观察持久关闭标志,并停止等待未来工作。

运行一个完整示例 · 01

在线程之前:先理解队列状态

先在单线程里跟踪容量为二的队列。之后再用同步保护同一组状态。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#include <stdio.h>
#include <stdlib.h>
enum { CAPACITY = 2 };
struct Queue {
    int data[CAPACITY];
    size_t head, tail, count;
};
static int push(struct Queue *q, int value) {
    if (q->count == CAPACITY)
        return 0;
    q->data[q->tail] = value;
    q->tail = (q->tail + 1) % CAPACITY;
    q->count++;
    return 1;
}
static int pop(struct Queue *q, int *value) {
    if (q->count == 0)
        return 0;
    *value = q->data[q->head];
    q->head = (q->head + 1) % CAPACITY;
    q->count--;
    return 1;
}
static void show(const struct Queue *q) {
    printf("head=%zu tail=%zu count=%zu\n", q->head, q->tail, q->count);
}
int main(void) {
    struct Queue q = {{0}, 0, 0, 0};
    int value = -1;
    printf("push 10=%d\n", push(&q, 10));
    printf("push 20=%d\n", push(&q, 20));
    show(&q);
    printf("push full=%d\n", push(&q, 30));
    if (pop(&q, &value))
        printf("pop=%d\n", value);
    printf("push 30=%d\n", push(&q, 30));
    show(&q);
    while (pop(&q, &value))
        printf("pop=%d\n", value);
    show(&q);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread condition-variables-c-warmup-ring-state.c -lm -o demo
./demo

预期输出

push 10=1
push 20=1
head=0 tail=0 count=2
push full=0
pop=10
push 30=1
head=1 tail=1 count=2
pop=20
pop=30
head=1 tail=1 count=0

读懂这里的语法

(tail + 1) % CAPACITY
移动到下一位置,到固定容量后绕回。
count == CAPACITY
表示已满;写入新元素之前检查。
count == 0
表示为空;读取元素之前检查。

按执行顺序理解

  1. 装满两个位置,观察 head 与 tail 相等但 count 为二。

  2. 拒绝第三次入队,取出十,再用空出的位置存三十。

  3. 按先进先出顺序排空,观察 head 与 tail 相等而 count 为零。

跟踪状态的变化
步骤状态
满head=0, tail=0, count=2
取出后补入head=1, tail=1, count=2
排空head=1, tail=1, count=0

逐步读懂代码

想象两个可重复使用的托盘。head 指向下一次取出的托盘,tail 指向下一次放入的位置。取模运算让下标绕回零。队列满和空时,head 都可能等于 tail,所以需要 count 区分。不变量是 0 ≤ count ≤ CAPACITY。

拒绝的 push 或 pop 不改变任何状态。成功操作把数据、对应下标和计数作为一个逻辑动作修改。这个版本有意保持单线程。在并发工程里,互斥锁保护整个动作;条件变量让工作线程等待 count > 0,让生产者等待 count < CAPACITY。还需要持久的 closed 标志,表示不会再有新任务,使等待能够结束。

运行一个完整示例 · 02

通过受保护条件发布一个值

允许任意调度先后,而不漏掉已完成工作。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t changed = PTHREAD_COND_INITIALIZER;
static int ready = 0, payload = 0, consumed = 0;
static void *consume(void *argument) {
    (void)argument;
    check_thread(pthread_mutex_lock(&lock));
    while (!ready)
        check_thread(pthread_cond_wait(&changed, &lock));
    consumed = payload;
    check_thread(pthread_mutex_unlock(&lock));
    return NULL;
}
int main(void) {
    pthread_t thread;
    check_thread(pthread_create(&thread, NULL, consume, NULL));
    check_thread(pthread_mutex_lock(&lock));
    payload = 42;
    ready = 1;
    check_thread(pthread_cond_signal(&changed));
    check_thread(pthread_mutex_unlock(&lock));
    check_thread(pthread_join(thread, NULL));
    printf("consumed=%d\n", consumed);
    check_thread(pthread_cond_destroy(&changed));
    check_thread(pthread_mutex_destroy(&lock));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread condition-variables-c-condition-predicate-handoff.c -lm -o demo
./demo

预期输出

consumed=42

读懂这里的语法

while (!ready)
等待每次返回时都重复检查受保护条件。
pthread_cond_wait(&changed, &lock)
原子地释放互斥锁并等待,返回时已重新获得锁。
pthread_cond_signal(&changed)
通知当前等待者状态可能改变,它不是被保存的许可。

按执行顺序理解

  1. 测试持久状态前先获得互斥锁。

  2. 一起发布载荷与 ready 条件。

  3. 使用最终结果及销毁同步对象前先汇合。

跟踪状态的变化
调度情况正确原因
消费者先wait → signal → recheck
生产者先ready 已为真

逐步读懂代码

生产者持锁写 payload 和 ready。消费者若已等待,信号让它有机会恢复;若更晚开始,它观察到 ready 就跳过等待。

循环容忍没有使条件为真的唤醒。main 只在 join 后读取 consumed。消费者不再可能等待或访问同步对象后,才销毁它们。

运行一个完整示例 · 03

排空有界队列后关闭

使用非空、未满条件及持久关闭标志。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

enum { CAPACITY = 2 };
static int buffer[CAPACITY];
static size_t head = 0, tail = 0, count = 0;
static int closed = 0, received = 0, total = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
static pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
static void *producer(void *argument) {
    (void)argument;
    for (int value = 1; value <= 5; value++) {
        check_thread(pthread_mutex_lock(&lock));
        while (count == CAPACITY)
            check_thread(pthread_cond_wait(&not_full, &lock));
        buffer[tail] = value;
        tail = (tail + 1) % CAPACITY;
        count++;
        check_thread(pthread_cond_signal(&not_empty));
        check_thread(pthread_mutex_unlock(&lock));
    }
    check_thread(pthread_mutex_lock(&lock));
    closed = 1;
    check_thread(pthread_cond_broadcast(&not_empty));
    check_thread(pthread_mutex_unlock(&lock));
    return NULL;
}
static void *consumer(void *argument) {
    (void)argument;
    for (;;) {
        check_thread(pthread_mutex_lock(&lock));
        while (count == 0 && !closed)
            check_thread(pthread_cond_wait(&not_empty, &lock));
        if (count == 0 && closed) {
            check_thread(pthread_mutex_unlock(&lock));
            break;
        }
        int value = buffer[head];
        head = (head + 1) % CAPACITY;
        count--;
        check_thread(pthread_cond_signal(&not_full));
        check_thread(pthread_mutex_unlock(&lock));
        total += value;
        received++;
    }
    return NULL;
}
int main(void) {
    pthread_t producer_thread, consumer_thread;
    check_thread(pthread_create(&consumer_thread, NULL, consumer, NULL));
    check_thread(pthread_create(&producer_thread, NULL, producer, NULL));
    check_thread(pthread_join(producer_thread, NULL));
    check_thread(pthread_join(consumer_thread, NULL));
    printf("consumed=%d sum=%d\n", received, total);
    printf("remaining=%zu closed=%d\n", count, closed);
    check_thread(pthread_cond_destroy(&not_empty));
    check_thread(pthread_cond_destroy(&not_full));
    check_thread(pthread_mutex_destroy(&lock));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread condition-variables-c-bounded-queue-shutdown.c -lm -o demo
./demo

预期输出

consumed=5 sum=15
remaining=0 closed=1

按执行顺序理解

  1. 用同一互斥锁保护环形下标与占用量。

  2. 针对互补的满与空条件循环等待。

  3. 生产结束后关闭,消费者先排空再终止。

逐步读懂代码

队列最多保存两个值。生产者在满时等待;消费者只在空且仍开放时等待。互斥锁保护下标、占用量与关闭标志,条件等待释放锁,让另一参与者推进。

唯一生产者发送五个值后设置关闭并广播。消费者先排空剩余值再退出。只有消费者写 total 与 received,main 汇合后读取。本设计只有一个有限生产者;提前取消或多个生产者需要扩展关闭契约。

Leon 的进一步思考

状态应让提前通知无害

生产者若在消费者等待前完成,消费者会看到条件为真而不睡眠。因此没有共享状态的条件变量协议不完整。

每个等待条件都应考虑关闭

队列为空可能是工作尚未到,也可能是未来不会再有工作。关闭标志区分二者,并必须唤醒阻塞参与者。

注意这个边界. 没有等待者时发送的 signal,不会被保存为一个许可。设计正确的消费者依靠持久的共享条件,才不会遗漏已经完成的工作。
检查自己的理解: 消费者等待时,为什么生产者必须能获得互斥锁?

生产者需要这把锁来改变条件。pthread_cond_wait 开始等待时会原子地释放锁,因此消费者不会挡住那个能让等待条件变真的工作。

↑ 返回知识框架
COMP2017 / 21Leon

死锁、锁顺序与持续前进

核心基础

先建立一个简单模型

如果所有工作线程都永远等待,仅有数值正确还不够。

把它用在工程决策里

列出谁可能等待谁。使用统一的锁顺序或明确的回退规则,并把队列关闭纳入程序能够前进的解释。

相反的加锁顺序为什么可能形成环?

每个线程都可能持有一把锁并等待另一把。全局顺序可消除这一类循环等待。

01 / 从一个故事开始

两位机械师各拿一件工具,都不肯在得到另一件工具前归还手中的工具。于是双方都无法继续。如果规定所有人先拿扳手、再拿电钻,就能消除这种循环等待。

不过,避开僵局不代表工作一定有效率。机械师可能同时反复放下和拿起工具,却始终没有完成工作;也可能某一位不断失去使用机会。这分别类似活锁和饥饿。并发设计既需要“什么事情永远不能出错”的安全性论证,也需要说明等待者最终怎样有机会完成的前进性论证。

02 / 理解原理

经典资源死锁需要互斥、持有并等待、不能强行抢占资源,以及循环等待。要有意打破其中一个条件:避免嵌套锁,按协议一次安排所需资源,或施加统一的全局锁顺序。应使用稳定的数值等级或其他明确有效的顺序,不能对指向互不相关 C 对象的指针随意做大小比较。

trylock 失败后,若协议要求,就必须释放已持有的资源。盲目重试可能活锁或饥饿。除非明确了解相互影响,否则不要在临界区执行阻塞输入输出或回调。条件等待、join 和信号量也会建立依赖,因此分析不能只看两把互斥锁。超时说明未能及时前进,却不能证明死锁。恢复需要重新建立不变式;强行杀掉持有普通锁的线程,不是通用安全方案。

先认识这些词

循环等待
每个参与者等待另一个、最终回到起点的依赖链。
锁等级
用于一致获取多个锁的稳定排序键。
活锁
持续活动却无法完成有用工作的情况。

03 / 逐项读懂公式

全局顺序不变式:只按等级递增的顺序获取锁
等待环:A 等 B,B 等……,……又等 A
前进性区别:死锁 = 被阻塞的循环;活锁 = 不断活动却不完成

等级是程序给可加锁资源定义的唯一顺序键。递增获取能防止形成锁等待环,因为沿环走就要求等级不断增加,最后却又回到起始等级,这是不可能的。等待边表示一个参与者必须等另一个释放或产生某样东西,才能继续。

这里的区别是诊断记忆法,不是形式化检测算法。饥饿是另一种失败:其他操作继续完成,某个操作却反复得不到前进机会。公平性是额外性质,单靠锁顺序不能保证。

04 / 先看一个完整例子

两个账户对象的锁等级分别为 10 和 20。它们之间的每次转账,无论方向如何,都先获取 10,再获取 20。从第二个账户转到第一个账户时,也必须先获取 10,再获取 20。这样就不会有人持有 20 再等待 10,因此排除了这种循环等待模式。

另一种方案先获取 10,再尝试 trylock 20;若失败,就释放 10,稍后重试。如果一直拿着 10 无休止地重试,可能阻止其他操作完成。拿齐两把所需锁之前,余额应保持不变。

一步步想清楚

  1. 为什么不用 < 排序无关对象指针?

    C 不提供无关对象指针的通用关系顺序,应使用明确稳定等级。

  2. 锁顺序保证公平吗?

    不保证。它消除目标循环等待模式,不消除全部饥饿或调度问题。

  3. 第二次 trylock 失败后,第一个锁怎么办?

    若保留会阻碍进展,协议必须在放弃或重试前释放它。

运行一个完整示例 · 01

两个方向采用同一锁顺序

在两个容器间移动单位,并保留总量。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

struct Resource {
    int rank;
    int units;
    pthread_mutex_t lock;
};
struct Move {
    struct Resource *from;
    struct Resource *to;
};
static void *move_units(void *argument) {
    struct Move *job = argument;
    struct Resource *first = job->from->rank < job->to->rank ? job->from : job->to;
    struct Resource *second = first == job->from ? job->to : job->from;
    for (int i = 0; i < 50; i++) {
        check_thread(pthread_mutex_lock(&first->lock));
        check_thread(pthread_mutex_lock(&second->lock));
        if (job->from->units > 0) {
            job->from->units--;
            job->to->units++;
        }
        check_thread(pthread_mutex_unlock(&second->lock));
        check_thread(pthread_mutex_unlock(&first->lock));
    }
    return NULL;
}
int main(void) {
    struct Resource left = {10, 100, PTHREAD_MUTEX_INITIALIZER};
    struct Resource right = {20, 100, PTHREAD_MUTEX_INITIALIZER};
    struct Move jobs[2] = {{&left, &right}, {&right, &left}};
    pthread_t threads[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_create(&threads[i], NULL, move_units, &jobs[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(threads[i], NULL));
    printf("left=%d right=%d total=%d\n", left.units, right.units,
           left.units + right.units);
    check_thread(pthread_mutex_destroy(&left.lock));
    check_thread(pthread_mutex_destroy(&right.lock));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread deadlock-progress-c-global-lock-order.c -lm -o demo
./demo

预期输出

left=100 right=100 total=200

读懂这里的语法

resource->rank
使用程序定义数字等级,不依赖内存地址。
pthread_mutex_lock(&first->lock)
先获取较低等级锁,再获取较高等级锁。
pthread_mutex_unlock(&second->lock)
完整共享状态操作后释放锁,本例采用清楚的逆序释放约定。

按执行顺序理解

  1. 工作线程启动前分配唯一等级。

  2. 按等级而非转移方向获取。

  3. 释放任一锁前完成全部更新。

跟踪状态的变化
方向获取顺序
left → right10 → 20
right → left10 → 20

逐步读懂代码

每个任务指定来源与目的地,但获取顺序只依赖稳定等级十和二十。因此两个方向都先锁同一低等级资源。检查及两次更新发生在同时持有两锁时。

每侧初始一百单位,只移出五十,因此任意调度都能成功。相反转移抵消,总量保持二百。辅助函数假设资源不同且等级不同,同资源转移需单独分支。

运行一个完整示例 · 02

尝试失败后释放部分获取

有意持有第二锁,让工作线程进入非阻塞回退路径。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <errno.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static pthread_mutex_t first = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t second = PTHREAD_MUTEX_INITIALIZER;
static int rolled_back = 0;
static void *attempt(void *argument) {
    (void)argument;
    check_thread(pthread_mutex_lock(&first));
    int result = pthread_mutex_trylock(&second);
    if (result == EBUSY) {
        check_thread(pthread_mutex_unlock(&first));
        rolled_back = 1;
        return NULL;
    }
    check_thread(result);
    check_thread(pthread_mutex_unlock(&second));
    check_thread(pthread_mutex_unlock(&first));
    return NULL;
}
int main(void) {
    check_thread(pthread_mutex_lock(&second));
    pthread_t thread;
    check_thread(pthread_create(&thread, NULL, attempt, NULL));
    check_thread(pthread_join(thread, NULL));
    check_thread(pthread_mutex_unlock(&second));
    check_thread(pthread_mutex_trylock(&first));
    puts("first lock available after rollback");
    check_thread(pthread_mutex_unlock(&first));
    printf("busy path taken=%d\n", rolled_back);
    check_thread(pthread_mutex_destroy(&first));
    check_thread(pthread_mutex_destroy(&second));
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread deadlock-progress-c-trylock-rollback.c -lm -o demo
./demo

预期输出

first lock available after rollback
busy path taken=1

按执行顺序理解

  1. 建立确定的第二锁不可用条件。

  2. 进入非阻塞失败路径并释放第一锁。

  3. 汇合后验证没有残留部分所有权。

逐步读懂代码

协调者创建线程前持有 second。工作线程获得 first,但用 trylock 尝试 second,因此返回 EBUSY 而不等待。它返回前释放 first。

此处持有 second 时 join 之所以安全,只因工作线程不会阻塞等待 second。随后协调者再用 trylock 证明 first 可用。两锁都可用前没有载荷修改,因此回退保持数据不变。

Leon 的进一步思考

把汇合与输入输出纳入依赖图

即使全部互斥锁获取都遵循等级,线程持锁等待需要该锁的工作线程,也可能死锁。应检查全部等待依赖。

重试前必须回退

部分获取失败应保留数据与所有权原状。重试策略还需进展论证,无限立即重试可能浪费工作或使参与者饥饿。

注意这个边界. 停顿五秒不能证明死锁。程序可能只是较慢、正在等外部输入,或遭遇饥饿。要检查依赖链和线程状态。
检查自己的理解: 统一锁顺序,能防止一个线程持有工作线程需要的锁,同时 join 该工作线程吗?

不能。这个依赖还涉及完成事件,不只是锁获取。在 join 之前释放工作线程需要的资源,或者重新设计协议。

↑ 返回知识框架
COMP2017 / 22Leon

工作量、关键路径与实际加速比

核心基础

先建立一个简单模型

看起来更快的设计,仍需要相同工作量与测量证据。

把它用在工程决策里

先检查结果再测量时间。若输入输出、创建、同步与报告属于实际任务,就应计入耗时。

四个线程就意味着四倍加速吗?

不意味着。串行工作、竞争、输入输出和协调成本可能占主导;小任务甚至可能变慢。

01 / 从一个故事开始

团队抄写一批手稿。增加抄写员能加快独立页面的处理,但最后只有一位总编辑,仍可能限制完成时间。很短的页面,抄写时间甚至可能少于分发和收回的时间。并行编程也有这些限制:必须有足够多的独立工作,而串行阶段、协调和负载不均仍然存在。

好的计时实验测量用户等待整个任务完成的时间,不只是把所有工作者消耗的处理器时间相加。还要确保比较的是相同工作。悄悄跳过部分工作后跑得更快,不是同一计算的加速。

02 / 理解原理

并发表示任务在时间上交叠,并行表示同时执行。工作量统计整个计算的操作总数,关键路径长度则是最长的依赖链。在理想的 p 个处理器上,执行时间既不能小于工作量除以 p,也不能小于关键路径长度。Amdahl 定律假设工作规模固定,串行比例为 s,其余部分能够理想并行。真实开销通常会进一步降低加速比。

任务创建、同步、内存带宽、缓存效应和负载不均都很重要。与其为每个微小的递归调用创建线程,更适合使用有上限的工作线程池,并设置任务大小阈值。解释计时前,先核对输出和输入工作量。端到端加速应使用单调时钟测得的实际经过时间。进程 CPU 时间可能把多个线程的耗时累加,回答的是另一个问题。重复测量,并说明工作负载和处理器假设。

先认识这些词

加速比
同等工作下串行经过时间除以并行经过时间。
跨度
计算中最长依赖链。
额外开销
由协调、调度或数据移动引入,而非原有有用任务的工作。

03 / 逐项读懂公式

Speedup(p) = T₁/Tp;效率 = Speedup(p)/p
Amdahl 理想模型:Speedup(p) = 1/(s+(1−s)/p)
理想工作量—关键路径模型下:Tp ≥ max(W/p, D)

T₁ 是串行基线的实际经过时间,Tp 是同一任务在 p 个处理单元上的实际经过时间。s 描述简单固定规模模型中本来就必须串行的工作比例,不是并行化后观察到的额外开销。W 是总工作量,D 是关键路径长度,两者采用兼容的时间单位。

这些是理想化界限。通信和机器限制还会增加成本。在给定基线下,效率接近一表示接近线性加速,并不能证明算法本身最优。

04 / 先看一个完整例子

某工作负载串行需要 10 秒,其中 20% 必须串行,80% 可以完美分割。四个处理器上的理想时间是 2+8/4=4 秒,加速比为 10/4=2.5,效率为 2.5/4=0.625。再加 0.5 秒协调开销,就需要 4.5 秒,加速比约为 2.22。即使有无穷多个处理器,仍要保留两秒串行部分,因此理想加速比最多为 5。

如果并行测量显示消耗六 CPU 秒,而实际经过三秒,用于用户可感知完成加速比的是三秒。CPU 数字仍有助于说明资源消耗,但它是另一种测量。

一步步想清楚

  1. Amdahl 数字是实测基准结果吗?

    不是。它是在指定串行比例与理想并行假设下的模型预测。

  2. 为什么计时前验证输出?

    跳过工作的程序可能显得很快,却在解决另一问题。

  3. CPU 时间能代替经过时间计算加速吗?

    通常不能。累计 CPU 消耗与端到端完成时间回答不同问题。

运行一个完整示例 · 01

计算理想界与明确额外开销

计算模型预测,不把它伪装成硬件测量。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    double serial_fraction = 0.2;
    int processors[] = {1, 2, 4, 8};
    for (size_t i = 0; i < sizeof processors / sizeof processors[0]; i++) {
        int p = processors[i];
        double speedup = 1.0 / (serial_fraction + (1.0 - serial_fraction) / p);
        printf("p=%d ideal speedup=%.3f efficiency=%.3f\n", p, speedup, speedup / p);
    }
    double serial_seconds = 10.0;
    double ideal_seconds =
        serial_seconds * (serial_fraction + (1.0 - serial_fraction) / 4.0);
    double with_overhead = ideal_seconds + 0.5;
    printf("four-worker model seconds=%.1f with overhead=%.1f\n", ideal_seconds,
           with_overhead);
    printf("overhead model speedup=%.3f limit=%.1f\n", serial_seconds / with_overhead,
           1.0 / serial_fraction);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread parallel-performance-c-amdahl-model.c -lm -o demo
./demo

预期输出

p=1 ideal speedup=1.000 efficiency=1.000
p=2 ideal speedup=1.667 efficiency=0.833
p=4 ideal speedup=2.500 efficiency=0.625
p=8 ideal speedup=3.333 efficiency=0.417
four-worker model seconds=4.0 with overhead=4.5
overhead model speedup=2.222 limit=5.0

读懂这里的语法

double serial_fraction = 0.2
用浮点模型参数表示假定串行比例。
1.0 / (s + (1.0 - s) / p)
计算固定工作量理想加速比,浮点常量避免整数除法。
printf("%.3f", value)
显示三位小数,舍入只是呈现,不是额外实测精度。

按执行顺序理解

  1. 计算前明确串行比例与固定工作量。

  2. 区分理想并行时间与额外协调成本。

  3. 把每个打印值理解为模型预测。

跟踪状态的变化
部分p=4 时秒数
串行工作2.0
并行工作2.0
假定开销0.5

逐步读懂代码

固定任务有百分之二十串行部分,只有剩余百分之八十除以 p。效率是模型加速比再除处理器数。

十秒基线下,四个理想工作者预测四秒。加上明确假定的半秒开销后,预测为 4.5 秒。这些是模型算术结果,不是经过时间观察。串行部分使理想加速上限为五。

运行一个完整示例 · 02

基准测试前检查工作等价

串行与四个独立工作线程计算同一总和。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

struct Job {
    const int *values;
    size_t begin;
    size_t end;
    long result;
};
static void *sum_range(void *argument) {
    struct Job *job = argument;
    long total = 0;
    for (size_t i = job->begin; i < job->end; i++)
        total += job->values[i];
    job->result = total;
    return NULL;
}
int main(void) {
    enum { COUNT = 100, WORKERS = 4 };
    int values[COUNT];
    long serial = 0;
    for (size_t i = 0; i < COUNT; i++) {
        values[i] = (int)i + 1;
        serial += values[i];
    }
    struct Job jobs[WORKERS];
    pthread_t threads[WORKERS];
    for (size_t i = 0; i < WORKERS; i++) {
        jobs[i] =
            (struct Job){values, i * COUNT / WORKERS, (i + 1) * COUNT / WORKERS, 0};
        check_thread(pthread_create(&threads[i], NULL, sum_range, &jobs[i]));
    }
    long parallel = 0;
    for (size_t i = 0; i < WORKERS; i++) {
        check_thread(pthread_join(threads[i], NULL));
        parallel += jobs[i].result;
    }
    printf("serial=%ld parallel=%ld\n", serial, parallel);
    printf("same result=%s workers=%d\n", serial == parallel ? "yes" : "no", WORKERS);
    return serial == parallel ? 0 : 1;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread parallel-performance-c-equivalent-parallel-work.c -lm -o demo
./demo

预期输出

serial=5050 parallel=5050
same result=yes workers=4

按执行顺序理解

  1. 建立共享只读输入及串行参考结果。

  2. 给工作记录分配不重叠范围。

  3. 汇合、组合、比较后,才考虑性能结论。

逐步读懂代码

分区边界恰好覆盖零到九十九下标一次。每个工作线程读取同一不变数组,却写独立结果字段。协调者先汇合再归约。

固定输入下两个结果都应为 5050。程序刻意不输出速度声明,这么小的任务可能由创建开销主导。验证结果等价后,才应另用真实工作量和经过时间做基准。

Leon 的进一步思考

测量完整任务并重复

有用基准包含相关创建、协调与清理成本,使用同等输入,并报告重复单调时钟经过时间及机器信息。

小演示不应声称速度优势

下面的线程求和建立结果等价与所有权。任务很小,可能比串行更慢;使用四个工作线程不意味着速度优势。

注意这个边界. 把已经很小的操作并行化,可能反而变慢。认定增加线程有帮助之前,先计算任务分发和同步开销。
检查自己的理解: 为什么把核心数加倍,可能不再改善内存密集任务?

工作线程可能已经占满共享内存带宽,或在争用缓存。此时瓶颈是数据搬运,而不是可用于算术的执行单元数量。

↑ 返回知识框架
COMP2017 / 23Leon

并行模式:划分、等待与合并

核心基础

先建立一个简单模型

先划分输出归属,再考虑加锁。

把它用在工程决策里

独立工作线程可以写各自结果位置,但协调者合并之前必须等待完成。要画出阶段依赖,而不只是工作线程方框。

协调者何时可以安全读取所有结果?

在所需的完成同步之后,例如成功 join,确认所有产生结果的线程都已结束。

01 / 从一个故事开始

盘点团队分别统计不同货架,把各自总数写下来,再统一合并。这种“划分后合并”避免所有人争抢一个共享计数器。生产线则让不同工作者负责不同阶段,可能提高吞吐量,但单件产品走完整条线的时间仍然不短。

前缀总数又是第三种形状:每个货架还需要知道之前所有货架的合计,所以局部结果之外,还需要一次偏移量处理。可见,“让它并行”不是一种固定操作。应先找出独立归属、依赖关系和最终合并规则,再选择工作者与同步方式。

02 / 理解原理

并行分治同时执行独立子问题,等它们完成后再合并。归并排序先排好互不重叠的两半,再归并。即使递归排序并行,串行归并仍可能限制关键路径。map–reduce 模式先算局部输出,再按规定的归约方式合并。浮点加法不满足结合律,所以改变分组可能改变舍入结果。批量查找中,独立查询通常比拆分一次很小的二分查找更适合并行。

矩阵乘法可以按输出行或输出块划分,使每个结果元素只有一个写者。工作队列用于平衡大小不均的任务;流水线用有界缓冲区和反压,让不同阶段交叠运行。前缀扫描先计算各块局部前缀及总和,再计算块总和的前缀偏移量,最后加上偏移量。各阶段之间必须同步,后一个阶段才能读取已完成的前一阶段结果。

先认识这些词

划分
把工作分成边界明确、归属清楚的子问题。
归约
用指定操作与组合顺序合并局部结果。
前缀扫描
计算直到每个输入位置的累计结果。

03 / 逐项读懂公式

矩阵乘积:Cᵢⱼ = Σₖ Aᵢₖ Bₖⱼ
分块扫描结果 = 局部前缀 + 所有先前块的总和
串行归并的并行归并排序:W(n)=Θ(n log n),D(n)=Θ(n)

i、j 标识一个输出矩阵元素,k 遍历两个输入共有的内维度。按行划分时,每个 C 元素分配给一个工作线程,A、B 保持只读。一个块总和是该输入块的元素之和;偏移量是这些块总和的排除当前项的前缀和。

归并排序界限假设递归两半平衡、两半排序并行,而每个节点的归并是顺序线性时间。不同的并行归并算法会改变关键路径,因此必须说明分析的是哪种实现。

04 / 先看一个完整例子

用两个各含三个元素的块,计算 [2,1,3,4,2,5] 的包含当前项的前缀和。第一个工作线程得到局部前缀 [2,3,6],块总和为 6;第二个得到 [4,6,11],块总和为 11。两者完成后,块偏移量为 [0,6]。第一块加零,第二块加六,得到 [2,3,6,10,12,17]。每个阶段只读取已经完成的前一阶段结果。

矩阵例子中,A=[[1,2],[3,4]]、B=[[2,0],[1,5]],得到 C=[[4,10],[10,20]]。给两个工作线程各分配一行输出,它们都可以读取输入,却不会同时写同一个 C 元素。

一步步想清楚

  1. 计算块偏移前为什么汇合?

    偏移依赖已完成的前块总量,读取未完成结果会违反依赖。

  2. 输出行所有权为什么能避免矩阵互斥锁?

    每个输出元素只有一个写入者,输入只读,协调者只在汇合后读取。

  3. 浮点加法满足结合律吗?

    不严格满足。即使没有竞争,改变归约分组也可能改变舍入。

运行一个完整示例 · 01

汇合局部前缀后应用偏移

用两个独立第一阶段工作线程计算包含当前位置的前缀和。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

struct Block {
    const int *input;
    int *output;
    size_t count;
    int total;
};
static void *local_prefix(void *argument) {
    struct Block *job = argument;
    int total = 0;
    for (size_t i = 0; i < job->count; i++) {
        total += job->input[i];
        job->output[i] = total;
    }
    job->total = total;
    return NULL;
}
int main(void) {
    const int input[] = {2, 1, 3, 4, 2, 5};
    int output[6] = {0};
    struct Block blocks[2] = {{input, output, 3, 0}, {input + 3, output + 3, 3, 0}};
    pthread_t threads[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_create(&threads[i], NULL, local_prefix, &blocks[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(threads[i], NULL));
    printf("block totals=%d %d\n", blocks[0].total, blocks[1].total);
    int offset = 0;
    for (size_t b = 0; b < 2; b++) {
        for (size_t i = 0; i < blocks[b].count; i++)
            blocks[b].output[i] += offset;
        offset += blocks[b].total;
    }
    for (size_t i = 0; i < 6; i++)
        printf("%s%d", i ? " " : "", output[i]);
    putchar('\n');
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread parallel-patterns-c-two-block-prefix-scan.c -lm -o demo
./demo

预期输出

block totals=6 11
2 3 6 10 12 17

读懂这里的语法

job->output[i] = total
只写分配给此工作线程的输出块。
pthread_join(...)
读取任一块总量前完成局部前缀阶段。
output[i] += offset
用前面所有块的总和调整已完成局部前缀。

按执行顺序理解

  1. 给每个工作线程分配输入和输出块。

  2. 读取块总量前先汇合。

  3. 把之前块总量累计为不含当前块的偏移。

跟踪状态的变化
块局部前缀偏移最终
02,3,602,3,6
14,6,11610,12,17

逐步读懂代码

各工作线程在不重叠输出块中计算局部累计和。汇合后块总量为六与十一。协调者在刻意顺序执行的第二阶段应用零与六偏移。

最终前缀恰好包含之前全部输入一次。本例展示阶段依赖,不是完整优化扫描实现。固定小整数让全部加法可表示。

运行一个完整示例 · 02

让每个矩阵行只有一个写入者

读取共享输入,写入分离输出行。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
static void check_thread(int code) {
    if (code != 0) {
        fprintf(stderr, "pthread error: %s\n", strerror(code));
        exit(EXIT_FAILURE);
    }
}

static const int left[2][2] = {{1, 2}, {3, 4}};
static const int right[2][2] = {{2, 0}, {1, 5}};
static int result[2][2];
struct Row {
    size_t index;
};
static void *multiply_row(void *argument) {
    const struct Row *job = argument;
    size_t r = job->index;
    for (size_t c = 0; c < 2; c++) {
        int total = 0;
        for (size_t k = 0; k < 2; k++)
            total += left[r][k] * right[k][c];
        result[r][c] = total;
    }
    return NULL;
}
int main(void) {
    struct Row jobs[2] = {{0}, {1}};
    pthread_t threads[2];
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_create(&threads[i], NULL, multiply_row, &jobs[i]));
    for (size_t i = 0; i < 2; i++)
        check_thread(pthread_join(threads[i], NULL));
    for (size_t r = 0; r < 2; r++)
        printf("row %zu: %d %d\n", r, result[r][0], result[r][1]);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread parallel-patterns-c-matrix-row-ownership.c -lm -o demo
./demo

预期输出

row 0: 4 10
row 1: 10 20

按执行顺序理解

  1. 保持两个输入矩阵只读。

  2. 把每个输出行分配给恰好一个工作线程。

  3. 任何完整矩阵使用者读取结果前先汇合。

逐步读懂代码

对每个输出格,k 遍历共同内维。各工作线程只写分配行,因此不会同时写同一标量对象。两个输入矩阵保持 const。

协调者汇合后才显示完整矩阵。这个微型例子展示依赖与所有权,不是高性能矩阵乘法。较大实现还需考虑分块、数值范围与内存移动。

Leon 的进一步思考

先划分所有权再加同步

互不重叠输出区域可在考虑锁之前消除许多冲突,但仍要明确哪些后续阶段依赖全部区域完成。

更快合并会改变跨度论证

并行递归排序配串行合并,与连合并也并行的设计具有不同关键路径。声称复杂度前先说明真实算法。

注意这个边界. 两半还没有都排好就开始归并,会违反依赖关系。排序阶段的写入区域彼此独立,并不代表之后不需要等待完成。
检查自己的理解: 为什么每处理一条记录就递增共享计数器,往往不如各线程先局部计数再归约?

每次共享递增都会增加同步或缓存一致性通信。局部计数让独立工作保持独立,最终归约只需少得多的共享操作。

↑ 返回知识框架
COMP2017 / 24Leon

用可重复的证据调试

核心基础

先建立一个简单模型

当失败能够重现并得到解释时,它就是有用证据。

把它用在工程决策里

缩小案例,定位第一个被破坏的契约,每次修正一个原因,并保留能覆盖失败边界的测试。

修复七字符缓冲区限制后应该测什么?

测试空输入、七字符、八字符和更长输入。除了是否崩溃,还要检查结束符和拒绝后的状态。

01 / 从一个故事开始

机械师听到偶发异响,先记录什么条件能触发它。随意更换零件,可能只是隐藏症状,并没有解释故障。系统调试也需要这样的纪律:缩小输入,找到最早被破坏的不变式,再选择能观察对应层面的工具。

编译器警告可能在运行前发现格式不匹配;内存检查器可以捕捉生命周期错误;调试器显示调用栈;线程状态记录揭示谁在等谁。每种工具都有范围。工具保持安静,只是有关这个范围的证据,不是普遍正确性的证明。

02 / 理解原理

先准备可重复的案例、明确的预期行为,并检查错误返回值。启用合适的编译器警告和调试信息。警告可以揭示类型、格式和控制流程错误,却不能证明正确。调试器支持断点、单步、栈检查和变量查看。地址与未定义行为检查器能在实际运行的路径上发现许多无效访问和操作。线程检查器则使用不同插桩检测数据竞争,平台支持也不同。内存工具能发现泄漏,但仍要审查归属。

进程问题应检查进程状态、描述符归属和等待结果。并发问题应记录依赖,而不是靠 sleep 强行安排顺序。测试空输入、单个元素、分配失败和输入输出中断等边界。保留故障的最小重现案例,修复原因后,重新验证目标不变式。

先认识这些词

最小复现
保留暴露故障所需条件的小案例。
故障注入
有意让选定操作失败,以测试恢复契约。
回归检查
保留的检查,用于发现已理解缺陷重新出现。

03 / 逐项读懂公式

记忆法:重现 → 定位 → 解释 → 修改 → 验证
边界集合:零、一个、最后有效位置、第一个无效位置、资源失败
证据规则:没有观察到失败 ≠ 证明不存在失败

这个顺序是调查工作流程,不是具有固定运行时间的形式算法。“最后有效”和“第一个无效”必须对应明确边界,例如数组长度或缓冲区容量。资源失败包括分配、打开、线程创建,以及 API 可能报告的操作中断。

工具结果受已执行路径、配置和检测能力限制。验证应针对修复的不变式及相关边界,还要避免在测试本身的预期输出推理中依赖未定义行为。

04 / 先看一个完整例子

行处理程序只在输入八个字符时崩溃。缓冲区容量为 8,复制代码先写入全部八个字符,再在下标 8 放终止符。有效下标只有 0 到 7,所以第一次无效写入就是终止符。内存检查器可能指出这次存储,而根本原因是没有预留一个字节。

可以把有效内容限制为七个字符,或为这次输入分配九字节,并明确如何处理更长的行。随后检查空输入、七字符、八字符和更长输入。修复前某次运行碰巧没有崩溃,也不会让越界写入变合法。

一步步想清楚

  1. 测试能期待某个已释放内存值吗?

    不能。访问已失效对象没有可移植期望结果。

  2. 为什么注入失败而不是分配巨大块?

    注入受控且可重复,巨大请求依赖环境,还可能干扰机器。

  3. 被拒绝操作应保留什么?

    保留其失败保证承诺的内容,例如所有权、长度与已有内容。应明确检查这些事实。

运行一个完整示例 · 01

测试最后载荷字节与终止符

输入放不下时保留目的地。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int copy_text(char *destination, size_t capacity, const char *source) {
    size_t length = strlen(source);
    if (length >= capacity)
        return 0;
    memcpy(destination, source, length + 1);
    return 1;
}
int main(void) {
    const char *inputs[] = {"", "1234567", "12345678", "12345678901"};
    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
        char destination[8] = "kept";
        int accepted = copy_text(destination, sizeof destination, inputs[i]);
        printf("length=%zu accepted=%d stored='%s'\n", strlen(inputs[i]), accepted,
               destination);
    }
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread debugging-evidence-c-terminator-boundary-regression.c -lm -o demo
./demo

预期输出

length=0 accepted=1 stored=''
length=7 accepted=1 stored='1234567'
length=8 accepted=0 stored='kept'
length=11 accepted=0 stored='kept'

读懂这里的语法

length >= capacity
载荷无法给零终止符留空间时拒绝。
memcpy(destination, source, length + 1)
检查容量后,复制已验证载荷及终止符。
return 0
在本辅助函数契约中,于写目的地前报告拒绝。

按执行顺序理解

  1. 判断载荷是否放得下前先预留一字节。

  2. 检查空、最后合法及第一个非法长度。

  3. 拒绝后检查目的地内容保持。

跟踪状态的变化
载荷长度所需容量能放入 8?
01是
78是
89否

逐步读懂代码

缓冲区八字节,最大接受载荷为七字节加一个终止符。空文本仍需一字节。检查在复制前执行,拒绝刚越界及更长情况。

每个案例从新的 kept 值开始。被拒绝输入保持它不变,既测试接受结果,也测试失败状态契约。辅助函数假设存储不重叠、来源有效终止;允许重叠时需另一复制原语。

运行一个完整示例 · 02

注入分配失败并验证所有权保留

同一追加路径先用受控失败调整,再用真实 realloc。

C11 / POSIX下载源代码 ↓

长代码行可左右滚动。复制或下载会保留原始代码。

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
struct Buffer {
    int *data;
    size_t length;
    size_t capacity;
};
typedef void *(*Resize)(void *, size_t);
static void *fail_resize(void *old, size_t size) {
    (void)old;
    (void)size;
    return NULL;
}
static int append(struct Buffer *buffer, int value, Resize resize) {
    if (buffer->length == buffer->capacity) {
        if (buffer->capacity > SIZE_MAX / 2)
            return 0;
        size_t capacity = buffer->capacity ? buffer->capacity * 2 : 1;
        if (capacity > SIZE_MAX / sizeof *buffer->data)
            return 0;
        int *next = resize(buffer->data, capacity * sizeof *buffer->data);
        if (next == NULL)
            return 0;
        buffer->data = next;
        buffer->capacity = capacity;
    }
    buffer->data[buffer->length++] = value;
    return 1;
}
int main(void) {
    struct Buffer buffer = {malloc(2 * sizeof(int)), 2, 2};
    if (buffer.data == NULL)
        return 1;
    buffer.data[0] = 4;
    buffer.data[1] = 7;
    int failed = append(&buffer, 9, fail_resize);
    printf("injected accepted=%d length=%zu capacity=%zu values=%d,%d\n", failed,
           buffer.length, buffer.capacity, buffer.data[0], buffer.data[1]);
    int accepted = append(&buffer, 9, realloc);
    if (!accepted) {
        free(buffer.data);
        return 1;
    }
    printf("real accepted=%d length=%zu capacity=%zu values=%d,%d,%d\n", accepted,
           buffer.length, buffer.capacity, buffer.data[0], buffer.data[1],
           buffer.data[2]);
    free(buffer.data);
    return 0;
}
在本地运行
cc -std=c11 -Wall -Wextra -pedantic -pthread debugging-evidence-c-injected-resize-failure.c -lm -o demo
./demo

预期输出

injected accepted=0 length=2 capacity=2 values=4,7
real accepted=1 length=3 capacity=4 values=4,7,9

按执行顺序理解

  1. 通过匹配真实分配器契约的函数引入失败。

  2. 追加被拒绝后检查与所有权相关状态。

  3. 运行成功路径,并释放最终分配。

逐步读懂代码

注入函数遵循非零 realloc 失败规则:返回 NULL,保留旧分配。append 在确认调整成功前,不改变所有者、长度与容量。

首次尝试因此仍保留容量二中的两个值。真实分配器随后扩容到四并追加九。大小检查在乘法前执行,最终所有者释放一次。辅助函数假设初始长度不大于容量,且缓冲区拥有所述分配。

Leon 的进一步思考

工具检查被执行路径

编译警告和检查器是有用证据,但干净运行不能证明未执行路径安全。应结合所有权与边界推理。

测试替换点应保留真实契约

下面注入的调整失败返回 NULL,不释放原非零分配,匹配 realloc 失败契约。若伪失败先释放,就在测试另一 API。

注意这个边界. 不要编写测试,期待有符号溢出、数据竞争或已释放内存产生某个特定值。未定义行为没有可移植的预期结果可供断言。
检查自己的理解: 为什么加入 printf 后,竞争问题看起来可能消失?

它改变了耗时和调度,有时还引入库内部的同步。这可能隐藏症状,但原来保护共享状态的协议仍然错误。

↑ 返回知识框架

继续探索

故事、概念讲解和推导例题由 Leon 原创编写。课程代号标识独立学习路径。参考资料属于各自作者。