4.2 Identifiers (标识)
Linux,象所有的Unix,使用用户和组标识符来检查对于系统中的文件和映像的访问权限。Linux系统中所有的文件都有所有权和许可,这些许 可描述了系统对于该文件或目录拥有什么样的权限。基本的权限是读、写和执行,并分配了3组用户:文件属主、属于特定组的进程和系统中的其他进程。每一组用 户都可以拥有不同的权限,例如一个文件可以让它的属主读写,它的组读,而系统中的其他进程不能访问。
Linux使用组来给一组用户赋予对文件或者目录的权限,而不是对系统中的单个用户或者进程赋予权限。比如你可以为一个软件项目中的所有用户创建一 个组,使得只有他们才能够读写项目的源代码。一个进程可以属于几个组(缺省是32个),这些组放在每一个进程的task_struct结构中的 groups向量表中。只要进程所属的其中一个组对于一个文件有访问权限,则这个进程就又对于这个文件的适当的组权限。
一个进程的task_struct中有4对进程和组标识符。
Uid,gid 该进程运行中所使用的用户的标识符和组的标识符
Effective uid and gid 一些程序把执行进程的uid和gid 改变为它们自己的(在VFS I节点执行映像的属性中)。这些程序叫做setuid程序。这种方式有用,因为它可以限制对于服务的访问,特别是那些用其他人的方式运行的,例如网络守护 进程。有效的uid 和gid来自setuid程序,而uid和gid 仍旧是原来的。核心检查特权的时候检查有效 uid和gid。
File system uid and gid 通常和有效uid和gid相等,检查对于文件系统的访问权限。用于通过NFS安装的文件系统。这时用户态的NFS服务器需要象一个特殊进程一样访问文件。 只有文件系统uid和gid改变(而非有效uid和gid)。这避免了恶意用户向NFS的服务程序发送Kill信号。Kill用一个特别的有效uid和 gid发送给进程。
Saved uid and gid 这是POSIX标准的要求,让程序可以通过系统调用改变进程的uid和gid。用于在原来的uid和gid改变之后存储真实的uid和gid。
4.3 Scheduling (调度)
所有的进程部分运行与用户态,部分运行于系统态。底层的硬件如何支持这些状态各不相同但是通常有一个安全机制从用户态转入系统态并转回来。用户态比 系统态的权限低了很多。每一次进程执行一个系统调用,它都从用户态切换到系统态并继续执行。这时让核心执行这个进程。Linux中,进程不是互相争夺成为 当前运行的进程,它们无法停止正在运行的其它进程然后执行自身。每一个进程在它必须等待一些系统事件的时候会放弃CPU。例如,一个进程可能不得不等待从 一个文件中读取一个字符。这个等待发生在系统态的系统调用中。进程使用了库函数打开并读文件,库函数又执行系统调用从打开的文件中读入字节。这时,等候的 进程会被挂起,另一个更加值得的进程将会被选择执行。进程经常调用系统调用,所以经常需要等待。即使进程执行到需要等待也有可能会用去不均衡的CPU事 件,所以Linux使用抢先式的调度。用这种方案,每一个进程允许运行少量一段时间,200毫秒,当这个时间过去,选择另一个进程运行,原来的进程等待一 段时间直到它又重新运行。这个时间段叫做时间片。
需要调度程序选择系统中所有可以运行的进程中最值得的进程。一个可以运行的进程是一个只等待CPU的进程。Linux使用合理而简单的基于优先级的 调度算法在系统当前的进程中进行选择。当它选择了准备运行的新进程,它就保存当前进程的状态、和处理器相关的寄存器和其他需要保存的上下文信息到进程的 task_struct数据结构中。然后恢复要运行的新的进程的状态(又和处理器相关),把系统的控制交给这个进程。为了公平地在系统中所有可以运行 (runnable)的进程之间分配CPU时间,调度程序在每一个进程的task_struct结构中保存了信息:
参见 kernel/sched.c schedule()
policy 进程的调度策略。Linux有两种类型的进程:普通和实时。实时进程比所有其它进程的优先级高。如果有一个实时的进程准备运行,那么它总是先被运行。实时 进程有两种策略:环或先进先出(round robin and first in first out)。在环的调度策略下,每一个实时进程依次运行,而在先进先出的策略下,每一个可以运行的进程按照它在调度队列中的顺序运行,这个顺序不会改变。
Priority 进程的调度优先级。也是它允许运行的时候可以使用的时间量(jiffies)。你可以通过系统调用或者renice命令来改变一个进程的优先级。
Rt_priority Linux支持实时进程。这些进程比系统中其他非实时的进程拥有更高的优先级。这个域允许调度程序赋予每一个实时进程一个相对的优先级。实时进程的优先级可以用系统调用来修改
Coutner 这时进程可以运行的时间量(jiffies)。进程启动的时候等于优先级(priority),每一次时钟周期递减。
调度程序从核心的多个地方运行。它可以在把当前进程放到等待队列之后运行,也可以在系统调用之后进程从系统态返回进程态之前运行。需要运行调度程序的另一个原因是系统时钟刚好把当前进程的计数器(counter)置成了0。每一次调度程序运行它做以下工作:
参见 kernel/sched.c schedule()
kernel work 调度程序运行bottom half handler并处理系统的调度任务队列。这些轻量级的核心线程在第11章详细描述
Current pocess 在选择另一个进程之前必须处理当前进程。
如果当前进程的调度策略是环则它放到运行队列的最后。
如果任务是可中断的而且它上次调度的时候收到过一个信号,它的状态变为RUNNING
如果当前进程超时,它的状态成为RUNNING
如果当前进程的状态为RUNNING则保持此状态
不是RUNNING或者INTERRUPTIBLE的进程被从运行队列中删除。这意味着当调度程序查找最值得运行的进程时不会考虑这样的进程。
Process Selection 调度程序查看运行队列中的进程,查找最值得运行的进程。如果有实时的进程(具有实时调度策略),就会比普通进程更重一些。普通进程的重量是它的 counter,但是对于实时进程则是counter 加1000。这意味着如果系统中存在可运行的实时进程,就总是在任何普通可运行的进程之前运行。当前的进程,因为用掉了一些时间片(它的counter减 少了),所以如果系统中由其他同等优先级的进程,就会处于不利的位置:这也是应该的。如果几个进程又同样的优先级,最接近运行队列前段的那个就被选中。当 前进程被放到运行队列的后面。如果一个平衡的系统,拥有大量相同优先级的进程,那么回按照顺序执行这些进程。这叫做环型调度策略。不过,因为进程需要等待 资源,它们的运行顺序可能会变化。 Swap Processes 如果最值得运行的进程不是当前进程,当前进程必须被挂起,运行新的进程。当一个进程运行的时候它使用了CPU和系统的寄存器和物理内存。每一次它调用例程 都通过寄存器或者堆栈传递参数、保存数值比如调用例程的返回地址等。因此,当调度程序运行的时候它在当前进程的上下文运行。它可能是特权模式:核心态,但 是它仍旧是当前运行的进程。当这个进程要挂起时,它的所有机器状态,包括程序计数器(PC)和所有的处理器寄存器,必须存到进程的task_struct 数据结构中。然后,必须加载新进程的所有机器状态。这种操作依赖于系统,不同的CPU不会完全相同地实现,不过经常都是通过一些硬件的帮助。
交换出去进程的上下文发生在调度的最后。前一个进程存储的上下文,就是当这个进程在调度结束的时候系统的硬件上下文的快照。相同的,当加载新的进程的上下文时,仍旧是调度结束时的快照,包括进程的程序计数器和寄存器的内容。
如果前一个进程或者新的当前进程使用虚拟内存,则系统的页表需要更新。同样,这个动作适合体系结构相关。Alpha AXP处理器,使用TLT(Translation Look-aside Table)或者缓存的页表条目,必须清除属于前一个进程的缓存的页表条目。
4.3.1 Scheduling in Multiprocessor Systems(多处理器系统中的调度)
在Linux世界中,多CPU系统比较少,但是已经做了大量的工作使Linux成为一个SMP(对称多处理)的操作系统。这就是,可以在系统中的CPU之间平衡负载的能力。负载均衡没有比在调度程序中更重要的了。
在一个多处理器的系统中,希望的情况是:所有的处理器都繁忙地运行进程。每一个进程都独立地运行调度程序直到它的当前的进程用完时间片或者不得不等 待系统资源。SMP系统中第一个需要注意的是系统中可能不止一个空闲(idle)进程。在一个单处理器的系统中,空闲进程是task向量表中的第一个任 务,在一个SMP系统中,每一个CPU都有一个空闲的进程,而你可能有不止一个空闲CPU。另外,每一个CPU有一个当前进程,所以SMP系统必须记录每 一个处理器的当前和空闲进程。
在一个SMP系统中,每一个进程的task_struct都包含进程当前运行的处理器编号(processor)和上次运行的处理器编号 (last_processor)。为什么进程每一次被选择运行时不要在不同的CPU上运行是没什么道理的,但是Linux可以使用 processor_mask把进程限制在一个或多个CPU上。如果位N置位,则该进程可以运行在处理器N上。当调度程序选择运行的进程的时候,它不会考 虑processor_mask相应位没有设置的进程。调度程序也会利用上一次在当前处理器运行的进程,因为把进程转移到另一个处理器上经常会有性能上的 开支。
4.4 Files(文件)
图4.1显示了描述系统每一个进程中的用于描述和文件系统相关的信息的两个数据结构。第一个fs_struct包括了这个进程的VFS I节点和它的umask。Umask是新文件创建时候的缺省模式,可以通过系统调用改变。
参见include/linux/sched.h
第二个数据结构,files_struct,包括了进程当前使用的所有文件的信息。程序从标准输入读取,向标准输出写,错误信息输出到标准错误。这 些可以是文件,终端输入/输出或者世纪的设备,但是从程序的角度它们都被看作是文件。每一个文件都有它的描述符,files_struct包括了指向 256个file数据结果,每一个描述进程形用的文件。F_mode域描述了文件创建的模式:只读、读写或者只写。F_pos记录了下一次读写操作在文件 中的位置。F_inode指向描述该文件的I节点,f_ops是指向一组例程地址的指针,每一个地址都是一个用于处理文件的函数。例如写数据的函数。这种 抽象的接口非常强大,使得Linux可以支持大量的文件类型。我们可以看到,在Linux中pipe也是用这种机制实现的。
每一次打开一个文件,就使用files_struct中的一个空闲的file指针指向这个新的file结构。Linux进程启动时有3个文件描述符 已经打开。这就是标准输入、标准输出和标准错误,这都是从创建它们的父进程中继承过来的。对于文件的访问都是通过标准的系统调用,需要传递或返回文件描述 符。这些描述符是进程的fd向量表中的索引,所以标准输入、标准输出和标准错误的文件描述符分别是0,1和2。对于文件的所有访问都是利用file数据结 构中的文件操作例程和它的VFS I节点一起来实现的。
4.5 Virtual Memory(虚拟内存)
进程的虚拟内存包括多种来源的执行代码和数据。第一种是加载的程序映像,例如ls命令。这个命令,象所有的执行映像一样,由执行代码和数据组成。映 像文件中包括将执行代码和相关的程序数据加载到进程地虚拟内存中所需要的所有信息。第二种,进程可以在处理过程中分配(虚拟)内存,比如用于存放它读入的 文件的内容。新分配的虚拟内存需要连接到进程现存的虚拟内存中才能使用。第三中,Linux进程使用通用代码组成的库,例如文件处理。每一个进程都包括库 的一份拷贝没有意义,Linux使用共享库,几个同时运行的进程可以共用。这些共享库里边的代码和数据必须连接到该进程的虚拟地址空间和其他共享该库的进 程的虚拟地址空间。
在一个特定的时间,进程不会使用它的虚拟内存中包括的所有代码和数据。它可能包括旨在特定情况下使用的代码,比如初始化或者处理特定的事件。它可能 只是用了它的共享库中一部分例程。如果把所有这些代码都加载到物理内存中而不使用只会是浪费。把这种浪费和系统中的进程数目相乘,系统的运行效率会很低。 Linux改为使用demand paging 技术,进程的虚拟内存只在进程试图使用的时候才调入物理内存中。所以,Linux不把代码和数据直接加载到内存中,而修改进程的页表,把这些虚拟区域标志 为存在但是不在内存中。当进程试图访问这些代码或者数据,系统硬件会产生一个page fault,把控制传递给Linux核心处理。因此,对于进程地址空间的每一个虚拟内存区域,Linux需要直到它从哪里来和如何把它放到内存中,这样才 可以处理这些page fault。
Linux核心需要管理所有的这些虚拟内存区域,每一个进程的虚拟内存的内容通过一个它的task_struct指向的一个mm_struct mm_struc数据结构描述。该进程的mm_struct数据结构也包括加载的执行映像的信息和进程页表的指针。它包括了指向一组 vm_area_struct数据结构的指针,每一个都表示该进程中的一个虚拟内存区域。
这个链接表按照虚拟内存顺序排序。图4.2显示了一个简单进程的虚拟内存分布和管理它的核心数据结构。因为这些虚拟内存区域来源不同,Linux通 过vm_area_struct指向一组虚拟内存处理例程(通过vm_ops)的方式抽象了接口。这样进程的所有虚拟内存都可以用一种一致的方式处理,不 管底层管理这块内存的服务如何不同。例如,会有一个通用的例程,在进程试图访问不存在的内存时调用,这就是page fault 的处理。
当Linux为一个进程创建新的虚拟内存区域和处理对于不在系统物理内存中的虚拟内存的引用时,反复引用进程的vm_area_struct数据结 构列表。这意味着它查找正确的vm_area_struct数据结构所花的事件对于系统的性能十分重要。为了加速访问,Linux也把 vm_area_struct数据结构放到一个AVL(Adelson-Velskii and Landis)树。对这个树进行安排使得每一个vm_area_struct(或节点)都有对相邻的vm_area_struct结构的一个左和一个右指 针。左指针指向拥有较低起始虚拟地址的节点,右指针指向一个拥有较高起始虚拟地址的节点。为了找到正确的节点,Linux从树的根开始,跟从每一个节点的 左和右指针,直到找到正确的vm_area_struct。当然,在这个树中间释放不需要时间,而插入新的vm_area_struct需要额外的处理时 间。
当一个进程分配虚拟内存的时候,Linux并不为该进程保留物理内存。它通过一个新的vm_area_struct数据结构来描述这块虚拟内存,连 接到进程的虚拟内存列表中。当进程试图写这个新的虚拟内存区域的时候,系统会发生page fault。处理器试图解码这个虚拟地址,但是没有对应该内存的页表条目,它会放弃并产生一个page fault异常,让Linux核心处理。Linux检查这个引用的虚拟地址是不是在进程的虚拟地址空间, 如果是,Linux创建适当的PTE并为该进程分配物理内存页。也许需要从文件系统或者交换磁盘中加载相应的代码或者数据,然后进程从引起page fault的指令重新运行,因为这次该内存实际存在,可以继续。
4.6 Creating a Process(创建一个进程)
当系统启动的时候它运行在核心态,这时,只有一个进程:初始化进程。象所有其他进程一样,初始进程有一组用堆栈、寄存器等等表示的机器状态。当系统 中的其他进程创建和运行的时候这些信息存在初始进程的task_struct数据结构中。在系统初始化结束的时候,初始进程启动一个核心线程(叫做 init)然后执行空闲循环,什么也不做。当没有什么可以做的时候,调度程序会运行这个空闲的进程。这个空闲进程的task_struct是唯一一个不是 动态分配而是在核心连接的时候静态定义的,为了不至于混淆,叫做init_task。
Init核心线程或进程拥有进程标识符1,是系统的第一个真正的进程。它执行系统的一些初始化的设置(比如打开系统控制它,安装根文件系统),然后 执行系统初始化程序。依赖于你的系统,可能是/etc/init,/bin/init或/sbin/init其中之一。Init程序使用 /etc/inittab作为脚本文件创建系统中的新进程。这些新进程自身可能创建新的进程。例如:getty进程可能会在用户试图登录的时候创建一个 login的进程。系统中的所有进程都是init核心线程的后代。
新的进程的创建是通过克隆旧的进程,或者说克隆当前的进程来实现的。一个新的任务是通过系统调用创建的(fork或clone),克隆发生在核心的 核心态。在系统调用的最后,产生一个新的进程,等待调度程序选择它运行。从系统的物理内存中为这个克隆进程的堆栈(用户和核心)分配一个或多个物理的页用 于新的task_struct数据结构。一个进程标识符将会创建,在系统的进程标识符组中是唯一的。但是,也可能克隆的进程保留它的父进程的进程标识符。 新的task_struct进入了task 向量表中,旧的(当前的)进程的task_struct的内容拷贝到了克隆的task_struct。
参见kernel/fork.c do_fork()
克隆进程的时候,Linux允许两个进程共享资源而不是拥有不同的拷贝。包括进程的文件,信号处理和虚拟内存。共享这些资源的时候,它们相应的 count字段相应增减,这样Linux不会释放这些资源直到两个进程都停止使用。例如,如果克隆的进程要共享虚拟内存,它的task_struct会包 括一个指向原来进程的mm_struct的指针,mm_struct的count域增加,表示当前共享它的进程数目。
克隆一个进程的虚拟内存要求相当的技术。必须产生一组vm_area_struct数据结构、相应的mm_struct数据结构和克隆进程的页表, 这时没有拷贝进程的虚拟内存。这会是困难和耗时的任务,因为一部分虚拟内存可能在物理内存中而另一部分可能在交换文件中。替代底,Linux使用了叫做 “copy on write”的技术,即只有两个进程中的一个试图写的时候才拷贝虚拟内存。任何不写入的虚拟内存,甚至可能写的,都可以在两个进程之间共享二部会有什么害 处。只读的内存,例如执行代码,可以共享。为了实现“copy on write”,可写的区域的页表条目标记为只读,而描述它的vm_area_struct数据结构标记为“copy on write”。当一个进程试图写向着这个虚拟内存的时候会产生page fault。这时Linux将会制作这块内存的一份拷贝并处理两个进程的页表和虚拟内存的数据结构。