阿里内核月报2014年12月

简介: 运行时间敏感的用户一直以来都很希望有一种方法可以在不重启系统的情况下对运行的操作系统内核打补丁。目前有几个还没有进入主线内核的实现(比如:kpatch, Kgraft)。很明显,这些实现不可能全部进入upstream内核。

Live kernel patching on track for 3.20

运行时间敏感的用户一直以来都很希望有一种方法可以在不重启系统的情况下对运行的操作系统内核打补丁。目前有几个还没有进入主线内核的实现(比如:kpatch, Kgraft)。很明显,这些实现不可能全部进入upstream内核。从最近Jiri Kosina发出的一组补丁来看, 内核热升级补丁的开发似乎又向前迈进了一步,kpatch和kGraft的开发,并且内核热升级的核心功能已经被Reviewed/Acked过。并且双方都同意共同进行下一步的开发工作。目前核心功能的补丁计划在3.20版本进入到主线内核,我们即将在主线内核中看到内核热升级功能。

从此前的开发角度来看,kpatch还是具有一定的优势的,主要原因是kpatch使用stop_machine()的方式来对代码进行替换,这一技术已经在ksplice上经过验证并且确认是可以有效果工作的。而kGraft使用的类似RCU的机制则还缺少实际使用的验证。此外,kpatch拥有较为完善的用户太工具来帮助用户生成、管理热升级补丁。而kGraft在这一方面走的较慢,用户态工具的开发仍然较为缓慢。相信随着内核热升级补丁的核心功能进入主线内核以及两个项目组共同的努力,很快我们就可以使用上较为晚上的内核热升级功能。

对混合存储设备的内核支持

我们这里说的混合存储,就是指用基于Flash的设备(如SSD,FusionIO卡等等)给传统的旋转式硬盘做Cache,达到加速的目的。Linux内核最近已经加入了不少软件实现的混合存储方案,比如Bcache和dm-cache,还有没有进入内核主干的flashcache。同时,硬件厂商们也有很多自己的硬件解决方案--在一个黑盒子里装上硬盘和一些Flash存储设备,硬件做进去一些算法来自动判断哪些数据是热点,需要放到Flash设备上,让它们能自动升降级。然后对外仍然表现出好像只有一个设备的样子,这样不需要软件支持也能做加速。

然而,判断出某些数据比另一个数据更热,更值得放在Flash设备上并不是容易的事,由于硬件所在的层次太低,有用的信息在漫长的IO路径上已经被过滤掉不少,因此多数情况下上层应用和操作系统很可能比硬盘要更清楚哪些数据更有价值。如果能给软件提供一些手段把这些hint发给硬件,硬件混合存储设备就有希望工作得更有效率。Intel的Jason Akers最近提了一些patch来实现这类API。他建议的方法是复用已有的ionice系统调用(BTW,是不是很多人都不知道还有这样一个调用?),加入诸如IOPRIO_ADV_EVICT、IOPRIO_ADV_DONTNEED、IOPRIO_ADV_NORMAL、IOPRIO_ADV_WILLNEED这四个命令字,分别表示接下来要读写的数据如果在cache里则最好淘汰掉、接下来要读写的数据近期不会再次用到(但对是否淘汰不表态)、无建议、接下来要读写的数据近期要用到,一共四种建议。内核收到这些建议后再把它们转成ATA命令发给硬件,ATA standard 3.2 已经定义了混合存储设备的特性和接口,这样上位机就有办法把这些信息传下去了。

这套patch短期内不大可能合并到主干,社区的反对意见不少,但都是针对API的设计风格来的,并没有人从根本上质疑有没有必要这么做。很多开发者认为指定per-process的粒度太大了,用户可能想给不同的文件指定不同的建议,甚至给某文件的各个区域指定不同建议,使用上述API没法达到这种目的。另外,Dave Chinner指出实际提交IO请求的线程往往不是最初产生数据的那些用户线程,例如很多文件系统都有自己的work thread,由它们去做submit_bio(),这样用户进程给的建议就不起作用了。

总之,多数开发者认为应该实现一套可以按文件粒度来给出缓存建议的API,Jens Axboe甚至给出了一套按请求粒度来的参考实现,例如可以把这套API做到他最近提出的非阻塞buffer读的补丁中。

另外,这套API的另一个问题是它与目前的混合存储硬件设备耦合得过于紧密:它致力于精细地控制硬件的缓存策略,未来的硬件可能并不能很好地落到以上几种建议的适应范围内。需要软件给出类似的“缓存建议”的场景也不只是混合存储硬件这一种,像带持久化memory device和支持T10/T13的NFS 4.2也会需要类似建议。一般来说内核开发者倾向于设计出能适应多种场景的方案。针对这个问题,Martin Petersen提出了另一种风格的API:不强求软件给出具体的缓存操作指示,把这些“建议”改成“描述”,让软件描述清楚它们现在的IO流是什么风格的,再让硬件自己去把这些风格映射到不同的缓存策略上。举个例子:transaction(事务)类型的IO流,它要求IO操作要尽快完成、写下去的数据未来很可能再次用到、再次用到的时候延迟要尽可能地低;streaming(流式)类型的IO流,它也要求IO操作要尽快完成,但写下去的数据未来再次用到的可能性不大。其他的还有metadata类、Paging类、Background类等等。

目前看来上述问题短期内还不好解决,如果比较一下提出的这几种方案,Martin Petersen的适应性最广,对未来的新硬件也会适用,但他的方案的风格与Intel提的这套patch完全不同,采纳他的方案就意味着开发者们又要从头来过了。

open() flags: O_TMPFILE and O_BENEATH

open系统调用在Linux中扮演了重要的角色。除此之外,没有其他访问已经存在的文件的方法。不同的flag对open系统调用的行为影响很大,这里介绍两个flag,一个已经被加入到最新内核中,另一个还在讨论阶段。

O_TMPFILE

关于O_TMPFILE标识的讨论由来已久,它被迅速加入到内核中,review不很充分,合并之后还有些问题。O_TMPFILE要求创建文件时,不创建文件目录项,其他进程便无法访问这个临时文件。

Eric Rannaud最近提出,以如下的方式调用open会出现什么情况

int fd = open("/tmp", O_TMPFILE | O_RDWR, 0);

flag标识要求创建一个可写的文件,但是mode域确要求不能有任何读写权限。对于这种情况,POSIX明确规定mode参数在文件创建之后生效。也就是说,虽然这样创建的文件进程不能访问,在创建时还是可以获得一个文件描述符。 然而,实际情况却不是这样。mode参数在创建时就生效,因此open调用失败。这种行为被界定为bug,Eric的fix在3.18-rc3之后生效。

另一个比较好玩的情况是,

 int fd = open("/tmp", O_TMPFILE | O_RDONLY, 0666);

带O_RDONLY的O_TMPFILE调用会失败。当引入O_TMPFILE的时候,大家觉得使用不能写的临时文件的情况不存在。但是后来有人找到了这样的情况,open()调用之后跟着fsetxattr()调用,随后用linkat()使文件可见。Linus最初决定支持这个的,后来又变卦了,所以这么使用还是会fail。

还有一个glibc的bug最有意思了啊,glibc编译带O_TMPFILE参数的时候,mode参数根本没有往内核里面传。巧合的是在x86-64机器上调用open进入内核态的时候mode域使用的寄存器正好和约定的是同一个。使用openat()调用的时候就不这么幸运了啊,mode的值就错了。这个应该很快会修掉,但是现在的glibc版本下,不要用open_at()和O_TMPFILE一起。

O_BENEATH

使用openat()调用的时候,需要打开的文件通常是明确的。但是,处理路径里面有符号链接的情况时候,再加上路径上的一些奇巧淫技,打开的文件会错掉啊。

David Drysdale给openat()系统调用增加了O_BENEATH flag来解决这个问题。当使用这个flag的时候,访问的文件必须在path指定的目录中或者更深的路径里面。采取的限制措施很简单,path不能以“/”开始或者包含“../",解析符号链接时也是这样。

这个flag还有其他的应用,可以安全的给sandbox程序一个文件目录。

这个flag应该很快会被加入到主线中。

An introduction to compound pages

复合页是指物理上连续的两个或两个以上的页组成一个单元,而被当作一个单独的大页使用。hugetlbfs或透明大页系统经常用它来创建大页,但除此之外也有其他一些使用场景。复合页可以被内核用来当作匿名页或者buffer,但是不能用来做page cache,因为page cache只能处理单独的页。

分配复合页仍然使用内存分配函数alloc_pages()但需要设置上__GFP_COMP分配标志,且order至少为1。因为复合页实现方式的问题,不能使用order 0(单独一个页)来创建复合页(参数order是指分配以2为底以order为幂的页个数,0对应于一个页,1对应两个页等)。

需要注意的是一个复合页不同于正常的高阶(high-order)分配请求,下面的调用:

  pages = alloc_pages(GFP_KERNEL, 2);  /* no __GFP_COMP */

将返回四个物理上连续的页,但是它们不是一个复合页。区别在于创建复合页时需要创建一些元数据,虽然很多情况下这些元数据可能不需要另外生成。

大部分元数据都被放在了对应的struct page结构体中,首先来看一下struct page里的flag。复合页里的第一个页叫做“head page”,会设上PG_head标志,其余剩下的页叫做“tail pages”,会设上PG_tail标志。在64位系统下page flag位较多,是用这种方式存储的。但对于32位系统,没有多余的page flag使用,采用的是另一个不同的方案:复合页里的所有页都会设上PG_compound标志,tail pages同时也会设上PG_reclaim。PG_reclaim标志只为page cache部分的代码使用,但是因为复合页不能用做page cache,因此可以拿来复用。操作复合页的代码不需要关心这些细节,只需要调用PageCompound()就可查询传入的页是否是一个复合页。如果需要区分是head还是tail页,就调用PageHead()和PageTail()。

每个tail page都有一个指针指向head page,指针放在了struct page结构的first_page域。first_page域和private域、该页存储页表项时使用的spinlock域、该页属于一个slab时使用的slab_cache域 占用的是相同的存储空间。compound_head()函数用来找到一个tail page的head page。

表示复合页整体的有两个信息:order和一个将页释放回系统时的析构函数。head page的struct page结构已经塞满了没有地方再存这些信息,因此order被存在了第一个tail页的struct page的lru.prev域。struct page里很多域都是用union存的,因此复合页的order是被转换成了一个指针类型再存进去。类似地析构函数的指针被存在了第一个tail页struct page的lru.next域。正是因为需要用第二个页的struct page来存储元数据,所以复合页最少需要包含两个页。现在内核里只定义了两个复合页析构函数,默认使用的是free_compound_page(),它会将内存返还给页分配器。hugetlbfs使用另一个free_huge_page()来更新计数。

复合页的所有页是一个整体,当只访问其中的一个页时也需要遵守这个规定。透明大页就是一个例子,如果用户空间尝试修改其中一个页的保护权限时,需要定位出整个大页并拆分。有些驱动也使用复合页来管理大块buffer。

以上基本就是复合页和普通高阶分配的区别,大部分内核开发者都不会用到复合页,但是当确实需要将一组页作为一个整体时,复合页就会是一个很好的选择。

THP 引用计数

Caspar

Linux 下大多数架构都用4KB大小的页面(译者:有的例外,比如 powerpc 是64K的,hugepage 是 16MB的),大多数都支持更大的页面,从2MB的大页到1GB的超大页。这些大页在很多工作负载下对性能有显著提升,其中最大的收益在于减少了 TLB 的压力(2MB大页一次只要翻一个地址,4KB的页就得翻512次,显而易见)。内核的透明大页(THP, Transparent Huge Page)特性(理论上来说)能解放开发者和用户的劳力,使大页的使用透明化,只不过这玩意儿受到诸多限制,发挥不出理想状态下的效果。现在 Kirill A. Shutermov 改了一堆复杂的代码,提交了一系列 patch 来减少这些限制。

THP

THP 的工作原理是,当它判断一个进程的地址空间 (1)有空页,(2) 替换为大页之后进程可收益之时,就会静悄悄暗落落地把这部分地址空间替换为更大的页面。这是 Red Hat 的开发者 Andrea Arcangeli 在 2.6.38 内核引入的特性。结果留下了一个难对付的问题,内核的内存管理代码里有一大坨代码没法对付随进分布在进程地址空间里的大页。针对此问题,Andrea 的一个解决方法是,整个功能都不用大页,这就是为什么 page-cache 的页面用不了大页的缘故。
在另外一些情形下,Andrea 放了一个函数叫split_huge_page(), 当一段代码没法用于大页场景时,就用这个函数把大页切回小页。(原文接下来开始强烈吐槽了:很显然这样会有性能开销了,不过这么做还是值得的,起码不会出现一堆奇奇怪怪的内核问题。不过这函数就跟大内核锁(BKL)一样就跟个拐杖一样,不是个健全的解决方法但是至少能 work,把一个难题延后推迟来解决 blah blah。)

然后一直以来,陆陆续续有一些代码修改完之后可以用于大页了,这个函数就被替换了。不过在页面合并代码里,这函数还在。比如在同页归并(KSM, Kernel Samepage Merging)的实现中,在bad-memory poisoning代码中,mprotect(),mlock()这俩系统调用中,swap 代码中,以及其他一些地方都还得用这个函数。有一些估计是改不了的,比如 KSM, 不切割成小页,估计 KSM 永远你不可能成功合并重复大页;其他一些就是比较难改,比方说mprotect(),怎么保护半个大页之类的。针对后面这种情况,其实是可以优化的,就是使用引用计数。

PMD-level 和 PTE-level 映射

要理解 Kirill 的补丁集,就得记得大页在内核中是以复合页的形式存在的,这里有一些复合页的阅读材料。

Kirill 的终极目标是让 page cache 能用上透明大页。到目前为止,只有匿名页可以用大页来替换,这只占内存中的一小部分而已。所以说这个目标很宏大,而目前他的这个补丁集根本就尝试都没尝试一下这个方向(太毒舌了Orz),Kirill 只是简化了管理 THP 的方式,让它们变得更灵活一些。

他的这套补丁消除了正常的4K页和大页之间的隔阂。具体来说,当前的内核中,一个4K页要么是一个独立的4K页,要么成为一个大页的一部分,但是不能既是4K页又是大页的一部分。而 Kirill 的补丁让一个4K页在一个进程空间里是一个独立的页,在另外一个进程空间里成为一个大页的一部分。
先来回顾一下 Linux 页表结构(图片摘自这篇10年前的 LWN 文章):

从图上所见,大页在里面处于 PMD 这层,独立的页面在 PTE 这层。不过并非所有进程里的相同内存都得以相同方式映射,所以在一个进程里一个 2MB 的空间被映射为一个大页,另一个进程里相同的这段内存空间被映射为 512 个 4K 页,这是完全合法的。如果支持这种不同的映射,那么一个进程可以调用mprotect()来保护一个大页的一部分,其他进程里可以继续以大页方式调用,不受干扰。

换句话说,如果split_huge_page()函数可以被替换为诸如split_huge_pmd()这样的函数,这个函数就是只切分一个进程里大页的映射,其他进程里的大页收益继续不受影响。可是当前内核不支持不同的映射,所有进程必须以相同方式来映射,这个限制最终可以归结为大页里的引用计数该如何表达的问题。

大页引用计数

引用计数用于跟踪一个对象(比如内存里的一个页)有多少用户,内核因此决定这个对象是否空闲,是否能被删除。一个普通的页的引用计数有两种:第一种,放在 struct page 结构体里的 _count 字段,是这个页面被引用的总数,另一种在 _mapcount 字段,是指向这个页的页表项的数量。后者从属于前者,每一个页表项的映射引用都会在 _count 字段里同样增加一次引用,所以 _count 字段永远大于等于 _mapcount 字段。在 _count 字段而不在 _mapcount 字段的情况包括:映射到 DMA 的页,通过比如 get_user_pages() 这样的函数映射到内核地址空间的页;以及用了 mlock() 锁住的页。这两个值的差值很重要,如果 _count 值和 _mapcount 相等,这个页可以整个回收,对应的页表项可以删掉。如果前者大于后者,那么多出来那些引用被清理掉之前,这个页面不能动,就被“钉住”了。

而这个规则对于复合页来说完全不一样了。对复合页来说,所有 tail page 的 _count 都是0,引用计数放在 head page。不过这就没办法统计单独的小页了。举个例子,如果一个大页中的部分页面被用于 I/O 操作,那么就得用个小技巧:把每个小页的引用计数放到 _mapcount 里,然后根据当前页面是不是 tail page,来挑选对应的 helper 函数来访问正确的引用计数。

在这个小技巧里,因为大页的 mapping 和 unmapping 都是一起的,所以不需要每个 tail page 单独拿出来跟踪其 mapping 状况。而如果有人想要把一个大页中的几个页单独拿出来 mapping/unmapping,这方法就不靠谱了,得找一个既能跟踪整块大页的映射又能跟踪单独的小页的映射的方法。

对于跟踪整个大页来说,得用另外一个广为人知的小技巧,把整个大页的引用计数放到第一个 tail page 的 mapping 字段中。这个字段一般是用来跟踪一个文件是不是映射到内存的这个页面中的,不过既然整个大页不会用于 page cache,所以这个字段可以放心得挪作他用(仅用于大页情形),计数是个原子类型,mapping 是指向一个结构体 struct address_space的指针,所以得强制类型转换一次。有人说这里最好用个 union 类型,不过作者还没这么干。

对于那些非映射的引用计数,因为 _count 字段废掉了,只有 _mapcount 在用,事情就难办了,Kirill 的解决方法就是:不记录某个具体的单独的小页的非映射的计数,取而代之的是每当一个小页被非映射的引用(比如 get_page() 调用)时,只增加 head page 的引用计数。所以某个页面被非映射引用时,这个页面会被“钉住”(参见这一节的第一段),但是我们不知道具体是哪个页面被钉住了,只知道有小页并钉住了。

所以当一个大页被分割的时候,我们就没办法把某个小页给标记为“钉住”,Kirill 的解决方法是,一旦如此,就直接把 split_huge_page() 函数给 fail 掉。注意不是说让 split_huge_pmd() fail 掉,前者函数是对所有进程的地址空间里的这段内存分割,后者函数只是分割 pmd. 这么做的话,调用 split_huge_page() 函数的代码不用改,只是调用不成功而已。

移除 tail page 的引用计数的这种行为的好处就是让 _mapcount 字段回归其本源:跟踪页表中映射到这个页的计数。这样,一个进程映射一个单独的大页,另一个进程映射这块地址空间为一堆独立的 4K 小页就成为可能。

Kirill 说,这块代码的修改带来了性能提升,虽然他没提供详细的 benchmark 数据。允许大页映射和小页分割同时存在这种行为也可能让内存共享更快一些。展望未来,哪天 page cache 也能用 THP 了,生活就更幸福了。当然首先这块代码得想办法进 mainline,Kirill 说可能这段代码会导致一些意外的状况,不过他对自己代码还是有信心的,短期内不会出什么问题。

Introducing lazytime

引入lazytime

POSIX兼容的文件系统为每个文件维护了3个时间戳,分别对应于文件元数据或内容最后改变的时间(ctime),文件内容的修改(mtime),和文件的访问(atime).前2个时间戳通常被认为是有用的,但"atime"对于它能提供的好处长期以来是非常昂贵的.在当前系统中,有一个mount选项"relatime",它能减缓atime造成的严重问题,但是它也有一些自身的问题.现在一个新的选项"lazytime"也许可以取代"relatime"并且工作得更好.

"atime"的问题是每当文件被访问,它将被更新.更新"atime"需要将文件inode写入磁盘,这就意味着"atime"跟踪本质上会将每个读操作转变成一个写操作.对于许多负载,这对性能的影响是非常严重的.在这之上,有很少的程序利用"atime"或者依赖于它的更新.因此,十年前,挂载文件系统的时候都会带有"noatime"选项,它彻底关闭了访问时间的跟踪.

问题是少数程序不是没有程序;最后确实有一些工具没有"atime"跟踪会出问题.一个经典的例子就是邮件客户端通常会用"atime"值来判断自从上次邮件被投递以来邮箱有没有被读.经过一些讨论之后,内核社区在2.6.20开发周期添加了"relatime"选项."relatime"造成了大多数"atime"更新被抑制,但当当前记录的"atime"早于"ctime"或者"mtime"才会允许更新.后来,"relatime"被调整为每24小时更新一次.

"relatime"对于大多数系统来说工作得很好,但依然有一些系统需要更好的"atime"跟踪,不需要为它付出性能的代价.一些用户也不喜欢"relatime",因为它会造成系统不与POSIX规范完全兼容.对于大部分,人们忍受"relatime"的小缺陷(或者忍受"atime"更新的代价),但现有有一个替代方法.

这个方法就是lazytime选项,它是Ted Ts'o发的一个ext4特定的补丁.当lazytime被使能,一个文件系统将保持"atime"更新在内存inode中.这个inode直到一些原因发生或者需要被从内存中清除的时候才被写入磁盘.这个好处是"atime"对于运行在系统的所有程序来说都是正确的.保存在磁盘上的"atime"可能是非常老的,但假如系统崩溃,当前的"atime"将会丢失.

Dave Chinner很快指出,这个选项是有用的,但不应该仅是在ext4中.假如它能在VFS实现,所有的文件系统都能用它,不仅仅是ext4,也许更重要的是,在所有的文件系统上能以相同的方式工作.Ted同意一个VFS实现实有意义的,这个补丁的下一版将会如此实现.

Dave也建议, 无规律的"atime"更新写入也许不是可取的. Ted也接受这个想法,因此下一版很可能将会以每24小时至少写入一次更新的"atime".没有这个改变,像在数据库服务器上,"atime"更新可能呆在内存中几个月才更新一次.

最后,有一个问题是关于"lazytime"是否成为缺省的mount选项.它满足POSIX没有引入正常"atime"更新的开销,因此看起来是一个比"relatime"更好的选项.Ted似乎想不久改变当前的缺省选项,然而Dave担心会有回归问题,想先等等看.同时这将导致这个特性能否得到更多的测试的问题,但正如Dave提醒的,未来会有对这个特性更感兴趣的人帮着测试.

这个是否征程需要时间去检验,"relatime"对于大多数用户来说工作得很好,因此没有必要让大多数的用户去试这个新的选项.但最后一些富于尝试的发行版很可能会采用这个新选项.在这点上来看,经过长时间,任何潜在的问题都很可能会暴露出来.因此也许"lazytime"选项会在2016年变为缺省选项,也许真正被测试得很好,证明没有问题.

Control group namespaces

LinuxContainer(LXC)使用namespace来做名字空间上的隔离,使用cgroup来做性能或者说资源隔离。目前的这套方案中存在的是一个问题是想操作cgroup就必须得mount cgroupfs,而cgroupfs本身是没有隔离的,所以一个container里边的root用户只要mount上cgroupfs,就可以看到整个宿主机上所有container的情况了。在container里边cat /proc/self/cgroup,可以看到从根到自己的完整路径,这显然会造成明显的信息泄漏。这件事的影响还不光是泄漏,Linux下的容器方案折腾在线热迁移已经有一阵子了,OpenVZ其实很早之前就支持这个,它向内核主干贡献的CRIU(Checkpoint/Restore In Userspace)方案正趋于成熟,对于容器里的进程来说,很可能迁到别的地方上之后对端机器的cgroupfs目录层次与源端是不同的,这个进程再去看/proc/self/cgroup就发现它变了,而一般的应用进程都不会防备发生这种事情,因此这对热迁移也产生了间接的影响。怎么办呢? Google的Aditya Kali提了一个patch,给cgroupfs引入了一个新的namespace,就叫cgroup namespace,通过给unshare()加新一个名为CLONE_NEWCGROUP的新标志,指示当前线程要进入一个新的cgroup namespace,这个新namespace会让当前线程把自己所处的那一级cgroupfs目录做为虚拟的根目录,从它自己的角度看过去所见的其他目录都依据这个动作做相应的调整。

例如:

  • 原先进程cat /proc/self/cgroup可见自己位于/batchjobs/c_job_1,unshare后就看见自己位于/
  • 机器上有/batchjobs/ag和/batchjobs/bg两个组,一个位于ag组中的进程做了unshare,然后去cat /proc/{bg组中的线程}/cgroup,就会看见它位于/../bg

以此类推
社区对这个patch非常欢迎,namespace的维护者Eric W. Biederman说自打cgroup被合并到主干的那天起他就想要这个功能了。有个这个功能之后各种cgroup manager也可以跑在子组中,嵌套着加以部署。社区对这个patch主要的讨论焦点是权限相关的,即unshare了之后,新的“假”根组中的线程可以被允许移到哪些组中去?(注意这里讨论的移动是指namespace之间的移动,不是指对应的cgroup组的移动,后者需要做这个操作的人自己去决定要不要做)原始的patch第一版是只允许普通线程移动到自己的子组中去,不能平行移动或者向上移动。特权线程可以任意移动。开发者们觉得这样的限制有点过于苛刻。
由于总体上没什么反对意见,我们有望在后边的内核中很快见到这个特性。

ACCESS_ONCE() and compiler bugs

ACCESS_ONCE()与编译器Bug

宏ACCESS_ONCE()在内核中应用广泛,它确保了相应变量仅被编译器生成的代码访问一次。这篇文章(http://lwn.net/Articles/508991/) 说明了它的工作原理以及应用场景。该文写于2012年,那时内核中大概有200处地方使用了这个宏。现在大概有700处。像很多用于并发管理的底层技术一样,ACCESS_ONCE()使用了不太容易理解的小花招。而且与其它技术一样,当编译器发生改变时,可能在此处出现Bug。 14年11月份时, Christian Borntraeger报告了这个Bug。为了理解这个问题,我们来仔细分析下这个宏,它在当前内核中的定义很简单(<linux/kernel.h>):

#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))

ACCESS_ONCE()将变量声明为volatile类型。Christian的报告指出GCC4.6与4.7会把非标量变量前的volatile忽略掉。因此,对int是没有问题的。但是对于其它的复杂结构则存在问题。比如,ACCESS_ONCE()经常用于声明页表项: typedef struct { Unsigned long pet; }pte_t; 在此例中,volatile会被上述编译器忽略,而导致内核Bug。Christian尝试寻找了解决方案,不过看来只能避免使用有问题的GCC来编译内核。但是许多系统中都安装了4.6或4.7,拉黑他们会让很多用户不舒服。而且Linus指出: “虽然有时我们可以避免使用有问题的编译器。但是编译器Bug总是无休止地提醒我们:你们在做一件很脆弱的事。或许我们应该找种方法避免脆弱了。 一种方法是对复杂结构体中的标量型变量使用ACCESS_ONCE()而不是该结构体变量本身。例如: Pte_t p = ACCESS_ONCE(pte); 可以被改写为: Unsigned long p = ACCESS_ONCE(pte->pte); 这种方法需要去检查每一处ACCESS_ONCE()调用,找到使用非标量类型的地方。这个过程很费时,而且容易出错。 Christian指出的另一种方法是一些有问题的ACCESS_ONCE()调用。而用barrier()代替。在很多例子中,屏障很有效,但并非总是如此。我们需要更详细的统计,阻止新的代码使用ACCESS_ONCE()。 Christian修改了ACCESS_ONCE(),使得它不能作用于非标量变量。在他提交的最近一组Patch中,ACCESS_ONCE()长成了这样:

   #define __ACCESS_ONCE(x) ({ \
__maybe_unused typeof(x) __var = 0; \ (volatile typeof(x) *)&(x); })
   #define ACCESS_ONCE(x) (*__ACCESS_ONCE(x))

这个版本在使用于非标量时,会报出编译错误。但一个非标量需要使用这个功能该怎么办呢?Christian引入了两个新的宏,READ_ONCE()/ASSIGN_ONCE(). 前者的定义如下:

   static __always_inline void __read_once_size(volatile void *p, void *res, int size)
   {
       switch (size) {
       case 1: *(u8 *)res = *(volatile u8 *)p; break;
       case 2: *(u16 *)res = *(volatile u16 *)p; break;
       case 4: *(u32 *)res = *(volatile u32 *)p; break;
   #ifdef CONFIG_64BIT
       case 8: *(u64 *)res = *(volatile u64 *)p; break;
   #endif
       }
   }
   
   #define READ_ONCE(p) \
         ({ typeof(p) __val; __read_once_size(&p, &__val, sizeof(__val)); __val; })

可以看出,它强制使用标量类型,即使传入的变量不是此类型。 Christian的补丁集使用了READ_ONCE()与ASSIGN_ONCE()替换了ACCESS_ONCE()。代码中的评论建议为了将优先使用这些宏,但是大多数已有的ACCESS_ONCE()不会被替换。开发者使用ACCESS_ONCE()来访问非标量变量时,会收到编译器给出的警告。 这个版本的Patch收到的评论不多,看来会在不久的将来进入upstream.在此之前,最好避免使用有Bug的编译器。编译器的Bug同时也说明内核中的相关代码可以写的更好,更加健壮。

Attaching eBPF programs to sockets

最近的内核开发周期中已经看到添加了柏克莱封包过滤器(extend Berkeley Packet Filter,缩写 eBPF)子系统到内核。但是,对于3.18版本的内核,一个用户空间的程序可以加载一个eBPF程序,但是不能令其运行在有用的上下文环境中;程序虽然可以加载和验证,但也仅仅只是加载和验证。不用说,eBPF开发者Alexei Starovoitov想让这个子系统有更加广泛的作用。3.19版的内核应该包含将会第一次包含一系列新的体现Alexei想法的补丁。 将加入3.19内核的主要特性是将可以把eBPF程序附加到sockets上。操作的顺序将是首先在内存中建立eBPF程序,然后使用新的 bpf()系统调用(相对于3.18版内核而言)来将程序加载到内核并获取一个文件描述符来引用它。这样,程序便可以将新的SO_ATTACH_BPF选项负载到setsockopt()函数了。

  setsockopt(socket, SOL_SOCKET, SO_ATTACH_BPF, &fd, sizeof(fd));

这里的参数socket表示网络的socket,fd表示加载eBPF程序的文件描述符。

一旦程序加载后,它将会在每次相应socket捕获到数据包的时候执行。目前,可用的功能仍然在以下两个方面受限: eBPF程序可以访问到存储在捕获到的数据包中的数据,但是不能访问到任何内核skb数据结构中的数据。未来计划将使一些元数据可用,但是目前还不清楚哪些数据将可以以及如何访问。 程序不能对数据包的传送产生任何影响。因此,尽管这些程序被成为“过滤器”,但是它们目前可以做的仅仅是存储在eBPF中的信息提供给用户空间程序使用。

最终的结果是,在3.19中,eBPF程序对于统计收集等功能有用,但用处不是很多。

不过,这是个开始。3.19内核中应该包含一些例子来说明如何使用该功能。它们中的两个是一个简单程序从数据包获取低级别的协议(如UDP,TCP,ICMP等)并在eBPF map中为每种协议维护一个计数器。如果某人想写这样一个直接使用eBPF虚拟机语言,他可以这样写:

    struct bpf_insn prog[] = {
    BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),
    BPF_LD_ABS(BPF_B, 14 + 9 /* R0 = ip->proto */),
    BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /* *(u32 *)(fp - 4) = r0 */
    BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
    BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
    BPF_LD_MAP_FD(BPF_REG_1, map_fd),
    BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
    BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
    BPF_MOV64_IMM(BPF_REG_1, 1), /* r1 = 1 */
    BPF_RAW_INSN(BPF_STX | BPF_XADD | BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0),
    BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */
    BPF_EXIT_INSN(),
};

不用说,这样的程序,对于大多数人而言,不是特别难懂。但是,正如例子所述,该程序也可以写成一个限制性的C语言版本。

    int bpf_prog1(struct sk_buff *skb)
    {
    int index = load_byte(skb, 14 + 9);
    long *value;
    value = bpf_map_lookup_elem(&my_map, &index);
    if (value)
        __sync_fetch_and_add(value, 1);

    return 0;
    }

这个程序可以被送到一个特别版本的LLVM编译器,产生针对eBPF虚拟机的目标文件。但是现在,还只能使用Alexei那版的LLVM编译器,但Alexei表示它正在将这些更新提交到LLVM主线上。一个用户空间工具可以使用通常的方式从(LLVM编译器)产生的目标文件中读取这个程序并将它加载到内核空间。这样就不需要直接与eBPF语言打交道了。

当读到最后一个例子时,可使用高级语言的能力使其价值显现,这个例子编译了一个300条指令的eBPF程序,这个程序实现了流跟踪、根据IP地址计算数据包数量。这个程序本身可能只有这些功能,但是它向我们展示了一些熟悉的复杂的功能可以使用eBPF虚拟机在内核中实现。

未来的计划还包括将eBPF应用在其他一些地方,包括secure computing(“seccomp”)子系统以及filtering tracepoint hits。鉴于eBPF将成为内核中的一个通用设施,看来,内核开发者们将会在其他它可以使用的地方使用它。希望能在未来几年中见到一些与eBPF相关的有趣的事情。

The iov_iter interface

内核其中一项很常见的操作是处理用户空间传入的一个buffer,有的时候这个buffer会包含很多块。这个地方的处理是内核代码里面经常容易出错的点,有时会导致安全问题。为此内核开始考虑将之前用于内存管理和文件系统层的iov_iter扩展到内核的其他部分。

iov_iter主要用于遍历iovec结构,该结构在<uapi/linux/uio.h>定义为:

   struct iovec
   {
       void __user *iov_base;
    __kernel_size_t iov_len;
   };

这个结构和用户态的iovec结构是对应的,被类似readv()这种系统调用使用。iovec用于描述一个在物理地址和虚拟地址可能分散的buffer。

iov_iter结构定义在<linux/uio.h>:

   struct iov_iter {
    int type;
    size_t iov_offset;
    size_t count;
    const struct iovec *iov; /* SIMPLIFIED - see below */
    unsigned long nr_segs;
   };

type指明iterator的类型,根据是从interator里面读或者往里面写而被设置为READ或者WRITE。iov_offset记录的是数据相对于iov里面第一个iovec指向地址的偏移。iovec数组包含数据的数目存储在count里面。需要注意的是,这些量大部分都会在遍历buffer过程中改变。它们描述的并不是整个buffer,而是类似一个指向buffer的标尺。

  • 使用iov_iter结构

iov_iter在使用前必须要进行初始化:

  void iov_iter_init(struct iov_iter *i, int direction,
               const struct iovec *iov, unsigned long nr_segs,
               size_t count);

然后数据可以在iterator和用户态之间通过如下的两个方式进行传递:

   size_t copy_to_iter(void *addr, size_t bytes, struct iov_iter *i);
   size_t copy_from_iter(void *addr, size_t bytes, struct iov_iter *i);

copy_to_iter()用于将addr地址对应buffer里面的数据拷贝到iterator指明的用户态buffer中。简单理解,copy_to_iter()看起来比较像是升级版的copy_to_user(),可以支持同时遍历多个buffer。类似的,copy_from_iter()用于将用户态 buffer的数据拷贝到地址addr里面。和copy_to_user()一样,返回值是没有拷贝的字节数。

注意,iovec数组的base地址在遍历过程中是会随着遍历的进程推进而改变,也就是说调用者需要自己记录iovec数组的base地址。

其他还有一些函数,用于将数据从page结构中移入或者移出iterator:

   size_t copy_page_to_iter(struct page *page, size_t offset, size_t bytes,
                 struct iov_iter *i);
   size_t copy_page_from_iter(struct page *page, size_t offset, size_t bytes,
                   struct iov_iter *i);

由于只有一个page,需要注意的是,这些函数拷贝的数据需要确保在一个page内。

原子上下文的代码从用户态获取数据的调用是:

   size_t iov_iter_copy_from_user_atomic(struct page *page, struct iov_iter *i,

unsigned long offset, size_t bytes); 这个仅当数据都存在于RAM里面才会成功,调用者为此需要做更多错误处理。

用于将用户态buffer映射到内核的调用如下:

   ssize_t iov_iter_get_pages(struct iov_iter *i, struct page **pages,
                              size_t maxsize, unsigned maxpages, size_t *start);
   ssize_t iov_iter_get_pages_alloc(struct iov_iter *i, struct page ***pages, 
                            size_t maxsize, size_t *start);

iov_iter_get_pages()期望的是page数组调用者已经申请了,而iov_iter_get_pages_alloc()将会自己进行申请。在这种情况,pages数组需要最后通过kvfree()进行释放。

不移动任何数据,而仅仅往前移动可以使用:

   void iov_iter_advance(struct iov_iter *i, size_t size);

清理iterator的buffer使用:

  size_t iov_iter_zero(size_t bytes, struct iov_iter *i);

获取iterator里面的信息相关函数:

   size_t iov_iter_single_seg_count(const struct iov_iter *i);
   int iov_iter_npages(const struct iov_iter *i, int maxpages);
   size_t iov_length(const struct iovec *iov, unsigned long nr_segs);

iov_lenght()函数使用时需要注意,由于该函数对iovec结构里面的len是完全信任的,如果数据来自于用户空间,可能会在内核导致整数溢出。

  • 不仅仅是iovec里面使用

目前在内核里面基本看不到iov_iter的使用。目前也还只是在block层的BIO结果相关里面使用,添加了一个iov的数组域:

   union {
    const struct iovec *iov;
    const struct bio_vec *bvec;
   };

这类iterator在type里面标识为ITER_BVEC。目前在内核里面只有swap和splice()代码里面有相关使用。

  • 即将到来的3.19

3.19里面对iov_iter进行了一些优化,优化的iterator通过以下函数创建:

   void iov_iter_kvec(struct iov_iter *i, int direction,
               const struct kvec *iov, unsigned long nr_segs,
               size_t count);

之前的union结构里面也会为此添加一个新的kevc域。

网络相关case里面也已经开始添加相关帮助函数,比如用于拷贝一个buffer并生成一个checksum。

结果就是,iov_iter接口慢慢的演变为标准的用于隐藏很多处理用户态buffer相关复杂性的方式。大量内核开发者将会需要了解相关内容。

User namespaces and setgroups()

当前Linux内核,进程调用setgroups()修改自己所属的group,需要拥有CAP_SETGID权限。而10月份时,Josh Triplett提交了一个patch,允许进程在没有CAP_SETGID权限时,也能通过setgroups()调用将自己从所属的group移除。从表面上看,这种改动似乎是安全的,但是实际上它引入了一个安全漏洞。比如,通过一个group来限制该group中的进程对某个文件的访问权限,但如果非特权进程能够通过调用setgroups(),将自己从所属组的移除,就有可能能够访问该文件。

对这个漏洞的进一步思考,发现该漏洞在user namespace机制中也存在。一个非特权进程通过clone创建一个新的user namespace时,该user namespace中的第一个进程拥有所有的权限,这样本来不拥有特权的进程就能够调用setgroups()来修改自己所属的组。

Eric提交了一个patch修复了该漏洞,其基本思想是在没有完成namespace内部gid与namespace外部gid映射之前,禁止进程调用setgroups()。具体实现方式是/proc/目录中为每个进程增加一个setgroups文件,如果该文件的内容为deny,则禁止进程调用setgroups()。另外,修改setgroups文件需要CAP_SYS_ADMIN 权限从而防止任意进程能够禁止setgroups()调用。

CoreOS looks to move from Btrfs to overlayfs

历经坎坷,overlayfs终于在3.18合并进入了Linux内核主线,最近CoreOS也适时的提出它的根文件系统要用overlayfs+ext4来替换btrfs。Docker用了很多的文件系统feature,最基本的需求就是要将一个读写文件系统架到一个只读文件系统上,这一块目前是使用btrfs来实现的,后面会用overlayfs来实现。这个变化是12月15日Brandon Phillips在coreos-dev的邮件列表里面提出的,Phillips认为目前btrfs仍然不成熟,还有很多问题(我的感觉这些问题已经存在很久了)。。。这个提议立刻得到了大家的认可。 Chris Mason(btrfs的作者)认为CoreOS遇到的关于btrfs的问题都是客观存在的,但是他认为btfs持续在进步,3.19也修了很多的bug,言下之意似乎是说btrfs是靠谱的,不过最后他还是说CoreOS选择什么文件系统也是他们自己的权利。

本文的最后吐槽了一下btrfs,尽管经历了多年的开发,btfs仍然在功能,性能,稳定性上有很多问题,Mason的邮件给大家了一些希望,但是似乎路还很长。

The "too small to fail" memory-allocation rule

对于内核开发者(linux) 来说,比较少见的情况中,系统资源不够,分配内存失败,比如:kmalloc, vmalloc, __get_free_pages 等。所以写代码的时候,都有错误检查啥的 (比如:分配失败的某种 exit 路径)。不过内存子系统在实现上和教科书就有些不一样了,有时候情况就会比较严重,看下面例子:

一个进程 A 运行情况比较不好,系统内存太少了,这个时候调用 XFS 文件系统的某些接口,事实上,里面就可能要先分配内存。 内存管理子系统收到了请求,开始处理,发现内存不够了,首先可能会先回收(例如:PFRA) ,如果还是不满足条件(比如:完全没回收到 page),就走 oom killer 了。 oom killer 就还是有选择的杀进程了,而且只能一个一个的杀,这会进程 B 很不幸,被选中了,但是 B 要推出的时候,必须在同一个 XFS 上面做点操作,而这种操作可能要拿之前 A 已经拿到还没释放的锁(A 也在做 XFS 操作,拿了锁等内存分配呢),这样就很可能死锁了,这种死锁是 3 者之间的环路等待,oom killer 很可能也会卡住。

系统管理员这会会不会想到要切换到 *bsd 呢?

XFS 的 maintainer Dave Chinner 也问到这么一个问题,为啥非要走 oom killer 而不是直接 fail 掉这次内存请求,因为 XFS 代码对请求失败的处理都是妥妥的呀。

这会内存管理的 maintainer Michal Hocko 也跳出来仍炸弹了:

“好吧,这里确实有一个隐含的条件,如果低阶(小于 3) 连续内存(8 个连续页)分配的话 (GFP_KERNEL)就不会失败(其实不是不会失败啦,只是大不了就 oom killer 接住)。这个是很久以前的决定了,现在要解决这样的问题而又不动大量的代码的话其实会很 tricky, 悲剧啊!“
Dave 这会回复说:

“我们一直被告诉说,啊,内存分配不一定成功啊,除非_GFP_NOFAIL 被指定了,而这个标志现在也不鼓励用啊“

”很多代码都依赖于内存分配失败啊,比如 page cache, 这也就是说所有的文件系统依赖于这一点。我们不会显示让内存分配失败,而是期待内存子系统在资源不够的时候分配失败。大伙都按这种方式写了 15 年代码了“

-- 说白了就是,不行就失败吧,反正我这能处理。

一个 “太小而不能失败” 原则(觉得中文翻译好别扭),反正就是,对于一般的内核来说,比如:1/8 个页(连续),总之,就是很小的连续内存分配就不让它失败。谁都不知道这个规则啥时候进的内核,在 git 时代之前估计。Johannes Weiner 说:如果很小的内存分配都满足不了的话,那基本表明系统已经不可用了,实在没啥别的办法了,就干脆 oom killer 了,意思就是你出错处理也没多大意义。但是其实 oom killer 的时候也导致系统不可用。

前面也提到另外一个选择就是,_GFP_NORETRY,就是说分配的时候不 retry 了,直接失败返回,但是会导致小片内存申请也 fail (因为可能不会 reclaim 了呀),但是 Dave 说用这种方式解决死锁的问题,坑是填不完的,总之不是一劳永逸的办法了。

然后回到了原始问题上来了,要不要废弃掉 "too small to fail",这里还是用英文吧,然后来让内存分配比较符合大多数开发者的想法,Johannes 回复的消息也带了一个 patch, 让回收的无限循环终止掉,当直接回收不成功的时候,但是他也提到,过了这么久(n 年了) 让那些小内存(0 阶,单页)分配直接失败掉是很大风险的。

原因是:很多开发者其实很可能并没有考虑内存分配失败的情况,也没有做失败后的处理。更大的原因是,即使做了处理,这些出错代码路径基本没有被走过,因为内存分配失败本来属于概率比较低的情况,再加上测试不够的话(这里提到 fault injection framework),不知道都有做过没,反正译者本人也没跑过 :

那突然大量的未经测试的出错路径可能同时发生的时候,想象一下,mail list 会不会爆啊。反正总之会有很多挑战。

总之现在,表示,就目前的阶段,废弃掉上面的规则来解决问题有点接受不了,风险太大,那就还是保持现状吧,长期来看还是会有更好的内核处理方式,短期我们就先忍忍吧。最简单的增大内存,或者系统级别的优化吧,这种问题总归也有很多其他的方法避免。

相关文章
|
5月前
|
存储 安全 Linux
探索eBPF:Linux内核的黑科技(上)
探索eBPF:Linux内核的黑科技
|
5月前
|
网络协议 Linux Android开发
|
3月前
|
JSON Linux C语言
内核雏形
内核雏形
26 0
|
Linux
《从 Linux 系统内核层面来解决实际问题的实战经验》电子版地址
从 Linux 系统内核层面来解决实际问题的实战经验
68 0
《从 Linux 系统内核层面来解决实际问题的实战经验》电子版地址
|
Linux 调度 开发工具
Plugsched 实战解读:如何在不中断业务时对 Linux 内核调度器热升级? | 龙蜥技术
plugsched 如何使用?每一步的操作如何进行以及背后的工作是什么?
Plugsched 实战解读:如何在不中断业务时对 Linux 内核调度器热升级? | 龙蜥技术
|
存储 弹性计算 运维
龙蜥开源 Plugsched:首次实现 Linux kernel 调度器热升级
Plugsched 是 Linux 内核调度器子系统热升级的 SDK,它可以实现在不重启系统、应用的情况下动态替换调度器子系统,毫秒级 downtime。Plugsched 可以对生产环境中的内核调度特性动态地进行增、删、改,以满足不同场景或应用的需求,且支持回滚。
297 0
龙蜥开源 Plugsched:首次实现 Linux kernel 调度器热升级
|
监控 Linux 数据中心
|
Rust 算法 安全
直播回顾:如何基于Linux内核构建起商用密码基础设施?| 龙蜥技术
Anolis商密版OS是国内⾸家从操作系统层⾯提供商⽤密码的OS解决⽅案。
直播回顾:如何基于Linux内核构建起商用密码基础设施?| 龙蜥技术
|
存储 Linux 调度
阿里内核月报:2017年05月
本文是2017年4月3日Netconf会议报告。主要的议题是:移除ndo_select_queue()函数,对于refcount_t类型引入开关,TC重定向导致内核陷入循环等。
2804 0