Linux内核分析——第三章 进程管理

时间:2022-06-11 19:56:21

                第三章 进程管理

3.1 进程

  1、进程就是处于执行期的程序;进程就是正在执行的程序代码的实时结果;进程是处于执行期的程序以及相关的资源的总称;进程包括代码段和其他资源。

线程:是在进程中活动的对象。

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

  3、内核调度的对象是线程,而不是进程。Linux对线程并不特别区分,视其为特殊的进程

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

  5、程序本身并不是进程。实际上,完全可能存在两个或多个不同的进程执行的是同一个程序。

  6、进程在创建它的时刻开始存活。在Linux系统中,这通常是调用fork()系统的结果。

  7、fork()系统调用从内核返回两次:一次回到父进程,另一次回到新产生的子进程。

  8、fork()实际上是由clone()系统调用实现的。

  9、程序通过exit()系统调用退出程序。

  10、进程的另一个名字是任务。

  11、exec():创建新的地址空间并把新的程序载入其中。

    wait4():父进程查询子进程是否终结

    wait()、waitpid():程序退出执行后变为僵死状态,调用这两个消灭掉。

3.2 进程描述符及任务结构

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

  2、进程描述符中包含的数据能完整的描述一个正在执行的程序:它打开的文件,进程的地址空间,挂起的信号,进程的状态,还有其他更多信息。

Linux内核分析——第三章  进程管理

一、分配进程描述符

  1、Linux通过slab分配器分配task_struct结构,这样能达到对象复用和缓存着色的目的。

  2、slab分配器——动态生成,只需在栈底或者栈顶创建一个新的结构struct thread_info。

Linux内核分析——第三章  进程管理

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

二、进程描述符的存放

  1、内核通过一个唯一的进程标识值PID来标识每个进程。pid类型为pid_t,实际上就是一个int类型,最大值默认设置为32768,上限私改/proc/sys/kernel/pid_max。

  2、pid存放在各自进程描述符中。

  3、通过current宏查找到当前正在运行进程的进程描述符。

  4、x86中,在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。

  5、current通过current_thread_info()把栈指针的后13个有效位屏蔽掉,再从thread_info的task域中提取并返回task_struct的地址。

三、进程状态

  1、进程描述符中的state域是用来描述进程当前状态的。共有五种状态,标志如下:

    (1)TASK_RUNNING(运行):进程是可执行的,或者正在执行,或者在运行队列中等待执行.

    (2)TASK_INTERRUPTIBLE(可中断):进程正在睡眠/被阻塞

    (3)TASK_UNINTERRUPTIBLE(不可中断):睡眠/被阻塞进程不被信号唤醒

    (4)TASK_TRACED:被其他进程跟踪的进程

    (5)TASK_STOPPED(停止):进程停止执行;进程没有投入运行也不能投入运行。

  2、接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号时,或者调试时收到任何信号,都可以进入这种状态。

Linux内核分析——第三章  进程管理

四、设置当前进程状态

  1、用set_task_state(task,state)函数。将任务task的状态设置为state。

  2、set_current_state(state)和set_task_state(current,state) 等价。

五、进程上下文

  1、程序执行系统调用或者触发异常后,会陷入内核空间,这时候内核“代表进程执行”并处于进程上下文中。在此上下文中current宏有效。

  2、进程对内核的访问必须通过接口:系统调用和异常处理程序。

六、进程家族树

  1、所有的进程都是pid为1的init进程的后代。

  2、内核在系统启动的最后阶段启动init进程。

  3、系统中的每一个进程必有一个父进程,可以拥有0个或多个子进程,拥有同一个父进程的进程叫做兄弟。这种关系存放在进程描述符中,parent指针指向父进程task_struct,children是子进程链表。

  4、获得父进程的进程描述符:struct task_struct *my_parent = current->parent;

  5、访问子进程:

    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静态分配的。

  6、获取链表中的下一个进程:list_entry(task->tasks.next, struct task_struct, tasks);

  7、获取链表中的上一个进程:list_entry(task->tasks.prev, struct task_struct, tasks);

    以上依赖于next_task(task)和prev_task(task)这两个宏实现。

  8、for_each_process(task)宏,依次访问整个任务队列,每次访问任务指针都指向链表中的下一个元素。

    struct task_struct *task;

    for_each_process(task){

      /* 它打印出每一个任务的名称和PID */

    printk("%s[%d]\n",task->comm, task->pid);

    }

3.3 进程创建

一、写时拷贝

  1、fork():通过拷贝当前进程创建一个子进程。子进程与父进程的区别仅在于PID,PPID和某些资源和统计量。

  2、exec():读取可执行文件并将其载入地址空间开始运行。

  3、写时拷贝是一种可以推迟甚至免除拷贝数据的技术,内核不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。

  4、资源的复制只有在需要写入时才会进行,在此之前以只读方式读取。

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

二、fork()

  1、Linux通过clone()系统调用实现fork()。

  2、do_fork完成创建中大量工作,定义在kernel/fork.c文件中。该函数调用copy_process()函数让进程开始运行。

  3、创建进程的大概步骤如下:

    (1)fork()、vfork()、__clone()都根据各自需要的参数标志调用clone()。

    (2)由clone()去调用do_fork()。

    (3)do_fork()调用copy_process()函数,然后让进程开始运行。

    (4)返回do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。内核有意选择子进程首先执行。

三、vfork()

  1、除了不拷贝父进程的页表项之外,vfork()系统调用和fork()的功能相同。

  2、系统最好不要调用vfork()。

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

    (1)调用copy_process()是,task_struct的vfor_done成员被设置为NULL。

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

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

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

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

3.4 线程在Linux中的实现

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

  2、从内核的角度来看并没有线程这个概念,它把所有线程都当做进程来实现,线程仅仅被视为一个与其他进程共享某些资源的进程。

一、创建线程

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

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

  2、普通的fork()的实现:clone(SIGCHLD, 0);

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

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

Linux内核分析——第三章  进程管理Linux内核分析——第三章  进程管理

二、内核线程

  1、内核线程:独立运行在内核空间的标准进程。

  2、内核线程没有独立的地址空间,只在内核空间运行,从来不切换到用户空间,可以被调度和被抢占。

  3、内核线程只能由其他内核线程创建。内核通过从kthreadd内核进程中衍生出所有新内核线程来自动处理。在<linux/kthread.h>中声明接口。

  4、新的任务是由kthread内核进系统调用程通过clone()而创建的。

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

3.5 进程终结

  1、当一个进程终结时,内核必须释放它所占有的资源并告知父进程。

  2、进程终结的原因:一般是来自自身,发生在调用exit()系统调用时。既可能显式地调用这个系统调用,也可能隐式的从某个程序的主函数返回。

  3、不管进程是怎么终结的,该任务大部分都要靠do_exit()来完成。

一、删除进程描述符

  1、在父进程获得已终结的子进程信息并且通知内核不关注后,子进程的task_struct结构才被释放。

  2、wait()这一族函数都是通过唯一的一个系统调用wait4()来实现的。它的标准动作是挂起调用它的进程,直到其中的一个子进程退出,此时函数返回该子进程的PID。

  3、释放进程描述符时,需要调用release_task()。

二、孤儿进程造成的进退维谷

  1、如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态,白白地耗费内存。

  2、解决方法:

    (1)给子进程在当前线程组内找一个线程作为父亲,

    (2)如果不行,就让init做它们的父进程。

    在do_exit()中会调用exit_notify(),该函数会调用forget_original_parent(),而后者会调用find_new_reaper()来执行寻父过程。

    接着遍历所有子进程并为它们设置新的父进程。

    然后调用ptrace_exit_finish()同样进行新的寻父过程,是给ptraced的子进程寻找父亲。

    遍历了两个链表:子进程链表和ptrace子进程链表,给每个子进程设置新的父进程。

    最后init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。

3.6 总结

     在本章中,学习了操作系统中的核心概念——进程。讨论了Linux如何存放和表示进程(用task_struct和thread_info),如何创建进程(通过fork(),实际上最终是clone()),如何把新的执行映像装入到地址空间(通过exec()系统调用族),如何表示进程的层次关系,父进程又是如何收集其后代的信息(通过wait()系统调用族),以及进程最终如何消亡(强制或自愿的调用exit())。进程是一个非常基础、非常关键的抽象概念,位于每一种现代操作系统的核心位置,也是我们拥有操作系统(用来运行程序)的最终原因。

参考资料

  《Linux内核设计与实现》第3版