现在使用Linux内核的32位ABI的人已经不多了,事实上只有少数几个发行版没有在编译时就把它们直接关掉,而Ubuntu不幸就是其中之一。本月内核安全邮件列表[email protected]报告了最新发现的32位ABI安全漏洞,安全专家提醒受影响用户尽快升级。
这个漏洞的起源要从Linux内核的X32模式说起。如果在编译内核时打开CONFIG_X86_X32开关,然后给gcc使用-mx32开关去编译程序。Linux内核就允许这个程序使用X32兼容模式运行,在这种模式下用户既可以继续享受64位x86带来的更多寄存器等便利,又可以使用32位指针来减少内存开销---当然,这时您可以索引的地址空间也只有4GB了。为了配合这种模式下运行的程序,内核提供的基础设施之一就是内核64位ABI的兼容层。2012年2月份,Lu Hong Jiu 向这个兼容层提交了一个补丁,用来给系统调用recvmsg提供这种32位的兼容接口。这次他犯了个小错误--对于用户传进来的最后一个timespec指针,没有检查它否是合法的用户态指针(比如检查它是否指向内核地址空间?)就直接向下送进了内部的操作流程。而在内核跑完recvmsg返回时,要向这个指针指向的timespec写入剩余的时间,因此攻击者就可以利用它去操纵内核地址空间内的数据。这里有两个例子:http://pastebin.com/DH3Lbg54 和 https://github.com/saelo/cve-2014-0038
更惨的是,这个CVE Bug是在Ubuntu 13.04停止官方支持两天之后被发现的,因此这个版本的Ubuntu用户不会收到任何更新,自力更生的办法是自己装上这个模块,由于代码中用户传来的指针都加上了__user做修饰,这种滥用本来是可以被代码静态检查工具发现的,事实上它确实被内核自带的静态检查工具Sparse发现了 -- 夹杂在针对这个文件的上百条警告之中。警告太多等于没有警告,内核开发者们说不定又要对此有所行动了。
在数据中心里使用基于ARM的服务器已经吵吵了很多年了。争论的焦点集中在:基于ARM的系统过于丰富,不同机器间的差异很大,这无疑会使在数据中心中大规模应用ARM服务器变得很苦逼。ARM Ltd. 对此心知肚明,所以才搞出了一套“SBSA(Server Base System Architecture)”标准。SBSA的中文可以翻译为:面向的服务器的系统体系结构。目前对SBSA的评论普遍比较乐观,但在开发者社区里还有一些不同的看法。
事实上,拿到SBSA的授权很难。该标准的许可证非常严格,读过它的人不多。Arnd Bergmann这样描述此标准:
“SBSA描述了兼容服务器上的硬件部件的细节。如:CPU,PCIe,定时器,IOMMU,UART,watchdog,中断等等。它对细节描述的很充分,根据这些描述,我们能够在服务器上引导兼容操作系统,并且自己加载非标准硬件的驱动程序。”
Arnd说SBSA制定的内容非常合理。Arm-soc内核树的维护者Olof Johansson也支持这种观点:
“这是一份及其重要的文档,它改变了ARM厂商各自为战的现状。它使得软件开发更加容易,至少不必再担心硬件太多而且容易更新换代。”
简单地说,SBSA正在打造一个基础平台,该平台可以作为所有兼容系统的一部分。在ARM的世界里,一直缺少这样一个平台。这正是过去难以支持ARM的主要原因。在SBSA的支持下,内核开发者,硬件厂商,服务器管理员都会好过很多。
社区中对SBSA的讨论也很激烈。最集中的讨论是:作为平台控制核心的Firmware没有在SBSA中得到描述。而是由一份独立的标准进行了描述。该标准的细节无从得知,但能够确定兼容平台应会同时使用UEFI和ACPI。
UEFI在大多数PC系统中得到广泛应用。UEFI工作的不错,它有一个开源版本的参考实现。内核也可以很容易支持UEFI。因此,在ARM系统上使用UEFI没有什么反对声音。
ACPI就是另一回事了。在X86系统上调稳ACPI本就是一个苦逼的过程。现在把它用到ARM系统上,会更加苦逼。大多数ARM片上系统的厂商在ACPI上的经验接近于0,他们在使用ACPI的道路上会犯很多错。而内核呢,必须逐个的去cover这些错误。
也有很多人在关心“基于ACPI的ARM平台会表现如何。在PC领域,对平台的测试通常就是“Windows能不能跑起来?”,一旦windows能工作,Firmware的开发者就会认为一切OK。这就要求Linux内核的开发者小心处理好那些windows能够做到的事。
在ARM服务器市场上,Windows肯定不再具有统治地位。这些系统上可能根本就不考虑安装windows。取而代之的是“Red Hat Enterprise Linux”。届时,各厂商都会在内核中提交N多补丁以支持他们的ACPI实现。最终可能会导致内核的兼容性下降,Bug泛滥,之后在花费数年的时间取解决它们。
ACPI真的需要吗?ACPI是操作系统探测与初始化硬件设备的标准方法。但ACPI的批评者指出,ARM体系结构已经具有这个机制了。该机制就是“设备树”。设备树已经相当成熟了。就像Olof在最近给Linux的合并请求里说到:
“新板卡,新设备在系统中就是一个新的设备树文件。新设备的加入不需相应的C代码做出任何改变,这意味着设备树系统成熟了”。
已经开始这项工作的开发者们也不禁问道:为什么非要采用一项在ARM中从没用过的PC技术?一些开发者还认为,即使工作完成了,也很难被内核接纳。
Linux内核的开发总是倾向于那些被广泛使用的系统。而ACPI在ARM上的应用有限。Grant可能会把它放在一个长期的Firmware标准上,因为ACPI可以使硬件厂商在保持兼容性上较为容易:
“他们已经有了针对ACPI标准的硬件。ACPI中整合了平台管理工具,他们希望在X86与ARM产品上使用相同的技术。他们也尽力确保操作系统能够直接在这些硬件上启动,而无需在内核里打补丁。使用ACPI使得他们能有度地控制平台的底层细节,从而可以抽象出平台间的差异。”
正如Grant指出的,ACPI与ARM系统的传统操作方式相违背。ACPI把那些ARM平台上需要做的工作(针脚配置,时钟编程等)统统放入了Firmware中,而不再有内核开发者控制。
无论如何,基于ACPI的ARM服务器还是很快会出现,内核也必须要支持它们。内核应该也会同时支持设备树系统。ACPI很快就是走入嵌入式领域。不久后,ARM上的ACPI会是内核支持的配置方法之一。那时,人们也许会奇怪:为什么当初对这事争论了那么久?但是,这个“不久”也可能会超出很多人的期待。
内核社区一直在为高效的电源管理而努力,特别是随着手持智能设备的普及,内核电源管理的相关开发成了一个热点。
电源管理中一个特别难处理的问题就是,保守设置管理可能效果不好,但是过于主动的设置又会引起问题,比如让CPU深度睡眠的时候设备正在进行DMA操作,CPU刚躺下去就要被唤醒,徒增了延迟。为了避免类似的情况,内核之前引入了电源管理质量控制机制(PM_QOS)。通过这个机制,内核的设备驱动可以对电源管理子系统描述自己在延迟方面的需求,避免不必要的睡眠。
在3.2内核开发周期中,这方面的工作又向前走了一步,精细化到可以满足每一个设备的不同需求。驱动可以设置一个值(DEV_PM_QOS_LATENCY),告诉电源管理模块它最大能容忍某个单元的延迟。这样这个单元可以根据这个值选择自己的睡眠方式而不影响到性能。
随着外设的发展,越来越多的外设有了自己内部的电源管理机制。这些机制通常是通过监控读写模式来选择设备的状态。比如说内存控制器发现某个BANK没有什么读写的时候,会自动把这个BANK放到低电量工作状态。这些机制简化了内核方面在电源管理的工作。比如磁盘可以把自己置于不转的状态,相机可以自己选择处于关闭状态。显然,当外设自主选择随眠时,也有不恰当的时候。同前面的设备需要通知内核电源管理模块一样,现在需要一种机制,反过来让内核可以通知外设,告诉它自己在延迟方面的需求,让外设满足满足这个值而避免不恰当的睡眠。比如说,CPU在某个时候告诉一个外设,我有一个请求,你最多只有10纳秒的时间来处理。
目前内核还缺少这么一个机制,通知内核对于外设在延迟方面的需求。这个情况最早会在3.15开发中得到好转。Rafael Wysock同学搞了一套补丁,通过利用现存的PM_OQS机制,实现了内核到外设的延迟通知机制。有支持这种机制的外设,可以通过实现一个回调函数
void (*set_latency_tolerance)(struct device *dev, s32 tolerance);
来满足这个需求。同时会在sysfs下暴露一个可调的属性pm_qos_latency_tolerance_us,代表能忍受这个设备的最大延迟。当然大多数时候写驱动的人不必关系这个特性,因为在设备的电源管理很多在bus这一层就做了。对于这个值的调节,大多数用户程序也是不会关心的。要是设备没有正常工作,倒是可以试试通过调节这个值(如果支持的话)来让它正常起来。
最近Miklos Szeredi给内核社区实现了一个新的系统调用renameat2(),这种系统调用名字后面加1加2的做法很恶心,同时也再次提醒我们在设计内核态和用户态交互的API时一定要考虑清楚,同样的杯具在Linux内核开发过程中多次出现,而Unix也同样不能幸免。
rename()在2.6.16的时候扩展成了renameat()(同时添加的还有其他12个系统调用),而这次新的renameat2()又是renameat()的扩展,这三个家伙都只做一件事情:在同一个文件系统上给一个文件改名。rename()只有两个参数,旧文件名和新文件名;renameat()给他们俩一人增加了一个辅助的参数,用来指明相对的目录:如果这两个辅助目录有效,那么新旧文件名可以是一个相对于这两个目录的,具体请参见man(2) renameat。
renameat2()给renameat增加了一种新功能:原子的交换两个文件名。尽管这两个系统调用有关联,但是我们还是得定义一个新的,因为renameat()竟然没有一个flag用来表示不同的功能,这个太杯具了,瞧瞧人家clone(),open()想得多周到。没办法,renameat2增加了一个新的flag,并使用了一个bit RENAME_EXCHANGE,希望renameat3()这样的杯具以后就别发生了。事实证明这个太赞了,很快Andy Lutomirski真的发现我们又需要了一个bit,RENAME_NOREPLACE,嗯,还好,还好。
还记得前面提到添加renameat()的2.6.16么?我们痛苦得发现和它一同进入内核的系统调用中竟然还有7个也没有任何的flag,让我们记住它们的名字: faccessat(), fchmodat(), futimesat(), mkdirat(), mknodat(), readlinkat(), renameat(), symlinkat()。原文后面分析了这些系统调用是多么需要一个flag,我就解释了,嗯,社区人太多,难免有不同的人犯同样的错误。不过本段结尾的时候话锋一转,以wait()为例说明Unix也是这个熊样,到底是不让Unix占一点便宜呀。
设计新的系统调用的时候一定要想一下我们是否需要一个flag以备不时之需呢? 3.2里增加了两个新的系统调用:process_vm_readv()和process_vm_writev(),并在参数里面添加了一个目前没用的flag,从历史来看应该是一个非常明智的选择。
上文提到设计系统调用的时候尽量要增加一个flag以保证后面的扩展,另外还有一个非常重要的事情:内核必须处理好那些不支持的flag,否则以后的兼容性会成为很大的问题,不过Linux/Unix系统调用的发展历史证明这个事情似乎没几个人记得住。
一个正确的带flag的系统调用应该始终在函数开始做如下检查:
if (flags & ~(FL_XXX | FL_YYY))
return -EINVAL;
FL_XXX和FL_YYY是该系统调用能够解释的flag,如果调用者使用其他的flag,则直接返回-EINVAL。如果将来我们增加一个新的flag FL_ZZZ,那么直接将上面的检查修改为
if (flags & ~(FL_XXX | FL_YYY | FL_ZZZ))
return -EINVAL;
这样用户层的程序就能够通过系统调用的返回值来判断当前内核到底支持哪些flags。听起来挺不错是么?可惜,内核里面有很多系统调用并没有做这样的检查,比如clock_nanosleep(), clone(), epoll_ctl(), fcntl(F_SETFL), mmap(), msgrcv(), msgsnd(), open(), recv(), send(), sigaction(), splice(), unshare(), 名单还有很长... 这些系统调用并没有一个清晰的方式来判定flag是否合法,所以调用的用户就非常痛苦,到这里似乎和内核开发者没啥关系,不过别着急,下面的例子证明他们的日子也不好过。
既然用户没有办法判断方便的判断flags里面哪些bit是非法的,他们就有可能随意的传递一些"没用的"flag,这样当内核开发者想使用其中的一些bit的时候,为了不使用户态程序受到影响,他就必须写一些不那么优雅的实现。一个最近的例子就是EPOLLWAKEUP,为了避免错误,用户必须拥有CAP_BLOCK_SUSPEND权限,似乎有点多此一举。同样的问题出现在O_TMPFILE,用户为了使用O_TMPFILE还得加上O_DIRECTORY的bit才能工作。
有同学可能会说把这些系统调用的处理里面都加上对flags的检测不就好了么?实际上并没有那么简单,由于已经有大量的用户态程序存在,增加这些check很有可能会导致一部分用户的程序突然无法使用,这个是内核社区的大忌,是无论如何不能接受的。到这里似乎最好的办法只有一个了,在给一个系统调用增加flags的时候,必须加入对这些bit的处理。
Linux内核如今是一个有几千名开发者参与的庞大开源项目,但大多数情况下开发者可以互无影响地独立完成工作,这主要得益于内核卓越的模块化设计。但林子大了什么鸟都有,有些修改还是不太容易适配到目前的开发模型中,例如那些修改涉及面广泛、跨越多个子系统的补丁。虽然近年来有所改善,但应付这种补丁无论是提交者还是维护者仍然是一种挑战,提交者需要小心处理依赖关系、做好职责分解,补丁粒度也得合适,还需要为相关开发者提供足够信息,否则双方都会事倍功半。在本文中,作者Wolfram Sang以提交者的角度给出了应付这种补丁的一些建议。
第一个问题就是修改应该以什么形式呈现?下面是一些常用策略:
当然,无论怎样,以上的分解策略都应该只出于技术因素考虑,提高补丁数量尤其不应该作为分拆的目标。如果不确定的话,基于目录组织补丁一般是个不错的起点,在不影响灵活性的前提下要尽量降低补丁数量,而且最好在补丁说明中提一下你在补丁组织上的想法。
无论是什么形式的补丁,"release early, release often"准则都仍然是适用的。准备一个公开的git repo是方便别人跟踪你的修改的好方法。如果修改比较复杂,最好先发个RFC征求一下意见和建议。如果是RFC式的修改,最好从单个子系统上开始。还可以请求吴峰光利用他的牛掰测试环境运行你的修改,如果没有缺陷并且没有反对意见就尽快发出补丁吧,当然不要忘记必要的补丁介绍。有可能一套补丁会经历多个开发周期才能陆续被接受,要准备好不断跟进呵。
如果讨论后决定使用第一种all-in-one的方法,补丁发出后有些维护者反馈说愿意接受你的部分修改,而其余维护者只回复了Acked-By,这时你就可以向Linus发pull request了,通常,rc1之后的短暂时间窗口是个发pull requests的合适时机。同时,也要向Stephen Rothwell发出对linux-next的pull request。当然,你得事先做好patch的rebase。
git send-mail是个发送补丁邮件的利器。除了手工增加邮件接收人和转发人(CC),内核代码树中的get_maintainer.pl是个不错的CC管理工具,它可以接受.get_maintainer.conf作为配置文件。推荐将--no-rolestats写入配置中,这可以避免邮件列表常见的一些垃圾内容。如果采用的是按文件组织补丁,需要使用--git-fallback,因为默认的行为CC子系统的maintainer,而且文件级的修改者。CC过多,会导致退信,这时可以尝试一下--no-m选项,它只收集邮件列表。
文件锁被广泛的用于数据库、文件服务器等应用程序中。当你有多个程序要同时访问一个文件的时候,这些访问没有同步的情况下很容易文件数据的损坏或者其他程序异常。目前的文件锁可以解决上面提到的问题。但目前的文件所实现使用起来较为困难,特别是对多线程程序来讲。本文提到的File-private POSIX locks正在尝试通过融合BSD和POSIX文件所中的元素来提供一个对线程更加友好的文件锁API。
多个写者同时修改同一个文件很容易互相影响。此外,对一个文件的修改可能会发生在一个文件的不同位置,如果另外一个线程看到的仅仅是这个修改操作的一部分,则很有可能会触发程序的异常。
文件锁通常有两种类型:读锁(即共享锁)和写锁(即排它锁)。读锁允许多个程序同时访问一个文件的某个部分;但某一时刻写锁只允许被一个程序获取。文件锁在某些操作系统上是强制的,而在UNIX类操作系统上,文件锁是通常并不是强制的。这样的文件所有点类似于信号灯,他们只有在所有程序在访问文件之前都尝试获取它的情况下才能工作正常。
POSIX规范中定义了文件锁处理的相关细节。规范中定义一个文件的任意字节均可以被一个文件锁保护。但是,很不幸,这个规范中存在很多严重的问题使得现在的应用程序使用起来十分困难。
当一个程序尝试获取一个文件所的时候,系统会根据当前其他锁的状态来批准或者拒绝这个请求。如果没有锁冲突,则批准请求;否则拒绝该请求。
在经典的POSIX文件锁规范中,来自相同进程的锁请求不会产生冲突。当针对一个文件的锁请求与之前进程中一个已经获取的锁有冲突时,内核认为新的锁请求是对原来锁的修改。因此POSIX规范中的文件锁无法用来同步同一进程内不同线程对同一个文件的访问。随着现在多线程应用的不断普及,这一文件锁变得越来越没有用处。
此外,规范中还指出所有归属于一个进程的文件锁在文件描述符被关闭时均会被释放,即使该文件仍然被另一个文件描述符打开着。这一特性使得程序员不得不格外注意在确保文件锁被释放前不能关闭相应的文件描述符。
这就是问题更加复杂了。如果一个程序分别打开一个文件的两个硬链接,在其中一个上获取了文件锁,然后关闭另外一个文件描述符。则文件锁就会被悄悄的释放掉,即使获得文件锁的文件描述符仍然处于打开的状态。
这就造成应用程序在使用库访问文件时候的怪异问题。一个库函数中打开、读/写数据并关闭文件,而函数的调用者根本不知道这一切的发生。如果在调用这个库函数时,应用程序恰好获得了这个文件的锁,则程序根本意识不到锁已经被释放了。这就很可能造成数据的损坏。Jeremy Allision在这篇博客中详细描述了这个问题。
此外,有一个诞生于BSD Unix上的文件锁标准。这个文件锁标准(使用flock(2)系统调用)有一个更加合理的语义。相比于POSIX文件锁总是属于某一进程,BSD文件锁总是属于开打的文件本身。如果一个京城打开一个文件两次,并且尝试设置两个排它锁,则第二次锁请求会被拒绝。所以,BSD文件锁更适合在线程间保证同步语义。注意使用dup(2)克隆一个文件描述符不能保证这一语义,因为这仅仅是增加了打开文件的引用计数。
BSD文件锁会在打开文件的最后一个引用被关闭的时候释放。因此如果一个程序打开一个文件并获得一把文件锁,然后使用dup(2)来复制一个文件描述符,则文件锁会在所有文件描述符被关闭时自动释放。
现在的问题是BSD文件锁只能针对整个文件进行加锁。而POSIX文件锁可以针对一个文件的任意部分进行加锁。当然对整个文件加锁也是有用的。但是在很多应用中,对整个文件加锁是不够的。应用程序,比如数据库,需要细粒度锁来提高并发性。
根据上面的描述,我们现在需要的是一个混合两个文件锁有点的新文件锁——一个带有BSD文件锁语义可以跨fork(2)和close(2)系统调用的字节范围锁。此外,因为有很多程序依然在使用传统的POSIX文件锁,所以新的文件锁要能够识别传统POSIX文件锁并正确处理他。
经典POSIX文件锁使用fcntl(2)系统调用来控制:
* F_GETLK - 测试是否可以获取一个文件锁
* F_SETLK - 尝试获取一个文件锁。如果失败返回错误
* F_SETLKW - 尝试获取一个文件锁,如果不能获取则阻塞
这些命令的使用需要一个struct flock参数:
于此类似,file-private POSIX locks使用相似的命令集,只是添加了‘P'作为后缀:
* F_GETLKP - 测试是否可以获取一个文件锁
* F_SETLKP - 尝试获取一个文件锁。如果失败返回错误
* F_SETLKPW - 尝试获取一个文件锁,如果不能获取则阻塞
新的命令看上去和此前的命令非常类似。唯一的区别仅仅是文件锁的属主。经典POSIX文件锁属于进程而file-private POSIX locks的属主是打开的文件。
目前为了使用新的文件锁需要定义__GNU_SOURCE预定义宏,因为目前file-private POSIX locks还不是POSIX标准的一部分。新文件锁的使用和旧的十分相似。在大多数情况下,应用程序可以直接用新文件锁替换旧的文件锁,尽管他们有些许不同。
经典POSIX文件锁的最大问题是在文件被关闭时,所以很明显新的文件锁在文件被关闭时的行为在这里是不同的。新的文件锁仅在打开文件的引用计数为0时才会被自动释放。
尽管我们很容易认为新的文件锁事属于文件描述符的,但这一描述严格说来并不准确。如果一个文件描述符是通过dup(2)克隆的,内核仅仅是对已经打开的文件的引用计数加一并且在文件描述符表中分配一个新的槽位。使用新文件锁对一个克隆的文件描述符进行加锁并不会与原始的文件描述符产生冲突。内核会将这样一个锁请求认为是对已有锁的更新请求。同时,一旦两个文件描述符都被关闭,file-private lcoks也将自动被释放。尽管应用程序可以通过F_UNLCK请求手工释放锁。
fork(2)背后的操作也是类似的。当程序调用fork(2)后,内核会增加已经打开的文件的引用计数并且在新进程的文件描述符表中分配相同的槽位。对于相同已打开文件的锁之间不会冲突并且在两个进程均关闭该文件后,文件锁会被自动释放。
经典POSIX文件锁和file-private文件锁同时使用会有冲突,即使在相同进程或者相同文件描述符上。很明显不应该同时混用新旧文件锁。
F_GETLK也许应该被命名为F_TESTLK更加合适。进去严格来讲,他确实获取了锁的状态,但是它真正的用途是在不尝试加锁的情况下测试一个给定范围的文件锁是否可以被获得。如果在此范围内已经有锁被其他进程获得,那么内核会覆盖掉struct flock的相关信息,并且设置l_pid来指明当前获得锁的进程。
l_pid对于file-private locks来讲是一个问题,因为file-private locks并不属于任何进程。一个文件描述符可能是通过fork(2)继承而来的,所以l_pid的数值对于file-private locks来讲已经没有任何意义。
在Linux中,POSIX和BSD文件锁事完全不同的名字空间。而在BSD上,他们在相同的名字空间。因此在BSD上,POSIX和BSD文件锁是相互冲突的。如果一个程序获得了一个BSD文件锁,而另一个程序尝试通过F_GETLK获得所得状态,BSD内核会将l_pid设置为-1。由于可移植的程序已经针对此行为进行了处理,所以对于file-private locks来讲,将l_pid设置为-1师一个合理的选择。
下面我们通过一个例子来看一下file-private locks在线程中的使用:
上面的程序首先创建了3个线程,每个循环5次向文件中追加写入数据。文件的访问通过file-private locks来进行同步。上面的程序在运行后,我们会在文件中看到15行数据。
如果我们将上述程序中的F_SETLKP和F_SETLKPW替换为旧的POSIX文件锁,则我们得到的会是损坏的数据。
File-private locks能够解决目前我们在使用经典POSIX文件锁时遇到的问题。但是在使用中程序员需要注意新旧文件锁的区别。
来自很多项目(Samba, NFS Ganesha, SQLite和OpenJDK)的开发人员已经对新文件锁表示了极大的兴趣。因为新文件锁可以简化程序中关于文件锁的代码,同时避免数据的损坏。
相关的内核补丁已经可以在邮件列表中获取,或者通过linux-next代码树获取。在linux-next中的相关代码将会被及时的更新。所以你现在就可以通过linux-next来使用新的文件所。同时这里有一个glibc的补丁,其中定义了新文件所的相关访问方法。
目前内核代码预计将于3.15版本合并进linux内核,随后glibc的相关代码也会被合并。与此同时,一份关于更新POSIX规范的请求也已经被提交,以便后续工作的开展。
C11标准为C和C++增加了一系列新特性,其中之一便是内置的原子类型,内核开发社区应该会比较感兴趣。但是经过一些讨论之后发现,内核社区似乎对切换原子类型的需求并不迫切。目前内核已经提供了一些原子类型及对应的操作方法,可以允许变量被原子的访问而不需要显式的加锁。C11原子类型有助于内核原子类型的实现,但它的作用显然不止如此。对C11原子变量的访问附带有一个显式的"内存模式(memory model)",它用来描述什么类型的内存访问可以被处理器或编译器优化,最宽松的模式允许操作被重新排序或者合并来提高性能。默认的模式("sequentially consistent")是最严格的,不允许对操作进行任何合并或重排,因此代价很大,而这些代价在不影响正确性的前提下会显得有些浪费。最宽松的模式允许在可控的方式下做一些优化并确保排序的正确性。
C11原子变量包含内核里称为内存屏障(memory barrier)的特性,例如在内核里可能有如下代码:
smp_store_release(&x, new_value);
smp_store_release() 语句告诉处理器确保它之前的所有读写操作在x赋值之前都已执行完成,而该语句之前的操作可以被重新排序,该语句之后的操作也可以被重排。在大多数情况下,操作都可以被重新排序而不影响正确性,因此只在必需的地方使用barrier可以使得其他大部分不需要barrier的访问被优化并显著提升性能。
如果x是C11原子类型,可以写成:
atomic_store(&x, new_value, memory_order_release);
memory_order_release指定了和smp_store_release()相同的顺序要求。
smp_store_release()的实现方式采用了特定于体系结构的代码,而C11方式则是在编译器里实现。当内核开始支持多核系统时,C语言并没有原子类型或内存屏障的概念,因此内核开发者必须自己想办法。然而既然现在语言标准也提出了解决办法,内核似乎应该使用标准的原子类型,虽然这个转变过程可能会很慢。
编译器的一个判断标准经常是代码的生成速度,编译器开发人员在优化代码时就会尽可能地追求这个目标。写程序时如果不仔细研究标准的话,一些情况下的优化可能会违反代码逻辑。在内核开发者看来,编译器开发者经常对标准咬文嚼字来判断是否进行优化,而这些优化通常没有意义或者违反代码。内核里并行度很高的代码在进行优化的情况下会更易于出错,因此内核开发者需要更加小心。其中一个问题是"写预测(speculative stores)",造成一个不正确的值对别人可见。一个经典的例子代码如下:
if (x)
y = 1;
else
y = 2;
编译器很可能进行如下优化:
y = 2;
if (x)
y = 1;
对于在自身地址空间里的顺序代码而言,优化结果是相同的且后者会省去一次跳转,但如果y在别处可见,在判断x之前就提前存入值就会引起错误的结果,因此这种会被其他运行进程可见的错误优化就需要被避免。标准里对原子变量行为的描述比较复杂,内核社区对写预测也比较担心,代表内核方面参与标准委员会的Paul McKenney对这种可能性也不是完全确定,他认为虽然标准基本上可以避免写预测行为,但是可能会有一些corner case。
另一个关心的问题是控制流依赖:原子变量和控制流交互的地方。考虑一下代码:
x = atomic_load(&a, memory_order_relaxed);
if (x)
atomic_store(&y, 42, memory_order_relaxed);
y的赋值依赖于x的值,但是C11标准目前没有进行描述,这意味着编译器或者处理器可以任意处理这两个原子操作的顺序,更甚至把判断分支优化掉,而这种优化结果可能会给内核带来灾难。针对这种情况Paul建议增加一些标志和一个新的memory_order_control内存模式来使控制依赖关系更加明显:
x = atomic_load(&a, memory_order_control);
if (control_dependency(x))
atomic_store(&b, 42, memory_order_relaxed);
但是因为Linus对这个主意很不爽,不大可能被采用。Linus认为控制依赖关系很明显,代码里已经写了要先判断x的值,任何错误地改变atomic_store()的编译器行为都违反了代码。
还有一种"值预测(value speculation)",编译器提前猜测变量的值,如果预测错误就再加入一些分支去修正,预测正确的话处理器的硬件预测分支会再次加速执行。Paul的一些工作证明值预测有时是错误的,庆幸的是那些情况将不被允许,但也不能100%确定当前标准会禁止所有可能的情况。
另一个忧虑是关于全局优化,编译器开发者会在整个源文件或源文件组的层次上优化程序,如果编译器理解变量的使用,那么优化效果经常会不错。但是编译器并不了解程序执行的硬件信息,因此标准规定它应该假设是在一个虚拟的机器上工作,因此如果真实的机器和虚拟机器行为不一致,结果就可能出现问题。Linus举过一个例子:编译器可能搜集内核访问页表的信息并注意到代码里并不会设置"page dirty"位,它如果得出任何测试dirty位的判断都可以被优化掉的结论就显然错误了,因为该位是由硬件进行设置的而不是内核代码。Linus认为这种优化本身就违反了定义,因为它假设所有信息都会在程序中给出。Paul也给出了一个列表证明编译器的虚拟机器模型和真实情况并不匹配,他的例子包括汇编代码,内核模块,内核空间内存映射到用户空间,JIT-compiled BPF代码等,这些都说明内核中的很多信息编译器都不太可能知道。
对这些非局部问题的一个解决方法是对易于出错的变量使用volatile,但这会关闭对该变量的所有优化,也不能使用原子变量。如果必须使用volatile,内核还不如继续使用当前的内存屏障,它起码能允许编译器和处理器上尽可能的优化。虽然有这些担心,但Linus也认为现实情况下编译器不大会这么干,它们不至于破坏依赖关系链,虽然说没有明确的保证。
Linus认为如果C11原子变量的标准和实现不能满足内核的需要,内核就不会使用这个特性。内核非常复杂非常庞大,是C11方案最想应用的客户,但是内核现有的方案工作的很好,如果没有很好的理由他就不会切换过去。Torvald也同意这个观点,但他也认为使用这个标准机制也会有一些好处,比如广泛的测试,以及把内核不同体系结构相关的代码都换成更通用更易维护的代码等。另外使用C11原子类型也会从一些学术研究工作中获益,剑桥大学的一些研究人员也提出了C11下的并发应该如何工作的论文等。如果最后大量程序都开始使用C11原子类型,编译器的质量也将会有很大提升。
最后如果C11原子类型被广泛使用,就有可能为C/C++在高并发环境下建立可靠的模式规范,而当前开发者并不知道如何做才是安全的。这样C11标准就有点类似于"封装"且原子类型可以成为更通用的语言,这样开发者可以更好的理解并发以及允许的优化。
因此如果使用这些新的语言特性内核也会有所受益,但是转换的过程也会很漫长,毕竟底层代码的并发性bug很难定位。路漫漫啊。
接着上篇文章,最近的讨论围绕着称为"consume"的内存访问顺序。
内存访问顺序方面有两个操作称为"acquire"和"release",这个链接介绍了标准里的模型含义。简而言之,带有"acquire"语义的读操作必须保证发生在同进程里所有后续读写操作之前,带有"release"语义的写操作必须发生在该句之前所有读写操作的之后。这两个语义经常一起使用,最终修改一个数据结构的写操作带有"release"类型,而读该数据指针的操作带有"acquire"类型。"acquire"和"release"是无锁共享数据环境下一个常见的概念,但是在很多情况下一个acquire操作提供了过于严格的顺序要求,而有些没有必要。带有acquire语义的读操作限制了所有后续的读写顺序,即使这些请求跟本次的读数据没有任何关系,而它们其实可以被编译器或处理器进行重排序的优化,这种情况就是内核希望acquire所能提供的较弱的顺序保证。与之相关的是read-copy-update(RCU)操作,简单说RCU的工作原理是保护通过指针访问的数据结构,修改数据时需要先分配一个新的数据结构,将更新数据拷贝过去,最后更新指针来指向新的结构。访问该数据的代码要么看见旧的数据指针要么看见新的数据指针,但在访问的当时都是有效的。这其中有一点很重要,更新数据结构指针之前,新的结构里的数据必须已经有效,否则可能会有进程访问到无效数据。我们可以使用带release语义的指针赋值来满足这个要求,而读指针的操作(经常通过rcu_dereference())带上acquire语义,但是其实真正要关心的是 获得指针 和 访问该指针的数据 两者之间的顺序,而这在很多处理器上不需要内存屏障就可以达到目的。
提供较弱版本的顺序保证就是"consume",它只保证和读操作有依赖的写操作的可见性。代码类似于:
p = rcu_dereference(pointer);
q = p->something;
*a = something_else;
在acquire语义下,*a的赋值语句不能重排序到rcu_dereference()调用之前,而consume就可以,而且在一些体系架构下运行时排序的代价也非常低(或是0)。目前RCU类的技术经常使用在性能敏感的环境下,因此如果这时有consume语义就值得一试了。
目前consume顺序的问题在于标准要求记录数据访问的所有依赖关系,而这些记录通常很难做,且记录的结果对于开发者也不易读,经常有GCC报告的bug反应consume顺序的处理不正确。而一些编译器的consume实现其实就是acquire,有着正确的结果但是性能较低。这些问题都使得consume很难在内核里使用,内核也会继续使用体系机构相关的宏或内存屏障。但是标准本身能否对consume顺序做出一些修改呢?Linus有一个建议,去掉繁杂的描述依赖关系的语言以及跟踪记录机制,而换成一个更简单的方案:consume只保证 原子读操作 和 通过直接或间接指针链来访问数据的操作 之间的顺序(原话是:The consume ordering guarantees the ordering between that atomic read and the accesses to the object that the pointer points to directly or indirectly through a chain of pointers)。想法很简单,其实就是提供RCU所需要的顺序保证即可,但是这块有一些小问题,"指针链"的概念是指赋值或者简单的修改,例如赋值是:
p = rcu_dereference(something);
下面这些赋值也会产生指针链:
q = p;
r = p + 1;
但是"指针链"并不包含别名,如果别的指针也恰巧指向p指向的结构,通过该指针发生的访问就不会有顺序保证,这使得Linus建议的consume语义不同于标准的定义,因为后者要求编译器也获得所有的别名指针信息。
Paul McKenney向标准委员会写了一个十二条准则试图描述"指针链"的构成,有一个例子是:
q = p & ~0x1;
r = p & 0x1;
逻辑AND操作在内核很常见,指针的低位bit常被用来保存额外的flag信息,q的赋值语句可以当作是有依赖关系的指针链,但r的赋值语句不是。编译器可以发现第二条赋值其实是生成一个整数而不是指针,因此便可以做相应的优化。但是Linus说你可以想出各种各样的方式来绕过依赖链,比如:
p = atomic_read(pp, consume);
if (p == &variable)
return p->val;
这种情况下编译器可能将p->val替换成variable.val,这样就不是指针链也没有顺序保证,variable.val的读操作也可能发生在原子读之前。而如果把 == 换成了!=,那么指针链和操作顺序就能保证,因为编译器提前不知道p指向何处。
deferrable timers的概念最早可追溯到2007年。可延迟定时器(deferrable timer)可用于定时器到期和超时代码执行之间可以存在一个任意延迟的情况。在这种情 况下,定时器到期时间可以延迟直到CPU被其他事件唤醒。可延迟到期时间可以通过这种方式减少CPU唤醒次数,也因此可以减少耗电量。
deferrable timers在内核依旧支持,但是并没有提供给用户态。导致定时器相关的系统调用(包括timerfd_settime(),clock_nanosleep(),和nanosleep())将在定时器到期时尽量通知用户态,即使用户态能够容忍一定的延迟。这对致力于改善耗电量的开发者来说很不能接受的。对于这些开发者来说有个好消息,经过数次错误的开始,现在看来deferrable timers终于有望对用户态提供。
一些读者肯定会想到提供给用户态有的timer slack机制。但是,这个机制会影响所有的定时器,对于一些应用部分定时器可能比其他的可延迟更久。带slack的定时器只能被延迟一样的时间,意味着它们到期时还是需要唤醒一颗正在休眠的CPU。deferrable timers可能会很适合这样一些timer slack不能很好处理的用例。
早在2012年,Anton Vorontsov曾发过一组patch用于为timerfd_settime()系统调用添加deferrable timer支持。在合并这个patch的过程中,Anton碰到了一个问题 :和所有其他定时相关系统调用,timerfd机制使用了内核的高精度定时器。而高精度定时器不支持deferrable操作,这种功能仅仅限用于老的"timer wheel"机制。timer wheel是存在很多问题的老代码,被提出需要移除已经有几年了,但一直没有人完成这部分工作,使得timer wheel仍然在那,同时也是唯一一个支持deferrable选项的地方。
Anton做的是通过这两个机制在timerfd子系统中将定时器分开。普通的定时器请求将照常使用高精度定时器子系统,而任何请求中如果带有TFD_TIMER_DEFERABLE标 识,则会由timer wheel处理。除其他事项外,唯一限制的是deferrable timers精度只能达到一个jiffy(0.001到0.001秒,由内核配置决定),但是,如果这个定 时器是deferrable,低精度并不一定是个问题。不过,这个补丁并没有走很远,Anton似乎很快就放弃它了。
最近,Alexey Perevalow重新拾起这一概念,尝试着去推进。他首先在一月份发了一个patch,这个一发带来的讨论甚至超过了之前那次。John Stultz更关心的是仅仅timerfd定时器能够获得这一新功能,他觉得更好的一个方式是将这一特性实现到更底层,使得所有的定时器相关的系统调用都将受益。这样的话,意味着需要为高精度定时器子系统添加deferrable功能。
Thomas Gleixner语气强硬的指出timer wheel的使用在将来将不会再存在。他建议这一功能应该通过提供一组新clock ID的方式添加到高精度定时器。clock ID由用户态提供,用于指明使用哪种时钟。例如,CLOCK_REALTIME对应于系统时钟,CLOCK_MONOTONIC时钟被保证单调递增。也还有其他一些时钟,包括在3.10添加的对应国际原子时间的CLOCK_TAI。Thomas抛出一组概念验证的补丁,添加了所有这些新版本时钟(如CLOCK_MONOTONIC_DEFERRABLE),用于提供deferrable操作。
然而John争论到clock ID并不是正确暴露给用户态的接口:
我的理由是,可延迟性并不是一种时钟域,而更多只是一个修改。为此为了保持合理的接口(避免必需要为每个新修改添加N个新clockid),我们应该使用定时器的flag参数。所以,相对于当前仅有的TIMER_ABSTIME,我们可以添加比如TIMER_DEFER,以供利用。
内核使用clock ID作为内核实现是没问题,但是提供给用户态用于请求该特性正确的方式是一个可以修改的flag,他说到。幸运的是几乎所有相关的系统调用已经有一个flag参数,唯一大例外的是一些开发者希望看到简单消失掉的nanosleep()。这样的话,John的争论获胜并没有真正的异议。
Alexey发了这组patch的很多版本,但是并没有得到Thomas的赞同。Thomas最终发了一组他自己的deferrable定时器补丁,以展示他觉得这个问题该如何解决。在这组patch中,clock_nanosleep()添加了一个新的TIMER_IS_DEFERRABLE标识,而timerfd_settime()添加了TFD_TIMER_IS_DEFERRABLE。任何一种方式设置标识都将导致内核使用新deferrable内部时钟ID的一个。基于这些ID的定时器并不对硬件编程,因此他们并不产生中断,也无法唤醒系统。到期函数将在系统被其他进程唤醒>时执行。没有使用新flag的系统调用和之前一样。
Thomas的补丁在除了底层实现细节外,并没有收到太多的评论。如果这种情况持续,静默意味着同意。因此,若无意外内核可能会在3.15左右版本为用户态提供deferrable timer。
Brendan Gregg目前作为性能优化工程师供职于Joyent。Joyent是一家云计算提供商,它维护了一个基于Solaris的操作系统SmartOS。SmartOS是一个诞生不久的操作系统,但是Gregg已经拥有在其他Solaris系统上进行性能分析和诊断的大量经验。他在SCALE 12x中的演讲介绍了Linux和Solaris阵营应该从对方那里学习的东西。
Gregg从一个基本的问题开始试着了解不同操作系统的不同。为什么一个应用程序在Linux和Illumos的表现会有不同呢?(Illumo是一个基于OpenSolaris的开源操作系统。)SmartOS使用Illumos内核,Joyent则同时提供SmartOS和Linux镜像给客户。所以从某些角度来说,这是不同操作系统内核之间的比较,但是在很多方面,系统架构的其他方面也对性能有着巨大的影响。Gregg将这两者作为不同的问题区别开来。
Gregg通过一个循环1亿设置字符串变量的perl脚本来比较两个系统性能上的差异。尽管只有一行,但是这一脚本在两个系统上运行的结果性能相差了14%(他没有说哪个系统差)。他指出,尽管只有一行,但要想优化这一程序则是十分复杂的。因为在Linux和SmartOS系统上,不同的因素太多了。perl解释器的版本,被不同编译器编译的perl解释器,不同的编译器优化参数,不同的系统库,不同的后台任务等等。任何这些组合都会直接影响程序的性能。当然这其中也包含内核的因素:设置字符串涉及到内存IO,内核自己来决定代码在内存中的位置,内核控制CPU的时钟频率,内核可能受到中断的影响,内核可能会将进程迁移到不同CPU上。
而问题的核心是客户希望了解这一14%的性能差距根源出在哪里?作为一名性能优化工程师,Gregg希望自己能够跟踪到引起这一差别的根源(尽管这一根源并不总是很容易观察到),判断这一差别是否是不同内核之间的差别,并且这一差别是否可以被修复。这些问题并不容易回答。很多时候,比较Linux和SmartOS之间的差别就想比较美国和澳大利亚的差别一样。他们有很多不同,也有很多相似之处。
这里他列举了两个内核中一些比较重要的影响性能因素。Linux上总是有更新及时的软件包,这些软件可以被更广泛的测试,设备驱动也更丰富,配置选项页更丰富。严格来说,RCU,futex和动态时钟对性能有着比较大的影响。而SmartOS上则有十分成熟的Zone虚拟化系统,ZFS文件系统,DTrace框架,可用于性能排查的丰富的符号表,优秀的CPU扩展性(源于Solaris内核通常会在众核机器上被反复测试和分析)。此外,这两个内核有很多小的区别也会影响到性能。
Gregg提醒我们,他演讲的主题是两个内核互相能从中学习到什么。
Solaris能从Linux中学到的是:
从非技术角度讲是丰富、被及时更新的软件包。Solaris经常遇到的性能问题是由于过旧的MySQL或者OpenSSL造成的。
从技术角度来讲,Linux中的likely()/unlikely()为编译器的分支预测提供了一种很有效的机制。Solaris中则缺少此类机制。Linux中的tickless特性也是一个改进性能的优秀特性。Solaris中目前仍然使用clock()函数来获取时钟周期。Gregg经常遇到的问题是10ms的延迟(clock()默认的时钟频率)在将close()频率修改为1000Hz后变为了1ms。
目前Solaris会将进程完全交换出去,这一特性从PDP-11/20时代就被引入了。在那个时期,把整个进程患处是合理的,因为当时进程最大不过64KB。而分页的支持时候来才加入的。Gregg认为应该将整个进程换入换出特性抛弃。另一方面,Solaris对于虚拟内存是有限制的,而Linux则允许尽可能多的使用使用内存,并通过OOM来进行回收。Solaris的工程师不能想象这一特性。Gregg说,使用这一特性的Linux运行在一个手机上也许是好的,但是对于服务器则说不同。同时他也警告目前Linux中很多地方并没有检测ENOMEM错误。
一个有趣的例子是Linux中的SLUB分配器。SLUB是Solaris中SLAB分配器的一个简化版本,但是它可以带来一定的性能提升。因此Solaris应该考虑将其移植回来。此外,Solaris也缺少Linux中懒惰TLB模式。这一模式在Linux中带来了显著的性能提升。Linux中的Sar工具能够提供比Solaris更丰富的信息。这也是值得Solaris学习的地方。
最后,Gregg指出尽管Solaris有成熟、可靠的虚拟化机制Zone。但是Zone目前只能运行一个内核。而KVM则可以运行多个OS。Joyent开发团队已经将KVM移植到了Illumos内核上,但是Oracle还没有合并。
当然,Linux也有很多需要学习的地方。ZFS文件系统是一个非常棒的文件系统,它有很丰富的特性。尽管因为许可证的问题,Linux不能直接合并ZFS代码。但是Linux中有Btrfs和ZFS on Linux。Solaris中的Zones虚拟化技术有很好的性能,而最近Linux也开始学习其中的概念,比如LXC,cgroup等等。
Gregg在这里提醒大家注意Solaris中的STREAMS。它曾被作为内核消息模块在Unix第八版时引入。Solaris使用它来实现TCP/IP协议栈,结果是在过去的数年中不断出现的性能问题。
另一方面,Gregg指出Solaris上很容易进行性能分析。而在这方面Linux则做得不尽如人意。Linux上默认情况下编译器会丢弃符号表,因此性能分析工具只能输出难于辨认的十六进制代码。同时由于编译器会丢弃栈帧,因此Linux上对于栈的分析也很困。Gregg建议应该使用-fno-omit-frame-pointer选项。Solaris上的prstat -mLc命令可以提供丰富的线程状态分析,而Linux上则机会没有任何微观统计信息。Linux可以学习的还有在htop工具中提供更加丰富的功能。SmartOS中还有一个叫做vfsstat的工具可以同来输出vfs中锁冲突,资源限流等等信息。
关于性能分析工具最大的争论是DTrace。DTrace是一个可编程、实时的性能跟踪工具。它几乎可以解决任何性能问题。最为关键的是DTrace可以直接用于生产系统上。目前DTrace在Linux上有两个实现。Gregg这里给出的Linux应该学习的是生产安全永远是最重要的。DTrace绝对不会将内核搞奔溃。你可以向使用top一样使用它。
Linux上有很多项目可以提供相似的功能,比如perf和ktap。尽管这两个并没有完全为生产系统做好准备。SystemTap看起来虽然不错,但是仍然有问题存在。Gregg说他自己并没有时间来仔细使用LTTng,所以不好给出评价。
Gregg指出,目前Solaris中DTrace的一大不足是Oracle提供的DTrace代码不全。如果DTrace4Linux可以不全这些欠缺的特性,那就真是太完美了。
Linux需要学习Solaris中追求性能的文化。Solaris长久以来被高端支付客户使用,因此具备很多性能分析工具。Linux在不久的将来也会由此需求,而在这些性能分析工具上,Linux仍然有很大的欠缺。
最后所有人都想知道到底Linux和Solaris那个更快。Gregg则表示他自己在不同场景中遇到过5%到500%的性能差别。但他指出,最重要的不是两个系统的性能差别,认识你自己的系统上的性能是多少,你是否可以自己将性能优化到满意。
OpenVZ的Kirill Kolyshkin在今年的SCALE 12x上有一个talk,题目是“Seven problems with Linux containers”。从名字上看似乎是在批评container,不过实际的内容是关于在容器化(containerization)过程中的挑战以及对应的策略。在开篇的时候,Kolyshkin提到在LinuxFest Northwest上他提到了6个问题,到了SCALE 12x变成了7个问题,所以实际上问题是在不断增加的,而每个问题都可以充分展开来说。
首要的问题是如何在用户不增加硬件成本的条件下最高效得搞定虚拟化。目前OpenVZ使用的是Linux container,这个是在目前各种虚拟化方式下最好的办法。但是我们应该看到,从历史上看大机上的虚拟化曾经是最好的,并衍生了许多新的虚拟机管理方式,但是他们却一直在持续的优化中。
如果把计算机看成一个三层三明治(这个比喻有点意思),这三层分别是硬件,操作系统和用户态,每层都可以有自己的虚拟化方式。Intel从硬件来支持虚拟化,VM hypervisor通过hypervisor来提供多种操作系统的虚拟化,container则是通过用户态来做。在container的方案中,我们仅仅是让各个进程不可见,所以效率最高。另外Linux还有各种各样的namespace,比如PID, network, users, inter-process communication (IPC)这些,所以简单的实现虚拟化。
第一步把进程限制在container里面以后,随着而来的是另一个问题:如何在各个container之间分配资源,比如CPU,内存,磁盘等等,另外还有有一些需求比如需要设置不同container的优先级以及防止对某个containter的ddos攻击会影响到其他container。
OpenVZ通过一整套资源控制方案来对每个container进行控制,其中一个是从HP/UX借鉴而来的beancounters,它大约可以以进程为力度控制20个左右的进程资源,但是事实证明这是远远不够的(比如像apache,PostgreSQL,它们会启动很多进程)。Linux upstream实现了cgroup,可以控制进程组级别的资源隔离,但是还是不够成熟。
前面提到了资源限制,就引入了另一个问题:如何让用户理解这些概念。前面提到OpenVZ有20个左右的资源控制维度,这么多的维度又相互独立,无疑极大增加了理解的难度,OpenVZ因此写了很多wiki以及知识库的文章,还做了一些工具想让用户能够快速理解。不过杯具的是,用户还是不太理解并不停的抱怨,"我只想运行数据库,为啥让我学这么多东西"。最后没有办法,OpenVZ提供了一个VSwap的概念,它只包括RAM+SWAP,这样就通俗易懂多了。
OpenVZ可以在两台不同的机器之间做热迁移,这一点连Solaris的zone都无法做到,但是如果做热迁移就必须做到足够快。一些大的container在运行Oracle数据库,这意味着内存中有大量的数据需要迁移,这样对于终端用户的查询相应会有很大的影响。OpenVZ的热迁移一般有如下几步:冻结container的一切操作,将container的状态导入到一个文件,将文件传输到目标服务器,然后再将状态导出,最后恢复container的运行。用户能感觉到延迟主要是因为大量内存导致文件导入和传输的速度都非常的慢。于是OpenVZ开发出一种新的办法——network swap,它只将很小一部分关系到container启停的内存传输到目标服务器,而后面的任何内存需求则通过fault的方式来读取。这种方案很快,但是却很容易受到网络的影响。所以OpenVZ新的方案是修改Linux内核,让它记录被修改的页,然后周期性的拷贝它们从而达到一种类似rsync的效果。
将对内核的修改合并进入主线似乎是OpenVZ目前最大的问题。在刚开始的时候,OpenVZ并没有想过将自己的修改合并进入Linux内核主线,于是它自己维护了一个很大的patch,但是渐渐的,它意识到将修改合并进入内核主线可以降低自己维护patch的时间并减少自己移植patch的次数。后来,OpenVZ开始尝试合并,不出意料的有很多的阻力。于是OpenVZ开始按照内核社区的要求改写代码,比如用cgroup改写beancounters,重新修改PID名字空间等等。对于有些社区不欢迎的patch,它们改写成了用户态,比如用户态的checkpoint/Restore,令人高兴的是,google和samsung对这个也很感兴趣,也算是意外收获吧!
第六个问题是在container之间共享文件系统。container的根目录其实就是普通文件系统里面的一个目录,我们通过chroot让container内的用户以为是根,这样带来的问题就是如果在一个文件系统中安装了大量的container,那么元数据更新的性能将会是一个很严重的问题。大量的小IO会影响上面提到的热迁移,并导致DOS的发生。这里Kirill举了一个例子:你可以试着对一个1MB的文件执行一百万次truncate,每次只truncate一个byte,那么整个文件系统的性能都会受到很严重的影响。另外目前比较成熟的文件系统还不支持对某个目录做snapshot和disk quotas(注:btrfs没人敢用),这里译者做个广告:阿里内核组已经加入了对目录quota的支持,另外可以通过阿里内核的overlayfs达到某种snapshot的效果。
LVM只支持块设备而loopback设备却不支持动态空间分配,所以OpenVZ使用ploop,它支持动态调整空间,实时snapshot,并支持多种文件格式。
Kolyshkin提到的最后一个问题和存储相关。OpenVZ的很多客户都是做虚拟主机的,所以每个主机都会有很多盘。最新的统计显示这些用户的平均磁盘利用率是37%,最高的达到51%,最低的也有14%,所以这里磁盘利用率成为很大的问题,但更重要的是浪费的磁盘带宽。
解决这个问题最简单就是用SAN了,SAN提供搞可用性,高性能以及高利用率,另外由于SAN是共享的,所以container热迁移的时候也不需要拷贝数据,不过SAN最大的毛病就是它的高价格啦。于是OpenVZ自己做了一个存储系统pstorage(Parralles Cloud Storage),把所有的磁盘做成一个虚拟的SAN。看描述就是一个分布式文件系统了,这个大家都有的啦,呵呵。