Linux 进程管理与调度剖析

来源:互联网 发布:逻辑 知乎 编辑:程序博客网 时间:2024/05/22 00:48

主要转载于两篇文章:http://www.ibm.com/developerworks/cn/linux/l-linux-process-management/http://www.linuxidc.com/Linux/2014-08/105366p3.htm
Linux 的用户空间进程的创建和管理所涉及的原理与 UNIX 有很多共同点,但也有一些特定于 Linux 的独特之处。在本文中,了解 Linux 进程的生命周期,探索用户进程创建、内存管理、调度和销毁的内核内幕。

Linux 是一种动态系统,能够适应不断变化的计算需求。Linux 计算需求的表现是以进程 的通用抽象为中心的。进程可以是短期的(从命令行执行的一个命令),也可以是长期的(一种网络服务)。因此,对进程及其调度进行一般管理就显得极为重要。

在用户空间,进程是由进程标识符(PID)表示的。从用户的角度来看,一个 PID 是一个数字值,可惟一标识一个进程。一个 PID 在进程的整个生命期间不会更改,但 PID 可以在进程销毁后被重新使用,所以对它们进行缓存并不见得总是理想的。

在用户空间,创建进程可以采用几种方式。可以执行一个程序(这会导致新进程的创建),也可以在程序内,调用一个 fork 或 exec 系统调用。fork 调用会导致创建一个子进程,而 exec 调用则会用新程序代替当前进程上下文。接下来,我将对这几种方法进行讨论以便您能很好地理解它们的工作原理。

在本文中,我将按照下面的顺序展开对进程的介绍,首先展示进程的内核表示以及它们是如何在内核内被管理的,然后来看看进程创建和调度的各种方式(在一个或多个处理器上),最后介绍进程的销毁。

进程表示

清单 1. task_struct 的一小部分在 Linux 内核内,进程是由相当大的一个称为 task_struct 的结构表示的。此结构包含所有表示此进程所必需的数据,此外,还包含了大量的其他数据用来统计(accounting)和维护与其他进程的关系(父和子)。对 task_struct 的完整介绍超出了本文的范围,清单 1 给出了 task_struct 的一小部分。这些代码包含了本文所要探索的这些特定元素。task_struct 位于 ./linux/include/linux/sched.h。

struct task_struct {volatile long state;void *stack;unsigned int flags;int prio, static_prio;struct list_head tasks;struct mm_struct *mm, *active_mm;pid_t pid;pid_t tgid;struct task_struct *real_parent;char comm[TASK_COMM_LEN];struct thread_struct thread;struct files_struct *files;...};

在清单 1 中,可以看到几个预料之中的项,比如执行的状态、堆栈、一组标志、父进程、执行的线程(可以有很多)以及开放文件。我稍后会对其进行详细说明,这里只简单加以介绍。state 变量是一些表明任务状态的比特位。最常见的状态有:TASK_RUNNING 表示进程正在运行,或是排在运行队列中正要运行;TASK_INTERRUPTIBLE 表示进程正在休眠、TASK_UNINTERRUPTIBLE 表示进程正在休眠但不能叫醒;TASK_STOPPED 表示进程停止等等。这些标志的完整列表可以在 ./linux/include/linux/sched.h 内找到。

flags 定义了很多指示符,表明进程是否正在被创建(PF_STARTING)或退出(PF_EXITING),或是进程当前是否在分配内存(PF_MEMALLOC)。可执行程序的名称(不包含路径)占用 comm(命令)字段。

每个进程都会被赋予优先级(称为 static_prio),但进程的实际优先级是基于加载以及其他几个因素动态决定的。优先级值越低,实际的优先级越高。

tasks 字段提供了链接列表的能力。它包含一个 prev 指针(指向前一个任务)和一个 next 指针(指向下一个任务)。

进程的地址空间由 mm 和 active_mm 字段表示。mm 代表的是进程的内存描述符,而 active_mm 则是前一个进程的内存描述符(为改进上下文切换时间的一种优化)。

thread_struct 则用来标识进程的存储状态。此元素依赖于 Linux 在其上运行的特定架构,在 ./linux/include/asm-i386/processor.h 内有这样的一个例子。在此结构内,可以找到该进程自执行上下文切换后的存储(硬件注册表、程序计数器等)。

进程管理

最大进程数

在 Linux 内虽然进程都是动态分配的,但还是需要考虑最大进程数。在内核内最大进程数是由一个称为max_threads 的符号表示的,它可以在 ./linux/kernel/fork.c 内找到。可以通过 /proc/sys/kernel/threads-max 的 proc 文件系统从用户空间更改此值。

现在,让我们来看看如何在 Linux 内管理进程。在很多情况下,进程都是动态创建并由一个动态分配的 task_struct 表示。一个例外是 init 进程本身,它总是存在并由一个静态分配的 task_struct 表示。在 ./linux/arch/i386/kernel/init_task.c 内可以找到这样的一个例子。

Linux 内所有进程的分配有两种方式。第一种方式是通过一个哈希表,由 PID 值进行哈希计算得到;第二种方式是通过双链循环表。循环表非常适合于对任务列表进行迭代。由于列表是循环的,没有头或尾;但是由于 init_task 总是存在,所以可以将其用作继续向前迭代的一个锚点。让我们来看一个遍历当前任务集的例子。

任务列表无法从用户空间访问,但该问题很容易解决,方法是以模块形式向内核内插入代码。清单 2 中所示的是一个很简单的程序,它会迭代任务列表并会提供有关每个任务的少量信息(namepid 和 parent 名)。注意,在这里,此模块使用 printk 来发出结果。要查看具体的结果,可以通过 cat 实用工具(或实时的 tail -f /var/log/messages)查看 /var/log/messages 文件。next_task 函数是 sched.h 内的一个宏,它简化了任务列表的迭代(返回下一个任务的 task_struct 引用)。

清单 2. 发出任务信息的简单内核模块(procsview.c)
#include <linux/kernel.h>#include <linux/module.h>#include <linux/sched.h>int init_module( void ){  /* Set up the anchor point */  struct task_struct *task = &init_task;  /* Walk through the task list, until we hit the init_task again */  do {    printk( KERN_INFO "*** %s [%d] parent %s\n",task->comm, task->pid, task->parent->comm );  } while ( (task = next_task(task)) != &init_task );  return 0;}void cleanup_module( void ){  return;}

可以用清单 3 所示的 Makefile 编译此模块。在编译时,可以用 insmod procsview.ko 插入模块对象,也可以用 rmmod procsview 删除它。

清单 3. 用来构建内核模块的 Makefile
obj-m += procsview.oKDIR := /lib/modules/$(shell uname -r)/buildPWD := $(shell pwd)default:$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules

插入后,/var/log/messages 可显示输出,如下所示。从中可以看到,这里有一个空闲任务(称为 swapper)和 init 任务(pid 1)。

Nov 12 22:19:51 mtj-desktop kernel: [8503.873310] *** swapper [0] parent swapperNov 12 22:19:51 mtj-desktop kernel: [8503.904182] *** init [1] parent swapperNov 12 22:19:51 mtj-desktop kernel: [8503.904215] *** kthreadd [2] parent swapperNov 12 22:19:51 mtj-desktop kernel: [8503.904233] *** migration/0 [3] parent kthreadd...

注意,还可以标识当前正在运行的任务。Linux 维护一个称为 current 的符号,代表的是当前运行的进程(类型是 task_struct)。如果在init_module 的尾部插入如下这行代码:

printk( KERN_INFO, "Current task is %s [%d], current->comm, current->pid );

会看到:

Nov 12 22:48:45 mtj-desktop kernel: [10233.323662] Current task is insmod [6538]

注意到,当前的任务是 insmod,这是因为 init_module 函数是在 insmod 命令执行的上下文运行的。current 符号实际指的是一个函数(get_current)并可在一个与 arch 有关的头部中找到(比如 ./linux/include/asm-i386/current.h 内找到)。

进程创建

系统调用函数

您可能已经看到过系统调用的模式了。在很多情况下,系统调用都被命名为 sys_* 并提供某些初始功能以实现调用(例如错误检查或用户空间的行为)。实际的工作常常会委派给另外一个名为 do_* 的函数。

让我们不妨亲自看看如何从用户空间创建一个进程。用户空间任务和内核任务的底层机制是一致的,因为二者最终都会依赖于一个名为 do_fork 的函数来创建新进程。在创建内核线程时,内核会调用一个名为 kernel_thread 的函数(参见 ./linux/arch/i386/kernel/process.c),此函数执行某些初始化后会调用 do_fork

创建用户空间进程的情况与此类似。在用户空间,一个程序会调用 fork,这会导致对名为sys_fork 的内核函数的系统调用(参见 ./linux/arch/i386/kernel/process.c)。函数关系如图 1 所示。

图 1. 负责创建进程的函数的层次结构
负责创建进程的函数的层次结构

从图 1 中,可以看到 do_fork 是进程创建的基础。可以在 ./linux/kernel/fork.c 内找到 do_fork 函数(以及合作函数 copy_process)。

do_fork 函数首先调用 alloc_pidmap,该调用会分配一个新的 PID。接下来,do_fork 检查调试器是否在跟踪父进程。如果是,在clone_flags 内设置 CLONE_PTRACE 标志以做好执行 fork 操作的准备。之后 do_fork 函数还会调用 copy_process,向其传递这些标志、堆栈、注册表、父进程以及最新分配的 PID。

新的进程在 copy_process 函数内作为父进程的一个副本创建。此函数能执行除启动进程之外的所有操作,启动进程在之后进行处理。copy_process 内的第一步是验证 CLONE 标志以确保这些标志是一致的。如果不一致,就会返回 EINVAL 错误。接下来,询问 Linux Security Module (LSM) 看当前任务是否可以创建一个新任务。要了解有关 LSM 在 Security-Enhanced Linux (SELinux) 上下文中的更多信息,请参见 参考资料 小节。

接下来,调用 dup_task_struct 函数(在 ./linux/kernel/fork.c 内),这会分配一个新 task_struct 并将当前进程的描述符复制到其内。在新的线程堆栈设置好后,一些状态信息也会被初始化,并且会将控制返回给 copy_process。控制回到 copy_process 后,除了其他几个限制和安全检查之外,还会执行一些常规管理,包括在新 task_struct 上的各种初始化。之后,会调用一系列复制函数来复制此进程的各个方面,比如复制开放文件描述符(copy_files)、复制符号信息(copy_sighand 和 copy_signal)、复制进程内存(copy_mm)以及最终复制线程(copy_thread)。

之后,这个新任务会被指定给一个处理程序,同时对允许执行进程的处理程序进行额外的检查(cpus_allowed)。新进程的优先级从父进程的优先级继承后,执行一小部分额外的常规管理,而且控制也会被返回给 do_fork。在此时,新进程存在但尚未运行。do_fork 函数通过调用wake_up_new_task 来修复此问题。此函数(可在 ./linux/kernel/sched.c 内找到)初始化某些调度程序的常规管理信息,将新进程放置在运行队列之内,然后将其唤醒以便执行。最后,一旦返回至 do_fork,此 PID 值即被返回给调用程序,进程完成。

进程调度

什么是调度

现在的操作系统都是多任务的,为了能让更多的任务能同时在系统上更好的运行,需要一个管理程序来管理计算机上同时运行的各个任务(也就是进程)。

这个管理程序就是调度程序,它的功能说起来很简单:

1. 决定哪些进程运行,哪些进程等待

2. 决定每个进程运行多长时间

此外,为了获得更好的用户体验,运行中的进程还可以立即被其他更紧急的进程打断。总之,调度是一个平衡的过程。一方面,它要保证各个运行的进程能够最大限度的使用CPU(即尽量少的切换进程,进程切换过多,CPU的时间会浪费在切换上);另一方面,保证各个进程能公平的使用CPU(即防止一个进程长时间独占CPU的情况)。


策略

I/O消耗型和处理器消耗型的进程

I/O消耗型进程:大部分时间用来提交I/O请求或是等待I/O请求,经常处于可运行状态,但运行时间短,等待请求过程时处于阻塞状态。如交互式程序。

处理器消耗型进程:时间大都用在执行代码上,除非被抢占否则一直不停的运行。

调度策略要在:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量)之间寻找平衡。

Linux为了保证交互式应用,所以对进程的相应做了优化,更倾向于优先调度I/O消耗型进程。


进程优先级

调度算法中最基本的一类就是基于优先级的调度。这是一种根据进程的价值和其对处理器时间的需求来对进程分级的想法。优先级高的进程先运行,低的后运行,相同优先级的进程按轮转方式进行调度。

Linux根据以上思想实现了一种基于动态优先级的调度方法。一开始,该方法先设置基本的优先级,然而它允许调度程度根据需要来加、减优先级。例如,如果一个进程在I/O等待上耗费的时间多于其运行时间,那么该进程明显属于I/O消耗型,它的优先级会被动态提高。相反,处理器消耗型进程的优先级会被动态降低。

Linux内核提供两组独立的优先级范围。第一种是nice值,范围从-20到+19,默认值是0。nice值越大优先级越低。第二种是实时优先级,其值可配置,范围从0到99,任何实时进程的优先级都高于普通的进程。


时间片

时间片是一个数值,它表明进程在被抢占前所能持续运行的时间,I/O消耗型不需要长的时间片,而处理器消耗型的进程则希望越长越好。时间片的大小设置并不简单,设大了,系统响应变慢(调度周期长);设小了,进程频繁切换带来的处理器消耗。

Linux调度程序提高交互程序的优先级,让它们运行得更频繁。于是,调度程序提供了比较长的默认时间片给交互程序。此外,Linux调度程序还能根据进程的优先级动态调整分配给它的时间片。从而保证优先级高的进程,假定也是重要性高的进程,执行的频率高,执行时间长。通过实现这样一种动态调整优先级和时间片长度的机制,Linux调度性性能不但非常稳定而且也很强健。

注意,进程并不是一定非要一次就用完它所有的时间片,例如一个拥有100毫秒时间片的进程,可以通过重复调度,分5次每次20毫秒用完这些时间片。

当一个进程的时间耗尽时,就认为到期了。没有时间片的进程不会再投入运行,除非等到其他所有的进程都耗尽了他们的时间片。那个时候,所有进程的时间片会被重新计算。


进程抢占

Linux是抢占式的。当一个进程进入TASK_RUNNING状态,内核会检查它的优先级是否高于当前正在执行的进程。如果是这样,调度程序会被唤醒,抢占当前正在运行的进程并运行新的可运行进程。此外,当一个进程的时间片变为0时,它会被抢占,调度程序被唤醒以选择一个新的进程。

 

调度算法


可执行队列

调度程序中最基本的数据结构式运行队列(runqueue)。可执行队列是给定处理器上的可执行进程的链表,每个处理器一个。每个可投入运行的进程都唯一的归属于一个可执行队列。此外,可执行队列中还包含每个处理器的调度信息。所以,可执行队列也是每个处理器最重要的数据结构。

为了避免死锁,要锁住多个运行队列的代码必须总是按照同样的顺序获取这些锁:按照可执行队列地址从低向高的顺序。


优先级数组

每个运行队列都有两个优先级数组,一个活跃的和一个过期的。优先级数组是一种能够提供O(1)级算法复杂度的数据结构。优先级数组使可运行处理器的每一种优先级都包含一个相应的队列,而这些队列包含对应优先级上的可执行进程链表。优先级数组还拥有一个优先级位图,当需要查找当前系统内拥有最高优先级的可执行进程时,它可以帮助提高效率。



重新计算时间片

许多操作系统在所有进程的时间片都用完时,都采用一种显示的方法来计算时间片。典型的实现是循环访问每个进程,这样可能会耗费相当长的时间,最坏情况为O(N);重算时必须考锁的形式来保护任务队列和每个进程描述符,这样做会加剧对锁的争用;重新计算时间的实际不确定。

活跃数组内的可执行队列上的进程都还有时间片剩余,而过期数组内的都耗尽了时间片。当一个进程的时间片耗尽时,它会被移至过期数组,但在此之前,时间片已经给它重新计算好。重新计算时间片变得非常简单,只要在活跃和过期数组之间来回切换,这是O(1)级调度程序的核心。


schedule()

选定下一个进程并切换到它去执行是通过schedule()函数实现的。当内核代码想要休眠时,会直接调用该函数,另外,如果有哪个进程将被抢占,那么该函数也会被唤起执行。schedule()函数独立于每个处理器运行。

首先要在活动优先级数组中找到第一个被设置的位,该位对于这优先级最高的可执行进程。然后,调度程序选择这个级别链表里的有一个进程。这就是系统中优先级最高的可执行程序。如果被选中的进程不是当前进程,就进行上下文切换。



计算优先级和时间片

nice值之所以起名为静态优先级,是因为它从一开始由用户指定后,就不能改变。动态优先级通过一个关于静态优先级和进程交互性的函数关系计算而来。effective_prio()函数可以返回一个进程的动态优先级。这个函数以nice值为基数,再加上-5到+5之间的进程交互性的奖励或罚分。

怎么通过一些推断来获取准确反映进程到底是I/O消耗型的还是处理器消耗型的。最明显的标准莫过于进程休眠的时间长短了。如果一个进程的大部分时间都在休眠,那么它就是I/O消耗型的。如果一个进程执行的时间比休眠的时间长,那它就是处理器消耗型的。

另一方面,重新计算时间片相对简单了。它只要以静态优先级为基础就可以了。在一个进程创建的时候,新建的子进程和父进程均分父进程剩余的进程时间片。这样的分配很公平并且防止用户通过不断创建新进程来不停地获取时间片。task_timeslice()函数为给定任务返回一个新的时间片。时间片的计算只需要把优先级按比例缩放,使其符合时间片的数值范围要求就可以了。进程的静态优先级越高,它每次执行得到的时间片就越长。

调度程序还提供了另外一种机制以支持交互进程:如果一个进程的交互性非常强,那么当它时间片用完后,它会被放置到活动数组而不是过期数组中。


睡眠与唤醒

休眠(被阻塞)的进程处于一个特殊的不可执行状态。进程把它自己标记成休眠状态,把自己从可执行队列移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程刚好相反:进程被设置为可执行状态,然后再从等待队列中移到可执行队列。

休眠有两种相关的进程状态:TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表等待队列。等待队列可以通过DECLARE_WAITQUEUE()静态创建,也可以由init_waitqueue_head()动态创建。唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。


负载平衡

Linux的调度程序为堆成多处理系统的每个处理器准备了单独的可执行队列和锁。为了使各个可执行队列上的负载平衡,提供了负载平衡程序。如果它发现了不平衡,就会把相抵繁忙的队列中的进程抽到当前的可自行队列中来。

负载平衡程序有kernel/sched.c中的函数load_balance()来实现。它有两种调用方法。在schedule()执行的时候,只要当前的可执行队列为空,它就会被调用。此外,它还会被定时器调用:系统空闲时每隔1毫秒调用一次或者在其他情况下每隔200毫秒调用一次。负载平衡程序调用时需要锁住当前处理器的可执行队列并且屏蔽中断,以避免可执行队列被并发地访问。


抢占和上下文切换

上下文切换,也就是从一个可执行进程切换到另一个可执行进程。进程切换schedule函数调用context_switch()函数完成以下工作:

1.调用定义在<asm/mmu_context.h>中的switch_mm(),该函数负责把虚拟内存从上一个进程映射切换到新进程中。

2.调用定义在<asm/system.h>中的switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息。

前面看到schedule函数调用有很多种情况,完全依靠用户来调用不能达到很好的效果。内核需要判断什么时候调用schedule,内核提供了一个need_resched标志来表明是否需要重新执行一次调度:

1当某个进程耗尽它的时间片时,scheduler_tick()就会设置这个标志;

2当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志。

每个进程都包含一个need_resched标志,这是因为访问进程描述符内的数值要比访问一个全局变量快


用户抢占

内核即将返回用户空间时候,如果need_resched标志被设置,会导致schedule函数被调用,此时发生用户抢占。

用户抢占在以下情况时产生:

1.从系统调返回用户空间。

2.从中断处理程序返回用户空间。


内核抢占

只要重新调度是安全的,那么内核就可以在任何时间抢占正在执行的任务。

什么时候重新调度才是安全的呢?只要没有持有锁,内核就可以进行抢占。

锁是非抢占区域的标志。由于内核是支持SMP的,所以,如果没有持有锁,那么正在执行的代码就是可重新导入的,也就是可以抢占的。

内核抢占会发生在:

1.当从中断处理程序正在执行,且返回内核空间之前。

2.当内核代码再一次具有可抢占性的时候。

3.如果内核中的任务显式的调用schedule()。

4.如果内核中的任务阻塞(这同样也会导致调用schedule())。


进程销毁

进程销毁可以通过几个事件驱动 — 通过正常的进程结束、通过信号或是通过对 exit 函数的调用。不管进程如何退出,进程的结束都要借助对内核函数 do_exit(在 ./linux/kernel/exit.c 内)的调用。此过程如图 2 所示。

图 2. 实现进程销毁的函数的层次结构
实现进程销毁的函数的层次结构

do_exit 的目的是将所有对当前进程的引用从操作系统删除(针对所有没有共享的资源)。销毁的过程先要通过设置 PF_EXITING 标志来表明进程正在退出。内核的其他方面会利用它来避免在进程被删除时还试图处理此进程。将进程从它在其生命期间获得的各种资源分离开来是通过一系列调用实现的,比如 exit_mm(删除内存页)和 exit_keys(释放线程会话和进程安全键)。do_exit 函数执行释放进程所需的各种统计,这之后,通过调用 exit_notify 执行一系列通知(比如,告知父进程其子进程正在退出)。最后,进程状态被更改为 PF_DEAD,并且还会调用 schedule 函数来选择一个将要执行的新进程。请注意,如果对父进程的通知是必需的(或进程正在被跟踪),那么任务将不会彻底消失。如果无需任何通知,就可以调用 release_task 来实际收回由进程使用的那部分内存。

结束语

Linux 还在不断演进,其中一个有待进一步创新和优化的领域就是进程管理。在坚持 UNIX 原理的同时,Linux 也在不断突破。新的处理器架构、对称多处理(SMP)以及虚拟化都将促使在内核领域内取得新进展。其中的一个例子就是 Linux 版本 2.6 中引入的新的 O(1) 调度程序,它为具有大量任务的系统提供了可伸缩性。另外一个例子就是使用 Native POSIX Thread Library (NPTL) 更新了的线程模型,与之前的 LinuxThreads 模型相比,它带来了更为有效的线程处理。

0 0