第 15 章:程序故障
本章介绍:
- 导致失效的不当操作;
- 程序状态劣化;
- 不幸事件;
- 预先检查错误;
- 清理。
C 程序失效的方式千差万别:可能悄无声息,可能时断时续,可能在意料之中,也可能声势浩大。人们往往把注意力集中在程序失效后会发生什么,却很少追问程序是否会失效、为何失效。这一点从常用于(甚至误用于)此类情形的一个术语就能看出来:未定义行为,行话中简称 UB。顾名思义,这个术语关注的是错误发生后程序(更准确地说,整个系统)的行为,而不是导致错误的原因。
这有点像靠罚款或坐牢来吓唬驾驶员,以此改善道路安全,而不是推行定期车辆安检、要求持证驾驶,或者向驾驶员和乘客宣传系安全带的好处。
本章区分三种程序失效形式。第一种是直接导致失效的具体动作,即不当操作(第 15.1 节)。第二种情形下,执行状态逐步劣化,找不到某个能够或应当为程序失效负责的具体动作(第 15.2 节)。最后一种情形最困难,也最复杂:程序中相距甚远的几个部分,其行为单独看都有效,却因不幸的组合而失效(第 15.3 节)。本章末尾还会简要讨论程序失效有哪些表现;出人意料的是,这些表现与失效形式几乎没有关联(第 15.5 节)。
本章不讨论编译、链接或启动阶段的失效。例如,把声明相互矛盾(包括 [[noreturn]] 等属性不一致)的翻译单元链接在一起,可能生成无效的可执行文件;使用不正确的字符常量或字符串常量,或者把无效函数用作 atexit 处理程序,也会造成这类问题。这些失效甚至可能在程序看似成功启动之后才暴露出来,而程序其实早已注定失败,只是自己没有察觉。
本章继续从一个与计算机截然不同的领域选取示例,希望借助其中丰富的类比来理解问题,这个领域就是汽车、火车、飞机或火箭的交通运输。它受一组与程序相似的约束支配:
- 它由众多参数描述,包括空间、速度、加速度、能耗和密度;
- 它具有动态性,情况每秒都在变化;某一时刻的正确动作,下一刻可能造成灾难;
- 它依赖数量有限的资源,例如可用空间、燃料或电力、氧气、时间、驾驶人员、车辆和轨道;
- 它具有多个目标,包括速度、吞吐量、舒适度、安全性和乐趣;
- 它受不同类型的规律与规则支配,包括物理规律、刑法、社会规则和迷信。
这里讨论的各种失效有一个共同点:它们在程序执行期间才显现,而且可能发生在编译时无法检测的情形下。违反语言约束的错误一般在编译期间就会得到诊断,因此不那么令人担心。
15.1 不当操作
第一类程序失效无疑最容易理解:某个动作或事件(或者本该发生却没有发生的动作或事件)直接导致了失效。一般来说,如果汽车在笔直的道路上冲出路面撞上了树,我们会责怪驾驶员,怀疑他有某种不当操作,例如操纵失误、超速、酒驾或类似的违法行为。车辆或道路本身也有极小的可能出了问题,所以在没有其他明显原因时,也可以调查一下它们。但只有在极少数情况下,才该让报道事故的记者、地球自转,或者那棵被撞的老橡树为损失负责。
15.1.1 算术违规
在程序执行期间可能出现的不当操作中,最简单的一类是 C 标准所说的异常条件,即操作所用的操作数没有定义的数学结果。最常见的大概是:
- 除以零;
- 对零取模。
对于我们所表示的数,这些运算在数学上没有定义。浮点算术的情况见下文。
还有几种类似的操作,它们引起的问题与用有限位集合表示数的具体方式有关,例如:
- 对
INT_MIN或类似负值取负; - 位移操作的第二操作数为负,或者大于或等于第一操作数的宽度;
- 试图把某个位移入有符号整数类型的符号位。
历史上,不同平台处理这类非法操作的策略差异很大。一种十分常见的策略是直接崩溃;最好的策略则是避免它们。
要点 15.1.1 #1
程序执行只应完成在底层类型范围内具有数学定义的算术操作。
毫不意外,在 C 中,“具有数学定义”这一条遇到浮点算术时另有解释。如果平台上有表示“无穷大”的浮点值,那么除以零就有定义(结果为无穷大),所有这类操作都有效。<float.h> 中的宏 INFINITY(C23 以前位于 <math.h>)还可以用来检验是否支持无穷大:
bool const has_inf =
#ifdef INFINITY
(1.0 / 0.0 == INFINITY)
#else
false
#endif
;2
3
4
5
6
7
头文件 <fenv.h>(floating-point environment,浮点环境)提供了若干宏,用来表示除以零等浮点异常条件:
int excepts[] = {
#ifdef FE_DIVBYZERO
FE_DIVBYZERO,
#endif
#ifdef FE_INEXACT
FE_INEXACT,
#endif
#ifdef FE_INVALID
FE_INVALID,
#endif
#ifdef FE_OVERFLOW
FE_OVERFLOW,
#endif
#ifdef FE_UNDERFLOW
FE_UNDERFLOW,
#endif
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
从这些异常的名称就能猜到,并非所有异常都与潜在的算术违规有关,后文还会看到另外一些。这些常量可以当作位集合使用:把它们按位或到一个整数值中,就能管理这些异常条件的组合。
放到交通类比中,无效操作就好比高速行驶时猛踩刹车。如果车不够先进(相当于不提供 INFINITY 的平台),就很可能冲出道路撞上树。如果车上装有防抱死制动系统(ABS),相当于能处理 FE_DIVBYZERO 的平台,也许能侥幸留在路面上。但无论哪种情况,先超速再猛踩刹车都不是好主意。
受支持的浮点异常通常不会导致程序失效;该头文件还提供了 fetestexcept 和 feclearexcept 接口,用来查询或管理这类异常:
void print_except(void) {
char const* name[] = {
#ifdef FE_DIVBYZERO
"divbyzero",
#endif
#ifdef FE_INEXACT
"inexact",
#endif
#ifdef FE_INVALID
"invalid",
#endif
#ifdef FE_OVERFLOW
"overflow",
#endif
#ifdef FE_UNDERFLOW
"underflow",
#endif
};
int except = fetestexcept(FE_ALL_EXCEPT);
if (except) {
printf("[");
for (unsigned j = 0; except; except &= ~excepts[j], ++j) {
if (excepts[j] & except) {
printf("%s ", name[j]);
}
}
printf("]");
}
}
int main(int argc, char* argv[static argc + 1]) {
printf("division by zero is %sequal to INFINITY\n",
has_inf ? "" : "un");
for (unsigned i = 1; i < argc; ++i) {
feclearexcept(FE_ALL_EXCEPT);
double x = strtod(argv[i], nullptr);
printf("%g ", x);
print_except();
feclearexcept(FE_ALL_EXCEPT);
printf(": %g ", 1.0 / x);
print_except();
puts("");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
要点 15.1.1 #2
平台的浮点环境决定哪些浮点操作会造成程序失效。
因此,数值程序若用到可能失效、也可能不失效的浮点操作,其可移植性就确实值得担忧,必须借助特性检验谨慎处理。
另一组可能失效的算术操作作用于指针值。事实上,把指针简单地看作伪装起来的整数,并没有说出全部真相。由于地址空间分段,指针值的加法或比较可能比整数上的类似操作受到多得多的限制。以下情形会出问题:
- 地址与整数相加或相减后越过数组边界;
- 比较没有指向同一数组对象的指针。
即使理论结果表示一个有效地址,操作本身也可能已经无效。
要点 15.1.1 #3
指针操作应当始终停留在数组对象边界以内。
一般来说,要读取数组的某个元素时(无论直接访问还是通过指针访问),最好用数组索引 A[i] 来表达意图,而不是用显式算术 *(A + i)。这样,现代编译器可能更容易捕获无效索引。
要点 15.1.1 #4
只要可能,就使用数组索引,不要组合指针算术与解引用。
还有一种指针算术操作,即指针减法,它同样要求两个指针指向同一个数组对象。在同一次程序运行中,这两个指针值的状态甚至可能前后不同:如果它们恰好位于同一个数组中,就是有效的;如果目标缓冲区后来被回收,二者转而指向不同的数组对象,那么用完全相同的值做同样的操作也可能出错。
与指针算术相似,关系运算符 <、<=、>= 和 > 要求两个操作数都指向同一数组。
指针减法还依赖于类型的表示。如果结果无法放进 ptrdiff_t,操作就是错误的。在某些平台上,ptrdiff_t 所用的整数类型非常受限(例如只有 16 位);在这类平台上,两个指针之间所支持的最大距离是
15.1.2 无效转换
如果某个值在另一种类型(不同于它最初的类型)中没有合理的解释,就会发生无效转换。请记住,如果目标类型是无符号类型,转换总是按模运算进行,结果总有明确定义。对于其他目标类型,则可能出错:
- 从整数类型无效转换到有符号类型。 例如把
UINT_MAX转换成signed。结果由实现定义:有些实现会沿用无符号值的位模式,另一些则可能停止执行。因此,尽管每个实现各自明确定义了这种行为,它仍然不可移植,应当视为错误情形。 - 浮点值与整数之间的无效转换。 浮点值可能超出目标整数类型的范围,大整数值的精度也可能远超浮点类型的处理能力。
- 不同浮点类型之间的无效转换。 精度可能丢失,源值也可能超出目标类型的值域。
- 把指针转换成位数不足的整数。 只有当整数类型比指针类型窄时才会发生。如果目标类型是
uintptr_t(这也意味着该类型存在),转换总是有定义的。 - 把指针转换成另一类型的指针。 源指针可能没有为目标类型正确对齐。
所有这些失效都直接发生在相应操作处,与是否使用转换结果无关。反过来,转换有效也不意味着结果值可以随意使用。例如,指针到指针的转换成功了,并不说明可以通过新指针值访问它所引用的对象。
15.1.3 值违规
错误的值可能导致的另一大类程序失效,出现在调用 C 库函数时。许多函数都有以下概念:
- 调用实参不允许取的无效值,例如空指针、过大的数,或者为分配函数传入零大小;
- 会使所请求操作的结果无法表示的值。
现代编译器和某些静态分析工具可能足够先进,能够检测出其中一些违规;但一般来说,只有仔细阅读规范才能发现这类情形。
15.1.4 类型违规
C 的类型系统相对严格,在大多数情况下,以错误的类型访问对象或函数都是错误的。无论对象还是函数,只有把指针转换成指向另一类型的指针时,才可能发生这类访问。因此,防止这类失效最简单的方法就是避开这些操作。
要点 15.1.4 #1
除非不可避免,否则不要转换指针。
第 12.6 节已经介绍过通过不同类型访问对象的规则。至于调用 C 函数,规则非常简单。
要点 15.1.4 #2
始终按照函数定义所用的原型调用函数。
以不同原型调用函数的一种途径,是先形成函数指针,再把这个函数指针值强制转换成另一种类型。其效果有点像具有特定轨距的铁路系统:如果给机车标上与实际不符的轨距规格,即使煞有介事地把这个谎言写进文件、刷在车头面板上,也改变不了事实。如果两种规格相差不大(比如只差几毫米),机车起初或许还能跑上几米,但一到第一个弯道或道岔就会脱轨。
避免这种情形最简单的方式,是根本不使用函数指针;如果不得不用,绝不要通过强制转换抹掉类型。
要点 15.1.4 #3
通过函数名称调用函数。
还有一种可能:不同翻译单元使用同一个函数名,却给出了不同的原型。这种情况下,只要执行过程碰上对该函数的调用,就会失效。
15.1.5 访问违规
在直接源自不当操作的失效中,访问违规很可能是 C 程序里最常见的一种。具体情形很多,其中大多数错误访问都可能带来灾难性后果。交通中的“禁止驶入”标志是个恰当的类比:驶入一条只靠这种标志把守的道路并不难,也没有任何强制手段直接阻止你,但后果可能极其惨烈。
在 C 中,访问违规通常经由指针或数组发生,因为若非如此,严格的类型系统在编译时就会强制给出诊断。下面的列表说明了用指针编程意味着哪些特殊责任:
- 解引用空指针;
- 访问已经不存在的对象,例如通过悬空指针访问局部对象、访问已经释放的存储,或者访问已经改变的系统对象(如区域设置指针);
- 在未定序的子表达式中修改并读取同一个对象;
- 越界访问紧跟在数组之后的元素;[1]
- 修改不可变对象,例如受
const限定的对象、字符串字面量或临时对象; - 通过非
volatile左值访问volatile对象; - 通过并非基于同一指针的左值,访问以
restrict指针为基础的对象; - 访问原子结构体或联合体的成员;
- 以相互重叠的对象为源进行存储,例如使用
=、scanf或memcpy; - 试图访问没有元素的柔性数组成员中的元素(见第 13.1.3 节);
- 通过带属性的原型访问不具备相应性质的函数;
- 调用
longjmp,而其初始化所在的函数语境已经消亡; - 因计算异常进入信号处理程序后,从该处理程序返回;
- 对已经释放的指针调用
free。
对于所有这些潜在的访问违规,使用者代码应当跟踪允许访问的必要条件;只要可能,就应添加检验,防止访问违规。
15.1.6 值解释错误
如果对象中存储的位模式,在用来访问它的类型下没有已知的有效解释,就会发生值解释错误。C23 称这种状态为不确定表示。
值解释出错最重要的情形,是值根本还没有解释,也就是程序访问了尚未初始化的存储。这可能发生在函数内部定义却未初始化的对象上(见要点 5.5 #1),也可能发生在使用 malloc 而非 calloc 的动态分配中。第 16 章会更详细地讨论这些问题。
另一种情形是,某些对象类型并非所有可能的位模式都表示有效值,这类位模式称为非值表示。[2] 有些标准类型按定义拥有的表示位就多于其值域所需,包括 bool、atomic_flag,以及 N 不是 CHAR_BIT 倍数的 _BitInt(N) 类型。
例如,bool 只有 false 和 true 两个正确值,却至少占 8 位。把最低有效位以外的位设为非零,可能导致布尔值被错误解释。在某些语境中,编译器检验全零位模式可能更方便;在另一些语境中,它可能只检查最低有效位。因此,随意摆弄 bool 对象的表示字节可能造成严重破坏。
要点 15.1.6 #1
不要在 bool 对象中存储 0 和 1 以外的值。
前面没有列出的标准类型(例如浮点类型)也可能有非值表示,不过如今这种情况比较少见。尽管如此,谨慎一些总没有坏处。
要点 15.1.6 #2
不要直接改变对象的表示字节。
15.1.7 显式声明不可到达
C23 引入了一个用来标注潜在失效的新工具:宏 unreachable。调用它就等于断言:任何执行都绝不会到达这条控制路径。考虑下面的函数:
ptrdiff_t ptr_dist(void const* p, void const* q) {
if (!p || !q) {
unreachable();
}
unsigned char const* P = p;
unsigned char const* Q = q;
return P - Q;
}2
3
4
5
6
7
8
这里的条件语句和 unreachable() 调用表明,该函数绝不会收到空指针形参值。遗憾的是,对于 void 指针,目前没有办法在函数原型中补充这一信息,因此完全要靠使用者自己保证这种情况绝不发生。
要点 15.1.7 #1
只有掌握证明时才能使用 unreachable()。
特别是,只要有可能,就尽量不要依靠外部信息来作这种判断。在前例中,更好的做法是使用带边界的数组形参,表明两个指针都不能为空,这还能帮助编译器在调用处判断这一性质:
[[maybe_unused]]
static inline ptrdiff_t ptr_dist_uchar(
unsigned char const p[static 1],
unsigned char const q[static 1]) {
if (!p || !q) {
unreachable();
}
return p - q;
}
#define ptr_dist(P, Q) \
ptr_dist_uchar((void const*){(P)}, (void const*){(Q)})2
3
4
5
6
7
8
9
10
11
12
调用 unreachable,与其他任何让执行进入未定义状态或显式结束执行的操作都不同。其他操作(常见的是除以零)可能由 C 标准以外的实体给出定义,例如编译器实现或操作系统;exit 或 abort 等显式终止程序的操作则另有规定的行为,例如执行清理或引发信号。这些都不等同于告诉编译器“执行绝不会到达这里”。二者的区别,就像把一条街标为死胡同,与这里根本没有街道之间的区别。
要点 15.1.7 #2
只能用 unreachable() 标记绝不会采用的控制路径,不要使用其他操作代替。
还要注意,这里真正的不当操作并不是调用 unreachable 本身,而是之前导致执行走到这里的决策。掉进兔子洞的后果,不该怪到兔子头上。
15.2 程序状态劣化
程序状态劣化比不当操作难处理得多,因为它不能归咎于某一个人或某一个动作,而是多个动作相互作用的结果。遇上堵车时,不该把责任推到前车司机身上;所有车辆都同样促成了这一局面,可能只是车太多,超出了道路的容量。柏林有一座桥,桥下每天都堵车,桥上曾写着:“不是你被堵在路上,你就是堵车本身。”
15.2.1 无界递归
正如第 7.3 节所见,递归是程序状态劣化的一个重要示例。如果对每次调用相比上一次调用取得了什么进展没有清楚的把握,也没有妥善定义递归触底的条件,函数调用所需的资源最终就会耗尽,导致执行崩溃。请始终牢记要点 7.3 #2,以免出现这种情况。
15.2.2 存储耗尽
无界递归通常耗尽的资源,是平台提供函数调用语境的能力。这种资源通常称为栈,因为函数调用语境的增删方式与栈数据结构相似:调用把新语境压入栈,返回则把当前语境弹出。因此,无界递归是栈溢出的一种特殊情形:到了某个时刻,栈的有限资源再也无法提供足够的空间来保存新的执行语境。
另一种栈溢出发生在建立含有变长数组的函数语境时。这时对象大小动态地取决于数组边界,因此很难估计还有多少存储可用,以及程序状态会以多快的速度劣化。不过请注意,这个问题只出现在 VLA 本身,不会出现在其他变长修改(VM)类型上。另一方面,用 VLA 代替大型定长数组,可能大大减少栈的用量。尽管如此,VLA 在 C 社区的部分群体中名声依然不佳;因此,与 VM 类型不同,VLA 在 C23 中仍是可选特性,可以用宏 __STDC_NO_VLA__ 检验。
C 标准没有提供预知栈耗尽的工具,但 C 库的另一套存储系统(通常称为堆)是否耗尽是可以检测的。分配内存的标准函数 malloc、calloc、realloc、aligned_alloc、strdup 和 strndup 在失败时返回空指针。不过,这类失败未必该归咎于这次调用本身,可能只是此前的调用太多了。即便如此,这种情形也好过执行状态悄无声息地劣化,因为它让我们能以受控的方式结束执行。
15.2.3 其他稀缺资源
除了存储,程序执行的运行时环境通常还有其他可能耗尽的稀缺资源:
| 资源 | 预留 | 释放 | 限制 |
|---|---|---|---|
| 调用语境 | va_start、va_copy | va_end | |
| 流 | fopen | fclose | FOPEN_MAX |
| 临时文件 | tmpfile | TMP_MAX | |
| 文件 | fopen、freopen | remove | |
| 线程语境 | thrd_create | thrd_join、thrd_detach | |
| 互斥 | mtx_init | mtx_destroy | |
| 条件量 | cnd_init | cnd_destroy | |
| 线程专属存储 | tss_create | tss_delete |
如果一直不调用“释放”一栏中列出的函数,这些资源的占用就会不断累积。注意,fopen 会预留两种不同的资源:一条流(以返回的指针表示)和一个文件(由第一个实参指明)。大多数预留资源的函数都有失败模式,借此可以检测资源是否耗尽。遗憾的是,其中的 va_start 和 va_copy 不提供这一信息。
15.3 不幸事件
不当操作和资源耗尽造成的失效(至少在理论上)可以在局部捕获;不幸事件则不同,它们源于时间或空间上相距甚远的若干事件恰好以某种方式凑在一起,导致执行失效。这类事件一般很少发生,而且难以追踪。
碰撞是这类失效的一个很好的交通类比。在一个没有交通信号灯、也没有空中交通管制员的世界里,两辆汽车或两架飞机各自独立规划路线,可能会在离规划地点很远的地方相撞。由于双方都看不到拐角后面(或者恰好处在对方的盲区里),各自都以为资源并不拥挤。等接近危险区域(交叉口)时,再作调整可能已经来不及,最终撞在一起。
15.3.1 状态劣化升级
最常见的不幸事件之一,其实发生在状态已经劣化之后。例如,如果栈或堆已经耗尽,程序执行却仍在继续,程序状态就会进一步劣化,导致系统行为失常,甚至可能危及当前执行范围之外的事物。
这就好比汽车的制动故障指示灯亮了。此时减速,不仅可能挽救你自己和同车乘客的生命,还可能保护恰好处在车辆行进路线上的无辜路人。做个负责任的公民吧。
15.3.2 碰撞与竞争条件
修改对象时,C 程序通常无须顾虑程序执行的其他部分同时改动该对象。这一性质的基础是定序(见第 4.6 节)。重要的是,不要对编译器要求过多。
要点 15.3.2 #1
不要在同一个算术表达式中读取并修改同一个对象。
带副作用的子表达式是典型示例,前文已经把它们列为不当操作:
printf("%lld\n", x++ + x); // 不要这样做!如果这没有被检测为失效,更新对象 x 的操作就可能发生在第二次读取 x 之前、之后,或者与之同时。因此,打印出的值可能是初始值的两倍、初始值加一,或者某个怪异的值,其高位字和低位字分别来自计算的不同阶段。问题在于,表达式可能很复杂,编译器未必能发现未定序访问。不过,只要通过对象名称来使用对象,这类失效就仍然只是某项不当操作的直接结果。
涉及指针时,情况更加复杂:
printf("%lld\n", (*p)++ + (*q)); // *p 和 *q 是否不同?p 和 q 可能碰巧相等,这时 *p 和 *q 引用的是同一个对象。因此,在代码的某个角落给 p 设了一个特定值,又在完全不同的地方设置了 q,就可能在第三个毫不相干的地方造成致命后果。第 12.3 节和第 19.2 节会给出更多细节。
原则上,前面的情形仍然可以通过事先检验 p == q 来防范,从而避开未定序访问。但还有一些类似情形无法检验,这就是所谓的竞争条件。当信号处理程序(见第 19.6 节)和被它中断的代码访问同一个对象时,就可能发生竞争。信号处理程序对对象的访问,与程序执行的其余部分之间是未定序的;因此,这类访问必须满足特殊条件、采用特殊机制才有效。不同执行线程并发访问同一个对象时,也会出现类似问题(第 20 章)。
15.3.3 不恰当的库调用和宏调用
C 库中的某些函数只允许在特定语境中调用。例如,多线程程序中不允许调用 signal。在某些情况下,使用了 signal 的函数可能被链接进使用线程的程序;这时,如果该程序收到信号,整个执行过程都可能陷入危险。
另一个例子是第 19.5 节将详细介绍的宏 setjmp,它只能出现在表达式中少数几个特定位置。对于自己无法处理的 setjmp 调用位置,高质量的实现很可能会给出诊断,较简陋的实现却未必如此。因此,为了可移植性,最稳妥的做法是遵守 C 标准施加的限制。
15.3.4 死锁
当多个实体试图访问资源,却陷入循环依赖链时,就会发生死锁失效。在道路交通中,环岛就是一个绝佳的例子:它是一种解决资源冲突的策略,但也可能出错。用它来解决局部资源冲突很有吸引力:只要进入环岛的车辆优先级低于已在环岛内的车辆,就不会因争用环岛内某个位置而产生竞争条件。如今欧洲各地随处可见环岛,很可能正是因为它能以较低的成本避免资源冲突(以及由此引发的撞车)。
车流不大时,这一策略效果很好;可惜在严重拥堵时,它会彻底失灵。如果环岛里挤满了要从下一个出口驶出的车辆,所有车都动弹不得,也就无法取得任何进展。因此,在交通严重拥堵的环境(例如城市)中,通常很少见到环岛,取而代之的是由交通信号灯控制的交叉口。
幸运的是,用 C 编程时,死锁只会出现在多线程语境中(见第 20 章)。要证明某个多线程程序不会死锁是一项艰巨的任务,在本书的范围内无法令人满意地解决。不过,第 20.7 节会通过对一个具体示例的情况分析,给出一些思路。
15.4 一连串不幸事件
有一种格外棘手的失效:程序执行在一个有限的状态集合上无休止地循环,却没有任何可见的进展。
要点 15.4 #1
如果程序执行在有限状态集合上循环,而且没有可观察副作用,它就已经失效。
这有点像飞机在风暴眼中盘旋。眼下的处境看起来并不特别危险,但在外界看来,这架飞机可能已经算作失踪。那场不可避免的坠毁究竟发生在过去还是将来,确切时刻并不重要,因为我们再也见不到这架飞机了。
第 5.7.5 节已经见过一个可能无法取得足够进展的循环。下面稍微修改那个示例,更仔细地看看各种可能:
void obscure(unsigned);
for (unsigned i = 0; true; ++i) {
obscure(i);
}
unreachable();2
3
4
5
6
这个循环是否失效,取决于函数 obscure。仅凭现有的规格说明,编译器无法判断这个循环是否会系统性失效。视 i 的值而定,该函数可能产生可见的副作用,例如:
- 执行某些输入输出;
- 改变某些全局状态;
- 调用
exit。
所以,这个 for 循环虽然没有明显的终止条件,却不能据此断定它必然失效。只有掌握了关于该函数的附加信息(例如函数标注了 [[unsequenced]] 属性),编译器才可能作出这种假定,进而给出诊断。否则,程序员就必须保证 obscure 至少对 i 的某一个值取得进展。无论如何,循环只能通过退出整个执行过程来终止,unreachable 调用永远不会被执行到。
若把 i 的类型从 unsigned 改成 signed,它甚至不再是无限循环:
for (signed i = 0; true; ++i) {
obscure(i);
}
unreachable();2
3
4
编译器可以假定:程序会在 i 的某个正值处终止;否则,当 i 等于 INT_MAX 时就会发生算术异常。
无界递归也可能是程序缺乏进展的一种特例。事实上,对于某些递归函数,编译器可能发现无须为每次调用单独保存状态;于是,对这种函数的无界递归调用就可能被替换成一个不再额外分配资源的循环。与循环的情形类似,好的编译器这时可能会警告:该递归调用会导致系统性的程序失效。
多线程程序还可能因一连串不幸事件而发生更复杂的失效:活锁。它的效果与前面讨论的死锁相似(基本上看不到任何事情发生),但活锁中程序各组成部分之间的交互更复杂。
可以拿一个大型交通系统来类比,其中的绕行路线构成了一个循环。设想城市某个街区四角的四个交叉口,每处都设置了绕行。单看每一处绕行似乎都是好主意,合在一起却形成了陷阱:驶到任一交叉口的车辆都会永远绕着这个街区转圈。即使只有一辆车,每一刻看起来交通都在正常推进,可这辆车永远逃不出这个旋涡。
这只是一个非常简单的活锁示例。实际情况可能涉及多辆车,绕行路线也可能把它们引向全城各处。根据车辆到达的先后和行驶的快慢,它们甚至可能在某处交换次序或改走不同路线,却始终无法脱身。这类情形要难发现、难预防得多。第 20.7 节的情况分析将展示怎样针对一个实际案例证明不存在活锁。
15.5 应对失效
C 程序失效可能有各种迹象,有时也可能完全不被察觉。事实上,应对失效最简单的办法就是避免失效。
要点 15.5 #1
保证可能失效的操作满足全部前置条件。
也就是说,第 15.1 节列出的所有不当操作,都不应在正常的程序执行中出现。应当检验前置条件,并在不满足时执行替代操作。要保证所有前置条件或许并不总是容易,但总体策略应当如此。
同样,一般也应当检测程序状态劣化,只不过这一次只能在事后检测。
要点 15.5 #2
可能耗尽资源的操作,应当检查其返回值是否表示错误。
这种错误通常有两类指示:C 库函数的错误返回(见第 8.1.3 节),以及 errno 的非零值。第 15.6 节将介绍处理这些错误的策略。
不幸事件则要难处理得多。就其本性而言,几乎没有什么迹象可以用来察觉这种局面。不过,现代系统有一些特性,可以限制对系统造成的损害:
- 信号是硬件与软件特性的一种古老而晦涩的混合体,它会在某个位置中断程序执行,并允许使用者提供后备代码,随后执行这些代码来挽救局面(见第 19.6 节)。
- 陷阱与之类似,也会立即中断执行流。但与信号不同,执行会直接结束;至于是否至少做一些清理,在很大程度上取决于系统。
- C 为程序执行提供了终止函数
exit、quick_exit、_Exit和abort,第 8.8 节已经介绍过。清理的程度取决于具体函数:从调用使用者定义的清理函数(exit和quick_exit)到几乎不做任何清理(abort)不等。 - 同样,可以用
thrd_exit单独终止线程,第 20.6 节会介绍。 - 最后,发生拜占庭失效后,执行仍会继续,系统状态却不断恶化。这时无法捕获失效;整个系统可能遭到严重破坏,敏感信息可能丢失,甚至可能在现实世界中触发灾难性的动作。
要点 15.5 #3
只有谨慎设计算法,才能避免不幸事件。
15.6 错误检查与清理
在检验操作的前置条件或 C 库调用的结果是否有效时,C 程序可能遇到许多错误条件。错误可能来自程序设计错误、编译器或操作系统软件的缺陷、硬件错误、资源耗尽(例如内存不足),或者这些因素的任意恶意组合。要让程序可靠,就必须检测这些错误条件并妥善处理。
先来看函数 fprintnumbers 的说明,它是第 14.1 节所讨论的那一系列函数的延续。该函数区分四种错误条件,通过返回负的常量值来表示。这些值对应的宏一般由平台在 <errno.h> 中提供,并且都以大写字母 E 开头。遗憾的是,C 标准只强制要求提供 EOF(负值),以及 EDOM、EILSEQ 和 ERANGE(正值)。
fprintnumbers
使用 printf 格式 form,在流 stream 上打印 nums 中的一系列数;各数之间以字符 sep 分隔,并以换行字符结束。
成功时返回写入 stream 的字符数,出错时返回负错误值。若 len 为 0,打印空行并返回 1。
可能返回:
- 若
stream尚未准备好写入,返回EOF(负值); - 若需要写入超过
INT_MAX个字符,包括len大于INT_MAX的情况,返回-EOVERFLOW; - 若
stream是空指针,返回-EFAULT; - 若
nums是空指针且len非零,返回-EFAULT; - 若发生内存错误,返回
-ENOMEM。
该函数使 errno 保持进入函数时的值。
int fprintnumbers(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict len]);2
3
4
5
其他错误值,平台不一定提供。因此,代码开头用一系列预处理语句为缺失的值提供默认定义,并保证这些宏的值互不相同:
#include <errno.h>
#ifndef EFAULT
#define EFAULT EDOM
#endif
#ifndef EOVERFLOW
#define EOVERFLOW (EFAULT - EOF)
#if EOVERFLOW > INT_MAX
#error EOVERFLOW constant is too large
#endif
#endif
#ifndef ENOMEM
#define ENOMEM (EOVERFLOW + EFAULT - EOF)
#if ENOMEM > INT_MAX
#error ENOMEM constant is too large
#endif
#endif2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
清单 15.1:打印数值数组
int fprintnumbers(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict len]) {
if (!stream) {
return -EFAULT;
}
if (len && !nums) {
return -EFAULT;
}
if (len > INT_MAX) {
return -EOVERFLOW;
}
size_t tot = (len ? len : 1) * strlen(sep);
int err = errno;
char* buf = nullptr;
if (len) {
/* 计算各个数所需字符数。 */
for (size_t i = 0; i < len; ++i) {
tot += snprintf(nullptr, 0, form, nums[i]);
}
/* 返回类型是 int,所以必须限制最大大小。 */
if (tot > INT_MAX) {
return error_cleanup(EOVERFLOW, err);
}
}
buf = malloc(tot + 1);
if (!buf) {
return error_cleanup(ENOMEM, err);
}
sprintnumbers(tot, buf, form, sep, len, nums);
/* 一次打印整行。 */
if (fputs(buf, stream) == EOF) {
tot = EOF;
}
free(buf);
return tot;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
在整个函数的实现中,错误处理几乎占了大部分工作量。开头三段处理进入函数时就已存在的错误,它们反映了未满足的前置条件,用附录 K(见第 8.1.4 节)的话说,就是运行时约束违规。
动态的运行时错误更难处理一些。特别是,C 库中的某些函数可能通过伪对象 errno 报告错误条件。如果想捕获并修复所有错误,就必须避免改动执行的任何全局状态,包括 errno。做法是在进入函数时保存它的当前值,出错时再调用小函数 error_cleanup 恢复:
static inline int error_cleanup(int err, int prev) {
errno = prev;
return -err;
}2
3
4
函数的核心部分用 for 循环遍历输入数组,计算应当打印的总字节数。循环体以空指针和大小为 0 的缓冲区调用 snprintf,算出每个数所需的大小。随后用第 14.1 节的 sprintnumbers 生成一个大字符串,再通过 fputs 打印出来。
请注意,成功调用 malloc 之后就再没有错误出口了。即使 fputs 返回时检测到错误,也只是把这一信息存入 tot,并不会跳过 free 调用。因此,即使发生输出错误,已分配的内存也不会泄漏。这里处理可能的输入输出错误相对简单,因为 fputs 调用离 free 调用很近。
函数 fprintnumbers_opt 则需要更加小心。它试图进一步优化处理过程,直接打印数值,而不再预先统计所需的字节数。这样在执行过程中可能遇到更多错误条件,处理它们时必须保证最后一定会调用 free。第一个这样的条件是初始分配的缓冲区太小:如果用 realloc 扩大缓冲区失败,就必须小心地退出。遇到总字符串长度超过 INT_MAX 这种不太可能发生的情况,也是如此。
清单 15.2:打印数值数组的优化版本
int fprintnumbers_opt(FILE* restrict stream,
char const form[restrict static 1],
char const sep[restrict static 1],
size_t len,
size_t nums[restrict static len]) {
if (!stream) {
return -EFAULT;
}
if (len && !nums) {
return -EFAULT;
}
if (len > INT_MAX) {
return -EOVERFLOW;
}
int err = errno;
size_t const sep_len = strlen(sep);
size_t tot = 0;
size_t max_tot = len * (sep_len + 10);
char* buf = malloc(max_tot);
if (!buf) {
return error_cleanup(ENOMEM, err);
}
for (size_t i = 0; i < len; ++i) {
tot += sprintf(&buf[tot], form, nums[i]);
++i;
if (i >= len) {
break;
}
if (tot > max_tot - 20) {
max_tot *= 2;
char* new_buf = realloc(buf, max_tot);
if (new_buf) {
buf = new_buf;
} else {
tot = error_cleanup(ENOMEM, err);
goto CLEANUP;
}
}
memcpy(&buf[tot], sep, sep_len);
tot += sep_len;
if (tot > INT_MAX) {
tot = error_cleanup(EOVERFLOW, err);
goto CLEANUP;
}
}
buf[tot] = 0;
/* 一次打印整行。 */
if (fputs(buf, stream) == EOF) {
tot = EOF;
}
CLEANUP:
free(buf);
return tot;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
在这两种出错情况下,函数都用 goto 跳到清理代码,再由清理代码调用 free。这是 C 中一种久经考验的技术:既能保证执行清理,又能避免难以阅读的嵌套 if-else 条件。goto 的规则相对简单。
要点 15.6 #1
goto 的标签在包含它的整个函数内可见。
要点 15.6 #2
goto 只能跳到同一个函数内的标签。
要点 15.6 #3
goto 不应跳过对象初始化。
从 Dijkstra [1968] 的文章开始,程序设计语言中 goto 及类似跳转的使用就一直备受争议。今天仍有人强烈反对这里给出的写法,但我们不妨务实一些:无论用不用 goto,代码都可能丑陋难懂。核心思路是尽量不打乱函数的“正常”控制流,并用 goto 或 return 清楚地标出只在异常情况下才发生的控制流变化。
第 19.5 节还会介绍 C 中另一种能更剧烈地改变控制流的工具:setjmp/longjmp。借助它,可以跳到调用方函数栈上的其他位置。
本章小结
- 程序失效可能源自不当操作、程序状态劣化或不幸事件。
- 谨慎检查前置条件,可以避免潜在的不当操作。
- 对可能导致状态劣化的 C 库函数调用,应检查错误返回。
- 不幸事件没有补救办法;必须通过谨慎的程序设计保证它们不会出现。
- 处理错误条件可能导致复杂的情况分析。可以为每个函数设置专门的清理代码块,并通过
goto语句跳转到该处,从而组织这些逻辑。