操作系统概念学习笔记 8
进程
概念
进程
进程是执行中的程序,这只是非正式的说法。进程不只是程序代码,程序代码称为文本段(代码段),还包括当前活动,通过程序计数器的值和处理器寄存器的内容来表示。此外,进程还包括进程堆栈段(临时数据、函数参数、局部变量、地址)和数据段(包括全全局变量。还可能包括堆(leap),是在进程运行期间动态分配内存。
程序是被动实体,如存储在磁盘上包含一系列指令的文件内容(可执行文件),而进程是一个活动实体,他有一个程序计数器来表示下一个要执行的命令和相关资源集合。
虽然两个进程可以与同一程序相关,如同一用户调用的不同web浏览器副本,但他们是独立的进程。尽管代码段相同的,但数据段、堆栈等却不同。
进程状态
进程状态:
- 新的:进程正在被创建
- 运行:指令正在被执行
- 等待:进程等待某个事件的发生(如I/O完成或受到信号)
- 就绪 :进程等待分配处理器
- 终止 :进程完成执行
一次只有一个进程可以在一个处理器上运行,但多个进程可以处于就绪或等待状态。
进程控制块
进程在操作系统内用进程控制块(process control block,PCB)来表示,PCB包含了进程状态、程序计数器、cpu寄存器、cpu调度信息、内存管理信息、记账信息、I/O状态信息等信息。
- 进程状态: 状态可包括新的,就绪,运行,等待,终止等。
- 程序计数器 : 计数器表示进程要执行的下个指令的地址。
- CPU寄存器: 与程序计数器一起,这些寄存器的状态信息在出现中断时也需要保存,以便进程以后能正确的执行。
- CPU调度信息:这类信息包括进程优先级、调度队列指针和其他调度参数。
- 内存管理信息:根据内存系统,这类信息包括基址和界限寄存器的值,页表或段表。
- 记账信息:这类信息包括CPU时间、实际使用时间、时间界限、记账数据、作业或进程数量等。
- I/O状态信息:这类信息包含分配给进程的I/O设备列表、打开的文件列表等。
进程调度
多道程序设计的目的是无论何时都有进程执行,从而使cpu利用率达到最大。分时系统的目的是在进程之间快速切换cpu以便用户在程序运行时能与其进行交互。为达到这一目的,进程调度选择一个可用的进程到cpu上执行。单处理器系统从不会有超过一个进程在执行。如果有多个进程,那么余下的则需要等待CPU空闲并且重新调度。
调度队列
进程进入系统时被加入到作业队列中,该队列包含系统中所有进程。驻留在内存中等待运行的程序保存在就绪队列中,该队列常用链表来实现,其头节点指向链表的第一个和最后一个PCB块的指针。每个PCB包括一个指向就绪队列的下一个PCB的指针域。
在linux 中每一个进程都由task_struct 数据结构来定义. task_struct就是我们通常所说的PCB,还包含有指向父进程和子进程的指针。例如,进程的状态就是通过这个结构中的long state字段来表示的。
在Linux内核里,所有活动的进程是通过一个名为task_struct的双向链表来表示的,内核为当前正在运行的进程保存了一个指针。
如内核想把当前运行的进程状态值修改成 new_state。如果current是指向当前进程的指针,则:
`current -> state = new_state;“
操作系统也有其他队列。等待特定IO设备的进程列表称为设备队列。每个设备都有自己的设备队列。
新进程开始处于就绪队列,它就在就绪队列中等待直到被选中执行或被派遣。当进程分配到cpu执行时,可能发生:
a.进程发出一个IO请求,并放到IO队列中。
b.进程创建新的子进程,并等待其结束
c.进程由于中断而强制释放cpu,并被放回到就绪队列中
对于前两种情况,进程最终从等待状态切换到就绪状态,并放回到就绪队列中。进程继续这一循环直到终止,到那时它将从所有队列中删除,其PCB和资源将得以释放。
调度程序
进程会在各种调度队列之间迁移,为了调度,操作系统必须按某种方式从这些队列中选择进程。进程的选择是由相应的调度程序(scheduler)来执行的。
通常批处理系统中,进程更多的是被提交,而不是马上执行。这些进程通常放到磁盘的缓冲池里,以便以后执行。长期调度程序或作业调度程序从该池中选择进程,并装入内存以准备执行。短期调度程序或cpu调度程序从准备执行的进程中选择进程,并为之分配cpu。
这两个调度程序的主要差别是调度的频率。
短期调度程序通常100ms至少执行一次,由于每次执行之间的时间较短,短期调度程序必须要快。
长期调度程序执行的并不频繁,所以长期调度程序能使用更多的时间来选择执行进程。长期调度程序控制多道程序设计的程度(内存中进程的数量)。长期调度程序必须仔细选择执行进程。通常,绝大多数进程可分为:I/O为主或CPU为主。I/O为主的进程通常执行I/O方面比执行计算花费更多时间,另一方面,CPU为主的进程很少产生I/O请求。为使系统达到平衡和更好的性能,长期调度程序应当选择一个合理的包含IO为主的和cpu为主的组合进程以充分使用设备和短期调度程序。
对于Linux和Windows系统通常没有长期调度程序,这些系统的稳定性依赖于物理限制,如果系统性能下降很多,会有用户的退出。
有的系统如分时系统,可能引入中期调度程序,其核心思想是能够将进程从内存中移出,从而降低多道程序设计的程度,之后进程可以被换入。
上下文切换
中断使CPU从当前任务改变为运行内核子程序。当发生一个中断时,系统需要保存当前运行在CPU中进程的上下文,从而能在其处理完后恢复上下文。进程的上下文用PCB来表示。通常通过执行一个状态保存(state save)来保存cpu当前状态,之后执行一个状态恢复(state restore)重新开始运行。
将CPU切换到另一进程需要保存当前状态并恢复另一进程状态,这叫做上下文切换(context switch)。当发生上下文切换时,内核会将旧进程的状态保存在PCB中,然后装入经调度要执行的并已保存的新进程的上下文。
上下文切换时间是额外开销,因为切换时系统并不能做什么有用的工作。其切换时间与硬件支持密切相关。
进程操作
进程创建
进程在执行时,能通过创建进程系统调用创建多个新进程。创建进程为父进程,而新进程叫做子进程。新进程都可再创建其他进程,从而形成了进程树。
大多数操作系统根据一个唯一的进程标识符(process indentifier,pid)来识别进程,pid通常是一个整数值。
在UNIX中,使用ps命令可以得到一个进程列表。
通常进程需要一定的资源(如CPU时间,内存,文件,I/O设备)来完成其任务。子进程被创建时,子进程可能直接从操作系统,也可能只从父进程那里获取资源。父进程可能必须在其子进程之间分配资源或共享资源(如内存或文件),限制子进程只能使用父进程的资源能防止创建过多的进程带来的系统超载。
在进程创建时,除了得到各种物理和逻辑资源外,初始化数据(或输入)由父进程传给子进程。
当进程创建新进程被创建时,有两种执行可能:
a.父进程与子进程并发执行
b.父进程等待,直到某个或全部子进程执行完
新进程的地址空间也有两种可能:
a.子进程是父进程的复制品(具有与父进程相同的程序和数据)。
b.子进程装入另外一个新程序
UNIX操作系统中,每个进程用唯一整数标识符来标识,通过fork()系统调用,可创建新进程,新进程通过复制原来进程的地址空间而成。这种机制允许父子进程之间方便的通信。
两个进程都继续执行位于系统调用fork()之后的指令,但是对于子进程,系统调用fork的返回值为0:而对于父进程,返回值为子进程的进程标识符(非零)。
通常系统调用fork后,一个进程会使用系统调用exec(),以用新程序来取代进程的内存空间。系统调用exec()将二进制文件装入内存(消除了原来包含系统调用exec()的程序内存映射),并开始执行。采用这种方式,两个进程都能相互通信,并按各自的方式执行。
父进程能创建更多的子进程,或者如果在子进程运行时没有什么可做,那么它采用系统调用wait()把自己移出就绪队列来等待子进程的终止。
下面的c程序是我自己写的在linux下编写一个多进程并发执行程序。父进程首先创建一个执行 ls 命令的子进程然后该子进程再创建一个执行 ps 命令的子进程,并控制ps 命令总在 ls 命令之前执行。
/*
* Filename
: pctl.c
*
: (C) 2015 by 孙铭超
* Function
: 编写一个多进程并发执行程序。父进
程首先创建一个执行 ls 命令的子进程然后再创建一个执行 ps 命令的子进程,
并控制ps 命令总在 ls 命令之前执行。
*/
#include < sys/types.h >
#include < wait.h >
#include < unistd.h >
#include < signal.h >
#include < stdio.h >
#include < stdlib.h >
int main(int argc)
{
int i;
int pid_ls;//存放执行ls命令子进程号
int pid_ps;//存放执行PS命令子进程号
int status_ls; //存放ls子进程返回状态
int status_ps; //存放ps子进程返回状态
char *args_ls[] = {"/bin/ls","-a",NULL}; //子进程要缺省执行的命令ls
char *args_ps[] = {"/bin/ps","-l",NULL}; //子进程要缺省执行的命令ps
pid_ls=fork() ; //建立ls子进程
if(pid_ls<0) // 建立ls子进程失败
{
printf("Create Process(ls) fail!\n");
exit(EXIT_FAILURE);
}
if(pid_ls == 0) // ls子进程执行代码段
{
//报告父子进程进程号
printf("I am Child process %d\nMy father is %d\n",getpid(),getppid());
//创建ps子进程
pid_ps=fork() ;
if(pid_ps<0) // 建立ps子进程失败
{
printf("Create Process(s) fail!\n");
exit(EXIT_FAILURE);
}
if(pid_ps == 0) // ps子进程执行代码段
{
//报告父子进程进程号
printf("I am Child process %d\nMy father is %d\n",getpid(),getppid());
//子进程执行ps
printf("%d child_ps will Running: \n",getpid());
//则执行缺省的命令ps
for(i=0; args_ps[i] != NULL; i++) printf("%s ",args_ps[i]);
printf("\n");
//装入并执行新的程序
status_ps = execve(args_ps[0],args_ps,NULL);
}else{
//父进程等待子进程执行结束
printf("%d Waiting for child done.\n\n",pid_ps);
waitpid(pid_ps,&status_ps,0); //等待子进程结束
printf("\nMy child exit! status = %d\n\n",status_ps);
}
//子进程执行ls
printf("%d child_ls will Running: \n",getpid()); //
//则执行缺省的命令ls
for(i=0; args_ls[i] != NULL; i++) printf("%s ",args_ls[i]);
printf("\n");
//装入并执行新的程序
status_ls = execve(args_ls[0],args_ls,NULL);
}
else //父进程执行代码段
{
printf("\nI am Parent process %d\n",getpid()); //报告父进程进程号
sleep(1) ; //等待子进程建立
//与子进程并发执行不等待子进程执行结束,
printf("%d don‘t Wait for child(ls) done.\n\n",getpid());
}
return EXIT_SUCCESS;
}
执行结果:
I am Parent process 29814
I am Child process 29815
My father is 29814
29816 Waiting for child done.
I am Child process 29816
My father is 29815
29816 child_ps will Running:
/bin/ps -l
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 25134 25128 0 80 0 - 6968 wait pts/1 00:00:00 bash
0 S 1000 29814 25134 0 80 0 - 1048 hrtime pts/1 00:00:00 deom1
1 S 1000 29815 29814 0 80 0 - 1048 wait pts/1 00:00:00 deom1
0 R 1000 29816 29815 0 80 0 - 1783 - pts/1 00:00:00 ps
My child exit! status = 0
29815 child_ls will Running:
/bin/ls -a
. .. demo1.c demo1.c~ deom1
29814 don‘t Wait for child(ls) done.
当子进程完成时通过调用exit(),父进程会从wait()调用处开始继续,并调用exit()以表示结束。
进程终止
进程执行完最后的语句并使用系统调用exit()请求系统删除自身时,进程终止。此时,进程可以返回状态值(通常为整数)到父进程(通过系统调用wait())。所有进程资源(物理和虚拟内存、打开文件和I/O缓冲)会被操作系统释放。
在其他情况下也会出现终止。
进程通过适当的系统调用能终止另外一个进程。通常,只有被终止进程的父进程才能执行这一系统调用。否则,用户可以任意的终止彼此的作业。
父进程终止其子进程的原因有很多,如:
- 子进程使用了超过它所分配的一些资源。(为判定是否发生这种情况,要求父进程有一个检查其子进程状态的机制)
- 分配给子进程的任务已经不需要
- 父进程退出,如果父进程终止,那么操作系统不允许子进程继续(有些操作系统,对于这类操作系统这种现象称为级联终止)。
UNIX:可以通过系统调用exit()来终止进程,父进程可以通过系统调用wait()以等待子进程的终止。系统调用wait()返回了中止子进程的进程标识符,以使父进程能够知道哪个子进程终止了。如果父进程终止,那么其所有子进程会以init进程作为父进程,因此,子进程仍然有一个父进程来收集状态和执行统计。
进程间通信
按进程是否与其他进程共享数据,可分为独立的和协作的,即独立进程或协作进程。
可能需要提供环境以允许进程协作,理由如下:
- 信息共享:
可能多个用户对同样的信息感兴趣
- 提高运算速度:
如果希望一个特定任务快速运行,那么必须将它分为子任务,每个子任务可以与其他子任务并行执行。如果要实现这样的加速,需要计算机有多个处理单元(例如CPU和I/O通道)
- 模块化:
可能需要按模块化方式构造系统
- 方便
单个用户也可能同时执行多任务。
协作进程需要一种进程间通信机制(IPC)来允许进程相互交换数据与信息。进程间通信有两种基本模式模式:
- 共享内存:通过建立一块供协作进程共享的内存区域并在此区域读写数据来交换信息。
- 消息传递:通过在协作进程之间交换消息来实现通信。
对于以上两种模式,消息传递对于交换较少数据很有用,并且更易于实现,但需要更多内核介入的时间。
共享内存比消息传递快,允许以最快速度进行方便的通信。
共享内存系统
共享内存系统需要建立共享内存区域。通常一块共享内存区域驻留在生成共享内存段进程的地址空间。其他希望使用这个共享内存段进行通信的进程必须将此放到它们自己的地址空间上。数据的形式或位置取决于这些进程而不是操作系统,进程还负责保证他们不向同一区域同时写数据。
生产者—消费者问题是协作进程的通用范例。生产者进程产生信息以供消费者进程消费。例如,编译器产生的汇编代码供汇编程序使用,而汇编程序反过来产生目标代码供连接和装入程序使用。
采用共享内存是解决生产值——消费者问题方法之一。为允许生产者进程和消费者进程能并发执行,必须有一个缓冲来被生产者填充并被消费者所使用。此缓冲驻留在共享内存区域,消费者使用一项时,生产者能产生另一项。生产者和消费者必须同步,以免消费者消费一个没有生产出的项。
可以使用两种缓冲:
无限缓冲对缓冲大小没有限制。消费者可能不得不等待新的项,但生产者总可以产生新的项。
有限缓冲假设缓存大小固定。对于这种情况,如果缓冲为空,那么消费者必须等待,如果缓冲为满,那么生产者必须等待。
# define BUFFER-SIZE 10
typedef struct {
...
} item;
item buffer[BUFFER-SIZE];
int in=0;
int out=0;
共享缓存通过循环数组和两个逻辑指针in和out来实现,in指向缓冲中下一个空位;out指向缓冲中第一个满位。当in = = out时,缓冲为空;当(in+1)%BUFFER-SIZE = = out时,缓冲为满。
生产者和消费者代码如下:生产者进程有一个局部变量nextProduced以储存所产生的新项。消费者有一个局部变量nextConsumed以存储要使用的新项。
生产者进程:
while (true) { /* Produce an item */
while (((in = (in + 1) % BUFFER SIZE count) = = out); /*do nothing */
buffer[in] = item;
in = (in + 1) % BUFFER SIZE;
}
消费者进程:
while (true) {
while (in == out); // do nothing -- nothing to consume
// remove an item from the buffer
item = buffer[out];
out = (out + 1) % BUFFER SIZE;
return item;
}
这种方法允许缓存的最大项数是BUFFER—SIZE-1
消息传递系统
消息传递提供一种机制以允许进程不必通过共享地址空间来实现通信和同步,这在分布式系统中很有用。例如用于WWW的chat程序就是通过信息交换来实现通信。
如果进程P和Q需要通信,他们之间要有通信线路(communication link).这里不关心线路的物理实现只讨论逻辑实现。下面是一些逻辑线路和接收/发送操作的方法:
- 直接或间接通信
- 同步或异步通信
- 自动或显式缓冲
下面研究这些相关问题
1.命名
需要通信的进程必须有一个方法以互相引用,他们可使用直接或间接通信。
直接通信:每个进程必须明确的命名通信的接受者或发送者,定义如下:
- send(P,message)发送信息到进程P
- receive(Q,message)接收来自Q的消息
这种方案的通信线路具有以下属性:
- 在需要通信的每对进程之间自动建立线路。进程仅需要知道相互通信的标识符。
- 一个线路仅与两个进程相关
- 每对进程之间只有一个线路
这种方式展示了对称寻址,即发送和接收进程必须命名对方以便通信。
这种方案的一种变形采用非对称寻址即只要发送者命名接受者,定义如下:
- send(P,message)发送消息到进程P
- receive(id,message)接收来自任何进程的消息,变量id设置成与其通信的进程名称
对称和非对称寻址的缺点是限制了进程定义的模块化
间接通信,通过邮箱或端口发送和接收消息。
邮箱可以抽象为一个对象,进程可以向其中存放消息,也可以从中删除消息,每个邮箱有唯一的标识符。一个进程可能通过许多不同的邮箱与其他进程通信。但两个进程仅在其共享至少一个邮箱时可以互相通信。
- send(A,message)发送一个消息到邮箱A
- receive(A,message)接收来自邮箱A的消息
对于这种方案,通信线路有如下属性:
- 只有在两个进程共享至少一个邮箱时可以互相通信
- 一个线路可以与两个以上的进程相关联
- 两个进程之间可有不同的线路,每个线路对应一个邮箱
2.同步
消息传递可以是阻塞或非阻塞——也称为同步或异步。
- 阻塞 send: 发送进程阻塞,直到消息被接收进程或邮箱所接收。
- 非阻塞 send:发送进程发送消息并再继续操作。
- 阻塞 receive : 接收者阻塞,直到有消息可用。
- 非阻塞 receive:接收者收到一个有效消息或空消息。
send和receive可以是阻塞或非阻塞的。当都阻塞时,则接受者和发送者之间就有一个集合点(rendezvous)。当使用阻塞send和receive时,如何解决生产者和消费者问题就不在重要了。生产者仅需要调用阻塞send()并等待,直到消息被送到进程或邮箱。同样的,当消费者调用receive()时,发生阻塞直到有一个消息可用。
3.缓冲
不管进程是直接的还是间接的通信进程所交换的消息都驻留在临时队列中,队列实现有三种方法
- 零容量:队列的最大长度为0.线路中不能有消息处于等待,对于这种情况,消息必须阻塞发送,直到接受者接收到消息。
- 有限容量:最多有n个消息驻留在其中,如线路满,阻塞发送者
- 无限容量
IPC系统实例
实例:POSIX 共享内存
首先使用shmget()创建共享内存段。
segment - id = shmget(IPC_PRIVATE,size,S_IRUSR | S_IWUSR);
参数:
- 共享内存段标识符,IPC_PRIVATE为生成一个新的共享内存段
- 内存大小(字节)
- 标识模式,指定如何使用内存段:读出或写入
想访问共享内存段的进程必须采用shmat()系统调用来将其加入地址空间,其需要三个参数。
- 希望加入的共享内存段的整数标识符
2.内存中一个指针位置,表示将要加入到的共享内存所在,如果传递一个NULL值,操作系统为用户选择位置。
- 指定加入到共享内存区域是只读还是只写,通过参数0,标识读写均可
shared_memory = (char*)shmat(id,NULL,0);
如果成功,shmat()返回一个指向附属的的共享内存区域的内存中的初始位置的指针。
一旦共享内存区域被加入到进程的地址空间,进程就可以采用从shmat()返回的指针,作为一般的内存访问来访问共享内存。
当一个进程不再需要访问共享内存段,可按照下面方法将共享内存的指针传给系统调用shmdt():
shmdt(shared_memory)
最后可以采用系统调用shmctl()删除共享内存段。
下图演示了:生成4096B的共享内存段,一旦共享内存被加入,进程向其中写入消息“hi,there”,输出后,他被删除共享内存区域。
实例:Mach
Mach系统中绝大多数通信是通过消息传递的。每个任务创建时,也创建了两个特别的邮箱:内核邮箱和通报邮箱(notify)。内核使用内核邮箱与任务通信,使用通报邮箱发送事件发生的通知。
消息传输需三个调用:msg-send()向邮箱发送消息,msg_receive()接受信息。远程过程调用(RPC)通过msg_rpc() 执行,它发送消息并只等待来自发送者的一个返回消息。RPC模拟了典型的子程序过程调用,但他还能在系统之间工作——这就解释了远程。
系统调用port-allocate()创建新邮箱并为其消息队列分配空间,消息队列默认最大长度8个消息。拥有者也被允许接收来自邮箱的消息。一次只能有一个任务能拥有邮箱或从邮箱接受。
消息本身由固定的头部和可变长的数据部分组成。头部包括消息长度和两个邮箱名。消息的可变部分为具有类型的数据项的链表。
通过虚拟内存管理技术,Mach消息系统试图避免双重复制,其关键在于Mach将发送者的地址空间映射到接收者的地址空间,消息本身并不真正复制。这种消息管理技术大大提高了性能,但是只适用于系统内部的消息传递。
客户机—服务器系统通信
1.Socket
Socket(套接字)可定义通信的端点。一对通过网络通信的进程需要使用一对Socket,每个进程各一个。Socket由IP地址与一个端口号连接组成。服务器通过监听指定端口来等待客户请求。一旦收到请求,服务器就接收来自客户Socket的连接。服务器实现特定功能(如telnet、ftp、http)所有低于1024的端口被认为是众所周知的,用来实现标准服务。
客户机进程发出连接请求时,他被主机赋予一个大于1024的某个端口。
所有连接必须唯一,通过不同端口号,确保所有连接都有唯一一对Socket
2.远程过程调用
之前介绍的RPC方式许多方面类似于IPC机制 。与IPC不同,用于RPC交换的消息有很好的结构,因此不再仅仅是数据包。
端口只是一个数字,并包含在消息包的开始处,虽然一个系统通常只有一个网络地址,但他在这一地址内有许多端口号以区分所支持的多种网络服务。
RPC语义允许客户机调用位于远程主机上的过程,就如同调用本地过程一样。
许多RPC系统定义了数据的机器无关表示。一种这样的表示称为外部数据表示(XDR)。在客户机端,参数编组涉及将机器有关数据再被发送到服务器端之前编组成XDR,在服务器端,XDR转换成服务器所用的机器有关表示。
一个重要的事项是调用的语义,处理该问题的一种方法是操作系统确保一个消息刚好执行一次,而不是最多执行一次。
另一个重要事项是RPC方案要求客户机和服务器之间有类此绑定的一种关系。但客户机如何知道服务器的端口呢?一个系统无法拥有另一个系统的所有信息,因为他们无法共享内存。
第一种方法,绑定信息以固定端口地址形式预先固定。
第二种方法,绑定通过集合点机制动态的进行。通常操作系统会在一个固定的RPC端口上提供集合点服务程序。客户机程序发送一个包括PRC的名称的消息给集合点服务程序,以请求他所执行的RPC端口地址。
3.远程方法调用
远程方法调用(RMI)是一个类似的java特性。RMI允许线程调用远程对象的方法。如果对象位于不同的JVM上,那么就认为是远程的。
RMI与PRC在两方面有根本不同。
一、RPC支持子程序编程,即只能调用远程的子程序或函数。而RMI是基于对象的,他支持调用远程对象的方法。
二、RPC中,远程过程的参数是普通数据结构,RMI可以将对象作为参数传递给远程方法。
为了使远程方法对客户机和服务器透明,RMI采用存根(stub)和骨干(skeleton)实现远程对象。存根为远程对象的代理,他驻留在客户机中。当客户机调用远程方法时,远程对象的存根被调用。这种客户端存根负责创建一个包,它具有服务器上要调用方法的名称和用于该方法的编排参数。存根将该报发送给服务器,远程对象的骨干会接受他。骨干负责重新编排参数并调用服务器上要执行的方法。骨干接着编排返回值,然后打包,并将该包返回给客户机。存根重新编排返回值,并传给客户机