Linux 进程模型

作者:Moshe Bar

本月,我们开始研究 Linux 内核的内部结构。我们将深入研究 2.0。x、2.2。x 和新的 2.4。x 系列的 Linux 内核内部。虽然每周都有大量关于如何最佳使用 Linux 的文章,但很少有文章回顾内核的内部结构。为什么有必要了解内核的工作原理呢?

首先,更好地理解您的内核将使您能够在问题发生之前预防它们。如果您将 Linux 用作服务器,大多数问题将在压力下开始出现。这正是了解内核内部结构以评估问题性质变得至关重要的时候。

如果您需要查阅内核源代码,您可以从您的发行版的 CD 安装源代码,或者访问 http://lxr.linux.no/source/ 在线浏览所有源代码。

Linux 进程模型

UNIX 系统有一个基本的构建块:进程,包括线程和轻量级进程。在 Linux 下,进程模型随着每个新版本都得到了显著发展。

内核中控制所有进程的基本数据结构是进程结构,它随着进程的派生和完成或终止而动态增长和缩小。

进程结构(在内核源代码中称为 task_struct)大约为 1KB 大小。您可以使用以下程序获取确切的大小

#define __KERNEL__
#include <linux/sched.h>
main()
{
printf("sizeof(struct task_struct) - %d\n",
   sizeof(struct task_struct));
}

在 Intel 386 机器上,它正好是 960 字节。但请注意,与其他 UNIX 系统不同,此进程结构并不占用真正意义上的空间。

自 2.2。x 以来,task_struct 分配在内核堆栈的底部。我们可以将 task_struct 与内核堆栈重叠,因为 task_struct 是每个任务的结构,就像内核堆栈一样。

内核堆栈在 Intel x86 上具有 固定 大小 8192 字节。如果内核在堆栈上递归 8192-960=7232 字节,则 task_struct 将被覆盖并因此损坏,导致内核崩溃。

基本上,内核通过在堆栈底部分配任务结构,将 可用 内核堆栈的大小减小到大约 7232 字节。这样做是因为 7KB 对于内核堆栈来说已足够,其余部分用于 task_struct。这种顺序的优点如下

  • 内核不必访问内存来获取其内核结构。

  • 减少了内存使用。

  • 在任务创建时避免了额外的动态分配。

  • task_struct 将始终在 PAGE_SIZE 边界上启动,因此在市场上大多数硬件上,缓存行始终是对齐的。

一旦 Linux 进入内核模式,您可以使用以下非常快速的伪代码随时获取 task_struct 的地址

task_struct = (struct task_struct *) STACK_POINTER & 0xffffe000;
这正是以上伪代码在 Linux 下用 C 语言实现的
/* cut-and-pasted from
linux/include/asm-i386/current.h */
static inline struct task_struct * get_current(void)
{
        struct task_struct *current;
        __asm__(-andl %%esp,%0;
        -:-=- (current) : "0" (~8191UL));
        return current;
        }
例如,在奔腾 II 上,从堆栈指针重新计算 task_struct 的开头比像某些其他操作系统(例如 Solaris 7)那样通过堆栈跨函数调用传递 task_struct 地址要快得多。也就是说,内核可以通过仅检查堆栈指针的值(根本没有内存访问)来派生 task_struct 的地址。这是一个很大的性能提升,并再次表明在自由软件中可以找到精细的工程设计。这段代码是由匈牙利内核黑客 Ingo Molnar 编写的。内核堆栈由 CPU 在进入内核模式时自动设置,方法是从 fork 时设置的 CPU 任务段状态加载内核堆栈指针地址。

x86 内核堆栈的布局如下所示

----- 0xXXXX0000 (bottom of the stack and address
                 of the task struct)
TASK_STRUCT
----- 0xXXXX03C0 (last byte usable from the kernel
                  as real kernel stack)
KERNEL_STACK
----- 0xXXXX2000 (top of the stack, first byte
                  used as kernel stack)

请注意,如今,task_struct 的大小正好是 960 字节。它将在内核修订版之间发生变化,因为添加到 task_struct 或从 task_struct 中删除的每个变量都会改变大小。反过来,内核堆栈的上限将随着 task_struct 的大小而变化。

进程数据结构的内存是在 Linux 内核执行期间动态分配的。更准确地说,内核根本不分配 task_struct,只分配两页宽的内核堆栈,task_struct 将成为其中的一部分。

在许多 UNIX 系统中,内核都有一个最大进程参数。在像 Solaris 这样的商业操作系统中,它是一个自调整参数。换句话说,它会根据启动时找到的 RAM 量进行调整。但是,在 Solaris 中,您仍然可以在 /etc/system 中调整此参数。

Linux 怎么样呢?

在 Linux 2.3。x(以及未来的 2.4.0)中,它也是一个运行时可调参数。在 2.2。x 中,它是一个编译时可调参数。要在 2.2。x 中更改它,您需要更改 Linux/include/linux/tasks.h 中的 NR_TASKS 预处理器定义

#define NR_TASKS 512 /* On x86 Max 4092 or 4090
                        with APM configured. */

将此数字增加到 4090 以增加并发任务的最大限制。

在 2.3。x 中,它是一个可调参数,默认值为 系统内存大小 / 内核堆栈大小 / 2。假设您有 512MB 的 RAM;那么,可用进程的默认上限将为 512*1024*1024 / 8192 / 2 = 32768。现在,32768 个进程听起来可能很多,但对于具有数据库以及来自 LAN 或 Internet 的许多连接的企业级 Linux 服务器来说,这是一个非常合理的数字。我个人见过具有更高数量的活动进程的 UNIX 服务器。在您的安装中调整此参数可能是有意义的。在 2.3。x 中,您还可以通过运行时的 sysctl 增加最大任务数。假设管理员想要将并发任务数增加到 40,000。他只需要这样做(以 root 身份)

echo 40000 > /proc/sys/kernel/threads-max
进程和线程

在过去 10 年左右的时间里,已经从重量级进程普遍转向线程模型。原因很清楚:创建一个完整的进程及其自身的地址空间在毫秒级的时间内会占用大量时间。线程在与父进程相同的地址空间内运行,因此创建所需的时间要少得多。

Linux 下进程和线程之间有什么区别?更重要的是,从调度器的角度来看,有什么区别?简而言之——没有。

线程和进程之间唯一值得注意的区别是线程完全共享相同的地址空间。所有线程都在相同的地址空间中运行,因此上下文切换基本上只是从一个代码位置跳转到另一个代码位置。

避免 TLB(转换后备缓冲区,CPU 内将虚拟内存地址转换为实际 RAM 地址的机制)刷新和内存管理器上下文切换的简单检查是这样的

/* cut from linux/arch/i386/kernel/process.c */
/* Re-load page tables */
{
        unsigned long new_cr3 = next->tss.cr3;
        if (new_cr3 !=3D prev->tss.cr3)
        asm volatile("movl %0,%%cr3": :"r" (new_cr3));
        }

以上检查在 Linux 内核上下文切换的核心中。它只是检查当前进程和要调度的进程的页目录地址是否相同。如果它们相同,则它们共享相同的地址空间(即,它们是两个线程),并且不会写入 %%cr3 寄存器,这会导致用户空间页表失效。也就是说,将任何值放入 %%cr3 寄存器都会自动使 TLB 失效;事实上,这实际上是强制 TLB 刷新的方式。由于同一地址空间中的两个任务永远不会切换地址空间,因此 TLB 永远不会失效。

通过以上两行检查,Linux 区分了内核进程切换和内核线程切换。这是 唯一 值得注意的区别。

由于线程和进程之间根本没有区别,因此 Linux 调度器是非常干净的代码。只有少数与信号处理相关的地方区分了线程和进程。

在 Solaris 中,与线程和轻量级进程 (LWP) 相比,进程非常不利。这是我在我的 Solaris 服务器(Ultra 2 桌面,167MHz 处理器,运行 Solaris 2.6)上所做的一次测量

hirame> ftime
Completed 100 forks
Avg Fork Time: 1.137 milliseconds
hirame> ttime
Completed 100 Thread Creates
Avg Thread Time: 0.017 milliseconds

我执行了 100 次 fork 并测量了经过的时间。如您所见,平均 fork 花费了 1.137 毫秒,而平均线程创建花费了 0.017 毫秒(17 微秒)。在本例中,线程创建速度快了约 67 倍。此外,我的线程测试用例不包括线程创建调用中的标志,以告知内核使用线程创建新的 LWP 并将线程绑定到 LWP。这将增加调用的额外权重,使其更接近 fork 时间。

即使 LWP 创建缩小了进程(fork)和线程之间创建时间的差距,用户线程在资源利用率和调度方面仍然具有优势。

当然,Linux SMP(甚至单处理器)调度器足够聪明,可以优化同一 CPU 上线程的调度。发生这种情况是因为通过重新调度线程,不会发生 TLB 刷新,并且基本上根本没有上下文切换——虚拟内存寻址不会改变。线程切换与进程切换相比非常轻量级,并且调度器意识到了这一点。Linux 在两个线程之间切换时唯一做的事情(非严格顺序)是

  • 进入 schedule()。

  • 恢复新线程的所有寄存器(包括堆栈指针和浮点)。

  • 使用新线程的数据更新任务结构。

  • 跳转到新线程的旧入口点。

仅此而已。TLB 没有被触及,地址空间和所有页表保持不变。在这里,Linux 的巨大优势在于它非常快速地完成了上述操作。

其他 UNIX 系统被 SMP 锁膨胀,因此内核在到达任务切换点时会浪费时间。如果不是这样,Solaris 内核线程不会比用户空间内核线程慢。当然,基于内核的线程将在多个 CPU 之间扩展负载,但像 Solaris 这样的操作系统在 CPU 数量较少的系统上为良好扩展到许多 CPU 的好处付出了巨大的固定成本。基本上,没有技术原因表明 Solaris 内核线程应该比 Linux 内核线程更轻。Linux 只是在上下文切换路径中执行尽可能少的操作,并且它做得很快。

线程化内核

Linux 内核线程化一直在不断改进。让我们再次看看不同的版本

  • 2.0。x 没有内核线程化。

  • 2.2。x 添加了内核线程化。

  • 2.3。x 是高度 SMP 线程化的。

在 2.2。x 中,许多地方仍然是单线程的,但 2.2。x 内核实际上仅在双路 SMP 上扩展良好。在 2.2。x 中,IRQ/定时器处理(例如)是 完全 SMP 线程化的,并且 IRQ 负载分布在多个 CPU 上。

在 2.3。x 中,内核中大多数有价值的代码段正在被重写以进行 SMP 线程化。例如,所有 VM(虚拟内存)都是 SMP 线程化的。最有趣的路径现在具有更精细的粒度,并且可以很好地扩展。

性能限制

为了系统稳定性,内核必须在压力情况下做出良好反应。例如,它必须降低优先级并将资源分配给行为不端的进程。

调度器如何处理一个编写不佳的程序,该程序紧密循环并在循环的每次迭代中进行 fork(从而在几秒钟内 fork 出数千个进程)?显然,调度器不能在时间上限制进程的创建,例如,每 0.5 秒创建一个进程或类似的情况。

但是,在 fork 之后,进程的“运行时优先级”在父进程和子进程之间分配。这意味着父进程/子进程将受到相对于其他任务的惩罚,并且其他任务将继续正常运行,直到第一次重新计算优先级。这可以防止系统在 fork 泛洪期间停顿。此代码的代码段是 linux/kernel/fork.c 中相关的代码段

/*
 "share" dynamic priority between parent
 * and child, thus the total amount of dynamic
 * priorities in the system doesn't change, more
 * scheduling fairness. This is only important
 * in the first time slice, in the long run the
 * scheduling behaviour is unchanged.
 */
current->counter >>= 1;
p->counter = current->counter;

此外,在生成第一个用户进程之前,可以从 init 设置每个用户的线程限制。可以使用 bash 中的  ulimit -u 进行设置。您可以告知它 user moshe 最多可以运行十个并发任务(计数包括 shell 和用户运行的每个进程)。

在 Linux 中,root 用户始终为自己保留一些备用任务。因此,如果用户在循环中生成任务,管理员只需登录并使用 killall 命令删除冒犯用户的所有任务。由于任务的“运行时优先级”在父进程和子进程之间分配,因此内核的反应足够平稳,可以处理这种情况。

如果您想修改内核以仅允许每个处理器时钟周期(通常每 1/100 秒一个;但是,此参数是可调的)进行一次 fork,称为 jiffie,您将需要像这样修补内核

--- 2.3.26/kernel/fork.c        Thu Oct 28 22:30:51 1999
+++ /tmp/fork.c Tue Nov  9 01:34:36 1999
@@ -591,6 +591,14 @@
        int retval = -ENOMEM;
        struct task_struct *p;
        DECLARE_MUTEX_LOCKED(sem);
+       static long last_fork;
+
+       while (time_after(last_fork+1, jiffies))
+       {
+               __set_current_state(TASK_INTERRUPTIBLE);
+               schedule_timeout(1);
+       }
+       last_fork = jiffies;
        if (clone_flags & CLONE_PID) {
/* This is only allowed from the boot up thread */

这就是开源的美妙之处。如果您不喜欢某些东西,只需更改它!

这是我们 Linux 内核之旅的第一部分结束。在下一期中,我们将更详细地了解调度器的工作原理。我可以向您保证一些令人惊讶的发现。其中一些发现使我完全重新评估了 Linux 对企业服务器市场的可能影响。敬请期待。

资源

The Linux Process Model
电子邮件:moshe@moelabs.com

Moshe Bar (moshe@moelabs.com) 是一位以色列系统管理员和操作系统研究员,他早在 1981 年就开始在配备 AT&T UNIX Release 6 的 PDP-11 上学习 UNIX。他拥有计算机科学硕士学位。访问 Moshe 的网站 http://www.moelabs.com/

加载 Disqus 评论