Posts Tagged ‘lwn’

[译文] 空指针的乐趣(2)

September 14th, 2009

作者:Jonathan Corbet
原文发布日期:July 21, 2009
来源:http://lwn.net/Articles/342420/
译者:王旭 ( http://wangxu.me , @gnawux )
翻译时间:2009年9月14日

译者注:本来没准备翻译本系列的2,翻译文章实际上是译者的一种自我消遣方式,不过,翻译了1之后,有很多朋友的反应很不错,也有朋友在期待2,所以,就接着把2翻译了,时间仓促,翻译得不一定到位,请各位将就着看吧 :)

本系列的第一篇详细分析了一长串的偶然错误,利用它们可以用一个空指针解引用来对内核进行攻击。清除这个 bug 本身可以直接修改;实际上在这个问题的本质被大部分人所理解之前就已经修正了。从这个意义上讲,这个问题本身相当小,很少有发布版带有有问题的内核版本。但是,这个攻击证明了,内核中会有一大类类似的问题,在被修正之前,绝对有攻击者有机会可以发现这些错误并利用它们。

一个显而易见的问题是,当安全模块机制被配置入内核的时候,管理员指定的最小合法用户控件虚拟地址会被忽略,安全模块允许覆盖管理员指定的最小合法用户空间地址限制((mmap_min_addr)。这个行为颠覆了人们对安全模块工作的理解:它们被认为能够限制权限而从不增加权限。而在这种情况下,SELinux 的存在实际是增加了权限,大部分部署的 SELinux 都没能关闭这个漏洞(攻击代码的注释中指出,AppAmor 也不会更好)。

此外,当完全不配置安全模块的 时候,mmap_min_addr 也没有被完全强制执行。目前主线(译注:2.6.31)内核有一个补丁来保证 map_min_addr sysctl 设置永远可用,这个补丁同时也被提交进入 2.6.27.27 和 2.6.30.2 更新了(以及其他的地方)。

问题同时也在SELinux层面进行了修复。Red Hat 未来的版本的 SELinux 将不再允许无限制的(在其他方面却无特权的)进程将页面映射到地址空间底端。虽然这里仍然有一些未解决问题,特别是像WINE这样的程序将陷入混乱。目前仍然不确定如何让系统安全地支持一小撮需要映射到0页面的程序。有个想法是让 WINE 以 root 权限运行,这样,或许太过类似 Windows 的行为了,这些想法只收到很少的关注。

另一个关于 map_min_addr 的方法也必须要谈到:一个带有 SVR4 个性化的特权进程在 exec() 的时候,会有一个只读页面映射到0页面。在十分罕见的情况下,一些老的 SVR4 程序会需要那里有这么一个页面,但是,它实际让空指针攻击成为了可能。于是,另一个被并入主线和稳定版更新中的补丁用来在 setuid 的程序运行的时候重置 SVR4 个性化(或者,至少重置那个关于映射0页面的部分)。

这个改变对有些用户仍然不够,他们要求能够完全关闭这个个性化功能。这个能力用于直接运行基于 386 的 Unix 的程序,其实已经没有它 1995 年的时候所拥有的重要性了,所以,很多人质疑是否值得为这个个性化特征付出这样的代价了。Linus 答道

我们可能可以扔掉那个愚蠢的特征。它已经不再那么重要了。有人真的关心么?同时,这么多年来,我们已经发展出了很多其他个性化标志,其中有些仍然是有用的。

特别的,禁止地址空间随机化(一个个性化特征)的能力在很多情况下是很有用的。所以 personality() 应该被留下来,不过,0页映射的特征可能要一去不返了。

这一连串错误中的一个就是被编译器优化掉的空指针校验。这个校验可以阻止攻击,但 GCC 却把它优化掉了,因为理论上说这个指针不应该是空的(因为它已经被正常的解引用过了)。GCC(本身)有一个标志可以禁止某种特殊优化;所以,从现在开始,内核将缺省使用 -fno-delete-null-pointer-checks 编译开关。由于在内核中空指针的确可能是个合法的指针,无限地禁止这种优化史合理的。

有人可能会提出,虽然所有上面的改变都是好的,但可能部分地没有切中要害:高质量的内核不应该在第一次发生的地方允许解引用空指针。这些解引用本身确实是 bug,这里才是最该修正的地方。这有些有趣的历史,实际上内核开发者们常常建议忽略空指针校验。比如,如下 BUG_ON() 代码:

    BUG_ON(some_pointer == NULL);
    /* dereference some_pointer */

经常被以这样的注释而删除掉:

如果我们解引用 NULL,那么内核将基本上会和 BUG 一样显示一个信息,也会采取相同的措施。所以添加一个 BUG_ON 实际无法得到任何收益。

这是因为,解引用空指针会导致一个内核 oops。表面上,这是合理的:如果我们的硬件可以检测到空指针解引用,那么添加软件开销来检测它没什么意义。但这个原因实际上不是无懈可击的,这次的攻击代码就说明了这一点。映射到0页确实是有一定原因的,所以,一个空指针未必总是非法的。你可能会假设所有相关开发者都理解这一点,但实际上内核中仍然有很多地方确实有游泳的空指针检验代码被删除掉了。

虽然如此,大部分内核中的空指针问题可能仅仅就是失察造成的。如果没有方法在相关代码中实际使用空指针的话,大部分的空指针问题是不可被利用的,缺少检验并不能带来什么。不过,如果能把它们全都修复肯定是件好事。

发现这些问题可能可以通过 Smatch 静态代码分析工具。Smatch悄无声息很多年了,不过 Dan Carpenter 正在让它死灰复燃;他最近发布了 Smatch 为他发现的一个空指针 bug。如果 Smatch 能成为一个通用的发现此类错误的工具的话,内核将会更加安全。但不幸的是,这个检查器似乎没有吸引开发者们的很多兴趣,自由软件依旧落后这个领域中的先进技术很远,这让我们都很受伤。

另一个方法由 Kulia Lawall 所采用,她使用一个 Coccinelle “语义补丁”来发现并修复 TUN 驱动中发现的这种先解引用再检查的 bug。一系列补丁()被发布出来,用于修复一系列类似 bug。指针在检查之前被解引用可能只是内核中的空指针问题的一个子集,但每个都被程序员们认为有可能出现空指针,并且是有问题的。所以它们显然是应该被修复的。

总之,这个攻击代码可以看做是内核社区的一个起床号,十分幸运,它将帮助清理很多代码,并消灭很多安全问题。攻击代码的作者 Brad Spengler 还明确的希望得到更多:他经常会发布一些对于内核安全问题的关切:严重的内核安全 bug 被悄悄地修复,或在最差情况下被变为拒绝服务的问题。这个问题是否会改变现状呢,在内核环境中,很多 bug 会造成安全隐患,这些在 bug 被修复的时候并不会立刻显现出来。所以我们无法看到更多 bug 被以安全问题的方式被发布出来,不过幸运的是,我们将会看到更多的 bug 被修复。

[译文] 空指针的乐趣(1)

September 4th, 2009

作者:Jonathan Corbet
原文发布日期:July 20, 2009
来源:http://lwn.net/Articles/342330/
译者:王旭 ( http://wangxu.me , @gnawux )
翻译时间:2009年9月2-3日

现在,大部分读者都已经知道了 Brad Spengler 发布的“the local kernel exploit”(本地内核漏洞利用)。这一漏洞会影响 2.6.30 内核(以及 RHEL5的一个测试版 2.6.18 内核),受到了多方关注。本文将详细分析如何利用这一漏洞,以及让这个漏洞得以成真的令人震惊的一连串错误。

TUN/TAP 驱动提供了一个虚拟的网络设备,它会建立一个隧道;这一驱动在多种场合都有很有用,包括虚拟化、VPN 等很多地方。使用 TUN 时,程序通常打开 /dev/net/tun,然后使用 ioctl() 调用来建立网络端点。Herbert Xu 近来注意到,缺少对包的审计可能会导致恶意程序能够占用大量的内核内存,并导致系统性能下降。他的解决方案是通过一个补丁给该设备添加一个“伪 socket”,使它可以使用内核的审计机制。问题是解决了,但是,回头看来,它的代价是已入了一个更严重问题。

TUN 设备支持 poll() 系统调用。(在 2.6.30 内核中)实现这个功能的函数的开头是这样的:

    static unsigned int tun_chr_poll(struct file *file, poll_table * wait)
    {
	struct tun_file *tfile = file->private_data;
	struct tun_struct *tun = __tun_get(tfile);
	struct sock *sk = tun->sk;
	unsigned int mask = 0;

	if (!tun)
	    return POLLERR;

上面有下划线的的那行代码是 Herbert 的补丁中添加的,这正是惹祸的开始。精心编写的内核代码都会小心的避免对指针的解引用,以避免 NULL;事实上,这里只在那个条件语句那里检查了 tun 指针。并且,这是件好事;现在看来,如果进行了 configuring ioctl() 调用,tun 确实将是 NULL。这时,按照预期,本来 tun_chr_poll() 应该返回一个错误状态。

但 Herbert 的补丁添加的指针解引用是在检查之前的,这显然是一个 bug。在正常操作中,这个 bug 的影响会比较有限,如果 tun 是 NULL 的话,会导致内核 oops。oops 会首先杀掉进行这个系统调用的进程,并将回溯信息加入系统日志,此外就不应该发生什么其他的事情了。最坏情况下,这应该也就是个拒绝服务问题。

依照上述推理,这是一个小问题,虽人 NULL (0)可能确实是个合法的指针地址。缺省的,不论在用户空间还是内核空间,虚拟地址空间的底部(“0页”和它上面的一些页面)都是不允许任何访问的,以用来捕捉空指针错误(如上描述)。不过,使用 mmap() 系统调用将真实的内存映射到虚拟地址空间的底部仍然是可能的。这个功能有一些合法的用例,包括运行一些过时的程序。尽管如此,大部分现代的系统都通过使用 mmap_min_addr sysctl 设置来禁止映射到0页。

安全模块检查被认为可以作为内核已经进行了的检查的一个补充,但这次,它并没有如愿工作。

这一设置应该阻止用户空间的程序区映射零页,这也就保证了空指针的解引用只会导致一次内核的oops。但是,不知何故,如果安全模块机制被配置入内核的话,2.6.30 中的 mmap() 的代码会显示地拒绝执行 mmap_min_addr 。取而代之的是将这个工作留给特定的安全模块来进行。安全模块的检查工作被认为是内核中已有的检查工作的一个补充,但它此时却并不工作。对于 0 页,安全模块会授权访问,而其他情况下则会拒绝访问。这个错误的最后一步是,Red Hat 的缺省 SELinux policy 允许映射 0 页。这样,运行 SELinux 实际是降低了系统的安全性。

但没有 SELinux 的生活也不是就一马平川了。在没有 SELinux 的时候,攻击行为会被 mmap_min_addr 限制,这似乎足够让一切结束了。但这是可以通过使用 personality() 系统调用绕过的。打开 SVR4 个性化会在程序被 exec() 调用的时候将一个只读页面映射到 0 地址,但只有进程有 CAP_SYS_RAWIO 能力的时候才会这样。所以,需要一个更进一步的欺诈行为:顶级的攻击代码设置 SVR4 更兴华,然后使用 exec 运行有特殊插件的 pulseaudio 服务器。pulseaudio 服务器是 setuid root 的,所以它将会在调用时映射到 0 页面。当调用到插件代码的时候,pulseaudio 将会放弃它的权限,但是,这时 0 页已经对攻击代码可用了,攻击代码可以让 0 页可写,并将其自己的数据放在这里。

上面这些攻击的结果就是,用户空间进程是有可能映射0页而不让 tun_chr_poll() 发生内核 oops。不过,你可能会想,攻击者还不能高兴得太早,毕竟接下来 tun 就会检查空指针。这正是这一系列错误中的下一个:GCC编译器缺省会优化掉 NULL 的彻底检验。原因在于,因为这个指针已经被解引用过了(而且也什么都没发生),所以它不可能是 NULL。所以,没有理由再去检查它了。于是,尽管这个逻辑本来在大部分情况下都有效,但是在 NULL 是一个合法指针的时候却是错误的。

所以,攻击者这时就能通过一个空 tun 指针而成功进入 tun_chr_poll() 内部了。接下来需要指出如何利用这种情况控制内核。tun_chr_poll() 中后面的下一步代码是这样的:

	if (sock_writeable(sk) ||
	    (!test_and_set_bit(SOCK_ASYNC_NOSPACE, &sk->sk_socket->flags) &&
	     sock_writeable(sk)))
		mask |= POLLOUT | POLLWRNORM;

注意,sk 的值来自于 tun 的解引用,所以它位于攻击者的控制之下。SOCK_ASYNC_NOSPACE 是 0,所以 test_and_set_bit() 调用可以用于设置内存中任何字的最低权重位。这是个小小的内存冲突,但这已经被证实是足够的了,在 Brad 展示的攻击代码中,sk->sk_socket->flags 指针指向了 TUN 驱动的 file_operations 结构;特别的,它是指向了 mmap() 函数。TUN驱动不支持 mmap() 调用,所以这个指针通常应该是 NULL,在 poll() 调用之后,它就是 1 了。

攻击代码的最后一步就是调用这个打开的 TUN 设备的文件描述符的 mmap() 调用。由于内部的 mmap() 已经不是空了(刚刚被我们设置成了 1),内核将会跳到哪里。那个地址已经在攻击代码所映射的0页面中了,所以,它在攻击者的控制之下。于是,攻击代码使用下一个跳转跳到其自己的代码处即可。这样,当内核调用(它以为的)TUN 驱动的 mmap() 函数的时候,结果就是任意代码都可以在内核模式下运行;这里,攻击代码获得了完全的控制权。

在一个良好设计的系统中,一个单独的错误很少导致灾难性的故障。而这里就是这样一个例子。很多东西都出错才导致了这个攻击成为可能:安全模块能够不顾系统策略而授权访问地位内存,SELinux 策略允许这些映射,pulseaudio 可以被攻击代码利用从而让这一映射可以被攻击代码使用,空指针在解引用之前未被检验,并且检验被编译器优化掉了,代码以某种方式使用空指针可以获取系统的控制权。这是一条长长的错误链,其中的每一环节都是让这一攻击成功的必要条件。

这个漏洞如今已经被关闭了,不过几乎可以肯定还有类似的问题。本系列的下一篇文章将会介绍内核开发者们如何应对这一攻击行为。

[译文] Linux 内核设计模式 (1)

August 30th, 2009

作者:Neil Brown
原文发布日期:June 8, 2009
来源:http://lwn.net/Articles/336224/
译者:王旭 ( http://wangxu.me , @gnawux )
翻译时间:2009年8月29-30日

[译注:初稿,未经审校,欢迎意见建议。]

内核社区中,始终受到关注的一个话题便是维持代码质量。显而易见,我们需要维护乃至提高内核的质量,不过,如何能更好的做到这一点却不那么显而易见了。一个已经取得一定成果的普适方法便是提升内核的各个方面的可见性。这将让这些方面的质量更加透明,进而可以提高它们的质量。

可见性的提高以不同的形式发生:

  • checkpatch.pl 脚本会检查代码的各式风格,将不一致的风格高亮标出,这会帮助(还记得使用这个脚本的)人们来修正代码风格上的错误。这样,通过提高代码风格指南的可见性,我们统一了代码的外观,于是在某种意义上说,提高了质量。
  • 在打开“lockdep”系统时,它可以动态计算锁(以及相关状态,比如是否允许中断等)之间的依赖性。如果有什么东西看起来比较异常的话,它会进行报告。这些异常并非总意味着可能死锁或有类似问题,但很多时候确实如此,而且这种死锁可能性是可以被消除掉的。这样,通过提升锁依赖关系图的可见性,可以提高代码质量。
  • 内核还包含着其他的可见性改进,如对不用的内存空间进行“poisoning(下毒)”,这样非法访问将更为明显,或者通过在 stack trace 中直接使用符号名称替代十六进制地址,从而使得 bug 报告能更有意义。
  • 在更高层面上,用于跟踪内核变化的”git”版本管理系统让观察补丁提交的时间和补丁作者变得十分容易。它鼓励让每个补丁的提交者都附加一个说明,由此可以获知为什么代码应该是这样的。这个可见性可以帮助理解代码,并且由于更多开发者能更好地了解代码的含义,从而可以提高质量。

除此之外,还有很多种方法可以提高其他方面的可见性,从而提高代码质量。在本系列文章中,我们将探讨一个特定的领域,这让作者们可以感到可见性可以发生质的改变的方式改进。这个领域就是阐明内核特定的设计模式。

设计模式

“设计模式”这个概念最早见于建筑学,1994年出版的《设计模式:可重用面向对象软件元素》一书将这个概念引入到了计算机工程,特别是面向对象编程领域之中。Wikipedia上有关于此主题的更加深入的介绍

简而言之,设计模式描述了一类特定的设计问题,并描述了已经被验证有效的解决这一类问题的方法细节。设计模式的一个特别的好处是,它将问题描述与解决方法的描述结合在一起并进行命名。一个模式具有一个简单好记的名称非常有好处。如果开发者和审阅者都知道一个模式的名字,那么重要的设计决策就可以用一两个词就做到有效沟通,决策问题也就变得非常具有可视性了。

在 Linux 内核代码中,很多设计模式都已经被证实是有效的了。不过,它们中的大多数都还从来没有被文档化过,对其他开发者来说也就不怎么可用了。我的希望就是显式地描述这些模式,让这些模式被更广泛地使用,进而开发者可以更快更有效的解决常见问题。

在本系列文章的后面的部分,我们将考察三个问题域,并从中发现适用范围和重要性迥然不同的各种设计模式。我们的目标不仅是描述这些模式,而且要介绍这些模式适用的范围和所具有的价值,这样,其他人也可以尝试来描述他们遇到的模式。

这个系列中将引用很多 Linux 内核中的例子,因为例子是解释模式的一个重要部分。如无特殊声明,这些例子都取自2.6.30-rc4。

引用计数

使用引用计数来管理对象的生命周期的想法非常普遍。核心思想就是使用一个计数器,当有一个新引用的时候就加一,释放引用的时候就减一。当计数器到达0的时候,这个对象所占用的所有资源(如用于存储它本身的内存)就都可以释放了。

管理引用计数的机制看起来非常直接。不过,实际上有一些陷阱会使得它非常容易出错。部分的处于这个原因,Linux 内核(自从 2004 年)就有了一个“kref”类型和一组相关机制(参考 Documentation/kref.txt, <linux/kref.h>, 和 lib/kref.c)。这些方法封装了一部分陷阱,特别的,让一个计数器明确地作为引用计数被以一种特定的方式来使用。如上所述,设计模式的名称非常有价值,开发者只提供要使用的设计模式的名字对于审阅者非常有好处。

Andrew Morton 语录

我更希望开发者们能这么说:“啊,这里用了kref。这样,我理解它用了 refcounting,我知道它被很好的 debug 了,并且知道它能处理一般的错误了。”这比下面这样强多了:“哦,这个东西实现了自己的refcounting — 这样我就得在这里对常见错误进行审阅”。

Linux 内核中引入 kref  为其显式支持设计模式既打了一个勾也打了一个叉。勾就是 kref 就是具体实现了一个重要的设计模式,良好的文档化,并在使用的时候使得代码清晰可见。而这个叉则是因为 kref 仅仅封装了部分关于引用计数的内容。有些引用计数的应用并未被 kref 模型所惠及,稍后我们将看到。一个没有提供所需的方法的引用计数具有“blessed”机制实际可以导致错误,因为人们可能会在 kref 不该使用的地方使用它,在实际上它不能工作的地方认为它可以工作。

了解引用计数的复杂性的有用的第一步是要了解,经常有两类截然不同的引用指向某个对象。事实上,可能会有三类甚至更多,但这并不常见,并且可以被理解成为更广义的两类的情况。我们将这两类引用称为“外部的”和“内部的”,虽然有的场合“强的”和“弱的”可能更合适一些。

“外部”引用是指我们最习惯于考虑到的引用。外部引用在“get”与“put”的时候被计数,可以被与管理对象的子系统相去甚远的子系统所持有。一个对象的外部引用的存在代表着一个强烈而简单的含义:对象在使用中。

与之相对应的,“内部”引用常常会被忽略,它仅在管理对象的系统内部(或与其密切相关的系统内)持有。不同的内部引用具有不同的含义,因此实现的含义也十分不同。

最常见的内部引用的例子是提供“按名称查询”服务的缓存。如果你知道了一个对象的名称,那么只要缓存中确实存在这个对象,就可以通过缓存来得到一个外部引用。这个缓存会在一个列表或一组链表中的一个链表,如一个哈希表中保存每个对象。这个列表中存在的对象实际是一个对对象的引用。但是它却不应该是一个被计数的引用。它不具有“对象在使用中”这样的语义,而仅仅是“当有人想用这个对象的时候,它是可用的”。在所有外部引用被释放之前,对象将一直不会被从列表中删除,而且即使所有外部引用都被释放的时候,它也可能不会被立刻从列表中删除。内部引用的存在和本意喻示着着引用计数的实现方式。

一个有用的对不同引用计数风格进行分类的方法是通过其所需要的“put”操作的实现来进行区分。“get”方法一般是一样的。它获取一个外部引用,并产生了另一个外部引用。它的实现差不多是这样的:

    assert(obj->refcount > 0) ; increment(obj->refcount);

或者如 Linux 内核中的 C 代码:

    BUG_ON(atomic_read(&obj->refcnt)) ; atomic_inc(&obj->refcnt);

注意,“get”不能被用于一个已经被释放了的对象。还需要一些其他的处理。

“put” 操作有三个变种。尽管在用例上还有一些重叠,但区分这三者对于保持代码的简洁是有好处的。这三种方式以 Linux C 的写法是这样的:

   1      atomic_dec(&obj->refcnt);

   2      if (atomic_dec_and_test(&obj->refcnt)) { ... do stuff ... }

   3      if (atomic_dec_and_lock(&obj->refcnt, &subsystem_lock)) {
                 ..... do stuff ....
		 spin_unlock(&subsystem_lock);
	  }
“kref” 风格

从中间一种方法开始,第2项是 kref 使用的风格。这个风格适用于对象没有其外部引用活得长的情况。当引用计数到达0的时候,对象需要被释放,否则就要进行处理,这就需要使用 atomic_dec_and_test() 来检查引用计数为为0的情况。

符合这一风格的对象通常都没有需要顾虑的内部引用,大部分 sysfs 中的对象就是这样,它们也使用了大量的 kref。但如果一个使用 kref 风格引用计数的对象有内部引用,那么它就不允许用一个内部引用来创建外部引用,除非能确定仍有其它的外部引用。如果有此必要,可以使用如下原语:

     atomic_inc_not_zero(&obj->refcnt);

这样,在计数器不为零的情况下自加,返回值指示操作是否成功。在 linux 内核中,atomic_inc_not_zero() 是一个较新引入的操作,在2005年末作为不加锁的 page cache 的一部分引入。因而,这个原语的使用还不够广泛,一些本可以使用这个原语的代码使用了自旋锁。遗憾的是,kref 包也没使用它。

这个风格的引用有一个没用 kref 的有意思的例子,甚至连 atomic_dec_and_test() 都没有用(虽然实际可以用并且确实应该用),这就是 struct super 里面的两个引用计数:s_counts_active

s_active 非常符合 kref 的风格。超级块的生命周期开始时 s_active 为1(alloc_super() 中设置),并且,当 s_active 变成零之后,就无法再获取外部引用了。这个规则位于 grab_super(),虽然超级块没有立刻被清除。当前的代码(由于历史原因)在 s_active 非零时给加上一个很大的数(S_BIAS),而 grab_super() 去检查 s_count 是否超过 S_BIAS 而不是 s_active 是否为零。后面这次检查实际上可以直接使用 atomic_inc_not_zero(),从而避免使用自旋锁。

s_count 提供了另一类不同类型的引用,既是内部引用,又是外部引用。它的内部。从语义远弱于 s_active 方面说,它是内部引用计数。s_count 引用计数的意思仅在于“超级块还不能立刻被释放”,而不检查它是否真的处于 active 状态。而它却又非常类似 kref 那样,从 1 开始生命周期(实际是 1*S_BIAS),当它到 0 的时候(在 __put_super() 中)超级块就被销毁了,从这个意义上讲又是外部引用计数。

只要进行如下操作,这两个引用计数就可以用两个 kref 所代替:

  • S_BIAS 置为 1
  • grab_super() 使用 atomic_inc_not_zero() 原语,而不是和 S_BIAS 进行比较

这样,很多自旋锁都可以不用了。具体细节可以作为练习留给各位读者了。

“kcref” 风格

Linux 内核并没有 “kcref” 对象,但这个名字似乎适用于接下来的这种引用计数风格。“c”是指的“缓存(cached)”,这种风格经常用于缓存之中。所以可以称为 Kernel Cached REFerence。

kcref 引用计数如上面第三种情况使用 atomic_dec_and_lock() 。这是因为,最后一次 put 的时候,需要释放掉资源或检验是否需要其他特定的处理。这需要进行一次加锁来保证当前状态被重新求值期间不会再产生新的引用。

一个简单的例子是 struct inode 中的 i_count 引用计数。iput() 中的主要部分是这样的:

    if (atomic_dec_and_lock(&inode->i_count, &inode_lock))
	iput_final(inode);

其中 iput_final() 检验 inode 的状态,并决定它是否可以被销毁,或是还要留在缓存中,以备稍后重用。

特别的,inode_lock 会阻止从 inode 哈希表中的内部引用建立外部引用。由于这个原因,仅有持有 inode_lock 的时候才能将内部引用转化为外部引用。支持这个操作的函数称为 iget_locked() (或 iget5_locked())。

略有一点复杂的例子是 struct dentry,这里的 d_count 是用的类似 kcref 的方式管理的。它更复杂一些的原因在于在我们获得引用之前,需要先获取两个锁—— dcache_lockde->d_lock。这要求我们必须先获得一个锁,然后再用 atomic_dec_and_lock() 来获取另一个(如 prune_one_dentry 之中),或者先用 atomic_dec_and_lock() 然后请求另一个锁并重新检查引用计数,如 dput() 中的操作。这是一个很好的例子,它说明了你永远不能肯定你已经封装了所有的可能的引用计数风格。需要两个锁的情况很难被预见到。

一个更复杂的例子是 struct vfsmount 中的 mnt_count。这里的复杂性源于两个引用计数的相互影响:mnt_count 是一个典型的外部引用计数,而 mnt_pinned 是进程审计模块的内部引用计数。特别地,它统计文件系统中打开的审计文件的数量(实际应该使用个更贴切的名字)。复杂性来自于当只有内部引用存在的时候,它们将全被转化为外部引用。关于这个例子的细节同样留作练习,留给感兴趣的读者。

“plain”风格

最后一种引用计数涉及到的风格就是直接对引用计数进行减值操作(atomic_dec())而不做其他任何事情。这个风格在内核中并不常见,必须有充分的理由才会使用。毕竟随意放着一个无人饮用的对象不是个好主意。

这个风格的一个例子出现在 struct buffer_head 中,位于 fs/buffer.c<linux/buffer_head.h>。函数非常简单 put_bh()

    static inline void put_bh(struct buffer_head *bh)
    {
        smp_mb__before_atomic_dec();
        atomic_dec(&bh->b_count);
    }

这样做没什么问题,因为 buffer_heads 有它们自己的生命周期管理规则,他们是紧密和页相关的。一个页面中会分配出一个或多个 buffer_heads,以分成小片(buffer)。它们会被保存着,直到页面本身被释放,这时,所有的 buffer_heads 都会被清除(通过 try_to_free_buffers() 调用 drop_buffers())。

通常,”plain”风格适用于那些内部引用会一直存在,对象不会丢的情况,这个内部引用的所在的进程会最终找到并释放对象。

反模式(Anti-patterns)

现在要回顾一下这个引用计数来作为设计模式的介绍,我们将要讨论反模式的相关概念。设计模式都是已经经过检验可以工作的方法,并应该鼓励使用,而反模式则是那些已经证明不能良好工作,并应被慎用的。

作者建议把在引用计数中使用”偏置(bias)“作为一个反模式的例子。偏置是一个大数,在引用计数中进行加减,它被用于保存一些信息。我们已经在超级块的 s_count 中见过这种方式了。在这个例子中,偏置的存在表示 s_active 是非零值,这很容易直接检验。所以偏置实际没有任何价值,只是使得代码的真实用意更不清楚了。

另一个使用偏置的例子是 fs/sysfs/sysfs.hfs/sysfs/dir.c 中的 struct sysfs_dirent 。十分有趣,struct sysfs_dirent 和超级块一样,有两个以用,也是叫做 s_counts_active。这里,当选项被去激活时,s_active 有一个大的负数偏置。同样信息可以被有效并更清晰地存储在一个标志字 s_flags 中。在标识中存放一些信息比用偏置的方法存放在计数器中更为易懂,也更应该被推荐。

总之,使用偏置不能增加清晰度,不是一个常用模式。它不能比一个单独的标志位提供更多的信息,极端缺少内存、无法发现其它可用内存的情况下使用偏置来存放信息的情况非常罕见。因此,引用计数中,偏置应该被视为反模式并尽力避免使用。

总结

到时候结束我们对各种引用计数相关的设计模式的介绍了。简单列出如”kref”与”kcref”,”外部“与”内部“引用这样的术语是非常有帮助的。像我们一样找到 kref 和可以用 kcref 的代码,并在所有可用的地方使用它们,这对开发者和审阅者都有好处,开发者可以在一开始就找到正确的方法,而审阅者也更容易知道代码的用意。

我们本文中涉及的设计模式包括

  • kref: 当对象的生命周期仅仅延续到最后一次释放外部引用的时候,kref 是恰当的设计模式。如果对象有内部引用,那么它们只有使用 atomic_inc_not_zero() 才能变成外部引用。例子:struct super_block 中的s_actives_count
  • kcref: 如果对象的生命周期超出最后一次释放外部引用,那么应该使用 atomic_dec_and_lock() 和 kcref。内部引用只有在获得了子系统锁的时候才能转化为外部引用。例如:i_count 中的 i_count
  • plain: 当对象的生命周期挂靠在其他对象之上的时候,应该采用plain 引用模式。对象的非零引用计数必须被看作是对父对象的内部引用,内部引用转化为外部引用必须遵循和父对象相同的规则。例子:struct buffer_head 中的 b_count
  • biased-reference: 当你想要在引用计数中使用一个大的偏移值来表征一些特殊状态的时候,停下,别这么干。使用个别的标志位吧,这是反模式。

下个星期我们将换一个领域看其中 Linux 内核已经证明了的成功的设计模式,并看一些复杂的数据结构略多的地方。

练习

作者在准备这个系列的时候就被提醒到,没有比直接研究代码更能让人理解这些问题了。所以也给感兴趣的读者留了一些练习。

  1. 使用 kref 在 struct super 中替换 s_active,抛弃 S_BIAS。比较使用 trifecta 进行正确性、可维护性和性能检查的结果 。
  2. mnt_pinned 和处理它的相关函数选择一个更贴切的名字。
  3. 给 kref 库添加一个使用 atomic_inc_not_zero() 的函数,并使用它(或相反的操作)去掉 net/sunrpc/svcauth.c 中使用的atomic_dec_and_lock() ,这里它破坏了 kref 的抽象。
  4. 检查 struct page (如 mm_types.h 中) 的 _count 引用计数,观察它的行为更像 kref 还是 kcref (提示:肯定不是 plain)。这应该包括标记所有的内部引用和相关的锁定规则。指明为什么 page cache (struct address_space.page_tree) 有一个引用计数或为什么不应该有。浙江包括理解 page_freeze_refs() 和它在 __remove_mapping() 中的使用,以及 page_cache_{get,add}_speculative()。

补充:一个系列的最小的自包含的补丁来实现上述这些研究结果的改变被证实是有用的。

[译文] Xen: 完成工作

March 22nd, 2009

http://lwn.net/Articles/321696/
By Jonathan Corbet
March 4, 2009
王旭 2009年3月22日译

曾几何时,Xen是炙手可热的虚拟化技术。Xen的开发者们拥有一个自由软件中最为领先的虚拟化解决方案,Xen看起来正是Linux虚拟化的未来。很多风投在追逐这个神话,各个发行版也竞相提供基于Xen的虚拟化平台。然而,在这条路上,Xen似乎已经走失了。XenSource的开发者们看起来似乎没有兴趣让他们的代码进入主线内核,而其他人的努力也遇到了没完没了的障碍。于是,Xen在多年以来就一直停留在主线之外了。Xen首次对外发布是2003年的事了,可Xen的核心代码仅仅在2007年10月才进入了2.6.23。

就在同时,KVM出现了,而且一下子抢走了众多的注意力。它以难以置信的速度一下子就进入了主线内核,而且很多内核开发者都毫不掩饰的表达了他们对于KVM锁采用的方法的青睐。就在最近,Red Hat发布了他们的KVM虚拟化时间表,让这种偏爱更加官方了。同时,lguest 成为了那些想要尝试虚拟化的人的简单选择。

Xen的故事是“上游优先”策略产生的原因的一个经典实例,该策略要求代码在被提供给客户之前应该首先进入主线。发布者们忙不迭地首先发布Xen,然后就发现他们自己正在支持一段out-of-tree的代码,这些代码常常根本不被他们的开发者锁支持。特别地,对外发布的Xen通常只支持相当老的内核,希望提供一些更新的内容的发布者需要做很多工作来让它们一起工作。现在,至少部分的发布者(发行版)已经开始走向其他的选择了,而高层的内核开发者则在问一个问题——事到如今,是否还值得来把剩下的Xen代码merge进来。

所有人都在说,Xen已经岌岌可危了。不过,或许更严重的所谓Xen已经死掉了的流言有些言过其实了。

主线中的代码实现了 Xen 的 DomU 概念——一没有访问硬件的权限的非特权域。而一个完整的Xen则需要更多功能;需要一个用户空间(原文如此)的hypervisor(是以GPL许可发布的)和内核中的Dom0代码。Dom0是hypervisor启动的第一个域;定性情况下,它比其他Xen guest拥有更多的特权。Dom0用于在管理策略的管理下小心的向其他域提供那些诸如访问硬件、网络等的特权。一个实用的Xen系统必须包含Dom0代码——目前是一大块out-of-tree的kernel代码。

Jeremy Fitzhardinge 希望改变这一局面。他发出了一组Xen Dom0 patch ,希望能够进入 2.6.30。在审阅者中,Andrew Morton问了这个问题

我讨厌成为说着句话的人,不过,我们确实应该坐下来弄明白把它merge到Linux当中来是否真的合适。我觉得Xen是实现虚拟化功能的老方法了,世界已经专项新方向KVM了。

三年之内,我们是不是会后悔我们merge了Xen?

Andrew的问题本质上是:(1)完成这个工作需要什么代码(这次提交的代码之外),和(2)这么做的原因是什么?第一个问题的答案 是“再有2-3个尺寸差不多的补丁集就将提供引导dom0所需的所有代码”。之后有一些其他的不同内容可能不会进入主线。不过,Jeremy说,让核心代码进入主线将会减少发行版需要打的out-of-tree的补丁,让所有人的生活变轻松一些。对于第二个问题,Jeremy回应到:

尽管kernel周期中有来自kvm的影响,Xen还是拥有庞大并且仍在增长的用户群的。此时,他们都工作在大量的out-of-tree补丁之上,这对大家来说都十分不快。让他们成为主线内核的一部分再好不过了。你知道,这和我们争论的所有其他事情都一样。

此外,Jeremy表示,Xen仍然有其存在的价值。它的设计在很多方面和KVM有本质的不同;这封邮件 精辟的解释了这些差异。所以,Xen作为另一个解决方案已然有意义。

Jeremy指出的一些Xen的优势包括:

  • Xen的实现页表的方法消除了shadow页表或guest中嵌入页表的需要;这样,可以让许多负载获得更好的性能。
  • Xen的hypervisor是非常轻的,并且可以独立运行;而KVM的hypervisor则就是Linux内核本身。似乎有些厂商(HP 和Dell被点到名了)在他们的很多系统的firmware中会提供一个Xen的hypervisor。这些代码于是和可以和其他的东西一样具有可以立即使用的特性了。
  • Xen的半虚拟化方式可以使用不支持虚拟化的硬件一起工作。而KVM则需要硬件支持。
  • 分离hypervisor, Dom0和 DomeU的方式让安全性检查更简单了。各个域之间的隔离还允许让每个设备都在一个独立的域中提供服务,这可以看做是一种重量级的微内核架构。

与此不同,KVM相对更简单、更易用,并且可以访问所有的内核特征。在Jeremy看来,两个系统在Linux中各有不同的位置。

这个讨论在最后重归沉默,这表明Jeremy的解释相当不错。Xen历史上犯过错误,但这个项目仍然活着,而且仍然有它的原因继续存在下去。你的编辑(译 著:编辑就是本文原文作者)预测,Dom0的代码在尝试进入2.6.30的merge window的时候还会遇到一定的反对意见。

[译文] GEM vs. TTM

August 8th, 2008

By Jonathan Corbet
May 28, 2008
王旭于2008年8月8日译
http://lwn.net/Articles/283793/

在 Linux 下,即使是在有了基础硬件的编程接口信息的情况系,得到高性能的 3D 渲染仍然是非常具有挑战性的。这个问题的一个原因就是内存管理:一个 GPU 本质上说是一个拥有它自己的独立的内存的计算机系统。要想管理GPU 的内存 —— 以及以它的视角看到的系统内存,让系统能够跑起来就必须要小心,更不要说还要获得可接受的性能了。

不久以前,这个问题看上去要被翻译表映射(TTM)子系统 解决了。TTM 目前仍然没有进入主线内核,依赖于它的所有驱动自然也没有进入。最近的一个关于 TTM 如何才能进入主线的提问引发了一场有趣的讨论,最终使得局面急转直下,TTM 可能根本就不是未来的显存管理系统。

很多关于 TTM 的抱怨都已经浮现出来了。它的 API 远大于任意一个开源 Linux 驱动的需求;换句话说,大量的代码都是用于二进制发布的驱动的。隔离机制(fence, 管理 CPU 和 GPU 的并发性的机制)非常复杂而难于使用,而且并不是总能得到很好的性能。大量的使用内存映射缓冲会给它自己带来性能问题。TTM API 尝试着提供所有情况下的一切东西;结果对于很多开发者来说很难让特定的硬件去匹配这些 API,很难开始使用 TTM,也缺乏足够的灵活性。对于使用TTM的开源驱动来说,这是一个重大的不足。所以 Dave Airlie 担忧地说 :

现在,我希望有一个 radeon 或是什么新的驱动已经使用了 TTM,或者,至少有个 demo 展示了如何使用它,可是现在什么也没有,这让我很痛苦……真正的问题是 TTM 是不是真的适合于 Linux 桌面和嵌入式驱动的开发者们,至少我在桌面方面还没看到足够的正面反馈。

所有这些但有看来都是徒劳的,毕竟 TTM 已经在那了,而且也没有什么其他的替代品。然而,事情发生了,一个转机发生了,它就叫做图形执行管理器(这名字翻译的好难听阿,对不起观众了),简称 GEM。Intel 资助的 GEM 项目已经有一个月了(译注:原文写于08年5月底,目前 GEM 形势大好阿)。GEM 的开发者其实还没完全准备好来对外公布他们的工作,不过 TTM 的讨论让他们提前走上了前台。

Keith Packard (译注:Intel 的开源图形驱动项目的带头人)的 GEM 介绍 中包含了一个已有 API 的描述文档。GEM 在处理上面有很多显著不同。首先 GEM 使用普通、匿名、用户空间内存来用于分配图形缓冲对象。这意味着这些缓冲区可以在必要的时候被交换出物理内存。这个方法有其显著的优越性,不仅仅是内存使用的灵活性:它让挂起和恢复的实现可以直接通过恢复所有缓冲对象的方式自动完成了。

GEM API 尽量避免将缓冲区到用户空间的映射。映射工作代价高昂而且而且带来了 CPU 与 GPU 之间的缓存一致性问题。所以,GEM 用简单的 read() 和 write() 调用来取代映射,访问缓冲区对象。或者,至少,在 GEM 开发者能给每个缓冲对象附加一个文件描述符的时候的处理方法。而内核不会那么轻易管理这么多文件描述符,这样,实际的 API 使用了不同的缓冲对象 handle 和一系列的 ioctl() 调用。

也就是说,GEM可能映射一个缓冲对象到用户空间。不过,用户空间的驱动必须显示地承担管理缓存一致性的责任。为此,有一组 ioctl() 调用用于管理缓冲的“域”;所谓“域”,本质上说就是描述了系统中的哪个组件拥有这块缓冲区并有权操作它。改变一个缓冲的域(两类域,其一用于读访问,而另一个是写)将会进行必要的缓存刷新。在某种意义上说,这个机制重现了流 DMA API,DMA API 中的 DMA 缓冲区的所有权就可以在 CPU 和 外设控制器之间交换。不必惊讶,他们解觉得也是类似的问题。

GEM API 不需要显示的屏蔽 (fence) 操作。相反,当 CPU 操作需要访问缓冲区的时候,如果必要的话 CPU 就会简单地等待 GPU 完成对该缓冲区的操作。

最后,GEM API 并没有想要独自解决所有问题;很多重要操作(比如执行一组 GPU 指令)被留给硬件特定的驱动来实现了。GEM 本身在目前非常适于 Intel 驱动的需求;它不需要达到所有的 TTM 所有达到的目标。Eric Anholt 描述 到:

TTM 的问题是它要对所有硬件提供一套统一的 API,即使我们的驱动不希望这样的时候也是如此……我们尽量从另一个方面来达到这一目的:实现一个驱动。当其他人实现了另一个驱动,又发现这里面有些代码应该是公共的,那么就将它移动到支撑库里并共享它。

这种方法的有点是更容易来给 Intel 新加入一些东西。而且这可能是个好的开始,一组工作的驱动总比没有强。另一方面,这也意味着要让 GEM 支持其它硬件的驱动还需要大量的工作。对于这些工作如何去做,目前大概有两种观点:(1) GEM 为新的驱动的需求增加新的能力,或者 (2) 让驱动使用它自己的内存管理器。

第一种方法在很多情况下看起来更漂亮。但它也预示着 GEM API 会经常发生很多变化。这可能会延缓整个系统进入主线内核的脚步;GEM API 会输出到用户空间,所以必须保持变化的兼容性。看起来必须要不断演进的 API 会在快速的进入主线内核的时候受到阻力。

而对于第二种方法,Dave Airlie 给出了最好的解释 :

嗯,事情是我不能相信我们还不足够知道如何以一种通用的方法来做到这点,不过 TTM vs GEM 可能已经证明了这是不可能的了。那么我们可以赌一赌每个驱动一个内存管理器,不过我怀疑这将成为一个维护的噩梦,所以如果人们决定了这就是下一步要走的,我会很高兴地看到他发生。不过提交第 n+1 个内存管理器的人必须解事情出接口后面的机制,而且解释清楚为什么不能复用内存管理器 1 到 n。

一个还没被讨论过的问题就是性能。Keith Whitwell 给出了一组评分结果 ,该结果显示对于 i915 驱动,使用 TTM 或 GEM 的时候,性能会比不使用的情况显著降低。而 Keith Packard 的到了不同的结果 ,他的结果显示 GEM 驱动的性能明显更好。显然,目前需要一组一致的 benchmark;图形性能是重要的,但如果不能有效地测量也就没法优化。

使用匿名内存也会引起一些性能考虑:如果第一人称射击游戏 (FPS) 的血腥像素们要不停地被换页的话,体验可能就不尽如人意了。匿名内存可以位于高位内存,而且这样就不必使用32位指针来访问。一些 GPU 硬件不能访问高位内存;这将可能影响 kernel 的 bounce buffer (不好意思啊,这个不知道怎么翻译好了)。最后,GEM 还需要去证明它可以提供良好的性能,GEM的开发者们非常主动地使他们的硬件看起来很好,这也让工作在上面的东西有了一个更好的机会。(这句话翻译的比较晕)

所有这些能得到的结论是GPU的内存管理问题还不能简单认为是已经解决了。GEM可能最终称为这个答案,但它还是非常新的API,仍然需要大量的工作。这个领域中似乎还有很多的工作需要做。

(感谢 Timo Jyrinki 建议这个话题。)

[译文]What is RCU, Fundamentally?

June 1st, 2008

http://lwn.net/Articles/262464/

December 17, 2007

Paul E. McKenney, IBM Linux Technology Center

Jonathan Walpole, Portland State University Department of Computer Science

王旭 [gnawux(at)gmail.com] 翻译,2008年5月26日–6月1日

[编者注:本文是解释 read-copy-update 工作机制的三部曲的第一部。十分感谢 Paul McKenney 和 Jonathan Walpole 允许我们发表这些文章。剩余的两片将在随后几周中陆续发出。]


《RCU是什么?》第一部分

概述

Read-copy update (RCU) 是一种 2002 年 10 月被引入到内核当中的同步机制。通过允许在更新的同时读数据,RCU 提高了同步机制的可伸缩性(scalability)。相对于传统的在并发线程间不区分是读者还是写者的简单互斥性锁机制,或者是哪些允许并发读但同时不允许写的读写锁,RCU 支持同时一个更新线程和多个读线程的并发。RCU 通过保存对象的多个副本来保障读操作的连续性,并保证在预定的读方临界区没有完成之前不会释放这个对象。RCU定义并使用高效、可伸缩的机制来发布并读取对象的新版本,并延长旧版本们的寿命。这些机制将工作分发到了读和更新路径上,以保证读路径可以极快地运行。在某些场合(非抢占内核),RCU 的读方没有任何性能负担。

问题1:seqlock 不是也允许读线程和更新线程并发工作么?

这个问题可以归结到 “确切地说,什么是RCU?” 这个问题,或许还是 “RCU 可能是如何工作的?” (再或者,不太可能的情况下,问题会变为什么情况下 RCU 不太可能工作)。本文从几个基本的出发点来回答这些问题;之后还会分批地从使用的角度和 API 的角度来看这些问题。最后一篇连载还会给出一组参考文献。

RCU 由三个基本机制组成,第一个用于插入,第二个用于删除,而第三个则用于让读线程可以承受并发的插入或删除。这三个机制将在下面的三节中介绍,讲述如何将 RCU 转化为链表:

  1. 订阅发布机制 (用于插入)
  2. 等待已有的RCU读者完成 (用于删除)
  3. 维护多个最近更新的对象的版本 (为读者维护)

这三个章节之后还有上重点回顾与快速问题答案。

订阅发布机制

RCU的一个关键特性是它可以安全地扫描数据,即使数据正被同时改写也没问题。要提供这种并发插入的能力,RCU使用了一种订阅发布机制。举例说,考虑一 个被初始化为 NULL 的全局指针变量 gp 将要被修改为新分配并初始化的数据结构。下面这段代码(使用附加的合适的锁机制)可以用于这个目的:

1 struct foo {
2 int a;
3 int b;
4 int c;
5 };
6 struct foo *gp = NULL;
7
8 /* . . . */
9
10 p = kmalloc(sizeof(*p), GFP_KERNEL);
11 p->a = 1;
12 p->b = 2;
13 p->c = 3;
14 gp = p;

不幸的是,没有方法强制保证编译器和CPU能顺序执行最后四条语句。如果gp的赋值早于p的各个域的初始化的话,那么并发的读操作将访问到未初始化的变 量。内存屏障(barrier)可以用于保障操作的顺序,但内存屏障以难以使用而闻名。这样我们将他们封装到具有发布语义的 rcu_assign_pointer() 原语之中。最后的四条将成为这样:

1 p->a = 1;
2 p->b = 2;
3 p->c = 3;
4 rcu_assign_pointer(gp, p);

rcu_assign_pointer() 将会发布新的结构,强制编译器和CPU在给p的各个域赋值之后再把指针赋值给gp。然而,仅仅强制更新操作的顺序是不够的,读者也必须强制使用恰当的顺序。考虑下面的这段代码:

1 p = gp;
2 if (p != NULL) {
3 do_something_with(p->a, p->b, p->c);
4 }

尽管这段代码看起来不会受到顺序错乱的影响,不过十分不幸,DEC Alpha CPU 和投机性编译器优化可能会引发问题,不论你是否相信,这的确有可能会导致 p->a, p->b, p->c 的读取会在读取 p 之前!这种情况在投机性编译器优化的情况中最有可能会出现,编译器会揣测p的值,取出 p->a, p->b 和 p->c,之后取出 p 的真实值来检查拽侧的正确性。这种优化非常激进,或者说疯狂,不过在确实会在profile-driven优化时发生。

毫无疑问,我们需要在CPU和编译器上阻止这种情况的发生。rcu_dereference() 原语使用了必要的内存屏障指令和编译器指令来达到这一目的:

1 rcu_read_lock();
2 p = rcu_dereference(gp);
3 if (p != NULL) {
4 do_something_with(p->a, p->b, p->c);
5 }
6 rcu_read_unlock();

rcu_dereference() 原语可以被看作是订阅了指针指向的值,保证接下来的取值操作将会看到对应的发布操作(rcu_assign_pointer())发生之前被初始化的值。 rcu_read_lock() 和 rcu_read_unlock() 绝对是必须的:他们定义了 RCU 读方临界区的范围。他们的目的将在下一节 解释,不过,他们不会自旋或阻塞,也不阻止 list_add_rcu() 的并发执行。事实上,对于非抢占内核,它们不产生任何代码。

虽然 rcu_assign_pointer() 和 rcu_dereference() 在理论上可以用于构建任意 RCU 保护的数据结构,但实际上,使用高层构造常常更好。因此,rcu_assign_pointer() 和 rcu_dereference() 原语被嵌入到了 Linux 的链表维护 API 中的特殊 RCU 变量之中了。Linux 有两个双向链表的变种,循环链表 struct list_head 和线性链表 struct hlist_head/struct hlist_node。前者的结构如下图所示,绿色的方块表示表头,蓝色的是链表中的元素。

Linux list

将上面的指针发布例子放到链表的场景中来就是这样:

1 struct foo {
2 struct list_head list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 list_add_rcu(&p->list, &head);

第15行被使用某种同步机制保护住了,通常是某种所,以组织多个 list_add() 实例并发执行。然而,这些同步不能组织同时发生的RCU读者。订阅一个 RCU 保护的链表非常直接:

1 rcu_read_lock();
2 list_for_each_entry_rcu(p, head, list) {
3 do_something_with(p->a, p->b, p->c);
4 }
5 rcu_read_unlock();

list_add_rcu() 原语发布一个节点到制定的链表中去,保证对应的 list_for_each_entry_rcu() 调用都正确的订阅到同一个节点上。

问题2:如果在 list_for_each_entry_rcu() 运行时,刚好进行了一次 list_add_rcu(),如何防止 segfault 的发生呢?

Linux 中的另一个双向链表,hlist,是一个线性表,也就是说,它的头部仅需要一个指针,而不是向循环链表一样需要两个指针。这样,使用 hlist 作为大型哈希表的 hash-bucket 数组的容器将仅消耗一半的内存空间。

Linux hlist

将一个新元素添加到一个 RCU 保护的 hlist 里面与添加到循环链表里非常类似:

1 struct foo {
2 struct hlist_node *list;
3 int a;
4 int b;
5 int c;
6 };
7 HLIST_HEAD(head);
8
9 /* . . . */
10
11 p = kmalloc(sizeof(*p), GFP_KERNEL);
12 p->a = 1;
13 p->b = 2;
14 p->c = 3;
15 hlist_add_head_rcu(&p->list, &head);

和上面一样,第15行一定使用了锁或其他某种同步机制。

订阅一个 RCU 保护的 hlist 也和循环链表非常接近。

1 rcu_read_lock();
2 hlist_for_each_entry_rcu(p, q, head, list) {
3 do_something_with(p->a, p->b, p->c);
4 }
5 rcu_read_unlock();

问题3:为什么我们需要传递两个指针给 hlist_for_each_entry_rcu(), list_for_each_entry_rcu() 可是只需要一个指针的啊?

RCU 发布与订阅原语在如下表中列出,同时给出了 “取消发布”或是撤回的原语

类别
发布
撤销
订阅

类别
发布
撤销
订阅

指针
rcu_assign_pointer()
rcu_assign_pointer(…, NULL)
rcu_dereference()

循环链表
list_add_rcu()
list_add_tail_rcu()
list_replace_rcu()
list_del_rcu()
list_for_each_entry_rcu()

双向链表
hlist_add_after_rcu()
hlist_add_before_rcu()
hlist_add_head_rcu()
hlist_replace_rcu()
hlist_del_rcu()
hlist_for_each_entry_rcu()

注意,list_replace_rcu(), list_del_rcu(), hlist_replace_rcu(), 以及 hlist_del_rcu() 增加了一些复杂度。什么时候释放被替换或删除掉的数据元素才是安全的呢?具体地说,我们怎么能知道所有的读者都释放了他们手中对数据元素的引用呢?

这些问题将在下面的章节中得到回答。

等待已经存在的RCU读者完成

RCU的最基本的功能就是等待一些事情的完成。当然,还有很多其他方法也是用于等待事情完成的,包括引用计数、读写锁、事件等。RCU最大的好处在于它可以等待所有(比如说)两万件不同点事情,而无需显式地跟踪它们中的每一个,也不需要担心性能的下降、可伸缩性限制、复杂度死锁场景,以及内存泄露等所有这些显式跟踪手法所固有的问题。

RCU 中,被等待的东西被叫做“RCU读方临界区”。一个RCU读方临界区始于 rcu_read_lock() 原语,止于 rcu_read_unlock() 原语。RCU 读方临界区可以嵌套,也可以放入很多代码,只要这些代码显式阻塞或睡眠即可(有一种称为“SRCU”的特殊RCU允许在它的读方临界区中睡眠)。只要你遵守这些约定,你就可以使用RCU来等待任何期望的代码段的完成。

正如其他地方对经典RCU实时RCU的描述,RCU 通过间接确定这些其他事情的完成时间来达到这一目的。

具体地说,如下图所示,RCU是一种等待已经存在的RCU读方临界区结束的方法,包括这些临界区中执行的内存操作。

Grace periods extend to contain pre-existing RCU read-side critical sections.

注意,开始于一个给定宽限期开始之后的RCU读方临界区能够、并可以延续到该宽限期结束之后。

下面的伪码展示了使用RCU等待读者的基本算法形式:

  1. 进行改动,比如,替换链表中的一个元素。
  2. 等待所有已经存在的RCU读方临界区完成(比如,使用synchronize_rcu()原语)。关键点是接下来的RCU读方临界区将无法得到新近删除的元素的引用了。
  3. 清理,比如,释放上述所有被替换的元素。

下面的代码段是从前一节修改而得的,用于说明这一过程,这里面的域a是这个搜索的键值。

1 struct foo {
2 struct list_head list;
3 int a;
4 int b;
5 int c;
6 };
7 LIST_HEAD(head);
8
9 /* . . . */
10
11 p = search(head, key);
12 if (p == NULL) {
13 /* Take appropriate action, unlock, and return. */
14 }
15 q = kmalloc(sizeof(*p), GFP_KERNEL);
16 *q = *p;
17 q->b = 2;
18 q->c = 3;
19 list_replace_rcu(&p->list, &q->list);
20 synchronize_rcu();
21 kfree(p);

第19、20 和 21 行实现了上面所说的三个步骤。第 16-19行展现了 RCU 的名字(读-复制-更新):在允许进行并发读操作的同时,第16行进行了复制,而第17-19行进行了更新。

乍一看会觉得 synchronize_rcu() 原语显得比较神秘。毕竟它必须等所有读方临界区完成,而且,正如我们前面看到的,用于限制RCU读方临界区的rcu_read_lock() 和 rcu_read_unlock() 原语在非抢占内核中甚至什么代码都不会生成。

这里有一个小伎俩,经典RCU通过 rcu_read_lock() 和 rcu_read_unlock() 界定的读方临界区是不允许阻塞和休眠的。因此,当一个给定的CPU要进行上下文切换的时候,我们可以确定任何已有的RCU读方临界区都已经完成了。也就是说,只要每个CPU都至少进行了一次上下文切换,那么所有先前的 RCU 读方临界区也就保证都完成了,即 synchronize_rcu() 可以安全返回了。

因此,经典RCU的 synchronize_rcu() 从概念上说可以被简化成这样:

1 for_each_online_cpu(cpu)
2 run_on(cpu);

这里,run_on() 将当前线程切换到指定 CPU,来强制该 CPU 进行上下文切换。而 for_each_online_cpu() 循环强制对每个 CPU 进行一次上下文切换。虽然这个简单的方法可以在一个不支持抢占的内核上工作,换句话说,对 non-CONFIG_PREEMPT 和 CONFIG_PREEMPT,但对 CONFIG_PREEMPT_RT 实时 (-rt) 内核无效。因此,实时RCU使用了一个(松散地)基于引用计数的方法。

当然,在真实内核中的实现要复杂得多了,因为它需要管理终端,NMI,CPU热插拔和其他实际内核中的可能有的风险,而且还要维护良好的性能和可伸缩性。RCU的实时实现还必须拥有良好的实时响应能力,这就使得(像上面两行那样)直接禁止抢占变得不可能了。

虽然我们了解到了 synchronize_rcu() 的简单实现原理,不过还有很多其它问题呢。比如,RCU读者们在读一个正在被并发地更新的链表的时候究竟读到了什么呢?这个问题将在下一节讲到。

维护多个版本的近期更新的对象

本节将展示 RCU 如何为多个不需要同步的读者维护不同版本的链表。我们使用两个例子来展示一个可能被给定的读者引用的元素必须在该读者处于读方临界区的整个过程中保持完好无损。第一个例子展示了链表元素的删除,而第二个例子则展示了元素的替换。

例1:在删除时维护多个版本

要开始这个“删除”的例子,我们先把上节这个例子的 11-21行改成如下的形式:

1 p = search(head, key);
2 if (p != NULL) {
3 list_del_rcu(&p->list);
4 synchronize_rcu();
5 kfree(p);
6 }

这个链表以及指针p的最初情况是这样的:

Initial list state.

表中每个元素的三元组分别代表域a, b, c。红色的便捷表明读者可以获取它们的指针,而且因为读操作和更新操作不是直接同步的,读者可以在这个删除的过程中同时发生。这里我们为了清晰没有画出双向链表的反向指针。

在第三行的 list_del_rcu() 完成的时候,5,6,7 这个元素已经被从链表中删除了(如下图)。由于读者并不直接和更新操作同步,读者可能同时正在扫描这个链表。由于访问时间不同,这些并发读者可能看到、也可能没看到新近删除的元素。不过,那些在获取指针之后延迟了读操作的读者(比如因为中断、ECC内存错误,或在 CONFIG_PREEMPT_RT内核中因为抢占而延迟了的)可能仍然会在删除之后的一段时间内看到那个老的链表的版本。下图中 5,6,7 元素的边框仍然是红色的,这意味着仍然有读者可能会引用它。

After deletion.

这里注意,在退出读方临界区之后,读者们就不能再持有 5,6,7 这个元素的引用了。所以,一旦第4行的 synchronize_rcu() 完成了,所有已有读者也就保证都完成了,这样就没有读者会访问这个元素了,下图中,这个元素的边框也变黑了。我们的链表也回到了一个单一的版本了。

After deletion.

这之后,5,6,7 这个元素就可以被安全的释放了:

After deletion.

这里,我们完成了删除 5,6,7 这个元素的操作,下一小节将介绍替换操作。

例2:在替换的过程中维护数据的多个不同版本

在开始替换的例子钱,我们再修改一下前面例子的最后几行:

1 q = kmalloc(sizeof(*p), GFP_KERNEL);
2 *q = *p;
3 q->b = 2;
4 q->c = 3;
5 list_replace_rcu(&p->list, &q->list);
6 synchronize_rcu();
7 kfree(p);

这个链表的初始状态和指针p和删除的那个例子是完全一样的:

Initial list state.

和之前一样,每个元素里面的三元组分别代表域 a, b 和 c。红色的边框代表了读者可能会持有这个元素的引用,因为读者和更新者没有直接的同步,读者可能会和整个替换过程并发进行。再次说明,这里我们为了清晰,再次省略了反向指针。

第一行的 kmalloc() 生成了一个替换元素,如下:

List state after allocation.

第二行把旧的元素的内容拷贝给新的元素:

List state after copy.

第三行,将 q->b 更新为2:

List state after update of b.

第四行,将 q->c 更新为3:

List state after update of c.

现在,第5行进行替换操作,这里,新元素最终对读者可见了。到了这里,如下所示,我们有了这个链表的两个版本。先前已经存在的读者可以看到 5,6,7 元素,而新读者将看到 5,2,3 元素。不过,任何读者都被保证可以看到一个完整的链表。

List state after replacement.

第6行的 synchronize_rcu() 返回后,宽限期将完成,所有在 list_replace_rcu() 之前开始的读者都将完成。具体地说,任何可能持有 5,6,7 的读者都已经退出了他们的读方临界区,这就保证他们不再持有一个引用。因而也在没有任何读者持有老元素的引用了,途中,5,6,7 元素的边框也就变黑了。对于读者来说,目前又只有一个单一的链表版本了,只是新的元素已经替代了旧元素的位置。

List state after grace period.

第七行的 kfree() 完成后,链表旧成为了如下的样子:

List state after grace period.

尽管 RCU 是以替换而命名的,但内核中的大多数使用都是前面小节 中的简单删除的情况。

讨论

这个例子假设在更新操作的过程中保存着一个互斥量,也就是说,这个链表在一个给定时间最多有两种版本。

问题4:如何修改删除的例子,来允许超过两个版本的链表可以同时存在?

问题5:在某一时刻,RCU最多可以有多少个链表的版本?

这组例子显示了RCU使用多个版本来保障在存在并发读者的情况下的安全更改数据。当然,一些算法是无法很好地支持多个版本的。有一个参考文献 介绍了如何对这些算法进行改造以使用RCU,不过,这超出了本文的讨论范围了。

小结

本文介绍了基于RCU的算法的三个基本部分:

  1. 对与添加新数据的发布-订阅机制
  2. 等待已有RCU读者完成,以及
  3. 维护多个版本以便在不顺坏或严重延迟RCU读者的情况下,允许更改。

问题6:如果 rcu_read_lock() 与 rcu_read_unlock() 之间没有自旋锁或阻塞,RCU更新者会怎样延迟RCU读者?

这三个RCU的组成部分允许数据在并发读者访问的同时更新数据,并可以以多种方式实现基于RCU的算法,一些算法将会在接下来的“What is RCU, Really?”系列中继续介绍。

致谢

我 们十分感激本文草稿的审阅者,他们极大地提高了本文的价值,这些审阅者是 Andy Whitcroft, Gautham Shenoy, 和 Mike Fulton。同时,我们要感谢 Relativistic Programming 项目的成员和 PNW TEC 的成员,他们带来乐很多有价值的讨论。我们还要感谢 Dan Frye 的支持。最后,本文基于 NSF CNS-0719851 资助项目的工作。

本文仅代表作者的个人观点,并不反映 IBM 或波特兰州立大学的观点。

Linux 是 Linus Torvalds 的注册商标。

其他涉及到的公司、产品和服务的名称是各自的商标或服务标志。

问题解答

问题1:seqlock 不是也允许读线程和更新线程并发工作么?

:是或不是。虽然 seqlock 的读者可以在 seqlock 写的同时并发访问,不过一旦写操作发生,read_seqretry() 原语都会强制要求读者重试。这意味着任何与 seqlock 更新者并发发生的 seqlock 读者所做的工作都将被放弃并重做。所以,seqlock 读者可以与更新者同时运行,但不能真的在这种情况下做什么工作。

与此相反,RCU 读者可以在存在并发 RCU 更新者的同时完成有效地工作。

问题2:如果在 list_for_each_entry_rcu() 运行时,刚好进行了一次 list_add_rcu(),如何防止 segfault 的发生呢?

:在所有运行着的 Linux 系统中,从指针读取或写入指针都是原子操作,也就是说,如果一个存储到指针和一个从指针读取操作是同时发生的,那么,读取要么读回初始值,要么读回存入值,而不会返回两者的混合体。另外,list_for_each_entry_rcu() 总会前向遍历链表,而从不反向遍历。因而,list_for_each_entry_rcu() 要么看到 list_add_rcu() 加入后的元素,要么看不到,不论如何,都会看到一个正确的链表。


问题3:为什么我们需要传递两个指针给 hlist_for_each_entry_rcu(), list_for_each_entry_rcu() 可是只需要一个指针的啊?

:因为一个 hlist 必须检查 NULL 而不是检查是不是回到头部了。(尝试编一个单指针的 hlist_for_each_entry_rcu()。如果你能得到一个不错的答案,那当然是非常好的事情啦!)

问题4:如何修改删除的例子,来允许超过两个版本的链表可以同时存在?

a答:一个方法可以通过如下代码达到:

spin_lock(&mylock);
p = search(head, key);
if (p == NULL)
spin_unlock(&mylock);
else {
list_del_rcu(&p->list);
spin_unlock(&mylock);
synchronize_rcu();
kfree(p);
}

注意,这意味着 synchronize_rcu() 可以等待多个并发的删除操作。


问题5:在某一时刻,RCU最多可以有多少个链表的版本?

:这依赖于同步时如何设计的。如果用一个信号量来保护宽限期内的变更操作,那么最多有新旧两个版本。然而,如果仅仅搜索、更新,并用锁来保护 list_replace_rcu(),那么可以有任意数量的活动版本,只受到内存和宽限期内可以完成多少个更新操作的限制。不过,要注意,那些如此频繁更新的数据结构不是实施RCU的好场所。这只是说,RCU 可以在必要时应付高更新速率。


问题6:如果 rcu_read_lock() 与 rcu_read_unlock() 之间没有自旋锁或阻塞,RCU更新者会怎样延迟RCU读者?

:给定的 RCU 更新者进行的改动将导致对应的 CPU 丢弃当前的 cache lines,强制 CPU 运行并发 RCU 读者,从而导致 cache 重新映射造成的昂贵代价。(你能设计一个算法来改变一个数据结构而不导致并发读者的昂贵的 cache 丢失么?对接下来的其他读者呢?)

[译文] CFS 组调度

January 28th, 2008

译者按:CFS 是 2.6.24 内核的一个亮点,自从 2.6.23 内核中引入 CFS 以来,又将有一次功能和性能的全面提升,这里分享一篇组调度相关的文章。

原文链接: http://lwn.net/Articles/240474/
[Posted July 2, 2007 by corbet]
译者:王旭
翻译时间:2008 年 1 月 28 日

Ingo Molnar 的完全公平调度器 (CFS) 补丁正在持续更新当中,本文写作时的版本是v18 ,翻译的时候的版本已经是v24了。不过,很多人都认为 CFS 的行为有一个极大的不足:它只在个体进程之间实现公平。如果50个进程想要同时运行,CFS 将小心行事,保障每个进程获得 2% 的 CPU。但是,这可能存在一个极端情况:这50个进程里有1个是 Alice 的 X server,而其余49个则是内核 hacker Karl 远程登陆过来,希望利用空闲空余的 CPU 时间加快内核编译过程的进程。如果 Karl 在系统中被真的公平地对待的话,应该让他的49个编译进程作为一个组来和 Alice 的 X server 来分享 CPU。换句话说,X 应该获得 50% 的 CPU(如果它需要的话),而所有的 Karl 的进程应该分享另外 50% 的 CPU。

上面提到的这种调度方式就称为“组调度”,Linux 中的任何一种调度器都还不能真正支持组调度。如果 CFS 在将来能被加入到内核当中,并且具备实现“组调度”的潜力,那将是一件很理想的事情。感谢 Srivatsa Vaddagiri 等人的工作,事情正在向着这个方向发展。

Srivatsa 的工作的第一部分已经进入了 CFS 补丁的 v17 版本了。它引入了一个称为“调度实体”的概念用以指代被调度的对象,这个对象不一定是一个进程。Srivatsa 保存了每个进程的调度信息,并将它们打包在一个 sched_entity 结构之中。这实际是一个简化,它封装了相关信息(实际要做的信息),同时也没有改变CFS 调度器的工作方式。

组调度实现为一组独立的补丁,目前还不是CFS代码的一部分。这些补丁将调度实体转换为一个层次结构。现在,调度实体并不和进程直接关联,相反,他们代表一组进程。每个调度实体内部有一个自己的运行队列。所有调度实体还有一个 parent 指针和一个指向调度它们的运行队列。

缺省地,进程都在顶级层次结构中,被单独地调度。一旦一个进程被移动到了一个调度实体下面,它就会在顶层运行队列里被删除。当进程可以运行的时候,它就被放到它的父调度实体对应的运行队列之中。

当调度起选择下一个要运行的任务的时候,它首先检查所有顶极调度实体,取出最应该获得CPU的任务。如果这个实体不是一个进程(而是一个高层调度实体),那么调度器就会检查调度实体中的运行队列,并重复这一过程,直到到达层次结构的最底层、找到一个进程,并运行这个进程。在进程运行时,同样会收集一些运行时统计信息,但这些信息同时会向上层调度实体传播,从而在每一级别上都正确度量其CPU占用。

这样,系统管理员可以为 Alice 和 Karl 各建立一个调度实体 。所有 Alice 的进程被放在她的调度实体之中,同样,Karl 的内核编译进程也放在他的调度实体里。CFS 调度器将保证 Alice 和Karl 之间的公平性;一旦调度器决定了谁应该获得 CPU,它将进入到下一级队列中,公平调度其中的用户进程。

队列的层次不一定按照用户来建立;进程可以被依照任何管理员觉得合适的方式来分组。分组可以更加粗略;比如,一个大学的计算机上,所有学生会被放到一个组里,教职员工放在另一个组中。层次化也可以是依照进程类型的:可以为系统守护进程、交互工具以及那些狂吃CPU的程序等各建立一个组。这个补丁中并不包括任何限制进程被如何分组的内容。

一个遗留问题可能是:系统管理员究竟怎么进行这个分组?答案在于组调度补丁的第二个部分,通过一个进程容器算法将调度实体组织在一起。管理员可以使用 cpuctl 参数来加载一个容器文件系统;调度组于是就可以在那个文件系统中以创建目录的方式被创建了。进程可以用容器界面来添加到(或移出)组。这样,任意特定的分组策略都可以利用一个简单的用户态守护程序来实现了,这个守护进程只要监视进程的创建事件,并将新建进程放到其应在的组里就可以了。

以目前的情况,容器代码只支持一层的组层次,而两级架构(如将用户分为管理员、故园和拜访者,然后对每个组里的用户们进行公平调度)还没有实现。这似乎是一个还没有被考虑到的限制,而不是一个代码中固有的问题。

有了组调度功能,CFS 将对相当一部分潜在用户更具吸引力。不过这些用户可能要等比较久了。2.6.23 的 merge window 很快就要开放了,不过那时这个补丁应该还没有完全准备好被加入到内核中去(译注:2.6.23 已经部分加入CFS了,不过更多的增强还是如作者所料在 2.6.24 中加入的)。可能 2.6.24 才会拥有那个值得期待的具有组调度功能的调度器。

[译文]SLUB 内存分配器

January 14th, 2008

原文链接:http://lwn.net/Articles/229984/
原文作者:corbet
原文发表时间:April 11, 2007
译者:王旭
翻译时间:2008年1月14日

译者按:不知道读者朋友们有没有误入过 /sys/slab 目录,进过这个吓人的目录之后,你可能就很想知道它到底是怎么回事,这和 slab 内存分配器有关,当然,更和 SLUB 内存分配器相关了,/sys/slab 和 slub 一同在 2.6.22 内核被引入,用于提升 cache 内存分配在多处理器系统中的效率。嗯,接下来还是看看这篇不是很久远的考古译文。

***
补充一下,目前SLUB也不是一帆风顺的,和 /sys/slab 有关的东东也有很多问题,http://lwn.net/Articles/263329/ 这篇文章和之后的评论指出了这些问题,让我们拭目以待吧,好了,现在可以继续看这篇考古译文了。
***

slab内存分配器多年以来一直位于内核的内存管理部分的核心地带,它(在底层页分配器之上)管理着特定大小的对象的缓存,允许快速而且省空间的内存分配。内核黑客们一般不愿意去动slab的带脉,因为它实在是非常复杂,而且在大多数情况下,它的工作完成的相当不错。

Christoph Lameter就是那些感到 slab 工作的不是那么好的少数人之一。长时间以来,他提出了一个长长的关于 slab 的问题列表。slab 分配器维护了大量的对象队列,这些队列可以让内存分配更快,却也增加了复杂度。此外,这些存储开销会随着系统规模增大而增大:

每个节点、每个CPU都有 slab 对象队列。alien cache 队列甚至有一个为每个节点的每个cpu都保存一个队列的队列数组。对于大规模的系统,队列数量和对象数量甚至会指数增长。在我们的有一千个节点/处理器的 系统中,大约有几G字节的内存被用于存放那些队列,这还不包括哪些在这些队列上的对象。恐怕有一天所有的内存会都消耗在这些队列上头。

而且,每个 slab (一组一个或多个连续的用于分配对象的页面) 都要在起始处包含大量的元数据 ,这使得对象的对其进一步变得困难了。而用于在内存紧张的时候情理 cache 的代码又进一步增加了复杂度……

Christoph 对此的处理是提出了一个SLUB allocator,用于替代 slab 代码。通过取消了大量的队列和相关开销、简化 slab 的结构,SLUB 承诺提供更好的性能和更好的系统可伸缩性,并且可以同时保持现有的 slab 分配器接口。

在 SLUB 分配器中,一个 slab 就是一组一个或多个页面,封装了固定大小的 对象。slab 内部没有元数据,只是空闲对象组织在简单的链表之中。当有一个分配请求的时候,第一个空闲对象就此被定位、从列表中删除并返还给调用者。

在缺少每个slab的元数据的情况下,你可能会非常好奇第一个空闲的对象是如何被发现的。答案就在于 SLUB  分配器将这些信息存储在了系统内存映像之中 — 与这个 slab 所在的页面相关的页结构。让 struct page 变大同样是让人不能认同的,所以SLUB 分配器通过增加了另一个 union 来让这个复杂的结构更复杂了一些,从而解决了这个问题。最终结果是 struct page 增加了三个只在作为 slab 组成部分时才有效的字段:

    void *freelist;
    short unsigned int inuse;
    short unsigned int offset;

在用于 slab 时,freelist 指向 slab 中的第一个空闲对象,inuse 是 slab 已经分配出去的对象数量,而通过 offset ,分配器可以知道那里可以找到指向链表中下一个空闲对象的指针。SLUB 分配器可以使用 RCU 来释放对象,但是,如果想这么做,它必须能够将“下一个对象”的指针放在对象之外;offset 指针正是 SLUB 用来跟踪指针放置位置的手段。

当 slab 被分配器创建的时候,没有对象从中被分配过。一旦一个对象被分配了,它也就成为了一个存储在 kmem_cache 结构中的一个链表中的“部分的” slab。作为一个以可伸缩性为目标的补丁,系统中的每个 NUMA 节点会有一个“部分的”链表。分配器尽力保证分配节点本地的内存,但是会在部分 slab 填满系统之前到达其他节点的。

另外,还有一个每CPU的激活 slab 的数组,用于防止包括 NUMA 节点内部的各种 cache line bouncing。 同时还有一个特别的线程(通过一个工作队列)运行,监视每 CPU  slab 的使用情况,如果一个CPU的 slab 没有被用上,它还会被放回到部分列表中,以便被其他进程使用。

如果一个 slab 中的所有对象都被分配出去了,分配器就可以完全不用考虑这个 slab 了。一旦一个满 slab 中的一个对象被释放了,分配器就可以可以通过系统内存映像来重定位这个 slab,并将它放回到相应的部分链表之中去。如果一个给定的 slab 中的所有对象(通过 inuse 计数器标记)都被释放了,那么整个 slab 都将被放回到页分配器中,以便重用。

SLUB 内存分配器的一个有趣的特性是它可以合并多个有相似对象尺寸和参数的 slab。其结果是系统中可以有更少的 slab 缓存(据说可以减少 50%),更好的本地化 slab 分配和更少的 slab 内存碎片。该补丁声明:

这个合并可以暴露出内核中迄今为止尚不为人所知的 bug,因为坏掉的对象现在可能难于放置,并会影响到临近的对象。请打开 sanity check 来发现这些问题。

让 bug 暴露出来总的来说是件好事,不过,过多使用 SLUB 分配器在那些新的 bug 没有被找出来之前会引起一些奇异行为。

过于广泛的使用是完全可能的:SLUB 分配器现在已经在 -mm 树之中了,并且可能会被加入到 2.6.22 主线内核当中。简化的代码非常诱人,据说有 5-10% 的性能提升。如果被加入到主线内核当中,SLUB 可以和当前的 slab 分配器 (以及面向小系统的 SLOB)共存相当常一段时间。从长远来看,当前的 slab 代码可能就要到了生命的终点了。

[译文] 从前……

December 8th, 2007

原文:http://lwn.net/Articles/244829/
原作者:Jonathan Corbet
原作时间:August 8, 2007
译者:王旭 | gnawux(at)gmail.com |
翻译时间:2007年12月8日

译注:译者当然知道 once upon atime 和 once upon a time 是不一样的,不过,原作者这么写不就是让我们像 once upon a time 这么念 once upon atime 么,所以,题目就是这样了,本文是译者琢磨怎么节约点能源的时候考古出来的,下面是译文。

大多数文件系统都维护着一个元数据,用于记录文件的最新访问时间,即“atime” 。这个时间可以说是非常有用的,管理员或管理程序根据这个时间就可以判断一个文件最近一次被使用是什么时候的事情了。不过,这个功能有个很严重的问题:每次文件被访问的时候,它都需要写一次硬盘。即使是那些已经被读入缓存的,可以被方便读取的文件的只读操作也需要写一次硬盘,来维护atime值。

最近,一个关于磁盘写操作节流(译注:或称为占空比调制,类似于CPU在一定时间的时候关闭时钟来降低功耗的机制)的讨论在 Ingo Molnar 指出 atime 可能 是比其它人何事情都严重的一个性能障碍之后转而开始讨论 atime 了。Ingo 提到:

Atime的更新是目前影响Linux IO性能的最为严重的因素。如果能不进行 atime 更新将会显著提高 Linux 性能,这个提高会比过去10年里所有的的关于 pagecache 加速机制加起来都会多。

他还认为 atime “可能是 Unix 的各种时间值设计中最愚蠢的一个”。

这个讨论很快就转换成了另一个问题:关于这种情况,我们应该怎么作呢?一个差不多所有Linux都能采用的方法就是在加载文件系统的时候使用 noatime 开关,这样就彻底地关掉了访问时间的记录。对于文件系统敏感的人物,这个改变的性能提高是立竿见影的。世事无完美,关掉 atime 在某些情况下可能会让那些依赖这个东东的程序没法正常工作。传统上说,一写邮件工具会比较访问时间和修改时间来判断邮件是否未读。tmpwatch工具和一写备份工具也使用 atime,如果这个时间不正确的话,它们可能会有异常行为。正是基于这个原因,各个发布版都不太敢于将 noatime 作为缺省开关。

2.6.20 内核引入了一个新的方法:relatime 开关。如果在挂载文件系统的时候使用这个开关,访问时间只有在它们比变更时间更旧的时候才会更新。这个变化允许工具们看当前文件似乎否被读过了,同时很大程度上减少了 atime 的更新次数。这个开关还很少有人使用,可能是因为很少有人听说过它或是发布版们的mount版本还没有更新,还不支持relatime。不过,如果有程序想知道某个文件是否在过去的一段特定长时间(比如一个星期)里被访问过,那它还是可能会被relatime欺骗。

为了解决这个问题,Linus 建议调整一下 relatime的工作方式:如果在一段时间里更新过文件,比如一天,那就更新它。Ingo 很快就写了一个补丁来实现这个特性,还增加了两个新的启动参数:指定缺省更新秒数的 relatime_interval 和在所有文件系统中缺省使用 relatime 的开关 default_relatime。

这个补丁的类似补产品可能会进入 2.6.24。这个补丁建议当一个inode将要被写入到硬盘的时候,kernel可以同时更新 atime。不过Alan Cox反对这个改变,因为这个改动可能会让整个行为变得不可预测。这个补丁到目前位置还没有更新版本,将来如果这个补丁能进入内核当中的话,也肯定不是当前这个版本。

[译文] i386和x86_64: 破镜重圆?

December 6th, 2007

原文:http://lwn.net/Articles/243704/
原作者:Jonathan Corbet
原作时间:July 31, 2007
译者:王旭 | gnawux(at)gmail.com |
翻译时间:2007年12月6日

译者注:题目叫破镜重圆当然不好了,有点辞不达意,不过,为了吸引眼球,损点人品也就认了。

译者又注:这次是正经话了,这两个架构即将在2.6.24中合并,于是译者才考古出来翻译的。好了,后面是译文了,译者不注了。

kernel 源码目录中的 arch 目录存放着所有架构相关的代码。尽管开发社区在过去多年以来都在致力于让代码尽可能地通用化,这个目录依然十分庞大。Linux目前支持26个不同架构,其中很多又有一些子架构。这26个架构中有两个分别是 i386 (Linux 最初支持的架构) 和 x86_64 —— i386 的大哥。这两种架构之间有太多共性了,而也确实已经有一些工作正致力于在这两个架构间尽量地共享代码。尽管如此,这两种架构的源码树仍然是彼此天各一方的。

至少在一些开发者眼里,这两个架构的彼此孤立是个问题。对于一个架构的 bug fix 常常对另一个也是可用的,但是不是都应该施加于两个地方就不清楚了。对于新功能也需要加两次。在这些情况下,一个架构的代码更改,常常破坏另一个架构。这样,负责架构相关项目的开发者们——譬如常常提到的虚拟化团队——不得不疲于同步两个强烈相关的源码树。与之相对应,32位和64位的 PowerPC 架构已经在 2.6.15 的内核中合并为了一个架构源码树,并且大家一致认为这十个成功的合并。不过,类似的合并还没有发生在 x86 的变种们之上。

这种情况可能很快得到改变:Thomas Gleixner 和 Ingo Molnar 最近发起了一个关于合并两个架构的补丁的动议来征求广大开发者的意见。这个补丁将十分巨大,超过9MB 涉及 1764 个文件。它非常依赖于内核源码树的当前状态,因为它只能施加于源码 git 仓库的某一特定时刻 (commit point)。这还不是将要被采纳的补丁,它的目的在于演示打过补丁之后源码树将是什么样子。如果真的到了合并这个补丁的时候,将会是另一番情景:

我们的下一步计划是生成一个细粒度、可拆分的,完全可用的替换,以将当前代码更新到新的 x86 源码树。它将呈现为 1000-2000个 commit (译注:git的版本控制单位,大致相当于分成1000-2000个补丁,打在源码包上。)

这也的确有一些危险。正是了解这些危险,补丁的开发者们努力地让这些补丁的过程更加可靠。这次合并将不会带来任何二进制代码变化:源码树在变更前后将可以生成完全相同的内核映像。

这个补丁创建了一个新的称为 x86 的架构,并将两个已有架构的所有内容移动到其中。有些地方每个架构都有相同文件的完全一样的靠北,只要在新架构源码树中保留一份即可。而更多的情况下,两个架构在同样的位置有内容不同的同名文件,在此情况下,两个文件将被移动到新的源码树中,并相应给与 _32 或 _64 的后缀。这样,比如两个架构都包含 kernel/ioport.c,那么在新的 x86 架构中就同时拥有 ioport_32.c 和 ioport_64.c 了。补丁中还用了一些简单的手法,以保证为目标架构编译代码会生使用确的文件。

在很多情况下(如果不是大部分情况的话),两个文件都有大量的公共代码,兵器,公共代码就留在那了。这个补丁的出发点是:目前的阶段的任务是让两个架构的源码树合并而不影响生成内核映像,这恐怕是这么一个大改动能够被进一步推进的唯一途径。一旦这些内容被合并了,在个体文件中排除重复代码的机会也就出现了——需要被处理的文件通常紧挨着。可以想象一个代码清理大军就将扑向这个工作,各自为战地解决这些问题了。当这些问题也被解决了的时候,我们就有了一个漂亮的、挤出所有重复代码的 x86 架构了,大家就都会高兴了。

不过也未必, Andi Kleen表达了他对于这个改动的反对意见

我觉得这不是个好主意,因为这意味着我们将永远不会丢掉那些老垃圾。IMNSHO (译注:in my not so humble opinion, 源于 IMHO——in my humble opinion,以我愚见,这里表示发文者还是很自信的),arch/x86_64比 arch/i386 在很多方面简洁多了,我更倾向于保存 x86_64 。同样,总的说 arch/x86_64也比 arch/i386 更容易hack,因为它更容易回退测试并且在测试时需要考虑较少的无用数据。我觉得完全去掉老旧的东西之外,没有办法在i386中解决这些问题。

Andi 作为 i386 和 x86_64 架构的维护者在这个讨论中非常强势。他的核心论点是——分成两个架构可以将很多老旧的问题封闭在 i386 源码树中,这是内核代码管理的一个基本思路。只支持较新硬件的代码往往比同时也处理老旧硬件的代码要干净很多,不过,去掉仍在使用中的硬件的支持是很难被接受的。所以,新的子系统被作为新的代码部分增加进来,而老的代码可以被分离出来用于支持老硬件直到它们随风而逝。这方面一个经典的例子是SATA的支持是通过一个新的子系统而不是IDE的扩展来实现的。Andi 和其他一些开发者认为 x86系列处理器也应该这样处理。

不过大多数 讨论的参与者都不同意Andi的意见。和老IDE设备不同,32位架构短时间内不会消失。而i386的变种数量也非常至少。Linus 表示,带着老代码继续前进的时候,把它作为一个共享代码树的一部分比扔进一个角落里要好。最终,争论的结果就是依照最初的提议,基本上公认一个统一的源码树是正确的解决方案。

有一些建议认为这些补丁应该直接进入 2.6.23 内核,不过有可能不这么干。2.6.23 已经有很多新东西了,并且这个补丁也太新了。同时,我们还没有将这个补丁拆成上千个 commit 呢。

更多个关于这个问题:关于合并的讨论还没有完成。在维护者不同意的情况下将两个架构重写为一个是种非常特别的敌意的接管行为。维护者对补丁没有绝对的否决权,但是在一个这么大的补丁的问题上否决维护者的一员不是一件小事。所以,统一 x86 架构的开发者们仍然面临着一个巨大的挑战:他们必须在漂亮地解决技术问题的同时说服大部分的开发者,使他们认为这个改动是必要的。所有牵扯进来的人都非常关心是否可以找到一个方法来说服与他们相关代码的维护者。

Switch to our mobile site