进程补充概念和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),唤醒后结束循环
    • 一个等待队列里同时包含两种进程的情况很少

进程运行状态

操作系统概念的对应

  1. 就绪
    1. TASK_RUNNING运行:准备执行或正在执行
  2. 阻塞
    1. TASK_INTERRUPTIBLE可中断:进程正在睡眠(阻塞),等待某些条件达成。产生硬件中断、释放进程正在等待的资源、传递一个信号(sleep过程中进程收到信号会被唤醒),都是可以唤醒进程的条件
    2. TASK_UNINTERRUPTIBLE不可中断:和可中断状态类似,除了信号不能唤醒进程
    • PS:这里的中断不是操作系统的概念,是指这个状态可不可以被改变
  3. 终止
    1. EXIT_ZOMBIE僵死:进程执行被终止,但父进程还没调用wait回收
    2. EXIT_DEAD僵死撤销:最终状态,进程被系统删除,父进程调用wait回收
  4. 暂停
    1. TASK_STOPPED停止:进程执行被暂停,当进程接收到SIGSTOP、SIGTSTP、SIGTTIN或SIGTTOU信号就进入停止状态
    2. TASK_TRACED跟踪:进程执行被debugger程序暂停

进程生存流程

  1. 通常是系统调用fork的结果,系统复制父进程来创建一个全新的子进程。调用结束后,系统调用从内核返回调用位置两次,一次返回父进程,恢复执行,一次返回子进程,开始执行。
  2. 通常调用exec,让子进程执行不同的、新的程序。过程是创建新的地址空间,并把新程序装入其中
  3. 调用exit系统调用退出执行。释放部分资源,设置为僵死状态
  4. 父进程调用wait回收剩余资源

进程创建

fork实现

一种系统调用,fork--->sys_fork--->do_fork--->copy_process

  • do_fork完成创建中的大部分工作
  • copy_process
    1. 调用dup_task_struct为新进程创建(复制)内核栈、task_struct和thread_info
    2. 更改task_struct部分成员,并设置为TASK_UNINTERRUPTIBLE
    3. 根据clone传递的参数,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等
    4. 共享时间片
      • 原因:避免一个进程不断创建新进程延长运行时间,霸占资源
    5. 返回指向子进程的指针
  • 实际开销:①复制页表;②创建进程描述符

内核有意选择子进程首先执行,因为一般子进程会调用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:指定参数
  • 步骤
    1. 查找位图,分配pid
    2. copy_process复制进程描述符
    3. 根据CLONE系列参数进行操作
      • 没有CLONE_VM,需要利用写时复制,把子进程插入到运行队列中父进程的前面
      • CLONE_VFORK,把父进程插入等待队列
    4. 返回pid

copy_process实现

创建进程描述符和子进程执行需要的所有其他数据结构,参数和do_fork相同,外加pid

  • 步骤
    1. 调用dup_task_struct为新进程分配task_struct、内核栈和thread_info内存,让两个结构体的成员相互指向对方,返回进程描述符指针
    2. 把传入的参数pid赋给进程描述符中的字段
    3. 更改或初始化进程描述符的部分字段
    4. 根据clone传递的参数,拷贝或共享打开的文件file_struct、文件系统信息fs_struct、信号处理函数signal_struct、进程地址空间mm_struct+页表+各个线性区vm_area_struct和命名空间namespace等
      • ps:fd_array在file_struct是个静态指针数组,文件描述符会被复制,copy了file_struct但会共享file对象
    5. 将父进程内核栈的值复制初始化子进程的内核栈
    6. 返回指向子进程的指针

进程终结

exit实现

一种系统调用,exit--->do_exit

  1. 省略一部分看不懂的
  2. 调用do_exit系统调用
  3. 调用exit_mm释放进程占用的mm_struct(地址空间),如果地址空间不是共享的话,彻底释放
  4. 调用exit_files、exit_fs减少文件的引用数,如果引用数减少为零就释放资源
  5. 调用exit_notify给父进程发送信号,给子进程重新找养父(线程组中其他线程或者init进程),并把进程状态设置为EXIT_ZOMBIE
  6. 调用schedule进程切换

do_exit实现

从内核数据结构中删除对终结进程的大部分引用

  1. 取消引用或删除(没有共享的进程)进程描述符中指向的数据结构
  2. 调用exit_notify
    • 给子进程找养父,同线程组内的另一个进程或init
    • 通过信号机制给父进程发送信号,通知父进程子进程死亡
    • 状态设置为EXIT_ZOMBIE
  3. 调用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值的相对差值决定的
    • 调度选择只根据该进程已消耗了多少处理器使用比来进行抢占
  • 具体实现
    1. 时间记账:没有时间片的概念,但必须维护每个进程运行的时间记账vruntime
      • vruntime初始化:先取父进程的vruntime,再取根据权重计算出新的vruntime作为子进程的vruntime;如果要求子进程比父进程先运行,那么取两者之中较小值为子进程的vruntime,父进程也相应进行替换
      • vruntime越小,被调用的机会越大
      • vruntime通过权重(对应nice)和实际运行时间计算出来的
      • 时钟中断会让当前进程vruntime稳定地增加, 因此进程在红黑树中总是向右移动的.
      • 因为越重要的进程vruntime增加的越慢, 因此他们向右移动的速度也越慢, 这样其被调度的机会要大于次要进程, 这刚好是我们需要的
      • 如果进程进入睡眠, 则其vruntime保持不变. 因为每个队列min_vruntime同时会单调增加, 那么当进程从睡眠中苏醒, 在红黑树中的位置会更靠左, 因为其键值相对来说变得更小了
      • vruntime溢出问题,减去一个最小的vruntime将所有进程的key围绕在最小vruntime的周围
    2. 进程选择:选择具有最小的vruntime的进程,进程树为红黑树,key值为vruntime
    3. 调度器入口
    4. 睡眠和唤醒

SCHED_FIFO:先进先出的实时进程

  • 不使用时间片
  • 静态优先级
  • SCHED_FIFO会比SCHED_NORMAL先得到调度
  • 抢占式,但只体现在优先级比当前运行进程高的(SCHED_FIFO或SCHED_RR)
  • 其他情况只有进程阻塞或自愿让出处理器,其他进程才可以运行

SCHED_RR:时间片轮转的实时进程

  • 带时间片的SCHED_FIFO
  • 抢占式,体现在用完时间片和高优先级抢占低优先级
  • 静态优先级
  • 时间片只用来调度同一优先级的进程

PS

  1. 每个优先级一个运行队列
  2. 两种实时都是软实时,并不是硬实时。即系统只是尽力使进程在限定时间到来前运行

进程切换

在进行进程切换之前,用户态进程使用的所有寄存器内容都已保存在内核态堆栈上

任务状态段TSS

每个CPU有一个TSS。存放硬件上下文(以前),反映CPU上的当前进程的特权级(现在)

  • 用处:
    • 当CPU从用户态切换到内核态,从TSS获取内核态堆栈的地址
    • 用户态进程试图通过in或out指令访问一个IO端口是,在TSS检查IO许可权位图检查该进程是否有访问端口的权利
  • 取得TSS的方法
    • tr寄存器,里面存放TSSD的选择子,也存放TSSD的Base和Limit字段(这样就不用在GDT表中查,直接对TSS寻址),这也是特殊之处,正常只有段选择子
    • TSSD:TSS描述符

步骤

  1. 切换页全局目录以安装一个新的地址空间(换页表)
  2. 切换内核态堆栈和硬件上下文(恢复环境)

switch_to

三个参数,prev和next是schedule的局部变量

  • prev:被替换进程的地址
  • next:新进程描述符的地址
  • last:是prev的引用
  • 为什么要用last:
    1. 如果进程切换顺序是A、B、C、A,第一次A进程切换时,prev是A,next是B,switch_to中A就停下来了;第二次进程切换回到A时,prev还是A,next是B,但实际上prev应该是C(跳出switch_to回到schedule中可能要用C)
    2. 不能直接写入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. 初始化内核需要的数据结构,激活中断
    2. 创建进程1(init进程)
    3. 执行cpu_idle函数(在开中断情况下重复执行hlt汇编指令,即暂停指令)
  • 每个CPU都有一个进程0,开机时只启用一个CPU,禁用其他CPU,完成其工作后再copy_process创建其他进程0
  • 没有TASK_RUNNING状态的进程,调度程序就会选择进程0

进程1

init进程,原本是内核线程,完成内核初始化后变成普通进程,但用超级用户权限运行

  • 作用:创建和监控在操作系统外层执行的所有进程的活动(养父发现有子进程终止就调用wait)
posted @ 2021-02-05 17:47  肥斯大只仔  阅读(100)  评论(0编辑  收藏  举报