操作系统进程模型分析

 

操作系统进程模型分析

赵威 石小兵

(华中科技大学计算机学院 武汉 430074

 

  分析Minix系统和Linux系统进程模型,分别对进程管理,进程调度,进程间通信和进程的数据表示进行描述,总结出两个系统的特点。

关键字  Minix  Linux  进程  进程模型

 

 

              ANALYSIS OF PROCESS MODEL IN OPERATING SYSTEM

 

ZHAO WEI   SHI XIAO BING

(Computer Science and Technology Department, Huazhong University of Science and Technology)

 

Abstract Here we analysised the process model both in Minix and Linux. Process management, process scheduler, process communication and the data structure in source code were described below.

Keywords Minix  Linux  process model  

 


引言

进程是操作系统中最重要的抽象之一,本文将分析LinuxMinix两个代表性的系统的进程模型。分析将从三个方面来进行,进程的数据表示、进程管理、进程间通信。由于每个系统都有自己的特点,所以相互之间的比较并不是为了分出孰优孰劣,而是在比较中提炼二者的特色。

Minix进程

Minix是荷兰Vrjie大学的Andrew S. Tanenbaum教授编写的一个类Unix操作系统,虽然在功能和规模上不能和Unix相比,但作为操作系统的学习对象是非常合适的。Minix一共约一万两千行代码,在Tanenbaum教授所著的“Operating Systems: Design and Implementation”一书中有具体的注释和讲解。Minix系统对进程模型的实现对初学者是很有借鉴意义的。

Minix系统被抽象为四个层次两个模式。四个层次分别为用户进程、服务器进程、设备驱动、内核进程。其中前三层属于用户模式下的进程,后面一个是内核模式下的进程。内核是Minix中最底层的程序,负责其它进程的调度、进程之间的消息。内核为其它层次的程序提供了所谓的内核调用,包括I/O端口的读写、跨越地址空间的数据访问等。在设备驱动层的进程相比更上层的程序拥有更多的特权,比如只有这一层的进程可以提出访问I/O端口等内核调用。服务器进程是为了想用户进程提供服务。进程管理器和文件系统都属于这一层。进程管理器提供了所有有关进程启动或结束的系统调用,同时实现了信号相关的系统调用。文件系统主要负责对存储设备进行管理,提供readmount等系统调用。在第三层中还有一个程序很重要,那就是再生服务器,它的作用是启动或这重启不与内核在一起加载到内存的设备驱动程序,同时检测在启动过程中驱动程序失败,并启动失败程序的副本,提高系统的容错能力。

Minix进程管理

在描述Minix进程管理前,首先了解Minix的启动过程。

Minix启动时首先访问引导盘的第一个磁道第一个扇区,对软盘来说,这个扇区里面已经记录了引导程序,引导程序装入boot程序,由boot从软盘中装入操作系统。对硬盘来说,则会在第一个扇区中读出主引导记录。主引导记录会找出活动分区,在活动分区中才会有引导程序。无论是软盘、硬盘甚至是光盘,boot将会在存储设备中找到一个文件,并装入到内存中,这个文件就是引导镜像。引导镜像中包括内核、进程管理器、文件系统、再生服务器和init程序。装入操作完成后,内核将开始运行。

内核运行后,首先启动系统任务和时钟任务,然后是进程管理器和文件系统。进程管理器和文件系统还将负责加载引导镜像的其它一部分程序。当这些都完成后,进程管理器和文件系统程序将阻塞。在Minix中只有包含在引导镜像中的所有任务、驱动程序和服务器程序都阻塞后,init就会开始运行。

Minix中,init是第一个用户进程,但不是第一个进程。在运行init前已经有进程运行了,这些进程没有PID,独立于进程树。在用户空间中的第一个进程是进程管理器,这一点是很容易接受的。它的PID0,同时没有子进程和父进程的身份。再生服务器由于可以把一个初始化后的普通进程特定的优先级和权限,使它们成为系统进程,所以再生服务器被作为其它所有在引导镜像中启动的进程的父进程。

init进程是作为引导镜像一部分加载的最后一个进程。它的PID1,可以被看做是再生服务器的一个子进程。init进程会启动rc脚本完成那些不在引导镜像中的驱动程序和服务器程序,可以想象这些程序运行时都是以init程序作为父进程。init进程随后通过rc脚本完成实时时钟的初始化、终端设备初始化、启动守护进程,最后调用login等待用户输入,用户输入完成后,启动响应的shell

Minix进程间通信

Minix的进程间可以使用四条原语来进行通信。这四条原语如下:

send(dest, &message)

send原语用于向指定的进程发送消息。

receive(source, &message)

receive原语用于收取指定进程发送的消息。

sendrec(src_dst, &message)

sendrec用来发送一条消息,并等待同一个进程应答,用于多次通信的情况。

notify(dest)

notify用来进行不阻塞的通知。

内核中的消息传递是将消息从发送者复制到接受者,对于sendrec原语,应答消息会覆盖原先的消息。当一个进程使用send原语发送消息到目标进程而目标进程并不在等待消息时,发送者进程会出现阻塞。这样做是因为实现起来比较简单,同时也避免了管理消息缓存。

Minix中的每个进程只允许和一些特定的进程通信,这样做的原因是由于发送消息是阻塞的过程,如果不加以限制的话,会出现两个进程相互等待的情况,导致死锁。使用notify原语发送通知消息是不会出现死锁,因为该原语不会导致阻塞。notify实现的原理是通知消息在没有被接受时很容易缓存。Minix中的消息很有限,通常只包括发送者的身份和一个时间戳。

Minix进程调度

Minix系统属于多道程序系统,当发生中断时,不管是硬终端或者是软中断都会发生进程的重新调度。

Minix采用了优先级队列加时间片轮转的策略。调度器维护着默认为16个的优先级队列,每个队列对应一个优先级。进程的优先级是可以被重新设置的,比如通过修改nice值等。优先级决定了进程可以被分配的时间片的大小。

一般来说,内核任务的优先级要大于设备驱动的优先级,设备驱动的优先级大于服务器程序的优先级,服务器程序的优先级大于用户程序的优先级。IDLE程序的优先级最低。假如系统有16个优先级,则最高优先级为0,一般是内核任务,IDLE15,优先级最低。用户进程最低优先级为14

在运行时调度器首先检查最高优先级队列中的进程,如果一个或者多个处于就绪状态,则队首的进程将运行。如果没有进程就绪,下一个优先级的队列将被检查,以此类推。由于驱动程序响应服务进程的请求,服务器进程响应用户进程的请求,最终所有搞优先级的进程都完成被请求的工作。

在一个进程的时间片被用完后,它将标记为阻塞,然后被调度器放到队尾,除非它是该优先级唯一的进程。选择下一个进程运行。在高优先级队列中没有进程而且该进程在队列中唯一,则它将再次得到运行。重要的驱动程序和服务器程序会被分配一个较大的时间片,通常它们出现阻塞不是由于时间片用完,而是由于其它原因。如果程序出错,它的优先级将会被降低,防止系统处于不响应状态和数据丢失,而且有利于收集调试信息。

Minix进程数据表示

Minix源码中,proc.h定义了进程在进程表中的数据结构

 

struct proc {

  struct stackframe_s p_reg;  /* 进程保存在堆栈中的寄存器 */

  reg_t p_ldt_sel;          /* 描述符表相关寄存器 */

  struct segdesc_s p_ldt[2+NR_REMOTE_SEGS]; /*  代码段和数据段寄存器 */

  proc_nr_t p_nr;         /* 进程编号 */

  struct priv *p_priv;           /* 特权结构体 */

  char p_rts_flags;        /* 进程的消息状态 */

  char p_priority;         /* 进程当前的优先级 */

  char p_max_priority;         /* 进程最大优先级 */

  char p_ticks_left;              /* 剩余运行时间时间*/

  char p_quantum_size;        /* 时间片最小单位*/

  struct mem_map p_memmap[NR_LOCAL_SEGS];   /* 内存映射*/

  clock_t p_user_time;         /* 用户时间滴答 */

  clock_t p_sys_time;           /* 系统时间滴答 */

  struct proc *p_nextready;   /* 指向下一个就绪的进程表项 */

  struct proc *p_caller_q;     /*消息通信时指向要通信的进程链 */

  struct proc *p_q_link;       /* 指向下一个要发送消息的进程 */

  message *p_messbuf;        /* 已发送消息缓冲区 */

  proc_nr_t p_getfrom;        /* 等待接收消息的源进程 */

  proc_nr_t p_sendto;          /* 要发送消息的目的进程 */

  sigset_t p_pending;           /* 信号位图 */

  char p_name[P_NAME_LEN];  /* 进程的名字 */

};

其中包括堆栈信息、段信息、寄存器等信息。对进程的优先级定义有以下宏

#define NR_SCHED_QUEUES   16  /* 最大优先级数+1 */

#define TASK_Q              0            /* 内核任务拥有的最高优先级 */

#define MAX_USER_Q         0     /* 用户进程用有的最高优先级 */  

#define USER_Q            7         /* 默认优先级,对应的Nice7 */  

#define MIN_USER_Q    14            /* 用户进程拥有的最低优先级 */

#define IDLE_Q             15         / * 最低优先级,只有IDLE */

 

 

 

Linux进程

Minix一样,Linux的进程是一段处在执行期的程序,包括可执行的代码、打开的文件、挂起的信号等资源,还有一些附加状态信息。总之进程是一个抽象的,动态的概念,是处在执行阶段程序的活标本。

进程在生存周期中可以有三种状态:就绪、运行和等待。其中等待又可以分为可打断的等待和不可打断的等待。就绪状态和运行状态很相似,差别是前者状态的进程没有处理器的使用权。在Linux新版本的内核代码中两种状态其实已经被合并。等待状态的进程是为了等待外部时间发生,即使处理区空闲也不会进入运行。外部事件一般是等待某种资源、等待I/O完成等。进程的状态变迁图如 1

 

1 进程状态变迁示意图

 

Linux进程模型有一类特殊的进程,它们共享同一个程序内的共享内存地址空间,被称之为线程。

 

2 进程模型

 

3 线程模型

 

线程适用于一些共同完成统一目标的的处理流程。传统概念中,一个进程只有有一条处理流程。进程之间空间独立,时时间独立,相互之间没有干扰。而线程则是完成同一个任务的不同的处理过程。这些处理过程需要同一段内存进行协调,关系紧密。由于共享了相同的地址空间,线程的调度成本要小于进程的调度。

Linux进程管理

Linux系统中存在一个树形结构的进程谱系图,通常所有进程都是PID1init进程的后代。这是因为init作为系统引导阶段最后部分被启动,由它调用rc脚本完成剩下部分系统启动,此阶段启动的驱动程序等父进程就是init了。用户登录时,是init在完成身份验证后开启的shell,用户所开启的一些进程都是shell的子进程,当然也是init的后代进程了。

Linux进程创建分为两个步骤,一个是通过拷贝当前进程创建一个子进程。父进程和子进程只有PIDPPID等一些微小的区别。第二步是读取可执行的文件并且载入新进程的资质空间开始运行。前一步通过fork()函数完成,后一步需要exec()函数完成。在新的机制中,通过写时拷贝技术优化了新建一个进程的流程。在写时拷贝中,fork()不再复制父进程,而是先让子进程以只读方式共享父进程的,直到子进程地址空间需要被写入。由于一般情况下,进程创建后都会马上运行一个可执行的文件,写时拷贝可以避免拷贝大量即将被覆盖的数据,提高了进程快速执行能力。

Linux进程的结束要靠系统调用exit()进行。exit()可以显式调用,也可以被隐式的调用。同时当进程出现无法处理的错误时也可以被动的被结束。进程结束的操作大部分操作有do_exit()完成。主要工作有设置进程的状态为结束状态,释放占用的内存描述符,退出相关的等待队列,释放文件描述符,通知父进程,设置子进程,最后是切换到其它进程。这个过程中并不会销毁进程描述符,进程描述符可以使父进程在子进程被结束后仍能获取它的信息,只有在父进程通知内核明确该进程描述符不再需要时,进程描述符才会被销毁。对一些父进程结束但子进程仍然在运行的情况,Linux有一套机制使子进程可以找到一个新的父进程。在do_exit()系统调用中,如果被结束的进程还有子进程正在运行,则会将这些子进程的父进程设置为别的进程。这个过程由notify_parent()通过forget_original_parent()函数完成。如果当前线程组中非空则选择其中一个作为孤儿子进程的父进程,否则设置init为父进程。

Linux进程调度

调度程序是作为内核的组成部分,主要任务是选择下一个要运行的进程,进程调度程序可以看做是为所有处在运行状态程序分配处理器使用时间的内核子系统。这样的一个系统是多道程序系统的基础,只有通过合理有效地调度,处理器才能充分的被使用,多道程序才成发挥效力。

因为调度程序要最大化使用处理器,所以只要有处在就绪态的进程就会有进程在运行。但是现实的情况是就绪的进程数目很多,在某一时刻必定有进程在就绪态等待处理器使用时间。多任务系统的特性在于可以并发的交互执行多个进程。主要分为非抢占式任务系统和抢占式任务系统。Linux属于后者。在抢占式任务系统中,调度程序决定了一个进程的运行以便其它进程可以分享到处理器使用时间。调度程序强制挂起某一个进程的行为就叫做抢占。进程从被允许运行到被强占之前可以运行的时间成为时间片。时间片的管理属于进程调度的一部分。有效的管理时间片可以避免个别优先级较高的程序独占处理器。对非抢占的多任务系统,除非正在运行的进程自己主动停止运行,否则会一直占据处理器使用权。如果该进程出现错误,则整个系统就会崩溃。进程主动放弃处理的行为称之为让步。

经过长时间的优化,新版本Linux内核的进程调度已经被极大的优化,采用优先级加时间片轮转的总体策略,在细节方面进一步优化,使调度的时间复杂度达到O(1),所以该调度程序叫做O(1)调度程序。

O(1)调度程序会对系统中的进程进行区分,分为I/O消耗型和处理器消耗型。I/O消耗型进程很多时候都是在提交I/O请求然后等待I/O请求,I/O消耗型进程经常处在运行状态,一方面还是因为大多数交互性较强的进程都是I/O消耗型的。提高这种进程的运行频率可以提高系统的响应速度,这也是Linux进程调度的目标之一。对I/O消耗型进程应该分配一些更短的时间片,提高运行的频率。处理器消耗型进程则把很多时间用于运行代码上,没有太多的I/O请求,对于处理器消耗型进程应该分配更长的时间片,降低运行频率。调度程序要在进程响应速度和系统利用率之间寻求平衡,为了做到这一点,调度程序要对进程进行区分那些程序是最值得投入运行的。Linux为了保证交互性,更倾向于优先调度I/O消耗型进程。

优先级是Linux调度算法的基础之一,根据进程的重要性和对处理器的消耗程度来确定进程有的优先级。优先级较高就会先被调度,同时,优先级较高的程序的时间片也较长。调度程序总是选择时间片没有用完而且优先级最高的进程投入运行。进程的优先级可以被设定,而且是动态的。当进程发现某个进程更偏向I/O消耗程序时,会提高该进程的优先级。相反,如果调度程序发现某一个进程每次都会耗尽时间片,它的优先级就会被动态的降低。Linux的优先级分为两个独立的组,一组的依据是nice值,这个值可以被用户修改但调度程序一般不会改变。nice值的范围是-20+19,默认值为0-20代表优先级最高。另一个是实时优先级,默认情况下是从099,任何实时进程的优先级都高于普通进程。

时间片是另外一个进程调度的依据是时间片,时间片是进程从被调度到被抢占之间运行的时间。时间片的管理是进程调度一个重要的部分,时间片过长会导致系统交互性降低,时间片太短对增加调度程序成本。同时还要协调好I/O消耗型进程和处理器消耗型进程关系。Linux一般会提高交互式程序的优先级,使它们运行更频繁,而且会提供较长的时间片。这是由于I/O消耗型进程一般不会消耗很多处理器时间,而是很快的进入I/O等待状态。当进程时间片被耗尽时,认为该进程到期,没有时间片的继承不会再次投入运行,除非其它进程耗尽了时间片并且重写计算分配了时间片后。

Linux调度程序给每个处理器一个叫做运行队列的数据结构。这是调度程序的核心数据结构体。每个可执行队列根据优先级的个数有若干优先级链表,表示每一个优先级上可以运行的进程。同时每个可执行队列上有两个优先级数组,在运行时其中一个会被称为活动的,即当前使用的,另一个被称为过期的。进程时间片用完时一般会被移动到过期的优先级数组中。过期优先级数组的时间片是实现已经被计算了的,当活动优先级数组中的进程时间片都用完时,可以直接进行两个优先级数组的切换,从而使时间片的重新计算时间复杂度变为O(1)

schedule()函数完成了进程的切换并执行。该函数要判断哪个进程的优先级最高,这个过程是通过优先级位图,在活动优先级数组中找到第一个被设置的优先级,然后选择这个优先级链表中第一个进程进行执行。如 4

 

4 Linux进程调度示意图

 

在进程运行过程中,调度程序会根据对进程类型的推断来动态的改变进程的优先级。在计算时间片时则是以优先级作为依据进行。值得一提的是进程在创建子进程时会分一半现有的时间片给子进程。这样可以防止一些进程通过不断创建子进程而长时间占用处理器。

Linux进程间通信

Linux进程通信方式很多。比如信号、管道、消息队列、共享内存甚至socket等。

信号是Linux系统响应某些条件而产生的一个事件,可以被发送到一个进程。信号可以作为进程间传递消息的一种方式,显式的由一个进程发送给另一个进程。信号的定义如下:

#define SIGHUP           1  /* 连接挂断 */

#define SIGINT            2

#define SIGQUIT          3  /* 终端退出 */

#define SIGILL             4  /* 非法指令 */

#define SIGTRAP         5

#define SIGABRT         6  /* 进程异常终止 */

#define SIGIOT            6

#define SIGBUS           7

#define SIGFPE            8  /* 浮点预算异常 */

#define SIGKILL          9  /* 终止进程 */

#define SIGUSR1         10

#define SIGSEGV         11  /* 无效内存访问 */

#define SIGUSR2         12

#define SIGPIPE          13

#define SIGALRM        14  /*超时警告*/

#define SIGTERM        15

#define SIGSTKFLT      16

#define SIGCHLD         17  /* 子程序已经停止或退出 */

#define SIGCONT         18

#define SIGSTOP         19  /* 停止执行 */

……

 

这些宏可以在include/asm/i386中的signal.h中找到。

当从一个进程连接数据流到另一个进程时,我们称之为管道。通常是实现方法是把一个进程的输出通过管道连接到另一个进程的输入。在shell命令中很常见。除此之外,可以在程序中通过popenpclose系统调用。这样启动另一个程序,并可以传递数据作为其输入,也可以接受输出。在结束时通过pclose关闭管道。管道的操作和文件打开关闭比较相似。在系统调用时,popen首先启动shelll,然后在启动新进程。这样做会占用大量的系统资源,因此还存在pipe系统调用,可以在不开启新的shell情况下在两个进程间传递数据。

共享内存也是进程间通信的一种方式,允许两个不相关的进程访问同一块逻辑内存。相比管道,共享内存在大块数据的交换上面有很大性能优势。共享内存是IPC为进程创建的一个特殊的地址范围,不同的进程可以将共享内存放入自己的地址空间中,由于没有提供同步机制,所以需要进程自行控制。

Linux进程数据表示

Linux进程在内存中会被保存在一个叫做任务队列的双向链表中,数据结构位struct task_struct,这是Linux内核中最为复杂的数据结构之一。其中包括了进程的一些标志位,同时还有调度信息、进程状态、进程空间信息等。具体的定义在include/linux/sched.h中。

struct task_struct {

       volatile long state;  /* -1 unrunnable, 0 runnable, >0 stopped */

       struct thread_info *thread_info;

       atomic_t usage;

       unsigned long flags;       /* per process flags, defined below */

       unsigned long ptrace;

 

       int lock_depth;              /* Lock depth */

 

       int prio, static_prio;

       struct list_head run_list;

       prio_array_t *array;

 

       unsigned long sleep_avg;

       long interactive_credit;

       unsigned long long timestamp, last_ran;

       int activated;

 

       unsigned long policy;

       cpumask_t cpus_allowed;

       unsigned int time_slice, first_time_slice;

….

}

 

可执行队列的数据结构为struct runqueue,定义在sched.c中。

struct runqueue {

       spinlock_t lock;  /* 保护队列锁 */

unsigned long nr_running;  /* 可运行的任务数 */

unsigned long expired_timestamp;  /* 队列最近被换出时间 */

unsigned long nr_uninterruptible;  /* 处在不可打断睡眠的任务数 */

prio_array_t *active, *expired, arrays[2];  /* 优先级数组相关 */

}

 

优先技术组的数据结构位struct prio_array,定义在sched.c中。

struct prio_array {

       unsigned int nr_active;   /* 任务数目 */

       unsigned long bitmap[BITMAP_SIZE];  /*优先级位图*/

       struct list_head queue[MAX_PRIO];  /* 优先级队列 */

};

 

总结

本文通过描述Minix系统和Linux系统中的进程模型,分析了相关的策略,算法和数据结构。虽然Linux在各个方面都显得比较现代,但都有Minix系统的影子,尤其是在进程调度策略中,Linux通过不断的改进使算法效率有很大提高,这种提高建立在衍生自Minix调度策略的基础上。对两个系统的比较不应只横向比较而可以从时间上进行横向比较,将Linux看做Minix的一种进步,这种进步背后的动力同样是是计算机科学能成为当今最具创造力领域的根基。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值