Linux进程管理
进程管理在任何操作系统中都是最重要的任务之一。有效的进程管理能够让一个应用程序稳定有效地运作。Linux进程管理包括进程调度、中断处理、信号、进程优先级、进程切换、进程状态、进程内存等等。
什么是进程
进程的概念起源于20世纪60年代,但到底什么是进程目前尚无统一、确切的定义。一般认为程序是存储在磁盘上包含可执行机器指令和数据的静态实体,而进程是具有一定功能的关于一个数据集合的一次运行活动,是处于活动状态的计算机程序。 进程是在处理器上执行的一个实例,进程可使用任意资源以便Linux内核可以处理完成它的任务。
进程在其生存期内可能处于3种基本状态。
- 运行态:进程占有CPU,正在运行。
- 就绪态:进程本身具备运行条件,等待CPU。
- 等待态:等待除CPU之外的其他资源或条件,不能运行。
进程在这几种状态之间相互转化,但对于用户是透明的。进程是一个随执行过程不断变化的实体。和程序要包含指令和数据一样,进程也包含程序计数器和所有CPU寄存器的值,同时它的堆栈中存储着如子程序参数、返回地址,以及变量之类的临时数据。当前的执行程序,或者说进程,包含着当前处理器中的活动状态。在多处理操作系统中,进程具有独立的权限与职责。如果系统中某个进程崩溃,不会影响到其余的进程。每个进程运行在各自的虚拟地址空间中,通过一定的通信机制,它们之间才能发生联系。
Linux进程
为了让Linux来管理系统中的进程,每个进程用一个task_struct数据结构来表示(task即任务它与进程在Linux中可以混用)。其也被称为“进程描述符”。一个进程描述符包含了单个进程在运行期间所有必要的信息,比如进程标识、进程的属性、构建进程的资源等。如果知道进程的结构,就能了解进程的执行对性能的重要性。
数组task包含指向系统中所有task_struct结构的指针。 这意味着系统中的最大进程数目受task数组大小的限制,默认值一般为512。创建新进程时,Linux将从系统内存中分配一个task_struct结构并将其加入task数组。当前运行进程的结构用current指针来指示。
task_struct数据结构庞大而复杂,但它可以分成一些功能组成部分。
- 状态(State):除了上述三种进程的基本状态之外,Linux进程还有stopped和加zombie状态。
- 调度信息:系统根据这些信息判定哪个进程最迫切需要运行。
- 进程标志号(Identifiers):用来区分进程的标识。
- 进程间通信机制:Linux支持经典的UuixIPC机制,如信号、管道和信号灯,以及SystemV中机制,包括信号量、消息队列和共享内存。
进程的识别号(ID)
在Linux系统中,每一个进程都有唯一的进程识别号(Process ID),系统就根据这些进程识别号来管理进程。除此之外,每个进程还有一个真实用户识别号(Real user ID)、一个真实组识别号(Real group ID)、一个有效用户识别号(Effective user ID)和一个有效组识别号(Effective group ID)。一般情况下,真实用户ID与有效用户ID是相同的,都是运行该进程的用户ID;当设置了set_user_ID标记位时,真实用户ID仍为运行进程的用户ID,但有效用户ID变成当前运行文件的所有者的ID。组识别号也是这样,区别就是相应的标记位为set_group_ID。
这些听起来有些复杂,下面用一个简单的例子来说明这个问题。现在用户A运行程序X产生一进程P。用户A的用户识别号是10,组识别号是1,程序X的所有者用户识别号为100,组识别号是2,当前set_user_ID位为1,set_group_ID为0。那么进程P的真实用户识别号为10,有效用户识别号为100,真实组识别号与有效组识别号都为l。有效用户/组识别号常用来判断读/写文件的优先权限。
进程的生命周期
每个进程都有自己的生命周期,比如创建、执行、终止、删除等。只要系统启动和运行,这些阶段就会不断地被重复,因此,从性能的角度讲,进程的生命周期是非常重要的。
[父进程] wait()=> [父进程]
fork()=> [子进程] exec()=>[子进程] exit()=>[僵尸进程]
当一个进程创建一个新进程的时候,创建进程(父进程)发出一个fork()系统调用,然后父进程得到一个新创建的进程(子进程)的进程描述符,并设置一个新的进程ID。它复制父进程的进程描述符的值给子进程。此时父进程的整个地址空间是不能被复制的,两个进程共享相同的地址空间。
exec()系统调用将新的程序复制到子进程的地址空间。因为两个进程共享相同的地址空间,所以新程序写数据时会导致页错误。对此,内核会给子进程分配新的物理页。
这种延迟的操作被称为Copy On Write。通常子进程执行它自己的程序,而不是执行与父进程相同的操作。此操作可避免不必要的开销,因为复制整个地址空间是非常慢和低效的操作,会占用处理器大量的时间与资源。
当程序执行完成的时候,通过一个exit()系统调用终止子进程。exit()系统调用释放进程的大部分数据结构并发送一个终止信号通知父进程。此时的进程被称为僵尸进程(zombie process)。
子进程不会完全被移除,直到父进程通过wait()系统调用得知子进程已终止。只要子进程的终止通知发送到父进程,父进程就会移除所有子进程的数据结构,并释放进程描述符。
进程调度
在Linux系统中,进程有两种运行模式:用户模式和系统模式。用户模式的权限比系统模式下的小很多,对于一般的进程,都是部分时间运行于用户模式,部分时间运行于系统模式。进程通过系统调用在这两种模式之间切换;当系统调用发生时,进程将由用户模式切换到系统模式继续执行;当系统调用返回时,进程将由系统模式切换回用户模式。
在Linux系统中,进程不能被抢占。只要能够运行它们就不会被停止。当进程必须等待某个系统事件时,它才决定释放出CPU。进程常因为执行系统调用需要等待。由于处于等待状态的进程还可能占用CPU时间,所以Linux采用了预加载调度策略。在此策略中,每个进程只允许运行很短的时间(200ms),当这个时间用完之后,系统将选择另一个进程来运行,原来的进程必须等待一段时间以继续运行。这段时间称为时间片。
可运行进程是一个只等待CPU资源的进程。Linux使用基于优先级的简单调度算法来选择下一个运行进程。当选定新进程后,系统必须将当前进程的状态、处理器中的寄存器,以及上下文状态保存到task_struct结构中。同时它将重新设置新进程的状态并将系统控制权交给此进程。为了将CPU时间合理地分配给系统中每个可执行进程,调度管理器必须将这些时间信息也保存在task_struct中。
线程简介
线程(Thread)技术早在20世纪60年代就被提出,但真正应用多线程到操作系统中,是在20世纪80年代中期,Solaris是这方面的佼佼者。传统的UNIX也支持线程的概念,但是在一个进程(Process)中只允许有一个线程,这样多线程就意味着多进程。现在,多线程技术已经被许多操作系统所支持,包括Windows,当然,也包括Linux。
多线程程序作为一种多任务、并发的工作方式,有以下的优点。
- 提高应用程序响应。这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作(Time Consuming)置于一个新的线程,可以避免这种尴尬的情况。
- 使CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。
- 改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会有利于理解和修改。
线程概述
许多程序必须执行一些独立的不需要串行化的任务。例如,一个数据库服务器应该能监听和处理大量的客户请求。因为这些请求不需要按照一个特定的顺序来得到服务,所以它们可以被当做独立的执行体来看待,原则上它们可以是并行运行的。如果系统提供了子任务可以并发执行的机制,那么这些应用程序可以执行得更好。
在传统的UNIX系统中,这些程序使用多个进程。许多关键的服务器应用程序有一个监听进程在不停地运行,等待客户请求到来。当一个请求到达时,这个监听进程创建(Fork)一个新的进程为这个请求服务。因为对请求进行服务经常包括一些I/O操作,它可能阻塞进程。
在一个应用程序中使用多个进程有着一些明显的缺点。创建这些进程增加了一些基本的开销,因为fork是一个花销很大的系统调用。由于每个进程都有它自己的地址空间,它必须使用进程间通信的手段,如消息传递或者共享内存。要把这些进程分配到不同的机器或处理器上去运行,以及在进程之间传递信息、等待进程的完成、收集结果等都需要额外的开销。
这些都说明了进程抽象概念不充分之处,因此,迫切需要一种能很好地适合于并行计算机的方法。现在能够建立一个独立的计算单元的概念模型,这些计算单元是一个应用程序全部处理工作的一个部分。这些单元之间的交互相对是很少的,因此,需要很少的同步。一个应用程序可能包含一个或多个这种单元。线程这种抽象概念就代表了一个单独计算单元。传统的UNIX进程是单线程的,这意味着所有的计算都被串行化在同一个单元之中了。
线程的基本概念
一个进程是一个复合的实体,可以分为两个部分:线程的集合和资源的集合。线程是一个动态的对象,它表示进程中的一个控制点,并且执行一系列的指令。资源包括地址空间、打开的文件、用户凭证和配额等,这些资源为进程中所有线程所共享。此外,每一个线程有它自己的私有对象,如程序计数器、堆栈和寄存器的值。传统的UNIX进程有一个单独的控制线程,在多线程系统中进行了扩展,允许在一个进程中有多于一个的控制线程。
用户态线程与内核态线程
用户态线程在管理上不需要内核的参与,所以通常又称“协作式多任务”,在进程内的这些线程统一由用户程序来切换,所以每一个线程在执行完任务后,调用任务切换功能,并向其发送信号,任务切换完成。线程对CPU资源的占用也切换到其他线程。通常,用户态线程在线程切换时要比内核线程的速度快,不过在几个比较成功的内核态线程库中,线程切换的速度也相当快。虽然用户态线程有许多灵活性和快速的特性,但是也存在一个严重的问题,即进程中的一个线程可能独占整个时间片,导致其他线程得不到CPU时间而无法运行。例如,当一个线程由于磁盘I/O而阻塞时,其他线程同样也不能运行。另外,用户态线程不能发挥多CPU机器(SMP)的性能。
内核态线程是由内核来管理的,在每一个时间片内,内核负责调度进程内的线程。由于内核参与了用户态进程的调度,所以就涉及了内核态与用户态上下文的切换。通常所说的内核态线程切换速度慢就是由于这个原因导致的。但是使用内核态线程的一个明显的好处是进程内的一个线程不会独占整个进程的CPU时间,这样,如果一个线程由于磁盘I/O而阻塞,其他线程仍可以利用CPU时间运行。使用内核态线程的另外一个好处是可以充分发挥SMP系统的性能,而且随着系统CPU数量的增多,应用程序运行的速度明显加快。
现在有一些线程库既支持用户态线程,也支持内核态线程。因为几个比较成功的内核态线程库在任务切换上都做得比较出色,所以似乎没有使用用户态线程的必要。如果要使用用户态线程,有一个好处是可以在一个进程的多个线程间方便地协调任务。
线程
线程是在进程中产生的一个执行单元,其在同一个进程中与其他线程并行运行。它们可以共享相同的资源,比如内存、地址空间、打开的文件,等等。它们也可以访问同一组应用程序的数据。线程也被称为轻量级进程(Light Weight Process, LWP)。因为它们共享资源,所以它们中的每个线程不能同时改变它们共享的资源。因此,互斥、锁、序列化等是用户应用程序要实现的机制。
从性能的角度来看,线程的创建要比进程的创建开销更小,因为创建线程不需要复制资源。另一方面,进程和线程在调度算法上有相似的特征。内核处理它们使用相似的方式。 在当前的Linux实现中,线程支持POSIX(可移植操作系统接口)UNIX兼容库(pthread)。在Linux操作系统中有如下几个线程实现:
- LinuxThreads。 自Linux 2.0内核开始,LinuxThreads成为默认的线程实现。LinuxThreads有一些不符合POSIX标准的实现。未来的企业级Linux发行版不支持LinuxThreads。
- Native POSIX Thread Library (NPTL)。 NPTL最初是由Red Hat开发的。NPTL更加符合POSIX标准,其增强了2.6内核的性能,比如新的clone()系统调用、信号处理的实现,等等。它比LinuxThreads有更好的性能和可扩展性。 NPTL与LinuxThreads有一些不兼容。如果一个应用程序依赖于LinuxThread,则通过NPTL实现可能不能工作。
- Next Generation POSIX Thread(NGPT下一代POSIX线程)。 NGPT是IBM开发的POSIX线程库的版本。它当前正处在维护状态,并没有进一步发展的计划。
使用LD_ASSUME_KERNEL环境变量,可以选择应用程序要使用的线程库。
进程优先级和nice等级
进程优先级是一个数字,用来确定CPU处理进程的顺序,并可以确定静态(实时)优先级和动态(非实时)优先级。一个具有最高优先级的进程有较大的机会得到在一个处理器上运行的权限。
最高静态(实时)优先级(99)对应于系统优先级0,最低静态(实时)优先级(0)对应于系统优先级99。这些静态(实时)优先级,系统是不能动态改变它们的。
对于动态(非实时)优先级,内核需要使用一个基于进程行为和特征的算法做上下+/-5的动态调整。一个进程可以间接地通过使用进程的nice级别来改变静态优先级。一个具有较高静态优先级的进程会具有更长的时间片(进程在一个处理器上运行多长时间)。Linux支持的nice级别可从19(最低优先级)到-20(最高优先级)。默认值是0。将一个程序的nice级别改为一个负数(使得它有较高优先级),则需要使用root登录,或者使用su命令切换到root账号。
上下文切换
在处理器执行期间,运行进程的信息被存储在处理器的寄存器和高速缓存(cache)中,执行的进程被加载到寄存器的数据集被称为上下文(context)。在切换过程中,先存储运行进程的上下文,然后将下一个要运行的进程的上下文恢复到寄存器。进程描述符和内核模式堆栈区域用于存储上下文。这个切换的过程被称为上下文切换(context switching)。一般不能有太多的上下文切换,因为处理器每次要刷新寄存器和高速缓存(cache),以便释放空间给新的进程。这可能会导致性能问题。
中断处理
中断处理是优先级最高的任务之一。中断通常由I/O设备产生,比如网络接口卡、键盘、磁盘控制器、串行适配卡,等等。中断处理是Linux内核通知事件(比如键盘输入、以太网帧到达,等等)。它告诉内核中断进程执行,并要尽可能快地执行中断处理,因为有些设备需要快速响应。这对于系统的稳定性是至关重要的。当一个中断信号到达内核的时候,内核必须从当前执行的进程切换到一个新的进程,以处理这个中断。这意味着中断会导致上下文切换。这也暗示大量的中断会导致性能下降。
在Linux实现中,有两种类型的中断。硬中断是由硬件设备产生的,需要快速响应(如磁盘I/O中断、网络适配器中断、键盘中断、鼠标中断等)。软中断被用来处理可以推迟的任务(如TCP/IP操作、SCSI协议操作,等等)。可以在/proc/interrupts下看到硬件中断相关的信息。 在一个多处理器的环境中,中断是由每个处理器处理的。将中断绑定到单个处理器上可以提高系统的性能。
进程状态
每个进程都有自己的状态。在进程执行期间进程状态会变化。下面是一些重要的状态:
- TASK_RUNNING。 在这种状态下,进程正在CPU上运行,或者在队列(运行队列)中等待运行。
- TASK_STOPPED。 在这种状态下,进程由于某些信号(例如SIGINT、SIGSTOP等)被暂停。进程在等待一个恢复信号比如SIGCONT。
- TASK_INTERRUPTIBLE。 在这种状态下,进程暂停,并等待某个条件得到满足。如果进程处在TASK_INTERRUPTIBLE状态下并接收到一个停止信号,进程的状态会改变,操作将被中断。TASK_INTERRUPTIBLE进程的一个典型的例子是进程等待键盘中断。
- TASK_UNINTERRUPTIBLE。 类似于TASK_INTERRUPTIBLE,当一个进程处在TASK_INTERRUPTIBLE状态时,它是能够被中断的。在TASK_UNINTERRUPTIBLE状态下会给进程发送一个不执行任何操作的信号。
- TASK_UNINTERRUPTIBLE。 进程的典型例子是一个进程在等待磁盘I/O操作。
- TASK_ZOMBIE。 一个进程通过exit()系统调用退出之后,它的父进程应该知道它已终止。在TASK_ZOMBIE状态下,一个进程在等待通知它的父进程释放所有的数据结构。
僵尸进程
当一个进程接收到一个终止信号时,在它结束之前一般需要一些时间结束所有的任务(比如关闭打开的文件)。通常在很短的时间内,这个进程是一个僵尸进程。
在进程完成所有的关闭任务之后,它将相关终止报告发给父进程。有时候,一个僵尸进程不能终止自己,在这种情况下其显示为Z(僵尸)状态。
使用kill命令是不能杀死这样一个进程的,因为它已经被认定为死亡。如果你无法摆脱一个僵尸进程,你可以杀死父进程,这样僵尸就会随之消失。注意,init进程是一个非常重要的进程,如果僵尸进程的父进程是init,那么需要重新启动系统来摆脱僵尸进程。
进程的内存段
进程使用它们自己的内存地址区域来执行工作。工作的变化取决于当前情况和进程的使用。一个进程可以有不同的工作负载和不同需求的数据大小。进程能处理各种各样的数据大小。为了满足这一需求,Linux内核对每个进程采用的是动态内存分配机制。
进程的内存区域由这些段组成:
- 文本段。 这个区域用来存储可执行代码。
- 数据段。 数据段由3个区域组成:
- 数据。这个区域存储初始化数据,比如静态变量。
- BSS。这个区域存储零初始化数据。数据被初始化为零。
- 堆(Heap)。在这个区域,malloc()会根据需求动态分配内存。堆向着较高的地址增长。
- 堆栈段 (Stack segment)。这个区域是局部变量、函数参数、返回的存储函数的存放区域。堆栈向着较低地址增长。
使用pamp命令可以显示一个用户态进程的内存地址空间分配情况。使用ps命令可以显示段的总共大小
Linux CPU调度程序
任何计算机的基本功能都是计算。为了能够计算,必须有一种方法能对计算资源、处理器、计算任务进行管理,同时也要了解线程或进程。一个单独的CPU在一个时间只能执行一个程序。Linux使用多任务处理(multitasking)机制使得系统中可以多个程序同时运行。在多任务处理(multitasking)机制下,多个程序共享CPU,它们在CPU上轮流运行。
内核使用进程调度程序来确定哪个程序在哪个给定时间点运行。为了工作正常,进程调度程序必须合理调度不同的资源。它必须很快确定接下来轮到哪个进程得到CPU。通常它必须保证各进程得到的CPU时间是公平的,但是允许高优先级进程得到更大的CPU时间,或许可以抢占较低优先级进程的CPU时间。它必须对交互式应用程序做出响应。最后,在多种多样的负载条件下它应该表现出可预见性和可扩展性,如同给系统添加额外的程序。
O(1)调度程序
O(1)调度程序是在Linux 2.6内核中引进的,比如Red Hat Enterprise Linux 4和Red Hat Enterprise Linux 5。以前的调度程序在O(n)时间里操作,它必须扫描整个进程列表,以便找到下一个要运行的进程。这不能很好地扩展拥有大量进程的系统。O(1)调度程序工作时每个CPU使用2个队列:一个运行队列和一个过期的队列。调度程序根据它们的优先级将它们放置在运行队列的进程列表中,需要调度时,取出运行队列中最高优先级列表中的第一个进程,并运行它。调度程序基于进程的优先级和以前的阻塞率给进程分配一个时间片,当进程时间片用完后,进程调度程序将其移动到过期队列相应的优先级列表中。然后它从运行队列中取出下一个具有最高优先级的进程,重复以上过程。一旦运行队列中不再有进程等待,调度程序就将过期队列转变为新的运行队列,之前的运行队列成为新的过期队列,开始再次循环。
一般交互式进程(相对于实时进程)有机会得到较高的优先级,拥有较长的时间片,比较低优先级的进程能得到较多的计算时间,但是它们不会导致完全饿死低优先级进程。这种算法的优点是极大地提高了可扩展性。企业级工作负载通常包括大量的线程和进程,并且也有相当数量的处理器。新的O(1) CPU调度程序在2.6内核中被设计出来,但又可向前移植到2.4内核系列中。
新的调度程序另一个显著的优势是支持非统一内存架构(Non-Uniform Memory Architecture, NUMA)和对称多线程处理器(SMP),比如,Intel超线程(HT)技术(Intel Hyper-Threading technology)。
NUMA支持确保了负载均衡不会在NUMA节点之前发生,除非一个节点负担过重。这种机制确保了流量相对缓慢的可伸缩性链路在NUMA系统中达到最小化。虽然负载均衡可穿越处理器在调度域中将负载均衡到每个调度器滴答,但只有节点过载并要求负载均衡才会出现跨越调度域的工作负载。
完全公平调度程序
Completely Fair Scheduler (CFS)在Linux 2.6.23内核版本中第一次被引入,例如在Red Hat Enterprise Linux 6中,用来替代O(1)调度程序。CFS使用基于“虚拟时间”的红黑树。虚拟时间是基于进程等待运行的时间、竞争CPU的进程数量以及进程的优先级来计算的。具有最多虚拟时间的进程(最长等待CPU的时间)得到使用CPU的权限。随着它使用CPU周期的增加,它的虚拟时间在减少。一旦进程不再拥有最多的虚拟时间,它将被拥有最多虚拟时间的进程抢占。但是,它在内核中的代码却是比较简单的,规模和表现也良好,并且在它的调度下一些“病态的”用户进程想要伤害系统的交互性是很困难的。