Linux内核3-进程管理

Linux内核第3章

3.1 进程

进程就是处于执行期的程序(目标码存放在某种存储介质上)。但进程并不仅仅局限于一段可执行程序代码(Unix称其为代码段)。通常进程还要包含其他资源,像打开的文件,挂起的信号,内核内部数据,处理器状态,一个或多个具有内存映射的内存地址空间及一个或多个执行线程,当然还包括用来存放全局变量的数据段等。实际上,进程就是正在执行的程序代码的实时结果。内核需要有效而又透明地管理所有细节。

执行线程,简称线程,是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程。

Linux系统的线程实现非常特别,它对线程和进程并不特别区分。对Linux而言,线程只不过是一种特殊的进程。

在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。在线程之间可以共享虚拟内存,但每个都拥有各自的虚拟处理器。

程序本身并不是进程,进程是处于执行期的程序以及相关的资源的总称。实际上,完全可能存在两个或多个不同的进程执行的是同一个程序。并且两个或以上并存的进程可以共享许多诸如打开的文件、地址空间之类的资源。

在Linux系统中,进程的创建通常是调用fork系统调用的结果,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程称为父进程,新产生的进程称为子进程。在该系统调用结束时,在返回点这个相同位置上,父进程恢复执行,子进程开始执行。fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。

在现代Linux内核中,fork()实际上是由clone()系统调用实现的。

最终,程序通过exit()系统调用退出执行。这个函数会终结进程并将其占用的资源释放掉。父进程可以通过wait()系统调用查询子进程是否终结,这其实使得进程拥有了等待特定进程执行完毕的能力。进程退出执行后被设置为僵死状态,直到它的父进程调用wait()或waitpid()为止。

PS:Linux内核中,进程通常称为task任务。

3.2 进程描述符及任务结构

内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中。链表中的每一项都是类型为task_struct、称为进程描述符(process descriptor)的结构,该结构定义在<linux/sched.h>文件中。进程描述符中包含一个具体进程的所有信息。

task_struct相对较大,在32位机器上,它大约有1.7KB.但如果考虑到该结构包含内核管理一个进程所需的所有信息,那么它的大小也算相当小了。进程描述符中包含的数据能完整地描述一个正在执行的程序:它打开的文件、进程的地址空间、挂起的信号、进程的状态,还有其它更多信息。

truct task_struct {
volatile long state; //说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; //Flage 是进程号,在调用fork()时给出
int sigpending; //进程上是否有待处理的信号
mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
//0-0xBFFFFFFF for user-thead
//0-0xFFFFFFFF for kernel-thread
//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
volatile long need_resched;
int lock_depth; //锁深度
long nice; //进程的基本时间片
//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
unsigned long policy;
struct mm_struct *mm; //进程内存管理信息
int processor;
//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
unsigned long cpus_runnable, cpus_allowed;
struct list_head run_list; //指向运行队列的指针
unsigned long sleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止时向子进程发送的信号
unsigned long personality;
//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
int did_exec:1;
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //表示进程是否为会话主管
struct task_struct *p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
struct list_head thread_group; //线程链表
struct task_struct *pidhash_next; //用于将进程链入HASH表
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion *vfork_done; //供vfork() 使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值

//it_real_value,it_real_incr用于REAL定时器,单位为jiffies, 系统根据it_real_value
//设置定时器的第一个终止时间. 在定时器到期时,向进程发送SIGALRM信号,同时根据
//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。
//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送
//信号SIGPROF,并根据it_prof_incr重置时间.
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种
//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据
//it_virt_incr重置初值。
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
struct timer_list real_timer; //指向实时定时器的指针
struct tms times; //记录进程消耗的时间
unsigned long start_time; //进程创建的时间
//记录进程在每个CPU上所消耗的用户态时间和核心态时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数); nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1; //表示进程的虚拟地址空间是否允许换出
//进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
//euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件
//系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups; //记录进程在多少个用户组中
gid_t groups[NGROUPS]; //记录进程所在的组
//进程的权能,分别是有效位集合,继承位集合,允许位集合
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
int keep_capabilities:1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned short used_math; //是否使用FPU
char comm[16]; //进程正在运行的可执行文件名
//文件系统信息
int link_count, total_link_count;
//NULL if no tty 进程所在的控制终端,如果不需要控制终端,则该指针为空
struct tty_struct *tty;
unsigned int locks;
//进程间通信信息
struct sem_undo *semundo; //进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
//进程的CPU状态,切换时,要保存到停止进程的task_struct中
struct thread_struct thread;
//文件系统信息
struct fs_struct *fs;
//打开文件信息
struct files_struct *files;
//信号处理函数
spinlock_t sigmask_lock;
struct signal_struct *sig; //信号处理函数
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;

spinlock_t alloc_lock;
void *journal_info;
};

3.2.1分配进程描述符

Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色(cache coloring)的目的。在2.6以前的内核中,各个进程的task_struct存放在它们内核栈的尾端(可以通过栈指针计算它的位置,而避免额外的寄存器专门记录)。由于现在用slab分配器动态生成task_struct,所以只需在栈底(对于向下增长的栈来说)或栈顶(对于向上增长的栈来说)创建一个新的结构struct thread_info。

在x86上,struct thread_info在文件<asm/thread_info.h>中定义如下:

struct thread_info{

  struct task_struct *task;

  struct exec_domain *exec_domain;

  __u32 flags;

  __u32 status;

  __u32 cpu;

  int cpu;

  mm_segment_t addr_limit;

  struct restart_block restart_block;

  void *sysenter_return;

  int uaccess_err;

};

thread_info有一个指向进程描述符的指针(task域)。

3.2.2 进程描述符的存放

内核通过一个唯一的进程标识值(process identification value)或PID来标识每个进程。PID是一个数,表示为pid_t隐含类型,实际上就是一个int类型。PID最大值默认设置为32768(short int短整型的最大值),尽管这个值也可以增加到高达400万(这受到<linux/threads.h>中定义的PID最大值的限制)。内核把PID的值存放在各自的进程描述符中。可通过修改/proc/sys/kernel/pid_max来修改PID最大值。

在内核中,访问任务通常需要获得指向task_struct的指针。实际上,内核中大部分处理进程的代码都是直接通过task_struct进行的。因此通过current宏查找到当前正在运行进程的进程描述符的速度就显得尤为重要。x86系统通过在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。

x86系统上通过current_thread_info()函数计算出thread_info的偏移。

3.2.3 进程状态

进程描述符中的state域描述了进程的当前状态。系统中的每个进程都必然处于五种进程状态之一,该域的值也是下列五种状态之一:

-TASK_RUNNING(运行)  进程是可执行的;它或者正在指向,或者在运行队列中等待指向这是进程在用户空间中执行的唯一可能的状态;这种状态也可以应用到内核空间中正在执行的进程

-TASK_INTERRUPTIBLE(可中断)  进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行。

-TASK_UNINTERRUPTIBLE(不可中断)  除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待不受干扰或等待事件很快就会发生时出现。

-__TASK_TRACED  被其它进程跟踪的进程,例如通过ptrace对调试程序进行跟踪。

-__TASK_STOPPED  进程停止执行;进程没有投入运行也不能投入运行。通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外在调试期间接收到任何信号,都会使进程进入这种状态。

3.2.4 设置当前进程状态

内核经常需要调整某个进程的状态。这时最好使用set_task_state(task, state)函数:

set_task_state(task, state);  //将任务task的状态设置为state

set_current_state()和set_task_state()含义是等同的。

3.2.5 进程上下文

可执行程序代码是进程的重要组成部分。这些代码从一个可执行文件载入到进程的某个地址空间执行。一般程序在用户空间执行。当一个程序执行了系统调用或者出发了某个异常,它就陷入了内核空间。此时,我们称内核“代表进程执行”并处于进程上下文中。在此上下文中current宏是有效的。除非在此间隙有更高优先级的进程需要执行并由调度器做出了相应调整,否则在内核退出的时候,程序恢复在用户空间会继续执行。

系统调用和异常处理程序是对内核明确定义的接口。进程只有通过这些接口才能陷入内核执行---对内核的所有访问都必须通过这些接口。

3.2.6 进程家族树

Linux系统中,所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本并执行其它的相关程序,最终完成系统启动的整个过程。

进程间的关系存放在进程描述符中。每个task_struct都包含一个指向其父进程task_struct、叫做parent的指针,还包含一个称为children的子进程链表。

init进程的进程描述符是作为init_task静态分配的。

3.3 进程创建

fork()通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅仅在于PID(每个进程唯一)、PPID(父进程的进程号,子进程将其设置为被拷贝进程的PID)和某些资源和统计量(例如,挂起的信号,它没有必要被继承)。exec()函数负责读取可执行文件并将其载入地址空间开始运行。

3.3.1 写时拷贝

Linux的fork()使用写时拷贝(copy-on-write)页实现。内核此时并不复制整个地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入时,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入时才进行,在此之前,只是以只读方式共享。

fork()的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。

3.3.2 fork()

Linux通过clone()系统调用实现fork()。这个调用通过一系列的参数标志来指明父、子进程需要共享的资源。fork()、vfork()和__clone()库函数都根据各自需要的参数标志去调用clone(),然后由clone()去调用do_fork()。

do_fork完成了创建中的大部分工作,它定义在<kernel/fork.c>文件中。该函数调用copy_process()函数,然后让进程开始执行。copy_process()函数完成的工作:

1)调用dup_task_struct()为新进程创建一个内核栈、thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符是完全相同的。

2)检查并确保新创建这个子进程后,当前用户所拥有的进程数目没有超出给它分配的资源的限制。

3)子进程着手使自己与父进程区别开来。进程描述符内的许多成员都要被清0或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct中的大多数数据都依然未被修改。

4)子进程的状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。

5)copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0.表明进程话没有调用exec()函数的PF_FORKNOEXEC标志被设置。

6)调用alloc_pid()为新进程分配一个有效的PID。

7)根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理程序、进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。

8)最后,copy_process()做扫尾工作并返回一个指向子进程的指针。

再回到do_fork()函数,如果copy_process()成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先指向,因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程首先执行的话,有可能会开始向地址空间写入。

3.3.3 vfork()

除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。子进程作为父进程的一个单独的线程在它的地址空间里运行,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。

vfork()系统调用的实现是通过向clone()系统调用传递一个特殊标志来进行的。

1)在调用copy_process()时,task_struct的vfork_done成员被设置为NULL。

2)在执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特定地址。

3)子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。

4)在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。

5)回到do_fork(),父进程醒来并返回。

3.4 线程在Linux中的实现

线程机制是现代编程技术中常用的一种抽象概念。该机制提供了在同一程序内共享内存地址空间运行的一组线程。这些线程还可以共享打开的文件和其它资源。线程机制支持并发程序设计技术,在多处理器系统上,它也能保证真正的并行处理。

Linux从内核的角度来说,并没有线程这个概念。Linux把所有的线程都当做进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表征线程。相反,线程仅仅被视为一个与其它进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其它一些进程共享某些资源,如地址空间)。

3.4.1 创建线程

线程的创建和普通进程的创建类似,只不过在调用clone()的时候需要传递一些参数标志来指明需要共享的资源:

clone( CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND , 0);

共享地址空间、文件系统资源、文件描述符和信号处理程序。

一个普通的fork实现是:clone( SIGCHLD, 0);

而vfork实现是:clone( CLONE_VFORK | CLONE_VM |SIGCHLD ,0);

传递给clone的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。

clone()参数标志:

-CLONE_FILES  父子进程共享打开的文件

-CLONE_FS  父子进程共享文件系统信息

-CLONE_IDLETASK  将PID设置为0(只供idle进程使用)

-CLONE_NEWNS  为子进程创建新的命名空间

-CLONE_PARENT  指定子进程与父进程拥有同一个父进程

-CLONE_PTRACE  继续调试子进程

-CLONE_SETTID  将TID回写至用户空间

-CLONE_SETTLS  为子进程创建新的TLS

-CLONE_SIGHAND  父子进程共享信号处理程序及被阻断的信号

-CLONE_SYSVSEM  父子进程共享System V SEM_UNDO语义

-CLONE_THREAD  父子进程放入相同的线程组

-CLONE_VFORK  调用vfork(),所以父进程准备睡眠等待子进程将其唤醒

-CLONE_UNTRACED  防止跟踪进程在子进程上强制执行CLONE_PTRACE

-CLONE_STOP  以TASK_STOPPED状态开始进程

-CLONE_SETTLS  为子进程创建新的TLS(thread-local storage)

-CLONE_CHILD_CLEARTID  清除子进程的TID

-CLONE_CHILD_SETTID  设置子进程的TID

-CLONE_PARENT_SETTID  设置父进程的TID

-CLONE_VM  父子进程共享地址空间

3.4.2 内核线程

内核经常需要在后台执行一些操作。这种任务可以通过内核线程完成----独立运行在内核空间的标准进程。内核线程和普通的进程间的区别在于内核线程没有独立的地址空间(实际上指向地址空间的mm指针被设置为NULL)。它们只在内核空间运行,从来不切换到用户空间去。内核进程和普通进程一样,可以被调度,也可以被抢占。

内核通过从kthreadd内核进程中衍生出所有新的内核线程来自动处理这一点的。新的任务是由kthreadd内核进程通过clone系统调用而创建的。

内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其它部分调用kthread_stop退出,传递给kthread_stop的参数为kthread_create函数返回的task_struct结构的地址。

3.5 进程终结

当一个进程终结时,内核必须释放它所占有的资源并把这一不幸告知其父进程

一般来说,进程的析构是自身引起的。它发生在进程调用exit系统调用时,既可能显示地调用这个系统调用,也可能隐式地从某个程序的主函数返回(C语言的编译器在main()函数的返回点后面防止调用exit()的代码)。当进程接受到它既不能处理也不能忽略的信号或异常时,它还可能被动地终结。不管进程是怎么终结的,该任务大部分都要靠do_exit()来完成,它要完成下面的工作:

1)将task_struct中的标志成员设置为PF_EXITING。

2)调用del_timer_sync()删除任一内核定时器。根据返回的结果,它确保没有定时器在排队,也没有定时器处理程序在运行。

3)如果BSD的进程记账功能是开启的,do_exit()调用acct_update_integrals()来输出记账信息。

4)然后调用exit_mm()函数释放进程占用的mm_struct,如果没有别的进程使用它们(也就是说,这个地址空间没有被共享),就彻底释放它们。

5)解下来调用sem_exit()函数。如果进程排队等候IPC信号,它则离开队列。

6)调用exit_files()和exit_fs(),以分别递减文件描述符、文件系统数据的引用计数。如果其中某个引用计数的数值降为0,那么就代表没有进程在使用相应的资源,此时可以释放。

7)接着把存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的退出代码,或者去完成任何其它由内核机制规定的退出动作。退出代码存放在这里供父进程随时检索。

8)调用exit_notify()向父进程发送信号,给子进程重新找养父,养父为线程组中的其它线程或者为init进程,并把进程退出状态设成EXIT_ZOMBIE。

9)do_exit()调用schedule()切换到新的进程。因为处于EXIT_ZOMBIE的进程不会调度,所以这是进程所执行的最后一段代码。do_exit()永不返回。

至此,与进程相关的所有资源都被释放掉了(假设该进程是这些资源的唯一使用者)。进程不可运行(实际上也没有地址空间让它运行)并处于EXIT_ZOMBIE退出状态。它占用的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。父进程检索到信息后,或者通知内核那是无关的信息后,由进程所持有的剩余内存被释放,归还给系统使用。

3.5.1 删除进程描述符

在父进程获得已终结的子进程的信息后,或者通知内核它并不关注那些信息后,子进程的task_struct结构才被释放。

wait()这一族函数都是通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子函数退出时的退出代码。

当最终需要释放进程描述符时,release_task()会被调用,用以完成以下工作:

1)它调用_exit_signal(),该函数调用_unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时也要从任务列表中删除该进程。

2)_exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。

3)如果这个进程是线程组的最后一个进程,并且领头进程已经死掉,那么release_task()就要通知僵死的领头进程的父进程。

4)release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。

3.5.2 孤儿进程造成的进退维谷

如果父进程在子进程之前退出,必须有机制来保证父进程能找到一个新的父亲,否则这些称为孤儿的进程就会在退出时永远处于僵死状态,白白耗费内存。解决方法是给子进程在当前线程组内找一个线程作为父亲,如果不行,就让init做它们的父进程。在do_exit()中会调用exit_notify(),该函数会调用forget'_original_parent(),而后者会调用find_new_reaper()来执行寻找父进程。

原文地址:https://www.cnblogs.com/cjj-ggboy/p/12335025.html