Unix昔日之魂(二):合并设计
2010年11月4日.本文由Neil Brown提供
在本系列的第一篇文章中,我们从阐释Linux和Unix系统的“完整开发”模式着手,开启了我们对于设计模式的历史探索,并且这种“完整开发”模式为Unix的系统实力做出了超凡贡献。在本次第2部分中,我们将探索这三种模式中第一个以设计决策为特征但又成效不佳的模式。
事实上,这些设计决策至今仍为我们所用并且依然值得探讨,这表明它们的缺陷之处并不随时随地的显而易见,此外,由于这些设计决策持续存在了足够长的时间以至于它们已经变得根深蒂固,简单地替换它们会导致弊大于利。对这些类型的设计问题而言,提前预警至关重要。对这些模式的研究只能服务于帮助我们尽早避免类似的错误。如果这些对这些模式的研究只能让我们认识到哪些是无法避免的错误,那么深入研究则会完全没有任何作用。
这三种模式按照以下的顺序排列,从最能提供预测力的模式到对提前预警价值最小的模式。但乐观的是,最终意见并不会是绝望之至的结果,毕竟任何关于未来的指引都聊胜于无。
本周探讨的模式使用了两个目前都是在早期Unix系统中的设计决策,并且紧随其后的是一系列解决随之产生的问题的补丁。通过了解这些必需补丁的潜在原因,我们希望可以避免未来仍需要补丁的设计。第一个设计决策来自于在第一篇文章中所实现的单一命名空间。
实现单一命名空间的核心工具是挂载指令,它使得磁盘驱动器中的内容可以作为文件系统使用,并且将该文件系统附加到已有的命名空间中。这个设计例证了这种模式的缺陷在于对“and”这个单词的描述。挂载指令在同一条指令中分别执行两个独立的动作。首先它使存储设备的内容作为文件系统出现,其次它绑定了文件系统和命名空间。这两个步骤必须同时完成,并且无法分离。同样的,卸载命令执行两个反向操作动作,从命名空间中解绑并关闭文件系统。这两者紧密结合起来,如果其中一方由于某种原因失败,另一方也将不会奏效。
起初把这两个操作完美结合在一起似乎看起来十分自然,而且将它们分离则显得毫无意义。然而,历史情况表明事实并非如此。相当大的努力已经付诸于将这些操作进行彼此分离。
自从发布于2011年的2.4.11版本以来,Linux系统有“lazy”的卸载版本。它可以将文件系统从命名空间中解绑而无需同时关闭文件系统,这在一定程度上分裂了原始卸载的两方面功能。“lazy”卸载方式在文件系统由于某种原因失效时尤其有用,一个常见的例子是来自于服务器已经无法使用的NFS文件系统。当在文件系统中有打开文件的程序时是不可能关闭文件系统的,因为很可能是在程序和文件系统上打开的文件。但至少在lazy卸载时,它可以删除命名空间让新的程序不会在试图打开新文件时卡住。
除了一键卸载方式,Linux系统开发人员发现它在增添bind mounts和move mounts 时非常有用。这允许单一命名空间的一部分被绑定到命名空间的另一部分(所以它会出现两次)或文件系统从一个位置移动到另一个位置——实现有效地bind mount并且lazy unmount紧随其后。最后我们会得到一个pivot_root()系统命令,这在两个文件系统之间执行一个稍微复杂的跳跃,以第一个成为根文件系统和第二个成为正常的挂载文件系统开始,以第二个成为根文件系统和第一个成为另外的挂载系统结束。
这似乎把所有关于两个功能合并成一个mount操作的问题都已经在自然发展过程中解决了,但这难以令人信服。我们现在所拥有的命名空间操作函数集是十分特别的,因此,虽然它似乎满足了当前需求,但它在任何意义上都绝对不是完美的。这种不完整的提示线索在事实中可以看到,一旦你执行一个一键卸载,文件系统很可能仍然存在,但是已经无法对其进行操作,因为它在完整的命名空间中已经没有了名字,然而所有当下的操作都需要一个命名。这使得在一键卸载之后很难执行强制卸载。
为了看看一个完整的交互界面是什么样子的我们需要使用一些在上周所探讨过的设计概念:“一切事物都有一个文件描述符与之对应。”假若这种模式已经被加入到挂载系统上,我们很可能会得到以下内容:
仅返回一个文件描述符的filesystem mount call。
一个链接文件描述符到命名空间的bind call。
一个断连文件系统的unmount call并且返回文件描述符的值。
这组简单的设置将会很容易以一种自然的方式提供我们现有的所有函数。例如目前由专用pivot_root()系统命令提供的函数可以实现上述最多添加的fchroot(),一个显然是对fchdir()和chroot()的模拟函数。
Unix系统的众多优点之一——尤其伴随内核的工具集是创建和结合工具的重要原则——每个工具都应该做一件事并把它做好。这些工具可以以多种方式组合在一起,通常为了达到工具开发人员无法预见到的目的。不幸的是,同样的原则在mount()system call中却无法维持。
因此,这个模式在某种程度上完全是与工具方法截然相反的。它需要一个比这更好的名字,不过,一个不错的选择似乎是称之为“合并设计”。一个字典(PJC)将“合并”定义为“忽视彼此之间的差别,把两个或两个以上完全不同的对象或想法结合成一个”,这用来总结这种模式似乎很好。
我们的第二个合并设计的例子可以在open( ) system call中体现。这种系统命令(在Linux系统中)用13不同的标记修改其行为、添加或删除函数——因此多个概念结合在了一个相同的系统命令中。大多数的这种组合并不意味着一个合并设计。一些标记可以用F_SETFL选项到fcntl()函数来独立设置或者清除open()。因此通常在把它们结合时,它们很容易分离,所以并不需要考虑合并。
在当前的内容中,open( ) system call的三个元素值得特别关注,它们是O_TRUNC,O_CLOEXEC和 O_NONBLOCK。
在早期的Unix系统版本中共计包含7级,并且以O_TRUNC开头的是截断文件的唯一途径,结果是,文件只会被截断为空。部分截断是不可能的。截断本质上与open( )紧密相连,实际上是应该避免的一种合并设计,而且幸运的是,这种合并很容易被识别。BSD Unix系统中引入了ftruncate( )系统命令,这使得可以在文件被打开后再进行截短,除此以外,它允许任何大小的任意值,包括大于当前文件大小的新值。由此,该合并可以被轻松解决。
O_CLOEXEC则有更微妙的故事可言。exec( )系统命令(触发一个进程终止正在运行的程序后运行另一个程序)的标准行为是在运行exec( )前后所有的文件描述符均处于可用状态。这种行为也可以被改变,分别从创建文件描述符的open( )命令和fcntl( )命令里进行改变。长久以来这似乎是一个令人十分满意的安排。
然而线程的出现,即多个程序可以共享其文件描述符,当线程或者程序打开一个文件时,所有该组内的线程均可以看到该文件的描述符,这在无形中让位于另一个潜在的族类。如果一个程序出于立即设置close-on-exec 标志的意图打开了一个文件,另一个程序执行exec( )(它导致文件表不再共享),则第二个进程中的新程序将继承它本不应该继承的文件描述符。为了应对这个问题,最近新加的O_CLOEXEC 标志会导致close-on-exec自动打开和open()标记文件描述符,这使得在此过程中不会有所遗漏。
或许有人会认为,创建一个文件描述符和允许它通过exce()被保存应该是两个独立的操作步骤。也就是说,默认情况下无法在执行exce()时保存文件描述符,而且需要下特殊命令才能保存文件描述符。然而,在初次设计open()时就能预见到线程所存在的问题将会远出于我们期待的能力之外,并且在增加共享文件表功能时就已经考虑到open的效果会如何,这一问题也会难以回答。
O_CLOEXEC例子的主要意义在于它让人们承认,在早期识别合并设计是非常困难的,这将会极大的鼓励我们把更多的精力放在回顾设计中的各种问题上。
第三个标志即O_NONBLOCK。这个标志本身就是合并的,同时也显示出和open()合并的特征。在Linux系统中,O_NONBLOCK有两个尽管表明上看起来相似,但实际上完全独立的作用。
“首先”
O_NONBLOCK影响文件描述符所有的读写操作,在程序中没有所需数据时会立即返回该值,或者根本不返回任何值。这个函数可以分别用fcntl()来进行开启和关闭,因此它并没有多大意义。
O_NONBLOCK的另一功能是使得open()本身不会受阻。各种各样不同的效果取决其于所处的环境。当打开一个已命名的管道进行写入时,open()将会失效而不是在没有读取内容时受阻。当打开一个已命名管道进行读取时,open()就会有效而不是受阻,而且如果有程序试图在管道上写入内容时就会返回错误提示。在只读光盘上O_NONBLOCK打开只读也会有效,但是将不会执行磁盘检查,并且没有进行读取也是可能存在的。文件描述符只能用于ioctl()命令,比如检查媒介现状或打开和关闭光盘的托盘。
最后提供了合并open()另一方面的线索。在概念上,指派参阅文件的文件描述符和使该文件为I/O系统备用是两个独立的操作。把这二者结合并均包含在同一系统命令中也确实会有意义。但正是要求把它们结合在一起才是问题产生的根源。
如果可以获得给定文件或设备的文件描述符,但并没有对该文件触发任何行为并且随后要求该文件能为I/O系统备用,那么一系列小问题将会得以解决。特别的,在执行检查时会有各种各样可能的事情,而文件和打开文件是及其特别的一种类型。如果文件在这两种操作之间被重新命名,程序的打开命令就会产生无法预料的后果。精确创建的O_DIRECTORY标志是为了避免这种事情的产生,但它只有在程序打开一个目录时才有效。如果打开文件的两个阶段可以被轻易分离,那么这种事情就可以被普遍轻松地避免。
在该事件中和创建网络连接的API接口时可以看到一个强大的并行。接口在创建时被完全初始化,之后在接口最终连接之前都可以被调整(比如用bind()或者setsockopt( ))。
在文件和接口的例子中,能在连接生效之前创建或者修改连接的各个方面是具有价值意义的。但是用open()函数事实上通常无法实现两个步骤的分离。
这里值得注意的是,将flag赋值为3(通常是一个无效值)打开一个文件,由于没有特定的读写请求,这有时和O_NONBLOCK会有类似的作用。显然开发人员看到了这种需要,但是我们仍然没有一个统一的途径来确定文件描述符没有造成任何设备访问,也没有从无读写访问到有访问的文件描述符的升级方式。
正如我们所看到的,大多数由合并设计引发的困难中,至少会存在上述两个问题例证,并且也已经随着时间得以解决。可能会有这样的议论,由于总有那么点持续性苦恼的存在,这种模式也无法被严肃以待。但这种议论会错失两个重要点。首先,它们已经多年以来都存在这种苦恼,这或许会使得用户在使用整个系统时受挫,并减少对Unix生态系统的投资成长。
“其次”
尽管最糟糕的问题已经在很大程度上得以修复,但最终结果还是没有变成本可以达到的灵巧和简洁。在我们的探索过程中可以发现,仍然有一些函数元素并没有被分离出来。很大一部分原因是由于并没有对这种分离的确切需要。然而,我们通常会发现,一旦该函数处于可用状态,使用某特定函数元素就只能呈现它本身。因此,在没有把所有元素清晰的分离之前,我们或许会并没有发现自己错失了一些非常有用的工具。
毫无疑问,在诸多关于Unix或者Linux系统设计的其他领域中,会有多样化的概念被整合为一个简单的操作,但是意义并不是在于枚举出所有Unix系统的缺陷,而是为了证明,有时甚至在没有意识到的时候分离概念也可以被结合在一起,和在某些情况下分离它们的困难之处。
下一周我们将会继续探讨和描述设计失误的模式,这种模式将相当难以检测并且在后期也非常难以修复。以此同时,接下来是一些练习,或许可以用来针对合并设计进行深入的探索。
解释为何open()和O_CREAT受益于O_EXCL标志,但是其他创建文件系统入口的系统命令(mkdir(), mknod(), link()等等)不需要这种标记。注意是否有其他合并被隐含在该差异中。
探索假设的bind()命令链接文件描述符到命名空间一个地址的可能性。其他文件描述符类型或许会有什么意义,会产生什么样的结果。
在IP协议组件中识别一个或多个合并设计的方面并解释该合并的消极后果。
【版权声明】
原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权
......
近期热文
【有奖分享】程序员这个职业需要戴上哪些紧箍咒(遵守哪些法律法规、协议)?