Linux进程管理

背景

进程主要分为两部分:
1)进程管理,见Linux进程管理
2)进程调度,见Linux进程调度
这部分主要讲进程管理。

进程和文件是Unix OS的2个最基本抽象。一个进程就是处于执行期的程序

进程包括:一段可执行程序代码(代码的text section),打开的文件、挂起的信号、内核内部数据、处理器状态、地址空间及一个或多个执行线程(thread of execution)、存放全局变量的数据段data section。

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

传统Unix OS中,一个进程只包含一个线程;现代OS中,一个进程包含多个线程。
而Linux OS中,线程实现很特别,对线程和进程不特别区分。线程只是一种特殊的进程。

进程有2种虚拟机制:虚拟处理器,虚拟内存
实际上可能多个进程正分享一个处理器,但虚拟处理器给进程一种假象:让这些进程觉得自己在独享处理器。
同样地,虚拟内存给进程一种假象:让这些进程觉得自己在独享内存。

Linux OS中,一次从进程创建到进程终止过程:
1)首先,进程的创建通常由系统调用fork()进行,该系统调用通过复制一个现有进程来创建一个全新的进程。调用fork()的进程称为父进程,新产生的进程称为子进程。fork()调用一次,返回两次:一次回到父进程,一次回到子进程。

2)接着,新进程执行不同的程序,需要调用exec*() 加载器家族函数,以创建新地址空间,并把新程序载入。

3)最后,程序通过系统调用exit()退出执行。函数会终结进程并将其占用的资源释放。父进程可通过wait4()系统调用查询子进程是否终结。进程退出执行后,成为僵尸状态,直到父进程调用wait()或waitpid()。
注意:内核实现wait4()系统调用,Linux C库要提供wait()/waitpid()/wait3()/wait4()函数。

进程的另一个名字是任务(task)。Linux内核通常也把进程叫任务。

[======]

进程描述符及任务结构

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

task_struct在32bit机器上,大约有1.7Kbyte。该结构包含了内核管理一个进程所需要的所有信息:进程打开的文件,地址空间,挂起的信号,进程的状态,及其他更多信息。

分配进程描述符

Linux通过slab分配器分配task_struct结构,达到对象服用和缓存着色(cache coloring)的目的,通过预先分配和重复使用task_struct,动态避免分配和释放所带来资源消耗。

内核2.6以前,各进程的task_struct存放在它们内核栈的尾端,是为了让像x86这样寄存器较少的硬件体系结构只要通过栈指针,就能计算出其位置,从而避免使用额外的寄存器专门记录。
内核2.6以后,用slab分配器动态生成task_struct,只需要在栈底(向下增长的栈)或栈顶(向上增长的栈)创建一个新的结构struct thread_info:该新结构能使在汇编代码中计算偏移变得很容易。

x86上,thread_info结构定义于<asm/thread_info.h>:

struct thread_info {
    struct task_struct *task;
    struct exec_domain *exec_domain;
    unsigned long flags;
    unsigned long status;
    __u32 cpu;
    __s32 preempt_count;
    mm_segment_t addr_limit;
    struct restart_block restart_block;
    unsigned long previous_esp;
    _u8 supervisor_stack[0];
};

每个任务的thread_info结构在它的内核栈尾端分配,task域存放的是指向该任务实际task_struct的指针。

进程的描述符存放

PID(process identification value):内核通过唯一的进程标识值标识每个进程。PID是一个pid_t隐含类型的数,实际int类型。为与老版Unix/Linux兼容,PID最大值默认32768(short int最大值)。

该最大值决定了系统中允许同时存在的进程的最大数目,不过管理员可通过修改/proc/sys/kernel/pid_max 来提高上限。

$ cat /proc/sys/kernel/pid_max # 查看
32768

$ echo 100000 > /proc/sys/kernel/pid_max # 修改

内核中,访问任务通常需要获得指向其task_struct指针,通常通过current宏(current_thread_info())来完成。x86这样的体系结构,由于寄存器较少,只能在内核栈尾端创建thread_info结构,通过计算偏移间接查找task_struct结构。PowerPC这样的体系结构,current宏可以把r2寄存器返回即可。

/* current从thread_info的task域中提取并返回task_struct地址 */
current_thread_info()->task;

进程状态

进程描述符(task_struct)的state域描述了进程的当前状态。每个进程比如处于这5中状态中的一种。

  • TASK_RUNNING(运行):进程是可执行的;或正在执行,或在运行队列中等待执行(就绪)。
  • TASK_INTERRUPTIBLE(可中断):进程正在睡眠(阻塞),等待某些条件达成。一旦达成,内核就会把进程置为运行。进程会因为接收到信号而被提前唤醒并投入运行。
  • TASK_UNINTERRUPTIBLE(不可中断):除不会因为接收到信号而被唤醒,从而投入运行外,其他与可中断状态相同。通常用于进程必须在等待时不受干扰,或等待时间很快就会出现。
  • TASK_ZOMBIE(僵死):进程已经终止,但父进程尚未调用wait4()系统调用,资源尚未被回收。
  • TASK_STOPPED(停止):进程停止执行;没有投入运行,也不能投入运行。通常这种状态发生在接收到信号SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号的时候。此外,调试期间接收到任何信号,都会使进程进入这种状态。

设置当前进程状态

内核经常需要调整某个进程的状态。最好使用set_task_state函数:

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

函数将指定进程设置为指定的状态。必要时,它会设置内存屏障来强制其他处理器作重新排序(只有SMP系统中有此必要);否则,它等价于:

task->state = state;

set_current_state(state)set_task_state(current, state)等价。

进程上下文

一般程序在用户空间执行。当一个程序执行了系统调用或触发了某个异常,就陷入内核空间。此时,称内核“代表进程执行”并处于进程上下文中。在此上下文中,current宏有效。除非有更高优先级进程需要执行,否则内核退出时,程序恢复用户空间继续执行。

进程家族树

Unix/Linux OS进程存在一个明显的继承关系,所有进程都是PID=1的init进程的后代。内核在系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本(initscript)并执行其他的相关程序,最终完成系统启动的整个过程。

系统中的每个进程必有一个父进程,每个进程也可以拥有0~多个子进程。拥有同一个父进程的所有进程称为兄弟(进程)。进程间的关系,存放进进程描述符中。每个task_struct都包含一个指向其父进程task_struct,叫parent指针,还包含一个称为children的子进程链表

对于当前进程,可以通过下面代码活动其父进程的进程描述符:

/* 通过parent指针访问父进程 */
struct task_struct* my_parent = current->parent;

同样,也可以按下面方式以此访问子进程:

/* 通过children子进程链表访问子进程 */
struct task_struct *task;
struct list_head *list;

list_for_each(list, &current->children) {
    task = list_entry(list, struct task_struct, sibling);
    /* task 现在指向当前的某个子进程 */
}

init进程的进程描述符作为init_task静态分配的。下面代码演示了所有进程间的关系:

struct task_struct* task;

/* 向上搜索父进程, 直到init进程为止 */
for (task = current; task != &init_task; task = task->parent)
    ;

/* task 现在指向init */

任务队列是一个双向循环链表。对于给定进程,获取链表的下一个进程:

list_entry(task->tasks.next, struct task_struct, tasks);
<=>
next_task(task); /* 宏实现 */

获取前一个进程的方法相同:

list_entry(task->tasks.prev, struct task_struct, tasks);
<=>
prev_task(task); /* 宏实现 */

访问整个任务队列,可以用for_each_process(task):

/* 不推荐这样遍历所有进程 */
struct task_struct *task;

for_each_process(task) {
    /* 打印出每个任务的名称和PID */
    printk("%s[%d]\n", task->comm, task->pid);
}

注意:在拥有大量进程的系统中,通过重复遍历所有进程非常耗时。因此,没有特别理由,不要这样做。

[======]

进程创建

Unix进程创建很特别,提供两个单独函数去执行:fork(), exec()。
首先,fork()通过拷贝当前进程创建一个子进程。子进程与父进程区别仅在于PID、PPID(父进程的进程号,子进程将其设置为父进程的PID)和某些资源和统计量(如挂起的信号,没有必要被继承)。

接着,exec() 函数负责读取可执行文件并将其载入地址空间开始运行。

写时拷贝(Copy-on-write)

传统fork()直接把所有资源复制给新创建进程,但实现简单、效率低下。如果新进程打算立即执行一个新的映像(exec),那么所有拷贝都是无用功。
Linux的fork()使用写时拷贝(copy-on-write,简称COW)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术,fork创建子进程后,内核此时并不复制整个进程空间,而是让父进程和子进程共享一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝;在此之前,只是以读方式共享。

此项优化对于Unix进程快速执行,有重要意义。

fork()

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

do_fork() 完成创建工作中大部分工作,其定义在kernel/fork.c中。该函数调用copy_process(),然后让进程开始运行。copy_process()完成工作:

  • 调用dup_task_struct() 为新进程创建一个内核栈、thread_info结构、task_struct,值与当前进程的值相同。此时,父子进程的描述符完全相同。
  • 检查新创建的子进程后,当前用户所拥有的进程数码没有超出给他分配的资源的限制。
  • 子进程着手使自己与父进程区别开。进程描述符内许多成员都要被清0或设为初值。进程描述符的成员值不是继承而来,而主要是统计信息,大多数数据都是共享的。
  • 子进程状态被设为TASK_UNINTERRUPTIBLE,以确保不会投入运行。
  • copy_process()调用copy_flags()以更新task_struct的flags成员。表明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0。表明进程还没调用exec()的PF_FORKNONEXEC标志被设置。
  • 调用get_pid() 为新进程获取一个有效的PID。
  • 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程都不同,因此被拷贝到这里。
  • 让父子进程平分剩余的时间片。
  • copy_process()做扫尾工作,并返回一个指向子进程的指针。

如果copy_process()成功返回,新创建子进程被唤醒并投入运行。内核有意让子进程先执行,因为一般子进程都会马上调用exec(),可避免写时拷贝的额外开销。

vfork()

vfork()系统调用和fork)功能相同,除了不拷贝父进程的页表项,子进程作为父进程的一个单独的线程在其地址空间运行,父进程阻塞,直到子进程退出或执行exec()。在fork使用写时拷贝技术后,vfork相比fork的好处仅限于不拷贝父进程的页表项。

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

  • 调用copy_process()时,task_struct的vfork_done成员被设置为NULL。
  • 执行do_fork()时,如果给定特别标志,则vfork_done会指向一个特殊地址。
  • 子进程开始执行后,父进程不是马上恢复执行,而是一直等待,直到子进程通过vfork_done指针向它发送信号。
  • 调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfork_done是否为空,如果不为空,则会向父进程发送信号。
  • 回到do_fork(),父进程醒来并返回。

如果一切顺利,子进程在新地址空间里运行而父进程也恢复了在原地址空间的运行。这样的实现降低了开销,但设计并非优良。

[======]

线程在Linux中的实现

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

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

Linux线程机制的实现与Windows、Sun Solaris等OS差异巨大。这些系统都在内核中提供专门支持线程的机制,常常把线程称为 轻量级进程(lightweight process)。

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

/* 创建线程, 父子俩共享地址空间、文件系统资源、文件描述符、信号处理程序 */
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);

上面代码与调用fork()差不多,只是父子俩共享地址空间、文件系统资源、文件描述符、信号处理程序。i.e. 新建进程和其父进程就是流行的所谓线程。对比一下,一个普通的fork()实现:

clone(SIGCHLD, 0);

vfork()实现:

clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

传递给clone的参数决定了新创建进程的行为方式和父子进程之间共享的资源种类,参见下图,定义于<linux/sched.h>。

内核线程

内核经常需要在后台执行一些操作,可通过内核线程(kernel thread)完成,内核线程是独立运行在内核空间的标准进程。内核线程和普通进程间的区别在于内核线程没有独立的地址空间(mm指针被设为NULL)。内核线程只允许在内核空间,从不切换到用户空间。内核进程和普通进程一样,可以被调度、抢占。

内核线程只能由其他内核线程创建。在现有内核线程中创建一个新内核线程的方法:

/* 在现有内核线程中创建一个新内核线程 */
int kernel_thread(int (*fn)(void*), void* arg, unsigned long flags);

新任务也是通过向clone()传递特定flags参数而创建的。上面函数返回时,父线程退出,并返回一个指向子线程task_strust的指针。子线程开始运行fn指向的函数,arg是运行时需要用到的参数。一个特殊clone标志CLONE_KERNEL定义了内核线程常用参数标志:CLONE_FS, CLONE_FILES, CLONE_SIGHAND。

[======]

进程终结

进程终结时,内核必须释放它占有的资源,并通知父进程。

通常,进程的析构发生在它调用exit()后,可能显式调用,也可能隐式地从某个程序的main函数返回。当进程接受到它既不能处理,也不能忽略的信号或异常时,可能被动地终结。不论进程如何终结,任务大部分都需要靠do_exit()完成(位于<kernel/exit.c>),做下面工作:

  • 首先,将task_strust中标志成员设置为PF_EXITING。
  • 其次,调用del_timer_sync()删除任一内核定时器。根据返回的结果,确保没有定时器在排队,也没有定时器处理程序在运行。
  • 如果BSD的进程记账功能是开启的,do_exit()调用acct_process()输出记账信息。
  • 然后,调用_exit_mm()放弃进程占用的mm_struct,如果没有别的进程使用它们(即没被共享),就彻底释放它们。
  • 解下来,调用exit__sem()。如果进程排队等候IPC信号,它则离开队列。
  • 调用_exit_files(), _exit_fs(),exit_namespace(), exit_sighand(),以分别递减文件描述符、文件系统数据,进程名字空间和信号处理函数的引用计数。如果其中某些引用计数的数值降为0,那么代表没有进程在使用相应的资源,此时可以释放。
  • 接着,把放在task_struct的exit_code成员中的任务退出代码置为exit()提供的代码中,或者去完成任何其他由内核机制规定的退出动作。退出代码存放在这里提供父进程随时检索。
  • 调用exit_notify()向父进程发送信号,将子进程的父进程重新设置为线程组中的其他线程或init进程,并把进程状态设为TASK_ZOMBIE。
  • 最后,do_exit()调用schedule()切换到其他进程。因为处于TASK_ZOMBIE状态的进程不会灾备调度,所以这是进程锁执行的最后一段代码。

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

删除进程描述符

进程为什么会有僵死状态?
调用do_exit()后,线程僵死,但系统仍为其保留进程描述符(task_struct结构),目的在于让系统能在子进程终结后仍能获得其信息。进程终结的清理工作,和进程描述符的删除,被分开执行。

什么时候删除进程描述符?
父进程获得已终结子进程的信息后,或通知内核它并不关注那些信息。父进程可以调用wait()函数族,来处理僵死进程。

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

  • 首先,它调用free_uid()来减少该进程拥有者的进程使用计数。Linux用一个单用户高速缓存统计和记录每个用户占用的进程数目、文件数目。如果这些数目都将为0,表明该用户没有使用任何进程和文件,那么这块缓存就可以销毁了。
  • 然后,release_task()调用unhash_process()从pidhash上删除该进程,同时也要从task_list中删除该进程。
  • 接下来,如果该进程被ptrace跟踪,release_task()将跟踪进程的父进程重设为其最初的父进程,并将它从ptrace list上删除。
  • 最后,release_task()调用put_task_struct()释放进程内核栈和thread_info结构所占的页,并释放task_struct所占的slab高速缓存。

至此,进程描述符和所有进程独享的资源就全部释放了。

孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新父亲,否则孤儿进程就会在退出时永远僵死,耗费内存。解决办法:给子进程在当前进程组内找一个进程作为父亲,如果不行,就让init做其父进程,称为收养。在do_exit()中会调用notify_parent(),该函数会通过forget_original_parent()来执行寻父过程:

/* 父进程在子进程之前退出, 给子进程找一个父亲 */

struct task_struct* p, *reaper = father;
struct list_head* list;

if (father->exit_signal != -1)
    reaper = prev_thread(reaper); /* 线程组内上一个进程 */
else
    reaper = child_reaper; /* child_reaper代表init进程 */

if (reaper == father) /* 线程组内没有其他进程 */
    reaper = child_reaper;

上面代码将reaper设为线程组内的其他进程,如果线程组内没有其他进程,那么就将reaper设为child_reaper,即init进程。找到合适父进程后,只需要遍历所有子进程并为其设置新父进程:

/* 遍历子进程链表, 重设子进程的父进程 */
list_for_each(list, &father->children) { 
    p = list_entry(list, struct task_struct, sibling);
    reparent_thread(p, reaper, child_reaper);
}

/* 遍历ptrace子进程链表, 重设子进程的父进程 */
list_for_each(list, &father->ptrace_children) {
    p = list_entry(list, struct task_struct, ptrace_list);
    reparent_thread(p, reaper, child_reaper);
}

[======]

进程小结

1)考察了进程的概念,讨论进程的一般特性,进程与线程的关系;
2)讨论了Linux如何存放和表示进程(task_struct进程描述符,thread_info索引、task_struct位置),如何创建进程(clone,fork),如何把新的执行映像装入地址空间(exec加载器),如何表示进程的层次关系(init进程、父子进程),父进程如何收集后代信息(wait),以及进程如何死亡(强制或自愿调用exit)。

posted @ 2022-06-05 23:17  明明1109  阅读(430)  评论(0编辑  收藏  举报