内容


pseudo 详解,第 3 部分

经验教训

吸取经验和教训

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: pseudo 详解,第 3 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:pseudo 详解,第 3 部分

敬请期待该系列的后续内容。

我曾临时看守过猫,从中明白了一个道理,扮酷的关键是假装您在故意犯错。或者,正如许多老师所说的那样,“现在,由你们来找出我故意犯的错” 。

在整个 pseudo 项目中,有许多有趣的错误、奇怪的 bug 以及其他学习经验。 其中包括有些怪异的极端案例;甚至有一些事情连我都不敢相信自己曾经参与过。所幸,我不必详细描述数百种甚至以代码无法编译而告终场景。我已经将简单的打字排版错误提升为一种艺术形式。

抛开无法实现的,了解我们可以做到的

在最初的 pseudo 版本中,重命名操作实际上并不起作用。该操作不会产生任何实际的后果,因为 pseudo 守护进程会在进一步访问的时候自动处理数据库条目。

SQLite SQL 引擎的一个缺陷是无法将索引用于 LIKE 比较。在重命名目录时,很显然,您可能希望在该目录中重命名文件。因此,如果您将目录 /foo 重命名为 /bar,那么在以 /foo/ 开头的每条路径中,都要使用 /bar 替代字符串 /foo

如果使用 SQL 子句 (path LIKE ? || '/') 实现此操作,尽管它无法使用索引,但它的运行速度仍然出奇得慢。四处浏览的时候,我欣喜地发现了一个有悖常规的解决方法: (path > (? || '/') AND path < (? || '0')

假设使用了一个 ASCII 系统,它完全等效于后跟一些内容的 path/,但是,因为它只是一个关系运算符,所以它使用了索引。即使在小型文件系统上,该操作也会产生一个大约加速两万倍的加速因子。

但是,在执行到这一步的时候,我犯了一个小小的错误。最终结果是,我改变了参数绑定顺序,比如说,如果您将 /foo 重命名为 /bar,那么最终的时候,在以 /foo/ 开头的所有路径中,我会使用 /foo 替换 /bar。该错误没有带来任何影响,至少短时间内没有带来任何影响。

因为 pseudo 的 paranoia 检查和健康检查(sanity check)从未真正带来虚假的结果,只是会在日志文件中产生一堆警告。

序列化不足以解决问题

一个有关 pseudo 的早期假设是:序列化没有问题,因为所有的操作均在服务器上实现序列化的,您不可能从无次序的给定客户端获得两个连续的操作。在 “640K 对任何人都应该足够用” 级别上,这个问题还不是很明显,但它确实是一个很严重的错误。

在最初的设计中,曾尝试进行一些底层操作,如果成功的话,则向服务器报告。对于单个程序,该操作总是行之有效的。但对于多个程序,可能会导致出现竞争状态。

进程 A 创建了一个临时文件,使用的 inode 号为 12345。然后进程 A 删除了这个临时文件。在删除该文件后,进程 B 新建了一个文件,并再次使用 inode 号 12345。但是,在这样做的时候,pseudo 守护进程会在看见来自进程 A 的断开链接消息之前看见来自进程 B 的创建消息。到底发生了什么事情?

在收到来自进程 B 的创建消息时,pseudo 守护进程会发出通知,指出数据库中有一个旧条目(进程 A 的临时文件)具有相同的 inode 号;它会记录存在的差异并删除该条目。然后创建新的数据库条目。但是,情况变得更糟糕。在收到删除消息时,daemon 发出通知,指出数据库中的一个旧条目(进行 B 的文件)具有相同的 inode 号。它删除了假的条目,然后继续运行,并试图从数据库中将进程 A 的临时文件也删除掉。该操作结束后,进程 B 的文件不再记录在数据库中。

我为解决此问题而进行的第一次尝试以凄凉的失败而告终;我修改了 UNLINK 操作,以返回文件以前的数据库条目,然后我收到了客户端发送的 UNLINK 消息,如果底层系统调用失败,则需要重新链接一个文件。这样做的确可以消除竞争状态,但它创建了一个更糟糕的故障模式:位于包含一些文件的某个目录上的 rmdir(2) 删除了所有文件的数据库条目(而删除目录意味着删除其所有内容)。

向文件添加 “正在删除” 标记,并添加 MAY_UNLINKDID_UNLINKCANCEL_UNLINK 消息来最终解决此问题。这些消息允许数据库记录被认为即将删除的文件,因此该文件的创建消息不会产生错误。然后,如果已经为文件设置了正在删除标记,那么 DID_UNLINK 消息会仅删除该文件。因此,我认为这种解决方法最终仍然是失败的。

日积月累,小问题成了大麻烦!

我们都曾经历过一个神秘的问题:在对某个目录进行重新命名后,会导致该目录中的文件被遗忘。这个问题是三个不同的 bug 造成的;修复其中任何一个 bug 都会纠正问题行为。

在重命名目录后,pseudo 会检查 pseudo 数据库中是否已经存在该目录,如果没有,则创建一个具有该名称的目录,以便重命名操作可以正常运行(因此可以重命名该目录中包含的、pseudo 已知的所有文件)。可能会出现以下情况,例如,您在 pseudo 环境以外的地方创建了一个目录,然后,在 pseudo 环境中运行时,又在该目录内创建了一些文件。

这个问题来自三个选择的组合使用。第一选择是,在链接文件时,pseudo 可以帮助具有相同名称的现有文件断开链接。第二个选择是,在断开目录链接时,pseudo 可以帮助断开该目录的内容的链接。将这些选择与来自重命名的隐式链接相结合意味着:当重命名以前未记录在数据库中的目录时,pseudo 可能丢失该目录中已记录在数据库中的文件的条目。

只此一点尚无法体现我们的构建系统。但这却促使我做了一个完全莫名其妙的决定,即努力提高处理使用 rename(3) 跨文件系统重命名文件的能力。事实上,不可能出现这种情况,由于某些原因,我们尝试着去实现这方面的支持,但做得很不好,例如,重命名包装器在重命名之前总是试图链接数据库中的旧名称。最终结果是,当您移动包含文件的目录时,总是从数据库中删除这些文件。

我们纠正了这些重大错误。通过 LINK 操作完成的隐式断开现在只删除指定文件,不会删除它们似乎包含的所有文件。重命名操作不再不合逻辑地尝试创建链接。最终结果是,重命名某个目录不会再破坏别的任何东西。

五维顶点(five-dimensional vertex)案例

您可能听说过边缘案例(edge cases)和极端案例(corner cases)。在所有的时间里,我一直在做软件,所以我只见过五维顶点案例。

在添加 “正在删除” 标记时,这意味着 pseudo 对 IPC 使用的数据结构发生了变化。因为我从未进行过版本控制,从理论上讲,对于客户端和服务器,它们正在使用的 IPC 消息版本有可能不一致。但这种情况从未发生过,我们的构建系统可以确保您总是能够同时重建组件。

然而,我们发现一个非常奇怪的问题,在构建过程中的特定点上,单个程序有时会失败。所谓的 “失败” 是指 “处于挂起状态,无限期地等待来自守护进程的响应”。同时,守护进程在等待来自套接字的输入。

搭建舞台

关于 pseudo 协议的更多细节操作可能都是井然有序的。在启动客户端后,它要做的第一件事就是向服务器发送 PSEUDO_MSG_PING 消息。该消息中的信息包括客户端 PID、客户端二进制文件的名称以及用来记录该客户端中的事件的可选 “标记” 消息。如果没有标记消息,只需忽略它即可。(名称和标记都是以 “路径” 形式发送的,pathlen 字段中对其长度进行了说明。)

执行 ping 命令时发生挂起。这种情况只出现在开发人员的机器上,并且只是暂时的。但是,我们最终会跟踪追查到它。

我们所做的更改使得 pseudo 消息结构的长度增加到了 4 个字节。服务器是智能的,它会读取基础结构大小,但这只是初始部分,于是它假设自己总是得到完整的阅读。(我还没有修复此错误。)

如果您能以某种方式进行安排,从而使得对旧的 pseudo 客户端运行新的 4 字节结构的 pseudo 守护进程不会得到预期数量的数据。客户端还将发送路径名称和标记。因此,仅当正运行的可执行文件 的名称在 4 个字符(一般情况)以下且没有设置标记时,才会发生失败。即便如此,您会如何处理旧的 pseudo 客户端和新的 pseudo 守护进程呢?

揭开神秘的面纱

在我们的构建系统中,您可以预先构建主机工具,这些工具将以符号链接树的形式镜像到构建目录中(使用 lndir),然后重新构建获得新版本需要重新构建的所有工具。相关开发人员可能有一些旧的主机工具(包括 pseudo 守护进程和客户端库),这些工具被镜像到项目目录中,然后开发人员可在该目录中创建新的工具,包括新的守护进程和新的客户端库。

因为我们将 LD_LIBRARY_PATH 设置为指向项目目录,所以通常可以挑选新的库,一切都还不错。但有一个小小的缺陷。您可以在可执行文件中设置连接器搜索路径,有两种方法可以做到这一点。使用现代的友好 RUNPATH 设置可能是您期望使用的方法。但是,较旧的不太友好的 RPATH 设置有其不同寻常的特征,通常会在处理 LD_LIBRARY_PATH之前处理它。相关二进制文件可能是使用设置为 $ORIGIN/../lib:$ORIGIN/../lib64RPATH 构建的。$ORIGIN magic cookie 已扩展到了包含二进制文件的目录。

还记得我说过的以符号链接形式制造镜像的工具吗?处理 $ORIGIN cookie 时也允许使用符号链接。因此,在运行这个特殊的可执行文件时,动态连接器最终会在 LD_LIBRARY_PATH 中找到它,而不是在预先构建的库目录中找到它,从而导致获得的是旧的 pseudo 客户端库。因为可执行文件的名称是在 3 个字符以下,所以此操作会导致出现挂起,而不是导致崩溃或诊断。

为了再现此 bug,您必须具备:

  • 预先构建的 pseudo 版本,该版本至少应该已经存在一周时间
  • 将重新构建较新版本的源代码树
  • 预先构建的树中的可执行文件,无需重新构建
  • ...名称长度不得超过 3 个字符
  • ...使用 $ORIGINRPATH 指定一个库搜索路径

跟踪此过程需要花费一些时间。长远解决方案涉及向消息添加版本控制功能(理想情况下,会使用目前消息中永远不会出现的一些指标),以及其他许多改进。该解决方法还涉及停止使用 RPATH 来指示链接路径,并且有可能将二进制文件而不是符号链接复制到路径中。

API 迁移

在最近的一些 Linux 机器上,使用普通的旧 /bin/cp 复制的文件都因为权限位不正确而终止使用。原来,getxattr()/setxattr() 系列的功能可用于查询或设置 POSIX 模式,并不仅限于扩展属性。在特定系统上,该操作不是使用普通的 chmod() 实现的。为了方便起见,如果 *xattr() 函数调用失败,根据规范,需要回退到 chmod(),所以现在,如果 pseudo 拦截它们并失败,请将 errno 设置为 ENOTSUP。这个问题可能需要留待以后解决。

同样,在重要重构阶段,许多 pseudo 包装器都因为一些琐碎的功能(也称为其他功能)而被重新实现;例如,使用 open()O_CREAT 实现 creat()。特别是,许多具有 *at() 变体的功能都是通过调用相应的 *at() 函数(使用 AT_FDCWD 作为 dirfd 参数)来实现的。在我们尝试在没有提供 openat() 的机器上执行此操作之前,这种方法一直表现得很出色。

随着时间的推移,很可能我们不得不为提供不同 API 支持范围的系统提供更全面的处理。

经验教训和未来的发展方向

在最初发展和持续维护 pseudo 期间,我们曾遇到许多问题,这些问题相对容易跟踪和诊断。最初我们决定将重点放在健壮的和良好的记录上,目前,该决定显然得到了回报。另一方面,我们一直计划 “不久以后” 编写的测试套件存在一个重大且明显的差距;越早构建更多的测试支持,使用它节省的时间 就越多。

在现有代码和项目与您要做的事相符合时使用它们是一件好事,不要因为您要解决的问题实际上一个新问题而感到害怕。这种事不常发生(我想这是第一次发生在我身上),但它确实发生了,在发生这样的事时,请做好应对它的准备。

将来,我们仍然需要做一些事来提高健壮性和改进诊断,但我们接下来要调查的重点领域可能是性能;pseudo 所做的一切都做得相当不错,但不可否认的是,它比 fakeroot 要慢一些,我们可以在这方面做一些改进。以稳定数据库格式将数据存储在磁盘上永远都不会像把它们存储在内存中那样快,但存储速度仍然有很大的提升空间。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Open source
ArticleID=682034
ArticleTitle=pseudo 详解,第 3 部分: 经验教训
publish-date=06222011