16.1什么是简单?
创建一个完美的API就像犯下一件完美的罪行。至少有50件事可以发生
错了,如果你是个天才,你可能能预见到其中的25个。
向可能还活着的凯瑟琳·特纳的粉丝们道歉。
当有人说“我想编程
我只需要说我想做的事,”给他们一颗棒棒糖。
Alan J. Perlis,更新
如果你倾向于轻视易用性要求,请考虑Linux内核RCU中的一个易用性错误导致了使用RCU时的可利用内核安全漏洞[McK19a]。因此,即使是在内核中的API也必须易于使用这一点显然非常重要。
不幸的是,“简单”是一个相对的概念。例如,许多人会觉得15小时的飞机飞行有点煎熬——除非他们停下来考虑其他交通方式,特别是游泳。这意味着创建一个易于使用的API需要你足够了解你的目标用户,知道对他们来说什么是简单。这可能与对你而言什么是简单无关。
以下问题说明了这一点:“在今天活着的所有人中随机选择一个人,有什么改变可以改善那个人的生活?”
没有一项单一的改变能够保证帮助所有人的生活。毕竟,人们有着极其广泛的需求、愿望和抱负。一个饥饿的人可能需要食物,但额外的食物可能会加速一个病态肥胖者的死亡。许多年轻人热切渴望的高度兴奋,对正在从心脏病发作中恢复的人来说可能是致命的。对某人成功至关重要的信息,可能会导致因信息过载而受苦的人失败。简而言之,如果你正在开发一个旨在帮助你完全不了解的人的软件项目,当这些人对你的项目提出批评时,你不必感到惊讶。
如果你真的想帮助某个特定群体,长期与他们紧密合作是无可替代的,这可能需要数年时间。然而,你可以做一些简单的事情来提高用户对软件满意的可能性,这些内容将在下一节中讨论。
因此,找到合适的测量方法不是数学上的练习,而是一种冒险的判断。
彼德·德拉科
本节内容改编自Rusty Russell 2003年渥太华Linux研讨会主题演讲的部分内容[Rus03,幻灯片39-57]。Rusty的核心观点是,目标不仅是要让API易于使用,更重要的是要让API难以被误用。为此,Rusty提出了他的“Rusty量表”,按这一重要不易误用属性的递减顺序排列。
以下列表试图将Rusty Scale推广到Linux内核之外:
1.不可能出错。尽管这是所有API设计者都应该努力达到的标准,但只有神话般的dwim()1命令才能接近这个标准。
2.编译器或链接程序不会让你出错。
3.如果编译器或链接程序出错,它会警告你。BUILD_BUG_ON()是用户的朋友。
4.最简单的用法就是正确的用法。
5.名称告诉您如何使用它。但是,名称可能是一把双刃剑。尽管对于从读写锁定转换代码的人来说,rcu_read_lock()非常简单,但对于从引用计数转换代码的人来说,它可能会引起一些困扰。
6.如果做不好,它在运行时就会崩溃。WARN_ON_ONCE()是用户的朋友。
7.遵循常见惯例,你就能做对。`malloc()`库函数就是一个很好的例子。尽管内存分配很容易出错,但许多项目确实能够做到正确,至少大多数时候是这样。结合使用`Valgrind`[The11]和`malloc()`,几乎可以将`malloc()`的错误率降到“要么正确执行,否则运行时总会出问题”的地步。
8.阅读文档,你就会做对。
9.阅读实现,你就会做对。
10.阅读正确的邮件列表存档,你就会得到正确的答案。
11.阅读正确的邮件列表存档,你就会弄错。
12.阅读实现,你就会犯错。rcu_read_lock()最初的非CONFIG_抢占实现[McK07a]是这个例子的典型代表。
13.阅读文档,你就会出错。例如,DEC Alpha的wmb指令文档[Cor02]曾误导许多开发人员,让他们误以为该指令的内存顺序语义比实际情况要强得多。后来的文档澄清了这一点[Com01,Pug00],将wmb指令提升到了“阅读文档,你就会做对”的级别。
14.遵循常见的惯例,你就会犯错。在这一尺度上,printf()语句就是一个例子,因为开发人员几乎总是没有检查printf()的错误返回值。
15.如果做对了,它会在运行时崩溃。
16.名字告诉你如何不使用它。
17.明显的用法是错误的。Linux内核中的smp_mb()函数就是一个例子,说明了这一点。许多开发人员认为这个函数具有比实际更强的顺序语义。第15章包含了避免这种错误所需的信息,Linux内核源代码树的文档和工具/内存模型目录也是如此。
18.如果编译器或链接程序正确地处理了它,它们会警告你。
19.编译器或链接程序不会让你得到正确的结果。
20.不可能完全正确。gets()函数是这个尺度上的一个著名例子。事实上,gets()可能最好被描述为一个无条件的缓冲区溢出安全漏洞。
简单并不先于复杂,
但要遵循它。
艾伦·珀利斯
这套有用的程序类似于曼德布罗集(见图16.1),因为它没有明确的平滑边界——如果有,停机问题就能解决。但我们需要的是普通人可以使用的API,而不是每个潜在用途都需要完成博士论文才能使用的API。因此,我们“修剪了曼德布罗集”,将API的使用限制在一个易于描述的子集中。
这种剃须似乎适得其反。毕竟,如果一个算法有效,为什么不用呢?
要了解为什么至少需要一些剃须操作,可以考虑一种锁定设计,这种设计避免了死锁,但可能是最糟糕的方式。该设计使用了一个循环双向链表,其中包含系统中每个线程的一个元素以及一个头部元素。当新线程被创建时,父线程必须将新元素插入到这个列表中,这需要某种形式的同步。
保护列表的一种方法是使用全局锁。但是,如果频繁创建和删除线程,这可能会成为瓶颈。另一种方法是使用哈希表并锁定各个哈希桶,但按顺序扫描列表时,这种方法的性能可能较差。
第三种方法是锁定单个列表元素,并要求在插入时同时持有前驱和后继的锁。由于需要获取两个锁,我们需要决定以何种顺序来获取它们。两种传统的方法是按地址顺序获取锁,或者按照列表中出现的顺序获取锁,这样当头部作为被锁定的两个元素之一时,总是先获取头部的锁。然而,这两种方法都需要特殊的检查和分支。
要实现的剃须解决方案是无条件地按列表顺序获取锁。但是,死锁怎么办?
不会发生死锁。
要实现这一点,从零开始给列表中的元素编号,起始为表头,结束于列表最后一个元素(即表头前的一个元素,因为列表是循环的)。同样地,将线程从零到N - 1进行编号。如果每个线程都尝试锁定一对连续的元素,至少有一个线程能够同时获得这两个锁。
为什么
因为没有足够的线程来完成整个列表的访问。假设线程0获取了元素0的锁。要被阻塞,其他线程必须已经获取了元素1的锁,因此我们假设线程1已经这样做了。同样地,对于线程1来说,要被阻塞,其他线程必须已经获取了元素2的锁,依此类推,直到线程N - 1,它获取了元素N - 1的锁。对于线程N - 1来说,要被阻塞,其他线程必须已经获取了元素N的锁。但没有更多的线程了,所以线程N - 1无法被阻塞。因此,死锁不会发生。
那么,为什么我们要禁止使用这个令人愉快的小算法呢?
事实上,如果您真的想要使用它,我们无法阻止您。但是,我们可以建议不要将这样的代码包含在我们关心的任何项目中。
事实上,这个算法极其专门化(仅适用于特定大小的列表),并且相当脆弱。任何意外未能向列表中添加节点的错误都可能导致死锁。实际上,仅仅是在稍晚一点时添加节点就可能引发死锁,增加线程数量也是如此。
此外,上述其他算法都是“良好且充分”的。例如,按地址顺序获取锁既简单又快速,同时允许使用任意大小的列表。只是要注意空列表和仅包含一个元素的列表带来的特殊情况!
总之,我们使用算法并不是因为它们碰巧有效。相反,我们会选择那些足够有用、值得学习的算法。算法越复杂难懂,它必须越普遍有用,才能让学习和修复其错误的过程变得有价值。
除例外情况外,我们必须继续削减软件“曼德布罗集”,以便我们的程序保持可维护性,如图16.2所示。