进程必需具备如下几个要素,完整的具备了才能称之为进程,否则称之为线程:
-
有一段程序供其执行,就好像一场戏要有一个剧本一样。这段不一定是进程所专用,可以与其它进程共用,就好像不同剧团的许多场演出可以共用一个剧本一样。
-
有起码的“私有财产”,这就是进程专用的系统堆栈空间。
-
有户口,这就是在内核中的一个task_struct数据结构,操作系统教科书中常称为进程控制块,有了这个数据结构。进程才能成为内核调度的一个基本单位接受内核的调度。同时这个结构又是进程的财产登记卡。记录着进程所占用的各项资源。
-
有独立的存储空间。意味着拥有专有的用户空间。进一步。还意味着除前所述的系统空间堆栈外还有其专用的用户空间堆栈。注意,系统空间是不能独立的,任何进程都不可以直接(不通过系统调用)改变系统空间的内容(除其本身的系统空间堆栈以外)。
这4条都是必要条件,缺了其中任何一条就不称其为进程。如果只具备了前面三条而缺第4条,那就称为线程。特别地,如果完全没有用户空间,那就称为内核线程(kernel thread);而如果共享用户是就称为用户线程。在不致引起混淆的场合,二者也都往往称为线程。
另一方面,进程与线程的区分也不是十分严格的。一般在进到进程时常常包括了线程。事实上在LINUX系统中,许多进程在诞生之实都与其父进程分道扬镳,成为真正意义上的进程。
再说,线程也有PID,也有TASK_STRUCT结构,所以这两个词在使用中有时候并不严格加以区分,要根据上下文理解其含意。
还有,在LINUX系统中进程PROCESS和任各TASK是同一个意思。在内核的代码中也常常滥用这两个名词和概念。例如。每个进程都要有一个TASK_STRUCT数据结构。而其号码却又是PID.唤醒一个进程的函数名为wake_upe_process().之所以有这样的情况是因为LINUX源自UNIX和I386系统结构。而UNIX中的进程在INTEL的技术资料中则称为任务。
Linux应用层创建进程或线程的函数(应用层最终还是调用系统函数来创建的,因为内核中按进程严格定义来说是没有进程的,因为没有用户空间。因此内核中创建进程其实是线程也可以用下面函数对应的系统调用)
Fork 创建进程 CLONE_VM=0独立的用户空间
__clone/Vfork创建线程 CLONE_VM=1与父进程共享用户空间(内核线程无用户空间)
Pthread_Create 创进线程 CLONE_VM=1与父进程共享用户空间(内核线程无用户空间)
exec 调用会用新程序代替当前进程上下文
kernel_thread:创建内核线程。
thread_info -- task_struct(=current)每个task_struct与thread_info对应的。
内核进程没有用户空间,可以看成线程(线程和进程没有严格的界限,要根据语境来区分)
上面三个函数的调用处都会返回两次:(如果返回小于0的数则只返回一次,表示创建进程或线程失败)返回0则进入子进程或线程执行 返回大于0则进入主进程或线程执行。(如果创建的是线程则需要线程先执行完才会返回执行主进程或线程。因为共用用户空间,必须一个先完成,不然会同时操作出现错误导致自动退出。)
知道了进程的概念,下面来分析一个进程在LINUX系统中的生命周期内的动作:
-
进程的内核表示以及它们是如何在内核内被管理的
-
看看进程创建和调度的各种方式(在一个或多个处理器上)
-
进程的销毁
下面的详细分析就是对上面3点进行实战总结:
A1:进程表示:在 Linux 内核内,进程是由相当大的一个称为 task_struct 的结构表示的。此结构包含所有表示此进程所必需的数据,此外,还包含了大量的其他数据用来统计(accounting)和维护与其他进程的关系(父和子)。对task_struct 的完整介绍超出了本文的范围,清单 1 给出了task_struct 的一小部分。这些代码包含了本文所要探索的这些特定元素。task_struct 位于 ./linux/include/linux/sched.h。
清单 1. 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;
...
};
在清单 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 内有这样的一个例子。在此结构内,可以找到该进程自执行上下文切换后的存储(硬件注册表、程序计数器等)。
A2:进程管理:
最大进程数
在 Linux 内虽然进程都是动态分配的,但还是需要考虑最大进程数。在内核内最大进程数是由一个称为 max_threads 的符号表示的,它可以在 ./linux/kernel/fork.c 内找到。可以通过 /proc/sys/kernel/threads-max 的 proc 文件系统从用户空间更改此值。
现在,让我们来看看如何在 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 引用)。
/*
* Initial task structure.
*
* All other task structs will be allocated on slabs in fork.c
*/
struct task_struct init_task = INIT_TASK(init_task);
清单 2. 发出任务信息的简单内核模块(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;
}
清单 3. 用来构建内核模块的 Makefile
obj-m += procsview.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules
插入后,/var/log/messages 可显示输出,如下所示。从中可以看到,这里有一个空闲任务(称为swapper)和init 任务(pid 1)。
Nov 12 22:19:51 mtj-desktop kernel: [8503.873310] *** swapper [0] parent swapper
Nov 12 22:19:51 mtj-desktop kernel: [8503.904182] *** init [1] parent swapper
Nov 12 22:19:51 mtj-desktop kernel: [8503.904215] *** kthreadd [2] parent swapper
Nov 12 22:19:51 mtj-desktop kernel: [8503.904233] *** migration/0 [3] parent kthreadd
注意,还可以标识当前正在运行的任务。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 内找到)。
/*
* how to get the thread information struct from C
*/
static inline struct thread_info *current_thread_info(void) __attribute_const__;
static inline struct thread_info *current_thread_info(void)
{
register unsigned long sp asm ("sp");
return (struct thread_info *)(sp & ~(THREAD_SIZE - 1));
}
static inline struct task_struct *get_current(void) __attribute_const__;
static inline struct task_struct *get_current(void)
{
return current_thread_info()->task;
}
A3:进程创建
系统调用函数
您可能已经看到过系统调用的模式了。在很多情况下,系统调用都被命名为 sys_* 并提供某些初始功能以实现调用(例如错误检查或用户空间的行为)。实际的工作常常会委派给另外一个名为 do_* 的函数。
让我们不妨亲自看看如何从用户空间创建一个进程。用户空间任务和内核任务的底层机制是一致的,因为二者最终都会依赖于一个名为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) 看当前任务是否可以创建一个新任务。
接下来,调用 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 值即被返回给调用程序,进程完成。
A4:进程调度
存在于 Linux 的进程也可通过 Linux 调度程序被调度。虽然调度程序超出了本文的讨论范围,但 Linux 调度程序维护了针对每个优先级别的一组列表,其中保存了task_struct 引用。任务通过schedule 函数(在 ./linux/kernel/sched.c 内)调用,它根据加载及进程执行历史决定最佳进程。
A5:进程销毁
进程销毁可以通过几个事件驱动 — 通过正常的进程结束、通过信号或是通过对 exit 函数的调用。不管进程如何退出,进程的结束都要借助对内核函数do_exit(在 ./linux/kernel/exit.c 内)的调用。此过程如图 2 所示。
do_exit 的目的是将所有对当前进程的引用从操作系统删除(针对所有没有共享的资源)。销毁的过程先要通过设置PF_EXITING 标志来表明进程正在退出。内核的其他方面会利用它来避免在进程被删除时还试图处理此进程。将进程从它在其生命期间获得的各种资源分离开来是通过一系列调用实现的,比如exit_mm(删除内存页)和exit_keys(释放线程会话和进程安全键)。do_exit 函数执行释放进程所需的各种统计,这之后,通过调用exit_notify 执行一系列通知(比如,告知父进程其子进程正在退出)。最后,进程状态被更改为PF_DEAD,并且还会调用schedule 函数来选择一个将要执行的新进程。请注意,如果对父进程的通知是必需的(或进程正在被跟踪),那么任务将不会彻底消失。如果无需任何通知,就可以调用release_task来实际收回由进程使用的那部分内存。
A6:结束语
Linux 还在不断演进,其中一个有待进一步创新和优化的领域就是进程管理。在坚持 UNIX 原理的同时,Linux 也在不断突破。新的处理器架构、对称多处理(SMP)以及虚拟化都将促使在内核领域内取得新进展。其中的一个例子就是 Linux 版本 2.6 中引入的新的 O(1) 调度程序,它为具有大量任务的系统提供了可伸缩性。另外一个例子就是使用 Native POSIX Thread Library (NPTL) 更新了的线程模型,与之前的 LinuxThreads 模型相比,它带来了更为有效的线程处理。
LINUX进程线程相关扩展知识点:
在用户空间,创建进程可以采用种方式:可以执行一个程序(这会导致新进程的创建),也可以在程序内,调用forkc或exec系统调用。Fork调用会导致创建一个子进程,exec调用则会用新程序代替当前进程上下文。
Schedule函数工作流程如下:
(1)清理当前运行中的进程
(2)选择下一个要运行的进程(pick_next_task)
(3)设置新进程的运行环境
(4) 进程上下文切换
进程调度时机:调度什么时候发生?即:schedule()函数什么时候被调用
(schedule()所做的事情是用某一个进程来代替当前进程)
1:主动式调度(自愿调度)
在内核中主动直接调用进程调度函数schedule(),当进程需要等待资源而暂时停止运行时,会把状态置于挂起(睡眠),并主动请求调度,让出cpu。
2:被动式调度(抢占式调度、强制调度)
用户抢占(2.4 2.6)
内核抢占(2.6)
(1)用户抢占发生在:从系统调用返回用户空间;
从中断处理程序返回用户空间。
(2)内核抢占:在不支持内核抢占的系统中,进程/线程一旦运行于内核空间,就可以一直执行,直到它主动放弃或时间片耗尽为止。这样一些非常紧急的进程或线程将长时间得不到运行。
在支持内核抢占的系统中,更高优先级的进程/线程可以抢占正在内核空间运行的低优先级的进程/线程。
当前的Linux内核加入了内核抢占(preempt)机制。内核抢占指用户程序在执行系统调用期间可以被抢占,该进程暂时挂起,使新唤醒的高优先级进程能够运行。这种抢占并非可以在内核中任意位置都能安全进行,比如在临界区中的代码就不能发生抢占。临界区是指同一时间内不可以有超过一个进程在其中执行的指令序列。在Linux内核中这些部分需要用自旋锁保护。
Linux内核抢占只有在内核正在执行例外处理程序(通常指系统调用)并且允许内核抢占时,才能进行抢占内核。禁止内核抢占的情况列出如下:
(1)内核执行中断处理例程时不允许内核抢占,中断返回时再执行内核抢占。
(2)当内核执行软中断或tasklet时,禁止内核抢占,软中断返回时再执行内核抢占。
(3)在临界区禁止内核抢占,临界区保护函数通过抢占计数宏控制抢占,计数大于0,表示禁止内核抢占。
抢占式内核实现的原理是在释放自旋锁时或从中断返回时,如果当前执行进程的 need_resched 被标记,则进行抢占式调度。
大内核锁(BKL)的设计是在kernel hacker们对多处理器的同步还没有十足把握时,引入的大粒度锁。
他的设计思想是,一旦某个内核路径获取了这把锁,那么其他所有的内核路径都不能再获取到这把锁。
自旋锁加锁的对象一般是一个全局变量,大内核锁加锁的对象是一段代码,里面可能包含多个全局变量调度程序schedule()会根据具体的标准在运行队列中选择下一个应该运行的进程