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 内核设计与实现》