当前位置:Linux教程 - Linux综合 - Linux 2.2.X进程管理分析及最大进程数限制的突破

Linux 2.2.X进程管理分析及最大进程数限制的突破

进程管理是操作系统最关键的部分,它的设计和实现直接影响到整个系统的性能。在一个多进程的操作系统中,一个时间段内可以有多个进程“并发”执行。这样就避免了较为快速的cpu等待较为低速的I/O设备的情况,提高了cpu利用率,从而提高系统的性能。另一个方面,同时运行多个进程,就可以同时提供多种服务或者同时为更多的客户服务,这也是现代操作系统的基本任务。

在基于Intel i386体系结构的Linux操作系统中,已经提供了这样的多进程运行的支持。通过合理的选择进程调度算法,可以获得比较好的平均相应时间和较高的系统性能。但是,美中不足的是,在目前的2.2.x版本的Linux内核中,存在对最大进程数的限制。也就是说,在目前的Linux系统中,最多只能有4090个用户进程同时存在。对于一般的桌面应用,这个数目是绰绰有余。但是,对于企业级的服务器应用来说,则是不够的。

设想一个典型的Web服务器软件,它们一般都采用多进程/线程的结构。每当接到一个连接请求,就产生一个子进程/线程来处理。显然,对于一个重负载的服务器来说,同时有成千上万个连接是很常见的。而这时,采用kernel 2.2.x的系统就不能胜任了。因此,可以说正是因为存在这个最大进程数的限制,使得Linux不能胜任企业级服务器操作系统的工作。事实上,目前这个级别的操作系统一般都是较为成熟,并且没有上述限制的Solaris、AIX、HP-UX等系统。

值得一提的是,众多的Linux开发者已经认识到了这个问题。kernel 2.2.x的后续版本,例如试验版本2.3.x和2.4都已经着手解决这个问题。然而,现在离2.4的发布还有很长的时间,同时2.4在使用中达到稳定,又需要一段时间。在这一段时间中,我们是不是只有等待或者选用其他系统呢?或者说,能否有一种方案,可以在2.2.x的内核上突破这个进程数的限制呢?

要回答这个问题,就必须先对2.2.x核心的进程管理加以分析。

一、i386体系结构与kernel V2.2.X存储管理

提到进程管理就必须首先了解基本的i386体系结构和存储管理。这是因为体系结构决定了存储管理的实现,而进程管理又与存储管理密不可分。在现代操作系统中,存储管理一般都采用虚拟存储的方式,也就是系统使用的地址空间与实际物理地址空间不同,是“虚拟”的地址。处理器提供一定的机制将“虚拟”的地址转换为实际的地址。操作系统的存储管理就是基于处理器提供的地址转换机制来实现的。

基本的存储管理有两种方式,即段式和页式。段式管理就是将内存划分为不同的段(segment),通过段指针来访问各个段的方法。在比较早的系统中,如pdp-11等就是采用这种方法。这种方法的缺点是在设计程序时必须考虑段的划分,这是很不方便的。页式管理就是将内存划分为固定大小的若干个页面(page),以页面为单位分配使用。通过页映射表完成地址转换。

i386的存储管理采用两级虚拟的段页式管理,就是说它先分段,再分页。具体地说,它通过gdt(Global Descriptor Table)和ldt(Local Descriptor Table)进行分段,把虚拟地址转换为线性地址。然后采用两级页表结构进行分页,把线性地址转换为物理地址。下图表示了i386中虚拟地址到物理地址的转换:

   虚拟地址------>线性地址------>物理地址
        (分段)       (分页)

       图1. i386虚拟地址转换

在Linux中,操作系统处于处理器的0特权级,通过设置gdt,将它的代码和数据放在独立的段中,以区别于供用户使用的用户数据段和代码段。所有的用户程序都使用相同的数据段和代码段,也就是说所有的用户程序都处于同一个地址空间中。程序之间的保护是通过建立不同的页表映射来完成的。Linux 2.2.x gdt表中的段描述符如下图所示:

          ...
          ...
        APM BIOS数据段
       APM BIOS16位代码段
        APM BIOS代码段
        APM BIOS保留
          保留
          保留
         用户数据段
         用户代码段
         内核数据段
         内核代码段
         空描述符

       图2. 全局描述符表gdt


在实际的使用中,用户程序还可以通过调用设置ldt的系统调用来拥有自己的地址空间。

二、Kernel 2.2.x进程管理

进程是一个程序的一次执行的过程,是一个动态的概念。在i386体系结构中,任务和进程是等价的概念。进程管理涉及了系统初始化、进程创建/消亡、进程调度以及进程间通讯等等问题。在Linux的内核中,进程实际上是一组数据结构,包括进程的上下文、调度数据、信号处理、进程队列指针、进程标识、时间数据、信号量数据等。这组数据都包括在进程控制块PCB(Process Control Block)中。

Linux进程管理与前面的i386体系结构关系十分密切。前面已经讨论了i386所采用的段页式内存管理的基本内容,实际上,i386中的段还有很多用处。例如,在进程管理中要用到一种特殊的段,就是任务状态段tss(Task Status Segment)。每一个进程都必须拥有自己的tss,这是通过设置正确的tr寄存器来完成的。根据i386体系结构的定义,tr寄存器中存放的是tss的选择符(selector),该选择符必须由gdt中的项(即描述符descriptor)来映射。同样的,进程的ldt也有这种限制,即ldtr对应的选择符也必须由gdt中的项来映射。

[1] [2] [3] 下一页 


在kernel 2.2.x中,为了满足i386体系结构的这种要求,采用了预先分配进程所需要的gdt项目的方法。也就是为每一个进程保留2项gdt条目。进程PCB中的tr和ldtr值就是它在gdt中的选择符。进程与它的gdt条目的对应关系可以由task数组来表示。

下面是详细的分析。

1.系统初始化
在Linux 2.2.x中,一些与进程管理相关的数据结构是在系统初始化的时候被初始化的。其中最重要的是gdt和进程表task。
Gdt的初始化主要是确定需要为多少个进程保留空间,也就是需要多大的gdt。这是由一个宏定义NR_TASKS决定的。NR_TASKS的值就是系统的最大进程数,它是在编译的时候被确定的。由图2可以知道,gdt的大小是10+2+NR_TASKS*2。
进程表task实际上是一个PCB指针数组,其定义如下:

StrUCt task_struct *task[NR_TASKS] = ;

其中,init_task是系统的第0号进程,也是所有其它进程的父进程。系统在初始化的时候,必须手工设置这个进程,把它加到进程指针表中去,才能启动进程管理机制。可以看出,这里task的大小同样依赖于NR_TASKS的定义。

2.进程创建
在Linux 2.2.x中,进程是通过系统调用fork创建的,新的进程是原来进程的子进程。需要说明的是,在2.2.x中,不存在真正意义上的线程(Thread)。Linux中常用的线程pthread实际上是通过进程来模拟的。也就是说linux中的线程也是通过fork创建的,是“轻”进程。fork系统调用的流程如下:

              (Yes)错误处理 --> 返回
                       ^
               |
Do-fork --> 创建进程PCB(a)--> 超过最大进程数?-->(No)加入进程表(b)--> 设置进程属性 --> 复制父进程数据 --> 复制父进程地址空间(c)--> 复制父进程tss(d)--> 返回

                 图3.fork系统调用流

比较关键的步骤是:
a、b) 将新进程的PCB加入到进程表中。从前面的讨论可以知道,进程表task的大小是预先定义的,所以首先要从task中找到一个空的表项:nr = find_empty_process()。如果不能找到空的表项,说明系统已经达到了最大进程数的上限,不能创建新进程。返回的nr是空闲表项的下标。
c) 将父进程的地址空间复制到子进程中。在这一步中,还要复制父进程的ldt到子进程,然后在gdt中建立子进程的ldt描述符(descriptor),并将这个选择符保存在PCB中。
d) 为子进程设置tss。同时在gdt中建立子进程的tss描述符,并将这个选择符保存在PCB中。


3.进程调度
这里我们暂不讨论进程调度的算法,只是看一看进程切换时应该做的工作。在Linux 2.2.x中,进程切换是在switch_to函数中完成的。Switch_to基本操作如下:
1) 设置ltr,加载新进程的tss
2) 保存原进程的fs,gs寄存器到它的PCB中。
3) 如果需要,加载新进程的ldt
4) 加载新进程的页表
加载新进程的fs和gs
以上的tss和ldt的选择符都是从进程的PCB中取出的。


三、突破最大进程数的限制

1.限制的起因

通过以上的讨论,我们可以看出Linux 2.2.x最大进程数限制产生的原因:
Linux 2.2.x中定义的宏NR_TASKS,在编译时静态决定了X86系统运行时的进程数上限。该数在系统初始化时静态决定了gdt表的总长度。但因为i386体系结构的特点,全局描述符表寄存器gdtr长度域为16位,每项描述符为8字节,故可容纳的
最大描述符数 = 1《(16-3)=1《13 = 8192 个。

内核在初始化时对gdt表的前12 项已有特殊安排,如下所示:
1) 空描述符(第0项),保留描述符(第1,6,7项)
2) 内核代码、数据段(第2,3项) 及用户代码、数据段(第4,5项)
3) APM BIOS 使用段(第8--11项)
同时由于每个进程需要两项分别存放tss和ldt描述符,因此理论上
   可用于进程的数目为 = (8192-12)/2=4090个。


2.解决方案

可见,突破最大进程数的限制,必须解决没有足够的gdt表项的问题。Gdt的大小是硬件限制的,所以只能考虑动态地设置进程的tss和ldt描述符,取消为进程预先分配gdt空间的做法。
事实上,根据进程数据结构PCB的分析可知,内核中可以动态地寻址到每个进程的tss 和ldt (如果有的话)段,因此在任务切换时通过PCB指针即可引用上述两段的寻址,分别为:

进程tss段:proc->tss
进程ldt段:proc->mm->segments

所以,静态地分配并保留这两个段的描述符是完全没有必要的。我们可以在任何需要的时候建立起它们,例如在切换时将该两段的段描述符动态设置到gdt表中即可。
这种方法的关键在于在gdt中为每个cpu保留两个描述符,分别存放该cpu上正在运行的进程的tss、ldt描述符。这两个描述符则是在进程切换时由操作系统根据进程PCB中的内容建立起来的。


3.方案实现

要突破上述4090进程数的限制,需要动态设置进程的TSS及LDT段描述符。具体体现在两方面:
1)系统初始化:
   a)在head.S中将静态设置的GDT表长度置为最大值8192。
   b)在desc.h的GDT表定义中,在进程可用的第一项位置插入NR_CPUS*2项,作为每颗CPU的专用项。用于超出GDT表之外的进程运行使用。仍留下4090-NR_CPUS=4090-32=4058项用于原有算法使用。

CPU0:SHARED_TSS_ENTRY  = 12
   SHARED_LDT_ENTRY  = 13
CPU1:SHARED_TSS_ENTRY+1 = 14
   SHARED_LDT_ENTRY+1 = 15
...
CPUn:SHARED_TSS_ENTRY+n = SHARED_TSS_ENTRY + n

上一页 [1] [2] [3] 下一页 

   SHARED_LDT_ENTRY+n = SHARED_LDT_ENTRY + n
(n
c)改变宏NR_TASKS为变量 int NR_TASKS,并在初始化函数start_kernel()中合适位置动态分配进程指针数组空间:

task = kmalloc(sizeof(void*)*NR_TASKS,GFP_ATOMIC);

这样,符号名task在内核中的使用方法与静态分配时一样。另外变量NR_TASKS可在lilo启动时以参数形式传入内核。

d)修改 parse_options(),使内核能够识别lilo传来的表示最大进程数的变量nrtasks,以用于task数组空间的分配。

2)任务切换

a) fork时,PCB中的tss.ldt 和 tss.tr分别用于保存该进程的ldt和tss的选择符,以便在切换时装载 tr寄存器和ldtr寄存器.由于现在存在大于4090的进程,按照原有算法计算,这些进程的ldt将会超出16位的范围,所以这里我们使用了tss结构中保留未用的short __ldth ,与short ldt合并使用变为长整数 (int) ldt,因此赋值指令变为:

*((unsigned long*)&(p->tss.ldt)) = (unsigned long)_LDT(nr);

if(*((unsigned long*)&(p->tss.ldt))<(unsigned long)(8292<<3))
set_ldt_desc(nr,ldt,LDT_ENTRIES);// 此句为原有代码;
else{ //什么也不做,留在切换时作类似设置工作}

这种方法的一个好处是仅仅通过判断PCB中ldt的值,就可以知道该进程是否大于4090,这大大方便了下面的进程切换的实现。

b) 切换时:我们已允许超出gdt表数目的进程数存在,即超出部分的进程无法在gdt表中预先分配,此时可以使用上述为每颗CPU预留的gdt入口,在gdt表中的寻址为:

SHARED_TSS_ENTRY+smp_processor_id();

这样,超出GDT表的进程段描述符的设置使用如下算法,而未超出部分的处理不变:

...
#define set_shared_tss_desc(addr,cpu)\
  _set_tssldt_desc(gdt_table+SHARED_TSS_ENTRY+2*cpu,(int)addr,235,0x89);

#define set_shared_ldt_desc(addr,size,cpu)\
  _set_tssldt_desc(gdt_table+SHARED_LDT_ENTRY+2*cpu,(int)addr,((size<<3)-1),0x82);
....
void __switch_to(task_struct *prev,task_struct *next){
...
if(next->tss.tr <= 0x0000ffff)
  {
    由原来的代码处理...
  }else
  {
    set_shared_tss_desc(&next->tss),smp_processor_id());
    set_shared_ldt_desc(&next->mm->segments,LDT_ENTRIES,smp_processor_id());
  }

  //接下来用适当的值装载ldtr 和tr 寄存器。
  ...
}

综上所述,可以以尽量简单的改动来突破最大进程数的限制,并在lilo设置文件中写上特定参数,启动时自动传入内核,就可以实现在kernel v2.2.x下超过4090个的进程数了。参数的格式为:

append = “nrtasks=40000”

4.方案总结

经过以上修改,使系统理论上的进程数上限数可设置到2的31次方.但实际使用中,每增加一个进程纯内核内存空间分配的代价为:

PCB及内核栈2页 + 页目录1页 + 页表1页(至少) = 4页(共16k)

因此,假设机器内存1G,进程内存管理开销以20k计算,操作系统占据约20M,则最大进程数为:

(1G-20M)/20k=(1073741824-20971520)/20480 = 51404 ~= 5 万.

实际应用进程的开销不止20k,若假设为30k,则

1G机器实际最大进程数为=5万*(2/3) = 33000(个)

需要说明的是,Linux 2.2.x原来的实现方法在进程切换时是比较快的。我们的方案则在进程数小于4090时与原来相近,超过4090个进程后会略微慢一点。

(出处:http://www.sheup.com)


上一页 [1] [2] [3] 

  }

  //接下来用适当的值装载ldtr 和tr 寄存器。
  ...
}

综上所述,可以以尽量简单的改动来突破最大进程数的限制,并在lilo设置文件中写上特定参数,启动时自动传入内核,就可以实现在kernel v2.2.x下超过4090个的进程数了。参数的格式为:

append = “nrtasks=40000”

4.方案总结

经过以上修改,使系统理论上的进程数上限数可设置到2的31次方.但实际使用中,每增加一个进程纯内核内存空间分配的代价为:

PCB及内核栈2页 + 页目录1页 + 页表1页(至少) = 4页(共16k)

因此,假设机器内存1G,进程内存管理开销以20k计算,操作系统占据约20M,则最大进程数为:

(1G-20M)/20k=(1073741824-20971520)/20480 = 51404 ~= 5 万.

实际应用进程的开销不止20k,若假设为30k,则

1G机器实际最大进程数为=5万*(2/3) = 33000(个)

需要说明的是,Linux 2.2.x原来的实现方法在进程切换时是比较快的。我们的方案则在进程数小于4090时与原来相近,超过4090个进程后会略微慢一点。

(出处:http://www.sheup.com)


上一页 [1] [2] [3] [4] 


因此,假设机器内存1G,进程内存管理开销以20k计算,操作系统占据约20M,则最大进程数为:

(1G-20M)/20k=(1073741824-20971520)/20480 = 51404 ~= 5 万.

实际应用进程的开销不止20k,若假设为30k,则

1G机器实际最大进程数为=5万*(2/3) = 33000(个)

需要说明的是,Linux 2.2.x原来的实现方法在进程切换时是比较快的。我们的方案则在进程数小于4090时与原来相近,超过4090个进程后会略微慢一点。

(出处:http://www.sheup.com)


上一页 [1] [2] [3] [4] [5]