Linux 是一个计算需求不断变化的非常动态的系统。 Linux 计算需求的表示以进程的公共抽象为中心,进程可以是短期的(从命令行执行的命令)或长期的(网络服务)。因此,进程的总体管理及其调度非常重要。
在用户空间中,进程由进程标识符 (PID) 表示。从用户的角度来看,PID 是唯一标识进程的数值。 PID 在进程的生命周期中不会改变,但 PID 可以在进程终止后重用,因此缓存它们并不总是理想的。
在用户空间中,您可以通过多种方式创建进程。您可以执行一个程序(这会导致创建一个新进程),或者在程序内,您可以调用 fork 或 exec 系统调用。 fork 调用导致创建子进程,而 exec 调用则用新程序替换当前进程上下文。我将讨论每种方法以了解它们的工作原理。
在这篇文章中,我首先展示进程的内核表示以及它们在内核中的管理方式,然后回顾在一个或多个处理器上创建和调度进程的各种方法,最后讨论如果它们死掉会发生什么,从而构建进程的描述。
进程表示
在 Linux 内核中,进程由一个称为 task_struct 的相当大的结构表示。该结构包含表示流程的所有必要数据,以及用于记账和维护与其他流程(父进程和子进程)关系的大量其他数据。对task_struct 的完整描述超出了本文的范围,但task_struct 的一部分如清单1 所示。此代码包含本文探讨的特定元素。请注意,task_struct 位于 ./linux/include/linux/sched.h 中。
/* task_struct部分代码 */
struct task_struct {
volatile long state;
void ∗stack;
unsigned int flags;
int prio, static_prio;
struct list_head tasks;
struct mm_struct ∗mm, ∗active_mm;
pid_t pid;
pid_t tgid;
struct task_struct ∗real_parent;
char comm[TASK_COMM_LEN];
struct thread_struct thread;
struct files_struct ∗files;
...
};
在上面代码片段中,你可以看到你期望的几个项目,例如执行状态、堆栈、一组标志、父进程、执行线程(可以有多个)和打开的文件。本文后面将探讨这些内容,这里简单介绍一些。状态变量是一组指示任务状态的位。最常见的状态表示进程正在运行或在运行队列中即将运行(TASK_RUNNING)、睡眠(TASK_INTERRUPTIBLE)、睡眠但无法唤醒(TASK_UNINTERRUPTIBLE)、停止(TASK_STOPPED)或其他一些状态。这些标志的完整列表可在 ./linux/include/linux/sched.h 中找到。
flags 字定义了大量的指示符,指示一切,从进程是否正在创建(PF_STARTING)或正在退出(PF_EXITING),甚至进程当前是否正在分配内存(PF_MEMALLOC)。可执行文件的名称(不包括路径)占据comm(命令)字段。
每个进程还被赋予了一个优先级(称为 static_prio),但进程的实际优先级是根据负载和其他因素动态确定的。优先级值越低,其实际优先级越高。
任务字段提供链表功能。它包含一个 prev 指针(指向上一个任务)和一个 next 指针(指向下一个任务)。
进程的地址空间由 mm 和 active_mm 字段表示。 mm 表示进程的内存描述符,而 active_mm 是前一个进程的内存描述符(改进上下文切换时间的优化)。
最后,thread_struct 标识了进程的存储状态。该元素取决于运行 Linux 的特定体系结构,你可以在 ./linux/include/asm-i386/processor.h 中查看相关示例。在此结构中,你将找到进程从执行上下文切换时的存储(硬件寄存器、程序计数器等)。
进程管理
现在,让我们探讨如何在 Linux 中管理进程。在大多数情况下,进程是动态创建的,并由动态分配的task_struct 表示。一个例外是 init 进程本身,它始终存在并由静态分配的 task_struct 表示。你可以在 ./linux/arch/i386/kernel/init_task.c 中看到这样的示例。
Linux 中的所有进程都以两种不同的方式收集。第一个是哈希表,通过PID值进行哈希;第二个是循环双向链表。循环链表非常适合迭代任务列表。由于链表是循环的,因此没有头或尾;但由于 init_task 始终存在,你可以将其用作锚点以进一步迭代。让我们看一个示例来演练当前的任务集。
任务列表无法从用户空间访问,但是你可以通过以模块的形式将代码插入内核来轻松解决该问题。下面的代码片段显示了一个非常简单的程序,它迭代任务列表并提供有关每个任务的少量信息(名称、pid 和父级名称)。请注意,该模块使用 printk 来输出内容。要查看输出,你需要使用 cat 实用程序查看 /var/log/messages 文件(或实时 tail -f /var/log/messages)。 next_task函数是sched.h中的一个宏,它简化了任务列表的迭代(返回下一个任务的task_struct引用)。
/* 用于发出任务信息的简单内核模块(procsview.c) */
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/sched.h>
int init_module( void )
{
/∗ Set up the anchor point ∗/
struct task_struct ∗task = &init_task;
/∗ Walk through the task list, until we hit the init_task again ∗/
do {
printk( KERN_INFO "∗∗∗ %s [%d] parent %s\n",
task‑>comm, task‑>pid, task‑>parent‑>comm );
} while ( (task = next_task(task)) != &init_task );
return 0;
}
void cleanup_module( void )
{
return;
}
可以使用下来代码所示的 Makefile 来编译该模块。编译后,可以使用 insmod procsview.ko 命令插入内核对象,删除时可使用 rmmod procsview 命令。
obj‑m += procsview.o
KDIR := /lib/modules/$(shell uname ‑r)/build
PWD := $(shell pwd)
default:
$(MAKE) ‑C $(KDIR) SUBDIRS=$(PWD) modules
进程创建
现在让我们来看看从用户空间创建一个进程的过程。用户空间任务和内核任务的底层机制是相同的,因为两者最终都依赖于一个名为 do_fork 的函数来创建新进程。在创建内核线程的情况下,内核调用一个名为 kernel_thread 的函数(参见 ./linux/arch/i386/kernel/process.c),该函数执行一些初始化,然后调用 do_fork。
用户空间进程的创建也会发生类似的操作。在用户空间中,程序调用 fork,这会导致对名为 sys_fork 的内核函数的系统调用(请参阅 ./linux/arch/i386/kernel/process.c)。函数关系如图 1 所示。
图1 用于进程创建的函数层次结构
从图1中,可以看到do_fork提供了进程创建的基础。你可以在 ./linux/kernel/fork.c 中找到 do_fork 函数(以及伙伴函数 copy_process)。
do_fork 函数首先调用 alloc_pidmap,分配一个新的 PID。接下来,do_fork 检查调试器是否正在跟踪父进程。如果是,则在 clone_flags 中设置 CLONE_PTRACE 标志以准备分叉。然后 do_fork 函数继续调用 copy_process,传递标志、堆栈、寄存器、父进程和新分配的 PID。
copy_process 函数是新进程创建为父进程的副本的地方。该函数执行除启动进程之外的所有操作,该进程稍后处理。 copy_process 的第一步是验证 CLONE 标志以确保它们一致。如果不是,则返回 EINVAL 错误。接下来,咨询Linux安全模块(LSM)以查看当前任务是否可以创建新任务。要了解有关安全增强型 Linux (SELinux) 环境中 LSM 的更多信息,请查看资源部分。
接下来,调用 dup_task_struct 函数(位于 ./linux/kernel/fork.c 中),该函数分配一个新的 task_struct 并将当前进程的描述符复制到其中。在建立新的线程堆栈后,一些状态信息被初始化并且控制权返回到copy_process。回到 copy_process,除了其他一些限制和安全检查之外,还执行一些内务处理,包括对新 task_struct 的各种初始化。然后调用一系列复制函数来复制进程的各个方面,从复制打开的文件描述符 (copy_files)、复制信号信息(copy_sighand 和 copy_signal)、复制进程内存 (copy_mm) 到最后复制线程 (copy_thread)。
然后,新任务被分配给处理器,并根据允许执行进程的处理器 (cpus_allowed) 进行一些额外的检查。新进程的优先级继承父进程的优先级后,将执行少量额外的内务处理,并将控制权返回到 do_fork。此时,您的新进程已存在但尚未运行。 do_fork 函数通过调用wake_up_new_task 修复了这个问题。您可以在 ./linux/kernel/sched.c 中找到该函数,它初始化一些调度程序内务信息,将新进程放入运行队列中,然后唤醒它执行。最后,返回do_fork时,PID值返回给调用者,过程完成。
进程调度
虽然 Linux 中存在进程,但它可以通过 Linux 调度程序进行调度。虽然超出了本文的讨论范围,但 Linux 调度程序为每个优先级维护一组列表,其中包含 task_struct 引用。任务通过调度函数(在 ./linux/kernel/sched.c 中提供)调用,该函数根据加载和先前进程执行历史记录确定要运行的最佳进程。您可以在右侧的资源部分了解有关 Linux 版本 2.6 调度程序的更多信息。
进程销毁
进程销毁可以由多个事件驱动–正常进程终止、通过信号或通过调用退出函数。然而进程退出是被驱动的,进程通过调用内核函数 do_exit(在 ./linux/kernel/exit.c 中可用)结束。此过程如图 2 所示。
![图2
进程销毁的函数层次结构]
do_exit 背后的目的是从操作系统中删除对当前进程的所有引用(对于所有未共享的资源)。销毁进程首先通过设置PF_EXITING标志来表明进程正在退出。内核的其他方面使用此指示来避免在删除该进程时对其进行操作。将进程与其在其生命周期中获得的各种资源分离的循环是通过一系列调用执行的,包括 exit_mm(用于删除内存页)到 exit_keys(用于处理每个线程会话和进程安全密钥)。 do_exit 函数执行进程处置的各种统计,然后通过调用 exit_notify 执行一系列通知(例如,向父进程发出子进程正在退出的信号)。最后进程状态变为PF_DEAD,并调用schedule函数选择新进程执行。请注意,如果需要向父级发送信号(或正在跟踪进程),则任务不会完全消失。如果不需要发出信号,则调用release_task实际上会回收进程使用的内存。