阿里内核月报2014年7月-8月-阿里云开发者社区

开发者社区> 开发与运维> 正文

阿里内核月报2014年7月-8月

简介: Capsicum是一种源自FreeBSD的安全模型,与Linux下众多LSM的相同之处在于它们都是基于权限管理的,而不同之处在于LSM针对的操作对象非常丰富,有进程、VMA、端口、带有标签的文件等等,而Capsicum操作的对象非常单一:文件句柄。

Capsicum for Linux

Capsicum: 一种基于文件句柄的新安全模型

Capsicum是一种源自FreeBSD的安全模型,与Linux下众多LSM的相同之处在于它们都是基于权限管理的,而不同之处在于LSM针对的操作对象非常丰富,有进程、VMA、端口、带有标签的文件等等,而Capsicum操作的对象非常单一:文件句柄。例如,一个fd必须带有CAP_READ才能被读取,必须带有CAP_SEEK才能被lseek(),必须带有CAP_MMAP_W才能被mmap()建立可写映射,针对ioctl()和fcntl()它还有一些特殊约定的权限。
可以想象,既然这些限制都是绑定在某些fd上的,那么如果一个被限制的进程可以随意地打开新的fd操作文件,这些限制自然就没什么用处了。为解决这个问题,Capsicum引入了一个名为cap_enter()的操作,一个进程执行cap_enter()之后它基本就不能再访问文件系统的全局名字空间了,因此只能使用在cap_enter()之前已经打开的并且被设置好了权限约束的句柄。但是cap_entery()这个操作在Capsicum的第一版patchset中并没有被实现,只是提出了这个概念而已。
在内核里,用户空间传来的fd会喂给fdget(),再由它返回struct file *,Capsicum的hook点就在这里。作者定义了一个struct file的wrapper结构,包含原始的struct file和额外的一些权限信息;然后提出了一个fdget()的新变种,形式如下:

struct file *fdgetr(unsigned int fd, int caps …); 

注意到它还是一个参数数量可变的函数,所有的cap会由最后一串参数传入。内核中原先调用fdget()的大约100个调用者都需要改成这个新接口,同时调用者还得处理新接口的返回值。因为原先的fdget()在出错时只返回NULL,不会有进一步的错误值返回,而fdgetr()的错误返回值要丰富得多,这意味着这个patchset侵入性相当强,估计很难被接受。 目前Capsicum是基于LSM框架之上实现的,有评论认为Capsicum与LSM的耦合性很低,完全可以抽出来独立实现。另有评论认为Capsicum完全可以由新的seccomp-bpf实现,不需要额外加patch。考虑到用seccomp-bpf写代码很麻烦,实现这些功能肯定不会简单,但这么做应该是可行的。总的来说,大家普遍觉得Capsicum这套patchset想被接受是相当困难。主要的优势在于FreeBSD既然已经有了这种安全模型,那么可能会方便一些FreeBSD上的代码移植到Linux上来,如此而已。

Control groups, part 1: On the history of process grouping

即便Control groups不是Linux中最具争议的特性,在各种邮件列表和论坛上随处可见对control groups热火朝天的讨论,甚至完全否认特性的价值.这一系列的文章介绍围绕control group(cgroups)的争议.

要理解这些争议,既需要广阔的视野,也需要对详细分析.前两篇文章通过介绍Unix的历史,分析cgroups给进程组带来了什么问题.然后分析cgroups的层次结构,借助Unix和Unix之外的系统,为衡量cgroups的层次结构提供标准.
后几篇文章深入分析cgroups.

第六版的Unix

历史上,Unix对进程组的管理经历了一些演变.了解这些演变对我们很有帮助,这里不从最初介绍演变过程,而是从第六版的Unix开始(后面叫"V6 Unix").

V6 Unix诞生自20世纪70年代中期,是走出Bell实验室获得巨大关注的第一版.它支持两种进程分组管理,开始之前我们先说明"进程分组"的含义.

(作者借数论里面的群group介绍这里的process groups)在数论中,不是所有的集合都是群(group).一组能用质数唯一标识的进程是一个组.然而在Unix中(无论是当时或是现在),不存在把这种进程组和其他以合数标识的进程组区分开的方法.另外的进程,既不能用质数标识也不能用合数标识的进程,其行为与这两组也有区别.由于它仅仅包含1号进程,不单独把它考虑为一个进程组.

数论中的群,包含基于群中元素的操作.类似的,对于进程组来说也需要与进程组相关的操作.
另一种不太直接的分组方法是根据进程所有者的ID(UID).这并不是V6 Unix一个真正的组,尽管有些操作对一些组和另外的组的影响是有区别的,但它们不是一个整体.
一种真正意义上的组是进程的子进程组.V6 Unix中与子进程组相关的唯一操作是wait()系统调用,它检查这个组是否为空.如果wait()系统调用返回ECHILD,那么这个组为空.如果wait()不返回或者正常返回,那么系统调用时这个组不为空.

类似的操作也可以在进程的后代进程上定义,即进程的子进程和其他后代进程.当且仅当没有这样的进程,wait()系统调用返回ECHILD.需要注意的是,子进程组中进程只能在执行完成的时候才能从组中退出.在后代组中,它的任何一个祖先进程执行完成,这个进程就可以退出组.
进程能否退出组是不是一个重要特点取决于具体情况.在V6 Unix中,1号进程的后代不能退出,而其他进程的后代可以.这种情况延续到了类Unix系统Linux,一直持续到Linux3.4在prctl()中加入了PR_SET_CHILD_SUBREAPER选项.它允许限制子进程退出组.一个进程停止时,它的子进程被这时这个选项的进程继承.

V6 Unix中的另一种控制组,是进程结构中的p_ttyp域指定的"controlling tty".当进程打开一个tty设备时(通常是一个串口数据连接),如果这个域没有被设置,它会被设置指向一个新打开的设备.这个域可以通过fork()或者exec()继承而来,因此一旦进程打开了设备,它的子孙后代也会继承这个设备.

p_ttyp的作用是定向所有到/dev/tty的I/O到controlling tty.由于它对每个进程的影响是独立的,这不是使进程成为组的原因.controlling tty使进程成为组的原因与信号处理有关系.当DEL或则FS(control-)被输出到tty,SIGINT或者SIGQIT信号被送到组中所有使用这个tty作为controlling tty的进程.类似的,如果链接中断,SIGHUP信号也被送到组中的所有进程.还可以使用sigkill()系统调用发送信号.发送到0号进程的信号也会被发送到与发送进程具有同样controlling tty的进程.

这种组策略已经与control groups非常类似了.尽管只通过信号传递体现出来,进程的分组和管理已经非常明显.组是自动产生的,与行为有关,并且是永久的(一旦成为组成员,进程不能脱离).但这并不完美,下一节介绍其改进.

第七版Unix

虽然V6 Unix已经支持process groups,却没有提出这一术语.V7 Unix中不仅提出这个名词,还丰富了group的含义.p_ttyp域仍然存在,它对/dev/tty访问的管理被限制了.它被重命名为u_ttyp,并且被移动到struct user中,user结构体可能随进程的其他部分被换到磁盘中.proc结构体使用新的p_pgrp域来管理process groups.在第一次使用open()打开tty的时候p_pgrp域被置位,它被用来传递SIGINT,SIGQUIT和SIGHUP信号,也被用来向0号进程传递信号.V7带来很多复杂的变化.

最大的变化是使process groups有了独立的名字,至少与tty无关.一个没有controlling tty的进程第一次打开tty时,会以进程ID为名字创建一个process group.当进程退出时,这个组仍然可以存在.活动的子进程可以防止ID被重用.

这么做的结果是,从tty退出后重新登陆时,会新创建一个process group,tty结构体中的t_pgrp域会被修改.与V6 Unix不同,送到一个进程组的信号决不会被送到同一个tty已经登陆的进程上.

另一个影响是process groups可以被用在tty之外.第七版的Unix有一个短暂存在的"multiplexor driver",现在仍在stat()的变更手册里

3000 S_IFMPC 030000 multiplexed character special (V7)
[...]
7000 S_IFMPB 070000 multiplexed block special (V7)

multiplexor和socket接口类似,运行不同的进程相互连接.它还提供了到多个互联进程的接口,允许管理进程发送一个信号到group中的其他进程.

V7 Unix的process groups仍然是封闭的,在一个group中的进程无法脱离group.mpxchan的确允许进程离开原本group加入一个新group,但不能确定这是设计者有意为之的结果.

Unix Berkeley第四版

Fourth Berkeley Software Distribution(4BSD)

从V7 Unix到4BSD经历了巨大的跨越,进程组也发生了很大改变.在BSD4.3中,拥有同样UID的进程变成一个组,可以将一个信号发送到这个组的所有进程.向PID -1的进程发送的信号则会传递到与当前进程拥有同样UID的进程中(特权进程向PID -1发出的信号则会被送到所有的进程).更重要的是,在4.4BSD中出现了进程组的层次结构.

Berkeley版本Unix的一大贡献是"作业控制".这里的作业指的是完成某个任务的一个或多个进程.Unix能够把一些进程放到后台,但其实现方式相当特别.这些进程会忽略任何信号,shell也不能等待进程完成.大多数情况下时没有问题的,但是进程不能从后台恢复到前台,和后台程序的输出会和前台程序混在一起.这些都是存在的问题.

在BSD"作业控制"下,shell可以控制哪个作业在前台,哪个作业放到后台.

在BSD之前,进程组与每次登陆之间是对应的,从与AT&T开发的"System V"Unix兼容的角度来说,这是有意义的.在4.4BSD中,这些与login对应的进程组被定义为"会话".进程属于进程组,进程组属于会话.每个终端有一个前台进程组t_pgrp和一个控制会话t_session.

会话和V7 Unix的进程组有点类似,但它们也有区别.一个区别是,不能向一个会话的所有进程组发送信号.另一个区别是进程可以离开当前会话,用set_sid系统调用创建新的会话.

这些区别使logout时杀死所有进程的操作变得复杂.其中有些问题在当时的版本中无法解决.

在现代基于窗口的桌面系统中,会话和进程组仍然存在,但与当时的含义不完全相同.用ps命令可以看到sess和pgrp域

ps -axgo comm,sess,pgrp

进程组和login会话之间再也没有直接的关系.每个终端窗口拥有自己的会话,包括其他产生终端的应用.每个从shell提示符下启动的job拥有其进程组,停止这些job的需求小的多.不需要停止当前终端上的活动任务.

要呈现现代桌面系统的进程分组,需要更复杂的层次结构.其中一层表示login会话,一层表示运行在会话上的进程,一层表示某个应用的作业.从4.4BSD发展来的Linux只提供了其中两层,我们可以在cgroups中寻找第三层.

问题

开始总结.在形成对cgroups的认识前,需要考虑一些问题

  • groups命名:V6 Unix中唯一的名称来自tty,并且与进程ID的命令空间相同.这种共享有点笨拙,虽然看起来很方便.
  • overlapping:信号传递和向/dev/tty的I/O请求最初用同样的机制.但是很快被区别开,它们并不完全相同.
  • 进程能否脱离进程组?历史上经过了从"不能"到"能"的转变.二者各有利弊.
  • 层次结构起了什么作用?

最后一个问题,层级结构至关重要.cgroups最近的一些改变和其反对意见都跟层级结构有关.现在离真正理解层级结构还差很远,接下来的文章中将要继续介绍.

Control groups, part 2: On the different sorts of hierarchies

满世界都是各种层级(hierarchy)关系,作者举了一堆例子,其中有一个挺有意思的: 如果你在 Wikipedia 上面打开一个文章,然后连续的点接下来的文章链接,94.52% 的概率你最终会找到一个词,i.e. 哲学 (Philosophy)。

Hierarchy 在 Control groups (cgroups) 中的设计,其实是引起了很多的争论和挑战,[译:可能是因为 use case 太多,总要 balance 各种好处和坏处,而作者很 enjoy 这一点。下面是他给的一些 case,也提出了一些看法]

  • 层级在账户权限中的应用

以前在澳洲一家大学的计算机学院做系统管理员的时候,总要针对学生,和教员以及其他后勤行政人员做相关的资源管理,权限分配。不通的部门和课程有很多交集,作者是在计算机支持组。而在这些交集中,角色 (role) 也不同,权限也不通。比如,教员可能比学生拥有更高的打印权限,或者有些打印机仅仅被小部分人使用来打印保密文件,或者有些需要彩色打印稿。
为了管理这些,我们整了两个层级,一个是“理由” (包含各种角色,基于这个来分配账户),一个是“组织” (角色所在的组织,比如:学校的部门)。然后每个账户也都是可以在不同的组中的,教员和学生都可以参与不同的课程,一些高年级学生可以代低年级的学生。在这些权限控制中,账户层级也是可以有继承关系的。
[译:只是一个例子,算是比较 high level 的,读者有兴趣也可以看下 RBAC (role based access control) 的规范,算是一个理论基础吧]

  • 可控的复杂度

多层级比单层级的好处,就是比较弹性,一旦做好了,这样在添加一个账户的时候就不用太费心,说白了就是后面的操作会比较简化。
后面就是说,其实系统稍微复杂的成本足够 cover 后面操作的复杂度,虽然两个层级,但是后面管理(增,删,改,查)比较一个层级的容易很多。

  • 两种层级类型

这两个层级在内在和细节上都有很多不同。

“Reason” 层级其实是一种分类,每个个体都有自己的角色,把相似的角色归小类,然后再给小类划分大类,比如:生物学中的,门,纲,目,科,属,种。在树这种结构中,叶子节点,就相当于种。

“Organisation“ 层级就很不一样,很多时候分组方式,是基于方便的方式,所以这个应该是动态的,而不是有和 “Reason” 一样的比较固定的从属关系。还有一个属性就是,学校或者科研项目的头,基本就代表这一个学校或者项目,而不用把他们在关联到具体的项目的课题中。

  • /sys 下面的设备

Sysfs 文件系统(通常挂载在 /sys 下面), 这里只关注一下设备信息,不 care 其他的(模块,文件系统细节等)。主要有三个设备相关的层级关系在 sysfs 里面,设备一般是目录树,Unix 文件系统一般不支持一个目录再拥有多个父目录,所以一般用的是软链接。

设备根在 /sys/dev 。早期主要就是块设备和字符设备,设备文件一般在 /dev (串口,并口,磁盘等),主次设备号来标识。

设备树分为三个层级,例如:/sys/dev/block/8:0,最后一个层级用冒号分隔主次设备号,而不是用斜杠,这是一个软链接。 (译:指向目录 /sys/block/sda)

一个用处是,如果你只有一个 /dev 下面的设备名或者一个打开的设备文件描述符,可以用 stat() 或者 fstat() 系统调用拿到设备类型,主从设备号等信息,然后就可以转换到对应 /sys/dev 下面,再拿到其他的需要的信息。

[译:下面这段好像有点问题,subsystem 指向的不一定是 class/bus, 还要具体看]

/sys/class 目录里面就是一堆设备分类,(用的是设备名,不是设备号);/sys/bus 里面的信息比较多,有其他的例如模块信息什么的。比如 块设备通过 usb 挂在 pci 下,这种物理层级就可以被描述出来。

简单分层很难描述出所有设备的内在连接,有的设备很可能从一个地方拿到控制信号,而从另外一个地方获得 power。这个在今年内核峰会之前,已经被广泛讨论过了。

从分类的角度,/sys/dev 是比较简单的,而 /sys/class 其实也是比较简单的,虽然它包含更完整的信息。

/sys/bus 也是一个只有两层分类。class 层级主要是功能层面的(例如,设备提供net, sound watch dog 功能),而 bus 层级主要是访问的角度 (如何被访问的,比如:物理顺序)。

这么理解的话,基本上就还是两个分类。

/sys/devices 分类包含所有类型的设备,有的简单的挂在物理总线下,否则(没有对应的物理总线)就在 /sys/devices/virtual

  • Linux 源代码树

Linux 源代码树的层级出发点很不一样,我们来看看 (其他代码树类似)。这种层级更加关注于组织而不是分类 (相比:sysfs) 。[译:作者一直强调 classification 和 organization 的区别,大概就是一个倾向于静态(分类之后的成员比较固定),一个倾向于动态(分类之后的成员尚可调整)]

顶层目录,fs 为文件系统,mm 为内存管理等 [译:kernel 目录我理解就是个杂项]。有些子系统比较小,比如 time ,就放在 kernel 里面,如果变得足够大了之后,就可能有自己的目录,但是不会跑出 kernel 目录。

fs 子树多半包含的都是不同种类的文件系统,也有一些辅助的模块,如:exportfs 用来辅助文件服务器,dlm 锁定集群文件系统,ad hoc 实现高层的系统调用,没有专门的杂项目录在 fs 目录里,都是直接放在 fs 下面。

具体怎么分类也不一定有准确的答案,不过在对 cgroups 分类争论的时候,不妨参考一下。

源码树也包含其他的分类,比如:scripts, firmware, 头文件在 include。最近几年也有在讨论把头文件放到相应的 c 文件附近。考虑一下 nfs 和 ext3 文件系统。 每个文件系统有自己的 c 文件和头文件,问题是这些头文件应该都放在 include 下面吗? 要不要把 .c 和 .h 对应起来放在一起? 这种情况在 3.4 版本内核上面已经变了, ext3 的 4 个头文件已经从 include/linux/ 搬到了 fs/ext3/ext3.h

层级分类的话只有当需要并且必要的时候才做。

  • 进程层级

理解 cgroup 才是这一系列文章的真正目的,依赖于层级如何来管理这些不同进程的角色。上面中的例子没有一个是关于进程的,不过他们引出了很多问题在深入讨论 cgroups 之前:

  • 单层级的简化比多层级的灵活重要?是否他们是独立的还是相互作用的?
  • 主要目标是给进程分类还是简单的组织一下他们?或者两个目的都有,如何把这两者有机结合起来?
  • 我们可否允许一些非层级机制,例如:符号连接,或者文件名后缀[译:某种程度上,打破了分级]来提供分类或者组织一些元素?
  • 可否把进程附加到在分层的内部节点上?或者应该强制他们在叶子节点上?即使叶子代表杂项?

上次一个进程组的例子,是一个单一的分级,先会话进程,然后是工作组,这样的进程组里面的进程都是叶子,比如从来不会打开 tty 的系统进程就不在分层里面。

找到这些问题的答案需要理解 cgroups 怎么来组织这些进程的,并且这些分组是用来干嘛的,后面会分析 subsystems 包含资源控制和其他的操作来回答这些问题,看下回分解把。。。。

Control groups, part 3: First steps to control

July 16, 2014 This article was contributed by Neil Brown
在cgroup之前就已经有nice用于控制每个进程可以使用的CPU time,以及通过setrlimit()系统调用限制内存及其他资源的使用。然而这些控制只能针对每个独立的进程,无法对进程组进行控制。

Cgroup子系统

Linux的cgroup能够允许设立独立的子系统用于对进程组控制,更适合的术语是“资源控制器”。

Linux3.15现已经有12个cgroup子系统。我们主要关注下这些子系统--特别是子系统之间的层级组织,如何工作。

在进入细节前,先快速描述下一个子系统能做的事情。每个子系统能做的包括: 1. 在每个cgroup中存储一些任意的状态数据 2. 在cgroup文件系统里提供一些属性文件用于查看或者修改状态数据,或者其他状态细节 3. 接受或者拒绝进程加入一个给定的cgroup的请求 4. 接受或拒绝在一个已经存在的组里面创建一个子组的请求 5. 一些cgroup有任何进程创建或者销毁时获得通知

这些只是用于和进程组进行交互的通用接口,实际上子系统还会有其他一些方式和进程组以及内核交互,以实现期望的结果。

A simple debugging aid

debug子系统并不实际“控制”任何东西,也不移除bug(很不幸)。它既不给任何的cgroup添加额外的数据,也不拒绝"attach"或者"create"请求,甚至也不关心进程创建和销毁。

开启这个子系统唯一的影响是使得许多独立的组或者整个cgroup系统的内部细节能够通过cgroup文件系统的虚拟文件查看到。细节包括部分数据结构当前的引用计数,以及内部标识项的设置情况。这些细节只有cgroup工作者可能全部关注。

Identity - the first step to control

Robert Heinlein首先向我表达了这样一个想法:让每个人带上ID是走向控制他们的第一步。虽然这样做对于控制人类来说会很不受欢迎,但提供明确的身份认证对于控制进程组是很实际和有用的。这是net_cl和net_prio cgroup子系统最主要关注的。

这两个子系统都含一个小的标识数字,组内的进程创建socket的时候会将该数字拷贝给创建的socket。net_prio使用每个cgroup的id(cgroupo->id)作为sequence number,并将这个存储在sk_cgrp_prioidx中。这个对每个cgroup都是独特的。net_cl允许为每个cgroup设置一个特点的数字,然后存储在sk_classid里面。这个数字并不需要每个cgroup都不同。这两个不同cgroup的标识被三个不同进程使用。

net_cl设置的sk_classid可以被iptables使用,根据包对应socket属于那个cgroup对包进行选择性过滤。sk_classid也可以在network调度用于对包进行分类。包分类器能够基于cgroup以及其他一些细节作出一些决定,这些决定会影响很多调度细节,包括设置每个信息(message)的优先级。 sk_cgrp_prioidx这个是单纯的用于设置网络包的优先级,使用这个之后将会覆盖之前通过SO_PRIORITY socket选项或者其他方式设置的值。设置这个之后的效果和sk_classid及包分类器共同完成的类似。然而根据引入net_prio子系统的commit所说,包分类器并不总是能够胜任,特别是对开启了data center bridging(DCB)的系统。

同时拥有两个不同的子系统对socket以三种不同方式进行控制,而且还有相互重合的地方,这看起来很奇葩。各个子系统是否需要变得更加轻量级,以使得添加它们比较容易,或者各个子系统需要更加的强大,这样一个子系统就可以用于各种场景--这个在目前还并不是很清晰。后面还会碰到更多的子系统之间有交集的情况,也许能够帮助更加清晰的认识这个问题。
当前,最重要的问题还是这些子系统如何实现cgroup原有的层与层之间的交互。答案是,大部分都没有。

无论是net_cl设置的class ID,或者net_prio设置的优先级(per-device)只是应用于对应的cgroup以及所有和这个cgroup里面进程相关的socket。这些设置并不会对子cgroup里面进程的socket产生影响。因此对于这些子系统,嵌套关系是无意义的。
这种对层级的忽视使得cgroup树看起来更像是一个组织层级关系 -- 子组并不是子类,而不是分类层次结构。

其他子系统对层级结构更加注重,值得看的三个子系统是device, freezer和perf_event。

Ways of working with hierarchy

从整体上考虑cgroup的话,一个挑战是不同使用场景会有存在非常大的差异,给当前架构带来很多不同的需求。下面三个子系统都使用了cgroup的分层,但是控制流如何影响到进程这些细节上却有很大不同。

devices

device子系统将访问控制托管给设备相关文件。每个组可以运行或者禁止所有的访问,随后给出一组访问被禁止会在运行的异常信息列表。

更新异常列表的代码会保证子组的权限不会比他父亲的多--设置或者传播父亲组不允许的权限给子组时会被拒绝。因此需要进行权限检查时,只需要检查自己组内的,并不需要遍历检查祖先是否允许这个访问,也不需要检查祖先组的规则是否已经在每个组里面。

当然这样也是有一定代价的,权限检查简单带来的是更新进程的权限会变得更加复杂。但由于权限检查相比权限更新更加频繁,这个代价是值得的。

对device子系统,Cgroup里面的配置会影响所有子cgroup,一直到层级的最下面。每个进程需要检查访问权限时,仍然回到相应的cgroup中。

freezer

freezer子系统的需求和device的完全不一样。这个子系统在每个cgroup里面提供了一个freezer.state文件,用于写入FROZEN或者THAWED。这类似发送SIGSTOP和SIGCONT个ie一个进程组,这样整个进程组将会stop或者restart。

freezer子系统在对进程组进行freeze或者thaw的时候,也跟device子系统的类似,会遍历所有子cgroup至最低层级,设置这些group为frozen或者thawed。然后还需一步,进程并不会定期检查所在的cgroup是否frozen,因此需要遍历所有cgroup里面的进程,显示的将它们移动给freeze处理者或者移出。为了保证没有进程逃离,freeze要求进程fork的时候会得到通知,这样就能够得到新建立的进程。

因此freeze子系统对cgroup层级管理提出另外一个要求,需要能够让配置一直下发到里面的每个进程。 perf_event还有另外一个需求。

perf_event

perf收集某组进程的各种性能数据,这个可以是系统里面的所有进程,或者某个特定用户的所有进程,或者某个特定父进程派生的所有进程,亦或perf_event cgroup子系统的所有进程。
为了检查是否在一个group里面,perf_event使用cgroup_is_descendant()函数简单的遍历->parent直到找到一个匹配的或者是root。这个操作并不是特别开销大,特别是在层级不深的情况,当然相比简单比较两个数字的开销肯定更大些。网络代码的开发者在对添加任何有性能开销代码的敏感性方面是出名的,特别是这些开销是给到每个包的。因此网络代码不使用cgroup_is_descendant()也不会有啥让人惊讶。

对于perf,配置并不会下发到各个层级。任何时候当需要一个控制抉择(比如这个事件是否需要统计),会从进程开始遍历这个树以找到答案。

让我们会到net_cl和net_prio,试问它们怎么放进这个图谱里面--将配置从cgroup一直到进程接受到控制,和device子系统一样。进程在创建socket的时候是能够找到对应的cgroup,但是并不按层级往上回溯。区别是下发配置留给用户态,而不是让内核提供。

cpuset: where it all began

最后的一个cgroup子系统是cpuset,也是Linux最早加入的一个子系统。

和net_cl不同的是对于cpuset子系统,当一个进程从一个cgroup移动到另外一个cgroup时,如果两个cgroup允许运行的处理器集不一样,进程可以简单的被放置到一个新的被允许的处理器对应runqueue上,而当允许的内存node修改了的话,将内存从一个node迁移到另外一个node就不是那么简单。

和device子系统不同的是,cpuset cgroup里面每个进程都保存有自己的CPU集,另外cpuset子系统需要跟freeze子系统一样将新的配置下发到每个独立的进程。还有一个不同的是,cpuset并不需要在fork的时候被通知。

此外,cpuset有时也需要往上遍历层级以找到一个合适的父亲。其中的一个例子是当一个进程发现所在的cgroup没有可以运行的CPU(可能是由于一个CPU已经offline)。另外一个是一个高优先级的内存申请发现mems_allowed里面所有node的内存都已经耗尽。这两种例子里面,从祖先节点里面借一些资源可以用于度过当前的紧要关头。

可以看到有的子系统需要配置下发,有的却需要沿着cgroup树往上搜索,对于cpuset来说这两种都需要。

The story so far

对当前这7个子系统,可以看到部分子系统会提供控制(device, freezer, cpuset),部分子系统仅仅给一个标识用于启动特定的控制(net_cl, net_prio),而部分子系统并不引入任何控制(debug, perf_event)。一些子系统(device)提供的控制是要求内核代码检查cgroup子系统里面的每个访问,同时另外一些子系统(cpuset, net_cl)提供对内核数据结构(threads, sockets)进行设置,其他一些内核子系统从那里获取设置。 一部分控制是沿着层级树往下分发给子cgroup,一些是沿层级树往上检查父亲节点,也有一些是两种都有或者两种都没有。

没有太多细节直接和我们在层级系统里面发现的许多问题相关,尽管如此the emphasis on using cgroups to identify processes perhaps suggests that classification rather than an organization is expected.

对这些子系统,稍微更倾向于使用分类层级,但是对于多层级并没有特殊的需求。

当然,我们还没有结束,后面我们还会对其他几个子系统:cpu cpuacct blkio memory hugetlb进行分析,看是否能够从这些子系统中学习到什么样的层级会更适合他们。

Control groups, part 4: On accounting

Linux和Unix对资源使用计数并不陌生,即使在V6 Unix,每个进程使用的CPU时间都被计数且运行总时间可以通过times()系统调用获得。这也扩展到了进程组,V6 Unix里一个进程派生出来的所有进程可以组成一个组,当组内的所有进程都退出时,使用的总CPU时间可以通过times()获得。在进程退出或者等待前,它的CPU时间只有自己知道。在2.10BSD里,被计数的资源种类扩展到包括内存使用、缺页中断数、磁盘IO等,和CPU时间统计类似,当子进程等待时这些计数会被加进父进程里。getrusage()调用可以访问这些计数,现在的linux里还存在。

getrusage()后有了setrlimit(),它可限制资源的使用数目,如CPU时间和内存。这些限制只能加在单独的进程上而非组:一个组的计数只能在进程退出时累加,但显然这时太晚了而没法达到限制的目的。

cpuacct--为统计而统计

cpuacct是最简单的统计子系统,部分原因是因为它只做统计,而不施加任何限制。cpuacct的出现最初是为了证明cgroup的能力,并没有想合进mainline,但它和其他cgroup代码一起被合进了2.6.24-rc1,但由于最初设计初衷马上又被移出去了,最后又因为看起来很有用又被重新加进了2.6.24-final。知道了这段历史,我们可能就不会期望cpuacct能满足那些大而全的需求。

cpuacct有两种不同的统计信息,第一个是组内所有进程的总CPU时间,它被调度器统计且精度是很高的纳秒级。这个信息以per-CPU来统计,且可以per-CPU和总时间两种形式呈现。第二个是组内所有进程的总CPU时间被拆分成“user”和“system”(从2.6.30开始),它们的统计方式和times()系统调用相同,都是以时钟滴答或“jiffies”为粒度。因此它们和CPU时间的纳秒级别相比没有那么精确。

从2.6.29开始按层级进行统计。当一些计数被加到一个组里时,它也会被加至这个组的所有祖先组里。因此,一个组内的使用统计是当前组和所有子组里进程使用之和。这是所有子系统的一个关键特点:按层级统计。虽然perf_event也做一些统计,但是这些统计只加进当前组,而不会向祖先组里累加。

对于cpuacct和perf_event两个子系统而言,按层级统计是否必要尚不清楚。内核并不使用总的统计,只对应用程序可用,但是它也不太可能以很高的速率频繁读取数据。这就以为着对于需要整个组计数信息的程序而言,一个有效的办法就是遍历所有子组并累加得到总和。当一个组被删除后,可以将它的使用计数累加近父组,就像进程退出后将cpu时间加进父进程一样。更早地累加也没有什么好处。

即使在cgroup文件里应该直接显示总和,内核是在需要而不是每次变化的时候计算总和更加切实可行。是应用程序在需要的时候遍历各个组得到总和,还是内核在每个计数时都遍历每个祖先加进总和,这之间存在明显的权衡。对这种权衡的分析需要将树的深度和更新的频率考虑在内,对于cpuacct,每个调度器事件或时钟滴答都会产生一次更新,即在一个繁忙的机器上每毫秒都会产生一次或更多。虽然这种事件已经很频繁了,但还有其他更频繁的事件。

无论cpuacct和perf_event的计数方法是否合理,这对理解cgroup都不是那么重要,值得关注的是如何权衡不同的选择。这些子系统可以自主选择方法,因为内核内部并不使用统计数字。但对于其余需要控制资源使用的子系统而言,它们需要准确无误的统计。

内存相关

有两个cgroup系统是用来对内存使用进行计数和限制的:memory和hugetlb,这两个子系统使用通用的数据结构记录及限制内存:"resource counter" 即 res_counter。res_counter的定义在include/linux/res_counter.h,实现在kernel/res_counter.c,它包含一些内存资源的使用计数和两个限制:limit和soft limit,还包含一个内存使用的历史最高值、申请失败的请求次数。同时,res_counter包含一个用来防止并发访问的spinlock和指向父组指针,这些父指针一起组成了一个树状的结构。

memory cgroup有三个res_counter,一个用来记录用户程序的内存使用,一个用来记录总内存和swap使用,另一个用来记录因为该进程使得内核方面的内存使用。hugetlb也还有一个res_counter,这意味着当memory和hugetlb都开启时共有四个父指针,cgroup的这种层级式设计也许并不能满足用户的需求。当进程申请一种内存资源时,res_counter需要向上遍历每个父指针,检查每个祖先的内存限制并更新当前使用量。这需要拿每层的spinlock,因此代价比较大,特别是层级比较深的情况下。Linux在内存分配做了很好的优化,除了per-cpu的空闲链表,还有分配释放的批量操作来减小单次分配的代价。内存分配有时候会很频繁,性能需要足够好,因此在每次内存申请时都要拿一系列的spinlock来更新计数显然不是一个好主意。庆幸的是,memory子系统不是这么做的。

当内存申请少于32个页时(大多数请求都只有1个页),memory cgroup会从res_counter一次请求32个页。如果请求成功,多申请的部分会被记录在一个per-cpu的“存量”里,它会记录每个cpu上最后申请的是哪个cgroup及剩余多少。如果请求不成功,它会只申请需要的页个数。当同一个进程在同一个cpu上有后续的内存分配时就会使用存量,直到用完。如果另外一个cgroup的新进程被调度到当前cpu上分配内存,原来的存量会被退回同时会为该cgroup创建一个新的存量。内存释放同样也是批量进行,但是是不同的机制,这是因为释放的量经常会更大且不会失败。批量释放使用per-process计数器(而不是per-cpu),且需要在代码里显式地被开启,调用顺序是:

   mem_cgroup_uncharge_start()
   repeat mem_cgroup_uncharge_page()
   mem_cgroup_uncharge_end()

这可以使用批量释放,单独一个mem_cgroup_uncharge_page()则不行。
以上可以看出对资源使用的计数代价可能会很大,而在不同的环境下有不同的方法来减小代价,因此不同的cgroup对这个问题应保持中立态度,并根据自己的实际需求找到最合适的办法。

另一个CPU子系统

有几个cgroup子系统和CPU相关,除了之前提到的用来限制进程可运行cpu的cpuset,记录cpu允许时间的cpuacct,第三个相 关的子系统就叫做cpu,它是调度器用来控制不同进程和不同cgroup间的运行时间比例。

Linux调度器的设计思想很简单,它的模型是基于一个设想——CPU是理想的多任务调度,可以同时跑任意多个线程,随着线程数目的增多运行速度递减。在这个模型下,调度器可以计算出每个线程应该得到多少CPU时间,同时选择实际运行时间最少的线程服务。如果所有的进程平等,且有N个可允许进程,那么每个进程会有1/N的实际运行时间。当然如果调度优先级或者nice值分配的权重不同,进程会有不同比例的运行时间,它们的时间比例总和是1。如果用cpu cgroup进行组调度时,运行时间比例就是基于组层级进行计算,因此一个上层组会被分配一个时间比例,并在该组进程和子组中共享。

另外,一个组的运行时间应该等于该组所有进程运行时间的总和,但是如果有进程退出,它多使用或少使用的时间信息就会丢失,为了防止这个因素导致的组间不公平,调度器会和每个进程类似也记录每个组的使用时间。“虚拟运行时间”就是记录理想和实际允许时间的偏差。为了管理不同层级上的值,cpu子系统建立了一套并行于层级的sched_entity结构,调度器用它来记录不同的权重和虚拟运行时间。每个CPU都有一套此层级结构,这意味着运行时间可以无锁地向上推送,因此比memory cgroup使用的res_counter更加高效。

CPU子系统还允许对每个组限制最大的CPU带宽,带宽的计算方法是CPU时间除以墙上实际时间。CPU时间(quota)和墙上实际时间(period)都需要设置,当设置quota和period时,子系统会检查父组的限制是否允许子组能充分使用这些quota,不行的话就会拒绝设置。带宽限制大多是在sched_entity下实现的,当调度器更新每个sched_entity使用了多少虚拟时间时,也会一并更新带宽使用并检查是否需要进行限制。

从我们提到的例子中可以看出,限制通常是从上到下检查层级,而资源计数是从下到上遍历层级。

blkio

Linux 3.15里blkio有两种策略:“throttle”和“cfq-iosched”,和cpu子系统的两种策略很类似(带宽和调度优先级),但是实现细节差别很大。许多想法在其他子系统中都已经提到了,但是另外两个点值得一提:

一个是blkio子系统为每个组增加了一个新的ID。之前提到cgroup框架为每个组分配了一个ID且net_prio用它来区分组,blkio增加的新ID也是类似的作用但是有一点区别。blkio ID是64位且从不重用,但cgroup框架的ID是int类型(32位)且可以被重用。唯一的ID是一个通用的特性,更应被cgroup框架提供。增加了blkio ID一年之后,cgroup框架也提供了一个非常类似的serial_nr,但是目前blkio还没有修改去重用这个域。注意当前的代码下,blkio也被称为blkcg。

另外一个特性是关于blkio的cfq-iosched策略。每个组都被分配一个不同的权重,类似于CPU调度器的权重,它用来平衡本组和兄弟组进程请求的调度。但是blkio还有一个leaf_weight,用来平衡组内进程和子组进程的请求。当非叶子cgroup包含进程时,cfq-iosched策略会将这些进程当作在一个虚拟组里并用leaf_weight作为它的权重。CPU调度器没有这个概念,两种调度行为也没有正确或错误之分,但如果他们行为一致是最好不过了,其中一个办法便是非叶子cgroup不能包含单独的进程。

Control groups, part 5: The cgroup hierarchy

July 30, 2014 This article was contributed by Neil Brown

Control groups

在之前的文章里面,我们已经看过一般情况下的层级,以及特定cgroup子系统如何处理层级。现在是时候将这些汇总起来,以理解那种层级是需要的,已经如何在当前的实现中进行支持。如我们最近报道的,3.16 Linux内核正在开发对“统一层级”的支持。那个开发引入的新想法在这将不进行讨论,因为我们暂时没法知道它们可能带来的意义,除非我们已经完全知道我们拥有的。后面还会有文章剖析统一层级,先前我们先开始理解我们称之为"classic"的cgroup层级。

Classic cgroup hierarchies

在classic的模式,会有许多单独的cgroup层级。每个层级都会有一个root cgroup,所有进程都包含在这个root cgroup里面。root节点是通过mount一个cgroup虚拟文件系统实例创建的,所有的修改都是通过操作这个文件系统进行的,比如通过mkdir创建cgroup,rmdir删除cgroup,以及mv对cgroup进行重命名。一旦cgroup创建,进程可以通过将pid写入特定的文件在cgroup之间移动。如一个特权用户将PID写入一个cgroup的cgroup.procs文件里面,那么这个进程就被从当前的cgroup里面移入到目标cgroup里。

这是一种有组织的层级管理:创建一个新的组,然后找到相应进程的放进这个组里面。这种方式对于基于文件系统层级组织来说是很自然的,但不能说这就是最好的管理层级的方式。在4.4 BSD里面的基于会话和进程组的简单层级组织工作方式就是相当不一样。

classic层级方式最大的问题是专制的选择。子系统之间有着大量不同的组合方式:一些在一个层级,一些在另外一个,也有的一个也没有。问题是这些选择一旦作出,影响是系统级的,很难改变。假如有的人需要某个特定的子系统组合方式,而同时另外一个人需要另外一种,这个时候两个需求可能并无法同时在一个宿主机上得到满足。这对基于container实现的在一个宿主机上同时支持多个各自独立的管理域来说是个严重的问题。所有的管理域只能看到相同的cgroup子系统层级组织。

显而易见的选择是只有一个层级(“统一层级”方式的目标),或者每个子系统有一个独立的层级(比如只有cpu和cpuacct两个是组合在一起的)。根据我们所学习到的关于cgroup子系统的知识,我们可以试着理解一些保持子系统相互独立或者相互组合的具体实现。

有一些子系统并不做统计,或者虽然做统计,但并不利用统计进行任何控制。这些子系统包括:debug,net_cl,net_perf,device,freezer,perf_event,cpuset,以及cpuacct。它们都没有重度使用层级,在几乎所有的用例中层级提供的功能可以独自实现。
这些子系统里面有两个使用层级的地方不是很好移除。第一个是cpuset子系统。这个子系统在紧急情况下会沿层级向上查看,以找到额外的资源进行使用。当然正如之前有提到的,类似的功能可以不依赖于层级关系进行提供,因此这只是个小问题。

另外一个是device子系统。它对层级的使用不是在控制方面,而是在配置授权上:子组不允许访问父组禁止的配置。区域层次结构(administrative hierarchy)在权限分配方面是很高效的,无论是对用户分组,或者针对独立的使用者,亦或对有自己用户集的container。对于非统计类(non-accounting)子系统,提供一个唯一的区域层次结构是很自然的选择,也很适合。

Network Trafic Control -- another control hierarchy

网络流量实际是由一个独立的层级进行管理,这个层级甚至独立于cgroup。为了便于理解,需要简单介绍下网络流量控制(Network Traffic Contro,NTC)。NTC机制是通过tc进行实现。这个工具允许为每个网络接口添加一个排队模型(或称为qdisc),有些qdisc是有类的(classful),它们允许有其他针对不同类别包的qdisc挂在下面。如果第二层的qdisc也是有类,那么意味着qdisc也是能够按层级组织的,甚至可以有很多层级,每个网络接口一个。

Tc也允许配置过滤器,这些过滤器用于指导网络包如何分配给不同的类(也意味着不同的队列)。过滤器可以使用很多值,包括每个包的大小、包使用的协议、产生这个包的socket。net_cl cgroup子系统能够每个cgroup里面进程创建的socket设定一个类ID(class ID),通过这个ID将包分类到不同的网络队列中。

每个包经过诸多过滤器被分类到某个队列,然后向上传递到根,也许会被限流(比如Token Bucket Filter,tfb, qdisc),或者被竞争调度(比如Stochastic Fair Queueing, sfq, qdisc)。一旦到达了根,包就被发送出去。

这个例子说明了层级对于资源调度和使用限制的意义。它也向我们展示独立的cgroup层级并不需要,本地的资源层级就能很好的满足需求。

对于CPU、memory、block I/O和network I/O,每个资源主控制器都维护有自己的独立层级。前三个都是通过cgroup管理的,但网络是单独管理的。这样看有两种不同类型的层级:一些用于组织资源,一些用于组织进程。

Cgroup层级其中有一个在NTC层级中不是很明显能做的是,在使用container时将层级分到一个独立的区域域。部分container的名字空间中仅仅挂载一个cgroup层级的子树,这个container被限制只能影响这个子树,这样的话container里面就没法实现类ID被设定给不同cgroup。

对于网络,这个问题通过虚拟化或者间接的方式能够解决。虚拟网络接口"veth"能够提供给container,这样就能够按照自己喜欢的方式进行配置。Container的流量都会被路由到真实的接口,并能够根据流量源自哪个container进行分类。同样的机制也对block I/O有效,但是CPU和内存资源的管理没办法,除非有类似KVM这样的全虚拟化。

How separate is too separate

正如我们上次提到的,资源统计控制器需要对祖先cgroup的信息可见才能够高效的实现限速,也需要对相邻cgroup的可见以实现公平共享,因此完整的层级对于这些子系统来说是很重要的。

以NTC作为例子,可能会引发争论的点是这些层级需要为每种资源分离开。NTC做得比cgroup更深远,能够允许每个接口拥有一个独立的层级。blkio可能也会想要对不同的块设备提供不同的调度结构(swap vs database vs logging),但这个目前cgroup还不支持。

尽管如此,过多的资源控制隔离会带来一定开销。统一层级支持方的Tejun Heo指出这部分开销是由于缺少“有效的合作”。

当一个进程往文件中写数据时,数据先到page cache,这样会消耗内存。在之后的某个时间,内存将会被写出到存储设备,这样会消耗一些块设备I/O带宽,或者也有可能一些网络带宽。因此这些子系统并不是完全分开的。

当内存被写出时,很可能这动作并不是由写这部分数据的进程执行,也可能不是由这个cgroup里面的其他进程执行。那么如何能够使得块设备I/O的统计更加精确呢?

memory cgroup子系统为每个内存页附加了一些额外的信息,这样能够在页被释放时知道应该找谁退款。似乎当页被写入的时候,我们可以在这个cgroup里面统计I/O使用。但是有一个问题,这个cgroup是和memory子系统一起的,因此有可能在完全不同的层级里。这样的话,这个cgroup里面对内存的统计可能对blkio子系统来说完全没意义。

还有其他一些方式来解决这种分离:
在每个页里面记录进程的ID,由于两个子系统都知道PID,因此可用这个计算内存和块设备的使用。这个方法有一个问题是进程可能存活时间很短,当进程退出时,我们要么需要将进程未归还的资源转移给其他进程或者cgroup,要么直接丢弃。这个问题和CPU调度里面的类似,只对进程进行统计很难实现合理进程组的公平性。合理的保存未归还的资源是一个挑战。 引入一些其他的标识,要求能够存活任意时间,能够和多个进程关联起来,能够被每个不同的cgroup子系统。这种间接法众所周知能够解决任何计算机科学的问题。 用于连接net_cl子系统和NTC的class ID就是这样一个标识。当有很多层级,每个接口一个时,只有一个class ID标识的名字空间。

为每个page存储多个标识,一个用于内存使用,一个用于I/O吞吐。当前用于存储额外的memory控制器信息的page_cgroup结构体在64位系统上每页消耗128字节--64字节是一个执行归属cgroup的指针,另外64字节用于做标识位(目前已经使用3位)。假如能够用一个数组的索引替换指针,十亿个组目前看是足够的,这样两个索引和一个额外的bit能够存储在之前一半的空间中。是否一个索引就能够提供足够的效率,这个留给感兴趣的读者练习。

对这个问题的解决方式也许能够使用与其他情况:任何有一个进程代替其他进程消耗资源的地方。Linux里面的md RAID驱动通常会在初始化该请求的进程上下文中将I/O请求直接下传给下层设备。但其他一些情况下,一些工作需要由一个协助进程完成,用于在将来提交请求。目前,完成这部分工作的CPU时间和该请求消耗的I/O带宽都被算到md而不是最初的进程上。假如能够为每个I/O请求加上消耗者标识,md和其他类似的驱动将有可能据此分配资源使用。

不幸的是,目前的实现下这个问题没有好的解决方式。过度的隔离会带来性能损耗,这些损耗并不能通过简单将所有子系统放到一个相同的层级得到减缓。

目前的实现,最好是将cpu blkio memory和hugetlb这些统计子系统放入单独的层级,而网络方面谢谢NTC使得已经有一个独立的层级,同时也最好将所有非统计类的子系统一起放在一个区域层级。这样需要的时候依赖于智能的工具对这些独立的层级进行有效的组合。

Answers ...

现在需要回答一些以前文章提到的问题。其中一个是如何命名组。正如我们上面看到的,这个是执行mkdir命令进程的职责。这个和任务控制进程组及会话组不一样,这些组是在进程调用setsid()或者setpgid(0,0)时内核会为组设定一个名字。这之间的区别能够得到解决,不过这里需要阐述下期望的权力结构。对于任务控制进程组,形成一个新组的决定来自于新组的一个成员。而对cgroup,这个决定更期望的是来自于外部。先前我们已经观察到在cgroup层级里面包含一个区域层级看起来很有道理。而与这个观察一致的是名字是从外部给予的这个事实。

另外一个问题是,是否允许从一个组移入到另外一个组。移动一个进程需要将进程ID写入cgroup文件系统的一个文件中,这个有可能由任意有权限对这个文件进行写的进程执行,这样需要更进一步检查执行写操作进程的所有者是否也是将被添加进程的所属者,或者是更高权限的。这意味着任何用户能够将他们的任意进程放入任意他们有对cgroup.procs写权限的组里面,而不顾跨越了多少个层级。

换句话说,我们可以限制一个进程可以移动到哪,但是对于进程从哪里移动过来的控制却很少。

... and questions

这个讨论引出的最大问题是,是否真的有对不同的资源使用不同层级管理的需求。NTC提供的灵活性是否很好的超越了需求,或者它是否为其他提供一个可以追随的有价值的模型?第二个问题关心的是假如不同的需求都使用一个层级,是否会引起组合爆炸,同时这个带来的开销是否和其价值成比例。在任意情况,我们需要清楚的知道如何计算请求实际发起者的资源消耗。

这些问题中,中间可能是最早的:拥有多cgroup在实现上有哪些开销?下面一个topic我们需要讲的就是这个。

Control groups, part 6: A look under the hood

这篇文章扼要地介绍了cgroup subsystem设计的背后原因,老哥还蛮谦虚的,说仅仅是雾里看花,错了莫怪。

首先,在配置上cgroup subsystem面临着组合爆炸的可能,可以算个流水帐:如果某个系统上有Q个管理员,而每个管理员则希望用N种方式分割M种资源,于是就需要Q x M x N个cgroup。但如果我们能够把这种层次从水平方向上切成多个层次就可以缓解这个现象了,比如把M个资源的N种切换方式与Q个管理员的层次分别独立出来,就只需要Q + M x N个cgroup了。

无论怎么说,cgroup的组合都显然是一种树状结构,相信读者瞬间就可以在脑子里画出那图像来,单就树状结构来看,确实没有什么新鲜的。这里的复杂性其实在于cgroup以线程为控制单元并非进程,以及cgroup是如何与线程关联起来的。

然后,作者从500年前开始讲起,好吧,其实是1975年的UNIX v6讲起,一步一步地介绍了UNIX是如何逐步为进程增加维度的:session, process group, process, thread。对于Linux,process实际上是所谓的thread_group,而PID其实是其中leader线程的TID。无论是进程还是线程,在Linux下都是用task_struct(任务)表示的。Linux的PID namespace又把事情变得更复杂了,一个任务在不同的PID namespace下的ID很可能并不一样。所以,每个任务的PID并不是一个简单的数字,而是三个链表:

<source lang="c">
enum pid_type { PIDTYPE_PID, PIDTYPE_PGID, PIDTYPE_SID, PIDTYPE_MAX };
struct hlist_head tasks[PIDTYPE_MAX];
struct pid_link
   {
struct hlist_node node; struct pid *pid;
   } pids[PIDTYPE_MAX];
</source>

后面作者介绍了不少社区为了做到高并发所做的N多努力。
OK,该是cgroup内部结构登场的时候了:

  • 每个cgroup结构都有cset_links字段,把其中的所有threads串起来。
  • 每个css_set结构则都有一个cgrp_links字段,把其中thread涉及到的cgroup串起来。另外,对每一个层次结构还有一个cgrp_cset_link字段。

这里面使用了大量内核中的链表技巧,亲们google之?

文章里讲了以上这些数据结构中锁机制的一些设计细节,也值得一看的。

Control groups, part 7: To unity and beyond

这一系列文章的目的是为了让我们理解linux Cgroup,能够参与到我们周围关于cgroup的讨论中去。 现在是检验我们成果的时候了,我可以参与到cgroup的讨论里,并发表自己的建议和质疑。 (译:下面就是作者自己的一些评价和质疑)。

The unified hierarchy: A score card

Unification of hierarchy 毫无疑问传统的cgroup允许太多的继承个数。将个数减少到一个是理想化的情况。在调查中,我们发现两种截然不同的继承用法:一些子系统利用control向下,而另外一些则相反。根据涉及 到不同的实现所关注的继承又很大的不同。 令人欣喜的是统一继承正朝着去除多余的重复的方向努力。看起来它不像要承认不同的子系统可能有真的不相容的需求,但是它完全关闭分割继承的大门。 得分B 有待进一步提高。

Processes only permitted in the leaves 统一继承要求只能在进程退出时才能退出。这个强制看起来很不合理。叶子是”不能把子系统扩展到孩子节点的节点“,在继承里创建新的level需要分两部走。 进程首先被下移,让后子系统被往下扩展。这个问题给个 C。这里要强调一个设计上的缺陷。进程被排除在内部cgroups外,除了root cgroup,显然root 需要特殊对待。基于这一点可以给个C+

Taming the chaotic subsystems 我们已经看到了cgroup子系统和各个功能单元之间是相当混乱的。这不是新观点,2011年的内核开发大会上, Paul Turne就提高了这一点: google 基于自己的经验,以更好的形式重新组织了许多控制器。 明确的列表使得 cgroup.controllers 里的子系统使得问题更糟。 所以这一点给个D

Providing a resource-consumer ID

在part 5里我们看到当内存的页被释放时,能够标识出谁获得了它们。但是在内容被写出时候,不知道谁负责IO。所有的子系统使用一个继承,一个cgroup可以作为所有资源类型的资源消耗 者ID。这是解决这个问题的一个清晰的方案,但很难说他是一个很好的方案(不同的资源可能差别很大)。 所以我给B

Processes or threads 传统的cgroup允许一个进程里的线程属于不同的cgoups。想象一个可靠的应用场景很困难,但是也不是一点可能都没有。 cpusetcgroup能够限制进程到多个cpu或者numa系统里的内存节点。前者可以通过在任一个线程上使用系统调用sched_setaffinity()或者程序taskset,而不用涉及到cgroups。但是内存节点 只能通过cgroups配置。 cgroup可以更细粒度的控制单个的线程优先级,它允许单个线程或者cgroup有100000个weight分级,而不像传统linux调度器40个nice分级那样。统一继承只允许进程(线程集合)能够在不同 的cgroups。这个主意看起来不错,但是又引发了一个问题:我们是使用控制?线程还是进程?或者其他别的呢? 无论结果如何,不再支持单个线程的转移是个好主意。 A

Code simplicity 统一继承只是漫长过程中的一步,还有很多要提高的地方。我们很明确只有进程而不是线程最终在cgroups里,并且他们只需要呆在一个单独cgroup里, 这当然会带来简化。 所以给A

Summary: 统一继承所依赖的基础还在建设中,现在还不能要求太多。

Auto-group Scheduling 正如2010年总结的,除了cgroups,还有其他的方法去批量控制进程。使用为cgroups开发的group调度,Mike Galbraith创建了一个不同的自动的把程序分组调度的机制。标准的unix调度器和 许多追随者试图公平的对待进程,但是进程并不是很需要公平的对待。 进程组自动调度有两个相关的问题: 1. Linus Torvalds提出的。他建议为了这个目的而使用进程组,粒度有些细了。创建一个新的调度组有一定的花销,所以做的太频繁会引入难以接受的迟钝。可惜没有任何人做任何的测试来 说明这一点。最终的实现使用了”sessions”而不是“process groups”, 这就不会被创建的太频繁。但并不能完美解决这个问题。 2. 第二个问题是有Lennart Poettering提出的。“在桌面系统上,这是完全不相干的”。auto-group现在是基于“sessions”做的,许多桌面会话管理都没有把每个应用程序放到不同的会话 里。一个正在开发中的会话管理:systemd 使用setsid()。 当时,Lennart的言论在当时没有引起重视。

与最初cgroups开发者们相比,我们有自己的优势,几年的经验和可运行的代码。这是一笔可以转化成为我们优势的财富。所以,去测试你对资源管理最新的认识。挑战是受到自动分组调度的 鼓舞,你怎么在linux上实现进程控制和资源管理。

Hindsight groups: highlighting some issues through contrast.

Hindsight groups 进程组是基本的控制单元,可通过交互式shell被创建,通过systemd,或通过其他session管理模块。也可以通过prlimit或者类似的命令控制单个的进程,但是组控制没有比进程组更细粒度的控制。在pid继承中引进了一个新的level,来提供一个管理这些进程组的管理结构。“process domain”被引入到“session”和进程组级别上。通过domains组织的继承很好的限制了进程。per-process-group, 是新的数据结构,定义了进程组的角色,很像signal_struct被分配给每个进程。它里面包含了对组里的进程各种限制, 例如可访问的设别列表,可被使用的进程的集合。这些限制可以被任何有合适用户id或者超级用户许可的进程改变。各种可被共享的资源:内存,cpu,网络可块设备io。每个都有特殊需求,并被单独管理。网络和块设备io比较类似,他们通常涉及覆盖或者共享数据,他们很容易被虚拟化,所以一个子域可以被授权访问一个虚拟设备,这个虚拟设备有可以把数据收发到一个真实设备。他们可以管理多个单独的设备,不仅仅是进程所涉及到的。网络系统需要管理自己的链路控制流量和从另外设备转发来的流量。块设备io子系统已经内部区分了metadata(使用REQ_META)和其他数据,对不同情景进行分类。结果,这两个系统有他们自己的队列管理结构。各种不同的度列算法可以根据原始的域将请求进行分类。或者支持标记单个的进程,这已经超出了hgroups的范围。

内存使用管理跟其他的资源共享很不一样,因为它是通过空间而不是时间进行测量的。一个进程可以启停使用这三种资源(网络,块设备io,cpu),或者暂时脱离拥有这些资源,而没有任何 负面影响。而内存不能这样。

Croups内存控制引入了两个限制:硬性的限制(绝对不允许超越)和软性的限制(只有在内存非常紧张的时候才能超越)。 cpu的限制跟内存的限制很类似,唯一的不同是对本地进程组的限制也可以在域上使用。任何有合适特权的进程都可以发出限制。 cpu调度可能是最复杂的资源管理。调度组大体有域,进程组,进程继承组成,但是在每个级别都有组选项。

Filesystem notification, part 1: An overview of dnotify and inotify

文件系统通知API提供了一个让应用程序监测一个文件打开、修改、删除、重命名等操作事件的方法。过去,Linux中共有三种不同的文件系统通知API,了解和掌握这三种API之间的区别是十分有用的。同时在了解这些API的过程中,我们也能够学习到很多API设计上的经验。

本文是一些列文章的第一部分。我们首先介绍最原始的API:dnotify以及这个API的诸多不足。然后我们将讨论inotify以及它对于dnotify的改进。在着一系列的最后,文章将介绍fanotify。

Filesystem notification use cases

为了比较这三种不同API之间的区别,首先我们来看一下这些API的常见用例。

  • 缓存文件系统对象模型

应用程序经常需要在自己内部维护一个精确反映文件系统当前状态的模型。比如一个文件管理器就需要通过图形界面来反馈文件系统当前的状态。

  • 记录文件系统活动

应用程序希望记录当前监控的文件系统中发生的某类事件。

  • 监控文件系统操作

应用程序希望在某些事件发生或采取必要的措施。此类经典应用是反病毒软件。当另外一个程序尝试去执行一个文件的时候,反病毒软件首先建厂文件的内容是否存在恶意代码,从而判断是否运行该文件被执行。

In the beginning: dnotify

在没有操作系统相关API支持的时候,应用程序需要自己来完成对文件系统事件的监控工作。其中比较常用的是通过轮询的方法来检查文件系统的状态。比如重复调用stat()和readdir()系统调用。这种实现方法显然效率低下。此外,这种方法仅仅能够监控一部分文件系统的事件。

为了解决这些文件,Stephen Rothwell在Linux 2.4.0上实现了第一班的dnotify接口。由于这是第一次尝试实现文件系统通知API,所以dnotify天生存在许多的不足。dnotify通过复用fcntl()系统调用来实现相应的功能。而随后的inotify和fanotify均实现了新的系统调用。为了开启dnotify,需要使用如下系统调用: <source lang=c> fcntl(fd, F_NOTIFY, mask); </source>

其中的fd是一个需要监控的目录的文件描述符。这种使用方法造成了dnotify只能对整个目录进行监控,无法对某个特定文件进行监控。第三个参数mask用来指定需要监控的事件。详细说明可以参考fcntl(2)的man page。

另外一个比较怪异的设计是dnotify在监控的事件发生的时候会向应用程序发送信号来进行通知(默认为SIGIO)。这个信号本身并不能反映到底是哪个被监控的目录发生的事件。需要使用sigaction()通过SA_SIGINFO来建立信号处理函数。在随后的信号处理函数中会接收到一个siginfo_t参数。在该参数中有一个si_fd域,通过该域可以获取到发生事件的目录。同时应用程序需要遍历整个监控目录列表来了解对应的目录信息。

这里是一个使用dnotify的简单程序

Problems with dnotify

正如上面的介绍,dnotify在设计上就存在诸多的不足。比如:只能监控整个目录而不是单独文件,可以监控的事件不全,无法监控文件的打卡或者关闭。

上面这些其实并不是最严重的问题。使用信号作为通知的方法造成了dnotify使用困难。首先信号的投递是异步的,这样获取信号的处理函数就很容易出错,尽管可以使用sigwaitinfo()来同步获取信号。同时信号必须要被及时处理,应用程序处理速度不够时,就会有信号丢失的问题。

信号的使用还有其他的问题,不如在事件发生时,应用程序无法得知具体发生事件的类型和具体文件,这就需要应用程序进行复杂的处理流程。同时如果其他库函数也会处理相同的信号,这样就会造成信号的冲突。

最后的问题是,只能监控目录会造成应用程序需要打开大量的文件,造成文件描述符使用很多。此外,打开大量文件描述符会造成文件系统无法被卸载。

尽管如此,dnotify仍然提供了一种高效的监控文件系统事件的方法,并且dnotify已经被广泛使用在一些应用程序中,比如:Beagle桌面搜索。但是显然涉及一种更晚上的API将会让程序员的日子更好过。

Enter inotify

inotify由John McCutchan在Robert Love的协助下开发完成,并于Linux 2.6.13版本发布。inotify的发布解决了dnotify中的一系列明显的问题

inotify中使用了三个新的系统调用:

<source lang=c> inotify_init(); inotify_add_watch(); inotify_rm_watch(); </source>

inotify_init()创建一个inotify实例——一个内核数据结构用来记录需要被监控的文件系统对象,并且维护该对象相关的事件列表。该调用会返回一个文件描述符用来完成其后的相关工作。

inotofy_add_watch()允许用户修改被监控对象的相关事件集合。当然inotify_rm_watch()操作与其相反。

inotify_add_watch()函数原型如下:

<source lang=c> int inotify_add_watch(int fd, const char *pathname, unint32_t mask); </source>

其中mask参数指定了需要监控的事件集合。pathname参数指定了需要监控的文件路径。下面的例子展示了监控mydir目录中文件创建、删除事件的相关代码:

 <source lang=c>
   int fd, wd;
   fd = inotify_init();
   wd = inotify_add_watch(fd, "mydir",
                          IN_CREATE | IN_DELETE | IN_DELETE_SELF);
</source> 

详细信息和可以参考inotify(7)的man page。inotify可以监控的事件集合是dnotify的超集。最显著的是对于文件打开和关闭的监控。

inotify_add_watch()返回一个监控描述符。该描述符是一个整数类型用来唯一标示inotify监控的一个特定的文件系统对象。在监控的事件发生后,应用程序可以通过read()来获取对应的信息。其结构如下:

<source lang=c>
   struct inotify_event {
       int      wd;      /* Watch descriptor */
       uint32_t mask;    /* Bit mask describing event */
       uint32_t cookie;  /* Unique cookie associating related events */
       uint32_t len;     /* Size of name field */
       char     name[];  /* Optional null-terminated name */
   };
</source> 

数据结构中具体的说明请参考相关手册。

inotify最重要的问题是没有提供地柜监控功能。当然我们可以通过为每个子目录来创建监听事件来解决。

Example program

下面的程序是inotify的演示程序。

<source lang=c> int
   main(int argc, char *argv[])
   {
       struct inotify_event *event
       ...
       inotifyFd = inotify_init();         /* Create inotify instance */
       for (j = 1; j < argc; j++) {
           wd = inotify_add_watch(inotifyFd, argv[j], IN_ALL_EVENTS);
           printf("Watching %s using wd %d\n", argv[j], wd);
       }
       for (;;) {                          /* Read events forever */
           numRead = read(inotifyFd, buf, BUF_LEN);
           ...
           /* Process all of the events in buffer returned by read() */
           for (p = buf; p < buf + numRead; ) {
               event = (struct inotify_event *) p;
               displayInotifyEvent(event);
               p += sizeof(struct inotify_event) + event->len;
           }
       }
   }
</source>

How inotify improves on dnotify

inotify相比于dnotify有多项改进:

  • 可以监控目录和文件
  • 通过read()取代信号
  • 不需要打开被监控目录
  • 更多的事件
  • 丰富的重命名事件
  • IN_IGNORED事件

Concluding remarks

本文我们主要介绍了dnotify和inotify,以及inotify相对于dnotify的改进。下面的文章将会更详细的介绍inotify以及如何使用inotify来构建健壮的应用。

Filesystem notification, part 2: A deeper investigation of inotify

在这个系列的第一篇文章中,我们简单了解了Linux文件系统通知API dnotify以及该接口的各种不足。随后文章介绍了它的继承者inotify,它是如何解决此前dnotify遗留的各种问题的以及带来了哪些好处。在上一篇文章中,我们看到了如何使用inotify来创建一个简单的文件系统状态监控程序。然后,inotify的使用并不像看起来那么简单。

现在,我们来深入了解inotify。我们将通过一个监控目录树状态的应用程序来深入了解inotify接口。一方面,通过该程序我们可以了解inotify是如何完成这一工作的;另一方面,我们也将看到inotify的一些不足。

程序仅仅用来作为演示使用,并没有对性能有任何考量。程序的使用方法如下:

./inotify_dtree <directory> <directory>...

这个程序的功能是动态的监控命令行指定的目录及其子目录的状态。这个程序的作用类似于一个GUI的文件管理器。

为了控制程序的大小,我们做了必要的简化:

  • 目前程序仅仅监控一个指定目录下的子目录的状态,对于其他文件则不予关注。尽管监控其他类型的文件十分简单,但是我们仅仅把注意力放在子目录的监控上,因为这是该程序最具挑战的部分;
  • 程序目前仅仅记录了目录的名字和对应的监控描述符。一个实用程序还会监控其他信息,比如文件属主,权限和修改事件;
  • 目前保存状态的数据结构为一个链表,这样做是为了简单。真是情况下,需要使用更加有效的树形数据结构。

经过上述简化后,我们依然可以看到这样一个监控目录状态的简单程序对inotify来说仍然是个挑战。

Challenge 1: recursively monitoring a directory tree

inotify目前不能递归监控目录结构。也就是说,inotify可以监控目录mydir以及它的直接孩子,但是不能监控子目录的孩子。

因此,为了能够监控整个目录树,我们需要遍历所有子目录。这就需要程序递归遍历每个子目录,然后监控这个子目录本身,而这样会有可能造成竞争。举例来讲:

  • 我们扫描mydir目录,监控mydir的所有子目录;
  • 然后监控mydir目录。

加入在监控mydir目录前,有个新的目录被创建,比如mydir/new,或者有个目录被移动到mydir目录中,那么这个事件应用程序是无法感知到的,因为程序已经完成了对子目录的扫描,同时mydir目录本身还没有被监控。

上面问题的解决方法是改变添加监控的顺序,即先监控父目录,然后再扫描添加子目录的监控。但是这样仍然会有问题。在父目录监控建立后,如果有目录被创建,那么应用程序会接收到两次事件。一次是新目录创建时父目录监控接收到的事件,另外一次是扫描子目录时对子目录建立监控时的事件。当然这是无害的,因为对相同文件对象调用inotify_add_watch()两次返回的监控描述符是相同的。

此外,还有其他的竞争,比如在扫描子目录过程中有目录被删除了,那么我们最好的处理方法就是忽略这一错误。

如果想完成上述工作,可以使用nftw()库函数,该函数对相关的操作进行了必要的封装,在遍历目录过程中,针对每个文件对象会调用用户提供的毁掉函数,这样事情就简单了许多。
下面就是一个nftw()回调函数的例子:

<source lang=c> static int traverseTree(const char *pathname, const struct stat *sb, int tflag,
            struct FTW *ftwbuf)
{
  int wd, slot, flags;
  
  if (! S_ISDIR(sb->st_mode))
      return 0;               /* Ignore nondirectory files */
  
  flags = IN_CREATE | IN_MOVED_FROM | IN_MOVED_TO | IN_DELETE_SELF;
  if (isRootDirPath(pathname))
      flags |= IN_MOVE_SELF;
  
  wd = inotify_add_watch(ifd, pathname, flags | IN_ONLYDIR);
  if (wd == -1) {
  
      /* By the time we come to create a watch, the directory might
         already have been deleted or renamed, in which case we'll get
         an ENOENT error. In that case, we log the error, but
         carry on execution. Other errors are unexpected, and if we
         hit them, we give up. */
 
      logMessage(VB_BASIC, "inotify_add_watch: %s: %s\n",
              pathname, strerror(errno));
      if (errno == ENOENT)
          return 0;
      else
          exit(EXIT_FAILURE);
  }
  
  if (findWatch(wd) > 0) {
  
      /* This watch descriptor is already in the cache;
         nothing more to do. */
  
      logMessage(VB_BASIC, "WD %d already in cache (%s)\n", wd, pathname);
      return 0;
  }
  
  slot = addWatchToCache(wd, pathname);

  return 0;
} </source>

Challenge 2: handling overflow events

inotify的消息队列需要占用内核内存,因此这个队列有大小限制。具体的大小可以参考intofy的手册或者查看/proc目录下的对应文件。当队列达到上限后,inotify会添加一个溢出事件到队列中,并且丢弃后面的所有事件,直到程序开始读取队列中的事件。
这一行为导致的结果就是应用程序会丢失某些事件,换言之,inotify不能生成一个十分精确的文件系统状态。

事件溢出对于我们的演示程序来讲意味着文件系统的状态和程序目前保存的状态不一致了。在这一个问题发生后,我们唯一能做的就是关闭所有已经打开的监控描述符,然后重新对缓存状态进行初始化。这里的相关代码请参考reinitialized()函数。

尽管我们可以通过增大队列的大小来尽量避免溢出的发生,但是所有使用inotify来监控文件系统状态的程序都应该小心的处理溢出问题。

此外,其他一些边角问题或者程序bug也可能造成状态的不一致。程序应该处理好这些问题。

Challenge 3: handling rename events

如前所述,inotify对于dnotify最大的改进是对于重命名事件的处理。当一个文件对象被重命名后,inotify会产生两个事件:IN_MOVED_FROM和IN_MOVED_TO。IN_MOVED_FROM表示文件移动前的目录,IN_MOVED_TO表示文件移动的目标目录。应用程序在接收到这两个事件后,name域表示新、旧文件名。这两个事件有相同的cookie域用来让应用程序进行识别。

重命名会对应用程序带来一些列的挑战,比如我们的演示程序中,一个重名名操作会让我们对缓存进行三次操作。

当然,更加智能的缓存设计能个避免和消除这个问题。比如用树形结构来保存文件系统的状态,这样在处理重命名操作时,我们只需要修改一个对象的指针就可以了。

重命名事件还会带来其他的问题,不如当我们仅仅监控目标目录的事件时,我们只能接收到IN_MOVED_TO事件。而对应的FROM事件应用程序就是收不到了。此外,如果程序仅仅监控了源目录的事件,那么我们将仅仅接收到IN_MOVED_FROM事件,从而造成我们把这个事件作为删除目录事件来进行处理。

此外,当程序接收到IN_MOVED_FROM事件后,并不能确定后面还会有一个IN_MOVED_TO事件,而且inotify不保证IN_MOVED_FROM/TO事件是连续发送个程序的。这就造成重命名事件的处理十分复杂。当然,有人会问,为什么不把IN_MOVED_FROM作为删除事件来处理,把IN_MOVED_TO当做重命名事件来处理呢。这样就让程序简单了很多。但是当有大量重命名操作发生的时候,程序要不断的删除某些目录的监控,然后再建立新的监控,这样会造成程序的效率非常低。

上面重命名事件处理中的问题在现实中的解决方法是:检查IN_MOVED_FROM事件后面是否有连续的IN_MOVED_TO事件。如果有,则按照重命名事件来进行处理。否则按照删除事件来进行处理。

演示程序中的processInotifyEvents()函数提供了一种处理重命名事件的方法。通过在read()操作中间插入2ms的延迟,我们可以解决99.8%的问题。

Challenge 4: using pathnames for notifications

在程序监控文件系统状态的时候,inotify的一个相对于dnotify的优点是可以提供文件的路径信息。但是这个信息处理起来是十分困难的。因为一个文件可能有多个路径,因为一个文件可以有多个硬链接。

加入我们需要监控如下的一个目录树:一个文件对象有两个硬链接,一个是mydir/abc/x1;另外一个是mydir/xyz/x2。我们只监控了mydir/abc/x1。

当我们对这个文件进行操作的时候就会出现问题,当我们打开mydir/abc/x1文件时,程序会接收到信号,但是当打开mydir/xyz/x2文件时,程序就不会接收到信号。概括来说,inotify仅针对监控的路径产生信号。

Other limitations of the inotify API

inotify除了上面详细介绍的问题外,还有其他的限制:

  • 目前的事件中没有包含产生事件的进程信息。这样当一个监控程序自己产生了一个事件后,程序自己无法建议区分;
  • inotify没有提供一个看门狗的功能。也就是说inotify仅仅发送事件,但是不会阻塞事件的发生。所以无法使用inotify来实现诸如反病毒程序的功能;
  • inotify仅仅报告通过文件系统API产生的事件,也就是说通过远程文件系统和虚拟文件系统产生的事件,程序是无法接收到的。同时通过文件影射对文件进行的修改,程序也是无法接收到的。这就需要程序不断调用stat()和readdir()来进行监控。

相对于dnotify,inotify已经有了很大的改进。但是inotify自身依然存在一些不足,后面的文章,我们将一些来看一看fanotify API。

Anatomy of a system call, part 1

深入剖析系统调用 (一)

By: David Drysdale

系统调用是用户空间与内核空间交互的首要机制。很有必要探索清楚系统调用的细节。比如,内核如何实现了系统调用的跨平台与高效性。 作者曾经将FreeBSD的Capsicum security framework机制移植到Linux上,并且在该工作中为Linux增加了几个新的系统调用(包括不常使用的execveat())。故而对系统调用的细节非常熟悉。这个文章系列共包括两篇文章,剖析了Linux系统调用的实现细节。第一篇文章分析了系统调用的基本实现机制:以read()为例介绍了系统调用的基本实现以及用户空间调用它的方法。第二篇文章将介绍其它一些不常用的系统调用,以及其它系统调用的实现机制。 系统调用不同于常规的函数调用,因为被调用的代码在内核中执行。需要用到一条特殊的指令将CPU切换到Ring 0(特权模式)。而且,被调用的内核代码通过系统调用号标识,而不是函数地址。

利用SYSCALL_DEFINEn()定义系统调用 探索Linux系统调用机制时,read()系统调用是很好的入门范例。它在fs/read_write.c中实现。这个函数很简单,它将绝大部分工作传给了vfs_read()。从调用的角度看,该代码的关键部分是SYSCALL_DEFINE3()宏定义的函数。但是,仅从代码来看,并不容易弄清楚谁调用了这个函数。

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
   {
       struct fd f = fdget_pos(fd);
       ssize_t ret = -EBADF;
       /* ... */

SYSCALL_DEFINEn()是一系列的宏定义,n为正整数,表示参数的个数。这些宏为Linux内核定义系统调用的标准方式。对每个系统调用而言,这些宏(include/linux/syscalls.h)都有两个不同的输出:

 SYSCALL_METADATA(_read, 3, unsigned int, fd, char __user *, buf, size_t, count)
   __SYSCALL_DEFINEx(3, _read, unsigned int, fd, char __user *, buf, size_t, count)
   {
       struct fd f = fdget_pos(fd);
       ssize_t ret = -EBADF;
       /* ... */

宏SYSCALL_METADATA()为系统调用定义了一组ftrace需要用到的元数据集合。这个宏只在CONFIG_FTRACE_SYSCALLS被定义时才会展开,展开后会定义用于描述该系统调用的数据以及该系统调用的参数。(另一篇文章http://lwn.net/Articles/604406/ 更加详细地描述了这些定义) __SYSCALL_DEFINEx()更有趣,因为它包含了系统调用的实现。将该宏以及GCC的类型扩展展开后,我们会看到一些有意思的特性:

  asmlinkage long sys_read(unsigned int fd, char __user * buf, size_t count)
       __attribute__((alias(__stringify(SyS_read))));
   static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count);
   asmlinkage long SyS_read(long int fd, long int buf, long int count);
   asmlinkage long SyS_read(long int fd, long int buf, long int count)
   {
       long ret = SYSC_read((unsigned int) fd, (char __user *) buf, (size_t) count);
       asmlinkage_protect(3, ret, fd, buf, count);
       return ret;
   }
   static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count)
   {
       struct fd f = fdget_pos(fd);
       ssize_t ret = -EBADF;
       /* ... */

首先,这个系统调用的真正实现为函数“SYSC_read()”。但是这个函数是static的,不能在其它代码块中访问它。SyS_read()是对SYSC_read()的封装,这个函数有个别名叫sys_read(),并且在外部可见。仔细看一下这些函数别名,他们的参数类型是不同的。sys_read()声明的类型更加严格(如第二个参数加了前缀__user*),而SyS_read()则声明了一组整数类型(long)。从历史角度看,声明成long,可以确保在64位的平台上正确地符号扩展32位的值。 针对SyS_read()封装,还需要注意GCC的指示符asmlinkage,以及asmlinkage_protect()调用。Kernel Newbies FAQ中介绍,asmlinkage表示该函数倾向于将参数放在栈上,而不是寄存器里。asmlinkage_protect()表示编译器不应该假设可以安全复用栈上的这些区域。 除了sys_read()的定义,include/linux/syscalls.h中也有相应的声明。这是为了让内核中的其它代码能够直接调用系统调用的实现(大概有半打的位置直接调用了sys_read())。不过,最好不要在内核中的其它位置直接调用系统调用,而且这种行为是不常见的。

系统调用表项 对sys_read()的调用者进行寻根溯源,能让我们搞清楚从用户空间到达这个函数的具体路径。“generic”体系结构没有提供系统调用函数的重载,include/uapi/asm-generic/unistd.h中包含了sys_read()的引用入口:

  #define __NR_read 63
   __SYSCALL(__NR_read, sys_read)

首先为函数read()定义了系统调用的编号__NR_read(63),并且通过宏__SYSCALL将这个编号与sys_read()关联起来。这种关联是体系结构相关的。比如ARM-64在头文件asm-generic/unistd.h中定义了一张表,该表映射了系统调用编号与相关函数的函数指针。(译者注:多数操作系统教材都采用了这种方法来解释系统调用的实现) 后面我们继续关注X86_64体系结构,X86_64没有使用这张通用的映射表。而是在arch/x86/syscalls/syscall_64.tbl中定义了自己的映射表。sys_read()对应的表项如下: 0 command read sys_read 第一项(0)表示read()在X86_64上的系统调用编号为0(不是63),第二项(common)表示对X86_64的两种ABI均有效。最后一项sys_read表示系统调用函数的名称。(X86_64的两种ABI将在下一篇文章中解释)脚本syscalltbl.sh可以根据系统调用表syscall_64.tbl生成头文件arch/x86/include/generated/asm/syscalls_64.h。该文件为sys_read调用了宏__SYSCALL_COMMON()。反过来,这个头文件也可以用来生成系统调用表sys_call_table,这张表是用于映射系统调用号与sys_name()函数的关键数据结构。

X86_64系统调用的调用过程 下面我们来解释用户空间的程序是如何调用系统调用的。这个过程跟体系结构密切相关,本文的剩下部分仅针对X86_64(其它X86体系结构的情景,将在下一篇文章中描述)。这个过程涉及到几个步骤,下图能够帮助大家理解。

在上一节里,我们提到了系统调用的函数指针表。在X86_64上,这张表的结构如下所示(利用GCC关于数组初始化的特性,能够保证所有未声明的表项,都能指向函数sys_ni_syscall()):

   asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
       [0 ... __NR_syscall_max] = &sys_ni_syscall,
       [0] = sys_read,
       [1] = sys_write,
       /*... */
   };

在64位的代码路径上,arch/x86/kernel/entry_64.S中的汇编函数system_call会访问这张表。该函数利用RAX寄存器保存系统调用的编号,并随后调用相应的函数。system_call首先会调用SAVE_ARGS宏将寄存器现场压进堆栈。这跟前文提到的asmlinkage就对应上了。 继续往调用路径的外层走,汇编函数system_call在syscall_init()中被调用。这个函数在内核初始化的早期就会被执行。

 void syscall_init(void)
   {
       /*
        * LSTAR and STAR live in a bit strange symbiosis.
        * They both write to the same internal register. STAR allows to
        * set CS/DS but only a 32bit target. LSTAR sets the 64bit rip.
        */
       wrmsrl(MSR_STAR,  ((u64)__USER32_CS)<<48  | ((u64)__KERNEL_CS)<<32);
       wrmsrl(MSR_LSTAR, system_call);
       wrmsrl(MSR_CSTAR, ignore_sysret);
       /* ... */

wrmsrl指令用于将一个值写入MSR寄存器。在我们的情景里,通用的系统调用处理函数system_call()被写入了寄存器MSR_LSTAR(0xc0000082)。在X86_64中,这是一个专用于处理SYSCALL指令的MSR。 把这些点串起来连起来,我们就能理清用户空间到内核空间的脉络了。在标准的ABI中,用户空间调用系统调用时,需要先将系统调用编号放入RAX寄存器,其它的参数放入指定的寄存器(RDI,RSI,RDX用于存储前3个参数),然后触发SYSCALL指令。这条指令将CPU转换到Ring 0,并且调用MSR_LSTAR中存储的函数,system_call()。system_call()的代码首先将寄存器压入内核栈,再利用RAX中的值在sys_call_table表中查找函数指针,并调用之。这个函数指针封装在SYSC_read()中,这层asmlinkage的封装很薄。 OK,我们已经了解了最常见的平台上实现系统调用的标准方法。下篇文章将继续深入介绍其它体系结构上的情况,以及一些不太常见的情景。

Anatomy of a system call, part 2

深入剖析系统调用 (二)

By:David Drysdale

上篇文章描述了内核实现系统调用的最普通的方法,在最常见的X86_64平台上解释了一个普通的系统调用(read())。在这个基调上,本文将更深入地探索系统调用,涉及到其它X86体系结构以及其它的系统调用实现方法。我们从介绍X86体系结构的各种32位变种开始。下图能够帮助大家理解本文的内容。

X86_32:利用SYSENTER指令实现系统调用

在32位的X86_32系统中,系统调用的实现方法与X86_64系统类似。表格arch/x86/syscalls/syscall_32.tbl中关于sys_read()的项为:

  3 i386 read    sys_read

X86_32中,read()的系统调用编号为3。入口为sys_read(),调用方式是i386。对这个表格处理之后,会在arch/x86/include/generated/asm/syscalls_32.h文件中生成对宏__SYSCALL_I386(3, sys_read, sys_read)的调用。当然,这个文件也可以反过来用于构建系统调用表:sys_call_table。

arch/x86/kernel/entry_32.S中的汇编函数ia32_sysenter_target会访问这个表。不过这里调用的SAVE_ALL宏压入了不同的寄存器集合(EBX/ECX/EDX/ESI/EDI/EBP 而不是 RDI/RSI/RDX/R10/R8/R9)。这是由于该平台的ABI不同于X86_64。

在内核初始化阶段,将ia32_sysenter_target的位置写入了MSR。此时用到的MSR为MSR_IA32_SYSENTER_EIP(0x176),这是SYSENTER指令专用的MSR。 这基本解释了从用户空间下来的路径。标准的现代ABI规定,X86_32程序需要先将系统调用编号(read()的编号是3)放入EAX寄存器。其它的参数放入指定的寄存器(EBX, ECX,与EDX用于存储前3个参数),然后触发SYSENTER指令。

该指令将CPU转换到Ring 0,并且调用MSR_IA32_SYSENTER_EIP寄存器中指向的代码(ia32_sysenter_target)。ia32_sysenter_target将寄存器压入内核栈,根据EAX中的值在sys_all_table中查找对应的函数指针,并调用之。该指针指向sys_read(),这个函数仅仅对SYSC_read()中真正的实现代码做了一层很薄的包装。

X86_32: 通过INT 0x80调用系统调用

表格sys_call_table仍然被arch/x86/kernel/entry_32.S中的汇编函数system_call访问。这个函数将寄存器保存在栈上,随后利用EAX寄存器的值查找sys_call_table中对应的表项,并调用之。只是,system_call函数的位置需要通过trap_init()获得:

   #ifdef CONFIG_X86_32
       set_system_trap_gate(SYSCALL_VECTOR, &system_call);
       set_bit(SYSCALL_VECTOR, used_vectors);
   #endif

这个函数将系统调用向量SYSCALL_VECTOR的处理函数设置为system_call。随后可以通过软件中断INT 0X80来触发系统调用。

这是用户空间触发系统的最原始的方法。但在现代的处理器上则被避免使用,因为它的执行速度比系统调用指令(SYSCALL与SYSENTER)要慢。

在这个较老的ABI中,程序在触发系统调用前,需先将系统调用编号放入EAX寄存器,将其它参数放入指定寄存器(EBX,ECX与EDX用于存储前3个参数),随后触发INT 0X80指令。该指令将CPU转换到Ring 0,并随后调用软件中断INT 0x80的处理函数,system_call()。System_call()中的代码先将寄存器压栈,并利用EAX的值中sys_call_table中查找到相应函数,如sys_read()。而sys_read()是对SYSC_read()中真正实现代码的封装。这个过程与利用SYSENTER的过程很相似。

X86的系统调用机制小结

上文描述过以下几种用户空间触发系统调用的方法:
1.64位程序使用SYSCALL指令触发系统调用。这条指令最初由AMD引入,Intel的64位平台随后也实现了它。出于跨平台(Intel/AMD)兼容性的考虑,这条指令是最佳选择。

2.现代32位程序使用SYSENTER指令触发系统调用。Intel在IA32体系结构上最先引入这条指令。

3.古代32位程序使用INT 0x80触发软件中断,进而实现用户空间对系统调用的触发。但是在现代32位处理器上,这种方法要远慢于SYSENTER指令。

在X86_64平台上触发X86_32系统调用(兼容模式)
现在我们考虑一种更加复杂的情景:如果在X86_64平台上执行32位程序,会发生什么?从用户空间的角度看,没有任何不同。因为执行的用户代码是完全相同的。

当使用SYSENTER时,X86_64内核会在寄存器MSR_IA32_SYSENTER_EIP中注册一个不同的函数。该函数与X86_32内核中的相应函数同名(ia32_sysenter_target),但是实现却有区别(实现在文件:arch/x86/ia32/ia32entry.S)。这个函数虽然也保存了旧式风格的寄存器(32位),却使用了不同的系统调用表,ia32_sys_call_table。这张表由32位的表项构造,比如sys_read()的编号为3(与32位系统相同)而不是0(64位系统中sys_read()的系统调用编号)。
当使用INT 0x80时,X86_64对trap_init()的实现如下:

   #ifdef CONFIG_IA32_EMULATION
       set_system_intr_gate(IA32_SYSCALL_VECTOR, ia32_syscall);
       set_bit(IA32_SYSCALL_VECTOR, used_vectors);
   #endif

这段代码将中断向量IA32_SYSCALL_VECTOR(仍然是0x80)映射到了函数ia32_syscall。这个汇编函数(arch/x86/ia32/ia32entry.S)使用了ia32_sys_call_table而非64位系统的sys_all_table。

更复杂的例子:execve()与32位兼容性处理

现在为大家描述一个更复杂的系统调用: execve()。我们依然从这个系统调用的内核实现一步步向外探索,并在这条路径上对比与read()这种简单系统调用的不同。下图可以帮助大家理解这个过程。

与read()类似,execve()定义在文件fs/exec.c中(read()定义在fs/read_write.c)。不同的是,就在这个函数之后,还定义了一个有趣的函数(如果打开了CONFIG_COMPAT)。

SYSCALL_DEFINE3(execve,
const char __user *, filename,
               const char __user *const __user *, argv,
               const char __user *const __user *, envp)
   {
           return do_execve(getname(filename), argv, envp);
   }
   #ifdef CONFIG_COMPAT
   asmlinkage long compat_sys_execve(const char __user * filename,
           const compat_uptr_t __user * argv,
           const compat_uptr_t __user * envp)
   {
           return compat_do_execve(getname(filename), argv, envp);
   }
   #endif

在调用路径上,两种实现最终都会通过do_execve_common()函数执行真正的工作(sys_execve()->do_execve()->do_execve_common() vs compat_sys_execve()->compat_do_execve()->do_execve_common())。在这两条路径上,主要工作都是为user_arg_ptr赋值。这些数据结构通过传递指针,保存了系统调用的参数,并且标示了这个参数是否为32位的兼容ABI。如果是,相应的指针就指向一个32位的用户空间地址,否则便是一个64位的值。这是从用户空间拷贝数据时必须考虑的。
read()不需要区分调用者是32位的程序还是64位的程序,因为它的参数传递方式是指针到值。而execve()必须区分调用者的类型,因为它的参数传递方式是指针到指针。这是一种常用的手段。其它利用指针传递参数的系统调用(或者传递的数据结构中包含指针,如struct iovec或struct aiocb)也采用了此方法。
对X32 ABI的支持
为了兼容不同平台,execve()同时具有两种实现,稍有一些复杂。系统调用表也是如此。X86_64平台上,64位系统调用表中有两个与execve()相关的表项:

   59    64    execve            stub_execve
   ...
   520    x32    execve            stub_x32_execve

这张64位的表格中,系统调用编号为520的表项是专为使用X32 ABI的应用程序而设的。使用X32 ABI的程序虽然运行在X86_64处理器上,但是却使用32位的指针。这两个表项表明,64位程序使用函数stub_execve(),而X32 ABI程序使用stub_x32_execve()函数。

之前在介绍read()时,并未提及X32 ABI。因为read()没有使用指针传递参数,它默认就兼容32位程序。使用read()时,32位程序的系统调用路径与系统调用编号均可以与64位程序共享。

stub_execve()与stub_x32_execve()均定义在arch/x86/kernel/entry_64.S中。这两个汇编函数分别调用sys_execve()与compat_sys_execve(),同时将额外的寄存器(R12-R15,RBX与RBP。“额外”是指与SAVE_ARGS相比。)保存到内核栈。文件arch/x86/kernel/entry_64.S中还有很多以“stub_*”开头的函数封装,封装了其它的系统调用(如rt_sigreturn(),clone(), fork(), vfork())。这些系统调用一旦被执行,那么返回用户空间时,应用程序的指令地址(IP)以及(或者)用户栈可能会发生改变(将执行其它的进行/线程)。

在X86_32平台上,32位的系统调用表中只有一项与exevce()相关。该表项在形式上与read()略有不同:

11 i386 execve sys_execve stub32_execve

首先execve()在32位平台上的系统调用编号为11,64位平台上是59(或520)。这张表的表项多出了一个字段,用以保存兼容入口stub32_execve。如果内核被编译为32位,该字段会被简单的忽略掉,sys_call_table的表项“11”的入口函数就是sys_execve()。
不过,如果内核被编译为64位,IA-32的兼容代码就会将stub32_execve()插入ia32_sys_call_table的表项“11”。这个函数定义在arch/x86/ia32/ia32entry.S中:

   PTREGSCALL stub32_execve, compat_sys_execve

宏PTREGSCALL使得stub32_execve()调用compat_sys_execve()(将这个函数的地址存入RAX中),并将额外的寄存器(R12-R15,RBX与RBP)保存到内核栈(与上文提到的stub_execve()类似)。

gettimeofday(): vDSO

有些系统调用仅从内核中读取少量信息,如果为这些系统调用变换特权级别,开销就显得特别大。vDSO(Virtual Dynamically-linked Shared Object)通过将包含这些信息的页面映射到用户空间,加速了这些只读系统调用的执行。而且,这个页面使用了ELF共享库的格式,可以直接与用户空间的程序进行链接。

用ldd查看一个gcc编译出的程序,可以看到vDSO通常被处理为依赖的共享库linux-vdso.so.1 或 linux-gate.so.1(当然这两个库没有对应实际的文件)。在进程的内存映射中也能找到vDSO的踪影(/proc/PID/maps中的[vdso])。

内核在过去曾使用vsyscall来做这件事,由于安全上的原因才被vDSO取代。Johan Petersson写过一篇文章,描述了vsyscall将页面映射为用户空间ELF对象(映射在一个固定的地址上。译者注:映射在固定的地址上,存在非常大的安全漏洞。vDSO将自己映射在一个随机地址上)的方法。
这篇Linux Journal上的文章讲述了vDSO的细节(略有过时)。我们在本文中仅描述vDSO的基本概念,并解释一下vDSO中的系统调用gettimeofday()的实现。

首先,gettimeofday()需要访问数据。内核将相关的数据结构vsyscall_gtod_data导出到了一个特殊的数据段.vvar_vsyscall_gtod_data。链接器可以将.vvar_vsyscall_gtod_data链接进内核的__var_page段。在内核的启动阶段,函数setup_arch()调用函数map_vsyscall()将_var_page段映射到了一个固定的地址上。

函数_vdso_gettimeofday()实现了vDSO版的gettimeofday()。这个函数被标记为notrace,从而阻止编译器增加用于性能分析的辅助代码(比如mcount())。定义在该函数之后的gettimeofday(),被简单定义为_vdso_gettimeofday()的别名(weak alias)。为了是最终的页面看起来像一个ELF共享对象,gettimeofday()与_vdso_gettimeofday()同时被导出到vdso页面。(*)

为了使新创建的vDSO进程能够访问vDSO页面,setup_additional_pages()(译者注:在upstream中,这个函数貌似已改名为arch_additional_pages())中的代码将vDSO页面映射到了一个随机的地址(译者注:用户空间的地址)上,这个地址由vdso_addr()函数在进程启动时选定。使用随机地址在一定程度上解决了vsyscall的安全问题,但是带来了一些不便。用户空间的进程需要自己去获得vDSO页面的位置。页面地址作为ELF文件的辅助向量曝漏给用户空间。ELF文件的加载器(load_elf_binary())使用宏ARCH_DINFO设置AT_SYSINFO_EHDR辅助向量。用户空间的程序通过函数getauxval()查找相关的辅助向量,即可找到vDSO页面的地址(实际上,这些工作由glibc代劳了)。

出于完整性考虑,我们再略提一下vDSO机制中用于32位程序的重要特性。内核在启动阶段会决定哪种系统调用机制为最佳。并将合适的机制(SYSENTER,INT 0x80, 或者AMD处理器的SYSCALL)封装进_kernel_vsyscall()函数。用户空间的程序可以调用这个封装,并选择一种最快的方式进入内核。Petterson的文章中有更加详细的说明。

ptrace():系统调用的跟踪机制

ptrace()系统调用本身采用常规的方法实现。但是它可以干预其它被追踪系统调用的行为。PTRACE_SYSCALL可以使被追踪者在进入或退出一个系统调用时暂时停止执行。 请求PTRACE_SYSCALL后,相关线程的线程信息标记中的TIF_SYSCALL_TRACE会被置位。(线程信息标记是thread_info中的flag字段)之后的行为是体系结构相关的,在此我们仅考虑X86_64的情况。

如果再次仔细阅读系统调用路径在汇编语言中的入口函数(包括X86_32, X86_64与IA32),我们会发现一个之前被忽略的细节:如果线程信息标记中有任何_TIF_WORK_SYSCALL_ENTRY标记(这是一组标记,包括TIF_SYSCALL_TRACE)被置位,系统调用的处理过程将进入另外一条完全不同的路径。此时会调用syscall_trace_enter()函数(包括X86_32, X86_64与IA32),该函数根据标记组_TIF_WORK_SYSCALL_ENTRY中被置位的标记,进一步调用不同的函数。
TIF_SINGLESTEP: ptrace()单步执行指令。

TIF_SECCOMP: 在系统调用的入口进行安全检查。

TIF_SYSCALL_EMU: 执行系统调用仿真。

TIF_SYSCALL_TRACE: ptrace()的系统调用追踪。

TIF_SYSCALL_TRACEPOINT: ftrace的系统调用追踪。

TIF_SYSCALL_AUDIT: 生成系统调用的统计信息。

换句话说,syscall_trace_enter()是各种系统调用拦截机制的控制点,包括TIF_SYSCALL_TRACE在内。将ptrace_stop()的why参数设为CLD_TRAPPED,可以结束追踪过程。ptrace_stop()会向在系统调用入口点被暂停的进程发送一个SIGCHLD信号。
结语:

几十年以来,系统调用都是用户程序与UNIX内核交互的标准方法。Linux内核又发展了一系列机制使得系统调用的实现更加方便,使用起来更有效率。尽管在不同的平台上调用系统调用的方法略有不同,同时还存在一些特例。但这并不影响系统调用在调用机制上具有高度同构的特性。这种稳定与同构的特性使得很多有用的工具(strace,seccomp-bpf)实现起来非常方便。

Handling ARM architecture changes

目前x86处理器上的Linux系统可运行90年代创建的二进制程序。主要原因可归结为重视不破坏用户态ABI兼容性,其实还有另一因素起作用:x86硬件架构同样关注保证旧程序能够正常运行。ARM架构就不同了,它的演进导致一些老的应用不能正常运行(或者根本不能运行)。这对那些不想破坏应用同样想保持内核长期可维护性的内核开发者来说,是个难题。

最近,Google的Colin Cross提出一个问题。ARM架构不时会发布一个主版本,最新的是ARMv8。该版本增加了64位支持以及其它的一些改动;ARMv8也提供了对老版本架构下的程序的兼容支持。但是这个兼容性也只能做到这个程度;特别是缺少对SWP指令(原子的读-改-写操作),SETEND指令(用于修改数据访问的字节序)以及某些barrier类型的支持。这些还不足为奇;这些特性在ARMv6或者ARMv7都已经被废止了。ARM在去除这些指令上已经有明确的计划。

当然,问题在于,已存在的程序仍然在使用这些指令。任何当前编译的程序不会使用这些废除的指令,但是仍然有大量的蹲在Google Play Store角落里的程序最近没有被编译过,而且有可能永远不会被重新编译。目前来看,如果一个用户在ARMv8设备上加载了这类程序,它根本就跑不起来。这与基于ARMv8的Android设备期望兼容已有程序相悖。

解决这个问题的办法很直接:内核trap尝试使用并模拟这些指令。使用这些指令的程序就可以继续运行,尽管它们会运行的慢很多。Colin问内核社区是否愿意接受一组实现这个模拟的补丁。他说,另一种选择是Google用单独的代码树为所有Android客户维护这些补丁;这样,主线内核可能会跑不通Android兼容性测试。

鉴于Android对ARM的重要性,有人可能认为这种方式的反对声音可能会很小。但是ARM开发者Will Deacon反对这种做法,一旦对诸如SWP之类指令的模拟进入内核,它将会被维护很长时间。他同样指出在程序里使用SWP指令基本上就是个bug。Will认为不动内核而是简单的修改相关程序是更好的解决方法。

Catalin Marinas 补充道,任何换到ARMv8设备的人都将会从Google Store下载这些应用。他的观点是,Google应该去推动这些旧程序被重新编译,这样当用户在他们崭新的ARMv8设备上下载应用时,就轻松愉快了。但是Grant反对这个做法,认为强迫开发者重新编译他们的应用会使得平台变得不友好。并且很多这种老应用已经找不到维护的公司或者开发者了;想重新编译这些程序基本不可能。但他们目前来说可以正常工作,不应该被破坏。

经过一阵交锋,讨论达成一个共识,就是破坏已有的应用程序不好。但是这个怎么转化成补丁进入主线内核仍然还不明了。开发者们同样重申了向后兼容的规则:就是在没有使用者的时候再删除被废止的功能。由于ARM在未来很可能继续废除硬件特性,内核开发者需要找到一种办法,为继续使用被废止特性提供最小支持。没有人愿意把内核转成支持老ARM架构的模拟器。

最后,Catalin针对如何处理被废止功能给出一个建议时间表。第一步出现在硬件废除特性时,但是当前硬件仍需要支持它。内核社区需要找到办法引起对这些被废除特性的关注,并且鼓励开发者去掉这部分内容。正确的方法应该是,给出替换这些被废除特性的方法。

第二步需要在下一个硬件版本发布时,根据ARM的实践,被废除的特性仍然存在但是可以被关闭。这时,内核将关闭这些特性并且软件模拟它们;并且将警告发给使用这些废除特性的应用程序。程序仍可正常工作(可能会变得更慢)。这里有个挑战是,在移动设备上,很难让人注意到这些内核警告;用户不会经常读日志文件。那么对这类设备,警告最好实现在其它层;例如Play Store。

在第三阶段,额外的硬件修订完全删除这些特性。Catalan认为,这时内核需要停止模拟这些指令并且发送SIGKILL给相关进程。模拟的代码可以仍然在内核中,但是默认情况不应该被使用。最终,第四阶段,模拟支持也应该被去除。

尽管在处理当前已有的问题可能还有些异议,ARM内核社区已经普遍认同这套处理硬件废止功能的规则。ARMv6中废除的SWP指令,还不算是处于第三阶段,因为让使用者绕开SWP指令的工作还没有做到位。所以,尽管部分开发者希望在ARMv8上看到使用SWP指令的程序被SIGKILL掉,对SWP指令的模拟还是有必要保留一阵子的。

但是,未来ARM废除的硬件特性,将遵循上面的时间表。只要相关信息能够及时的传达给开发者,在相关支持完全删除时,这些特性应该不会再有使用者。这样在硬件架构演进时,可以更好的保证内核可维护性。那些希望从老G1手机上移过来的程序能够正常跑在新设备上的用户可能会失望了。

让人民群众拥有更好的随机数:记一个新系统调用的诞生

正如我们所知,大多数的随机数算法都需要一个种子或者说seed,并且不管你的随机算法多么厉害,只要给定的种子和其他外部输入是确定的、重复的,它吐出的随机数序列就必然也是确定的、重复的。这里的原因很好理解----冯诺依曼结构的计算机是一个确定性的系统,你不可能指望它产生“真正”的随机数。从实用主义的观点来看,只要产生的随机数序列别人不容易猜测到,或者更直白点说,产生随机数序列的种子还有其他输入参数别人不容易猜测到,这就已经足够好了。LibreSSL也正是这么做的,和其他很多做加密的库一样,它从/dev/urandom取出随机数做种子,如果/dev/urandom不可用(什么情况下/dev/urandom会不可用?太多了,攻击者消耗光了所有fd以至于你打不开一个设备;程序被放到一个看不见/dev的被严格限制的container里跑;程序被chroot了等等等等)它就退回到一个自己定义的随机数生成器上去,这个随机数生成器试图用一些用户态能接触到的有随机性的事件来生成随机数序列(比如pid,时间戳等等,看起来也是个有趣的算法)。

那么,/dev/urandom的数据是怎么来的呢?目前的Linux内核在内部维护一个随机数池,它通过一些对于用户来说更不容易预测的事件,例如键盘的敲击间隔、网卡数据包的到达间隔等等做为种子来生成随机数。一般场景使用/dev/urandom就足够了,如果是那些更加敏感的场景,比如生成PGP key,或者只在某些初始化的时候用一次,以后就不再用的场景下,也可以使用/dev/random。它的要求更高,如果没有足够的随机事件发生,就会让read一直阻塞在那里。应用这时往往就要提示一下用户:敲敲键盘!

这样做是不是足够好了?LibreSSL的开发者们显然不同意,他们抱怨说OpenBSD有一个叫getentropy()的系统调用,可以方便地向用户返回想要的长度的随机数。而在Linux世界里就必须得和/dev/urandom打交道,由于上边说的种种限制,很可能那些应用打不开/dev/urandom,这太不方便了。为了响应这个需求,Tso决定添加一个类似的系统调用,相关的patchset已经出到第四版了【http://lwn.net/Articles/606202/】。

这个调用实现的功能比它的OpenBSD对应者还要更多些,比如:尽管/dev/urandom会在内核启动的早期完成初始化,但你仍然有可能在它初始化完成之前调用这个系统调用,因此这个系统调用的语义中加入了表示未初始化完成的返回值;同时,它也允许用户使用非阻塞的方式来取得随机数,在随机数不足时返回-EAGAIN而非阻塞在那里,如此种种。

和以往一样,这个patchset当然也收到了一些反对的声音,不过没有人从根本上反对添加这个系统调用,多数是一些细节修正,例如Christoph Hellwig认为没有必要添加额外的那些功能,那些功能使得这个系统调用的接口还有语义变得与OpenBSD不同了,这没有必要。Tso的回应是OpenBSD式样的接口完全可以通过在Glibc中包装一层来达到,这不成为问题。

总之,到目前为此没有大的反对声音,这个patchset有望最早在v3.17进入主干。

友提:本文的lwn.net原贴下边的讨论相当精彩!

Two paths to a better readdir()

通常文件系统的工作遵守一定的模式:在一个目录下查找文件,使用stat()获得每个文件的信息。“ls -l ”就是以这样的模式工作的典型例子,当然还有其它很多都是这样工作的。这样的工作模式在linux系统中运行的通常比开发者们想象中的的要慢,解决这个问题的方法发展的也同样缓慢。 最近,Abhi Das提出了几个可能的解决这一问题的方法或许可行。

“ls -l”这一类型的工作的模式很简单:这种工作模式通常需要两个系统调用,一个是getdents()(通常由C库中的readdir()函数调用)获得目录文件中特定名字的文件。然后调用stat()获得文件的更多元信息。stat()会有很大的开销,每次调用都会迫使相应的文件系统进行必要的I/O去获得需要的信息。在某些情况下,这些信息可能会分散到磁盘中的不同的地方,这就需要更多的I/O以完成请求。然而调用者并不需要由stat()返回的全部信息。这样,也就是没必要使用stat获得全部的信息。如果能够有一种方法让应用开发人员可以设定需要获得的信息,从而减小需要I/O的数据量,这样就好了。

这个问题并不是新提出的。事实上,它是个很老的问题,在2009年Linux Storage and Filesystem Workshop就讨论过这一问题。曾经,有人提到过使用一个xstat()的系统调用来解决这一问题,但是后来这种方法也没能最终做到。目前,一些文件系统使用各自的方法来避免这种模式带来较高的I/O。但是,内核中并没有一种通用的方法来应对这一问题。近年来似乎很少人关注并解决这一问题。

Abhi提出了两个独立的方法,他希望获得大家对这两种方法的反馈,然后选取一种较好的并希望可以并入upstream。

xgetdents()

第一种方法建立在由David Howells在2010年提出的xstat()系统调用的基础上。他添加了两个新的系统调用:

       int xstat(int dirfd, const char *filename, unsigned int flags,
                   unsigned int mask, struct xstat *info);
       int fxstat(int fd, unsigned int flags, unsigned int mask, struct xstat *info);

第一个函数由文件名字符串查找文件,而第二个函数由进程以打开的文件号获得文件的信息。 flags参数可以调成函数的行为(很少使用)。mask告诉内核哪些信息需要获 取给进程。这里只可以设置其中的很少的几个bit。如XSTAT_MODE(权限),XSTAT_UID(文件所有者),XSTAT_RDEV(文件所在设备号),XSTAT_ATIME(最后一次访问时间), XSTAT_INO XSTAT_ALL_STATS获取全部信息。函数成功会添充info结构。

Abhi在此基础上添加了另外一个:

  int xgetdents(unsigned int fd, unsigned int flags, unsigned int mask,
                        void *buf, unsigned int count);

这里fd是目录的一个描述符,flags和mask和上面的一样。但是mask扩展到可以支持文件的扩展特性了。 返回的信息放在了buf中, buf是count长的数组。xgetdents会将尽可能多的文件的信息放在buf中,直至buf填满。
buf结构有些复杂。如下:

  struct xdirent_blob {
      unsigned int    xb_xattr_count;
      char            xb_blob[1]; /* contains variable length data like
                                          * NULL-terminated name, xattrs etc */
   };
   struct linux_xdirent {
   unsigned long        xd_ino;
   char                 xd_type;
   unsigned long        xd_off;
   struct xstat         xd_stat;
   unsigned long        xd_reclen;
   struct xdirent_blob  xd_blob;
   };

几乎没有资料对上面的结构进行说明, 我们必须查看源码来了解这些结构的意义。每个文件的信息放在一个linux_xdirent中,文件名保存在xd_blob,中,如果存在xattr的话,之后是xattr的信息,这个结构需要费些功夫理解,但它确实可以使得只用一次系统调用就返回足够的信息。

dirreadahead()

另一个方法很简单,只需要增加一个系统调用:

       int dirreadahead(unsigned int fd, loff_t *offset, unsigned int count);

这个函数初始化对文件信息的读取, 读取从offset开始的count个目录fd中的文件,offset会在函数使用后更新,表示实际读取的文件个数,所以可以对一个目录使用多次dirreadahead,内核会维护offset的信息。

在这种方法中,用户还是需要调用getdents()和stat()来获取所需的信息,但是,区别在于, 这些信息已经被填充到了内部的cache中了,所以这样并不会再进行I/O了,这样速度快了很多,一次读取多个文件信息可以被成群的处理,这样及时不同文件的信息很分散,I/O会被按照最佳的顺序进行。

在这两种方法的patch的介绍中包含了在GFS2中的benchmark测试结果。在大量使用与"ls -l"类似的要调用getdents()和stat()的系统中,使用这两种方法都会比mainline kernel的表现好。有些人可能会奇怪,dirreadahead()的表现比xgetdents()要好很多。这可能说明不了xgetdents()或GFS2的实现不好,但是却说明,更加简单的基于预读的方法更值得考虑。

这种预读的方法很容易就就让人想到内核可不可以自动进行这种预读,就像普通文件的预读那样,Trond Myklebust说NFS尝试监测到要使用这种预读的地方一边自动进行预读,更一般的情况下,这种情况很难监测,所以到目前,还是要靠用户空间来触发,上文提到的两种方法都可以被使用,但是,即使没有更好的benchmark测试,看起来相对于简单的dirreadahead()方法更适合使用。

The RCU-tasks subsystem

RCU-task是类似RCU的机制,只是直到没有进程引用旧数据时才释放. 为了证实可行性,Paul Mackenney(这鸟人是rcu方面的权威) 已经提交了一个验证性的模型.

通常RCU使用一个指针指向被保护的数据.当被RCU保护的数据需要改变时,RCU首先做一次copy,在副本上做改动,而后指针指向副本.之后,通过新赋值的指针不会再访问旧的数据.但在数据被改动之前,目前正在运行的代码可能已经获取了取得了旧数据的指针.所以现在旧数据不能马上被释放. RCU使用规则要求只能够在一个原子的上下文中引用数据.每个CPU经历一次上下文切换才能保证旧数据不再被任何cpu引用,进而可以被安全的释放掉.因此RCU必须等待每个cpu都经历了一个上下文切换或者空闲.

通常,一个cpu上最多只有一个进程引用被rcu保护的数据.rcu关注什么时候会cpu不再引用被保护的数据. 相比RCU来说,进程有可能在使用旧数据的过程中会被抢占,而且一个cpu上可能有一个或者多个进程引用rcutask保护的数据.所以关注点就不一样了. rcu-task机制是被用来描述没有进程(not cpu)引用被保护的数据.rcu-task需要更慢的锁机制,并稍微改变一下使用规则.

其api:

void call_rcu_tasks(struct head *rhp, void (*func)(struct rcu_head *rhp));

一旦度过了安全期,调用func()释放相应的数据结构. rcu task没有成对的rcu_read_lock()来保护被访问的数据.

经过这么多年的发展,rcu为了尽量的可扩展,变的很复杂.而第一版的rcu task很简单. 调用all_rcu_tasks()的进程被链到一条链上.有个内核进程负责维护这个条链,每秒钟(后续版本会使用等待队列)都会检查是否有新的被加入到这条链上. 如果有,那么这条链会被移动到一个单独的链上,并等待安全期结束.

只有runnable的进程保留rcu task引用.每个持有引用的进程都会被打上一个特殊的标志"rcu_tasks_holdout".当进程主动放弃cpu或者返回用户空间时,放置在调度器里的钩子会清除这个标志.有个单独的内核线程每秒钟循环10次去检查链上的进程,被清除了特殊标记的进程会被从这条链上删除.当链变成空的时候,执行释放操作函数.并开始新一轮的循环.

随着patch的完善,代码也变得更加复杂,最近的大改动时跟进程退出相关的.进程可能会在被检查到之前就已经退出了,显然不能访问退出的进程的特殊标志位.新加代码很大一部分时在处理这种情况.

目前还没有模块使用这种机制,patch里的大多数评论来自与Peter Zijlstra.他很关注polling和相关的考虑.rcu-task看起来时对rcu一个很有用的补充,但是不要期望在3.17版本里见到它.

Year 2038 preparations in 3.17

在2038年1月19日这一天,32位的time_t变量将会溢出,带来类Unix系统的末日.虽然2038看起来很遥远,是时候开始关注这个问题了;需要保证代码在未来能够工作,现在开发的某些系统在24年之后也会存在.保证32位系统在2038年能够正常工作的系统方案需要一段时间才能实现.但是一些最初的修改已经被加入到3.17内核中.

需要进行的改动与两个数据结果密切相关:union ktime(ktime_t)和struct timespec.ktime_t结构类型随2006高分辨率定时器而引入.它被设计为内核内部的时间表示类型,ktime_t太不透明了,以至于它的定义随底层体系结构的不同而有区别.

在64位系统中,ktime_t一直用一个整数记录了纳秒数.对这种格式数据的管理和算数运算非常方便,只要体系结构支持对64位操作.由于32位系统中通常不存在64位操作,ktime_t的定义也与64位系统中的定义不同.32位系统中分别用2个32位的变量记录秒数和纳秒数.内核代码通过一系列经过包装的函数来操作ktime_t变量,把32位系统和64位系统的区别隐藏起来,不影响内核其他部分.

在2038年,记录秒数的32位域将会溢出,32位系统和64位系统的差异则会表现出来.因此,为了解决2038问题,ktime_t变量需要修改.3.17内核中的第一个修改就是取消阶梯式的ktime_t表示,强制使用64位纳秒计数.这样可能会影响32位系统的性能,特别是影响时间表示之间的转换速度.正如changelog中提到,ARM和x86体系结构已经使用了这样的表示,它们不会变得更慢.

把ktime_t结构和其他时间表示转换快慢的问题先放到一边,减少不必要的转换看起来是有效的优化手段.3.17内核中还修改了部分子系统对时间的使用方式,使它们直接使用64位纳秒计数.结果通常是对代码的简化,使代码执行更快.

另一个数据结构是timespec结构

    struct timespec {
    __kernel_time_t    tv_sec;            /* seconds */
    long        tv_nsec;        /* nanoseconds */
    };

__kernel_ktime_t类型只是当前内核中ktime_t类型的另一个名称,在32位系统中它就是32位变量.与ktime_t类型不同,timespec变量也在用户空间中被使用,它也是内核ABI的一部分,因此不能修改timespec结构.3.17内种增加了如下的数据结构定义

    struct timespec64 {
    time64_t    tv_sec;            /* seconds */
    long        tv_nsec;        /* nanoseconds */
    };

在64位系统中,这个数据结构与timespec是完全相同的.在timekeeping代码中,所有timespec数据都被修改成了timespec64类型.timekeeping操作的接口都被修改,以隐藏timespec64数据类型的引入,使用timespec64的一套新接口也已经引入.修改之后,timekeeping代码中不再使用32位变量计数秒.

当前的修改离解决2038问题还有很大差距.但确是非常重要的一步修改,timekeeping代码中在2038年不会有时间溢出.通过其他一些修改,系统的解决方案有可能展现出来.其中第一步就是把timespec64的使用从timekeeping内部扩展到内核其他部分.解决方案可能需要大量工作,但这是内核社区非常擅长的改格式修改的一个例子.假以时日,内核代码能够完全避免2038问题.

更艰难的修改是,把在2038年安全的代码扩展到内核ABI和推动用户程序开发者修改应用代码.这需要与C库开发者合作,同时考虑怎么以最小的代价完成修改.期望修改迅速完成是不现实的.但目前这个问题已经引起了开发人员足够的重视,在最后时刻之前解决这个问题是有希望的.第一步已经迈出,希望后续修改很快可以完成.

Ftrace: The hidden light switch

在ftrace诞生前,Linux内核性能调优是个很有挑战的工作。但是当ftrace诞生后,这一工作开始变得简单起来。

最近在Netflix的一个Cassandra数据库系统升级后,出现了磁盘IO增加的问题。到底是cache命中率降低了,数据库中的记录变大了,预读数量增长了还是其他应用程序的问题呢?如何来确定问题的根源并且修复这一问题呢?

1. iosnoop

首先我们来对服务器进行一些必要的健康检查。这里作者使用的工具是iosnoop。iosnoop是一个shell脚本。在运行iosnoop后,结果如下(通过-Q参数来获取IO队列的影响):

  # ./iosnoop -ts
  STARTs          ENDs            COMM         PID    TYPE DEV      BLOCK        BYTES     LATms
  13370264.614265 13370264.614844 java         8248   R    202,32   1431244248   45056      0.58
  13370264.614269 13370264.614852 java         8248   R    202,32   1431244336   45056      0.58
  13370264.614271 13370264.614857 java         8248   R    202,32   1431244424   45056      0.59
  13370264.614273 13370264.614868 java         8248   R    202,32   1431244512   45056      0.59
  [...]
  # ./iosnoop -Qts
  STARTs          ENDs            COMM         PID    TYPE DEV      BLOCK        BYTES     LATms
  13370410.927331 13370410.931182 java         8248   R    202,32   1596381840   45056      3.85
  13370410.927332 13370410.931200 java         8248   R    202,32   1596381928   45056      3.87
  13370410.927332 13370410.931215 java         8248   R    202,32   1596382016   45056      3.88
  13370410.927332 13370410.931226 java         8248   R    202,32   1596382104   45056      3.89
  [...]

从结果可以看到,IO队列对磁盘负载有较大的影响。

2. tpoint

为了深入调查这些磁盘读操作,作者使用了tpoint来跟踪block:block_rq_insert事件:

  # ./tpoint -H block:block_rq_insert
  Tracing block:block_rq_insert. Ctrl-C to end.
  # tracer: nop
  #
  #       TASK-PID    CPU#    TIMESTAMP  FUNCTION
  #          | |       |          |         |
          java-16035 [000] 13371565.253582: block_rq_insert: 202,16 WS 0 () 550505336 + 88 [java]
          java-16035 [000] 13371565.253582: block_rq_insert: 202,16 WS 0 () 550505424 + 56 [java]
          java-8248  [007] 13371565.278372: block_rq_insert: 202,32 R 0 () 660621368 + 88 [java]
          java-8248  [007] 13371565.278373: block_rq_insert: 202,32 R 0 () 660621456 + 88 [java]
          java-8248  [007] 13371565.278374: block_rq_insert: 202,32 R 0 () 660621544 + 24 [java]
          java-8249  [007] 13371565.311507: block_rq_insert: 202,32 R 0 () 660666416 + 88 [java]
  [...]

从结果看,磁盘IO并没有什么异常。接下来,作者通过-s参数打印IO的调用栈。

  # ./tpoint -s block:block_rq_insert 'rwbs ~ "*R*"' | head -1000
  Tracing block:block_rq_insert. Ctrl-C to end.
          java-8248  [005] 13370789.973826: block_rq_insert: 202,16 R 0 () 1431480000 + 8 [java]
          java-8248  [005] 13370789.973831: <stack trace>
   => blk_flush_plug_list
   => blk_queue_bio
   => generic_make_request.part.50
   => generic_make_request
   => submit_bio
   => do_mpage_readpage
   => mpage_readpages
   => xfs_vm_readpages
   => read_pages
   => __do_page_cache_readahead
   => ra_submit
   => do_sync_mmap_readahead.isra.24
   => filemap_fault
   => __do_fault
   => handle_pte_fault
   => handle_mm_fault
   => do_page_fault
   => page_fault
          java-8248  [005] 13370789.973831: block_rq_insert: 202,16 R 0 () 1431480024 + 32 [java]
          java-8248  [005] 13370789.973836: <stack trace>
   => blk_flush_plug_list
   => blk_queue_bio
   => generic_make_request.part.50
  [...]

结果显示,系统发生了缺页中断,造成系统启动预读机制。作者调查的系统是ubuntu,并且已经开启了2MB大页。这样预读的数据大小就变成了2048KB,而不是默认4KB页下的128KB。尽管上面的预读可能造成磁盘IO过多,但是通过关闭预读,问题并没有缓解。

3. funccount

作者为了更好的了解整个IO调用栈的情况,使用了funccount工具。

  # ./funccount -i 1 submit_bio
  Tracing "submit_bio"... Ctrl-C to end.

  FUNC                              COUNT
  submit_bio                        27881

  FUNC                              COUNT
  submit_bio                        28478
  [...]

随后我们查看filemap_fault()的调用情况:

  # ./funccount -i 1 filemap_fault
  Tracing "filemap_fault"... Ctrl-C to end.

  FUNC                              COUNT
  filemap_fault                      2203

  FUNC                              COUNT
  filemap_fault                      3227
  [...]

从结果可以看到,submit_bio()的调用次数是filemap_fault()调用次数的10倍。

4. funcslower

为了确认目前的调查方向是正确的,作者使用了funcslower来查看filemap_fault()的调用时间:

  # ./funcslower -P filemap_fault 1000
  Tracing "filemap_fault" slower than 1000 us... Ctrl-C to end.
   0)   java-8210    | ! 5133.499 us |  } /* filemap_fault */
   0)   java-8258    | ! 1120.600 us |  } /* filemap_fault */
   0)   java-8235    | ! 6526.470 us |  } /* filemap_fault */
   2)   java-8245    | ! 1458.30 us  |  } /* filemap_fault */
  [...]

看来作者是正确的。

5. funccount (again)

作者再次使用funccount来获取readpage和readpages的调用次数。

  # ./funccount -i 1 '*mpage_readpage*'
  Tracing "*mpage_readpage*"... Ctrl-C to end.

  FUNC                              COUNT
  mpage_readpages                     364
  do_mpage_readpage                122930

  FUNC                              COUNT
  mpage_readpages                     318
  do_mpage_readpage                110344
  [...]

似乎造成问题的原因还是readahead。

6. kprobe

作者为了确认此前对readahead的调整是正确的,使用了Kprobe来查看__do_page_cache_readahead()中的nr_to_read参数的数值。

  # ./kprobe -H 'p:do __do_page_cache_readahead nr_to_read=%cx'
  Tracing kprobe m. Ctrl-C to end.
  # tracer: nop
  #
  #   TASK-PID    CPU#    TIMESTAMP  FUNCTION
  #      | |       |          |         |
      java-8714  [000] 13445354.703793: do: (__do_page_cache_readahead+0x0/0x180) nr_to_read=200
      java-8716  [002] 13445354.819645: do: (__do_page_cache_readahead+0x0/0x180) nr_to_read=200
      java-8734  [001] 13445354.820965: do: (__do_page_cache_readahead+0x0/0x180) nr_to_read=200
      java-8709  [000] 13445354.825280: do: (__do_page_cache_readahead+0x0/0x180) nr_to_read=200
  [...]

从结果看,确实是512个pages,也就是2048KB。

7. funcgraph

为了了解整个调用流程,作者使用funcgraph。

  # ./funcgraph -P filemap_fault | head -1000
   2)   java-8248    |               |  filemap_fault() {
   2)   java-8248    |   0.568 us    |    find_get_page();
   2)   java-8248    |               |    do_sync_mmap_readahead.isra.24() {
   2)   java-8248    |   0.160 us    |      max_sane_readahead();
   2)   java-8248    |               |      ra_submit() {
   2)   java-8248    |               |        __do_page_cache_readahead() {
   2)   java-8248    |               |          __page_cache_alloc() {
   2)   java-8248    |               |            alloc_pages_current() {
   2)   java-8248    |   0.228 us    |              interleave_nodes();
   2)   java-8248    |               |              alloc_page_interleave() {
   2)   java-8248    |               |                __alloc_pages_nodemask() {
   2)   java-8248    |   0.105 us    |                  next_zones_zonelist();
   2)   java-8248    |               |                  get_page_from_freelist() {
   2)   java-8248    |   0.093 us    |                    next_zones_zonelist();
   2)   java-8248    |   0.101 us    |                    zone_watermark_ok();
   2)   java-8248    |               |                    zone_statistics() {
   2)   java-8248    |   0.073 us    |                      __inc_zone_state();
   2)   java-8248    |   0.074 us    |                      __inc_zone_state();
   2)   java-8248    |   1.209 us    |                    }
   2)   java-8248    |   0.142 us    |                    prep_new_page();
   2)   java-8248    |   3.582 us    |                  }
   2)   java-8248    |   4.810 us    |                }
   2)   java-8248    |   0.094 us    |                inc_zone_page_state();
  [...]

8. kprobe (again)

作者再次使用kprobe来查看max_sane_readahead()函数。

  # ./kprobe 'r:m max_sane_readahead $retval'
  Tracing kprobe m. Ctrl-C to end.
      java-8700  [000] 13445377.393895: m: (do_sync_mmap_readahead.isra.24+0x62/0x9c <- \
              max_sane_readahead) arg1=200
      java-8723  [003] 13445377.396362: m: (do_sync_mmap_readahead.isra.24+0x62/0x9c <- \
              max_sane_readahead) arg1=200
      java-8701  [001] 13445377.398216: m: (do_sync_mmap_readahead.isra.24+0x62/0x9c <- \
              max_sane_readahead) arg1=200
      java-8738  [000] 13445377.399793: m: (do_sync_mmap_readahead.isra.24+0x62/0x9c <- \
              max_sane_readahead) arg1=200
      java-8728  [000] 13445377.408529: m: (do_sync_mmap_readahead.isra.24+0x62/0x9c <- \
              max_sane_readahead) arg1=200
  [...]

从结果可以看到此前的调整确实没有生效。于是:

  # ./kprobe -s p:file_ra_state_init
  Tracing kprobe m. Ctrl-C to end.
            kprobe-20331 [002] 13454836.914913: file_ra_state_init: (file_ra_state_init+0x0/0x30)
            kprobe-20331 [002] 13454836.914918: <stack trace>
   => vfs_open
   => nameidata_to_filp
   => do_last
   => path_openat
   => do_filp_open
   => do_sys_open
   => sys_open
   => system_call_fastpath
            kprobe-20332 [007] 13454836.915191: file_ra_state_init: (file_ra_state_init+0x0/0x30)
            kprobe-20332 [007] 13454836.915194: <stack trace>
   => vfs_open
   => nameidata_to_filp
  [...]

从结果看,作者需要重启Cassandra来让调整生效。

  # ./kprobe 'r:m max_sane_readahead $retval'
  Tracing kprobe m. Ctrl-C to end.
      java-11918 [007] 13445663.126999: m: (ondemand_readahead+0x3b/0x230 <- \
               max_sane_readahead) arg1=80
      java-11918 [007] 13445663.128329: m: (ondemand_readahead+0x3b/0x230 <- \
              max_sane_readahead) arg1=80
      java-11918 [007] 13445663.129795: m: (ondemand_readahead+0x3b/0x230 <- \
              max_sane_readahead) arg1=80
      java-11918 [007] 13445663.131164: m: (ondemand_readahead+0x3b/0x230 <- \
              max_sane_readahead) arg1=80
  [...]

成功!

Conclusion

上述所有工具都是借助ftrace和相关功能来实现的。这些工具仅仅是ftrace的前端实现。读者可以参考相关的文档来了解ftrace的实现以及相关的信息。

作者目前十分希望eBPF能够进入主线内核,这样上述的很多工具都可以得到很大的简化。

Error handling for I/O memory management units

2014 kernel summit有一个议题是讨论如何处理IOMMU产生的错误信号。IOMMU的作用是在设备和CPU之间做内存地址的转换,这样外围设备不仅可以看见简化的的地址空间,也可以让一些实际分散的缓存看起来连续,同时限制设备的地址范围。虽然现在并不是所有的系统都有IOMMU,但是一个缓慢的趋势是使更多的系统都包含。

David Woodhouse指出,在IOMMU上下文下,没有一个标准的方法来反馈错误,IOMMU出错时驱动无法简单地得到通知。目前只有特定于具体架构的处理方案,PowerPC下有“extended error handling” (EEH),但是“只有Ben Herrenschmidt懂”,PCI子系统也有类似的错误处理机制。但是内核需要的是一套一致的处理方法来将错误从IOMMU反馈给驱动,而不管它们是怎么连接到系统的。同时也需要有一个标准的机制来关闭出错的设备从而防止大量的中断搞垮整个系统。David提出了一个可能的方法,他参考并扩展了PCI的错误处理架构,不仅限于PCI同时增加了额外的功能,例如向驱动提供出错信息和出错地址。

Ben指出反馈一个错误的具体信息并不是那么容易,对错误的处理经常设计到硬件,要隔离开出错IOMMU后的整个设备,但是这样就没有办法传递任何信息。驱动可以要求获得错误通知,也可以尝试恢复出错设备,但是如果没有驱动支持,默认的处理就是模拟设备的拔掉和重插事件。David指出,对于一些设备特别是图像适配器而言,用户并不希望出错时停止整个设备,一条命令流可以出错并被停止,但是其他并行的流应当可以继续,因此需要一个更为轻量的处理。

Josh Triplett询问出错时通常的反应是什么,恢复路径会做一些尝试还是放弃并重置整个设备?对于多数设备而言,重置是一种足够的处理,但是就像刚提到的,图像设备有些不同,同样网络设备也需要一种更为温和的错误处理机制。但是David认为在大部分情况下,整体隔离并重置设备是一个好的方法。

Andi Kleen问这种错误处理代码如何被测试,在没有全面测试的情况下,这些代码很可能有问题。David说让设备尝试对一个错误地址进行DMA是比较容易做到的,而且也可以通过注入错误的方法。但是Ben指出即使有这些工具,EEH错误处理也依然可能频繁出问题。
David问ARM是怎么做的,Will Deacon说PCI之外并没有真正的标准,他也没见过ARM里能很好的处理这些错误。他同时指出在hypervisor下这个问题会更复杂,一个IOMMU可能向guest提供受限的DMA访问,向guest暴露潜在的IOMMU错误,guest可能会隔离出错设备,从而让host比较迷惑。

Arnd Bergmann认为任何错误处理方案都不应只局限于PCI设备,因为在ARM里根本就没有PCI总线。David说PCI现有的错误处理架构是一个很好的起点,可以把它做的更为通用。虽然有些PCI特定的概念(如PCI设备)需要被保留,但是大部分都可以移至struct device结构并通用化。目前这个方法没有收到反对的声音,David会开始去实现。

Kernel performance regressions / 内核性能回归缺陷

内核性能回归缺陷差不多是折磨 Linux Kernel 的最让人讨厌的问题了。性能回归缺陷通常在人们不知情的情况下被引入,一段时间(可能长达数年)之后,一些用户尝试升级内核到新版本,结果却发现上面跑的东西变得相当之慢。到那时候,最原始引入的那个性能回归缺陷可能会变得难以追溯。鉴于此,讨论性能回归问题成为了内核峰会的常驻话题,今年也不例外。和以往有所不同的是,在避免制造新的回归缺陷这件事上,内核社区做得远比以前好多了。

Chris Mason 的开场白就提到,在他的公司(Facebook),Linux 无处不在,且跑得比 FreeBSD 快。Facebook 打算让工作集驻留在内存中,这样工作负载就受限于 CPU、内存和网络。性能在当中是重要的一个考量点,所以公司制定维护了广泛的用以衡量系统和应用性能的指标。

Facebook 大多数线上生产系统运行的是 3.10 稳定版内核,加上额外的约 75 个补丁(译者:3.10! Facebook 对内核新技术的掌控力真是令人膜拜,对阿里线上系统有一定参考意义)。也有系统跑老内核,但是 Facebook 内核组在慢慢推动他们改用新内核,一个推动手段便是拒绝为老内核修 BUG。

当 Facebook 第一次开始用 3.10 时,内核开发者们一如往常地担心性能回归问题。最终发现这个版本的内核问题比预期少得多,当然还是冒出不少问题的。其中有一个 IPv6 协议栈性能下降 10% 的问题,不过在 Chris 去追踪这个问题之前 upstream 就已经把这问题修了。除此之外,还有一些 CPU 频率 governor 上的问题,CPU 会跑在不合适的低频率上,制造不必要的延迟。所以 Facebook 当前使用 基于 ACPI 的 CPU 频率 governor 策略(译者:这个也可以给阿里线上系统提供参考),同时也在尝试找出让新的 intel_pstate 特性相关代码正确工作的方式。还有一个问题是在新内核中更频现的 futex bucket lock 争锁问题,在 Facebook 内部,Chris 已经通过把一些明显的代码移出临界区的方式来解决这个问题。Rik van Riel 建议尝试一下增加 buckets 数量对解决这个问题应该也有助益。

那么,Chris 在 3.16 内核上尝试跑 Facebook 的负载效果如何呢?他反馈说得到的数据相当乐观。3.16 内核上跑的压力得到了 2.5% 的 QPS 提升以及 5% 的延迟降低。不过整体负载跑完的时间延长了大概 4.5% 之多。当然这个是在 Chris 打了他修复 futex bucket lock 的补丁之后的结果,否则系统时间半数耗在了争锁上,整个系统几乎不可用。

回到他们公司 3.10 的迁移,Chris 重复了他之前 session 里的一个点:stable tree 里的补丁引起的回归缺陷数为0. (对于其他问题) 他们碰到过一些 OOM-killer 在杀基于 POSIX 线程的程序时把系统锁死的情况,这个问题在 upstream 也已经被修复了。还有一个问题是,在一个文件上整合 direct I/O 和 buffered I/O 会造成数据污染,填0页会留在 page cache 中。Chris 表示对现有测试没有发现这个问题感到很惊讶,特别是这种问题居然没有引起竞争条件。他打算去看看 xfstests 测试套件以发现为何这个问题没被捕捉到。

不过总体来说,他表示迁移到 3.10 是历次内核迁移中最容易的一次。

主话题之外的发散环节,Arnd Bergmann 询问了关于 Facebook 额外加的那约 75 个补丁的情况。Chris 回答说,一个显著的点是其中有一些 patch 通过移动一些系统调用到 VDSO 区域来加速任务抢得一个线程的 CPU 的使用权。他说这批补丁很快应该会进 upstream,不过目前还要修点别的。另一个点是允许内存管理系统在发生缺页时在内存映射区(memory-mapped region)避免产生0页。他解释了一下为什么这个补丁难以进 upstream 的原因。还有一个降低了由 /proc 接口导出 IPv6 路由表的数量。Facebook 整个内网都是用 IPv6 的,所以路由表很大。

回到性能问题,正在为 SLES 12 发布准备稳定化 3.12 内核的 Jan Kara 同意最近内核升级迁移变得容易了。他最大的顾虑是新内核中的一些为优化负载开销做出的行为变化。只要这些变化没让你机器速度变慢,就不会是什么糟糕的变化。但是他还是重点提出了几个类似的例子,比如 CFQ I/O 调度模式,还有 NUMA 负载均衡相关的工作。

Andi Kleen 问 Chris 为何他觉得内核迁移升级变得越来越方便了,不过毕竟内核进程是没有慢下来的。James Bottomley 附和了这个问题,他疑惑我们好几年没有跟踪回归缺陷而为何缺陷数量会下降。看起来得从几个方面来回答这个问题,不过关键因素很容易阐释:相比以前现在有更多性能测试在进行,如果性能问题被引入了,在进入 stable kernel 之前就更可能被发现并修复了。

Chris 补充道,Red Hat 和 SuSE 最近都过了一遍他们企业发行版的稳定化周期,修 BUG 显然有助于产品的稳定化。Mel Gorman 补充道,新硬件平台已经引入一批硬件厂商来支持。他们致力于提速系统速度,但是所有人都从中受益了。尽管如此,他还是警告说,现在的好条件可能只是暂时,绝非一成不变。

最后,Chris 在结束语中总结道,3.10 是目前 Facebook 用过的最快的内核,或许那些长期受困于新内核引入新的性能回归缺陷问题的开发人员听到这个消息能欢欣鼓舞吧。

Kernel self tests

2014 内核峰会上 Shuan Khan 在她的 session 开场白中说,她时不时会帮着做一些稳定内核发布版本的测试过程,这类测试多数都是类似“编译-构建-启动”类别的测试,不过如果测得更全面彻底当然会更好。如果有一个简单的健全测试(sanity test)集可供开发者运行,或许会有更多的回归缺陷还没影响用户便能被暴露出来。基于上述目的,她的工作是在内核构建系统中添加了一个新的 make 目标项,叫 "kselftest"。

现在这个功能有一个最小化的测试集,之后她将会丰富其中的测试项。她说:“我们在里面已经加了许多测试代码”,如果能多用善用这个测试功能是最好。不过她还是打算仔细决策哪些测试应该进 kselftest,因为这个测试功能的目标是快速运行内核测试,这是一个基本的健全性测试,而非全面覆盖的压力测试。(译者:为什么不直接用 LTP 的 sanity test set? 坑爹啊)

Ted Ts'o 问道,何为“快速”,如何界定其范畴?Shuah 回答说,她不知道如何界定,当前的测试集跑完不会超过10分钟,随着测试增加,时间很可能会相应增加,但是这个时间不应该无限制增长到一个开发者都不愿再跑的值。Mel Gorman (译者:这哥们开发了一个 memtests 工具,还是挺好用的,不过包含了很多大型的压力测试集) 指出,他自己的测试如果跑完整测试,大概要花个13天左右时间,这应该算是超出了“快速”的范畴了吧?(译者:Mel 你是来卖萌么,你跑个mm-tree花个13天时间看你还能好好干活不) Paul McKenney 补充说,为 read-copy-update 子系统做的 torture-test 测试套件,完整运行下来会超过 6 小时。听了业内大家提供的例子之后,Shuah 认为她能接受的目标差不多在 15 到 20 分钟左右。(译者:跑 LTP 呀跑 LTP 呀!为啥峰会上没有 LTP 的开发者去,让我去呀!摔!)

Josh Triplett 表达了他对于内核树自带测试集的忧虑。如果测试代码自己在变,当测试失败的时候就挺难通过 bisect 来定位问题所在了,因为不知道到底是测试代码出问题,还是内核代码出问题。他说或许不把测试代码和内核代码放一起会更好。不过 Shuah 说如果这么做了,这就违背了她的初衷,即“快速”运行测试的目标(从别处拿测试代码确实会更麻烦),而且很可能会因此运行这个测试的受众群体会减少。

Darren Hart 问这个测试集是否只关注功能测试,还是说性能测试也会包括在内。 Shuah 回答说,这没有规定,如果一个测试跑得快速而有效,不管是什么类型的测试都可以放进去。那驱动测试呢?这个可能会难一点,不过也许可以通过模拟真实硬件、BUG 场景和所有的外部环境来实现测试。

Grant Likely 说是否有一个标准化的输出格式以便于生成统一的报告。由此又引发了一系列关于测试框架和测试工具的衍生讨论。大家还建议与其大家讨论一致选择一个合适的框架,不如 Shuah 就从成熟框架里挑一个。不过 Christoph Hellwig 指出 xfstests 测试套件也没有一个标准框架,里面的测试跑完之后只是生成一个和基准输出不一致的 diff,这使得新测试能够抛开测试框架和测试工具的限制,更自由地添加到测试套件中。Chris Mason 同意说这种策略才是做事的“唯一可行之法”。

最后 Shuah 再次重复,她想要更多的测试能加入到 kselftest 里来,并且欢迎大家献计献策如何把这个测试机制给运作起来。

Two sessions on review

如其他自由软件项目一样,Linux Kernel 也有一个很基本的问题:得不到足够的 review. 有一些开发领域比其他领域更需要 review, 首当其冲则是用户空间的二进制接口的创建,因为这些接口必须得维护很长一段时间。不过这个问题远非 ABI 定义这么简单。2014 内核峰会上有两个 session 就 review 问题以及如何改进的话题发起了讨论。

ABI 的变化

由 Michael Kerrisk 和 Andy Lutomirski 发起了第一个 session,他们主要担忧的是 ABI 的问题。Michael 开场的时候就说,无论他什么时候去测一个新的系统调用,他有一半时间都能发现 BUG。Christoph Hellwig 补充说“一半”这个数表示他测得还不够深。由此引出的观点便是:stable release 的代码很明显没有经过足够多的 review 和测试,事实上很多时候根本没有经过一丁点儿测试。Michael 举例说,recvmmsg() 这个系统调用在第一版里,有一个 timeout 值,结果这个值其实设置得完全不合理。

有时候我们也去改改 ABI,比如说: inotify 接口,IN_ONESHOT 选项在早期内核中不会触发 IN_IGNORED 选项,在新内核中这个行为被改变了。

他说新的 ABI 没有 spec 规范,是造成 ABI 难以 review 和 测试的一个事实。缺少规范还引起一些细微的代码实现的问题。Michael 仍旧以 inotify 为例,谈论了跟踪文件在目录之间移动的问题时的困难,细节在这篇文章里。大多数新的系统调用都没有 man page 和足够的 review 者,还引发了关于设计的质疑, Michael 说 O_TMPFILE 选项提供了一个很好的例子:且不论它的其他问题,至少这个选项从设计上来说,它的功能实现足以把它放到一个单独的系统调用中。

Andy 补充说,spec 规范是个好事,不过对一个新的 ABI 做单元测试也是一个好事。从这点出发,Peter Zijlstra 问相比内核树自己来说,Linux Test Project, LTP 是否用来做单元测试更合适。(译者 & LTP 维护者:是的!absolutely!) 不过有人顾虑说 LTP 测的东西远不止系统调用,还有的开发者嫌 LTP 整个测试工具不够轻量,装起来也麻烦。

Ted Ts'o 观察到开发者手头必须有他们开发的特性相对应的测试(代码),要不然他们就不会那么勤勉地去做测试。Dave Airlie 说这样看来在内核树里放测试代码是个好事。他又建议或许社区应该坚持新系统调用的准入制度里必须得有 man page 这一条,否则不能进主线。Michael 回应说以前这么试过,不过没成功。不过 3.17 加进去的四个新系统调用都有 man page. Ben Herrenschmidt 之处系统调用只是冰山一角。内核 ABI 还有其他方面,比如 ioctl() 调用,sysfs, netlink 以及其他。

之后有一些重复的话题,比如改了 kABI 的补丁必须得 cc linux-api 邮件列表一份,又或许 cc 给对应的邮件列表这事应该是对应子系统的维护者的职责。Josh Triplett 建议说 get_maintainer 脚本可以改一改以实现自动 cc 对应邮件列表的功能,不过这个观点没得到热切的赞同,这个脚本可能会在发补丁邮件的时候加上很多不相关的收件人,内核开发者不太喜欢这个功能。

Peter Anvin 声称 linux-api 这个邮件列表不工作了,他说或许把 man page 合并到内核树里会更好,这样代码和文档就可以一起发补丁。Michael 回应说这个观点以前提过 。这么做好处坏处兼具,坏处就是 man page 里的很多内容都不是描述内核接口的,它们是为应用开发者准备的文档,而不是内核开发者,所以 man page 里有一堆 glibc 的接口,以及其他东西。

在一些重复的话题,比如 系统调用没有 man page 不准进内核,改了 kABI 要发送补丁抄送 linux-api 列表等讨论声中,这个 session 结束了。开发者们都在努力改善现在的情况,只是目前来看还是没什么好的解决方案。

获得更多的 review

James Bottomley 讨论了一个更宽泛的话题,patch review. 他问道我们怎么才能增加进 mainline 的代码的 review 数量?有没有什么关于改进内核 review 流程的新鲜观点?这个 session 没有讨论出确切答案,不过确实涵盖了一些 review 操作机制上的内容。

Peter Zijlstra 说他一直收到不少带有假的 Reviewed-by 标签的补丁。他说这里的“假标签”是指代码其实没有经过深度 review,而有时候只是跟补丁作者同个公司的同事(有可能是随意)打的 reviewed-by 标签。(译者:我记得我也干过这事儿) James 说要是没有靠谱的注释和 reviewed-by 标签一起,他会自动忽略邮件里的这些 reviewed-by 标签。

不过 Darren Hart 说,这些标签可能是在补丁发出来之前已经经过内部 review 了,所以就不详细列 review 说明了。至少在有些公司这类内部 review 是很严肃认真的,所以列出那些 reviewed-by 标签还是一件靠谱的事情。Dave 反问为什么 review 的过程要内部进行,而不搬到社区来公开呢?(译者:这个有点吹毛求疵了啊) Darren 回答说,差不多对于任何项目来说,面向公众开放之前做小范围检查都是一件再自然不过的事情了。

James 补充说,他常常怀疑同个厂商的 review,不过它们当然不是说无效,只是该不该信任特定的 review 者这个大有关系。

他又问了一个泛泛的问题,一个补丁多大的改动值得让人去 review 一次 reviewed-by 标签的可靠性?一个空白格的变化当然不需要重新 review,不过一堆补丁做了一堆改动就有必要了。会场上关于怎么划分界限出现了一些不同意见,最后达成一致,这个界限由子系统的维护者来做主。

这个 session 的最后,Linus 大神冒泡说,Reviewed-by, Acked-by, 还有 Cc 标签其实都是一个意思:如果这个补丁出问题了,后续的报告中应该把标签里的那个名字抄上。有些开发者用一类标签,其他的人用其他的标签,不过它们本质上没什么区别。在一些反对 Linus 大神的这个观点的讨论声中,这个 session 结束了,也没有人就如何让内核代码得到更多 review 这一问题提出新的观点。

One year of Coverity work

去年 Dave Jones 在参与一个名为 Coverity scanner 的项目,旨在发现并修复潜在的内核 BUG。和许多其他开发者类似,他也担忧随着时间推移,BUG 问题越来越糟糕,随着项目代码循序渐进全部进入内核之后,缺陷必然随之而来。不过最后发现实际情况比想象的要好一丁点儿。

Dave 提供给 Coverity 的是一个“厨房水槽式构建”的东西,几乎把所有的选项都打开了。这导致最后他编出来的内核有 6955 个选项之多,在这个内核跑整个扫描程序花了好几个小时。他让 Coverity 持续跑着,最后公司给他提供了专用的服务器让他得以一天能跑上两三次扫描。

Dave 扫了一遍 3.11 内核,他总结出一个“缺陷密度”值,即每千行代码里的缺陷数量。3.11 内核的缺陷密度是 0.68 —— 略高于公司的“开源平均值” 0.59。各内核版本的缺陷密度值如下:

内核版本    缺陷密度
3.11         0.68
3.12         0.62
3.13         0.59
3.14         0.55
3.15         0.55
3.16         0.53

当前在 3.17 合并窗口关闭之后的缺陷密度是 0.52。所以情况随着时间推移其实在持续变好。

他总结了一个内核各领域的缺陷排名, 在排名顶端的是 staging 树,他说这个情况是好的。如果其他子系统比 staging 树还糟糕,那表示这个子系统真的有问题了。其次的条目是驱动树,这一点毫不意外,因为它的代码量最大。

他说,用了 Coverity 之后暴露出来的最大问题,是死代码。有时候程序里的告警信息其实是有用的,不过并非总是正确。比如说,有些代码在配置选项不同时可能会路径不可达。列表里排名第二的是检查返回值的失败情况,其中有相当大一部分并非真正的 BUG,而是分支环境的不同所致。排名第三的条目是指针被去引用(dereferenced)之后检查出来的空指针情况,显然这是一个糟糕的消息,需要被修复。

同样可怕的问题是静态缓冲区溢出错误。这个问题会变得很危险,尽管情况在逐步改善,但是还是存有很多这样的问题。它们也并不总是 BUG,举个例子,网络层会在 skb 结构体里玩这样的小把戏使得缓冲区看起来溢出了,但是事实上没有溢出。此外 Coverity 还标注了一大堆资源泄露,这也同样不奇怪,它们是经常发生的错误。

有大量其他类型的潜在错误,比如“无效声明”,往往是无害并且是故意这么做的。比如:变量给自己赋值没什么效果,不过这可以达到屏蔽过去的编译器的“possibly uninitialized”告警信息的效果。其他的,比如使用用户控件未经检查的数据,可能会更严重,这个例子中,功能检查背后往往潜伏着非法使用的情况,并且不容易马上发现。

Dave 说,好消息是现在内核中只有不到50个 "use-after-free" 错误了。另外一些其他的“哑巴”错误也几乎从内核中消灭殆尽了。Dave 说他一直在关注那些错误,一旦有新的错误冒出来,他会去尝试快速修复。

Ted Ts'o 问 Coverity 标出来的问题中有多少是真实的 BUG, Dave 的感觉是只有一小部分是严重的 BUG。他说如果有人对安全问题感兴趣,可以跑 Trinity 测试,它能比 Coverity 发现更多的问题。

那么 ARM 的覆盖率呢?商业版的 Coverity 产品有这个功能,不过免费的开源社区版本没有。Dave 说如果一段代码能在 x86 编译器上编译出来,Coverity 就会去扫描,所以他在考虑类似把 ARM 树中的所有内联汇编代码给注释掉然后让 Coverity 在上面运行的做法。不过这应该是未来的一个项目了。

如果其他开发者想要帮忙修复 Coverity 报出来的问题,可以看一眼扫描结果。方法是先要登陆 [5] 然后注册 "Linux" 项目,然后跟 Dave 打个招呼,他会授权查看结果。群策群力,更多人关注扫描结果从长期来看对内核肯定大有益处。

Axiomatic validation of memory barriers and atomic instructions

相对于内存屏障来说,原子操作的必要性和正确性相对更容易理解和使用。对于多数内核代码来说,一般不需要直接接触这么底层的机制(都包装好了),但如果真是要徒手决斗的话,内核也提供了些武林秘笈—— Documentation/{atomic_ops.txt, memory_barries.txt}。 除了秘笈,本文还透露了两个大杀器。

ppcmem/armmem

这两个工具LWN在2011年就介绍过了,它对于内核代码的小型临界区的并发行为验证很有帮助。举个例子,注释内嵌了:

PPC IRIW.litmus                                                       # 表示这是一个PPC平台上的测试,测试名字叫作IRIW.litmus
""                                                                           # 测试的别名,一般是空的
(* Traditional IRIW. *)                                              #  测试的注释
{
0:r1=1; 0:r2=x;                                                       # 处理器0,寄存器r0初始化为1,  寄存器r2 初始化为变量x的地址。
1:r1=1; 1:r4=y;                                                       # 以上类推
2:r2=x; 2:r4=y;
3:r2=x; 3:r4=y;
}
                                                                             # 变量x和y的值,默认初始化为0

 P0           | P1           | P2                 | P3                 ;              # 表示有4个进程,分别名为P0, P1, P2, P3
 stw r1,0(r2) | stw r1,0(r4) | lwz r3,0(r2)       | lwz r3,0(r4)       ;   # P0执行 r1 = *(&x), P1执行:r1=*(&y), P2执行:r3 = *(&x), P3执行:r3=*(&y)
              |              | sync               | sync               ;                 # P2和P3执行 memory barrier
              |              | lwz r5,0(r4)       | lwz r5,0(r2)       ;             # P2执行:r5 = *(&y) , P3执行r5 = *(&x)
exists                                                                                     # 表示后面一行是一个assert表达式。
(2:r3=1 /\ 2:r5=0 /\ 3:r3=1 /\ 3:r5=0)                                   # 即表示P2和P3读到了不一致的x和y的值。

使用以上输入运行“完全状态空间测试工具”ppcmem,结果会显示以上断言是不成立的。事实上,这个工具对调试内核非常有用,它在过去的几年里也的确协助解决了几个内核问题。以上测试需要花14CPU小时和10GB内存,与手工分析需要的以月或者周计的时间相比,这已经是巨大进步了,但仍然有两个牛烘烘的内核黑客连等这N个小时的耐心也木有。

herd

ppmmem的两个作者Jade Alglave、 Luc Maranget和Michael Tautschnig最近发了篇论文:http://diy.inria.fr/herd/herding-cats-color.pdf

他们修改了ppcmem/armmem,原设计会搜索全部可能的状态空间,现在则使用基于事件的公理方法(an axiomatic, event-based approach)。这种方法通过承认偏序关系避免了对总体上有序的大量等价关系的搜索,从而极大降低了算法的时间复杂度。held会构造出候选的执行流组合,再根据底层内存模型去掉其中不合理的组合。

如果使用held执行上面的测试,也可以得到相同的结果但只花了16毫秒,大约是3,000,000x的提升。虽然这个简单测试的结果不具代表性,但论文给出的结果也是非常乐观的,一般在45,000x左右。

如果将上面sync指令换成轻量级内存屏障指令lwsync,再运行一下测试,就会得到发现断言中的条件发生了!

Test IRIW Allowed
States 16
2:r3=0; 2:r5=0; 3:r3=0; 3:r5=0;
2:r3=0; 2:r5=0; 3:r3=0; 3:r5=1;
2:r3=0; 2:r5=0; 3:r3=1; 3:r5=0;
2:r3=0; 2:r5=0; 3:r3=1; 3:r5=1;
2:r3=0; 2:r5=1; 3:r3=0; 3:r5=0;
2:r3=0; 2:r5=1; 3:r3=0; 3:r5=1;
2:r3=0; 2:r5=1; 3:r3=1; 3:r5=0;
2:r3=0; 2:r5=1; 3:r3=1; 3:r5=1;
2:r3=1; 2:r5=0; 3:r3=0; 3:r5=0;
2:r3=1; 2:r5=0; 3:r3=0; 3:r5=1;
2:r3=1; 2:r5=0; 3:r3=1; 3:r5=0;
2:r3=1; 2:r5=0; 3:r3=1; 3:r5=1;
2:r3=1; 2:r5=1; 3:r3=0; 3:r5=0;
2:r3=1; 2:r5=1; 3:r3=0; 3:r5=1;
2:r3=1; 2:r5=1; 3:r3=1; 3:r5=0;
2:r3=1; 2:r5=1; 3:r3=1; 3:r5=1;
Ok
Witnesses
Positive: 1 Negative: 15
Condition exists (2:r3=1 /\ 2:r5=0 /\ 3:r3=1 /\ 3:r5=0)
Observation IRIW Sometimes 1 15                      # 注意这行,“Sometimes”发生呐!
Hash=a886ed63a2b5bddbf5ddc43195a85db7

你一定在想,如果我能知道是什么原因就好了,好的,herd可以再帮你一把,它可以生成个状态图:

  • fr: From read, 即WAR(即write after read for same address)。
  • GHB: Global happens before,基于内存屏障指令或者数据流依赖的效果。
  • po: Program order.
  • propbase: 基于写传播关系(store-propagation relationship)
  • rf: Read from, 即RAW。

更爽的是,held工具是支持x86的呢!!好的,我们跳过另一个例子和对held的scalability的测试的讨论,该是浇凉水的时候了。

Limitations and summary

限制

  • 无论是ARM、PPC或者Intel都没有官方宣称认可held/ppcmem/armmem结果的正确性。毕竟这些工具都还在开发中!
  • 然而,至少曾经有过一例硬件与这些工具的运行结果不一致的现象,后来被确认是硬件bug,毕竟处理器也都还在开发中!
  • ppcmem/armmem和held的结果有些也不完全一致。虽然论文中证明两者应该是等价的,但还是可以构造出让两者不一致的反例,而且是held更加保守的结果。
  • held的时间复杂度仍然是指数级,虽然已经比ppcmem/armmem快了许多。
  • 这些工具处理复杂数据结构时都还不够方便,只能通过一些简单指令模拟。
  • 这些工具不能处理memory mapped-IO和设备寄存器。
  • 形式方法是不能完全替代测试嘀,正如Donald Knuth所言,“Beware of bugs in the above code; I have only proved it correct, not tried it.”

The power-aware scheduling miniconference

By Jonathan Corbet August 27, 2014

Kernel Summit 2014的一个miniconference讨论了power-aware scheduling。虽然这个问题离最终完美解决方案出炉还有相当长的路要走,但至少目前的进展都在朝这个方向努力。

2013年的会议提出需要一组标准和benchmark,用于评估提交的patch。今年Linaro开发的两个工具已经可以使用。一个是用于运行特定的调度算法,同时观察结果。目前有两个可用的负载,Android系统上的音乐播放和一个web浏览器负载。

另外一个工具是"idlestat"。这个工具运行的数据来源于ftrace抓取的运行系统上进入sleep状态和在sleep状态持续时间信息,通过给定一个power模型描述处理器各个状态下的能耗情况,该工具可以评估出这次运行的总能耗情况。

这些工具是一个好的开始,但也仅仅是一个开始,Morten这样说到。现在的工作仅仅局限在CPU能耗,其他的如gpu及外部设备的能耗目前看都是非常难解决的问题。

内核添加的load tracking对调度很有用,power-aware scheduling也需要对CPU利用率进行追踪,以让调度器更好的评估每个process将需要多少CPU时间,调度器依据此做出更加好的调度决策。Load tracking目前并没有考虑CPU频率变化的情况,这是个需要fix的问题。下一步的目标是开始让调度器自己控制CPU频率的变化,而不是对CPU频率调节器的动作作出相应反应。

节能调度之前已经有一些简单的技术(比如small-task packing),但只是在某些特定的场景下有意义,并不通用。一个可行方案是采用启发式算法,这是目前最可能的一种比较完备的解决方案,但是这个实现会很痛苦。

供选择的一个方式是给scheduler一个CPU平台模型,对任何给定配置的处理器,这个模型能够评估能耗将会是多少。这样调度器就能够来回调整处理器,同时评估能耗情况。平台模型必须由architecture-specific代码提供,基于处理器空闲和睡眠状态实现。这块已经有一组patch,目前看没有很大的反对意见。

未来的任务之一,是使调度器感知CPU空闲状态,如频率调整。另外一个任务是,虚拟化情况下的能耗管理。Guest系统也会希望能够运行在节能状态,但这个工作主要是在host里面,Guest可以将这种需求传给hypervisor,而是否响应取决于host方面。

在Morten报告的末尾,一位开发者问power-aware scheduling是否会考虑thermal aware。Morten表示这个不会在这会做,power model现在需要保持尽量简单,当前这块的复杂度已经够开发者处理。当这个简单问题的解决方式已经可以预见了,大家才会开始考虑其他的比如温度管理。

A report from the networking miniconference

2014 内核峰会的第二天,包含一个网络子系统开发者的小型会议,作者没能参加,但是确实听了 Dave Miller 的关于相关 topic 的简单总结。下面的报告不可能很完整了,速记很难的,但是,幸运的是,它 cover 到了一些关键点。

Dave 快速总结了一些 topic, 其中一个是 Stream Control Transmission Protocol (SCTP),大体上,他是这样说的:网络层有很多高度抽象的代码被 share 在不同的协议实现上, 但是对 SCTP 来说很难 share, 由于 associations,这导致了大量的重复的代码存在于 SCTP 子系统,现在看起来有新的办法来 rework SCTP 实现,并从很大意义上把代码和网络子系统统一。

网络子系统代码中,一个长期存在还没达到最优的地方是启动的时候和协议(比如:TCP)相关的大哈希表,这些表占用了很多内存,其实没有必要那么大,但是还没有办法知道系统启动的时候,这些表到底适当的大小是多大。现在,网络层,有在 RCU 保护下的可变大小的哈希表了,这些表可以根据需要重新分配,因此,在整个的系统生命周期中,不再有必要保持那些大表了。

Dave 表示,extended Berkeley Packet Filter (eBPF) 相关的工作, 仍然有些争议,最大的问题,eBPF 开发者 Alexei Starovoitov 有很大热情,但是 reviewers 没能跟的上节奏。因此 Dave 说,他会开始把那些 patch 打回去,让 Alexei 慢点。

有一些 concerns 对于给 eBPF 增加访问一些通用的指针的能力,另外,对于可能给 eBPF 虚拟机增加 backward branches 预测也让一些嗯担忧。没有人不同意 Alexei 的主要目的:在内核中创建一个通用的虚拟机[译:给别的 module 也用上,后面提到 nftables]。但是比较重要的是不能失去 eBPF 提供的执行保护环境;让 eBPF 成为 kernel 里面的安全漏洞可不是什么好事。因此很有必要有一些更加严格的指针访问的规则,做很很多检查,Dave 说。

Ted Ts'o 建议 SystemTap 开发者应该看看 eBPF,因为 eBPF 可能在创建特殊目的的模块上面提供了一个更好的参考。但是 James Bottomley 回应说,SystemTap 需要更加通用的引擎,因为它要访问内核中跟多的地方,而 eBPF 不会显式这么做。

Dave 然后介绍了 Pablo Neira Ayuso 在最近法国举办的 Netfilter workshop 的报告,已经有很多工作放在删除连接跟踪代码里面的中心锁这,让代码更加有效率。当 traffic 由很多小包组成的时候,在网口硬件全速工作的情况下,找出协议栈目前在哪脱了后腿的工作目前看应该也已经在进行中了。

对于英特尔的 Data Plane Development Kit (DPDK),也有一些兴趣,这是一种把包直接推送到用户空间的机制。一些 benchmark 的数据不错,但是在内核中也有一些相似的方法来获得相似的性能,Dave 说,他提到了 receive polling ,性能也不错,而且也可以让 network stack 全面工作。

对 nftables 也有一些讨论,这个内核虚拟机试图最终取代 iptables, 但是在 iptables 的兼容性方面有很多工作要做,需要让网络管理员尽可能不修改上层接口代码或者脚本。nftables 取代 iptables 不会是一个很快的过程,在内核接口层上面,这两者不兼容,在 workshop 里面也有说用 eBPF 的 vm 来代替 nftables 虚拟机,有一个主要问题:nftables 允许部分取代防火墙规则,而 eBPF 目前不会。

之后,Dave 提到 encapsulation offloading, 无论什么时候加密数据包,然后通过其他的传输协议隧道出去,你必须的考虑在哪里做 checksum ,流分发是怎样管理的。这里有个很大问题,udp 加密无处不在,因为网卡可以很容易的 checksum udp 包,但是流操作却不容易,网络开发者想要避免加密流的深度的包检查;最后,他们用了一个 trick 的办法,使用源端口号来标示并且操作流。其他的 tricks 在各个层中管理 checksumming, 叫做 “remote checksum offload”, 用接受端的内部 checksumming, 限制出包 checksumming。 [译:听起来蛮 tricky, 这里缺乏细节,读者自己 google 看]

一个普遍的对协议栈的兴趣点是打包发送。网卡驱动设计是每次只发送一个包,而不知道后面是否有大量的包来,而概率上,常常是会的。如果驱动知道,大量包要来,它就会延迟发送,很大程度减少传输消耗,这个计划是去增加一个 “transmit flush” 操作,如果驱动提供那个功能,再接收到一个包要传送的时候,就不会立刻启动硬件发包,而是延迟到直到 flush 操作调用。也有些 concern, 如:延迟发送会让硬件 wire 变 idle, 不过也是可以解决的。

无线网络

很多被讨论的话题中,有一个是关于在 AC (access point) 上面做 arp proxying, 来节能。这样 arp request 可以被 AC 直接回复,不用到目的端系统。这个已经被同意在网桥代码中做,网桥本来就是干这个的。

一个比较大的问题是 network function offloading, 网桥的硬件可以直接管理转发而不用 cpu 的介入。这个是很好的功能,但是有一个问题:这些都只能被驱动或者用户态(vendor-specific) 的二进制代码所管理,这样 OpenWRT 可能会疯掉。一些工作已经被做了来 给 netlink 增加扩展接口,来让 vendor follow 这些 generic 的工具和接口来开发,一个 qemu-based 的设备正在被开发来做测试。

Wireless 的 maintainer John Linville 站起来讨论了一些无线峰会中的问题,无线开发者正在面对的一个问题是 Android 仍然在用 "wireless extensions" ABI,这个已经很多年就被不建议使用了。看起来对无线开发者来说很容易增加 vendor-specific 的操作扩展,因此,vendors 目前正在这么做。从回应上来说,无线开发者已经增加了很多选项来让现有接口更加弹性,但是这个工作还没有完全传达到 vendor 那里。现在的计划是让 google 鼓励 vendors 不要使用 wireless extensions。

已经有一些工作来把 firmware dump tool 放在合适的位置,经过讨论之后,开发者的想法是用 sysfs 来获得相关的数据。

最后,John 表示,当无线的 maintainer 他有点累了,但是他还没有找到更好的 candidate。在无线协议栈,有很多有天赋的开发者,但是大多数都是为硬件 vendor 工作的。而这些硬件 vendor 不太热衷于让这些开发者为其他 vendor 的硬件开发驱动,因此,一个新的无线 maintainer 几乎确切的应该是硬件中立的组织,例如:一个 distributor。如果这有任何合适的人,John 也愿意听听。

会议的这部分也 cover 了很多其他的 topic, 例如:蓝牙 maintainer Marcel Holtmann 给了一个高速蓝牙的升级,3.17 kernel 会包含蓝牙 4.1 的功能。

结论就是,网络协议栈的工作还有很多,并正在继续着。。。。。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章