阿里内核月报2017年01月

Controlling access to the memory cache

控制对Cache的访问

cpu对内存的访问一直以来都会通过L1/L2/L3缓存来加速,我们都知道当你打算严肃地去考察性能问题时,各级缓存的命中率一直是一个重要的指标。而一个进程的缓存命中率在很大程度上又和它在各级缓存中所占的空间大小正相关。由于缓存本身是socket范围上的共享资源,一个进程的缓存命中率不仅取决于它自己的行为,同时也受和它共同运行的其他进程影响,这使得工程师们很难将一个特定进程的缓存命中率维持在理想的值上。

Intel自Haswell开始的cpu已经搭载了它们的缓存控制技术。Intel把它称为Cache Allocation Technology,缩写为CAT。在本届LinuxCon 2016上,来自Intel的Yu Fenghua向大家汇报了将这一技术集成进Linux内核的进度情况。

那么CAT具体是怎么工作的呢?在一个普通的Intel X86 cpu上,对于一个N-way set associative cache(N路组相联缓存)来说,当一个64B的cache line被带入LLC时,它的一部分虚拟地址决定了它会被hash到哪个行(或者说set)中,具体的映射算法Intel保密没有公开。进入一个set之后,放到哪个way(或者说列)上就按照正常的淘汰算法来进行,而在一个开启了CAT的cpu上,这时会有一个掩码(Intel称它为CBM,Cache Bit Mask)介入,决定这个新cache line只能放到哪几个way上、只能在哪几个way的范围之内进行淘汰。这样就控制住了一个进程可以污染的cache范围。为了使用方便,这个掩码并不会直接使用,而是通过和一个名为CLOSID(Class of Service ID)的值联系在一起。Intel希望集成这一特性的OS在上下文切换时把新进程对应的CLOSID写到一个特定的MSR寄存器里,这样CAT就流畅地跑起来了。

话虽如此,当Linux集成一个新特性的时候,考虑的可不仅仅是让它跑起来这么简单。硬件厂商对于一个新特性的想法会持续地变化,一个通用操作系统的架构必须能够在一定范围内适应这种变化,它既要做好抽象,把细节屏蔽掉,使得这个框架能够适应不同厂商的相似特性;又不能抽象得太过份,以至于某些特性无法得到支持。在集成CAT这个问题上我们再一次看到了类似的故事上演。最初的一版patch是基于cgroup写的,经过几轮review之后,社区的反对意见集成在cgroup这个事情本身-- CAT可以有两种配置风格,一种是像上文说的那样,以进程或者进程组为中心配置,这时使用cgroup是没问题的;另一种是以cpu为中心配置,即我们指定给某一个cpu分配某些cache,跑在它上边的东西,不管是进程的用户态代码,还是陷入内核后执行的内核代码,还是中断处理函数,都遵守相同的分配规则。这时就无法和cgroup协调起来了,因为整个cgroup的设计都是围绕着进程视角来实现的。另外,CAT的掩码作用范围是在socket级别的,这就意味着操作系统可以允许一个进程跑在不同的socket上时使用不同的掩码,cgroup抽象由于没有办法描述cpu,同样抑制了这种用法。

放弃cgroup这个抽象之后,又有人提出了ioctl风格的接口,响应者寥寥。大家都不愿意再往ioctl这个大杂烩里加东西了。

最新一版的代码以新提出的kernfs抽象为基础,这是一个脱胎于sysfs的新的虚拟文件系统,cat的专属目录会出现在/sys/fs/resctrl下。在它之下有三个虚拟文件:tasks、cpus以及schema。tasks文件包含了被schema中的掩码控制的进程PID。cpus则包含被这些掩码控制的cpu编号。那么当一个特定进程跑在一个特定cpu上,而它俩在schema中的配置冲突了该听谁的呢?目前的策略是以cpu为中心的配置优先。

Yu Fenghua的演讲最后提到了这一新特性具体的配置方法,这可以在patchset中找到;同时他的slides里也提到了一些性能测试,从中可以看到对于一些cache竞争严重的场景CAT确实会有很大作用。

Context information in memory-allocation requests

本文要解决的是内核 api 设计在历史演进过程中的合理化问题。

大家都知道,内核的内存分配过程中,是要清楚的区分各种不同的情况的,例如:是否内存分配过程中会引起进程切换,大部分的驱动代码的中断处理函数中(面试中常问的问题,为什么中断上下文不能发生进程切换?)。所以,linux 内核的做法一直以来也就和我们大多数人在设计 api 的时候一样,即:加个 flag 吧,于是内核的内存分配函数变成下面的情况:

void *kmalloc(size_t size, gfp_t flags);

具体多少 flag, 看 吧,处理内存分配中要区别处理的种种问题(这里面显然有些问题是 common 的,有些是 by design 的)

现在考虑下面三种情况:

  1. 当调用栈足够深的时候(内存分配 api 被多层封装):
    Amalloc(..., flags) -> Bmalloc (..., flags) -> Cmalloc (..., flags) -> ................... -> kmalloc(..., flags)
  2. 当调用在某个上下文频繁多次的时候:
    kmalloc(..., flags) ... kmalloc(..., flags) ... kmalloc(..., flags) ...
  3. 当底层调用需要修改 flags 的时候:
    Amalloc (..., flags) -> kmalloc(..., new_flags) (比如:判断自己的上下文发生变化了)

我们看到了什么?很多重复的 flags 出现在连续的 api 调用里,还有就是上层 flags 未必能完全决定底层 flags, 显然功能都 ok 了,只是过了 n 年之后,现在的开发者觉得太 ugly 了。

解决方法?大概就是很直观的:

  1. 拿掉 API 里面的这些 flags (或者提供新的 api 来过渡) 2. 最底层分配函数处理的时候,通过访问某个全局的信息来拿到这个 flags 或者根据具体情况来调整这个 flags, 全部由底层调用决定

1 很简单了,2 这个全局的信息存哪呢,社区给出的部分做法是 task_struck 里面了,当然这个没问题,可以解决掉很多进程上下文的内存分配 flags 冗余问题(暂且就叫 flags 冗余吧),这应该是大头,因为在中断上下文中内存分配显然要简化很多,不过依然存在上述问题。

目前内核中给出了相应做法的例子:

PF_MEMALLOC_NOFS ==> task_struct -> flags
memalloc_nofs_save
... 这里面的内存分配,默认相当于增加 GFP_NOFS ...
memalloc_nofs_restore

目前解决方法存在的问题:

  1. 我个人认为这也不是一个通用的方法,因为 flags 实在很多(就像我上面提到的有些是 common 的,有些是 by design 的),如果都这样单独处理难道不就是用另外一种复杂度替换之? 2. 历史问题太久远,很多已有的 callers (users) 依然需要支持,或者在很长一段时间内两种 api 要并存。 3. 并没有解决中断上下文的冗余问题
    本文原作者提出了这样一个问题,也给出了一些解决方法,不过依然不完整,希望有兴趣的同学继续跟进内核社区在此处的进展,或者有更好的主意,也不妨自己改改。

Reworking kexec for signatures

kexec 可以用于从第一个内核切换到第二个内核,它用了 kexec_load() 这个系统调用,把新内核加载到内存中,然后用 reboot() 系统调用快速重启;用户态也有一个 kexec 命令,可以加载新内核然后启动之。由于 kexec 跳过了固件加载和bootloader阶段,它可以用于快速启动,当然它最主要还是被用于 kdump 以生成 vmcore . 不过最近 mjg 发现个问题,kexec 可能绕过 UEFI 安全启动的限制,简单来说 UEFI 安全启动机制要求内核必须是经过有效签名的,而 kexec 启动第二个内核的时候可以直接跳过 UEFI 的检查,这样就导致了安全问题,而一些公司比如微软,觉察到这个风险之后可能会把用来签 Linux bootloader 的 key 给禁掉,从而给 Linux 发行版带来灾难(比如说就不能装 Linux 到一些微软合作的厂商的电脑上),所以他们不得不把 kexec 禁掉了。

幸好 kdump 的开发者 Vivek Goyal 最近提交了一系列 patch 可以让 kexec 只启动签过名的内核,这样应该就可以解决 mjg 的问题。不过 mjg 还是建议那些需要支持 Secure Boot 的发行版禁用 kexec,因为还有方法可以绕开 Secure Boot 的检查限制,比如说改一下 sysfs 中的 sig_enforce 参数,然后跳回原内核。不过不管怎么说, Vivek 一直在努力解决这个问题,以尽早消除这个安全风险。

Vivek 现在的 patch 里,引入了一个新的系统调用:

    long kexec_file_load(int kernel_fd, int initrd_fd,
             const char *cmdline_ptr, unsigned long cmdline_len,
                         unsigned long flags);

和原来的的 kexec_load 系统调用对比:

    long kexec_load(unsigned long entry, unsigned long nr_segments,
                    struct kexec_segment *segments, unsigned long flags);

新旧系统调用都会把内核分成不同的片段(segments),所不同的是,用了新的系统调用之后,只会先加载签过名的部分的片段,而不会像原来一样什么都不检查直接把所有片段都加载到内存中;然后,kexec-tools 里引入了一个新的工具,叫 "purgatory",它在旧内核调用 reboot 之后,新内核启动之前检查剩下那些片段的hash,验证通过了才会启动第二个内核。不过如果不是在起第二个内核,而是正常内核启动的时候,也是需要 prgatory 的,这部分代码也被 Vivek 加到了内核中。

这部分代码目前还是 RFC 阶段,还有很多要完成,比如最重要的是怎么利用这个思想去验证签名。Vivek 也解释了一下他的思路,主要是基于 David Howell 验证内核模块签名的想法。大体上是在 kexec_load_file() 调用发生的时候进行签名验证,此时还会要计算每个片段的 SHA-256 hash,然后存到 purgatory 中。

社区对这批补丁的接受度还挺高的,估计过完年就能进主线了。

Enhancing lockdep with crossrelease

Lockdep 是一个运行时的锁有效性检查工具。它不仅仅是在死锁事件发生后上报bug,而且支持潜在的死锁检测,非常有用。细节请参考kernel文档:https://www.kernel.org/doc/Documentation/locking/lockdep-design.txt

但是目前的实现有一些限制,死锁检测只是用于一些经典锁:例如spinlock,mutex,要求锁的持有释放均是在同一个上下文中。所以,lockdep可能会漏掉一些死锁场景的检测。例如,page lock 或者completions这类同步原语,它们允许在不同上下文中释放(称之为crosslock)。本次提交的crossrelease(指在不同的上下文中释放)特性支持上述场景的死锁检测。

pagelock的一个死锁案例如下:

       CONTEXT X      CONTEXT Y         CONTEXT Z
                      mutex_lock(A)
       lock_page(B)
                      lock_page(B)         
                                        mutex_lock(A) /* DEADLOCK */
                                        mutex_unlock(A)
                                        unlock_page(B) /* acquired by X */
                      unlock_page(B)
                      mutex_unlock(A)

首先在上下文Y中持有mutex A,等待page lock B;上下文X首先持有page lock B,在上下文Z中释放,但Z在释放page lock B前,需要先持有metex A,形成死锁。
首先看一下lockdep的原理。定义A->B表示事件A依赖于事件B的发生,如果
同时存在B->A,那么则形成了一个闭环,也就意味着死锁。比如一个依赖图如下所示:

   A-> B -> E <- D <- C

图中的A,B,C,D,E都是lock class,箭头表示依赖关系。当lockdep检测到一种新的依赖关系时,比如E->C加入图中,则形成了一个CDE闭环,即发生死锁。lockdep往图中添加的依赖关系越多,那么检测到死锁的概率越高。如果去除传统lockdep的约束:锁的持有、释放必须在同一个上下文中,那么可以检测到的依赖关系则将更加完善。

对比传统实现,crossrelease新增了一步commit操作,整体的操作步骤如下所示:

  1. Acquisition: 对于经典锁,与之前一样,放在task_struct的所属队列中。对于crosslock,则是添加至一个全局链表中。
  2. Commit:对于经典锁,无副作用。对于crosslock,则对上一步收集到的数据进行依赖检查。
  3. Release:对于经典锁,与之前一样。对于crosslock,则从全局链表中删除。

所以crossrelease实现的关键点在于把per task的锁队列扩展到了一个全局的队列,从而支持cross context release支持。当然,具体的实现还得考虑不同锁的申请释放时间点来判断依赖关系,看作者的patch是使用了全局递增的id来实现这一点。

结论:crossrelease特性为lockdep模块增加了更多的依赖检测场景,完善了死锁检查体系。相应的,比传统实现也会更复杂。死锁的场景很多,目前所能支持检测的场景并不完备,相关的完善工作仍在继续中。

BBR congestion control

拥塞算法允许网络协(通常指TCP)可以达到任何给定连接的最大吞吐量,同时可以与其他用户共享有效带宽。BBR (Bottleneck Bandwidth and RTT)算法由Google发布,因此吸引了大量的关注。它用一种新的机制取代了传统算法,尝试在无线连接、中间设备干预和缓存膨胀(bufferbloat)中获得更好的结果。

对于给定连接,网络没有任何机制将有效的带宽通知到端,任何拥塞算法都要解决这个问题。所以算法必须通过某种方式得知特定时间内可以发送多少数据。由于有效带宽随着时间一直变化,因此带宽评估需要实时更新。换句话说,拥塞控制算法必须持续的评估多少数据可以被发送。

这些算法的测量标准是:没有到达对端而不得不重传的包的数目。当网络“平稳运行”,丢包是极少发生的。一旦路由器的缓冲被填满,则会开始将无法容纳的包丢弃。丢包因此成为一个简单可靠的信号,该信号指示连接超过了有效带宽,需要进行降速处理。

这个方法的问题是在现有的网络环境下,端到端的连接中缓冲区非常大。过大的缓冲区是近年来公认的问题,并且已经开始着手解决缓存膨胀问题。但是仍旧大量的路由器处于缓存膨胀状态,并且一些链路层技术(例如WiFi)需要确定缓冲区的总量以便于性能优化。等到端已经发送出足够的数据使得连接的缓冲区溢出,积累下来的数据缓冲区是非常巨大的。丢包信号来的太晚,当获得丢包信号时,连接已经被超负荷工作了很长时间。

基于丢包的算法在遇到包错误的时候会产生问题。算法不必要的降速,使得有效带宽没有被充分利用。

BBR (Bottleneck Bandwidth and RTT)

BBR算法不同于那些关注于包丢弃的算法,相反它主要度量标准是发往远端数据的真实带宽。每次收到一个应答包,BBR都会更新数据发送总量。固定周期内累计发送数据量是一个很好的指标,该指标指示连接可以提供的带宽。

当一个连接开始建立,BBR进入“startup”状态;在这个模式下,它的行为类似于传统拥塞控制算法,接着会不断提升传输速度同时尝试测量有效带宽。大多数算法会持续的增加带宽,直到丢包发生;BRR则是观察上述的带宽变化。尤其是观察最后三次往返的发送带宽是否发生变化。当带宽停止上升,BBR认为它已经发现了连接的有效带宽,同时停止增加数据发送带宽,这比持续增加带宽直到丢包要好得多。测量的带宽被认为是连接的发送速率。

但是在测量速率的时候,BBR有可能在短时间内以较高的速率发送数据包,某些包将要被置入队列中等待发送。为了排空这些被置入缓冲区的包,BBR将要进入一个“drain”状态,在这个阶段,发送速度会低于测量的带宽,直到之前超额的包被发送完成。

一旦排空周期完成,BBR将进入稳定状态,传输速率会在所计算的带宽上下波动。“上下波动”是因为网络连接的特性会随时间变化,所以要持续的监控真实的发送带宽。同时,有效带宽的增加也只能通过尝试以更高的速率发送去检测,所以BBR会在1/8的时间里面增加25%的速率去尝试更高的带宽。如果带宽没有增加,则接下来的1/8时间段会进入“drain”周期,排空之前发送的多余包。

BBR不像大多数其他算法,它不用拥塞窗口作为主要控制组件。拥塞窗口限制了在给定的时间内在传输管道中的数据,窗口的增加会使得突发的包消耗新的有效带宽。BBR 应用tc-fq(Fair Queue)包调度按照适当的速率发送数据。拥塞窗口仍然存在,但已经不再是主要的管理机制,它只是为了保证没有过多的数据存在传输管道中。

Virtually mapped stacks 2: thread_info strikes back

Virtually mapped kernel stacks
在我们熟悉的3.10内核中,内核栈是通过alloc_thread_info_node()函数直接从buddy system分配,即栈空间的物理地址连续。该方式有以下几个不足:

  1. 内核栈空间太小(4k for 32-bit system, 8K for 64-bit system),容易导致栈溢出;因此,在kernel 3.15版本之后,栈空间被扩大(8k for 32-bit system, 16K for 64-bit system)。
  2. 当内存碎片化严重时,分配高阶页可能失败,导致进程创建失败;
  3. 如果使用保护页(guard page)防止栈溢出刷掉其邻近页,该保护页将会占用一个物理页,导致内存浪费,因此内核默认没有开启保护页机制;
  4. 在关闭保护页机制时,缺乏手段检测栈溢出,最终,往往是由于邻近页内容被篡改(memory corruption),导致内核panic
  5. 存在潜在安全性问题,thread_info结构被放置在内核栈的底部,精心设计的内核栈溢出可能会修改thread_info,导致安全隐患;

基于上述不足,目前主流思路是通过vmalloc分配内核栈,该方案有效的解决上述缺点:

  1. 便于内核栈扩张,且不会出现分配高阶页失败而导致创建进程失败的情况;
  2. 保护页不需要占用一个物理页,只需要在页表上添加一个页表项,并标记为禁止访问;
  3. 栈溢出能够被保护页及时检查出来,溢出时邻近的页不会被篡改,因此只需要kill当前进程,不会导致内核panic。这使内核栈溢出便于调试。

但是该方案存在几个小问题,比如:

  1. performace regression:即通过clone()创建一个进程会多消耗1.5微秒,在大量创建和销毁进程的场景下,影响性能,Linus要求该patch进入upstream之前必须修复该问题。
  2. TLB miss增加;对于64位系统来说,以前内核栈对应的虚拟地址属于直接地址映射,在页表中使用1G的大页,因此只需一个TLB entry就能装下。而vmalloc对应的虚拟地址空间使用单页映射;内核栈需要对应多个TLB entry。
  3. 有些非常老的代码竟然在内核栈中执行DMA操作,这些代码需要重写

其中最值得关注的是一个问题如何解决这个regression:

很显然主要是由vmalloc导致了performace regression,调用vmalloc()比alloc_pages()这类函数代价更高。很自然想到的一种方法就是,预先分配一定数量的内核栈数据结构。
作者惊奇的发现,这不能解决问题,进程退出时并没有立刻释放资源(包括内核栈)。因为使用了RCU机制来保证该进程的资源在释放前没有被其它代码应用,只有在下一个RCU grace period,这些资源才会被释放。

这造成一个效果:在大量创建和销毁进程的场景下,会导致内核栈结构被批量申请,然后批量释放;由于释放的数量超出缓存管理的上限,超出的内核栈结构被直接丢弃。这导致内核栈缓存效果很差。

原则上来说,进程退出后,其资源不应该再被其它代码应用,但由于历史原因,thread_info中的一些数据仍然被应用,而thread_info 被放在内核栈的底部,连累内核栈所占的空间也不能被直接释放。

如果thread_info与内核栈完全独立开,这个问题就能愉快的解决了。因此,开发者正在想办法将thread_info中的数据结构挪动到 task_struct中去。thread_info在内核代码中被大量用到,显然这不是一个简单的工作。

A way forward for BFQ

BFQ(Budget Fair Queuing)I/O调度器自2014年起被内核社区讨论了无数次。通过修改调度算法、添加很多启发式策略,BFQ能够让I/O设备提供更好的响应,特别是针对传统的机械硬盘设备。尽管有这些明显的优势,但BFQ一直没有被内核主线接受。然而最近的一些信息显示阻碍BFQ进入主线代码的障碍就要被移除了。

过去几年对于BFQ调度器的反对主要集中在如下几点。首先BFQ刚开始尝试进入内核主线的时候,内核中已经有很多I/O调度器了,大家都认为应该将BFQ的功能集成到已有的CFQ调度器中。2016年2月,BFQ作者尝试将BFQ功能添加到CFQ中,但是这一尝试存在很多缺陷,后来就不了了之了。此外,大家认为用BFQ来替换已经经过大量使用和测试的CFQ是不妥当的。而最大的问题是,BFQ使用的是此前的内核API,而这些API并不支持多队列。

多队列功能是用来解决块设备扩展性问题的一个特性。该特性允许I/O请求被放入不同CPU的队列中并被这些CPU处理。因此内核开发者当然希望尽可能多的块设备代码能够建立在这些已有的架构上。BFQ利用新的多队列API存在一个问题,即当前的多队列不支持I/O调度器。因为多队列特性开发之初主要为了解决高性能块设备的扩展性问题,缺少对传统机械硬盘的考虑。因此,在此基础上集成一个I/O调度器的工作确实有一定的困难。

当然如果传统机械硬盘会被慢慢淘汰,那么我们也不用考虑这一问题了。但现实是传统机械硬盘还要继续存在很长一段时间。因此为多队列代码提供一个I/O调度器是有意义的一件事。
最近Jens Axboe开始尝试在多队列代码中添加I/O调度器的支持。同时BFQ的作者也开始尝试将BFQ调度器移植到最新的代码上。

最后我们要说,BFQ即将进入内核主线!

Debating the value of XDP

XDP价值大讨论

内核各个子系统的设计都面临扩展性和性能的要求,网络子系统走在了前面。最近出现了一种XDP(express data patch)的计数开始出现,然而评论褒贬不一。XDP大致想对收方向的packets做出一些简单快速判断,它最早被希望应用在丢弃不需要的包上,随后加上了简单路由和包修改的功能。实现上内核通过加载BPF程序,在包进入内核协议栈之前做出判断。

很多关于XDP的讨论都集中在XDP的实现方面,而不是XDP设计本身。直到12月初,Florian Westphal在给Hannes Frederic Sowa的回信中说到,“netdev分支开始出现大量关于XDP的patch,我觉得应该停一下了”。他宁愿开发者解决那些网络子系统正在面临的问题,而不是那些“很有意义但是意义不大”事情。

这些事情,(包括DPDK)用用户态程序来bypass网络协议栈,需要使用定义限制严格的一套机制,并且把通用内核协议栈带来的好处全部抛弃,来换取性能上的提升。他还说,这种情况下用硬件提供的包过滤功能比较合适。Westphal还认为XDP是一种比较low的机制。用户态的网络代码好歹可以用各种语言编写,调试方便等等。相比之下,BPF开发不方便,功能上也更受限制。用XDP来解决的路由、负载均衡和先期包过滤等功能,各自都有更好的解决方式。

Thomas Graf提到:packet出了内核之后一切就变得不可控了,在用户态做安全控制就不合适了,用户态代码可能被破坏。内核里面的BPF代码应该更难被破坏,他也不同意负载均衡这样的事情在用户态来做。

Sowa提到:在早期包丢弃这个应用场景下,用硬件做丢包已经可以解决问题,使用XDP有什么好处呢?Herbert解释了这个问题,灵活性和高性能都是需要的:面对DDOS攻击的时候XDP非常有帮助,XDP让系统遭受攻击时可以尽快丢包,可以影响到协议栈,可编程对越来越鸡贼的攻击者来说也非常必要。仅仅用硬件解决方案在这个问题上远远不够。

网络模块maintainer David Miller也认为XDP应用在丢包场景非常合适,硬件方案不足以解决问题。

Sowa还提到了不太好解决的API问题,随着XDP的发展会用到越来越多内核用户态的ABI。反过来ABI会限制网络协议栈的发展。

statx() v3

某些开发到取得成果耗费了很长时间。这在提议的statx()系统调用上得到了验证——至少,很长时间是肯定的,尽管我们依旧需要等待其开花结果。不过从大部分描述来看,这个stat()系统调用扩展将接近ready。近期的补丁显示出当前statx()的状态以及遗留的阻塞点(sticking point)。

stat()系统调用用于返回文件的元数据。它有着悠久的历史,早在1971年的Unix发行版第一版就首次登场。在接下来的45年里stat()很少变更,甚至操作系统其余部分围绕它变更。因此,它毫无疑问趋向不符合当前的需求。现在,它无法表示文件的关联信息,包括generation和版本号,文件创建时间,加密状态,是否存储在远程服务器上,等等。它使得调用者无法选择获取何种具体信息,甚至可能强制耗时的操作获取应用并不需要的数据。同时,时间戳域又有着year-2038问题。等等。

David Howells自2010开始零星地致力于替换stat()的工作,他的第3版Patch(算上他今年早些时候重启该项努力)于11月23日发布。而提议的statx()系统调用看上去和5月份的基本一样,存在少许变更。

statx()的原型依旧是:

int statx(int dfd, const char *filename, unsigned atflag, unsigned mask, struct statx *buffer);

通常地,dfd表示一个目录的文件描述符,filename表示对应的文件名;该文件可通过给定目录的相对路径查找到。如果filename传入为空,则dfd被解释成需要查询的文件。因此,statx()替代了stat()和fstat()的功能。

atflag参数修饰该系统调用的行为。它处理了几个当前内核已存在的标记:AT_SYMLINK_NOFOLLOW,用于返回符号连接信息而不是进一步跟踪它;AT_NO_AUTOMOUNT,用于阻止远程文件系统自动挂载。一组statx()专用的新标记控制着与远程服务器的数据同步,允许应用程序调节IO和精确结果之间的平衡。AT_STATX_FORCE_SYNC将强制与远程服务器的同步,即使本地内核认为其信息是最新的;而AT_STATX_DONT_SYNC则隐含着快速获取远程服务器的查询结果,但可能已过时甚至完全不可用。

因此,atflag参数控制着statx()将如何获数据;而mask则控制着获取何种数据。可用的标记允许应用程序请求文件权限,类型,连接数,所有权,时间戳等。特别值STATX_BASIC_STATS返回stat()将返回的所有信息,而STATX_ALL则返回所有可用的信息。降低请求的信息量可能减少执行该系统调用的IO数,但一些检视者担心开发者将直接用STATX_ALL以避免需要更多的思考。

最后的参数buffer包含需要填充关联信息的结构体;该补丁版本中结构体如下:


struct statx { __u32    stx_mask;    /* What results were written [uncond] */ __u32    stx_blksize;    /* Preferred general I/O size [uncond] */ __u64    stx_attributes;    /* Flags conveying information about the file [uncond] */ __u32    stx_nlink;    /* Number of hard links */ __u32 stx_uid;    /* User ID of owner */ __u32    stx_gid;    /* Group ID of owner */ __u16    stx_mode;    /* File mode */ __u16    __spare0[1]; __u64    stx_ino;    /* Inode number */ __u64    stx_size;    /* File size */ __u64    stx_blocks;    /* Number of 512-byte blocks allocated */ __u64    __spare1[1]; struct statx_timestamp    stx_atime;    /* Last access time */ struct statx_timestamp    stx_btime;    /* File creation time */ struct statx_timestamp stx_ctime;    /* Last attribute change time */ struct statx_timestamp    stx_mtime;    /* Last data modification time */ __u32    stx_rdev_major;    /* Device ID of special file [if bdev/cdev] */ __u32    stx_rdev_minor; __u32    stx_dev_major;    /* ID of device containing file [uncond] */ __u32 stx_dev_minor; __u64    __spare2[14];    /* Spare space for future expansion */ };

这里stx_mask表示实际有效的域,它将是应用程序请求的信息与文件系统实际能提供的信息之间的交集。stx_attributes包含描述文件状态的标记,他们表示文件是否被压缩,加密,不可变,只允许追加,不包含在备份中,或者自动挂载点等。
时间戳域结构体:

struct statx_timestamp { __s64    tv_sec; __s32    tv_nsec; __s32    __reserved; };

__reserved是第3版基于近期讨论针对statx()的一个强烈反对观点加进来的。Dave Chinner建议,在将来的某个时间点,纳秒精度将不再适用,他指出该接口应当能够处理飞秒时间戳。几乎只有他一个人持有该观点,而其余参与者,如Alan Cox,指出光速将保证我们永远不需要纳秒精度以下的时间戳。但Chinner坚持自己的观点,因此Howells新增__reserved以备将来所需。
Chinner针对该接口还有若干其他反对意见,其中一部分尚未处理。这些包括STATX_ATTR_标记的定义通过FS_IOC_GETFLAGS和FS_IOC_SETFLAGS ioctl()系统调用屏蔽了对一组现有标记的使用。重用这些标记给予statx()代码的微小优化,但将继续保持过去造成的部分接口错误,Chinner说。Ted Ts‘o在检视2015版补丁集时提供过类似的建议,但第3版保持着同样的标记定义。

Chinner的最大反对意见在于,statx()缺少综合测试用例。他认为这些代码在测试用例提供之前不能进入主干。

非常坦白地说,我认为综合测试用例对这样一个通用、可扩展的新系统调用功能来说是无条件的。要么我们在合入前做到测试覆盖,要么我们不合入。我们一次又一次地证明没有经过测试的垃圾是无法工作的(shit doesn’t work if it’s not tested),并且无法被独立的文件系统开发者广泛验证。

该态度近期也被其他人所回应,如Michael Kerrisk。内核确有一段很长的历史合入过无法像其宣称那样工作的新系统调用,并得到了应有的惩罚。Howells将提供类似的测试用例,但目前没有。

在此之上发生的众多有争议的话题(bikeshedding),我很庆幸我目前还没有完成这个测试套件,它将至少有着两倍以上的工作。我仍然不知道最终的形式将会是什么。

该补丁集的变更看上去慢下来了,也许最终的版本开始成为焦点。但是,该项工作的历史暗示我们预期它在近期合入是不明智的。stat()系统调用伴随了我们很长时间,因此期望statx()将持续同样长时间也是合理的。一些额外做好该接口的“具有争议的话题(bikeshedding)”,也是可以理解的。

Topics in live kernel patching

在内核主线提供热补丁的能力是个长期的过程。4.0版本开始就已经集成了基本的热补丁功能,但是更多的支持阻塞在一致性模型(代码保证一个热补丁应用到一个正在运行的内核上是安全的)该如何工作上面。此外,kernel stack validation一文提出了最大的反对意见,因此,现在是时候向前走了。在2016年Linux Plumbers Conference上面,热补丁方向的内核开发者们坐在一起讨论内核热补丁当前的挑战以及未来的方向。

这篇文章并不是那个为时半天的讨论的一个总结,而是想通过展示热补丁开发者所面临的一些挑战以及他们准备如何应对这些挑战来聊一聊一些更有趣的话题。

帮倒忙的编译器优化

一个聪明的编译器对于每个想要他们的代码获得可观的性能的人来说都很重要,但是,当编译器变得过分聪明的时,就会有另一些问题。譬如,有时候跟并发执行的内核打交道的开发者不得不担忧编译器的过分优化。Miroslav Benes说,内核热补丁的开发者也得担心这些问题。编译器优化可能会在一些微妙的方式上改变代码编译的方式,以至于当热补丁应用上的时候会导致错乱。

我们从最简单的问题开始,之后在讨论那些比较tricky的。Benes发现当需要对一个内联函数打patch时,自动inlining功能会是个问题。不过这种情况的解决方法相对比较简答,对于所有的要patch的内联函数的调用者统统都需要变。gcc的-fpartial-inlining选项会导致这个问题变得复杂,但是并不会改变这个问题的本质。

-fipa-sra选项相对来说更加微妙一些,它可能导致删除一些没有用到的函数参数,或者改变函数参数传递的方式。也就是说,对于函数的调用者来说,它改变了函数的ABI。对于这种函数的热补丁会改变这种-fipa-sra优化的工作方式(譬如之前优化是会删除某个没有用到的参数,打了热补丁之后,这个参数就需要用到了,优化就会保留这个参数),因此可能会导致ABI出现奇怪的改变。好消息是,当这种情况发生的时候,GCC同时会改变被编译的函数的名字,这样,被破坏的ABI会立马显现出来。但是,这样导致不能直接给一个有bug的函数打补丁,这个函数的调用者也就得跟着打patch了。

带-fipa-pure-const编译的代码可能会改变一个函数工作的方式;如果一个函数看起来并不会访问主存,编译器会假设主存的状态在这个函数调用前后不会发生变化。当一个热补丁改变了这个函数的行为时,这些假设可能不在成立;再一次,这个函数的所有调用者又得全部跟着都打一次补丁。

一个更加”疯狂“的选项是-fipa-icf,这个选项会执行相同代码折叠(Identical Code Folding)。简单来说,它会把拥有相同功能的函数合并成一个,这个功能可以减小代码量,但是很难检测某个函数是否被”折叠“了。代码折叠对于内核stack unwinder来说也是个问题。另外,还有一些其他问题。譬如,当一个GCC认为一个函数不会改变某个全局变量时。当一个函数打了patch后会改变这个全局变量,调用这个函数的代码可能就不对了。这种类型的问题,同样很难检测。或者如果GCC有一个选项,可以要求它对于它做的优化创建一个日志会更好一些。

或许最吓人的选项是-fipa-ra,这个选项追踪调用的函数所使用的寄存器,并且避免去保存这些不会被改变的寄存器的值。对于这种函数打补丁很容易会导致这个函数使用新的寄存器,从而导致调用者数据错乱,并且很可能大幅减少热补丁开发者期望热补丁工作的时间。这个选项很难被检测到,同时,它也可以看做是一种的ABI的改变,但是函数名却没有变。当前,这个优化选项在GCC开启了-pg时候会被禁用,并且Ftrace子系统需要-pg选项来支持热补丁。但是-pg和--fipa-ra选项并没有内在的原因导致两者不兼容,因此,这个行为可能在任何某个时候会被修改。

Miroslav说,上述几点,只是编译器优化对于热补丁技术所带来的问题的一个小的子集。随着编译器开发者追求更加激进的优化,这些问题会变得越来越严重。

补丁构建

内核有一个标准的方式应用一个热补丁,但没有任何类型的主线机制来创建热补丁。Josh Poimboeuf给出了一些补丁制作工具的简要总结,着眼于选择一个用于upstream。

第一个工具是kpatch-build。它的工作原理是构建有补丁应用和没补丁应用的内核,然后比较二进制diff看哪些功能改变。所有改变功能的提取和包装成一个“Josh Poimboeuf 内核模块”,而这个模块是随附着热补丁的。他说,这是一个给力的系统,具有许多优点,包括它自动处理前面提到的大多数优化问题。另一方面,kpatch-build相当复杂。它必须知道内核所使用的所有特殊部分(special sections),以及它对某些类型的更改是有问题的.。目前它只能运行中x86_64体系结构,对于不同的体系结构,这些特殊部分(special sections)也不同,所以把kpatch-build变成一个多架构的工具并不容易。而且,他说,kpatch建立是易碎的,甚至是一场噩梦。

另一种方法是使用常规的内核构建系统和它的模块构建基础设施。将改变的函数复制并粘贴到一个新的模块,增加一些样板并用热补丁API注册函数,最终完成任务。这种方式很容易,但有它自己的问题,特别是这个模块是无法访问非导出的符号,而这些符号可能会被打补丁的函数使用到。这个问题可以通过使用kallsyms_lookup_name()身边工作,但是这个解决方案容易出错,速度慢,还有点“恶心”。

第三种方法有点新,事实上,他在会议召开前一周就发布了这个建议。这种方法使用复制和粘贴的方法,但增加了一个API和后处理工具,可以使生成的模块能够访问非导出的符号。目前这种方法有效,尽管现在还有许多可以改进的地方,包括自动连接到非导出符号的过程和检测编译器优化的干扰。

在演讲结束时的简短讨论中,很明显可以发现,人们对新的工具没有太多的关注,因此这可能就是要发展的方向。

模块依赖

内核热补丁技术可以修改内核模块以便修复其中的代码错误。而这带来了一个有趣的问题:如果需要修复的内核模块在补丁模块加载时没有到加载系统中,而后又被人为加载了该如何处理。当前内核热补丁技术实现了一套复杂的基础架构来监测这一问题并在内核模块加载时一并加载补丁模块。Jessica Yu是当前内核模块加载子系统的维护者,她在会议上介绍了当前这一机制对内核模块加载的影响。

补丁模块自己其实也是一个内核模块。允许这样一个补丁模块加载时先加载问题模块本身需要模块携带很多额外的信息,并需要一套复杂的架构来实现这一功能。此外,这样一套基础架构还跳过了当前内核模块加载的依赖机制,包括在热补丁相关代码中几乎重写了一遍内核模块加载器。

当然还有其他的方法来解决这一问题。一种方式是简单的要求问题模块必须在补丁模块加载前先行加载。这样做当然是可以解决问题的,但缺点是这样会加载很多无用的内核模块。另外一种可能的解决方法是将补丁模块根据修复的问题模块做拆分,拆分成多个不同的补丁模块,根据当前系统中加载的问题模块加载对应的补丁模块。

第一种方式可以极大地简化内核热补丁模块的相关代码,同时减小代码冗余。但有个问题缺不容易解决。即如何强制加载需要打补丁的所有问题模块。depmod工具无法识别这种依赖。FreeBSD系统上有一个MODULE_DEPEND()宏可以完美的处理此问题,但是该宏定义在Linux上并没有实现。

第二种拆分补丁模块的方式对于影响面很大的补丁并不适用。以CVE-2016-7097为例,该补丁修复了一处位于VFS层API上的安全漏洞。但这一补丁会涉及所有文件系统模块。如果将这一补丁模块进行拆分,结果则是生成一长串模块列表。

在一系列的讨论中,Steve Rostedt提出了一个观点,既然问题模块并没有加载,为什么不直接将磁盘上的问题模块替换成修复后的新模块。而Jiri Kosina的回答是这样会造成发行版软件包管理上的混乱。即内核模块和其对应的软件包版本并不一致。而这样的不一致会造成很多麻烦。同时新模块并不像补丁模块一样可以随时回退,把磁盘上已经替换掉的问题模块恢复回来也是个麻烦事。尽管如此,Rostdt依然坚持认为直接替换磁盘上的内核模块才是问题的解决之道。

讨论的最后大家达成的一致意见是当前内核热补丁对于模块依赖问题的解决方法已经足够了。

其他话题

Petr Mladek讨论了关于数据结构修改的问题。对于全局变量这类数据结构的修改还是比较容易的。困难的是串联在多个链表上的数据结构的修改,这几乎是不太可能直接通过类型的转换来做到的。对于这类数据结构的修改要十分小心。现在有一些技术来尝试解决这一问题,比如对于需要修改的数据结构添加影子结构。但是这样会造成额外的性能开销。同时当补丁模块卸载时的处理也十分复杂。

Miroslav Benes讨论了关于一些调度器相关代码的修改。比如schedule()就是一个很棘手的函数。因为这个函数在返回前后可能调用者的调用栈已经改变了。

他给出了一个解决的方法是在上下文切换时将指令指针保存到上线文中。这样补丁模块在加载后可以通过这一信息判断schedule()前后的上线文信息是否一致。这一方式被证明是可以工作的,但是是否值得就另当别论了。毕竟schedule()极少出现问题。

Jiri Kosina讨论了内核热补丁未来的工作。其中之一是一致性模型。现在的栈验证工作已经完成,内核栈回溯机制是可信的,因此一致性模型的相关工作可以继续开展了。

特别是混合一致性模型可以继续推进。但是还有另外一个问题,为了构建确保栈信息可靠,需要内核编译时打开栈针。而这会带来约10%的性能开销。目前还不知道打开这一选项为什么会带来如此巨大的性能损失。Mel Gorman正在跟进这一问题。

Kosina说当前正在将内核热补丁移植到arm64平台上。而其他相关工作也在有序开展中。
此外会中还讨论了PowerPC的相关问题,以及用户态热补丁的问题。

Balbir Singh提出是否真的需要在集群中使用热补丁技术?如果一个集群可以分批下线进行升级,那么热补丁技术其实并不是必须的。当然对于用户来说热补丁技术可能确实是需要。

最后一个问题是关于rootkits的。一个可以将指定代码注入到运行的内核中的工具。Kosina说他并不担心这个工具。因为内核热补丁本身就是内核模块,如果攻击者可以加载一个内核模块,那他也可以做任何他想做的事情。但Singh说,如果热补丁本身存在缺陷怎么办。如果修了一个安全问题却引入了新的安全问题呢。

A discussion on virtual-memory topics

VM子系统现在还有哪些问题? 基于这个话题,Mel Gorman(目前工作在suse的performance group,主要关注在提升小块内存分配性能提升上)、Rik van Riel(主要关注点在qemu里面通过使用persistent-memory技术将page cache从虚拟机里面移到宿主机上,便于管理和共享)、Johannes Weiner(主要工作是提升page-cache的抖动检测)、Vlastimil Babka(主要关注点是高order页的分配,一方面是减少不必要的高order分配,virtually mapped kernel stacks这套patch使得栈分配这块已经不再需要使用高order内存,另一方面是优化内存compaction和减少内存碎片)四位用了半个小时就此展开讨论,总结下主要在如下两个方面:

  • swap相关困扰
  1. 使用zram将内存swap到硬件设备时,其中有2/3的cpu消耗是在内存压缩,而另外1/3的cpu消耗是其他部分导致的,当内存压缩移植到硬件里面之后,另外的这1/3就是大头,需要定位清楚这部分消耗并做相关优化。
  2. swap对快速设备路径支持这块当前存在一系列问题(Johannes):
   a. 换出路径的全局锁
   b. vm子系统即使在page cache频繁被访问时也还是会倾向于回收page cache,而不是考虑进行swap
   c. 换出路径对hugepage的拆分

Mel对这块总结是swap现在已经running into walls,需要重新思考下,这类问题大概需要6~24个月来进行fix。

  • 诡异的shrinker

brtfs一位开发者做了一个实验,将系统80%的内存用来缓存inode和dentry,同时注册了shrinker保证这部分内存能够管理,结果观察到的现象是大部分时候都在扫描page cache,而当前系统中并没有page cache,导致这些扫描都是无用的,同时注册的shrinker并没有被告知尽可能多的释放内存。ext4的开发者在extent status slab cache这块也碰到类似的问题,导致这个cache可能变得特别越大;同时在多个shrinker并发执行时还会有spinlock竞争问题。

Rik谈到VM子系统对此唯一能做的是调用相应的shrinker,但是对slab缓存,一个page可能包含很多个object,只有当这个page上面所有的object都free了,对应这个page才能够被free,有可能大量的object被回收了,但是实际系统可用内存并没有增加多少。针对这个,有人提出需要有方式能够区分干净的object(能够立即回收的)和脏的object(需要先回写),同时需要有一个基于page的shrinker,能够快速找到所有object都是干净的page。

Mel建议需要增加一个帮助接口,给shrinker用来找到属于同一个page的所有object,这样可以一起释放。对于锁竞争,一种方法是限制direct reclaim并发线程数,另外一种就是发现有人持锁时快速的回避(持锁者能够回收出相应的内存)

还有一个问题,有一些object会将其他的pin在内存中,比如inode会pin对应的dentry,dentry会pin父目录的dentry。linus建议将叶子节点(类似普通文件)的dentry和目录的dentry分开来,叶子节点的dentry更容易被回收,因此将它们放到一块,这样可以增加释放整个page的概率。这样做唯一的问题是:内核在分配内存的时候并不能区分dentry的类型。Mel建议文件系统在发现放置错误之后重新再分配另外一个dentry,然后将数据拷贝过去。

最后Tim Chen介绍了下他的swap optimization work,由于persistent memory可以直接寻址,内核可以将换出的page直接映射到进程地址空间,避免重新swap回内存。但是这种方式如果对于页访问太频繁会有性能影响,因此需要有个机制能够评判什么时候需要swap回内存。对于普通内存有lru表,单对于persistent memory只有page table里面的access bit。到讨论最后这块目前还没有解决方案。

你可能感兴趣的:(阿里内核月报2017年01月)