进程补充概念和Linux部分实现
承接已有博客
task_struct
- 结构体内包含的几种重要信息
- thread_info:进程的基本信息
- mm_struct:指向内存区描述符的指针
- fs_struct:当前目录
- files_struct:指向文件描述符的指针
- signal_struct:所接收的信号
- pid:每个进程都有唯一的,内核用位图存储,在内存中为保留页
- 引进线程组后,线程组的所有线程都是用和领头线程相同的pid,放入进程描述符的tgid字段
- getpid返回tgid的值
- real_parent:指向创建P的进程的描述符,如果父进程不在,就指向进程1(init)
- children:一个链表的头部,链表中所有元素都是P的子进程
- pid:进程的pid
- 利用哈希表查找task_struct,pid作为key值
- 线程也用pid,同一进程内的所有线程的pid不相同
- tgid:P所在线程组的领头进程的pid,同一进程内的所有线程tgid都相同,和领头线程的pid相同
- pgid:P所在进程组的领头进程的pid
USER VIEW
<-- PID 43 --> <----------------- PID 42 ----------------->
+---------+
| process |
_| pid=42 |_
_/ | tgid=42 | \_ (new thread) _
_ (fork) _/ +---------+ \
/ +---------+
+---------+ | process |
| process | | pid=44 |
| pid=43 | | tgid=42 |
| tgid=43 | +---------+
+---------+
<-- PID 43 --> <--------- PID 42 --------> <--- PID 44 --->
KERNEL VIEW
- 存放位置:
- 2.6以前,task_struct存放在它们内核栈的尾端,可以直接计算位置
- 现在使用slab分配器,将struct thread_info存放在内核栈的尾端,结构体内有指向task_struct的指针
- 有些硬件结构使用专门寄存器存放指向当前task_struct的指针
- 获取方法:
- 将栈指针后13位屏蔽掉,可得到thread_info的偏移,即结构体位置,通过->task即可得到task_struct
- 因为有内存对齐和栈从高到低的原则,内核栈为8KB就是13位,4KB就是12位,屏蔽后N位相当于-8KB或-4KB,就能得到内核栈栈底位置,即thread_info结构体位置
内核栈
大小通常为连续两个页框8KB,thread_info在栈底
进程组织
运行队列
每个CPU都有自己的运行队列
等待队列
一条双向链表,实现了在事件上的条件等待,当某一条件变为真时,由内核唤醒它们。一条队列上的进程都是等待同一事件的
- 每个等待队列有一个链表头,里面有一个自旋锁,保护链表以免同时操作
- 结点有成员flag判断进程是否互斥,成员task指向task_struct等
- 资源释放后,唤醒该队列上所有进程,如果资源是互斥的话,唤醒所有进程最后只有一个进程可以执行,其他进程再次睡眠
- 解决方法:将进程分为互斥和非互斥,互斥进程由内核有选择地唤醒,非互斥进程直接唤醒
- 非互斥进程插入等待队列的第一个位置,互斥进程插入等待队列的最后一个位置
- 先唤醒所有非互斥进程,当进程为互斥(flag字段=1),唤醒后结束循环
- 一个等待队列里同时包含两种进程的情况很少
进程运行状态
操作系统概念的对应
- 就绪
- TASK_RUNNING运行:准备执行或正在执行
- 阻塞
- TASK_INTERRUPTIBLE可中断:进程正在睡眠(阻塞),等待某些条件达成。产生硬件中断、释放进程正在等待的资源、传递一个信号(sleep过程中进程收到信号会被唤醒),都是可以唤醒进程的条件
- TASK_UNINTERRUPTIBLE不可中断:和可中断状态类似,除了信号不能唤醒进程
- PS:这里的中断不是操作系统的概念,是指这个状态可不可以被改变
- 终止
- EXIT_ZOMBIE僵死:进程执行被终止,但父进程还没调用wait回收
- EXIT_DEAD僵死撤销:最终状态,进程被系统删除,父进程调用wait回收
- 暂停
- TASK_STOPPED停止:进程执行被暂停,当进程接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号就进入停止状态
- TASK_TRACED跟踪:进程执行被debugger程序暂停
进程生存流程
- 通常是系统调用fork的结果,系统复制父进程来创建一个全新的子进程。调用结束后,系统调用从内核返回调用位置两次,一次返回父进程,恢复执行,一次返回子进程,开始执行。
- 通常调用exec,让子进程执行不同的、新的程序。过程是创建新的地址空间,并把新程序装入其中
- 调用exit系统调用退出执行。释放部分资源,设置为僵死状态
- 父进程调用wait回收剩余资源
进程创建
fork实现
一种系统调用,fork--->sys_fork--->do_fork--->copy_process
- do_fork完成创建中的大部分工作
- copy_process
- 调用dup_task_struct为新进程创建(复制)内核栈、task_struct和thread_info
- 更改task_struct部分成员,并设置为TASK_UNINTERRUPTIBLE
- 根据clone传递的参数,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等
- 共享时间片
- 原因:避免一个进程不断创建新进程延长运行时间,霸占资源
- 返回指向子进程的指针
- 实际开销:①复制页表;②创建进程描述符
内核有意选择子进程首先执行,因为一般子进程会调用exec,避免父进程先执行写入
写时拷贝
一种可以推迟或免除拷贝数据的技术
- 实现:
- 父进程和子进程共享同一个拷贝(页框,物理上的,页表还是要复制的),资源设置为只读
- 当进程写入时,内核把这个页的内容拷贝到新的物理页,把新的物理页分配给写入的进程
- 旧页框的count字段减1,当其他进程写入时count为0,则改为可写,不需要复制
- 优点:
- 如果子进程exec了,就可以免除拷贝的开销了
- 避免拷贝大量不会被使用的数据
vfork实现
一种系统调用,vfork--->sys_vfork--->do_fork--->copy_process
除了不拷贝页表,其他和fork一样。阻塞父进程,直到子进程退出或执行一个新程序
- 出现原因:当时没有写时拷贝,创建一个子进程开销太大
- 注意:因为会阻塞父进程,所以一定要调用exit退出;子进程不能写入
clone实现
一种系统调用,创建LWP,clone--->sys_clone--->do_fork--->copy_process
do_fork实现
负责处理sys_fork、sys_clone和sys_vfork,使用CLONE系列参数来区分
- fork:只用参数SIGCHLD
- vfork:SIGCHLD、CLONE_VM、CLONE_VFORK
- clone:指定参数
- 步骤
- 查找位图,分配pid
- copy_process复制进程描述符
- 根据CLONE系列参数进行操作
- 没有CLONE_VM,需要利用写时复制,把子进程插入到运行队列中父进程的前面
- CLONE_VFORK,把父进程插入等待队列
- 返回pid
copy_process实现
创建进程描述符和子进程执行需要的所有其他数据结构,参数和do_fork相同,外加pid
- 步骤
- 调用dup_task_struct为新进程分配task_struct、内核栈和thread_info内存,让两个结构体的成员相互指向对方,返回进程描述符指针
- 把传入的参数pid赋给进程描述符中的字段
- 更改或初始化进程描述符的部分字段
- 根据clone传递的参数,拷贝或共享打开的文件file_struct、文件系统信息fs_struct、信号处理函数signal_struct、进程地址空间mm_struct+页表+各个线性区vm_area_struct和命名空间namespace等
- ps:fd_array在file_struct是个静态指针数组,文件描述符会被复制,copy了file_struct但会共享file对象
- 将父进程内核栈的值复制初始化子进程的内核栈
- 返回指向子进程的指针
进程终结
exit实现
一种系统调用,exit--->do_exit
- 省略一部分看不懂的
- 调用do_exit系统调用
- 调用exit_mm释放进程占用的mm_struct(地址空间),如果地址空间不是共享的话,彻底释放
- 调用exit_files、exit_fs减少文件的引用数,如果引用数减少为零就释放资源
- 调用exit_notify给父进程发送信号,给子进程重新找养父(线程组中其他线程或者init进程),并把进程状态设置为EXIT_ZOMBIE
- 调用schedule进程切换
do_exit实现
从内核数据结构中删除对终结进程的大部分引用
- 取消引用或删除(没有共享的进程)进程描述符中指向的数据结构
- 调用exit_notify
- 给子进程找养父,同线程组内的另一个进程或init
- 通过信号机制给父进程发送信号,通知父进程子进程死亡
- 状态设置为EXIT_ZOMBIE
- 调用schedule选择一个新进程运行
进程删除
ZOMBIE进程剩下占用内存的就是内核栈、thread_info、task_struct
- 目的:向它父进程提供信息
- 父进程调用wait检索信息后,或通知内核那是无关信息后,释放剩下的内存
wait实现
标准动作,挂起调用它的进程,直到它的某个子进程退出,进行资源回收。如果没有子进程,则出错返回
- PS:父进程会回收子进程的时间片
孤儿进程
父进程在子进程之前退出,子进程就是孤儿进程
- 解决方法:给子进程在当前线程组找一个线程作为父进程,否则让init进程作为父进程,在exit_notify进行
僵尸进程
子进程退出,但父进程没有调用wait
- 问题:占用pid和内存资源
- 解决方法:杀死父进程,让子进程变成孤儿进程
进程调度
抢占
Linux提供抢占式多任务模式
- 抢占时机
- 新进程进入TASK_RUNNING状态,内核检查优先级
- 进程用完时间片
- 进程阻塞
- 进程暂停或被杀死
- 进程自愿放弃CPU
进程类型
- IO消耗型:进程大部分时间用来提交IO请求或等待IO请求。经常处于可运行状态,但通常只运行短短的一会儿,因为等待IO时会阻塞
- 处理器消耗型:进程大部分时间用来执行代码
- IO消耗型和处理器消耗型并存:比如字处理器,会等待键盘输入,又会疯狂的进行拼写检查
进程优先级
nice值,命令ps -el
中的NI字段就是进程对应的nice值
- 范围是-20到+19,默认值为0
- 低nice值代表高优先级,高nice值代表低优先级
- 代表时间片的比例
SCHED_NORMAL:完全公平调度算法CFS
- CFS调度器并不把时间片分给进程,而是把处理器的使用比划分给进程
- 抢占时机:新的可运行进程消耗了多少处理器使用比
- 例子:文本编辑器(IO消耗型)和视频解码器(处理器消耗型)。文本编辑器经常IO阻塞,导致使用比低。当文本编辑器变为可执行状态,就会马上抢占执行
- 逻辑实现:
- nice值在CFS中被作为进程获得处理器使用比的权重,不影响调度,只影响处理器时间分配比例
- 任何进程所获得的处理器时间是由它自己和其他所有可运行进程的nice值的相对差值决定的
- 调度选择只根据该进程已消耗了多少处理器使用比来进行抢占
- 具体实现:
- 时间记账:没有时间片的概念,但必须维护每个进程运行的时间记账vruntime
- vruntime初始化:先取父进程的vruntime,再取根据权重计算出新的vruntime作为子进程的vruntime;如果要求子进程比父进程先运行,那么取两者之中较小值为子进程的vruntime,父进程也相应进行替换
- vruntime越小,被调用的机会越大
- vruntime通过权重(对应nice)和实际运行时间计算出来的
- 时钟中断会让当前进程vruntime稳定地增加, 因此进程在红黑树中总是向右移动的.
- 因为越重要的进程vruntime增加的越慢, 因此他们向右移动的速度也越慢, 这样其被调度的机会要大于次要进程, 这刚好是我们需要的
- 如果进程进入睡眠, 则其vruntime保持不变. 因为每个队列min_vruntime同时会单调增加, 那么当进程从睡眠中苏醒, 在红黑树中的位置会更靠左, 因为其键值相对来说变得更小了
- vruntime溢出问题,减去一个最小的vruntime将所有进程的key围绕在最小vruntime的周围
- 进程选择:选择具有最小的vruntime的进程,进程树为红黑树,key值为vruntime
- 调度器入口
- 睡眠和唤醒
- 时间记账:没有时间片的概念,但必须维护每个进程运行的时间记账vruntime
SCHED_FIFO:先进先出的实时进程
- 不使用时间片
- 静态优先级
- SCHED_FIFO会比SCHED_NORMAL先得到调度
- 抢占式,但只体现在优先级比当前运行进程高的(SCHED_FIFO或SCHED_RR)
- 其他情况只有进程阻塞或自愿让出处理器,其他进程才可以运行
SCHED_RR:时间片轮转的实时进程
- 带时间片的SCHED_FIFO
- 抢占式,体现在用完时间片和高优先级抢占低优先级
- 静态优先级
- 时间片只用来调度同一优先级的进程
PS
- 每个优先级一个运行队列
- 两种实时都是软实时,并不是硬实时。即系统只是尽力使进程在限定时间到来前运行
进程切换
在进行进程切换之前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上
任务状态段TSS
每个CPU有一个TSS。存放硬件上下文(以前),反映CPU上的当前进程的特权级(现在)
- 用处:
- 当CPU从用户态切换到内核态,从TSS获取内核态堆栈的地址
- 用户态进程试图通过in或out指令访问一个IO端口是,在TSS检查IO许可权位图检查该进程是否有访问端口的权利
- 取得TSS的方法
- tr寄存器,里面存放TSSD的选择子,也存放TSSD的Base和Limit字段(这样就不用在GDT表中查,直接对TSS寻址),这也是特殊之处,正常只有段选择子
- TSSD:TSS描述符
步骤
- 切换页全局目录以安装一个新的地址空间(换页表)
- 切换内核态堆栈和硬件上下文(恢复环境)
switch_to
三个参数,prev和next是schedule的局部变量
- prev:被替换进程的地址
- next:新进程描述符的地址
- last:是prev的引用
- 为什么要用last:
- 如果进程切换顺序是A、B、C、A,第一次A进程切换时,prev是A,next是B,switch_to中A就停下来了;第二次进程切换回到A时,prev还是A,next是B,但实际上prev应该是C(跳出switch_to回到schedule中可能要用C)
- 不能直接写入prev,在schedule中传入switch_to的prev,是拷贝的,在switch中修改prev不会修改schedule的prev;而传入switch_to的last是schedule中prev的引用,在switch_to中修改last可以同步修改schedule中的prev
- 实现:C到A的进程切换之前,prev(C)写入eax寄存器;进程切换之后eax写入last,因为last是prev(schedule)的引用,所以相当于修改了prev
进程同步
线程
对于Liunx而言,线程是一种特殊的进程,也有自己的task_struct,只是和其他进程共享某些资源比如地址空间(页表)、打开文件表和信号处理
- 线程有私有数据,放在tls段,进程中为所有线程创建一个posix全局key,每个线程都会有相同的key,但会指向的值是自己独有的
进程家族树
进程0
idle进程或swapper进程,系统进程,数据结构都是静态分配的(其他进程的数据结构都是动态分配的)
- PS:静态分配是指资源在进程结束后才释放,动态分配是指使用完就释放、需要再请求
- 工作:
- 初始化内核需要的数据结构,激活中断
- 创建进程1(init进程)
- 执行cpu_idle函数(在开中断情况下重复执行hlt汇编指令,即暂停指令)
- 每个CPU都有一个进程0,开机时只启用一个CPU,禁用其他CPU,完成其工作后再copy_process创建其他进程0
- 没有TASK_RUNNING状态的进程,调度程序就会选择进程0
进程1
init进程,原本是内核线程,完成内核初始化后变成普通进程,但用超级用户权限运行
- 作用:创建和监控在操作系统外层执行的所有进程的活动(养父发现有子进程终止就调用wait)