2026年10月

C 语言内存安全问题的排查与思考

摘要:内存安全问题是 C/C++ 工程实践中最难定位的一类缺陷。与 Java、Go 这类带运行时检查的语言不同,C 语言在编译期不做数组边界检查,对未定义行为也不提供任何保证。一段存在越界写入、悬空指针或资源泄漏的代码,往往可以编译通过、通过功能测试,甚至在长期运行中表现稳定,然后在某个不确定的时刻以难以复现的方式失效。本文从一段命令行通讯录程序出发,梳理了它在文件读写、序列化存取、资源管理三个方面暴露出的 11 类缺陷,也一并讨论了链表宏实现本身带来的使用约束,逐一给出标准依据与实测证据,并进一步讨论了 AddressSanitizer 这类动态检测工具的能力边界与使用注意事项。

本文分为四个部分:第一部分介绍背景与基本概念;第二部分逐个剖析四类问题;第三部分讨论消毒剂工具的检测边界与一套可复用的排查方法;第四部分进行总结并列出实践中的常见误区。

说明:本文案例取自一个完整的命令行通讯录程序,出于篇幅考虑,文中只保留与主题相关的代码片段。文中所有结论均在 Linux 6.8 / gcc 11.4.0 / x86_64 环境下实测验证,输出原样引用。

1 背景

1.1 问题的来源

这份通讯录程序的功能很朴素:用双向链表保存联系人,支持增删查,并能把链表写进文本文件、再从文件读回。它通过了所有的功能性自测——每条记录都能正确插入、查找、删除,保存文件里确实有内容,读取之后链表也确实不为空。

但它同时存在 11 类缺陷,其中若干条会让功能直接失效。这些缺陷有一个共同点:编译器不报错(只有一处例外),sanitizer 也不一定报,而常规的功能测试完全覆盖不到。

促使我系统梳理这份代码的,是一次存取往返测试中发现的现象:把一条记录保存到文件、再读回来,电话号码的前面会多出一个 phone: 前缀;反复存取,前缀会不断累积。这个过程中没有任何越界、没有任何崩溃、没有触发任何 sanitizer——但数据已经错了。

由此引出一个值得展开的问题:在 C 语言里,"能跑"和"正确"之间的距离究竟在哪里?

1.2 为什么要专门讨论内存安全

C 语言把内存管理的责任完全交给了程序员。这既是它能在操作系统内核、嵌入式固件、高性能中间件等领域长期占据主导的原因,也是这些领域缺陷密度偏高的原因。具体来说,C 语言在以下三个方面不设保护:

  • 不做边界检查。 数组下标、指针运算、字符串写入,编译器都不验证是否落在对象范围内。
  • 不追踪对象生命周期。 对象被释放之后,指向它的指针不会失效,继续使用是未定义行为。
  • 不定义越界与未定义行为的后果。 C11 只声明这类行为"未定义",具体表现取决于编译器、优化级别、运行时内存布局,可能是静默的数据损坏,可能是崩溃,也可能看起来一切正常。

这三个特征叠加起来,使得内存安全缺陷的现场往往远离成因。一个被字符串覆写坏掉的链表指针,可能在几十次操作之后才被解引用,此时栈上早已没有留下任何有用的线索。

1.3 本文的组织

本文按缺陷的分布层次组织,从 I/O 边界,到持久化约定,再到资源管理,最后讨论检测工具的能力边界:

章节讨论范围缺陷数
第 3 章双向链表的宏实现(机制与使用约束)——
第 4 章文件读写循环与缓冲区边界5
第 5 章序列化存取不对称2
第 6 章返回值与内存所有权4

第 3 章讨论的是链表实现方式本身带来的使用约束(属于设计层面的注意事项,不计入缺陷清单),第 7 章讨论验证工具的能力边界与排查方法论,不单独对应缺陷。

2 基本概念

在展开具体问题之前,先统一定义本文反复使用的几个概念。

2.1 内存安全(Memory Safety)

内存安全指程序在运行期间只访问属于它的、并且仍然有效的内存区域。与之相对的是内存安全缺陷,常见形态包括:

  • 缓冲区溢出(buffer overflow):写入超出目标对象容量。
  • 释放后使用(use-after-free):对象释放之后继续通过指针访问。
  • 重复释放(double free):同一块内存被释放两次。
  • 内存泄漏(memory leak):对象失去所有引用但未被释放。

C11 未对上述行为定义语义,它们统一落入"未定义行为(undefined behavior)"的范畴。C11 3.4.3 对未定义行为的定义是:使用了一个不可移植或错误的程序构造,或错误的数据时,标准不施加任何要求。Annex J.2 中列举了大量属于未定义行为的情形,越界访问与解引用无效指针均在列。

2.2 数组容量与字符串长度

这是后面多个缺陷的根源,需要严格区分:

  • 数组容量是编译期确定的字节数量。char name[16] 的容量恒为 16 字节,不随内容变化。
  • 字符串长度是运行期由第一个空字符('\0')的位置决定的量。

C 语言的字符串处理函数(printf("%s")、strlen、strcpy)只信任终止空字符,不信任数组长度。这意味着写入方必须自行保证:在容量范围内为终止空字符留出一个字节。也就是——

数组的最大有效字符串长度 = 容量 − 1

NAME_LENGTH 定义为 16,可实际能容纳的姓名上限是 15 个字符。这个"减一"如果被遗忘,后果不是一个字符的截断,而是字符串失去终点:读取方会越过数组边界一直读下去。

2.3 对象边界与字段边界

这个区分在讨论检测工具时至关重要。

  • 对象指一次分配产生的内存块。栈上的一个局部变量、堆上的一次 malloc、一个结构体实例,都是独立的对象。
  • 字段指结构体内部的成员。

对下面这个结构体:

struct person {
    char name[16];      /* 偏移 0  */
    char phone[32];     /* 偏移 16 */
    struct person *next; /* 偏移 48 */
    struct person *prev; /* 偏移 56 */
};

sizeof(struct person) 为 64 字节(实测确认)。从 name 的第 17 个字节开始写入,在字段层面是越界——它踩坏了 phone;但在对象层面它仍在 64 字节范围之内。第 7 章会看到,这个差别直接决定了 AddressSanitizer 能否发现该缺陷。

2.4 所有权(Ownership)

所有权指"谁负责释放一块内存"这一约定。本文案例中涉及的所有权关系是:

对象分配点释放点(应有)
struct contactsmainmain 退出前
struct personinsert_entry / load_file链表所有者(struct contacts)统一释放
FILE *fopen同一函数内的 fclose

所有权一旦不明确,就会同时导致泄漏和重复释放。第 6.2 节会给出这两种情况的实测结果。

3 链表实现:宏带来的收益与代价

3.1 双向链表与头插法

链表的插入与删除被实现为两个宏:

#define LIST_INSERT(item, list) do {                        \
    (item)->prev = NULL;                                    \
    (item)->next = (list);                                  \
    if ((list) != NULL) (list)->prev = (item);              \
    (list) = (item);                                        \
} while(0)

#define LIST_REMOVE(item, list) do {                        \
    if (item->prev != NULL) item->prev->next = item->next;  \
    if (item->next != NULL) item->next->prev = item->prev;  \
    if (list == item) list = item->next;                    \
    item->prev = item->next = NULL;                         \
} while(0)

选择双向链表而非单链表,动机在删除操作上。删除一个已知节点时,单链表需要从头遍历找到它的前驱才能修改前驱的 next,时间复杂度 O(n);双向链表通过 item->prev 直接拿到前驱,可以做到 O(1)。通讯录这类以删除为高频操作的数据结构,这个差异是实质性的。

LIST_INSERT 采用的是头插法:(list) = (item); 让新节点成为链表头。头插法不需要维护尾指针,插入代价恒为 O(1)。但它的可见后果是——遍历输出顺序与插入顺序相反。实测印证:

head->id = 2, head->next->id = 1(头插法,顺序反过来)

LIST_REMOVE 完整覆盖了四种情形:被删节点位于表头(prev == NULL,跳过第一句,链表头改为 item->next)、位于表中(前后指针都需修改)、位于表尾(next == NULL,跳过第二句)、以及链表仅剩一个节点(两个条件均不成立,链表头置为 NULL)。

值得单独指出的是最后一句 item->prev = item->next = NULL;。它保证一旦发生"删除后再次访问"的错误,访问到的是空指针,而不是一个已经脱离链表、却仍指向原邻居的节点。后者会导致链表结构被意外重新连上,问题更难定位。不过需要明确:该宏只做指针摘除,不释放内存,free 由上层负责,这样职责划分才清晰。

3.2 为什么用宏,以及 do { } while(0) 的用途

用宏而不是函数的理由:函数参数是强类型的。要用函数实现通用的链表插入,只有两条路——为每种节点类型各写一个专用函数,或把参数退化为 void * 并额外传递成员偏移量。宏在预处理阶段做纯文本替换,不参与类型检查,因此同一套逻辑可以同时服务于 struct person、struct task、struct worker 等任意节点类型。这是用宏换取泛型能力。

do { } while(0) 的用途:这两个宏都是多语句宏。如果直接写成 { ... },下面的代码会出错:

if (cond)
    LIST_INSERT(p, head);   /* 展开为大括号块 */
else
    do_something();         /* 编译错误 */

展开后是 if (cond) { ... }; else ...,多出的分号提前结束了 if 语句,else 失去配对的 if。用 do { ... } while(0) 包裹,整体加上末尾分号后恰好构成一条完整语句,在任何上下文都不会被拆散。编译器在优化阶段会消除 while(0) 判断,不产生运行时开销。这是 C 语言中多语句宏的标准写法。

3.3 宏的固有代价:参数重复求值

宏参数在展开时是纯文本替换,参数名在宏体中出现几次,传入的表达式就会被求值几次。统计 LIST_INSERT 中 (list) 的出现位置:

  1. (item)->next = (list);
  2. if ((list) != NULL)
  3. (list)->prev = (item);
  4. (list) = (item);

共四处。若实参是普通变量名,读取四次不产生副作用;若实参是带副作用的表达式,问题立即显现。构造一个返回链表头地址并同时计数的函数,可以量化这一现象:

static int eval_count = 0;

static struct node **head_slot(void) {
    eval_count++;
    return &head;
}

LIST_INSERT(&a, *head_slot());

实测输出:

第 1 次插入:实参被求值 3 次
第 2 次插入:实参被求值 4 次

同一行调用,求值次数随运行状态变化。 原因在于第 2 和第 3 处位于同一个 if 内:链表为空时 (list) != NULL 为假,短路求值使第 3 处被跳过,因此是 3 次;链表非空时两处都要求值,变为 4 次。

这一实验的结论不是"宏不可用",而是一条使用约束:

宏的实参不应包含带副作用的表达式——自增、自减、函数调用、赋值都属于此类。

若必须在宏中保证单次求值,常见做法有二:改用 static inline 函数;或在 GNU C 中使用语句表达式 ({ ... }) 先将实参存入临时变量。

3.4 选型建议

方案泛型能力参数求值次数类型检查调试友好度
宏强(不区分类型)多次(取决于宏体)无差(调试器看不到宏内变量)
static inline 函数弱(需固定类型)一次有好
void * + 偏移量中一次弱一般
C11 _Generic中一次有一般

实践中的取舍是:若节点类型固定、且宏体较复杂,优先用 static inline 函数;只有在需要跨多种节点类型复用、且实参确无副作用时,才使用宏。

4 文件读写:循环条件与缓冲区边界

4.1 feof 的语义

读文件时最经典的一种写法:

while (!feof(fp)) {
    char buffer[BUFFER_LENGTH] = {0};
    fgets(buffer, BUFFER_LENGTH, fp);
    /* 处理 buffer */
}

C11 7.21.10.2 对 feof 的规定是:当且仅当流的文件结束指示器(end-of-file indicator)被设置时,返回非零值。而该指示器由一次失败的读取操作来设置。

因此 feof 是一个事后标志:它回答的是"上一次读取是否撞到了文件末尾",而不是"下一次读取是否会读到内容"。把它放在循环条件里,等价于用后视镜判断前方路况。

4.2 实测:2 行的文件跑了 3 轮

构造一个只有 2 行的数据文件,观察循环行为:

数据文件共 2 行:
    name: aa, phone: 111
    name: bb, phone: 222

第 1 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: aa, phone: 111
"
第 2 轮: fgets 返回 非NULL strlen(buffer) = 21  buffer = "name: bb, phone: 222
"
第 3 轮: fgets 返回 NULL   strlen(buffer) = 0  buffer = ""

实际执行 3 轮,文件只有 2 行 —— 最后一轮读的是空内容。

时序是:进入第 3 轮 → fgets 尝试读取、失败、返回 NULL、同时设置文件结束指示器 → buffer 保持初始化时的全零状态 → 处理逻辑拿到一个空缓冲区。

4.3 为什么这个缺陷在测试中隐身

如果"多跑一轮"只是多处理一次空数据,它算不上严重问题。它真正值得警惕的地方在于会与另一个缺陷互相掩盖。

本例的解析函数开头有这样一句:

if (length < MIN_TOKEN_LENGTH) return -2;   /* MIN_TOKEN_LENGTH = 5 */

第 3 轮的 buffer 全零,strlen 得到 0,小于 5,函数返回 -2,调用方 continue 跳过。空轮被这道长度检查挡住了。数据文件中的空行(长度为 1)同样是被这一句跳过的。

于是两个缺陷叠加的结果是:程序看起来完全正常。但只要调整或删除这句长度检查,隐藏的空轮问题立刻现形。

这给出一条工程经验:不能仅凭"当前测试通过"判断某段代码可以删除。 一个防御性检查可能正在掩盖另一个缺陷,删除它之前需要先确认它拦截的到底是哪些输入。

4.4 正确写法

把读取本身作为循环条件,让循环次数由成功读取的次数决定:

while (fgets(buffer, BUFFER_LENGTH, fp) != NULL) {
    /* 处理 buffer */
}

fgets 的返回值既是控制条件,也是有效性证明,循环体只在真正读到内容时执行。这是一个通用模式,读取二进制数据时同理:

size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
    /* 处理 n 字节 */
}

4.5 无界读入:scanf("%s")

struct person *p = (struct person*)malloc(sizeof(struct person));
memset(p, 0, sizeof(struct person));

scanf("%s", p->name);       /* name 只有 16 字节 */

C11 7.21.6.2 对 %s 转换说明符的定义是:匹配一段非空白字符的最长序列,将其加上终止空字符写入对应参数指向的数组;标准同时要求"该数组必须足以容纳这段序列和终止空字符"。

也就是说,容量是否足够由调用方负责,scanf 不做任何检查。不写宽度限制,%s 会一直读到空白字符为止。

输入 100 个字符时的实测结果:

==ERROR: AddressSanitizer: heap-buffer-overflow
0x506000000060 is located 0 bytes to the right of 64-byte region [0x506000000020,0x506000000060)
    #1 ... in insert_entry contacts.c:186
SUMMARY: AddressSanitizer: heap-buffer-overflow ... in scanf_common

被破坏的是 insert_entry 中 malloc 得到的 64 字节对象,写入位置正好在该对象右侧边界外 0 字节处。

修法是限定宽度,让 scanf 最多读取 容量 − 1 个字符:

scanf("%15s", p->name);     /* 16 字节数组,最多写 15 个字符 */

需要注意 %15s 中的 15 是字符数而非字节数,处理多字节编码时不能直接套用数组长度。更稳妥的做法是先读入一个足够大的缓冲区再截断:

char buf[256];
if (fgets(buf, sizeof(buf), stdin) != NULL) {
    buf[strcspn(buf, "\n")] = '\0';          /* 去掉换行符 */
    strncpy(p->name, buf, sizeof(p->name) - 1);
    p->name[sizeof(p->name) - 1] = '\0';     /* 兜底:确保终止 */
}

至少 fgets 的容量参数是显式写出的,不会遗漏。

4.6 手写解析器的两处边界错误

解析一行 name: liaoqf,telephone: 23456543789 的函数如下:

int parser_token(char *buffer, int length, char *name, char *phone) {
    if (buffer == NULL) return -1;
    if (length < MIN_TOKEN_LENGTH) return -2;

    int i = 0, j = 0, status = 0;
    for (i = 0; buffer[i] != ','; i++) {          /* 缺陷 1 */
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            name[j++] = buffer[i];                /* 缺陷 2 */
        }
    }

    status = 0;
    j = 0;
    for (; i < length; i++) {
        if (buffer[i] == ' ') {
            status = 1;
        } else if (status == 1) {
            phone[j++] = buffer[i];
        }
    }
    return 0;
}

缺陷 1:循环终止条件只认逗号。 for (i = 0; buffer[i] != ','; i++) 只检查字符是否为逗号。如果该行不含逗号,'\0' 不等于 ',',循环不会在字符串末尾停止,而是继续向后读取。

单独复现这一条需要一点技巧——构造一行既无逗号也无空格的数据,使写入路径保持安静,从而单独暴露读取问题:

==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 1
    #0 ... in parser_token contacts.c:119
    #1 ... in load_file contacts.c:160
    [128, 256) 'buffer' (line 151) <== Memory access at offset 256 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:119 in parser_token

buffer 是 load_file 中的 char buffer[128]。报告显示访问发生在偏移 256 处,即越过整个 128 字节数组后又前进了 128 字节。

修法是把长度纳入条件:

for (i = 0; i < length && buffer[i] != ','; i++)

length 由调用方通过 strlen(buffer) 计算并传入,本身是正确的,问题只在于循环没有使用它。

缺陷 2:写入时不比较目标容量。 name[j++] = buffer[i]; 中 j 只增不减,也从未与 NAME_LENGTH 比较。调用方传入的缓冲区:

char name[NAME_LENGTH] = {0};    /* 16 字节 */
char phone[PHONE_LENGTH] = {0};  /* 32 字节 */

只要文件中某一行的姓名字段超过 15 字节,就会写穿这 16 字节。实测:

==ERROR: AddressSanitizer: stack-buffer-overflow
    #0 ... in parser_token contacts.c:123
    #1 ... in load_file contacts.c:160
    [32, 48) 'name' (line 157) <== Memory access at offset 48 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow contacts.c:123 in parser_token

这里有两点值得注意。

其一,两处缺陷存在先后关系。 写越界(第 123 行)在多数输入下先触发,把读越界(第 119 行)挡住。这解释了为什么 ASan 报告的往往不是第一个缺陷,而是执行顺序上最先到达的那一个——修完一处必须重跑,才能看到后面藏着的另一处。

其二,length 参数在这里帮不上忙。 它约束的是源缓冲区的长度,而缺陷在于目标缓冲区的容量,两者是独立的量,必须分别检查:

if (status == 1) {
    if (j >= NAME_LENGTH - 1) return -3;   /* 留一个字节给终止空字符 */
    name[j++] = buffer[i];
}

4.7 终止符缺失引发的连锁后果

越界写入最直观的后果是"覆盖了相邻变量",但实际影响往往更隐蔽。将 40 个 A 从标准输入喂入 insert_entry(p->name 仅 16 字节),ASan 未报告任何错误,而程序打印出:

name: AAAAAAAAAAAAAAAA13900001111,phone: 13900001111

对照正常输入(alice):

name: alice,phone: 13900001111

分析其成因。在 64 字节的结构体中,偏移 0–15 属于 name,16–47 属于 phone。scanf 从 name 起始处写入 40 个 A,随后写入终止空字符,于是:

  1. name 的 16 字节被完全填满,终止空字符落在 name 之外,进入 phone 所在区域;
  2. 接下来 scanf("%s", p->phone) 又把电话号码写入 phone,覆盖了那个终止符;
  3. 结果 name 数组中不含任何 0 字节。

由于 C 语言的字符串以终止空字符界定,printf("%s", item->name) 会从 name[0] 逐字节输出,直到遇见 0 字节为止——而 0 字节此时已经在 phone 内。因此它先输出 16 个 A,随后越过数组边界继续输出,把紧邻的 phone 内容一并印出。

这个案例把 2.2 节的结论具体化了:数组容量与字符串长度是两个不同的量,写入方必须保证容量范围内留有一个字节给终止空字符。 忘记"减一"的后果不是截断一个字符,而是让字符串失去终点。

4.8 缓冲区操作的检查清单

综合以上各点,凡涉及缓冲区写入的地方,建议逐项确认:

  • [ ] 目标缓冲区容量是否已知(sizeof(array) 或显式传入)?
  • [ ] 写入长度是否在写入前与容量比较,而不是写入之后?
  • [ ] 是否为终止空字符预留了位置(最大长度 = 容量 − 1)?
  • [ ] 源数据的长度与目标缓冲区的容量是否分别检查?
  • [ ] scanf("%s")、strcpy、sprintf、gets 这类无界接口是否已替换?
  • [ ] 循环中是否存在依赖输入内容的终止条件(如"直到遇见某字符")?是否已加上长度上界?

5 持久化:存取不对称

5.1 格式约定的隐性依赖

保存函数写出的是这样的格式:

fprintf(fp, "name: %s, phone: %s\n", item->name, item->phone);

注意逗号后面带有一个空格。

而解析函数的规则是:以逗号切分两段,然后在每一段中取第一个空格之后的全部内容作为值。

这条规则本身不难理解,问题在于它对格式极为敏感——"第一个空格"出现的位置,取决于逗号后面有没有空格。对仓库中三份数据文件的实测结果:

文件行格式实测解析结果
a.txtname: liaoqf,telephone: 23456543789liaoqf --> 23456543789(正确)
b.txtname: qiuxiang,telephone: 98765678123qiuxiang --> 98765678123(正确)
c.txtname: king, phone: 344567754king --> phone:344567754(错误)

a.txt / b.txt 中逗号紧跟字段名,第一个空格出现在字段名冒号之后,取到的值是纯数字;c.txt 中逗号后面先有一个空格,这个空格被当作字段名与值的分隔符,于是字段名 phone: 连同冒号一起被保留在值里。

差别只在一个空格,解析结果却完全不同——这说明格式约定存在隐性依赖,而这类依赖通常不会被写进文档。

5.2 实测:一次往返就损坏数据

把上面的格式问题与保存格式对应起来,结果就不只是"某份测试数据格式不对"了。保存时写出的是逗号加空格——恰恰是解析会出错的那一种。实测一次完整的存取往返:

保存进 rt.txt 的内容:
name: alice, phone: 13900001111

读回时解析结果:
file token : alice --> phone:13900001111

遍历输出:
name: alice,phone: phone:13900001111       ← 从文件读回的记录
name: alice,phone: 13900001111             ← 内存中原有的记录

每执行一次"保存再读取",电话号码前面就会多一个 phone: 前缀。 反复存取会不断累积。这个缺陷不会触发任何 sanitizer,因为从内存安全的角度看,代码一行越界都没有。

同时还可以看到两个相关问题:

  • 加载是追加语义。 load_file 将读到的节点逐个头插进链表,调用方已有的节点一个都不动。因此输出中 alice 出现两次——一次是内存中原有的,一次是从文件读入的。cts->count 也随之累加,对同一文件加载两次,记录数会翻倍。
  • 顺序被反转两次。 save_file 按链表顺序逐行写出,load_file 用头插法读回,两个方向相反,因此"保存 → 读取"之后链表顺序与原状态相反。

5.3 往返测试:拦截这类缺陷的唯一手段

这类缺陷不越界、不崩溃、不产生编译警告,动态检测工具完全无力。能守住它的只有"往返测试"——把序列化与反序列化作为一对操作来验证:

构造数据 → 序列化 → 清空内存 → 反序列化 → 逐字段比较

实现成本极低,却能覆盖一整类缺陷。需要注意的是,仅验证"保存后文件非空"和"读取后链表非空"是远远不够的:

/* 不足够的断言 */
assert(file_size > 0);
assert(list_is_empty() == false);

/* 有效的断言 */
save(list, path);
clear(list);
load(&list, path);
assert(list_count() == original_count);
for (i = 0; i < original_count; i++)
    assert(strcmp(list[i].name, original[i].name) == 0 &&
           strcmp(list[i].phone, original[i].phone) == 0);

只有比较内容本身,才能发现"值里多了个字段名前缀"这类错误。

6 资源管理:返回值与所有权

6.1 隐式返回值

int save_file(struct person *people, const char *filename) {
    FILE *fp = fopen(filename, "w");
    if (fp == NULL) return -1;
    /* ... */
    fclose(fp);
}                                    /* 函数结束,但没有 return 语句 */

本例中 save_file、print_entry、save_entry、load_entry 四个函数都是这种形态:声明返回 int,正常路径上却没有任何 return。

C11 6.9.1p12 规定:若执行到终结函数的 },且调用方使用了该次调用的返回值,则行为未定义。注意构成未定义行为需要同时满足两个条件——"到达函数末尾"与"调用方使用返回值"。当前 main 中:

case OPER_SAVE:
    save_entry(cts);        /* 返回值被丢弃 */
    break;

返回值确实未被使用,因此按标准字面,当前代码不构成未定义行为。但这是一个脆弱的巧合:只要有人将调用改为 if (save_entry(cts) != 0) { ... },未定义行为立即成立,而届时编译器未必还会警告——尤其是当该函数被编译在另一个翻译单元、-Wreturn-type 看不到函数体时。

好消息是,GCC 在 -Wall 下会直接指出(-Wreturn-type 属于 -Wall):

contacts.c: In function 'save_file':
contacts.c:111:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'print_entry':
contacts.c:218:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'save_entry':
contacts.c:274:1: warning: control reaches end of non-void function [-Wreturn-type]
contacts.c: In function 'load_entry':
contacts.c:284:1: warning: control reaches end of non-void function [-Wreturn-type]

四个函数的位置被逐行列出。也就是说这类问题在零成本的条件下就能被发现。之所以长期存在,只是因为警告没有被当作需要处理的问题。

由此得到一条实践建议:编译带上 -Wall -Wextra,并要求零警告。 警告数量一旦被允许存在,人就会习惯性地忽略它们,而忽略的代价是放弃了一层免费的静态检查。若希望更严格,可叠加 -Werror 将警告升级为错误,或使用 -fanalyzer(GCC 10 起提供)做过程间分析。

6.2 内存泄漏实测

main 退出前的清理只有一句:

exit:
    free(cts);
    return 0;

cts 是 malloc 得到的 struct contacts,释放它是正确的。但 cts->people 指向的整条链表——每个节点都由 insert_entry 单独 malloc——一个都没有被释放。用 LeakSanitizer 运行完整流程(插入一条记录后退出):

==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 64 byte(s) in 1 object(s) allocated from:
    #0 ... in __interceptor_malloc
    #1 ... in insert_entry contacts.c:186
    #2 ... in main contacts.c:317

SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).

泄漏 64 字节,正是 sizeof(struct person);分配点在 insert_entry 第 186 行。插入 N 条记录即泄漏 64 × N 字节。

6.3 释放顺序

正确的清理函数如下:

void contacts_destroy(struct contacts *cts) {
    struct person *item = cts->people;
    while (item != NULL) {
        struct person *next = item->next;   /* 先保存后继,再释放当前节点 */
        free(item);
        item = next;
    }
    cts->people = NULL;
    cts->count = 0;
}

next 必须在 free(item) 之前取出。 若写成:

free(item);
item = item->next;      /* 错误:释放后使用 */

就是典型的释放后使用(use-after-free)。free 之后那块内存的内容可能已被分配器改写,再读 item->next 得到的可能是任意地址。使用 -fsanitize=address 时,这类访问会以 heap-use-after-free 的形式报出,报告中会注明该区域"已被释放"。

此外,load_file 中还有一处错误路径泄漏:

struct person *p = (struct person*)malloc(sizeof(struct person));
if (p == NULL) return -2;      /* 直接返回,fp 没有 fclose */

分配失败时函数提前返回,fopen 得到的文件句柄永不关闭。修法是让失败路径统一在 fclose(fp) 之后返回,即每个函数只保留一个出口;或者用 goto cleanup 集中处理。

这一点容易被忽略,因为"分配失败"在测试中几乎不会出现——64 字节的分配失败通常意味着进程早已无法继续。但在内存受限的嵌入式环境中,这恰恰是最需要正确处理的分支。

7 消毒剂的能力边界

7.1 影子内存与红区机制

前文多处越界之所以能被发现,依靠的是 AddressSanitizer。其原理是:为每个内存对象两侧添加红区(redzone),并用一段影子内存(shadow memory)记录每个字节是否可访问;编译阶段在每次内存访问前插入检查,命中不可访问区域即报告并打印调用栈。

影子内存的粒度可以从 ASan 自身的输出中读出:

Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack right redzone:     f3
  Intra object redzone:    bb

一个影子字节对应 8 个应用字节。红区的分类中,f1 / f3 是理解检测边界的关键:红区加在栈变量之间,而不是加在结构体字段之间。

7.2 实测:同一个越界,两种结果

下面这段对比程序,在 struct person 的 name[16] 上做越界写入,分别放在结构体内部和独立局部数组中:

__attribute__((noinline))
static void write_into_struct(const char *src, int n) {
    struct person p;
    memset(&p, 0, sizeof(p));
    for (int i = 0; i < n; i++) p.name[i] = src[i];   /* A:溢出落在结构体内部 */
    printf("  [A] phone 字段读到: %.32s\n", p.phone);
}

__attribute__((noinline))
static void write_into_local(const char *src, int n) {
    char name[16];
    memset(name, 0, sizeof(name));
    for (int i = 0; i < n; i++) name[i] = src[i];     /* B:溢出踩到相邻栈对象 */
    printf("  [B] name 前 16 字节: %.16s\n", name);
}

以 -fsanitize=address 编译,改变写入长度,实测结果如下:

写入长度情形 A(写在 struct person 内部,对象共 64 字节)情形 B(写在独立的 char name[16] 之外)
17未报告,phone 被污染 1 字节报告 stack-buffer-overflow
32未报告报告
64未报告(刚好写满整个结构体)报告
80报告 stack-buffer-overflow报告

情形 A 在写入 32 字节时的完整输出:

情形 A:越界写在 struct person 内部
  [A] 写入 32 字节后,phone 字段读到: XXXXXXXXXXXXXXXX

16 个 X 恰好对应从 name 溢出的 16 个字节——数据已经写坏,ASan 却未报告。原因在于,从 ASan 的视角看,p 是一个 64 字节的对象,偏移 0 至 63 全部可访问;至于程序员认为 name 只应占前 16 字节,影子内存无从得知。只有当写入越过整个 64 字节对象时(情形 A 的 80 字节),红区才会被踩到。

7.3 这一边界对实际代码的影响

回到 4.5 节的代码:

struct person *p = (struct person*)malloc(sizeof(struct person));  /* 64 字节 */
scanf("%s", p->name);       /* 写入 17~64 字节之间:静默损坏 phone / next / prev */

输入长度在 17 到 64 之间时,越界发生在对象内部,ASan 不报告;只有超过 64 字节才会报 heap-buffer-overflow。而 next / prev 两个指针一旦被字符串覆写,链表结构会被破坏为任意值,之后任何一次遍历都是解引用野指针。这类缺陷的现场往往远离成因,定位难度极高。

这正是 ASan 最需要覆盖、却恰恰覆盖不到的区域。

能否让 ASan 也检测对象内部越界?可以,Clang 提供 -fsanitize-address-field-padding=<n>,在字段之间插入填充与红区。但这是 Clang 独有的选项:本文验证环境仅有 gcc 11.4.0,加上该参数直接报错——

gcc: error: unrecognized command-line option '-fsanitize-address-field-padding=2'

因此这一条未能在 GCC 上实测,只能从 ASan 输出中 Intra object redzone: bb 这一项确认该机制存在。若工程中使用 GCC 并希望覆盖对象内部越界,需依赖代码审查与显式的边界检查。

7.4 一个实践细节:泄漏检测会吞掉标准输出

用同一程序演示内存泄漏时,容易遇到一个会造成误判的现象。将程序的标准输出接入管道,输出会是完全空的:

$ printf "1\nalice\n13900001111\n2\n0\n" | ./contacts_asan 2>/dev/null
(无输出)

原因在 LeakSanitizer 的退出方式:检测到泄漏后,它走的是 _exit 一类不执行清理的终止路径,不会刷新 stdio 缓冲区。标准输出接终端时是行缓冲,每次换行即落盘;接入管道时变为全缓冲,内容仍留在缓冲区中,进程直接终止便一并丢失。

要看正常输出,显式关闭泄漏检测即可:

ASAN_OPTIONS=detect_leaks=0 ./contacts_asan

这不是程序的缺陷,但排查时若不了解,很容易把"输出为空"误判为"程序未运行"。

8 排查方法论

8.1 缺陷全景

将本文涉及的全部缺陷汇总如下,标注各自的可见性:

#问题编译警告ASan 捕获修法要点
1保存格式与读取规则不匹配否否统一格式,写往返测试
2加载采用追加而非替换否否明确语义,同步 count
3scanf("%s") 无宽度限制否仅越过整个对象时加宽度限制或改用 fgets
4解析循环终止条件只认逗号否是(越界读)加入长度上界
5解析写入不检查目标容量否对象内部不报源长度与目标容量分别检查
6while (!feof(fp)) 多跑一轮否否改用 while (fgets(...) != NULL)
7错误路径文件句柄泄漏否部分(LSan 可报)统一出口
8四个函数缺少 return是否编译零警告
9退出时未释放链表否是(LeakSanitizer)实现 destroy,注意释放顺序
10调用方不检查返回值否否检查并反馈
11循环内 fflush 增加 I/O 次数否否交给 fclose 统一刷新

可以看到,11 类缺陷中只有 1 类会产生编译警告,3 类能被 ASan 捕获。其余 7 类需要依靠代码审查、往返测试与边界检查清单来覆盖。

8.2 建议的排查流程

结合以上经验,对涉及内存操作与 I/O 的模块,可以采用如下流程:

  1. 零警告编译。 gcc -Wall -Wextra -O0 -g,要求零警告。这一步成本最低,收益最直接。
  2. 静态审查缓冲区。 按 4.8 节的清单逐项核对,重点是所有无界接口与依赖输入内容的循环条件。
  3. 接入 ASan 运行。 -fsanitize=address,跑通全部功能路径。注意它的检测边界(第 7 章):对象内部的越界不会报告,需要人工补位。
  4. 补写往返测试。 凡涉及序列化与反序列化的模块,必须验证"写出去再读回来,内容相等"。
  5. 检查所有权。 每个 malloc 都要能对应到一处 free,每个 fopen 都要能对应到一处 fclose(包括所有错误路径)。
  6. 逐个修复,逐个重跑。 ASan 通常只报告执行顺序上最先到达的缺陷——修完一处必须重跑,才能暴露后面隐藏的问题。

8.3 关于"缺陷互相掩盖"

第 4.3 节提到的现象值得单独强调:load_file 的长度检查(length < MIN_TOKEN_LENGTH)恰好掩盖了 feof 多跑一轮的后果,使缺陷在测试中隐身。

缺陷之间存在互相伪装的可能。 一份通过了全部测试的代码,未必是"没有缺陷的代码",也可能是"缺陷恰好被彼此抵消或掩盖的代码"。这也是为什么修复应当逐个进行、每改一处就重跑一次验证——批量修改会让新的问题难以归因。

9 总结

本文从一段功能上"完全正常"的通讯录程序出发,梳理了 11 类内存安全与资源管理缺陷,覆盖双向链表的宏实现、文件读写循环、缓冲区边界、序列化存取与内存所有权五个方面。主要结论如下:

第一,feof 是事后标志,不是预测。 C11 7.21.10.2 规定它查询的是文件结束指示器,而该指示器仅在一次失败的读取之后才被设置。任何形如 while (!feof(fp)) 的循环都会多执行一轮,正确做法是让读取操作的返回值充当循环条件。

第二,数组容量与字符串长度是两个独立的量。 容量决定可写入的字节数,字符串长度由终止空字符界定。写入方必须预留终止空字符的位置(最大长度 = 容量 − 1);读取方只信任那个 0 字节。忘记这一条,后果不是截断,而是字符串失去终点、越界读取。

第三,宏的参数会被求值多次。 实测同一个宏在链表为空时求值 3 次、非空时 4 次,差异来自宏体内 if 的短路求值。宏的实参不应包含带副作用的表达式。

第四,ASan 的检测边界是"对象"而非"字段"。 红区加在变量之间而不加在结构体字段之间,因此结构体内部的越界写入不会报告,只有越过整个对象才会。而越是容易破坏链表结构的越界(覆写 next / prev),越落在这个盲区内。GCC 不支持 Clang 的字段填充选项,无法用它在 GCC 上补齐。

第五,序列化必须成对设计,并配往返测试。 本例中 save 写出 ", ",load 却把逗号后的空格当成分隔符,每存取一次就给电话号码加上一个字段名前缀。这类缺陷不触发任何 sanitizer,只能靠"写出去再读回来、比较内容"的测试发现。

第六,编译警告应当清零。 四个缺少 return 的函数没有被人工发现,而 GCC 早已逐行指出。允许警告存在,等于放弃了一层免费的静态检查。

9.1 常见误区

实践中,有几个容易出现的认识偏差:

误区一:程序能跑通就说明没有内存问题。 未定义行为的特征恰恰是"可能看起来正常"。越界写入未必落在关键字段上,释放后使用未必立刻崩溃,泄漏在内存充足时完全无症状。

误区二:接入了 sanitizer 就万事大吉。 ASan 有明确的检测边界(第 7 章)。它覆盖不到对象内部的越界,也覆盖不到语义错误(如第 5 章的存取不对称)。工具是必要而非充分条件。

误区三:用增加检查的方式保证安全,不考虑检查本身的正确性。 本例中 if (length < MIN_TOKEN_LENGTH) return -2; 就属于这类"看似周全"的检查——它在挡住空行的同时,也掩盖了 feof 的缺陷。检查的语义必须与它要拦截的输入精确对应。

误区四:把编译警告当作噪声。 -Wall 报出的是标准明确要求提示的内容。长期忽略警告,等于让编译器这层免费的检查失效。

误区五:批量修复所有缺陷。 ASan 一次通常只报告最先到达的问题。批量修改会让验证结果无法归因,反而延长定位时间。逐个修复、逐个验证才是更快的路径。

参考文献

  1. ISO/IEC 9899:2011(C11)6.9.1p12 —— 函数返回值与到达函数末尾的未定义行为
  2. ISO/IEC 9899:2011(C11)7.21.6.2 —— fscanf 系列对 %s 转换说明符的容量要求
  3. ISO/IEC 9899:2011(C11)7.21.10.2 —— feof 函数语义
  4. ISO/IEC 9899:2011(C11)3.4.3、Annex J.2 —— 未定义行为的定义与情形列举
  5. AddressSanitizer 官方文档 —— 影子内存布局与红区类型
  6. GCC 手册 —— -Wreturn-type、-fsanitize=address、-fanalyzer

从竞态条件到双重释放:C 并发编程的几个关键问题

多线程编程里,真正难处理的往往不是"写不出来",而是写出来了、跑通了、然后在某个不确定的时刻崩溃。

本文讨论四个在实际代码中反复出现的核心问题:

  1. 竞态条件——count++ 为什么不是原子操作,以及四种同步方案的取舍
  2. 条件变量的正确用法——为什么等待必须包在 while 里,为什么执行阶段要放锁
  3. 内存所有权的归属——双重释放是怎么产生的,如何从设计上规避
  4. 运行期验证——如何用消毒剂在测试阶段捕捉上述问题

一、竞态条件:count++ 为什么不是原子操作

多线程对同一个变量做自增时,count++ 在高级语言里看是一条语句,编译成机器指令却是三条:

  1. 从内存读取 count 到寄存器
  2. 寄存器加一
  3. 把寄存器写回内存

这三步之间存在可被抢占的窗口。假设 count 当前为 5,线程 A 执行完第 1 步读到 5,此时发生线程切换;线程 B 完整执行三步,把 count 变成 6;切回 A,它寄存器里还是 5,加一后写回,count 依然是 6。

两次自增,结果只增加了 1。 这就是竞态条件(race condition)。

注意问题的根源是复合操作的不可分割性被破坏,而不是"读到了脏数据"。即使内存访问本身是原子的(现代 CPU 对对齐的机器字访问确实是原子的),自增这个复合操作仍然不是。

二、四种同步方案的取舍

方案实现未获取到时的行为适用场景
无同步(*pcount)++——仅用于验证竞态确实存在
互斥锁pthread_mutex_lock线程休眠,让出 CPU临界区长、竞争激烈
自旋锁pthread_spin_lock线程忙等,持续占用 CPU临界区极短,省下切换开销
CPU 原子指令lock; xaddl无锁,单条指令完成读改写单个变量的简单操作

互斥锁与自旋锁的分界线是"临界区长度"。互斥锁在拿不到锁时会把线程挂起,这涉及两次上下文切换(挂起、唤醒),每次切换的开销在微秒量级。如果临界区本身就只需要几十纳秒,那么切换的开销远大于等待本身——这种情况下自旋锁更划算,它让线程原地空转等待,避免了切换代价,代价是空转期间持续占用 CPU 周期。

原子指令方案则是另一个层次:它不依赖锁,而是让 CPU 直接提供一条不可分割的读-改-写指令。

int inc(int *value, int add) {
    int old;
    __asm__ volatile(
        "lock; xaddl %2, %1;"      /* lock 前缀保证多核下原子执行 */
        : "=a" (old)                /* 输出:旧值放入 eax */
        : "m" (*value), "a"(add)    /* 输入:内存操作数、加数放入 eax */
        : "cc", "memory"            /* 会修改标志寄存器与内存,阻止编译器重排 */
    );
    return old;
}

几个要点:

  • xaddl 的语义是交换后相加——先交换寄存器值和内存值,再把两者之和写回内存。返回值(old)是交换出来的旧值,这正是自增操作需要的语义。
  • lock 前缀不是"加锁"的意思,而是一个指令前缀,作用是让该指令在执行期间锁定对应的缓存行,其他核心无法同时访问同一地址。这是硬件层面的互斥,粒度远小于互斥锁。
  • 约束列表里的 "cc" 表示该指令会修改条件码寄存器(EFLAGS),"memory" 表示会读写内存。如果漏掉 "memory",编译器可能把其他内存访问重排到这条指令之前或之后,破坏原子性——这是内联汇编里最容易出错的地方。

三、线程池:条件变量的正确用法

线程池的本质是复用。每来一个任务就 pthread_create 有两个代价:创建本身需要分配内核对象和栈空间;任务量突增时线程数失控,CPU 大量时间耗在上下文切换而不是实际计算上。

固定大小的线程池用三个结构体撑起:

结构体作用挂在哪条链表
nTask待执行任务:函数指针 + 用户数据manager->tasks
nWorker工作线程:线程 ID + 终止标志manager->workers
nManager管理器:两条链表头 + 互斥锁 + 条件变量本身

任务队列和线程列表都用双向链表实现,LIST_INSERT 采用头插法,LIST_REMOVE 分别处理被删元素位于表头、表中、表尾以及链表只剩一个元素的情形。用宏而不是函数实现,是因为宏不区分类型,一套逻辑可以同时服务于两种节点。

条件变量的语义

条件变量(condition variable,pthread_cond_t)必须与互斥锁配合使用。它的核心 API 是:

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

这个调用做三件事,且整体是原子的:

  1. 释放传入的互斥锁
  2. 让当前线程进入睡眠
  3. 被唤醒时重新获取该互斥锁,然后才返回

"释放锁"和"进入睡眠"必须是原子的,否则会有一个致命窗口:如果先释放锁、还没睡着,此时另一个线程插入任务并发出信号,这个信号就会丢失,而当前线程随后睡下去,永远等不到唤醒。

消费循环的三个细节

pthread_mutex_lock(&manager->mutex);
while (manager->tasks == NULL) {           /* 细节 1 */
    if (worker->terminate) break;
    pthread_cond_wait(&manager->cond, &manager->mutex);
}
if (worker->terminate) {                   /* 细节 2 */
    pthread_mutex_unlock(&manager->mutex);
    break;
}
struct nTask *task = manager->tasks;
LIST_REMOVE(task, manager->tasks);
pthread_mutex_unlock(&manager->mutex);     /* 细节 3 */
task->task_func(task);

细节 1:用 while 而不是 if。 pthread_cond_wait 返回不代表条件一定成立。原因有两类:一是 pthread_cond_broadcast 会唤醒所有等待者,但其中只有一个能抢到任务;二是 POSIX 标准明确允许虚假唤醒(spurious wakeup)——即使没有任何线程发出信号,等待也可能返回。因此被唤醒后必须重新检查条件,if 只检查一次,会漏掉这两种情况。

细节 2:终止检查要在两处都有。 内层循环里的检查处理"线程在等待中被唤醒"的路径;循环外的检查处理"队列非空、直接跳过内层循环"的路径。少任何一处都有线程退不出去或提前退出的问题。

细节 3:执行任务时不持锁。 这是整个设计中最重要的性能决定。task_func 是用户代码,执行时间不可预知。如果在锁内调用,所有工作线程都会被串行化,线程池退化成"单线程加一个队列",完全失去并发意义。

正确的做法是:持锁期间只做"取出任务并摘链"这个 O(1) 的链表操作,拿到任务指针后立即放锁,再执行。

四、内存所有权:双重释放的成因与规避

这个问题在一次线程池销毁逻辑的修正中暴露出来。

原始代码在回调函数结尾有一句 free(worker);——线程退出时释放自己的结构体。而销毁流程缺了 pthread_join 和资源释放,属于资源泄漏。看起来"补上 join 和 free"就能修好,但这样做会踩进一个更严重的坑:

同一个指针被释放两次。

线程退出时 free 一次,销毁流程再 free 一次,构成双重释放(double free)。这是 C 语言中的未定义行为,实际表现通常是堆管理器的内部链表被破坏,然后在完全无关的代码位置崩溃——排查成本极高,因为崩溃点不是案发现场。

问题的根源不是"忘记加判断",而是所有权(ownership)没有定义清楚:这块内存该由谁负责回收?写代码时如果没有明确回答这个问题,释放逻辑迟早会出现重复或遗漏。

可行的所有权模型主要有两种:

模型 A:生产者/管理者持有。 由创建方统一负责释放,使用者只借用。

模型 B:自持有。 谁创建谁释放,或者对象自行负责生命周期。

线程池这里选择模型 A——worker 结构体的所有权归线程池管理器:

  • 回调函数不再自释放,改为 return NULL;
  • 销毁流程统一负责回收

确定所有权之后,销毁顺序也被约束了,每一步的前后关系都不能调换:

置终止标志 → 广播唤醒 → 逐个 join → free(worker) → 释放残留任务 → 销毁同步原语
  • join 必须在 free 之前:线程还在运行时,它的结构体不能被回收,否则线程会访问已释放的内存。
  • terminate 的写入必须在持锁状态下:否则与工作线程读取该标志构成数据竞争。虽然 int 的读写在多数平台上是原子的,但 C 语言的内存模型并不保证这一点,编译器的重排和 CPU 的乱序执行都可能让另一个线程读到意外值。

五、运行验证:用消毒剂捕捉并发缺陷

代码"看起来对"和"确实对"之间差距很大,并发场景下尤其明显——很多缺陷只在特定时序下出现,低负载测试根本触发不到。

GCC 和 Clang 内置的消毒剂(sanitizer)可以在运行时捕捉这类问题,代价只是重新编译一次。

AddressSanitizer:内存错误

gcc -fsanitize=address -g -pthread program.c -o program_asan

ASan 的原理是在每次内存分配的周围插入不可访问的"红区"(redzone),并记录每个内存块的分配与释放状态。一旦发生越界访问、访问已释放内存、重复释放,它会立刻中止程序并打印完整的调用栈。程序退出时若存在内存泄漏,也会给出泄漏点。

用 ASan 验证线程池的销毁逻辑,可以直接确认双重释放是否真的被消除。

ThreadSanitizer:数据竞争

gcc -fsanitize=thread -g -pthread program.c -o program_tsan

TSan 通过记录每个内存访问的线程标识和同步顺序,检测多个线程未经同步地访问同一内存、且至少有一个是写操作的情况。它会输出冲突双方的调用栈以及涉及的内存地址。

一个环境兼容问题

TSan 在部分系统上会直接启动失败:

FATAL: ThreadSanitizer: unexpected memory mapping 0x58de4eb36000-0x58de4eb37000

这不是代码缺陷,而是 TSan 对进程的内存布局有特定要求,与系统的 ASLR(Address Space Layout Randomization,地址空间布局随机化)冲突。解决办法是关闭 ASLR 后运行:

setarch $(uname -m) -R ./program_tsan

-R 表示禁用地址随机化,只对当前进程生效,不影响系统全局设置。

两项检查全部无报告,才能说明这段并发代码通过了内存安全和数据竞争的基本检验。

六、两个容易被忽略的工程细节

换行符的跨平台差异

三种系统对"换行"的定义不同:

名称字节序列使用系统
LF\n(1 字节)Unix / Linux / macOS
CRLF\r\n(2 字节)Windows
CR\r早期 Mac,已淘汰

混用的后果是:Git 会认为整个文件的每一行都被修改过,因为每个换行符的字节序列都不同,diff 显示为全文重写。

处理办法是配置 core.autocrlf:

git config --global core.autocrlf true

它在两个方向上自动转换:提交时把工作区的 CRLF 转成 LF 存入仓库,检出时把仓库的 LF 转成 CRLF 写回工作区。结果是仓库里永远只存 LF,换行符差异被完全隔离在本地。

提交时偶尔会看到警告 LF will be replaced by CRLF the next time Git touches it,这是提示而非错误,说明下次检出时会执行转换。

二进制文件与版本控制

编译产物不应纳入版本管理——它们体积大、每次重编译都产生巨大差异、且可以从源码重新生成。

.gitignore 的常规写法能覆盖大部分情况:

build/
bin/
*.o
*.obj
*.exe
*.so

但有一个特例:Linux 下的可执行文件通常不带扩展名。gcc lock.c -o lock 生成的文件就叫 lock,无法用 *.xxx 匹配,必须按路径显式列出:

# Linux 可执行文件没有扩展名,无法用通配符匹配
/01-concurrent-lock/lock

开头的斜杠表示路径从仓库根目录算起,避免误伤其他同名文件。这也意味着每新增一个可执行文件都要检查是否需要补一行——相比之下,把编译输出统一约定到被忽略的 bin/ 目录下更省心。

七、小结

几个可以直接带走的结论:

  1. 竞态条件的根源是复合操作不可分割性被破坏,而不只是"内存访问不同步"。解决思路要么是让操作变得原子(lock 前缀),要么是让临界区互斥(锁)。
  2. pthread_cond_wait 必须包在 while 里,因为存在广播唤醒和虚假唤醒两种情况。
  3. 持锁期间只做必要的临界操作——线程池在锁内只摘链,执行任务时放锁。
  4. 写释放逻辑之前先明确所有权归属,否则双重释放和内存泄漏迟早出现。
  5. join 永远在 free 之前,销毁顺序由所有权模型决定,不能随意调整。
  6. 并发代码要用 ASan 和 TSan 验证,而不是"跑一遍没崩"就认为没问题。