进程就是处于执行期的程序(目标码存放在某种介质上)。但进程并不仅仅局限于一段可执行程序代码,通常还包含其他资源,像打开的文件、挂起的信号、内核内部数据、处理器的状态等、一个或多个具有内存映射的内存地址空间、一个或多个执行的线程以及用来存放全局变量的数据段。
线程是进程中活动的对象。每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。内核调度的对象是线程,而不是进程(传统的UNIX系统中,每个进程只包含一个线程)。
对linux而言,线程只是一种特殊的进程,实现上并没有特别的区分。
内核把进程的列表存放在任务队列中(一个双向循环链表)。链表中每一个项都是类型为task_struct,称为进程描述符的结构,其中包含了一个具体进程的所有信息。该结构体定义在
进程描述符中包含的数据能够完整的描述一个正在执行的程序:它打开的文件描述符、进程地址空间、挂起的信号、进程状态等等。
各个进程的task_struct存放在它们内核栈的尾端(内核栈一般固定为8k/4k),一般通过slab分配器分配。
内核通过一个唯一的进程标识值(PID)来标识每个进程。PID是一个整型数据。
在内核中,访问任务通常需要获取指向其task_struct的指针。因此,通过current宏查找当前正在运行的进程尤为重要(有的体系结构会使用一个专门的寄存器来保存当前进程的指针)。
一般程序只会在用户空间执行,当一个程序执行了系统调用或是触发了某种异常,它就陷入了内核空间。此时,我们称内核代表进程执行,并且处于进程上下文中。在这期间,若没有更高优先级的进程抢占,在内核退出的时候,程序恢复在用户空间继续执行。
Unix/linux系统中进程之间存在一个明显的进程关系。所有的进程都是PID为1的init进程的后代。内核在系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本并执行其他的相关程序,最终完成系统启动的整个过程。
系统中每个进程必有一个父进程。进程间的关系存放在进程描述符,每个struct_task都拥有一个指向其父进程task_struct的指针parent,还拥有一个称为children的子进程的链表。
Unix采用了与众不同的方式创建进程,进程的创建可以分解为两个步骤:fork()和exec(),fork会拷贝当前进程创建一个子进程(父子进程不同之处在于pid、ppid、以及一些系统资源的统计量),exec会读取可执行文件并将其载入地址空间运行。
linux的fork使用写时拷贝页实现,这种方式可以延时拷贝甚至免除拷贝。
linux中实现线程的方式非常独特,从内核的角度来说,没有线程这个概念,linux把所有的线程都当作进程来实现。例如,linux创建四个线程,仅仅是通过创建四个进程并分配四个普通的task_struct,然后指定这个四个进程共享某些资源。(线程只是linux进程共享资源的一种手段罢了)
内核经常需要在后台执行一些操作,这些操作由内核线程完成。内核线程和普通进程的区别在于,内核线程没有独立的地址空间(task_struct中执行地址空间的字段mm被设置为NULL)。他们只在内核运行,不会切换到用户空间去。
重点记住进程终结时所需要的清理工作和进程描述符的删除是被分开执行的,这样做的目的是为了让系统有办法在子进程终结后还能获取到它的信息。因此,只有在父进程获取到子进程的终结信息后,子进程的task_struct才会释放。
如果出现父进程在子进程的前面退出了,内核会为子进程重新找一个继父(进程组的某个进程或者init进程)。
调度程序没有复杂的有原理,最大限度地利用处理器时间原则是,只要有可以执行的进程,那么就总会有进程正在执行。但是只有系统中可运行的进程数量大于处理器的数量,就注定某个时刻,有些进程无法执行。在一组处于可运行状态的进程中选择一个来执行,是调度程序所需要完成的基本工作。
多任务操作系统是能同时并发地交互执行多个进程的操作系统。无论是在单处理器还是多处理器,多任务系统都能使多个进程处理阻塞或者睡眠状态,直到工作就绪了才会实际执行。也就是说,现代linux系统也许有100个进程在内存中,但是只有一个进程处理可运行状态。
多任务操作系统可以分为抢占式和非抢占式两类。
linux提供了抢占式的多任务模式。在此模式下,由调度程序来决定进程什么时候停止,以便其他进程能够得到运行的机会。这个强制挂起(结束)进程的操作就叫做抢占。进程在被抢占前能够运行的时间是已经固定好了的,并且这个时间有一个专门的名称,叫做进程的时间片。
策略决定了调度程序在何时让什么进程运行。调度器的策略往往决定了系统的整体印象,并且,还要负责优化使用处理器的时间。无论从哪方面来看都是至关重要的。
进程可以被划分为以下两种:
进程也可能同时存在以上两种行为,调度策略通常要在两个矛盾的目标中寻找平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量)。
调度算法最基本的一类就是基于优先级的调度。这是一种根据进程的价值和对处理器时间的需求来对进程分级的思想。通常的做法是,高优先级的先运行,低的后运行,相同优先级的按照轮转进行调度(一个接一个,重复进行)。调度程序总是选择时间片未用尽且优先级高的进程运行。
linux采用两种不同的优先级范围。
第一种是用nice值,它的范围是-20到+19,默认值为0,越大的nice值意味着优先级越低。
第二种是实时优先级,其值是可以配的。默认情况下它的范围值为0-99,与nice值相反,实时优先级的数值越高意味着进程的优先级越高。任何实时进程的优先级都高于普通进程,也就是说实时优先级和nice优先级处于互不相交的两个范畴(实时进程和普通进程)。
时间片是一个数值,表明了进程被抢占前所能持续运行的时间。调度程序规定一个默认大小的时间片是一件很不容易的事情,太长会影响与系统交互体验;太短进程切换开销会增大。IO消耗型进程不需要太多时间片,处理器消耗型进程又希望时间片尽可能长一点。
linux的CFS调度器(完全公平调度器)没有直接划分时间片到进程,它是将处理器的使用比(可以理解为动态的时间片)划分给进程。这样一来,进程的使用处理器的时间实际上是和系统的负载密切相关的。
linux的调度器是以模块方式提供的,这样做的目的是为了让不同类型的进程可以根据需要选择调度算法。
现代进程调度器有两个通用的概念:进程优先级和时间片。进程一旦启动就会有一个默认的时间片,更高优先级的进程会运行的更加频繁,并且在多数系统上,其拥有更多的时间片。
linux nice值讨论(理解)
在linux系统上,优先级通过nice值输出到用户空间。在实现上可能会遇到一些问题,具体有如下讨论(CFS中有解决方法):
将nice值映射到时间片
若要将nice值映射到时间片,就需要将nice值得单位值对应到处理器的绝对时间。这样做将导致进程切换无法最优进行,例如:
有A、B两个进程,A的nice值为0,对应一个100ms的时间片;B的nice值是20,对应一个5ms的时间片;假设这两个进程处于可运行状态,则普通进程获取20/21(105ms中的100ms),低优先级的获取1/21(105ms中的5ms)。则对于两个相同优先级的进程来说,低优先级的进程10ms切换两次进程,高优先级的进程则是100ms切换一次。
使用相对nice值
有A、B两个进程,nice值分别为0和1,他们将分别映射时间片100ms和95ms(O(1)调度算法),他们的时间片差距几乎不大。但是如果分别赋予进程nice值18ms和19ms,那么他们会映射时间片10和5,前者是后者2倍的时间。
nice值与时间片
如果执行nice值到时间片的映射,我们需要能分配一个绝对的时间片。在多数操作系统上,要求这个时间片必须是定时节拍的整数倍。这在一定程度上使得时间片会受定时器数值的限制。
是一个针对普通进程的调度类,在linux中称为SCHED_NORMAL。
CFS的出发点基于一个简单的理念:进程调度器的效果应如同系统具备一个理想中的完美多任务处理器。在这种系统中,调度器将处理器时间按权重比例的方式划分给每个进程。公式为:
当前进程权重/(所有进程权重之和) * 目标延时值(一个进程循环切换周期时间)
注意:调度进程抢占会带来一定的代价,将一个进程换入,另一个进程换出本身是有消耗的,并且还会影响到缓存的效率。
时间记账
所有调度器都必须对进程的运行时间做记账,分配一个时间片给进程,每过一个时钟周期,时间片就减少一个时钟周期,只到时间片减为0即切换进程。
进程选择
进程的运行时间记录在调度器结构体的虚拟运行时间字段中,这个字段记录了一个进程到底运行了多久,以及它还应该运行多久。CFS试图用一个简单的规则去选择下一个需要运行的进程:虚拟运行时间最小的任务。
进程调度器的入口
进程调度的主要入口点是函数schedule(),它正是内核其他地方调用调度器的入口:选择那个进程投入运行,何时将其投入运行。该函数的实现非常简单,它会找到一个最高优先级的调度类,并询问后者谁才是下一个可运行的进程。
睡眠和唤醒
休眠(被阻塞)的进程处于一种特殊的不可执行状态。无论哪种形式的休眠,内核的操作都是相同的:进程把自己标记为休眠状态,从可执行红黑树上移除,并放入等待队列中,调用schedule函数切换到其他进程。唤醒的过程则刚好相反。
注意:如果被唤醒的进程比当前的正在执行的进程优先级高,需要设置need_resched标志(需要重新调度,当该标志被设置时,表明有其他进程需要被运行,内核应当尽快调用调度程序schedule)。
等待队列
休眠通过等待队列进行处理,等待队列是由等待某些事件进程组成的一个简单链表。
linux提供了两种实时策略:SCHED_FIFO和SCHED_RR。与之相对的是非实时调度策略SCHED_NORMAL。
SCHED_FIFO实现一个简单的先入先出调度算法,它不使用时间片。处于可运行状态的SCHED_FIFO级进程比任何SCHED_NORMAL级进程都先得到调度。一旦一个SCHED_FIFO级进程开始执行,他就会一直执行,直到自己显式的阻塞或主动释放处理器为止(注意:更高级的FIFO进程也是可以抢占的)。
SCHED_RR与FIFO大致相同,只是它是基于时间片的,只有耗尽事先分配给它的时间片后才会停止执行。
实时优先级的范围一般为(0-99)。
上下文切换,也就是从一个可执行程序切换到另一个可执行程序。由函数context_switch负责处理。schedule函数中就会调用该函数。该函数完成两件基本工作:
(1)负责把虚拟内存从上一个进程映射切换到新进程中。
(2)负责将处理器状态设置为新进程的状态,包括保存、恢复栈信息和寄存器信息,还有其他与体系结构相关的状态信息,都必须以进程为对象进行管理和保存。
用户抢占
内核即将返回用户空间的时候,如果need_resched标志被设置,会导致schedule函数被调用,此时会发生用户抢占。
注:系统调用返回、中断处理程序返回都可能发生用户抢占。
内核抢占
linux完整的支持内核抢占,但是必须保证在没有持有锁的情况下才能进行安全的内核抢占。
如果内核进程被阻塞了,或它显式调用了schedule(),内核抢占也会显式发生。这时候内核(或者编码内核的程序员)应该清楚的知道自己是可以被抢占的。