进程
进程概念
进程
进程是执行中的程序,这只是非正式的说法。进程不只是程序代码,程序代码称为文本段(代码段),还包括当前活动,通过程序计数器(PC)的值和处理器寄存器的内容来表示。此外,进程还包括进程堆栈段(临时数据、函数参数、局部变量、地址)和数据段(包括全全局变量。还可能包括堆(leap),是在进程运行期间动态分配内存。
程序是被动实体,如存储在磁盘上包含一系列指令的文件内容(可执行文件),而进程是一个活动实体,他有一个程序计数器来表示下一个要执行的命令和相关资源集合。
虽然两个进程可以与同一程序相关,如同一用户调用的不同web浏览器副本,但他们是独立的进程。尽管代码段相同的,但数据段、堆栈等却不同。
进程状态
进程状态:
- 新的(NEW):进程正在被创建
- 运行(RUNNING):指令正在被执行
- 等待(WAIT):进程等待某个事件的发生(如I/O完成或受到信号)
- 就绪 (READY):进程等待分配处理器
- 终止(TERMINAL):进程完成执行
一次只有一个进程可以在一个处理器上运行,但多个进程可以处于就绪或等待状态。
进程控制块(PCB)
进程在操作系统内用进程控制块(process control block,PCB)来表示,PCB包含了进程状态、程序计数器、cpu寄存器、cpu调度信息、内存管理信息、记账信息、I/O状态信息等信息。
- 进程状态(Process state): 状态可包括新的,就绪,运行,等待,终止等。
- 程序计数器(Program counter) : 计数器表示进程要执行的下个指令的地址。
- CPU寄存器(CPU registers):与程序计数器一起,这些寄存器的状态信息在出现中断时也需要保存,以便进程以后能正确的执行。
- CPU调度信息(CPU scheduling information):这类信息包括进程优先级、调度队列指针和其他调度参数。
- 内存管理信息(Memory-management information):根据内存系统,这类信息包括基址和界限寄存器的值,页表或段表。
- 记账信息(Accounting information):这类信息包括CPU时间、实际使用时间、时间界限、记账数据、作业或进程数量等。
- I/O状态信息(I/O status information):这类信息包含分配给进程的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;
新进程开始处于就绪队列,它就在就绪队列中等待直到被选中执行或被派遣。当进程分配到CPU执行时,可能发生:
- 进程发出一个IO请求,并放到IO队列中。
- 进程创建新的子进程,并等待其结束
- 进程由于中断而强制释放cpu,并被放回到就绪队列中
对于前两种情况,进程最终从等待状态切换到就绪状态,并放回到就绪队列中。进程继续这一循环直到终止,到那时它将从所有队列中删除,其PCB和资源将得以释放。
调度程序
进程会在各种调度队列之间迁移,为了调度,操作系统必须按某种方式从这些队列中选择进程。进程的选择是由相应的调度程序(scheduler)来执行的。
通常批处理系统中,进程更多的是被提交,而不是马上执行。这些进程通常放到磁盘的缓冲池里,以便以后执行。长期调度程序或作业调度程序从该池中选择进程,并装入内存以准备执行。短期调度程序或cpu调度程序从准备执行的进程中选择进程,并为之分配cpu。
这两个调度程序的主要差别是调度的频率。
短期调度程序通常100ms至少执行一次,由于每次执行之间的时间较短,短期调度程序必须要快。
Short-term scheduler is invoked very frequently (milliseconds) ? (must be fast).
长期调度程序执行的并不频繁,所以长期调度程序能使用更多的时间来选择执行进程。
Long-term scheduler is invoked very infrequently (seconds, minutes) ? (may be slow).
长期调度程序控制多道程序设计的程度(内存中进程的数量degree of multiprogramming)。长期调度程序必须仔细选择执行进程。通常,绝大多数进程可分为:
I/O-bound process – spends more time doing I/O than computations, many short CPU bursts.
CPU-bound process – spends more time doing computations; few very long CPU bursts.
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设备)来完成其任务。子进程被创建时,子进程可能直接从操作系统,也可能只从父进程那里获取资源。父进程可能必须在其子进程之间分配资源或共享资源(如内存或文件),限制子进程只能使用父进程的资源能防止创建过多的进程带来的系统超载。
在进程创建时,除了得到各种物理和逻辑资源外,初始化数据(或输入)由父进程传给子进程。
当进程创建新进程被创建时,有两种执行可能:
- 父进程与子进程并发执行
- 父进程等待,直到某个或全部子进程执行完
新进程的地址空间也有两种可能:
- 子进程是父进程的复制品(具有与父进程相同的程序和数据)。
- 子进程装入另外一个新程序
UNIX操作系统中,每个进程用唯一整数标识符来标识,通过fork()系统调用,可创建新进程,新进程通过复制原来进程的地址空间而成。这种机制允许父子进程之间方便的通信。
两个进程都继续执行位于系统调用fork()之后的指令,但是对于子进程,系统调用fork的返回值为0:而对于父进程,返回值为子进程的进程标识符(非零)。
通常系统调用fork后,一个进程会使用系统调用exec(),以用新程序来取代进程的内存空间。系统调用exec()将二进制文件装入内存(消除了原来包含系统调用exec()的程序内存映射),并开始执行。采用这种方式,两个进程都能相互通信,并按各自的方式执行。
父进程能创建更多的子进程,或者如果在子进程运行时没有什么可做,那么它采用系统调用wait()把自己移出就绪队列来等待子进程的终止。
#include <cstdio>
#include <unistd.h>
#include <syspes.h>
#include <sys/wait.h>
#include <stdlib.h>
#include <iostream>
#define debug(a) cout << #a" = " << (a) << endl;
using namespace std;
int main(int argc, char* argv[])
{
pid_t pid;
int f0 = 0, f1 = 1, tmp;
pid = fork();
if (pid < 0)
{
printf("Fork failed!\n");
return -1;
}
else if (pid == 0)
{
printf("I am the son process\n");
debug(atoi(argv[1]));
int num = atoi(argv[1]);
if (num < 1)
{
printf("Requst ilegal!\n");
return -1;
}
switch(num)
{
case 1: printf("0\n");break;
case 2: printf("0 1\n");break;
default:
{
printf("0 1");
for (int i = 3; i <= num; i++)
{
tmp = f1;
f1 = f0 + f1;
f0 = tmp;
printf(" %d", f1);
}
printf("\n");
}
}
}
else
{
printf("I am the parent process\n");
wait(NULL);
printf("I am the parent process\n");
printf("Parent process exit!\n");
}
return 0;
}
网络上找到的其他的代码,有详细解释。
当子进程完成时通过调用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-
消息传递系统
消息传递提供一种机制以允许进程不必通过共享地址空间来实现通信和同步,这在分布式系统中很有用。例如用于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个消息驻留在其中,如线路满,阻塞发送者
- 无限容量