配置cgroup blkio 配置时启动挂在有问题,求解答

出自淘宝内核组
这期比较长,实际上把7月份和8月份的lwn的所有文章都做了翻译。
The 3.17 merge window part 1
The 3.17 merge window part 2
The 3.17 Merge window part 3
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不是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最近的一些改变和其反对意见都跟层级结构有关.现在离真正理解层级结构还差很远,接下来的文章中将要继续介绍.
满世界都是各种层级(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 包含资源控制和其他的操作来回答这些问题,看下回分解把。。。。
July 16, 2014
This article was contributed by Neil Brown
在cgroup之前就已经有nice用于控制每个进程可以使用的CPU time,以及通过setrlimit()系统调用限制内存及其他资源的使用。然而这些控制只能针对每个独立的进程,无法对进程组进行控制。
Linux的cgroup能够允许设立独立的子系统用于对进程组控制,更适合的术语是“资源控制器”。
Linux3.15现已经有12个cgroup子系统。我们主要关注下这些子系统--特别是子系统之间的层级组织,如何工作。
在进入细节前,先快速描述下一个子系统能做的事情。每个子系统能做的包括:
1. 在每个cgroup中存储一些任意的状态数据
2. 在cgroup文件系统里提供一些属性文件用于查看或者修改状态数据,或者其他状态细节
3. 接受或者拒绝进程加入一个给定的cgroup的请求
4. 接受或拒绝在一个已经存在的组里面创建一个子组的请求
5. 一些cgroup有任何进程创建或者销毁时获得通知
这些只是用于和进程组进行交互的通用接口,实际上子系统还会有其他一些方式和进程组以及内核交互,以实现期望的结果。
debug子系统并不实际“控制”任何东西,也不移除bug(很不幸)。它既不给任何的cgroup添加额外的数据,也不拒绝"attach"或者"create"请求,甚至也不关心进程创建和销毁。
开启这个子系统唯一的影响是使得许多独立的组或者整个cgroup系统的内部细节能够通过cgroup文件系统的虚拟文件查看到。细节包括部分数据结构当前的引用计数,以及内部标识项的设置情况。这些细节只有cgroup工作者可能全部关注。
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。
从整体上考虑cgroup的话,一个挑战是不同使用场景会有存在非常大的差异,给当前架构带来很多不同的需求。下面三个子系统都使用了cgroup的分层,但是控制流如何影响到进程这些细节上却有很大不同。
device子系统将访问控制托管给设备相关文件。每个组可以运行或者禁止所有的访问,随后给出一组访问被禁止会在运行的异常信息列表。
更新异常列表的代码会保证子组的权限不会比他父亲的多--设置或者传播父亲组不允许的权限给子组时会被拒绝。因此需要进行权限检查时,只需要检查自己组内的,并不需要遍历检查祖先是否允许这个访问,也不需要检查祖先组的规则是否已经在每个组里面。
当然这样也是有一定代价的,权限检查简单带来的是更新进程的权限会变得更加复杂。但由于权限检查相比权限更新更加频繁,这个代价是值得的。
对device子系统,Cgroup里面的配置会影响所有子cgroup,一直到层级的最下面。每个进程需要检查访问权限时,仍然回到相应的cgroup中。
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收集某组进程的各种性能数据,这个可以是系统里面的所有进程,或者某个特定用户的所有进程,或者某个特定父进程派生的所有进程,亦或perf_event cgroup子系统的所有进程。
为了检查是否在一个group里面,perf_event使用cgroup_is_descendant()函数简单的遍历-&parent直到找到一个匹配的或者是root。这个操作并不是特别开销大,特别是在层级不深的情况,当然相比简单比较两个数字的开销肯定更大些。网络代码的开发者在对添加任何有性能开销代码的敏感性方面是出名的,特别是这些开销是给到每个包的。因此网络代码不使用cgroup_is_descendant()也不会有啥让人惊讶。
对于perf,配置并不会下发到各个层级。任何时候当需要一个控制抉择(比如这个事件是否需要统计),会从进程开始遍历这个树以找到答案。
让我们会到net_cl和net_prio,试问它们怎么放进这个图谱里面--将配置从cgroup一直到进程接受到控制,和device子系统一样。进程在创建socket的时候是能够找到对应的cgroup,但是并不按层级往上回溯。区别是下发配置留给用户态,而不是让内核提供。
最后的一个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来说这两种都需要。
对当前这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进行分析,看是否能够从这些子系统中学习到什么样的层级会更适合他们。
Linux和Unix对资源使用计数并不陌生,即使在V6 Unix,每个进程使用的CPU时间都被计数且运行总时间可以通过times()系统调用获得。这也扩展到了进程组,V6 Unix里一个进程派生出来的所有进程可以组成一个组,当组内的所有进程都退出时,使用的总CPU时间可以通过times()获得。在进程退出或者等待前,它的CPU时间只有自己知道。在2.10BSD里,被计数的资源种类扩展到包括内存使用、缺页中断数、磁盘IO等,和CPU时间统计类似,当子进程等待时这些计数会被加进父进程里。getrusage()调用可以访问这些计数,现在的linux里还存在。
getrusage()后有了setrlimit(),它可限制资源的使用数目,如CPU时间和内存。这些限制只能加在单独的进程上而非组:一个组的计数只能在进程退出时累加,但显然这时太晚了而没法达到限制的目的。
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对这个问题应保持中立态度,并根据自己的实际需求找到最合适的办法。
有几个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使用了多少虚拟时间时,也会一并更新带宽使用并检查是否需要进行限制。
从我们提到的例子中可以看出,限制通常是从上到下检查层级,而资源计数是从下到上遍历层级。
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不能包含单独的进程。
July 30, 2014
This article was contributed by Neil Brown
在之前的文章里面,我们已经看过一般情况下的层级,以及特定cgroup子系统如何处理层级。现在是时候将这些汇总起来,以理解那种层级是需要的,已经如何在当前的实现中进行支持。如我们最近报道的,3.16 Linux内核正在开发对“统一层级”的支持。那个开发引入的新想法在这将不进行讨论,因为我们暂时没法知道它们可能带来的意义,除非我们已经完全知道我们拥有的。后面还会有文章剖析统一层级,先前我们先开始理解我们称之为"classic"的cgroup层级。
在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)子系统,提供一个唯一的区域层次结构是很自然的选择,也很适合。
网络流量实际是由一个独立的层级进行管理,这个层级甚至独立于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这样的全虚拟化。
正如我们上次提到的,资源统计控制器需要对祖先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使得已经有一个独立的层级,同时也最好将所有非统计类的子系统一起放在一个区域层级。这样需要的时候依赖于智能的工具对这些独立的层级进行有效的组合。
现在需要回答一些以前文章提到的问题。其中一个是如何命名组。正如我们上面看到的,这个是执行mkdir命令进程的职责。这个和任务控制进程组及会话组不一样,这些组是在进程调用setsid()或者setpgid(0,0)时内核会为组设定一个名字。这之间的区别能够得到解决,不过这里需要阐述下期望的权力结构。对于任务控制进程组,形成一个新组的决定来自于新组的一个成员。而对cgroup,这个决定更期望的是来自于外部。先前我们已经观察到在cgroup层级里面包含一个区域层级看起来很有道理。而与这个观察一致的是名字是从外部给予的这个事实。
另外一个问题是,是否允许从一个组移入到另外一个组。移动一个进程需要将进程ID写入cgroup文件系统的一个文件中,这个有可能由任意有权限对这个文件进行写的进程执行,这样需要更进一步检查执行写操作进程的所有者是否也是将被添加进程的所属者,或者是更高权限的。这意味着任何用户能够将他们的任意进程放入任意他们有对cgroup.procs写权限的组里面,而不顾跨越了多少个层级。
换句话说,我们可以限制一个进程可以移动到哪,但是对于进程从哪里移动过来的控制却很少。
这个讨论引出的最大问题是,是否真的有对不同的资源使用不同层级管理的需求。NTC提供的灵活性是否很好的超越了需求,或者它是否为其他提供一个可以追随的有价值的模型?第二个问题关心的是假如不同的需求都使用一个层级,是否会引起组合爆炸,同时这个带来的开销是否和其价值成比例。在任意情况,我们需要清楚的知道如何计算请求实际发起者的资源消耗。
这些问题中,中间可能是最早的:拥有多cgroup在实现上有哪些开销?下面一个topic我们需要讲的就是这个。
这篇文章扼要地介绍了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_
struct pid *
} pids[PIDTYPE_MAX];
后面作者介绍了不少社区为了做到高并发所做的N多努力。
OK,该是cgroup内部结构登场的时候了:
每个cgroup结构都有cset_links字段,把其中的所有threads串起来。
每个css_set结构则都有一个cgrp_links字段,把其中thread涉及到的cgroup串起来。另外,对每一个层次结构还有一个cgrp_cset_link字段。
这里面使用了大量内核中的链表技巧,亲们google之?
文章里讲了以上这些数据结构中锁机制的一些设计细节,也值得一看的。
这一系列文章的目的是为了让我们理解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
统一继承所依赖的基础还在建设中,现在还不能要求太多。
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调度可能是最复杂的资源管理。调度组大体有域,进程组,进程继承组成,但是在每个级别都有组选项。
文件系统通知API提供了一个让应用程序监测一个文件打开、修改、删除、重命名等操作事件的方法。过去,Linux中共有三种不同的文件系统通知API,了解和掌握这三种API之间的区别是十分有用的。同时在了解这些API的过程中,我们也能够学习到很多API设计上的经验。
本文是一些列文章的第一部分。我们首先介绍最原始的API:dnotify以及这个API的诸多不足。然后我们将讨论inotify以及它对于dnotify的改进。在着一系列的最后,文章将介绍fanotify。
为了比较这三种不同API之间的区别,首先我们来看一下这些API的常见用例。
缓存文件系统对象模型
应用程序经常需要在自己内部维护一个精确反映文件系统当前状态的模型。比如一个文件管理器就需要通过图形界面来反馈文件系统当前的状态。
记录文件系统活动
应用程序希望记录当前监控的文件系统中发生的某类事件。
监控文件系统操作
应用程序希望在某些事件发生或采取必要的措施。此类经典应用是反病毒软件。当另外一个程序尝试去执行一个文件的时候,反病毒软件首先建厂文件的内容是否存在恶意代码,从而判断是否运行该文件被执行。
在没有操作系统相关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);
其中的fd是一个需要监控的目录的文件描述符。这种使用方法造成了dnotify只能对整个目录进行监控,无法对某个特定文件进行监控。第三个参数mask用来指定需要监控的事件。详细说明可以参考。
另外一个比较怪异的设计是dnotify在监控的事件发生的时候会向应用程序发送信号来进行通知(默认为SIGIO)。这个信号本身并不能反映到底是哪个被监控的目录发生的事件。需要使用通过SA_SIGINFO来建立信号处理函数。在随后的信号处理函数中会接收到一个siginfo_t参数。在该参数中有一个si_fd域,通过该域可以获取到发生事件的目录。同时应用程序需要遍历整个监控目录列表来了解对应的目录信息。
这里是一个使用dnotify的简单
正如上面的介绍,dnotify在设计上就存在诸多的不足。比如:只能监控整个目录而不是单独文件,可以监控的事件不全,无法监控文件的打卡或者关闭。
上面这些其实并不是最严重的问题。使用信号作为通知的方法造成了dnotify使用困难。首先信号的投递是异步的,这样获取信号的处理函数就很容易出错,尽管可以使用来同步获取信号。同时信号必须要被及时处理,应用程序处理速度不够时,就会有信号丢失的问题。
信号的使用还有其他的问题,不如在事件发生时,应用程序无法得知具体发生事件的类型和具体文件,这就需要应用程序进行复杂的处理流程。同时如果其他库函数也会处理相同的信号,这样就会造成信号的冲突。
最后的问题是,只能监控目录会造成应用程序需要打开大量的文件,造成文件描述符使用很多。此外,打开大量文件描述符会造成文件系统无法被卸载。
尽管如此,dnotify仍然提供了一种高效的监控文件系统事件的方法,并且dnotify已经被广泛使用在一些应用程序中,比如:Beagle桌面搜索。但是显然涉及一种更晚上的API将会让程序员的日子更好过。
inotify由John McCutchan在Robert Love的协助下开发完成,并于Linux 2.6.13版本发布。inotify的发布解决了dnotify中的一系列明显的问题
inotify中使用了三个新的系统调用:
&source lang=c&
inotify_init();
inotify_add_watch();
inotify_rm_watch();
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);
其中mask参数指定了需要监控的事件集合。pathname参数指定了需要监控的文件路径。下面的例子展示了监控mydir目录中文件创建、删除事件的相关代码:
&source lang=c&
fd = inotify_init();
wd = inotify_add_watch(fd, "mydir",
IN_CREATE | IN_DELETE | IN_DELETE_SELF);
详细信息和可以参考。inotify可以监控的事件集合是dnotify的超集。最显著的是对于文件打开和关闭的监控。
inotify_add_watch()返回一个监控描述符。该描述符是一个整数类型用来唯一标示inotify监控的一个特定的文件系统对象。在监控的事件发生后,应用程序可以通过read()来获取对应的信息。其结构如下:
&source lang=c&
struct inotify_event {
/* Watch descriptor */
/* Bit mask describing event */
/* Unique cookie associating related events */
/* Size of name field */
/* Optional null-terminated name */
数据结构中具体的说明请参考相关手册。
inotify最重要的问题是没有提供地柜监控功能。当然我们可以通过为每个子目录来创建监听事件来解决。
下面的程序是inotify的演示程序。
&source lang=c&
main(int argc, char *argv[])
struct inotify_event *event
inotifyFd = inotify_init();
/* Create inotify instance */
for (j = 1; j & 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 = p & buf + numR ) {
event = (struct inotify_event *)
displayInotifyEvent(event);
p += sizeof(struct inotify_event) + event-&
inotify相比于dnotify有多项改进:
可以监控目录和文件
通过read()取代信号
不需要打开被监控目录
更多的事件
丰富的重命名事件
IN_IGNORED事件
本文我们主要介绍了dnotify和inotify,以及inotify相对于dnotify的改进。下面的文章将会更详细的介绍inotify以及如何使用inotify来构建健壮的应用。
在这个系列的第一篇文章中,我们简单了解了Linux文件系统通知API dnotify以及该接口的各种不足。随后文章介绍了它的继承者inotify,它是如何解决此前dnotify遗留的各种问题的以及带来了哪些好处。在上一篇文章中,我们看到了如何使用inotify来创建一个简单的文件系统状态监控程序。然后,inotify的使用并不像看起来那么简单。
现在,我们来深入了解inotify。我们将通过一个监控目录树状态的应用程序来深入了解inotify接口。一方面,通过该程序我们可以了解inotify是如何完成这一工作的;另一方面,我们也将看到inotify的一些不足。
仅仅用来作为演示使用,并没有对性能有任何考量。程序的使用方法如下:
./inotify_dtree &directory& &directory&...
这个程序的功能是动态的监控命令行指定的目录及其子目录的状态。这个程序的作用类似于一个GUI的文件管理器。
为了控制程序的大小,我们做了必要的简化:
目前程序仅仅监控一个指定目录下的子目录的状态,对于其他文件则不予关注。尽管监控其他类型的文件十分简单,但是我们仅仅把注意力放在子目录的监控上,因为这是该程序最具挑战的部分;
程序目前仅仅记录了目录的名字和对应的监控描述符。一个实用程序还会监控其他信息,比如文件属主,权限和修改事件;
目前保存状态的数据结构为一个链表,这样做是为了简单。真是情况下,需要使用更加有效的树形数据结构。
经过上述简化后,我们依然可以看到这样一个监控目录状态的简单程序对inotify来说仍然是个挑战。
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,
if (! S_ISDIR(sb-&st_mode))
/* 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)
exit(EXIT_FAILURE);
if (findWatch(wd) & 0) {
/* This watch descriptor is
nothing more to do. */
logMessage(VB_BASIC, "WD&%d already in cache (%s)\n", wd, pathname);
slot = addWatchToCache(wd, pathname);
inotify的消息队列需要占用内核内存,因此这个队列有大小限制。具体的大小可以参考intofy的手册或者查看/proc目录下的对应文件。当队列达到上限后,inotify会添加一个溢出事件到队列中,并且丢弃后面的所有事件,直到程序开始读取队列中的事件。
这一行为导致的结果就是应用程序会丢失某些事件,换言之,inotify不能生成一个十分精确的文件系统状态。
事件溢出对于我们的演示程序来讲意味着文件系统的状态和程序目前保存的状态不一致了。在这一个问题发生后,我们唯一能做的就是关闭所有已经打开的监控描述符,然后重新对缓存状态进行初始化。这里的相关代码请参考。
尽管我们可以通过增大队列的大小来尽量避免溢出的发生,但是所有使用inotify来监控文件系统状态的程序都应该小心的处理溢出问题。
此外,其他一些边角问题或者程序bug也可能造成状态的不一致。程序应该处理好这些问题。
如前所述,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事件。如果有,则按照重命名事件来进行处理。否则按照删除事件来进行处理。
演示程序中的提供了一种处理重命名事件的方法。通过在read()操作中间插入2ms的延迟,我们可以解决99.8%的问题。
在程序监控文件系统状态的时候,inotify的一个相对于dnotify的优点是可以提供文件的路径信息。但是这个信息处理起来是十分困难的。因为一个文件可能有多个路径,因为一个文件可以有多个硬链接。
加入我们需要监控如下的一个目录树:一个文件对象有两个硬链接,一个是mydir/abc/x1;另外一个是mydir/xyz/x2。我们只监控了mydir/abc/x1。
当我们对这个文件进行操作的时候就会出现问题,当我们打开mydir/abc/x1文件时,程序会接收到信号,但是当打开mydir/xyz/x2文件时,程序就不会接收到信号。概括来说,inotify仅针对监控的路径产生信号。
inotify除了上面详细介绍的问题外,还有其他的限制:
目前的事件中没有包含产生事件的进程信息。这样当一个监控程序自己产生了一个事件后,程序自己无法建议区分;
inotify没有提供一个看门狗的功能。也就是说inotify仅仅发送事件,但是不会阻塞事件的发生。所以无法使用inotify来实现诸如反病毒程序的功能;
inotify仅仅报告通过文件系统API产生的事件,也就是说通过远程文件系统和虚拟文件系统产生的事件,程序是无法接收到的。同时通过文件影射对文件进行的修改,程序也是无法接收到的。这就需要程序不断调用stat()和readdir()来进行监控。
相对于dnotify,inotify已经有了很大的改进。但是inotify自身依然存在一些不足,后面的文章,我们将一些来看一看fanotify API。
深入剖析系统调用 (一)
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被定义时才会展开,展开后会定义用于描述该系统调用的数据以及该系统调用的参数。(另一篇文章
__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);
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
第一项(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,我们已经了解了最常见的平台上实现系统调用的标准方法。下篇文章将继续深入介绍其它体系结构上的情况,以及一些不太常见的情景。
深入剖析系统调用 (二)
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
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);
这个函数将系统调用向量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内

我要回帖

更多关于 dns配置有问题 的文章

 

随机推荐