linux进程管理
一、进程数据结构和组织
二、进程切换
三、进程创建
四、进程调度
进程是一个程序运行的实例,操作系统通过并行和并发的运行多个进程实现多个任务的并行处理;从系统资源的角度看,多个进程同时运行时,操作系统以进程为单位来分配系统资源(比如CPU时间、内存等);
进程作为系统资源分配的实体,而调度的基本单位是线程;在linux中,对于有多个用户态线程的进程,linux中用轻量级进程来实现这些用户态线程,然后将轻量级进程和内核态线程对应起来,这样轻量级进程之间就可以共享进程的内存地址空间、打开文件集等资源,并且可以被独立调度;
一、linux进程数据结构和组织
Linux通过进程描述符task_struct来管理进程,描述符里面包括了系统给进程分配的资源情况(比如内存描述符、打开的文件等);每个进程都有一个唯一的进程标识符PID,存放在进程描述符的pid字段中,同一个进程的所有线程有相同的PID;
对于每个进程,内核都要在内核地址空间给进程分配两个连续页框(可以配置成非连续页框),这个连续页框用于存放两个数据结构,一个是线程描述符thread_info(其中thread_info结构体的成员包括进程描述符指针),另一个是内核态的进程堆栈,这样进程切换到内核态后,就可以通过esp寄存器找到进程描述符;
进程的状态:进程描述符的state字段描述了进程当前所处的状态;进程状态有:可运行状态、可中断的等待状态、不可中断的等待状态、暂停状态、跟踪状态、僵死状态、僵死撤销状态;
进程描述符组织:linux把所有进程的进程描述符放在一个进程链表里面,进程链表的头是init_task;对于可运行状态的进程,linux用单独的链表来组织,为了提高调度程序的运行速度,建立多个可运行进程链表,每个进程优先权对应一个进程链表,并且每个cpu都有这样一组可运行进程链表;
等待队列:对于等待特殊事件而暂时休眠的可运行进程,linux引入了等待队列机制来组织这些进程,等待队列实现了在事件上的条件等待,希望等待特定事件的进程把自己放进合适的等待队列,并放弃CPU控制权,然后,事件完成后,由另外一个流程唤醒,大致流程如下:
wait_queue_t wait; --- 定义一个等待队列成员
init_waitqueue_entry(&wait, current); --- 初始化等待队列成员
current->state = TASK_UNINTERRUPTIBLE; --- 进程状态设置为不可中断唤醒
add_wait_queue(wq, &wait); --- 把进程加入等待队列wq
schedule(); --- 调度进程,放弃CPU控制权
remove_wait_queue(wq, &wait); --- 进程重新运行,从等待队列移出
二、进程切换
每个进程都是自己独立的地址空间,运行时独占CPU的资源(比如CPU的寄存器),并且CPU的寻址都在这个进程的地址空间中进行;因此,进程切换时主要得完成两个步骤:第一、将页全局目录切换到新进程的页全局目录,这样CPU就可以在新进程的地址空间寻址;第二、把CPU的硬件上下文保存起来,这样下次才可以在切换的点继续运行下去,这个硬件上下文主要是CPU的寄存器,其中大部分寄存器保存在进程描述符一个类型为thread_struct的thread字段,部分诸如eax、ebx等通用寄存器保存在内核堆栈中;
需要指出的是,多线程的应用进程有多个轻量级进程,如果进程切换在同一个进程的轻量级进程之间进行,那么因为这些轻量级进程共享进程的地址空间,因此切换时不需要切换也全局目录,只需要切换硬件上下文即可;
三、 进程创建
Linux进程的创建策略是,基于已有进程创建,通过复制已有进程的数据结构创建新进程,期间通过clone标志控制如何复制这些数据结构,进程创建完成后,可以被调度运行,不过运行起来的代码和父进程是一样的,需要调用excve等函数加载子进程的可执行文件,如果创建的是轻量级进程,那么就是线程,指定线程的回调函数即可,不用excve加载可执行文件;
因此程序创建的进程具有父/子关系,子进程之间具有兄弟关系;进程0和进程1是由内核创建,进程1(init)是所有进程的祖先;
创建子进程的函数是clone()、fork()及vfork();这些函数会建立进程的堆栈、进程描述符以及进程需要的其他数据结构,函数返回后,创建的进程已经可以被调度运行,关键的几个步骤如下:
- 查找pidmap_array位图给子进程分配新的PID;
- 调用__unlazy_fpu(),把FPU、MMX和SSE/SSE2寄存器的内容保存到父进程的thread_info结构中;
- 执行alloc_task_struct()宏,为新进程获取进程描述符,然后把current进程描述符的内容复制到刚获取的进程描述符中;
- 执行alloc_thread_info宏获取一块空闲内存区,用来存放新进程的thread_info结构和内核栈,然后把current进程的thread_info描述符的内容复制到刚分配的thread_info结构中;
- 检测进程数量是否超过限制;
- 初始化子进程描述符中的list_head数据结构和自旋锁,并为与挂起信号、定时器及时间统计表相关的几个字段赋值;
- 调用copy_semundo()、copy_files()、copy_fs()、copy_sighand()、copy_signal()、copy_mm()和copy_namespace()创建新的数据结构,并把父进程相应数据结构的值复制到新数据结构中;
- 调用copy_thread(),用clone()系统调用时CPU寄存器的值来初始化子进程的内核栈,子进程描述符的thread.esp字段初始化为子进程内核栈的基地址;
- 调用sched_fork()完成对新进程调度程序数据结构的初始化,该函数把新进程的状态设置为TASK_RUNNING,并把thread_info结构的preempt_count字段设置为1,从而禁止内核抢占,为了保证公平的进程调度,该函数在父子进程之间共享父进程的时间片;
- 执行SET_LINKS宏,把新进程描述符插入进程链表;
- 新进程已经被加入进程集合,递增nr_threads变量的值,递增total_forks变量以记录被创建的进程的数量,至此,子进程已经处于可运行状态,但是限制子进程还在运行队列中,需要调度程序调度才能运行,当调度程序调度到子进程时,把子进程thread中的值加载到对应的CPU寄存器,特别是把thread.esp装入esp寄存器,把函数ret_from_fork()的地址装入eip寄存器,用存放在栈中的值再装载所有的寄存器,并强迫CPU返回到用户态,然后在fork()、vfork()或clone()函数返回时,新进程开始运行,函数的返回值放在eax寄存器中,返回给子进程的值是0,返回给父进程的值是子进程的PID,应用程序的编写可以基于这个事实,在fork()、vfork()或clone()函数返回的地方加个if/else条件判断子进程和父进程的不同流程;
四、进程调度
Linux进程的调度策略是进程响应时间尽可能快,后台作业的吞吐量尽可能高,尽可能避免进程的饥饿现象,低优先级和高优先级进程尽可能调和;
Linux的调度基于分时技术,给每个进程分配时间片,时间片到期时,进程切换动作就会发生;每个进程都有一个调度优先级,对于可抢占的linux(也可以配置成不可抢占的系统),当高优先级的进程进入可运行状态时,就可以抢占低优先级的进程,比如一个文本编辑进程,当用户敲键盘后,触发中断,内核唤醒文本编辑进程,并且确定文本编辑进程的优先级比current进程高,那么就会把文本编辑进程的TIF_NEED_RESCHED标志设置,这样内核处理完中断后就会激活调度程序;
Linux对不同的进程采用不同的调度策略,目前有5种类型的进程,停机调度类进程、期限调度类进程、实时调度类进程、公平调度类进程、空闲调度类进程,这5类进程优先级从高到低;
停机调度类:优先级最高,可以抢占其他所有进程,但是不能抢占停机进程,目前只有迁移进程属于停机调度类,每个处理器有一个迁移进程,迁移进程的任务是把进程从当前处理器迁移到其他处理器,对外伪装成实时优先级是99的先进先出实时进程;停机进程没有时间片,如果不主动让出处理器,那么就一直霸占处理器;
期限调度类:优先级是-1,比实时调度类优先级高,但是比停机调度类优先级低;如下图所示,每个周期运行一次,在截至期限之前执行完;
实时调度类:用于实时进程的调度,实时进程的优先级是1~99;有两种调度方式,一种是先进先出调度策略,这种调度策略,每次进程运行完时间片后,都是放到对应优先级队列的队首,那么如果没有高优先级的进程,处理器会继续选择这个进程运行,直到这个进程主动放弃cpu,那么同优先级的进程就没有机会运行;另一种策略是轮流调度,每次时间片运行完后,把进程放到队列尾部,这样同优先级的进程就有机会运行;
公平调度类:用于普通进程的调度,普通进程的优先级是100~139;使用完全公平调度算法,这种算法引入了虚拟运行时间的概念:虚拟运行时间 = 实际运行时间 X nice 0对应的权重 / 进程的权重;优先级越高,进程的权重就越大,那么实际运行时间相同的情况下,虚拟运行时间就越小;完全公平调度算法使用红黑树把进程按虚拟运行时间从小到大排序,每次调度时选择虚拟运行时间最小的进程;
空闲调度类:针对空闲线程,每个处理器有一个空闲线程,仅当没有其他进程可以调度的时候,才会调度空闲线程;
参考资料:
《深入理解linux内核》
《linux内核深度解析》