Linux 命名空间
 

2.3.2 命名空间(1)

命名空间提供了虚拟化的一种轻量级形式,使得我们可以从不同的方面来查看运行系统的全局属性。该机制类似于Solaris中的zone或 FreeBSD中的jail。对该概念做一般概述之后,我将讨论命名空间框架所提供的基础设施。

1. 概念

传统上,在Linux以及其他衍生的UNIX变体中,许多资源是全局管理的。例如,系统中的所有进程按照惯例是通过PID标识的,这意味着内核必须管理一个全局的PID列表。而且,所有调用者通过uname系统调用返回的系统相关信息(包括系统名称和有关内核的一些信息)都是相同的。用户ID的管理方式类似,即各个用户是通过一个全局唯一的UID号标识。

全局ID使得内核可以有选择地允许或拒绝某些特权。虽然UID为0的root用户基本上允许做任何事,但其他用户ID则会受到限制。例如UID为n 的用户,不允许杀死属于用户m的进程(m≠ n)。但这不能防止用户看到彼此,即用户n可以看到另一个用户m也在计算机上活动。只要用户只能操纵他们自己的进程,这就没什么问题,因为没有理由不允许用户看到其他用户的进程。

但有些情况下,这种效果可能是不想要的。如果提供Web主机的供应商打算向用户提供Linux计算机的全部访问权限,包括root权限在内。传统上,这需要为每个用户准备一台计算机,代价太高。使用KVM或VMWare提供的虚拟化环境是一种解决问题的方法,但资源分配做得不是非常好。计算机的各个用户都需要一个独立的内核,以及一份完全安装好的配套的用户层应用。

命名空间提供了一种不同的解决方案,所需资源较少。在虚拟化的系统中,一台物理计算机可以运行多个内核,可能是并行的多个不同的操作系统。而命名空间则只使用一个内核在一台物理计算机上运作,前述的所有全局资源都通过命名空间抽象起来。这使得可以将一组进程放置到容器中,各个容器彼此隔离。隔离可以使容器的成员与其他容器毫无关系。但也可以通过允许容器进行一定的共享,来降低容器之间的分隔。例如,容器可以设置为使用自身的PID集合,但仍然与其他容器共享部分文件系统。

本质上,命名空间建立了系统的不同视图。此前的每一项全局资源都必须包装到容器数据结构中,只有资源和包含资源的命名空间构成的二元组仍然是全局唯一的。虽然在给定容器内部资源是自足的,但无法提供在容器外部具有唯一性的ID。图2-3给出了此情况的一个概述。

 
图2-3 命名空间可以按层次关联起来。 每个命名空间都发
源于一个父命名空间,一个父命名空间可以有多个子命名空间

考虑系统上有3个不同命名空间的情况。命名空间可以组织为层次,我会在这里讨论这种情况。一个命名空间是父命名空间,衍生了两个子命名空间。假定容器用于虚拟主机配置中,其中的每个容器必须看起来像是单独的一台Linux计算机。因此其中每一个都有自身的init进程,PID为0,其他进程的PID 以递增次序分配。两个子命名空间都有PID为0的init进程,以及PID分别为2和3的两个进程。由于相同的PID在系统中出现多次,PID号不是全局唯一的。

虽然子容器不了解系统中的其他容器,但父容器知道子命名空间的存在,也可以看到其中执行的所有进程。图中子容器的进程映射到父容器中,PID为4到 9。尽管系统上有9个进程,但却需要15个PID来表示,因为一个进程可以关联到多个PID。至于哪个PID是"正确"的,则依赖于具体的上下文。

如果命名空间包含的是比较简单的量,也可以是非层次的,例如下文讨论的UTS命名空间。在这种情况下,父子命名空间之间没有联系。

请注意,Linux系统对简单形式的命名空间的支持已经有很长一段时间了,主要是chroot系统调用。该方法可以将进程限制到文件系统的某一部分,因而是一种简单的命名空间机制。但真正的命名空间能够控制的功能远远超过文件系统视图。

新的命名空间可以用下面两种方法创建。

(1) 在用fork或clone系统调用创建新进程时,有特定的选项可以控制是与父进程共享命名空间,还是建立新的命名空间。

(2) unshare系统调用将进程的某些部分从父进程分离,其中也包括命名空间。更多信息请参见手册页unshare(2)。

在进程已经使用上述的两种机制之一从父进程命名空间分离后,从该进程的角度来看,改变全局属性不会传播到父进程命名空间,而父进程的修改也不会传播到子进程,至少对于简单的量是这样。而对于文件系统来说,情况就比较复杂,其中的共享机制非常强大,带来了大量的可能性,具体的情况会在第8章讨论。

在标准内核中命名空间当前仍然标记为试验性的,为使内核的所有部分都能够感知到命名空间,相关开发仍然在进行中。但就内核版本2.6.24而言,基本的框架已经建立就绪。 当前的实现仍然存在一些问题,相关的信息可以参见Documentation/namespaces/compatibility-list.txt文件。

2. 实现

命名空间的实现需要两个部分:每个子系统的命名空间结构,将此前所有的全局组件包装到命名空间中;将给定进程关联到所属各个命名空间的机制。图 2-4说明了具体情形。
 

 
(点击查看大图)图2-4 进程和命名空间之间的联系

2.3.2 命名空间(2)

子系统此前的全局属性现在封装到命名空间中,每个进程关联到一个选定的命名空间。每个可以感知命名空间的内核子系统都必须提供一个数据结构,将所有通过命名空间形式提供的对象集中起来。struct nsproxy用于汇集指向特定于子系统的命名空间包装器的指针:

 
              
  1. <nsproxy.h> 
  2. struct nsproxy {  
  3.         atomic_t count;  
  4.         struct uts_namespace *uts_ns;  
  5.         struct ipc_namespace *ipc_ns;  
  6.         struct mnt_namespace *mnt_ns;  
  7.         struct pid_namespace *pid_ns;  
  8.         struct user_namespace *user_ns;  
  9.         struct net *net_ns;  
  10. }; 

当前内核的以下范围可以感知到命名空间。

UTS命名空间包含了运行内核的名称、版本、底层体系结构类型等信息。UTS是UNIX Timesharing System的简称。

保存在struct ipc_namespace中的所有与进程间通信(IPC)有关的信息。

已经装载的文件系统的视图,在struct mnt_namespace中给出。

有关进程ID的信息,由struct pid_namespace提供。

struct user_namespace保存的用于限制每个用户资源使用的信息。

struct net_ns包含所有网络相关的命名空间参数。读者在第12章中会看到,为使网络相关的内核代码能够完全感知命名空间,还有许多工作需要完成。

当我讨论相应的子系统时,会介绍各个命名空间容器的内容。在本章中,我们主要讲解UTS和用户命名空间。由于在创建新进程时可使用fork建立一个新的命名空间,因此必须提供控制该行为的适当的标志。每个命名空间都有一个对应的标志:

 
              
  1. <sched.h> 
  2. #define CLONE_NEWUTS    0x04000000      /* 创建新的utsname组 */  
  3. #define CLONE_NEWIPC    0x08000000      /* 创建新的IPC命名空间  */  
  4. #define CLONE_NEWUSER   0x10000000      /* 创建新的用户命名空间   */  
  5. #define CLONE_NEWPID    0x20000000      /* 创建新的PID命名空间  */  
  6. #define CLONE_NEWNET    0x40000000      /* 创建新的网络命名空间   */ 

每个进程都关联到自身的命名空间视图:

 
              
  1. <sched.h> 
  2. struct task_struct {  
  3. ...  
  4. /* 命名空间 */  
  5.  
  6.         struct nsproxy *nsproxy;  
  7. ...  

因为使用了指针,多个进程可以共享一组子命名空间。这样,修改给定的命名空间,对所有属于该命名空间的进程都是可见的。

请注意,对命名空间的支持必须在编译时启用,而且必须逐一指定需要支持的命名空间。但对命名空间的一般性支持总是会编译到内核中。 这使得内核不管有无命名空间,都不必使用不同的代码。除非指定不同的选项,否则每个进程都会关联到一个默认命名空间,这样可感知命名空间的代码总是可以使用。但如果内核编译时没有指定对具体命名空间的支持,默认命名空间的作用则类似于不启用命名空间,所有的属性都相当于全局的。

init_nsproxy定义了初始的全局命名空间,其中维护了指向各子系统初始的命名空间对象的指针:

 
              
  1. <kernel/nsproxy.c> 
  2. struct nsproxy init_nsproxy = INIT_NSPROXY(init_nsproxy);  
  3.  
  4. <init_task.h> 
  5. #define INIT_NSPROXY(nsproxy) { \  
  6.         .pid_ns = &init_pid_ns, \  
  7.         .count = ATOMIC_INIT(1), \  
  8.         .uts_ns = &init_uts_ns, \  
  9.         .mnt_ns = NULL, \  
  10.         INIT_NET_NS(net_ns) \  
  11.         INIT_IPC_NS(ipc_ns) \  
  12.         .user_ns = &init_user_ns, \  

UTS命名空间

UTS命名空间几乎不需要特别的处理,因为它只需要简单量,没有层次组织。所有相关信息都汇集到下列结构的一个实例中:

 
              
  1. <utsname.h> 
  2. struct uts_namespace {  
  3.         struct kref kref;  
  4.         struct new_utsname name;  
  5.  
  6. }; 

kref是一个嵌入的引用计数器,可用于跟踪内核中有多少地方使用了struct uts_namespace的实例(回想第1章,其中讲述了更多有关处理引用计数的一般框架信息)。uts_namespace所提供的属性信息本身包含在struct new_utsname中:

 
              
  1. <utsname.h> 
  2. struct new_utsname {  
  3.         char sysname[65];  
  4.         char nodename[65];  
  5.         char release[65];  
  6.         char version[65];  
  7.         char machine[65];  
  8.         char domainname[65];  
  9. }; 

各个字符串分别存储了系统的名称(Linux...)、内核发布版本、机器名,等等。使用uname工具可以取得这些属性的当前值,也可以在 /proc/sys/kernel/中看到:

 
              
  1. wolfgang@meitner> cat /proc/sys/kernel/ostype  
  2. Linux  
  3. wolfgang@meitner> cat /proc/sys/kernel/osrelease  
  4. 2.6.24 

初始设置保存在init_uts_ns中:

 
              
  1. init/version.c  
  2. struct uts_namespace init_uts_ns = {  
  3. ...  
  4.         .name = {  
  5.                 .sysname = UTS_SYSNAME,  
  6.                 .nodename = UTS_NODENAME,  
  7.                 .release = UTS_RELEASE,  
  8.                 .version = UTS_VERSION,  
  9.                 .machine = UTS_MACHINE,  
  10.                 .domainname = UTS_DOMAINNAME,  
  11.         },  
  12. }; 

相关的预处理器常数在内核中各处定义。例如,UTS_RELEASE在<utsrelease.h>中定义,该文件是连编时通过顶层 Makefile动态生成的。

请注意,UTS结构的某些部分不能修改。例如,把sysname换成Linux以外的其他值是没有意义的,但改变机器名是可以的。

内核如何创建一个新的UTS命名空间呢?这属于copy_utsname函数的职责。在某个进程调用fork并通过CLONE_NEWUTS标志指定创建新的UTS命名空间时,则调用该函数。在这种情况下,会生成先前的uts_namespace实例的一份副本,当前进程的nsproxy实例内部的指针会指向新的副本。如此而已!由于在读取或设置UTS属性值时,内核会保证总是操作特定于当前进程的uts_namespace实例,在当前进程修改 UTS属性不会反映到父进程,而父进程的修改也不会传播到子进程。

用户命名空间

用户命名空间在数据结构管理方面类似于UTS:在要求创建新的用户命名空间时,则生成当前用户命名空间的一份副本,并关联到当前进程的 nsproxy实例。但用户命名空间自身的表示要稍微复杂一些:
 

 
              
  1. <user_namespace.h> 
  2. struct user_namespace {  
  3.         struct kref kref;  
  4.         struct hlist_head uidhash_table[UIDHASH_SZ];  
  5.         struct user_struct *root_user;  
  6. }; 

如前所述,kref是一个引用计数器,用于跟踪多少地方需要使用user_namespace实例。对命名空间中的每个用户,都有一个struct user_struct的实例负责记录其资源消耗,各个实例可通过散列表uidhash_table访问。

对我们来说user_struct的精确定义是无关紧要的。只要知道该结构维护了一些统计数据(如进程和打开文件的数目)就足够了。我们更感兴趣的问题是:每个用户命名空间对其用户资源使用的统计,与其他命名空间完全无关,对root用户的统计也是如此。这是因为在克隆一个用户命名空间时,为当前用户和root都创建了新的user_struct实例:

 
              
  1. kernel/user_namespace.c  
  2. static struct user_namespace *clone_user_ns(struct 
    user_namespace *old_ns)  
  3. {  
  4.         struct user_namespace *ns;  
  5.         struct user_struct *new_user;  
  6. ...  
  7.         ns = kmalloc(sizeof(struct user_namespace), GFP_KERNEL);  
  8. ...  
  9.         ns->root_user = alloc_uid(ns, 0);  
  10.  
  11.         /* 将current->user替换为新的 */  
  12.         new_user = alloc_uid(ns, current->uid);  
  13.  
  14.         switch_uid(new_user);  
  15.         return ns;  

alloc_uid是一个辅助函数,对当前命名空间中给定UID的一个用户,如果该用户没有对应的user_struct实例,则分配一个新的实例。在为root和当前用户分别设置了user_struct实例后,switch_uid确保从现在开始将新的user_struct实例用于资源统计。实质上就是将struct task_struct的user成员指向新的user_struct实例。

请注意,如果内核编译时未指定支持用户命名空间,那么复制用户命名空间实际上是空操作,即总是会使用默认的命名空间。
 


2.3.3 进程ID号(1)

UNIX进程总是会分配一个号码用于在其命名空间中唯一地标识它们。该号码被称作进程ID号,简称PID。用fork或clone产生的每个进程都由内核自动地分配了一个新的唯一的PID值。

1. 进程ID

但每个进程除了PID这个特征值之外,还有其他的ID。有下列几种可能的类型。

处于某个线程组(在一个进程中,以标志CLONE_THREAD来调用clone建立的该进程的不同的执行上下文,我们在后文会看到)中的所有进程都有统一的线程组ID(TGID)。如果进程没有使用线程,则其PID和TGID相同。

线程组中的主进程被称作组长(group leader)。通过clone创建的所有线程的task_struct的group_leader成员,会指向组长的task_struct实例。

另外,独立进程可以合并成进程组(使用setpgrp系统调用)。进程组成员的task_struct的pgrp属性值都是相同的,即进程组组长的 PID。进程组简化了向组的所有成员发送信号的操作,这对于各种系统程序设计应用(参见系统程序设计方面的文献,例如[SR05])是有用的。请注意,用管道连接的进程包含在同一个进程组中。

几个进程组可以合并成一个会话。会话中的所有进程都有同样的会话ID,保存在task_struct的session成员中。SID可以使用 setsid系统调用设置。它可以用于终端程序设计,但和我们这里的讨论不相干。

命名空间增加了PID管理的复杂性。回想一下,PID命名空间按层次组织。在建立一个新的命名空间时,该命名空间中的所有PID对父命名空间都是可见的,但子命名空间无法看到父命名空间的PID。但这意味着某些进程具有多个PID,凡可以看到该进程的命名空间,都会为其分配一个PID。 这必须反映在数据结构中。我们必须区分局部ID和全局ID。

全局ID是在内核本身和初始命名空间中的唯一ID号,在系统启动期间开始的init进程即属于初始命名空间。对每个ID类型,都有一个给定的全局 ID,保证在整个系统中是唯一的。

局部ID属于某个特定的命名空间,不具备全局有效性。对每个ID类型,它们在所属的命名空间内部有效,但类型相同、值也相同的ID可能出现在不同的命名空间中。

全局PID和TGID直接保存在task_struct中,分别是task_struct的pid和tgid成员:

 
              
  1. <sched.h> 
  2. struct task_struct {  
  3. ...  
  4.         pid_t pid;  
  5.         pid_t tgid;  
  6. ...  

这两项都是pid_t类型,该类型定义为__kernel_pid_t,后者由各个体系结构分别定义。通常定义为int,即可以同时使用232个不同的ID。

会话和进程组ID不是直接包含在task_struct本身中,但保存在用于信号处理的结构中。task_ struct->signal->__session表示全局SID,而全局PGID则保存在 task_struct->signal->__pgrp。辅助函数set_task_session和set_task_pgrp可用于修改这些值。

2. 管理PID

除了这两个字段之外,内核还需要找一个办法来管理所有命名空间内部的局部量,以及其他ID(如TID和SID)。这需要几个相互连接的数据结构,以及许多辅助函数,并将在下文讨论。

数据结构

下文我将使用ID指代提到的任何进程ID。在必要的情况下,我会明确地说明ID类型(例如,TGID,即线程组ID)。

一个小型的子系统称之为PID分配器(pid allocator)用于加速新ID的分配。此外,内核需要提供辅助函数,以实现通过ID及其类型查找进程的task_struct的功能,以及将ID的内核表示形式和用户空间可见的数值进行转换的功能。

在介绍表示ID本身所需的数据结构之前,我需要讨论PID命名空间的表示方式。我们所需查看的代码如下所示:

 
              
  1. <pid_namespace.h> 
  2. struct pid_namespace {  
  3. ...  
  4.         struct task_struct *child_reaper;  
  5. ...  
  6.         int level;  
  7.         struct pid_namespace *parent;  
  8. }; 

实际上PID分配器也需要依靠该结构的某些部分来连续生成唯一ID,但我们目前对此无需关注。我们上述代码中给出的下列成员更感兴趣。

每个PID命名空间都具有一个进程,其发挥的作用相当于全局的init进程。init的一个目的是对孤儿进程调用wait4,命名空间局部的 init变体也必须完成该工作。child_reaper保存了指向该进程的task_struct的指针。

parent是指向父命名空间的指针,层次表示当前命名空间在命名空间层次结构中的深度。初始命名空间的level为0,该命名空间的子空间 level为1,下一层的子空间level为2,依次递推。level的计算比较重要,因为level较高的命名空间中的ID,对level较低的命名空间来说是可见的。从给定的level设置,内核即可推断进程会关联到多少个ID。

回想图2-3的内容,命名空间是按层次关联的。这有助于理解上述的定义。

PID的管理围绕两个数据结构展开:struct pid是内核对PID的内部表示,而struct upid则表示特定的命名空间中可见的信息。两个结构的定义如下:

 
              
  1. <pid.h> 
  2. struct upid {  
  3.         int nr;  
  4.         struct pid_namespace *ns;  
  5.         struct hlist_node pid_chain;  
  6. };  
  7.  
  8. struct pid  
  9. {  
  10.         atomic_t count;  
  11.         /* 使用该pid的进程的列表 */  
  12.         struct hlist_head tasks[PIDTYPE_MAX];  
  13.         int level;  
  14.         struct upid numbers[1];  
  15. }; 

由于这两个结构与其他一些数据结构存在广泛的联系,在分别讨论相关结构之前,图2-5对此进行了概述。

对于struct upid,nr表示ID的数值,ns是指向该ID所属的命名空间的指针。所有的upid实例都保存在一个散列表中,稍后我们会看到该结构。 pid_chain用内核的标准方法实现了散列溢出链表。

struct pid的定义首先是一个引用计数器count。tasks是一个数组,每个数组项都是一个散列表头,对应于一个ID类型。这样做是必要的,因为一个ID可能用于几个进程。所有共享同一给定ID的task_struct实例,都通过该列表连接起来。PIDTYPE_MAX表示ID类型的数目:

 
              
  1. <pid.h> 
  2. enum pid_type  
  3. {  
  4.         PIDTYPE_PID,  
  5.         PIDTYPE_PGID,  
  6.         PIDTYPE_SID,  
  7.         PIDTYPE_MAX  
  8. }; 
 
(点击查看大图)图2-5 实现可感知命名空间的 ID表示所用的数据结构

2.3.3 进程ID号(2)

请注意,枚举类型中定义的ID类型不包括线程组ID!这是因为线程组ID无非是线程组组长的PID而已,因此再单独定义一项是不必要的。

一个进程可能在多个命名空间中可见,而其在各个命名空间中的局部ID各不相同。level表示可以看到该进程的命名空间的数目(换言之,即包含该进程的命名空间在命名空间层次结构中的深度),而numbers是一个upid实例的数组,每个数组项都对应于一个命名空间。注意该数组形式上只有一个数组项,如果一个进程只包含在全局命名空间中,那么确实如此。由于该数组位于结构的末尾,因此只要分配更多的内存空间,即可向数组添加附加的项。

由于所有共享同一ID的task_struct实例都按进程存储在一个散列表中,因此需要在struct task_struct中增加一个散列表元素:

 
              
  1. <sched.h> 
  2. struct task_struct {  
  3. ...  
  4.         /* PID与PID散列表的联系。 */  
  5.         struct pid_link pids[PIDTYPE_MAX];  
  6. ...  
  7. }; 

辅助数据结构pid_link可以将task_struct连接到表头在struct pid中的散列表上:

 
              
  1. <pid.h> 
  2. struct pid_link  
  3. {  
  4.         struct hlist_node node;  
  5.         struct pid *pid;  
  6. }; 

pid指向进程所属的pid结构实例,node用作散列表元素。

为在给定的命名空间中查找对应于指定PID数值的pid结构实例,使用了一个散列表:

 
              
  1. kernel/pid.c  
  2. static struct hlist_head *pid_hash; 

hlist_head是一个内核的标准数据结构,用于建立双链散列表(附录C描述了该散列表的结构,并介绍了用于处理该数据结构的几个辅助函数)。

pid_hash用作一个hlist_head数组。数组的元素数目取决于计算机的内存配置,大约在24=16和212=4096之间。 pidhash_init用于计算恰当的容量并分配所需的内存。

假如已经分配了struct pid的一个新实例,并设置用于给定的ID类型。它会如下附加到task_struct:

 
              
  1. kernel/pid.c  
  2. int fastcall attach_pid(struct task_struct *task, enum pid_type type,  
  3.                 struct pid *pid)  
  4. {  
  5.         struct pid_link *link;  
  6.  
  7.         link = &task->pids[type];  
  8.         link->pidpid = pid;  
  9.         hlist_add_head_rcu(&link->node, &pid->tasks[type]);  
  10.  
  11.         return 0;  

这里建立了双向连接:task_struct可以通过task_struct->pids[type]->pid访问pid实例。而从 pid实例开始,可以遍历tasks[type]散列表找到task_struct。hlist_add_head_rcu是遍历散列表的标准函数,此外还确保了遵守RCU机制(参见第5章)。因为,在其他内核组件并发地操作散列表时,可防止竞态条件(race condition)出现。

函数

内核提供了若干辅助函数,用于操作和扫描上面描述的数据结构。本质上内核必须完成下面两个不同的任务。

(1) 给出局部数字ID和对应的命名空间,查找此二元组描述的task_struct。

(2) 给出task_struct、ID类型、命名空间,取得命名空间局部的数字ID。

我们首先专注于如何将task_struct实例变为数字ID。这个过程包含下面两个步骤。

(1) 获得与task_struct关联的pid实例。辅助函数task_pid、task_tgid、task_pgrp和task_session分别用于取得不同类型的ID。获取PID的实现很简单:

 
              
  1. <sched.h> 
  2. static inline struct pid *task_pid(struct task_struct *task)  
  3. {  
  4.         return task->pids[PIDTYPE_PID].pid;  

获取TGID的做法类似,因为TGID不过是线程组组长的PID而已。只要将上述实现替换为task-> group_leader->pids[PIDTYPE_PID].pid即可。

找出进程组ID则需要使用PIDTYPE_PGID作为数组索引,但该ID仍然需要从线程组组长的task_ struct实例获取:

 
              
  1. <sched.h> 
  2. static inline struct pid *task_pgrp(struct task_struct *task)  
  3. {  
  4.         return task->group_leader->pids[PIDTYPE_PGID].pid;  

(2) 在获得pid实例之后,从struct pid的numbers数组中的uid信息,即可获得数字ID:

 
              
  1. kernel/pid.c  
  2. pid_t pid_nr_ns(struct pid *pid, struct pid_namespace *ns)  
  3. {  
  4.         struct upid *upid;  
  5.         pid_t nr = 0;  
  6.  
  7.         if (pid && ns->level <= pid->level) {  
  8.                 upid = &pid->numbers[ns->level];  
  9.                 if (upid->ns == ns)  
  10.                         nr = upid->nr;  
  11.         }  
  12.         return nr;  

因为父命名空间可以看到子命名空间中的PID,反过来却不行,内核必须确保当前命名空间的level小于或等于产生局部PID的命名空间的 level。

同样重要的是要注意到,内核只需要关注产生全局PID。因为全局命名空间中所有其他ID类型都会映射到PID,因此不必生成诸如全局TGID或 SID。

除了在第2步使用的pid_nr_ns之外,内核还可以使用下列辅助函数:

pid_vnr返回该ID所属的命名空间所看到的局部PID;

pid_nr则获取从init进程看到的全局PID。

这两个函数都依赖于pid_nr_ns,并自动选择适当的level:0用于获取全局PID,而pid->level则用于获取局部PID。

内核提供了几个辅助函数,合并了前述步骤:

 
              
  1. kernel/pid.c  
  2. pid_t task_pid_nr_ns(struct task_struct 
    *tsk, struct pid_namespace *ns)  
  3. pid_t task_tgid_nr_ns(struct task_struct 
    *tsk, struct pid_namespace *ns)  
  4. pid_t task_pgrp_nr_ns(struct task_struct 
    *tsk, struct pid_namespace *ns)  
  5. pid_t task_session_nr_ns(struct task_struct 
    *tsk, struct pid_namespace *ns) 

从函数名可以明显推断其语义,因此我们不再赘述。

2.3.3 进程ID号(3)

现在我们把注意力转向内核如何将数字PID和命名空间转换为pid实例。同样需要下面两个步骤。

(1) 给出进程的局部数字PID和关联的命名空间(这是PID的用户空间表示),为确定pid实例(这是PID的内核表示),内核必须采用标准的散列方案。首先,根据PID和命名空间指针计算在pid_hash数组中的索引, 然后遍历散列表直至找到所要的元素。这是通过辅助函数find_pid_ns处理的:

 
              
  1. kernel/pid.c  
  2. struct pid * fastcall find_pid_ns(int nr,
    struct pid_namespace *ns) 

struct upid的实例保存在散列表中,由于这些实例直接包含在struct pid中,内核可以使用container_of机制(参见附录C)推断出所要的信息。

(2) pid_task取出pid->tasks[type]散列表中的第一个task_struct实例。

这两个步骤可以通过辅助函数find_task_by_pid_type_ns完成:

 
              
  1. kernel/pid.c  
  2. struct task_struct *find_task_by_pid_type_ns(int type, int nr,  
  3.                 struct pid_namespace *ns)  
  4. {  
  5.         return pid_task(find_pid_ns(nr, ns), type);  

一些简单一点的辅助函数基于最一般性的find_task_by_pid_type_ns:

find_task_by_pid_ns(pid_t nr, struct pid_namespace * ns)根据给出的数字PID和进程的命名空间来查找task_struct实例。

find_task_by_vpid(pid_t vnr)通过局部数字PID查找进程。

find_task_by_pid(pid_t nr)通过全局数字PID查找进程。

内核源代码中许多地方都需要find_task_by_pid,因为很多特定于进程的操作(例如,使用kill发送一个信号)都通过PID标识目标进程。

3. 生成唯一的PID

除了管理PID之外,内核还负责提供机制来生成唯一的PID(尚未分配)。在这种情况下,可以忽略各种不同类型的PID之间的差别,因为按一般的 UNIX观念,只需要为PID生成唯一的数值即可。所有其他的ID都可以派生自PID,在下文讨论fork和clone时会看到这一点。在随后的几节中,名词PID还是指一般的UNIX进程ID(PIDTYPE_PID)。

为跟踪已经分配和仍然可用的PID,内核使用一个大的位图,其中每个PID由一个比特标识。PID的值可通过对应比特在位图中的位置计算而来。

因此,分配一个空闲的PID,本质上就等同于寻找位图中第一个值为0的比特,接下来将该比特设置为1。反之,释放一个PID可通过将对应的比特从1 切换为0来实现。这些操作使用下述两个函数实现:

 
              
  1. kernel/pid.c  
  2. static int alloc_pidmap(struct pid_namespace *pid_ns) 

用于分配一个PID,而

 
              
  1. kernel/pid.c  
  2. static fastcall void free_pidmap(struct 
    pid_namespace *pid_ns, int pid) 

用于释放一个PID。我们这里不关注具体的实现方式,但它们必须能够在命名空间下工作。

在建立一个新进程时,进程可能在多个命名空间中是可见的。对每个这样的命名空间,都需要生成一个局部PID。这是在alloc_pid中处理的:

 
              
  1. kernel/pid.c  
  2. struct pid *alloc_pid(struct pid_namespace *ns)  
  3. {  
  4.         struct pid *pid;  
  5.         enum pid_type type;  
  6.         int i, nr;  
  7.         struct pid_namespace *tmp;  
  8.         struct upid *upid;  
  9. ...  
  10.         tmp = ns;  
  11.         for (i = ns->level; i >= 0; i--) {  
  12.                 nr = alloc_pidmap(tmp);  
  13. ...  
  14.                 pid->numbers[i].nr = nr;  
  15.                 pid->numbers[i].ns = tmp;  
  16.                 tmptmp = tmp->parent;  
  17.         }  
  18.         pid->level = ns->level;  
  19. ... 

起始于建立进程的命名空间,一直到初始的全局命名空间,内核会为此间的每个命名空间分别创建一个局部PID。包含在struct pid中的所有upid都用重新生成的PID更新其数据。每个upid实例都必须置于PID散列表中:

 
              
  1. kernel/pid.c  
  2.         for (i = ns->level; i >= 0; i--) {  
  3.                 upid = &pid->numbers[i];  
  4.                 hlist_add_head_rcu(&upid->pid_chain,  
  5.                                 &pid_hash[pid_
    hashfn(upid->nr, upid->ns)]);  
  6.         }  
  7. ...  
  8.         return pid;  

2.3.4 进程关系

除了源于ID连接的关系之外,内核还负责管理建立在UNIX进程创建模型之上"家族关系"。相关讨论一般使用下列术语。

如果进程A分支形成进程B,进程A称之为父进程而进程B则是子进程。

如果进程B再次分支建立另一个进程C,进程A和进程C之间有时称之为祖孙关系。

如果进程A分支若干次形成几个子进程B1,B2,…,Bn,各个Bi进程之间的关系称之为兄弟关系。

图2-6说明了可能的进程家族关系。

 
(点击查看大图)图2-6 进程之间的家族关系
task_struct 数据结构提供了两个链表表头,用于实现这些关系:
 
              
  1. <sched.h>   
  2. struct task_struct {  
  3. ...  
  4.         struct list_head children;  /* 子进程链表 */  
  5.         struct list_head sibling;   /* 连接到父进程的子进程链表 */   
  6. ...   

children是链表表头,该链表中保存有进程的所有子进程。

sibling用于将兄弟进程彼此连接起来。

新的子进程置于sibling链表的起始位置,这意味着可以重建进程分支的时间顺序。