Linux系统进程的实现(深入理解Linux内核进程的整理)

linux系统进程的实现

1.进程结构

进程描述符是一个task_struct结构体,包含了与进程相关的所有信息,其中包括了很多进程的属性以及指向其他数据结构的指针。

state字段:描述了进程当前所处的状态,由一组标志组成,每个标志描述一种可能的进程状态,这些状态互斥,因此只能设置一种状态,其他状态需要清零。

a.可运行状态(TASK_RUNNING):进程要么在运行,要么准备执行。

b.可中断的等待状态(TASK_INTERRUPTIBLE):进程睡眠直到某个条件变为真,如产生一个硬件中断、释放进程正等待的资源或传递信号都是唤醒进程的条件。

c.不可中断的等待状态(TASK_UNITERRUPTIBLE):和上类似,但是不可被打断,很少用,但是特定情况下很有用。如进程打开设备文件,驱动程序探测硬件设备时,探测完成之前不能被打断,否则硬件设备处于不可预知的状态。

d.暂停状态(TASK_STOPPED):进程被暂停,当收到信号如SIGSSTOP等信号时进入暂停状态。

e.跟踪状态(TASK_TRACED):进程的执行由debugger程序暂停,当一个进程被另一个进程监控时,任何信号都可以把这个进程设置为该状态。

f.僵死状态(EXIT_ZOMBIE):进程的执行被终止,但是父进程还没哟调用wait函数回收。

g.僵死撤销状态(EXIT_DEAD):最终状态:由于父进程刚发出wait函数,因而进程由系统删除,为了防止其他线程在同一进程上也执行wait,因此把进程该为此状态。

进程和描述符之间是严格的一一对应的关系,内核通过一个指向进程描述符的进程描述符指针来对进程进行管理。
图片来源
加粗样式

pid字段:linux系统用进程标识符prossesID(PID)的数来标识进程,PID被顺序编号,新创建的进程PID通常是前一个进程PID+1,不过PID有一个上限,当内核达到该值时必须开始循环使用闲置的小PID号,默认情况下PID最大是32767(PID_MAX_DEFAULT-1),管理员可以通过往/proc/sys/kernal/pid_max文件中写入一个更小的值来降低PID的上限,64位系统下,管理员可以把PID的上限扩大到4194303,四百万级别。内核通过管理pidmap_array位图来表示已分配和闲置的PID号,一个页框包含32768位,正好4k,所以32位系统中位图被放在单独的页中,然而64系统中当内核超过当前位图大小的PID时,需要为PID位图增加更多的页,系统一直保存这些页不被释放。

每个进程来说,linux把两个不同的数据结构紧凑地存放在一个单独为进程分配的存储区域内:一个是线程描述符thread_info,另一个是内核的进程堆栈,这说明每个进程都有一个自己的内核态进程堆栈。这块两个数据结构的存储区域的大小通常是8192字节(两个页框),由于8192正好是2的13次方,因此考虑到效率的因素,内核让这8K的空间占据连续的两个页框并且让第一个页框的起始地址为2^13的整数倍。内存中的存放顺序是先thread_info再内核堆栈。

thread_info指针:指向thread_info数据结构,该数据结构为线程描述符,保存线程相关的信息,其中有一个task指针,可以指向当前的进程描述符task_struct,因此两个描述符相互指向,找到一个就都找到了。且线程描述符固定长度为52字节,由上文知道thread_info和内核态的进程堆栈加起来是8192字节,因此进程堆栈最多能扩展到8140字节。
下图是thread_info和堆栈以及thread_info和进程描述符的关系。图片来源
在这里插入图片描述

如何寻找当前进程描述符地址:esp寄存器是CPU的栈指针,指向内核堆栈的栈顶,初始时堆栈为空,所以指向存储区域的末尾,之前提到两块数据结构的起始地址也就是thread_info的起始地址是2^13的倍数,且存储区域为8k,意思是存储区域范围内的地址在第14位及以上都是一样的,因此通过屏蔽掉esp指针的低13位(不是第13,而是低13,即13位及以下),就得到了thread_info的起始地址。类似的,如果结构为4k,那么屏蔽掉低12位就得到了起始地址。这可以由内核中的current_thread_info()函数来实现,由于内核最常用的是进程描述符的地址而不是线程描述符的地址,而线程描述符的地址的task指针偏移量为0,也就是在起始地址。因此可以调用current宏来实现,相当于current_thread_info()->task,就得到了进程描述符的地址,可以进行相关操作了,比如想要得到进程的PID,就调用current->pid。

**双向链表:**内核定义了list_head数据结构,next和prev分别指向后边和前边的list_head,注意指向的是list_head,而不是包含list_head的整个数据结构。
下图是linux内核中list_head结构。图片来源
在这里插入图片描述

进程链表:为双向链表的一个实现,该链表把所有的进程描述符链接起来,每个task_struct结构都有一个list_head类型的tasks字段,它的next和prev指向后边和前边的task_struct的tasks。进程链表的头是init_task描述符,即0进程或swapper进程的进程描述符它的tasks.prev指向链表中最后插入的进程描述符的tasks字段。有一个for_each_process宏,功能是扫描整个进程链表,利用链表的循环性从init_task开始每次向后移动,直到再次遇到init_task为止。

运行态(TASK_RUNNING)的进程链表:为了便于寻找可运行的进程,linux为每个优先级的进程都建立一个可运行进程表,每种优先级对应一个不同的链表,一共140个链表,每个task_struct描述符包含一个list_head类型的字段run_list,如果进程优先级为k(k属于0-139),run_list字段就把该进程链接到对应的运行队列,所有运行链表的头结点由一个prio_array_t的数据结构全部包含。该结构包含三个字段:

int nr_active 链表中进程描述符的数量

unsigned long [5] bitmap 优先权位图,当且仅当某个优先权的进程链表不为空时设置

struct list_head [140] queue 140个优先权队列的头结点。

调用enqueue_task(p,array)函数可以把进程描述符插入到相应优先级的运行链表中,p为task_struct指针,array为prio_array_t指针,dequeue_task(p,array)则是删除。

表示进程P的亲属关系的字段如下:

real_parent字段:指向创建P的进程描述符,如果其不再存在,则指向进程1的描述符,因此如果用户运行一个后台进程而且退出了shell,后台进程就会成为init的子进程,僵尸进程也是这样解决的,当一个进程为僵尸进程时,可以选择将P的父进程杀死,则P的real_parent即指向init进程,成为init进程的子进程,有init进程回收资源。

parent字段:指向P当前的父进程,它通常和real_parent的值一样,偶尔可以不同,例如另一个进程跟踪P时。

children字段:子进程链表的头部,链表中所有元素都是P的子进程。

sibling字段:指向兄弟进程链表中的下一个元素或前一个元素的指针。

pidhash表及链表:有的时候内核需要从进程的PID导出对应的进程描述符指针,比如kill()系统调用时,需要传入另一个进程的参数才能给它发信号,内核根据PID找到进程描述符,然后做相应处理。顺序扫描进程描述符也可行但是效率低,因此为了加速查找引入了4个散列表,需要4个是因为进程描述符包含了表示不同类型PID的字段,每个类型需要它自己的散列表。散列表和进程描述符的相关字段如下:

内核初始化期间动态地为4个散列表分配空间,把它们的地址存入pid_hash数组,一个散列表的长度依赖于可用RAM的容量,例如一个有512MB的RAM,那么每个散列表就存在4个页框里,可以拥有2048个表项。用pid_hashfn宏把PID转换成表索引,然而由于进程号可能大于2048,因此散列函数不能保证PID与索引一一对应,可能会发生冲突,linux使用链表来处理冲突的PID,每个表项是由冲突的进程描述符组成的双向链表。由于需要跟踪进程之间的关系,因此比如要回收制定线程组的所有进程,那么这些进程的tpid值是相同的,因此用散列表可以很好的处理,这是因为一共四个散列表,有一个专门的tpid的散列表,用于保存那些tpid对应的进程描述符,因此只需要在tpid散列表查找就可以了。
在这里插入图片描述

pids字段: 如上所说的散列表的结构就是一个pid_hash结构,该结构有四个指针,分别指向PID,TGID,PGID,SID的哈希表,每个哈希表是一个数组,存放的是指向进程描述符的pid结构的指针,pid结构是进程描述符中的一个字段,是解决哈希冲突的链表中的元素,pid结构的内容如下:

在这里插入图片描述

等待队列:表示睡眠中的进程,同样用双向链表实现,等待队列头结构wait_queue_head_t包含两个字段,一个是自旋锁,保护队列,另一个是指向list_head类型的等待队列元素的task_list字段,为等待进程链表的头,等待队列元素为wait_queue_t类型,其中四个字段,flags表示是否互斥,1互斥,0非互斥,一个指向task_struct的指针task,一个唤醒睡眠进程的方法func,还有一个task_list字段。

sleep_on()函数将进程设置成不可打断的等待状态,并插入到特定的等待队列,并主动调用调度程序,调度程序选择别的进程执行,当该进程由睡眠状态再被唤醒时,继续执行sleep_on()函数,接下来就从等待队列中移除该进程。

很多宏可以用来唤醒等待的进程如wake_up宏,所有的宏都考虑进程处于可打断的等待状态,如果宏名字不含有interruptible字符串,那么也考虑不可打断的等待进程,所有宏都唤醒具有请求状态的非互斥进程,名字带nr的宏唤醒具有请求状态的互斥进程,名字带all的宏唤醒具有请求状态的所有进程,名字没有nr和all的唤醒具有请求状态的一个互斥进程。

例如wake_up宏,功能是扫描等待队列的所有进程,对每一项计算wait_queue_t变量的地址,它的func字段存放唤醒函数的地址,可以唤醒由等待队列的task字段标识的进程,如果一个进程已经被唤醒且进程互斥,循环结束,因为所有的呼哧进程都在链表的尾部,所以函数总是唤醒互斥进程在唤醒非互斥进程。

进程资源限制:每个进程都有一组资源限制,指定进程能使用的系统资源数量。资源限制存放在current->signal->rlim字段,即进程描述符的一个字段,该字段是类型rlimit结构的数组,每个资源限制对应一个元素:

struct rlimit{

​ unsigned long rlim_cur;

​ unsgned long rlim_max;

};

第一个字段是当前资源限制,第二个字段是资源限制允许的最大值,用getrlimit()和setrlimit()系统调用用户可以获得当前rlim_cur并最多增加到rlim_max。然而只有超级用户能改变rlim_max字段或者把rlim_cur设置成比rlim_max大的值。

2.进程切换

a.硬件上下文

每个进程虽然有自己的地址空间,但所有进程必须共享cpu寄存器,因此恢复一个进城之前必须确保寄存器装入了挂起时进程的值。这种需要装入寄存器的数据称为硬件上下文,硬件上下文的一部分保存在TSS段,另一部分保存在内核堆栈,用prev表示被切换出的进程描述符,next表示切换进的,**那么进程切换可以定义为:保存prev的硬件上下文,用next硬件上下文代替prev的。**进程切换只发生在内核态,因此进程切换之前,用户态进程使用的寄存器内容都得保存在内核态堆栈上。

b.任务状态段(Task State Segment,TSS)

用于存放硬件上下文。tss_struct结构描述TSS的格式。虽然linux不使用硬件上下文切换,但是强制它为系统中每个不同的CPU创建一个TSS,原因如下:

1.当CPU从用户态切换到内核态时,它从TSS获取内核态堆栈的地址。

2.当用户进程试图通过in或out指令访问IO端口时,CPU需要访问存放在TSS中的IO许可权位图,检查该进程是否有访问端口的权力。

每个TSS有它自己的8字节的任务状态段描述符(TSSD),包含指向TSS起始地址的Base字段和20位的Limit字段,TSSD放在全局描述符表(GDT)中,GDT的基地址存放在每个CPU的gdtr寄存器中,每个CPU的tr寄存器包含相应的TSS的TSSD选择符,也包含TSSD的Base字段和Limit字段,因此处理器能直接对TSS寻址而不用从GDT中检索TSS地址。

**进程描述符的thread字段:**每个进程描述符包含一个thread_struct类型的thread字段,只要进程被切换出去,内核就把其硬件上下文保存在这个结构中。

c.执行进程切换

进行切换发生在schedule()函数,不过这里只关心内核如何执行进程切换,schedule()太长了,看linux内核那本书第七章。

进程切换本质分两步:

1.切换页全局目录以安装一个新的地址空间;

2.切换内核态堆栈和硬件上下文,因为硬件上下文提供了内核执行新进程所需要的所有信息,包含CPU寄存器。

3.创建进程

4.撤销进程

只记一下大概步骤:
如果是终止整个线程组就调用exit_group()系统调用,主要实现函数为一个内核函数do_group_exit()。
如果是终止某一个线程,调用exit()系统调用,实现函数为一个内核函数do_exit()。

do_group_exit()函数杀死current线程组的所有进程,接受终止代号作为参数,可能是正常结束指定的一个值也可能是错误代号。函数执行以下操作:

1.检查SIGNAL_GROUP_EXIT标志,如果不是0,说明内核正在执行该函数,那么把current->signal->group_exit_code当做退出码,跳到第4步。
2.否则,设置进程的SIGNAL_GROUP_EXIT标志并把终止代号放到current->signal->group_exit_code字段。
3.扫描与current->tgid对应的PIDTYPE_TGID类型的散列表中的每个PID链表,向表中所有不同于current的进程发送SIGKILL信号,相当于对它们执行do_exit()函数,把他们全杀死。
4.调用do_exit()函数,把进程终止代号传给他。

do_exit()函数:所有进程的终止都是有do_exit()函数终止的,该函数删除对终止进程的大部分引用,接受终止代号作为参数并执行以下操作:

1.设置进程描述符的flag字段为PF_EXITING标志,标志进程正在被删除。
2.有需要的话从定时器队列删除进程描述符。
3.调用一系列函数从进程描述符分离出与分页、信号量、文件系统、打开文件描述符、命名空间、IO权限位图有关的数据结构,如果没有其他进程共享这些数据结构,也会删除这些结构。
4.设置进程描述符的exit_code字段为进程终止代号。
5.调用exit_notify()函数执行下列操作:
a.更新亲属关系,如果同一线程组有别的进程,那么将终止进程子进程的父进程设置成同一线程组的另外一个进程,否则将父进程设置为init进程。
b.向父进程发一个SIGCHILD信号,宣告死亡。
c.如果描述符的exit_signal字段等于-1,且进程没被跟踪,就把exit_state设置成EXIT_DEAD,然后回收进程其他数据结构。
d.如果exit_signal字段不等于-1,或者进程在被跟踪,将字段设置为EXIT_ZOMBLE,代表僵尸态。
e.把描述符的flags字段设置成PF_DEAD标志。
6.调用schedule()函数,选择新进程运行。

进程删除:linux允许进程查看子进程的状态,调用wait()函数查看子进程是否终止,如果终止,那么它的终止代号将告诉父进程是否已成功完成。因此,不允许内核在进程终止后直接丢弃进程描述符里的数据,而是由父进程来回收,这就是引入僵尸状态的原因:丛技术上讲进程已经死亡,但是必须保存它的描述符,直到父进程得到通知。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值