0%

Linux进程管理(一)基本概念

原图

进程的概念

进程是处于执行期的程序以及相关的资源的总称。进程并不仅仅局限于一段可执行程序代码,通常进程还要包含其他资源,像数据段、打开的文件、挂起的信号、处理器状态、一个或多个具有内存映射的内存地址空间及一个或多个执行线程(thread of execution)等。

线程(thread),是在进程中活动的对象。每个线程都拥有一个独立的程序计数器、线程栈和一组进程寄存器,内核调度的对象是线程,而不是进程。

在现代操作系统中,进程提供两种虚拟机制:虚拟处理器和虚拟内存。

  • 虚拟处理器给进程一种假象,让这些进程觉得自己在独享处理器
  • 虚拟内存让进程在分配和管理内存时觉得自己拥有整个系统的所有内存资源

进程描述符

进程是操作系统中调度的实体,需要对进程所必须拥有的资源做抽象描述,这种对象描述称为进程控制块(Process Control Block,PCB)。进程控制块需要描述如下几类信息:

  • 进程的运行状态:包括就绪、运行、阻塞、僵尸等状态
  • 程序计数器:记录当前进程运行到哪条指令了
  • CPU寄存器:主要保存当前运行的上下文,记录CPU所有必须保存下来的寄存器信息,以便当前进程调度出去之后还能调度回来并接着运行
  • CPU调度信息:包括进程优先级、调度队列等相关信息
  • 内存管理信息:进程使用的内存信息,比如进程的页表等
  • 统计信息:包含进程运行时间等相关的统计信息
  • 文件相关信息:包括进程打开的文件等

在Linux内核里面采用名为task_struct的数据结构来描述,Linux内核利用链表task_list来存放所有进程描述符,task_struct数据结构定义在include/inux/sched.h文件中。

task_struct数据结构很大,里面包含的内容很多,可以简单归纳成如下几类:

  • 进程属性的相关信息(包括进程状态、进程的PID等信息)
  • 进程间的关系(父子、兄弟、进程组等)
  • 进程调度的相关信息(优先级、调度类、policy进程类型等)
  • 内存管理的相关信息(mm)
  • 文件管理的相关信息(fs)
  • 信号的相关信息
  • 资源限制的相关信息

进程的生命周期

典型的操作系统中的进程如下图所示,其中包含如下状态:

  • TASK_RUNNING(可运行态或就绪态):它是指进程处于可执行状态,或许正在执行,或许在就绪队列中等待执行
  • TASK_INTERRUPTIBLE(可中断睡眠态):进程进入睡眠状态(被阻塞)以等待,一旦条件达成或者资源就位,可以把进程的状态设置成可运行态(TASK_RUNNING)并加入就绪队列。也有人将这种状态称为浅睡眠状态
  • TASK_UNINTERRUPTIBLE(不可中断态):进程在睡眠等待时不受干扰,对信号不做任何反应,所以这种状态又称为不可中断态。通常,使用ps命令看到的被标记为D状态的进程就是处于不可中断态的进程,不可以发送SIGKILL信号终止它们,因为它们不响应信号。也有人把这种状态称为深度睡眠状态
  • TASK_STOPPED(终止态):进程停止运行了
  • EXIT_ZOMBIE (僵尸态),进程已经消亡,但是task_struct数据结构还没有释放这种状态叫作僵尸状态,每个进程在自己的生命周期中都要经历这种状态。子进程退出时,父进程可以通过wait()或waitpid()来获取子进程消亡的原因

进程标识

在创建时会为进程分配唯一的号码来标识,这个号码就是进程标识符(Process Identife PID)。

除了PID之外,Linux内核还引入了线程组的概念。一个线程组中的所有线程都使用和该线程组中主线程相同的PID,即该线程组中第一个线程的PID,它会被存入task_stuct数据结构的tgid成员中。这与POSIX 1003.1c标准里的规定有关,一个多线程应用程序中的所有线程都必须有相同的PID,这样就可以通过PID把信号发送给线程组里所有的线程。

1
2
getpid() // 会返回当前进程的TGID
gettid() // 会返回线程的PID

进程间的家族关系

Linux内核维护了进程之间的家族关系,比如:

  • Linux内核在启动时会有一个init_task进程,它是系统中所有进程的“鼻祖”,称为进程0或idle进程。当系统没有进程需要调度时,调度器就会执行idle进程。idle进程在内核启动(start_kernel()函数)时静态创建,所有的核心数据结构都预先静态赋值
  • 系统初始化快完成时会创建一个init进程,也就是常说的进程1,它是所有用户进程的祖先,从这个进程开始,所有的进程都将参与调度
  • 如果进程A创建了进程B,那么进程A称为父进程,进程B称为子进程。如果进程B创建了进程C,那么进程A和进程C之间的关系就是祖孙关系
  • 如果进程A创建了Bi(1 < i< n)个进程,那么这些进程之间为兄弟关系

Linux进程调度

进程的分类

进程可以分成两类:一类是CPU消耗型(CPU-Bound),另外一类是I/O消耗型(I/O-Bound)。

  • CPU消耗型的进程会把大部分时间用在执行代码上,也就是一直占用CPU
  • I/O消耗型的进程大部分时间在提交I/O请求或者等待I/O请求,比如需要键盘输入的进程或者等待网络I/O的进程

进程的优先级和权重

操作系统中最经典的进程调度算法是基于优先级调度。调度器总是从就绪队列中选择优先级高的进程进行调度,而且优先级高的进程分配的时间片也会比优先级低的进程多。

Linux系统最早采用nice值来调整进程的优先级。范围是-20~+19,默认值是0。

内核使用0 ~ 139的数值表示进程的优先级(normal_prio),数值越低优先级越高。优先级在Linux中的划分方式如下。

  • 普通进程的优先级:100~139(120 + nice)
  • 实时进程的优先级:0~99 (99 - rt_prio)
  • deadline进程的优先级:-1

task struct数据结构中有4个成员,用来描述进程的优先级。

1
2
3
4
5
6
struct task_struct {
int prio;
int static_prio;
int normal_prio;
unsignea int rt_priority;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define MAX_RT_PRIO		100

static inline int __normal_prio(struct task_struct *p)
{
return p->static_prio;
}

static inline int normal_prio(struct task_struct *p)
{
int prio;

if (task_has_dl_policy(p))
prio = MAX_DL_PRIO-1;
else if (task_has_rt_policy(p))
prio = MAX_RT_PRIO-1 - p->rt_priority;
else
prio = __normal_prio(p);
return prio;
}
  • static_prio是静态优先级,在进程启动时分配[120+nice]
  • normal_prio是基于static_prio和调度策略计算出来的优先级,在创建进程时会继承父进程的normal_prio。对于普通进程来说,normal_prio等同于static_prio;对于实时进程,会根据rt_priority重新计算normal_prio[99 - rt_priority]
  • prio保存着进程的动态优先级,也是调度类考虑使用的优先级。有些情况下需要暂时提高进程的优先级,例如实时互斥量等
  • rt_priority是实时进程的优先级

在Linux内核中,除了使用优先级来表示进程的轻重缓急之外,在实际的调度器中也使用权重的概念来表示进程的优先级,详细的权重含义将在CFS调度器一节中介绍,这里不做过多介绍了。

推荐阅读Linux调度器:进程优先级更详细。

调度策略

进程调度依赖于调度策略(schedule policy),Linux内核把相同的调度策略抽象成了调度类(schedule class)。不同类型的进程采用不同的调度策略,目前Linux内核中默认实现了5个调度类,分别是stop、 deadline、realtime、CFS和idle,它们分别使用sched_class来定义,并且通过next指针串联在一起,如图所示。

  • stop
    • 最高优先级,比deadline进程的优先级高
    • 可以抢占任何进程
    • 在每个CPU上实现一个名为“migration/N”的内核线程,N表示CPU的编号。该内核线程的优先级最高,可以抢占任何进程的执行,一般用来执行特殊的功能
    • 用于负载均衡机制中的进程迁移、softlockup检测、CPU热插拔、RCU等
  • deadline(SCHED_DEADLINE )
    • 最高优先级的实时进程,优先级为-1
    • 用于调度有严格时间要求的实时进程,如视频编解码等
  • realtime(SCHED_FIFO、SCHED_RR)
    • 普通实时进程,优先级为0~99
    • 用于普通的实时进程,比如IRQ线程化
  • CFS(SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE)
    • 普通进程,优先级为100~139,由CFS来调度
  • idle
    • 最低优先级的进程
    • 当就绪队列中没有其他进程时进入idle调度类,idle调度类会让CPU进入低功耗模式

进程调度的入口是schedule()函数,他会调用pick_next_task()函数,pick_next_task()会以优先级为序,从高到低,依次检查每一个调度类,并从最高优先级的调度类中选择最高优先级的进程运行。

用户空间程序可以使用调度策略API函数,比如sched_setscheduler()来设定用户进程的调度策略。其中,SCHED_NORMAL、SCHED_BATCH以及SCHED_IDLE使用完全公平调度器(CFS),SCHED_FIFO和SCHED_RR使用realtime调度器,SCHED_DEADLINE专用deadline 调度器。

1
2
3
4
5
6
7
#define SCHED_NORMAL		0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
/* SCHED_ISO: reserved but not implemented yet */
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
  • SCHED_RR(循环)调度策略表示优先级相同的进程以循环分享时间的方式执行。进程每次使用CPU的时间为一个固定长度的时间片。进程会保持占有CPU,直到下面的某个条件得到满足

    • 时间片用完
    • 自愿放弃CPU
    • 进程终止
    • 被高优先级进程抢占
  • SCHED_FIFO(先进先出)调度策略与SCHED_RR调度策略类似,只不过没有时间片的概念。一旦进程获取了CPU控制权,就会一直执行下去,直到下面的某个条件得到满足

    • 自愿放弃CPU
    • 进程终止
    • 被更高优先级进程抢占
  • SCHED_BATCH(批处理)调度策略是普通进程调度策略。这种调度策略会让调度器认为进程是CPU密集型的。因此,调度器对这类进程的唤醒惩罚(wakeup penalty)比较小。在Linux内核里,此类调度策略使用完全公平调度器

  • SCHED_NORMAL(以前称为SCHED_OTHER)分时调度策略是非实时进程的默度策略。所有普通类型的进程的静态优先级都为0,因此,任何使用SCHED_FIFO、SCHED_RR调度策略的就绪进程都会抢占它们。Linux内核没有实现这类调度策略。

  • SCHED_IDLE 空闲调度策略用于运行低优先级的任务

其中实时进程一旦执行就会一直执行下去,需要进程自己主动放弃CPU使用权,只要有实时进程在运行其他普通进程就无法得到执行,因此实时进程的设计需要格外小心,需要主动放弃CPU使用权。

时间片

时间片(time slice)表示进程被调度进与被调度出去之间所能持续运行的时间长度。通常操作系统都会规定默认的时间片,但是很难确定多长的时间片是合适的。I/O消耗型的进程不需要很长的时间片,而CPU消耗型的进程则希望时间片越长越好。

早期的Linux中的调度器采用的是固定时间片,但是现在的CFS已经抛弃固定时间片的做法,而是采用进程权重占比的方法来公平地划分CPU时间,这样进程获得的CPU时间就与进程的权重以及CPU上的总权重有了关系。权重和优先级相关,优先级高的进程权重也高,有机会占用更多的CPU时间;而优先级低的进程权重也低,那么理应占用较少的CPU时间。

抢占

  • 抢占内核:更高优先级的进程会打断当前进程的执行,得到优先执行
  • 不可抢占内核:更高优先级的进程需要等到当前进程主动放弃CPU使用权才可以得到CPU使用权

用户抢占时机:

  • 从系统调用返回用户空间时
  • 从中断处理程序返回用户空间时

内核抢占时机:

  • 中断处理程序正在执行,返回内核空间之前
  • 内核代码再一次具有可抢占性的时候
  • 内核显式调用schedule()
  • 内核中的任务sleep的时候会调用schedule()

上下文切换

  • CPU上下文切换
    CPU上下文包括CPU 寄存器和程序计数器,CPU上下文切换就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。系统调用时会发生CPU上下文切换,但是没有切换进程,始终在同一个进程里运行。

  • 进程上下文切换
    进程上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的状态。进程的上下文切换就比系统调用时多了一步:在保存内核态资源(当前进程的内核状态和 CPU 寄存器)之前,需要先把该进程的用户态资源(虚拟内存、栈等)保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈。

  • 中断上下文切换
    中断上下文包括 CPU 寄存器、内核堆栈、硬件中断参数等。中断上下文切换并不涉及到进程的用户态。所以,即便中断过程打断了一个正处在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。只保存CPU 寄存器、内核堆栈、硬件中断参数等中断服务程序执行所必需的状态。

参考文献

http://www.wowotech.net/process_management/19.html
《奔跑吧Linux内核》
《Linux内核设计与实现》