突破最大进程数限制

作者:张勇

进程管理是操作系统最重要的组成部分。其设计和实现可以极大地影响性能。在多进程操作系统中,许多进程同时运行,从而提高了 CPU 使用率和系统性能。通过并发运行进程,我们可以提供多项服务,同时服务更多客户端,这是现代操作系统的主要任务。

在 Linux Intel i386 架构中,已经支持多进程。通过选择合适的进程调度算法,它具有较低的平均响应时间和相对较高的系统性能。但不幸的是,Linux 内核 2.2.x 中存在一个限制,将运行进程的数量限制为 4090 个。这个数字对于桌面系统可能足够,但对于企业服务器来说是不够的。

考虑一下典型 Web 服务器的基本原理,它是基于多进程/多线程技术的。当客户端请求到来时,Web 服务器会创建一个子进程或线程来处理该请求。因此,对于高负载服务器来说,很容易有数千个进程在运行。事实上,大多数此类企业服务器运行的是 Solaris、AIX、HP-UX 等操作系统,而不是 Linux。

许多 Linux 开发者已经注意到这个问题,并尝试解决它。在实验版本 2.3.x 和预发布版本 2.4 中,这个问题已经得到处理。但是,2.4 的正式发布还需要一段时间,并且使其稳定可能需要更长的时间。这是否意味着我们必须选择另一个操作系统?是否有可能找到一种解决方案来突破 Linux 2.2.x 的这一限制?为了回答这个问题,首先我们必须了解 2.2.x 中的进程管理是如何工作的。

Intel i386 架构和 Linux 2.2.x 内存管理

进程管理与内存管理紧密相关。由于内存管理的实现是基于硬件架构的,我们首先必须了解 i386 架构。在现代操作系统中,虚拟内存技术被广泛采用。得益于虚拟内存技术,软件可以使用比物理内存更多的内存。也就是说,软件使用的内存地址是虚拟的,并在访问期间通过处理器提供的机制转换为实际地址。

有两种基本的内存管理方法:分段和分页。分段是指将内存划分为多个段,并通过段指针和偏移量访问内存。这种方法用于早期的系统,如 PDP-11 等。分页是指将内存划分为多个固定大小的页,并使用页作为基本的内存管理单元。访问内存时,地址会根据页表转换为物理地址。

i386 架构中的内存管理称为分段分页。虚拟地址空间首先使用两个表划分为段:全局描述符表 (GDT) 和局部描述符表 (LDT)。之后,虚拟地址转换为线性地址。然后,线性地址使用两级页表(页目录表和页表)转换为物理地址。图 1 显示了虚拟地址如何转换为实际地址。

Breaking through the Maximum Process Number

图 1. 虚拟地址转换

Breaking through the Maximum Process Number

图 2. 全局描述符表

在 Linux 中,内核在 ring 0 中运行。通过设置 GDT,内核将其代码和数据放入单独的地址空间中。所有其他程序都在 ring 3 中运行,其数据和代码在相同的地址空间中。创建不同的页表可以保护这些用户程序。图 2 显示了 Linux 2.2.x 中的 GDT 表。在实践中,用户程序可以通过设置 LDT 来使用其他代码/数据段。

内核 2.2.x 进程管理

进程是一个正在运行的程序,它分配了所有资源。这是一个动态的概念。在 i386 架构中,“任务”是进程的另一个名称。为了方便起见,这里我们只使用进程。进程管理是一个与系统初始化、进程创建和销毁、调度、进程间通信等相关的概念。在 Linux 中,进程实际上是一组数据结构,包括进程上下文、调度数据、信号量、进程队列、进程 ID、时间、信号等。这组数据称为进程控制块或 PCB。在实现中,PCB 位于进程堆栈的底部。

Linux 中的进程管理很大程度上依赖于硬件架构。我们刚刚讨论了 i386 中分段分页内存管理的基础,但实际上,段的作用不仅仅是一个内存块。例如,任务状态段是 i386 中最重要的段之一。它包含系统需要的许多数据。每个进程都必须有一个由 TR 寄存器指向的 TSS。根据 i386 的定义,TR 中的选择器必须选择 GDT 中的描述符。此外,LDTR 中的选择器(定义进程 LDT)也必须在 GDT 中有一个对应的条目。

为了满足上述要求,Linux 2.2.x GDT 为所有可能的进程分配。最大并发进程数在启动内核时定义。内核为每个进程保留 2 个 GDT 条目。

系统初始化

在 Linux 2.2.x 中,一些与进程管理相关的数据结构在启动系统时初始化。其中最重要的是 GDT 和进程列表。

当内核启动时,它必须决定 GDT 的大小。由于每个进程必须在 GDT 中保留两个 GDT 条目,因此 GDT 的大小由最大并发进程数定义。在 Linux 中,此数字在编译时定义为 NR_TASKS。根据图 2,GDT 的大小为 10+2(带 APM)+NR_TASKS*2。

进程列表实际上是一个 PCB 指针数组,定义如下

Struct task_struct *task[NR_TASKS] = {&init_task,};

在上面这行代码中,init_task 是根进程的 PCB。将此进程插入进程列表后,进程管理机制就可以开始工作了。请注意,进程列表的大小也取决于 NR_TASKS

进程创建

在 Linux 2.2.x 中,进程是通过系统调用 fork 创建的。新进程是原始进程的子进程。使用 clone 可以创建一个线程,这实际上是一个轻量级进程。事实上,Linux 2.2.x 中没有真正的线程。图 3 显示了 fork 系统调用的工作原理。

Breaking through the Maximum Process Number

图 3. Fork 系统调用

fork 中的关键步骤是

  1. 创建新进程 PCB:内核为新进程堆栈分配两个页面,并将 PCB 放在堆栈底部。

  2. 将新进程插入进程列表:内核必须从进程列表中找到一个空条目。如果系统已达到最大并发进程限制,则找不到空条目,并且系统调用失败。

  3. 复制父进程地址空间:子进程有自己的地址空间,但首先它使用写时复制机制与其父进程共享地址空间。新进程 LDT 的相应 GDT 描述符也在此步骤中创建。

  4. 为新进程设置 TSS:新进程的 TSS 在 PCB 中创建,并且也创建了相应的 GDT 描述符。

调度

调度的核心是下面列出的算法。但在这里,我们只看一下进程切换。在 Linux 2.2.x 中,进程切换是在 switch_to 函数中完成的。它的工作方式如下

  1. 通过设置 TR 加载新的 TSS

  2. 将旧的 FS 和 GS 寄存器保存到旧的 PCB 中

  3. 如果新进程需要,则加载 LDT

  4. 为新进程加载新的页表

  5. 加载新进程的 FS 和 GS

请注意,TR 和 LDTR 的值来自 PCB。

突破最大进程数限制

什么是最大进程数限制?根据上面的讨论,我们可以很容易地找到为什么存在最大进程数限制。Linux 2.2.x 中定义的 NR_TASKS 在编译时静态定义了最大并发进程数。NR_TASKS 也在编译时定义了 GDT 的大小。正如 i386 架构中定义的,GDT 的最大大小为 8192*8 字节,这意味着它可以包含 8192 个描述符。在 Linux 2.2.x 中,当启动内核时,GDT 的使用方式如下所述

  1. NULL 描述符(条目 0),保留描述符(条目 1,6,7)

  2. 内核代码和数据描述符(条目 2,3)以及用户代码和数据描述符(条目 4,5)

  3. APM BIOS 描述符(条目 8-11)

总共使用了 12 个条目。由于每个进程需要两个 GDT 条目,因此理论上我们可以并发运行 (8192 -12)/2 = 4090 个进程。

此问题的解决方案

尽管 GDT 大小受到硬件限制,但我们仍然可以找到解决此问题的方法。对于一个 CPU,在特定时间只能运行一个进程。也就是说,完全没有必要为所有可能的进程保留 GDT 描述符。当进程即将运行时,我们动态设置其描述符。

在分析 PCB 结构后,我们可以在其中找到 TSS 和 LDT(如果有)。因此,在进行进程切换时,我们可以通过 PCB 指针找到这两个段,如下所示

TSS: proc->tss
LDT:proc->mm->segments

事实上,在进行进程切换时,我们可以从进程列表中找到 PCB 指针。由于 TSS 和 LDT 都可以找到,因此没有必要始终将它们保存在 GDT 中。

我们的解决方案是为每个 CPU 仅保留两个 GDT 描述符,为所有进程使用通用条目。例如,在具有两个 CPU 的机器中,保留四个 GDT 条目。当进程 A 将在 CPU1 上运行时,GDT 条目三和四将被设置为进程 A 的 TSS 和 LDT 的描述符。这些条目的旧值将被丢弃。剩余的 GDT 条目的使用方式与原始系统相同。

实现简述

我们解决方案的基础是动态设置进程 TSS 和 LDT 描述符(参见列表 1)。

列表 1. 系统初始化

进程切换

在原始设计中,当执行 fork 操作时,PCB 中的 tss.ldt 和 tss.tr 用于保存 LDTR 和 TR 中的选择器。根据原始算法,进程 LDT 的选择器可能会超过其 16 位限制。因此,我们使用额外的变量 tss.__ldth 与 tss.ldt 一起保存选择器。由于 tss.__ldth 在 Linux 2.2.x 中未使用,因此我们的修改不会破坏内核。现在 LDTR 和 TR 的保存方式如下

((unsigned long *) & (p->tss.ldt)) =
   (unsigned long)_LDT(nr);
if (*((unsigned long *) & (p->tss.ldt)) <
   (unsignedlong)(8192<< 3)
        set_ldt_desc(nr,ldt, LDT_ENTRIES);
        // original code here else{
        //do nothing
        //let the process switch code handle LDT
        //and TSS
}

此实现的好处之一是,我们可以通过检查 tss.ldt 的值轻松发现此进程数是否大于 4088。这对于性能非常重要。

如果进程数大于 4,088,则它在 GDT 中没有保留的描述符,并且必须使用共享的 GDT 条目。我们可以通过以下代码找到这些条目

SHARED_TSS_ENTRY + smp_processor_id();

列表 2 显示了处理共享 GDT 条目的代码。

列表 2. 使用共享 GDT 条目

完成这些操作后,我们已经突破了最大进程数限制。我们甚至可以在 lilo 配置文件中添加一个额外的参数来动态设置此数字。以下行将最大进程数设置为 40,000,这远大于 4,090

        Append = "nrtasks=40000"
结论

根据上述解决方案,理论上我们可以将并发进程数的上限设置为 2G。但在实践中,硬件和操作系统仍然限制了这个数字。创建新进程时,内核会为其分配内存,如下所示

Process stack (2 pages) + page table (1 page)
+ page directory table (1 page) = 4 pages

因此,如果计算机有 1G 内存,每个进程使用五个页面,而操作系统使用 20M 内存,则最大进程数可以是

(1G - 20M) / 20K = 51404 ~= 50,000
更实际地说,一个进程至少会使用 30K 内存,所以现在的数字是
50000 * (2/3) = 33,000
这个数字仍然远大于 4,090。

Breaking through the Maximum Process Number
张勇 (leon@xteamlinux.com.cn) 是 Xteam Software Co., Ltd. 的高级软件工程师。他的工作涵盖 Linux 的许多方面,包括内核开发、Linux I18N&I10N 和网络应用程序等。他目前专注于即将发布的新版本 XteamServer,这是一个基于 Linux 的高端服务器解决方案。Xteam Software Co., Ltd. 也是 XteamLinux 和 XteamLindows 的供应商。它们都是中国最流行的 Linux 发行版。有关更多信息,请访问 http://www.xteamlinux.com.cn/
加载 Disqus 评论