Linux 是一种动态系统,能够适应不断变化的计算需求。Linux 计算需求的表现是以进程 的通用抽象为中心的。进程可以是短期的(从命令行执行的一个命令),也可以是长期的(一种网络服务)。因此,对进程及其调度进行一般管理就显得极为重要。
在用户空间,进程是由进程标识符(PID)表示的。从用户的角度来看,一个 PID 是一个数字值,可惟一标识一个进程。一个 PID 在进程的整个生命期间不会更改,但 PID 可以在进程销毁后被重新使用,所以对它们进行缓存并不见得总是理想的。
在用户空间,创建进程可以采用几种方式。可以执行一个程序(这会导致新进程的创建),也可以在程序内,调用一个 fork
或 exec
系统调用。fork
调用会导致创建一个子进程,而 exec
调用则会用新程序代替当前进程上下文。接下来,我将对这几种方法进行讨论以便您能很好地理解它们的工作原理。
在本文中,我将按照下面的顺序展开对进程的介绍,首先展示进程的内核表示以及它们是如何在内核内被管理的,然后来看看进程创建和调度的各种方式(在一个或多个处理器上),最后介绍进程的销毁。
进程表示
|
在 Linux 内核内,进程是由相当大的一个称为 task_struct
的结构表示的。此结构包含所有表示此进程所必需的数据,此外,还包含了大量的其他数据用来统计(accounting)和维护与其他进程的关系(父和子)。对 task_struct
的完整介绍超出了本文的范围,清单 1 给出了 task_struct
的一小部分。这些代码包含了本文所要探索的这些特定元素。task_struct
位于 ./linux/include/linux/sched.h。
清单 1. task_struct 的一小部分
|
在清单 1 中,可以看到几个预料之中的项,比如执行的状态、堆栈、一组标志、父进程、执行的线程(可以有很多)以及开放文件。我稍后会对其进行详细说明,这里只简单加以介绍。state
变量是一些表明任务状态的比特位。最常见的状态有:TASK_RUNNING
表示进程正在运行,或是排在运行队列中正要运行;TASK_INTERRUPTIBLE
表示进程正在休眠、TASK_UNINTERRUPTIBLE
表示进程正在休眠但不能叫醒;TASK_STOPPED
表示进程停止等等。这些标志的完整列表可以在 ./linux/include/linux/sched.h 内找到。
flags
定义了很多指示符,表明进程是否正在被创建(PF_STARTING
)或退出(PF_EXITING
),或是进程当前是否在分配内存(PF_MEMALLOC
)。可执行程序的名称(不包含路径)占用 comm
(命令)字段。
每个进程都会被赋予优先级(称为 static_prio
),但进程的实际优先级是基于加载以及其他几个因素动态决定的。优先级值越低,实际的优先级越高。
tasks
字段提供了链接列表的能力。它包含一个 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
总是存在,所以可以将其用作继续向前迭代的一个锚点。让我们来看一个遍历当前任务集的例子。
任务列表无法从用户空间访问,但该问题很容易解决,方法是以模块形式向内核内插入代码。清单 2 中所示的是一个很简单的程序,它会迭代任务列表并会提供有关每个任务的少量信息(name
、pid
和 parent
名)。注意,在这里,此模块使用 printk
来发出结果。要查看具体的结果,可以通过 cat
实用工具(或实时的 tail -f /var/log/messages
)查看 /var/log/messages 文件。next_task
函数是 sched.h 内的一个宏,它简化了任务列表的迭代(返回下一个任务的 task_struct
引用)。
清单 2. 发出任务信息的简单内核模块(procsview.c)
|
可以用清单 3 所示的 Makefile 编译此模块。在编译时,可以用 insmod procsview.ko
插入模块对象,也可以用 rmmod procsview
删除它。
清单 3. 用来构建内核模块的 Makefile
|
插入后,/var/log/messages 可显示输出,如下所示。从中可以看到,这里有一个空闲任务(称为 swapper
)和 init
任务(pid 1)。
Nov 12 22:19:51 mtj-desktop kernel: [8503.873310] *** swapper [0] parent swapper |
注意,还可以标识当前正在运行的任务。Linux 维护一个称为 current
的符号,代表的是当前运行的进程(类型是 task_struct
)。如果在 init_module
的尾部插入如下这行代码:
printk( KERN_INFO, "Current task is %s [%d], current->comm, current->pid ); |
会看到:
Nov 12 22:48:45 mtj-desktop kernel: [10233.323662] Current task is insmod [6538] |
注意到,当前的任务是 insmod
,这是因为 init_module
函数是在 insmod
命令执行的上下文运行的。current
符号实际指的是一个函数(get_current
)并可在一个与 arch 有关的头部中找到(比如 ./linux/include/asm-i386/current.h 内找到)。
|
进程创建
|
让我们不妨亲自看看如何从用户空间创建一个进程。用户空间任务和内核任务的底层机制是一致的,因为二者最终都会依赖于一个名为 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
标志以做好执行 fork 操作的准备。之后 do_fork
函数还会调用 copy_process
,向其传递这些标志、堆栈、注册表、父进程以及最新分配的 PID。
新的进程在 copy_process
函数内作为父进程的一个副本创建。此函数能执行除启动进程之外的所有操作,启动进程在之后进行处理。copy_process
内的第一步是验证 CLONE
标志以确保这些标志是一致的。如果不一致,就会返回 EINVAL
错误。接下来,询问 Linux Security Module (LSM) 看当前任务是否可以创建一个新任务。要了解有关 LSM 在 Security-Enhanced Linux (SELinux) 上下文中的更多信息,请参见 参考资料 小节。
接下来,调用 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
引用。任务通过 schedule
函数(在 ./linux/kernel/sched.c 内)调用,它根据加载及进程执行历史决定最佳进程。在本文的 参考资料 小节可以了解有关 Linux 版本 2.6 调度程序的更多信息。
|
进程销毁
进程销毁可以通过几个事件驱动 — 通过正常的进程结束、通过信号或是通过对 exit
函数的调用。不管进程如何退出,进程的结束都要借助对内核函数 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
来实际收回由进程使用的那部分内存。
|
结束语
Linux 还在不断演进,其中一个有待进一步创新和优化的领域就是进程管理。在坚持 UNIX 原理的同时,Linux 也在不断突破。新的处理器架构、对称多处理(SMP)以及虚拟化都将促使在内核领域内取得新进展。其中的一个例子就是 Linux 版本 2.6 中引入的新的 O(1) 调度程序,它为具有大量任务的系统提供了可伸缩性。另外一个例子就是使用 Native POSIX Thread Library (NPTL) 更新了的线程模型,与之前的 LinuxThreads 模型相比,它带来了更为有效的线程处理。有关这些创新及其前景的更多信息,请参见 参考资料。
参考资料
学习
获得产品和技术
讨论
关于作者
M. Tim Jones 是一名嵌入式软件工程师,他是 Artificial Intelligence: A Systems Approach, GNU/Linux Application Programming(现在已经是第 2 版)、AI Application Programming(第 2 版)和 BSD Sockets Programming from a Multilanguage Perspective 等书的作者。他的工程背景非常广泛,从同步宇宙飞船的内核开发到嵌入式系统架构设计,再到网络协议的开发。Tim 是位于科罗拉多州 Longmont 的 Emulex Corp. 的一名顾问工程师。 |