20、进程编程基础
进程的基本概念
进程是操作系统设计的核心概念,Multics的设计者在20世纪60年代首次使用了"进程"这个术语,比作业更通用一点。目前存在很多关于进程的定义,例如:
1、一个正在执行的程序。
2、计算机中正在运行的程序的一个实例。
3、可以分配给处理器并由处理器执行的一个实体。
4.由单一的顺序的执行线程、一个当前状态和一组相关的系统资源所描述的活动单元。
也可以把进程是为由一组元素组成的实体,进程的两个基本元素是程序代码和代码相关联的数据集。假设处理器开始执行程序代码,那我们把这个执行的实体成为进程。进程执行时,任意给定一个时间,进程都可以用以下元素为唯一表征:
- 标识符:跟这个进程相关地唯一标识符,用来区别其他进程
- 状态:如果进程正在执行,那么进程处于运行态
- 优先级:相对于其他进程的优先级
- 程序计数器:程序中即将被执行的下一条指令的地址
- 内存地址:包括程序代码和进程相关数据的指针,还有和其他进程共享内存块的指针
- 上下文数据:进程执行时处理器的寄存器中的数据
- I/O状态信息:包括显式的I/O请求、分配给进程的I/O设备和被进程使用的文件列表
- 记账信息
上述列表信息被存放在一个称为进程控制块的数据结构中,该控制块由操作系统创建和管理。
比较有意义的一点是,进程控制块包含充分的信息,这样当进程被中断时,操作系统会把程序计数器和处理器寄存器(上下文数据)保存到进程控制块中的相应位置,进程状态也被改变为其他值,例如阻塞态或就绪态。现在操作系统可以自由地把其他进程置为运行态,把其他进程的程序计数器和进程上下文数据加载到处理器寄存器,这样其他进程就可以开始执行。
为什么设计了进程?
设计出一个能够协调各种不同活动的系统软件是非常困难的。在任何时刻都有许多作业在运行中,每个作业都包括要求按照顺序执行的很多步骤,因此分析时间的序列组合是不可能的。由于缺乏能够在所有活动中进行协调和合作的系统级的方法,程序员只能基于他们对操作系统所控制的环境的理解,采用自己的特殊方法。然而这种方法是很脆弱的,尤其对于一些程序设计中的小错误,因为这些错误只有在很少见的时间序列发生时才会出现。由于需要从应用程序软件错误和硬件错误中区分出这些错误,因而诊断工作是很困难的。及时检测出错误,也很难确定原因,因为很难在线错误产生的精确场景。一般而言,产生这类错误的4个主要原因如下:
1.不正确同步
2.失败互斥。
3.不确定的程序操作
4.死锁
解决这些问题需要一种系统级别的方法监控处理器中不同程序的执行。进程的概念为此提供了基础。 因此进程可以看做是由三部分组成的:
1.一段可以执行的程序
2.程序所需要的相关数据
3.程序的执行上下文
执行上下文是进程的重重之中。执行上下文(execution context)
又称作进程状态(process state)
,是操作系统用来管理和控制进程所需的内部数据。这种内部信息和进程是分开的,因为操作系统信息不允许被进程直接访问。上下文包括操作系统管理进程以及处理器正确执行进程所需要的所有信息。包括了各种处理器寄存器的内容,如程序计数器和数据寄存器。它还包括操作系统使用的信息,如进程优先级以及进程是否在等待特定 I/O事件的完成。
图中两个进程A 和B ,存在于内存中某部分。也就是说给每个进程(包含程序、数据和上下文信息)分配一块存储器区域,并且在由操作系统建立和维护的进程表中进行记录。进程表中包含记录每个进程的表现,表项内容包括指向包含进程的存储块地址的指针,还包括该进程的部分或全部执行上下文。指向上下文的其余部分存放在别处,可能和进程自己保存在一起,通常也可能保存在内存里一块独立的区域中。进程索引寄存器(process index register)
包含当前正在控制处理器的进程在进程表中的索引。程序计数器
指向该进程中下一条待执行的指令。基址寄存器(base register)
和界限寄存器(limit register)
定义了该进程所占据的存储器区域:基址寄存器
中保存了该存储器区域的开始地址,界限寄存器
中保存了该区域的大小(以字节或字为单位)。程序计数器和所有的数据引用相对于基址寄存器
被解释,并且不能超过界限寄存器
中的值,这就可以保护内部进程间不会相互干涉。(解决了互斥的问题)
图中进程索引寄存器
表明进程B正在执行。以前执行的进程被临时中断,在A中断的同时,所有的寄存器的内容被记录在它执行上下文环境中,以后操作系统就可以执行进程切换,恢复进程A的执行。进程切换过程包括保存B的上下文和恢复A的上下文。当在程序计数器
中载入指向A的程序区域的值时,进程A自动恢复执行。
因此进程被当做数据结构来实现。一个进程可以是正在执行,也可是等待执行。任何时候整个进程状态都包含在它的执行上下文环境中。这个结构使得可以开发功能强大的技术,以确保在进程中进行协调和合作。在操作系统中可能会设计和并入一些新的功能(优先级,linux中nice值。)这可以通过扩展上下文环境以包括支持这些特征的新信息。
两状态
进程模型
两状态
进程模型操作系统的基本职责是控制进程的执行。这包括确定交替执行的方式和给进程分配资源在设计控制进程的程序时,第一步就是描述进程所表现出的行为。
在任何时刻,一个进程要么正在执行,要么没有执行,因而可以构造最简单的模型。一个进程可以处于以下两种状态之一:运行态或未运行态。当操作系统创建一个新的进程时,它将该进程运行态加入到系统中,操作系统知道这个进程是存在的,并且正在等待执行机会。当前正在运行的进程时不时的被中断,操作系统中的分派器
部分将选择一个新进程运行。前一个进程从运行态转换到未运行状态,另外一个集成转换到运行态。
从这个简单的模型可以意识到操作系统的一些设计元素。必须用某种方式来表示每个进程,使得操作系统能够跟踪它,也就是说,必须有一些与进程相关的信息,包括进程在内存中的当前状态和位置,即进程控制块
。未运行的进程必须保持在某种类型的队列中,并等待它们的执行时机。结构中有一个队列,队列中的每一项都指向某个特定进程的指针,或队列可以由数据块构成的链表组成,每个数据块表示一个进程。
因此可以用该队列图描述分派器
的行为。被中断的进程转移到等待进程队列中,或者,如果进程已经结束或取消,则被销毁(离开系统)。在任何一种情况下,分派器
均从队列中选择一个进程来执行。
五状态模型
如果所有的进程都做好了执行准备。队列是先进先出(first-in-first-out)
的表,对于可运行的进程处理器以一种轮转(round-robin)
方式操作(依次给队列中的每个进程一定的执行时间,然后进程返回队列,阻塞情况除外)。但是存在着一些非运行状态但已经就绪等待执行的进程,而同时存在另外的一些处于阻塞状态等待I/O操作结束的进程。因此,如果使用单个队列,分派器不能只考虑队列中最老的进程,相反,他应该扫描这个列表,查找那些被阻塞且在队列中时间最长的进程。解决这种情况的一种比较自然的方法是将非运行状态分成两个状态:就绪(ready)
和阻塞(blocked)
,此外应该增加两个已经证明很有用的状态。
五状态模型中的五种状态具体含义如下:
运行态:进程正在被执行(本章中,假设计算机只有一个处理器,因此一次最多只有一个进程处于这个状态)。
就绪态:进程做好了准备,只要有机会就可运行。
阻塞态:进程在某些事件发生前不能执行,例如IO操作完成。
新建态:刚刚创建的进程,操作系统还没有把它加入到可执行进程组中,通常是进程块已经创建但还没有加载到内存中的新进程。
退出态:操作系统从可执行进程组中释放出的进程,或者因为它自身停止了,或者是因为某种原因被取消。
新建态和退出态对进程管理是非常有用的。新建态对于刚刚定义的进程,例如,如果一位新用户试图登录到分时系统中,或者新的一批作业被提交执行,那么操作系统可以分两步定义新进程:首先,操作系统执行一些必须的辅助工作,将标识符关联到进程中,分配和创建管理进程所需要的所有表,此时,进程处于新建态,这意味着操作系统已经执行了创建进程的必要动作,但还没有执行进程;其次,操作系统将所需要的关于该进程的信息保存到内存的进程表中,但进程自身还未进入内存,就是说即将执行的程序代码不在内存中,也没有为这个程序相关的数据分配空间。当进程处于新建态时,程序保留在外存中,通常是磁盘中。
进程退出也分为两步:(1)当进程到达一个自然结束点,由于出现不可恢复的错误而被取消时,或当具有相应权限的另一个进程取消该进程时,进程被终止。终止的进程转换到退出态,此时程序不再被执行,与程序相关的信息被操作系统临时保留起来,这给辅助程序或支持程序提供了获取所需信息的时间。(2)为了分析性能和利用率,一个实用程序可能需要提取进程的历史信息,一旦这些程序提取了所需要的信息,操作系统就不再需要任何于该进程相关的数据,该进程将从系统中被删掉。
进程状态切换
导致进程状态转换的事件类型有很多,其中常见的有以下8种。
1、空——>新建
通常有四个事情会导致一个进程的创建:
- 批处理环境中,响应作业提交时会创建进程。
- 交互环境中,当一个新用户试图登录时会创建进程。
- 现有进程派生新进程。基于模块话的考虑,或者为了开发并行性,用户程序可以指示创建多个进程。
- 操作系统代表应用程序创建进程,例如当用户请求打印一个文件时,操作系统会创建一个管理打印的进程,进而请求进程可以继续执行,与完成打印任务的时间无关。
2、新建——>就绪
操作系统尊卑在接纳一个进程时,把一个进程从新建态转换到就绪态。大多数系统会基于现有的进程数或分配给现有进程的虚拟数量设置一些限制,以确保不会因为活跃的进程过多而导致系统性能下降。
3、就绪——>运行
需要选择一个新进程运行时,操作系统会选择一个处于就绪态的进程,这是调度器或分派器的工作。
4、运行——>就绪
运行态转换为就绪态最常见的原因是:正在运行的进程到达了“允许不中断执行"的最大时间段,实际上所有多道程序操作系统都实行了这类时间限定。这类转换还有很多其他原因,例如操作系统给不同进程分配不同的优先级,优先级高的进程抢占优先级低的进程,但这不试用于所有操作系统。还有一种情况是:进程自愿释放对处理器的控制,例如一个周期行的进行记账和维护的后台进程。
5、运行——>阻塞
如果操作系统被进程请求必须等待某些事件,那么该进程则进入阻塞态。对操作系统的请求通常以系统调用的形式发出。例如:进程可能请求操作系统的一个服务,但操作系统无法立即予以服务,即请求一个无法立即得到的资源,如文件或虚拟内存中的共享区域;或者也可能需要进行某种初始化的工作,如IO操作所遇到的情况,并且只有在该初始化完成后才能继续执行。当进程相互通信,一个进程等待另一个进程提供输入时,或者等待来自另一个进程提供输入时,或者等待来自另一个进程的信息时,进程都可能被阻塞。
6、阻塞——>就绪
当所等待的事情发生后,处于阻塞态的进程转换到就绪态。
7、就绪——>退出
为了清楚,图中并未表示这种转换。在某些系统中,父进程可以在任何时刻终止一个子进程。如果一个父进程终止,那么与该父进程相关的所有子进程都将被终止。
8、阻塞——>退出
阻塞态转换为退出态于就绪态转换为退出态类似。
进程启动
从磁盘加载到Linux系统内存中并被执行,一个程序大致经过7个阶段,如图所示:
C函数总是从函数main()开始执行,其函数原型如下:
int main(int argc,char *argv[]);
其中,argc是命令行参数的数目;参数argv是指向命令行参数构成的数组。
当内核执行C程序是,在调用函数main()之前会先调用一个特殊的启动例程。可执行程序文件将此启动例程制定为程序的起始地址——这是由连接器设置的,而连接器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为调用main()做好准备。
进程终止
终止进程的方式有8种,其中5中为正常终止:
(1)从函数main()主动返回;
(2)调用函数exit();
(3)调用函数_exit()或Exit();
(4)最后一个线程从启动例程返回;
(5)最后一个线程调用函数pthread_exit()。
异常终止有3种:
(1)调用函数abort()。
(2)接到一个信号。
(3)最后一个线程对线程取消做出响应。
函数_exit()和_Exit()立即进入内核;函数exit()则先进行一些清理,然后返回内核。其函数原型如下:
#include<unistd.h> void _exit(int status); #inlcude <stdlib.h> void _Exit(int status); void exit(int status);
由于历史原因,函数exit()总是执行一个标准IO的清理关闭操作,对于所有打开流调用函数fclose(),这造成输出缓冲区中的所有数据都被冲洗(写入到文件中)。
这3个推出函数都带一个整型参数,称为终止状态。Linux系统Shell可以检查进程终止的状态。如果调用函数时不带终止状态,或函数main()执行了一个无返回值的return语句,或函数main()没有声明返回类型为整型,并且函数main()执行到最后一条语句时返回(隐式返回),那么该进程的终止状态为0。
进程的内核空间
程序在内存中大致分为5个部分,如下图所示:
可以看到一个进程地址空间的主要成分为:
-
正文:这是整个用户空间的最低地址部分,存放的是指令(也就是程序所编译成的可执行机器码),通常,正文段是可共享的,所以即使是频繁执行的程序在存储器中也只需要一个副本。此外,程序端通常是只读的,以防止程序意外修改其指令。
-
已初始化数据段:这里存放的是初始化过的全局变量
-
未初始化数据段:此段又称为BSS段,这里存放的是未初始化的全局变量,在程序开始执行之前,内核将此段数据初始化为0或空指针。
-
堆(heap):堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)
-
栈:栈是用户存放程序临时创建的局部变量,也就是说我们函数括弧“{}”中定义的变量(但不包括static声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进先出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
-
命令行参数和环境变量:用户调用的最底层。
下面以一个实例完成对内存的探究:
#include<stdio.h> #include<malloc.h> #include<unistd.h> int bss_var;//未初始化变量 int data_var0=1;//初始化变量 int main(int argc,char **argv) { printf("below are addresses of types of process's mem\n"); printf("Text location:\n"); printf("\tAddress of main(Code Segment):%p\n",main);//获取main的地址 printf("____________________________\n"); int stack_var0=2;//栈变量 printf("Stack Location:\n"); printf("\tInitial end of stack:%p\n",&stack_var0); int stack_var1=3; printf("\tnew end of stack:%p\n",&stack_var1); printf("____________________________\n"); printf("Data Location:\n"); printf("\tAddress of data_var(Data Segment):%p\n",&data_var0);//初始化数据段地址 static int data_var1=4; printf("\tNew end of data_var(Data Segment):%p\n",&data_var1); printf("____________________________\n"); printf("BSS Location:\n"); printf("\tAddress of bss_var:%p\n",&bss_var);//未初始化数据段地址 printf("____________________________\n"); char *b = sbrk((ptrdiff_t)0);//保存在堆区的内存指针 printf("Heap Location:\n"); printf("\tInitial end of heap:%p\n",b); brk(b+4);//brk通过传递的addr来重新设置program break,brk和sbrk主要的工作是实现虚拟内存到内存的映射. b=sbrk((ptrdiff_t)0); //而sbrk用来增加heap,增加的大小通过参数increment决定,返回增加大小前的heap的program break,如果increment为0则返回program break。 printf("\tNew end of heap:%p\n",b); return 0; }
编译运行结果如下: