【Linux操作系统分析】进程——进程切换,进程的创建和撤销

来源:互联网 发布:html5开车小游戏源码 编辑:程序博客网 时间:2024/05/03 03:23

1 进程

进程是程序执行时的一个实例,可以把它看作充分描述程序已经执行到何种程度的数据结构的汇集。

从内核的观点看,进程的目的是担当分配系统资源(CPU时间,内存等)的实体。

(早期的系统中)当一个进程创建时,它几乎与父进程相同,执行的下一条指令与父进程父进程相同,但是之后的活动是与父进程没有关系,它们各自有独立的数据拷贝(栈和堆)。

多线程应用程序多个执行流的创建、处理、调度都是在用户态进行的。

Linux使用轻量级进程对多线程应用程序提供更好的支持。


2 进程描述符(process descriptor)

进程描述符是task_struct类型结构,它的字段包含了与一个进程相关的所有信息。



3 进程状态

进程描述符中的state字段描述了进程当前所处的状态。它由一组标志组成,其中每个标志描述一种可能的进程状态。

下面是进程可能的状态:

  • 可运行状态
  • 可中断的等待状态
  • 不可中断的等待状态
  • 暂停状态
  • 跟踪状态
还有两个进程状态是既可以存放在进程描述符的state字段中,也可以存放在exit_state字段中。只有当进程被终止时,进程的状态才会变为这两种状态的一种:
  • 僵死状态:进程终止,但没有发布死亡消息,数据保留,可能会用到。
  • 僵死撤销状态:最终状态,进程由系统删除。

3.1 标识一个进程

一般来说,能被独立调度的每个执行上下文都必须拥有他/她自己的进程描述符,即自己的task_struct结构。
进程和进程描述符之间有非常严格的一一对应关系,32位进程描述符地址。
另一方面,进程标识符process ID(PID)可用来表示进程,PID存放在进程描述符的pid字段中。最大的PID值是32767。在64位体系结构中,PID上限为4194303.
PID的管理:pidmap-array位图。32位体系结构中pidmap-array位图存放在一个单独的页中。
一个多线程应用程序中的所有线程都必须有相同的PID。
线程组:一个线程组中的所有线程使用和该线程组的领头线程相同的PID,也就是该组中第一个轻量级进程的PID。

3.2 进程描述符处理

对每个进程来说,Linux都把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域(动态内存)内:一个是内核态的进程堆栈,另一个是紧挨着进程描述符的小数据结构thread_info,叫做线程描述符。这块存储区域的大小通常为8192个字节(两个页框)。当使用一个页框存放内核态堆栈和thread_info结构时,内核要采用一些额外的栈以防止中断和异常的深度嵌套而引起的溢出。

因为tread_info结构是52个字节长,因此内核栈能扩展到8140个字节。
内核使用alloc_thread_info和free_thread_info宏分配和释放存储thread_info结构和内核栈的内存区。

3.3 标识当前进程

从效率的观点来看,刚才所讲的thread_info结构与内核态堆栈之间的紧密结合提供的主要好处是:内核很容易从esp寄存器的值获得当前在CPU上正在运行进程的thread_info结构的地址(通过屏蔽低13位或低12位)。这项工作由current_thread_info()函数完成。

这三条指令执行以后,p就包含在执行指令的CPU上运行的进程的thread_info结构的指针。又因为task字段在thread_info结构中的偏移量为0,所以执行完这三条指令之后,p就包含在CPU上运行进程的描述符指针。因为进程最常用的是进程描述符地址,而不是thread_info结构的地址。进程描述符地址可以通过:current_thread_info()->task获得。
current宏经常作为进程描述符字段的前缀出现在内核代码中,例如,current->pid返回在CPU上正在执行的进程的PID。
在多处理器系统上,有必要把current定义为一个数组,每一个元素对应一个可用CPU。

Linux内核定义的list_head数据结构:


3.4 进程链表——双向链表的第一个例子

进程链表把所有进程的描述符链接起来,每个task_struct结构都包含一个list-head类型的tasks字段,这个类型的prev和next字段分别指向前面和后面的task_struct元素。
进程链表的头是init_task描述符,它是所谓的0号进程(process 0)或swapper进程的进程描述符。
for_each_process宏,它的功能是扫描整个进程链表。

TASK_RUNNING状态的进程链表:
当内核寻找一个新进程在CPU上运行时,必须只考虑可运行进程。
提高调度程序运行速度的诀窍是简历多个可运行进程链表,每种进程优先权对应一个不同的链表。
enqueue_task(p,array)函数把进程描述符插入某个运行队列的链表。类似的,dequeue(p,array)函数从运行队列的链表中删除一个进程的描述符。

4 进程间的关系

程序创建的进程具有父子关系,如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。
进程0和进程1是由内核创建的,进程1(init)是所有进程的祖先。


进程之间存在其他关系:一个进程可能是一个进程组或登录会话的领头进程,也可能是一个线程组的领头进程,它还可能跟踪其他进程的执行。

pidhash表及链表:
在几种情况下,内核必须能从进程的PID到处对应的进程描述符指针。顺序扫描进程链表并检查进程描述符的pid字段是可行但相当低效的。
4个散列表(因为进程描述符包含了表示不同类型PID的字段,每种类型的PID需要它自己的散列表):


内核初始化期间,动态地为4个散列表分配空间,并把它们的地址存入pid_hash数组。
散列函数并不总能确保PID与表的索引一一对应,两个不同的PID散列到相同的表索引称为冲突。
Linux利用链表来处理冲突的PID:每一个表项是由冲突的进程描述符组成的双向链表。


由于需要跟踪进程间的关系,PID散列表中使用的数据结构非常复杂。这个数据结构最主要的部分是四个pid结构的数组,它在进程描述符的pid字段中。



pid_hash数组的第二个元素存放散列表的地址,也就是hlist_head结构的数组表示链表的头。

5 如何组织进程

5.1 等待队列

等待队列在内核中有很多用途,尤其用在中断处理,进程同步及定时。
等待队列实现了在时间上的条件等待:希望等待特定事件的进程把自己放进合适的等待队列,并放弃控制权。因此,等待队列表示一组睡眠的进程,当某一条件变为真时,由内核唤醒它们。
等待队列数据结构为双向链表,其元素包括指向进程描述符的指针。
等待队列头是一个类型为wait_queue_head_t的数据结构

因为等待队列是由中断处理程序和主要内核函数修改的,因此必须对其双向连表进行保护以免对其进行同时访问,因为同时访问会导致不可预测的后果。同步是通过等待队列头中的Lock自旋锁打到的。task_list字段是等待进程聊表的头。

等待队列链表中的元素类型为wait_queue_t:

等待队列链表中的每个元素代表一个睡眠进程,该进程等待某一事件的发生,它的描述符地址存放在task字段中,task_list字段中包含的是指针,由这个指针把一个元素链接到等待相同事件的进程链表中。等待队列元素的func字段用来表示等待队列中睡眠进程应该用什么方式唤醒。

等待队列的操作:P103

6 进程资源限制resouce limit

进程资源限制 限制指定了进程能使用的系统资源数量,这些限制避免用户过分使用系统资源。对当前进程的资源限制存放在current->signal->rlim字段,即进程的信号描述符的一个字段。

7 进程切换(上下文切换)

7.1 硬件上下文

进程恢复执行前必须装入寄存器的一组数据称为硬件上下文。尽管每个进程可以拥有属于自己的地址空间,但所有进程必须共享CPU寄存器。因此,在恢复一个进程的执行之前,内核必须确保每个寄存器装入了挂起进程时的值,即硬件上下文。
prev局部变量表示切换出的进程的描述符,next表示切换进的进程的描述符。
我们把进程切换定义为这样的行为:保存prev硬件上下文,用next硬件上下文代替prev。
Linux是用软件执行进程切换。进程切换只发生在内核态。在执行进程切换之前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上,这也包括ss和esp这对寄存器的内容。

7.2 TSS(任务态段)——存放硬件上下文

尽管Linux不使用硬件上下文切换,但是强制它为系统中每个不同的CPU创建一个TSS。
在每次进程切换时,内核都更新了TSS的某些字段以便相应的CPU控制单元可以安全地检索到它需要的信息,因此,TSS反应了CPU上的当前进程的特权级。
每个TSS有它自己8字节的任务状态段描述符TSSD。
每个CPU只有一个TSS,因此,Busy位总置为1.
由Linux创建的TSSD存放在全局描述符表(GDT)中,GDT的基地址存放在每个CPU的gdtr寄存器中。每个CPU的tr寄存器包含相应的TSS的TSSD选择符,也包含两个隐藏的非编程字段:TSSD的Base字段和Limit字段。这样,处理器就能直接对TSS寻址而不用从GDT中检索TSS的地址。
thread字段:每个进程描述符包含一个类型为thread_struct的thread字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。

7.3 执行进程切换

从本质上说,每个进程切换由两步组成:
  1. 切换全局目录以安装一个新的地址空间
  2. 切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包括CPU寄存器
进程切换的第二步由switch_to宏执行。它是内核中与硬件关系最密切的例程之一。
首先,该宏有三个参数,它们是prev, next 和last。在任何进程切换中,涉及到三个进程而不是两个。

__switch_to()函数


(跳过)保存和加载FPU, MMX, 及XMM寄存器 P115

8 (KD)创建进程

现代Unix内核通过引入三种不同的机制来创建新进程:
  • 写时复制技术,允许父子进程读相同的物理页。只要两者有一个试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理页,并把这个新的物理页分配给正在写的进程。
  • 轻量级进程允许父子进程共享进程在内核的很多数据结构,如页表、打开文件及信号处理
  • vfork()系统调用创建的进程能共享其父进程的内存地址空间。为了防止父进程重写子进程需要的数据,阻塞父进程的执行,一直到子进程退出或执行一个新的程序为止。

8.1 clone(), fork(), vfork()

clone()函数:创建轻量级进程的堆栈并且调用对编程者隐藏的clone()系统调用。调用sys_clone()服务例程。
fork()函数:用clone()实现,它的child_stack参数是父进程当前的堆栈指针。因此,父进程和子进程暂时共享同一个用户态堆栈。通常只要父子进程中有一个试图去改变栈,则立即各自得到用户态堆栈的一份拷贝。
do_fork()函数负责处理clone(), fork()和vfork()系统调用。利用辅助函数copy_process()来创建进程描述符以及子进程执行所需要的所有其他内核数据结构。
copy_process()函数:创建进程描述符以及子进程执行所需要的所有其他数据结构。(28个步骤)

do_fork()结束之后,继续完善子进程,1)把子进程描述符thread字段的值装入几个CPU寄存器;2)在fork(),vfork()或clone()系统调用结束时,新进程将开始执行。

9 内核线程

内核线程不同于普通线程:
  • 内核线程只运行在内核态,而普通线程既可以运行在内核态,也可以运行在用户态
  • 因为内核线程只运行在内核态,它们只使用大于PAGE_OFFSET的线性地址空间,另一方面,不管在用户态还是在内核态,普通进程可以用4GB的线性地址空间。
kernel_thread()函数创建一个新的内核线程。

进程0:所有进程的祖先。只有当没有其他进程处于TASK_RUNNING状态时,调度程序才选择进程0.
进程1:又叫init进程,start_kernel()函数初始化内核需要的所有数据结构,激活中断,创建另一个叫进程1的内核线程(一般叫做init进程):
               kernel_thread(init, NULL, CLONE_FS|CLONE_SIGHAND);
新创建的内核线程的PID为1,并与进程0共享进程所有的内核数据结构。当调度程序选择到它时,init进程开始执行init()函数。init()函数一次完成内核初始化:init()调用execve()系统调用装入可执行程序init。结果,init内核线程变为一个普通进程,且拥有自己的每进程内核数据结构。在系统关闭之前,init进程一直存活,因为它创建和监控在操作系统外层执行的所有进程的活动。

9 撤销进程

进程终止的一般方式是调用exit()库函数:该函数释放C函数库所分配的资源,执行编程者所注册的每个函数,并结束从系统回收进程的那个系统调用。

进程终止(两个系统调用):
  • exit_group()系统调用,它终止整个线程组,即整个基于多线程的应用。do_group_exit()
  • exit()系统调用,它终止某一个线程,而不管该线程所属线程组中的所有其他进程。do_eixt()

进程删除:
wait()库函数:检查子进程是否终止。如果子进程已经终止,那么它的终止代号将告诉父进程这个任务是否已成功地完成。
不允许UNIX内核在进程一终止后就丢弃包含在进程描述符字段中的数据。只有父进程发出了与被终止的进程相关的wait()类系统调用后,才允许这样做。(即引入的僵死状态)。从技术上讲,进程已死,但必须保存它的描述符,直到父进程得到通知。