概述
学习目标:
- 理解逻辑流、并发流基本概念
- 理解进程概念、结构与描述
- 理解进程基本状态及状态转换关系图,了解进程PCB组织,分辨进程与程序的区别与联系
- 掌握利用进程创建、程序加载、进程终止、进程撤销进行多进程并发编程基本方法
- 理解多进程并发执行特征,掌握程序并发运行的基本分析方法
- 理解信号机制与应用,掌握利用信号机制编程基本框架
- 理解守护进程概念,了解应用编程方法
- 进程基本概念(属性、结构、组织、基本状态与转换)
- 进程创建与程序加载
- 多进程并发特征
- 进程等待、终止、回收 非本地跳转与应用
- 信号机制及应用
- 守护进程编程
5.1 逻辑控制流和并发流
逻辑控制流
- 逻辑流:在多进程运行环境下,一个程序(或进程)按程序员意图,从main函数开始,一个一个语句,一条一条指令执行,执行轨迹为一系列程序计数器(PC)的值,给人一种每个程序都独占处理器的假象,而一般整个系统可能仅有一个CPU,各进程轮流执行,我们称进程为一个逻辑流
- 实际上,每个进程的执行过程是不连续的,它们交替使用处理器
- 处理器在不同进程间切换有两种原因:
- 主动放弃CPU:如进程执行耗时的I/O操作时(比如执行C语言的scanf语句)
- 进程被动放弃CPU:比如本次分配给进程的时间配额已经用完,或有紧迫程度更高的任务要执行,操作系统强行夺走CPU
- CPU控制发生转移的时机:一般都在中断响应之时,因为只有这个节骨眼操作系统能介入控制,中断有外部中断、时钟中断等
- 逻辑流示例:进程、中断(异常)处理程序、信号处理程序、线程和Java进程。

并发概念
- 日常生活中并发:日常生活中普遍存在,也是操作系统最基本的特征。
- 计算机系统中并发:体现为多任务、多进程、多线程的并发执行
- 操作系统的多任务管理能力,使我们能够很好地驾驭对并发活动的复杂管理,开发出各种功能强大的信息管理系统、网络应用、购物平台;
- 充分发挥计算机硬件系统强大的处理能力,满足信息查询、网络购物等应用需求
并发流及相关概念
- 并发流:一个逻辑流的执行在时间上与另一个流存在重叠情况,这两个流被称为并发地运行,生命周期存在重叠的2个进程是并发流,或称并发进程。
- 并发(concurrency):多个流并发地执行的一般现象
- 多任务(multitasking):一个进程和其他进程轮流运行的概念
- 时间片(time slice) :每次分配给一个进程的执行时间称之为时间片(time slice),进程也因此划分为多个时间分片(time slicing)。
- 判断方法:某种可能的执行模式在时间上存在重叠,它们就是并发流。

并发与并行
- 并发流:如果两个流在时间上重叠,那么它们就是并发的,即使它们是运行在同一个处理器上的。
- 并行流:如果两个流同一时刻运行在不同的处理器核或者计算机上,那么我们称它们为并行流(parallel flow),并行流在某段时间内同时执行。
- 并行流是并发流的一个真子集。

5.2 进程的概念
5.2.1 进程概念、结构与描述
进程
- 不严格的定义:进程是正在执行中的程序
- 较为严格的定义:进程是程序在一个独立数据集上的执行过程。
- 示例: Shell窗口、Linux 命令执行过程
- Linux系统进程家庭: Linux允许多个用户同时登录系统。每个用户可以同时运行许多个程序,或者同时运行同一个程序的许多个实例,每个程序运行实例都是一个进程。系统本身也运行着一些管理系统资源和控制用户访问的程序
进程结构
作为程序执行过程的进程,至少需包含三项内容:程序代码、数据集和进程控制块(PCB, Process Control Block)
- 程序代码:一般是一个包括main函数的可执行程序,程序装载到内存,进程才能启动
- 数据集:进程的处理对象,可认为是变量内容,保存初始化信息、环境变量、命令行参数和文件的数据
- PCB:保存程序代码、数据变量地址、进程其他属性,PCB是进程存在的唯一标志,以后操作系统就通过PCB来对进程实施管理和控制

进程属性(保持在PCB中)
(1)进程描述信息:
- 进程号(PID)
- 用户标识
- 用户组标识
- 进程族亲信息:父进程标识、兄弟进程标识
(2)进程控制信息
- 进程状态
- 调度信息:优先级、剩余时间片和调度策略
- 记时信息:CPU使用时间等
- 通信信息:未处理信号、管道、信号量、消息队列、共享内存等
(3)进程资源信息
(4)CPU现场信息
- 当前进程CPU寄存器副本:程序计数器PC、通用寄存器、
- 标识寄存器FLAGS
PCB实例(Linux任务结构体:task_struct)

5.2.2 进程的基本状态及状态转换
一般根据CPU对资源拥有情况,定义三种基本状态:
- 就绪状态(ready state)。进程已分配到除CPU以外的所有必要资源,只要获得CPU,便可立即执行,该状态为就绪状态
- 运行状态(也称执行状态, running state):进程已包括CPU在内的所有运行所需资源,正在执行中
- 阻塞状态(blocked state):正在执行的进程因请求资源、等待事件发生、等待I/O等原因,而暂时无法继续执行时,进入阻塞状态
实际操作系统设计中,往往还需增加2个状态:
- 创建状态:正在创建且尚未完成创建过程的进程(处于胎儿期)所处的状态称为创建状态
- 终止状态:进程终止后并不立即将其清理,而是让其进入终止状态。

5.2.3 对进程PCB进行组织
Linux系统以双向链表、树形链表等多种形式进行组织:
(1)双向链表队列
- 根据进程状态将进程PCB按双向链表组织成多个双向链表,
- Linux用prev_task和next_task两个指针来构建进程队列
- 可设置一个就绪队列和多个阻塞队列,每种等待事件设置一个等待队列
- 好处:方便快速取得队列中第一个进程PCB

双向链表+树形结构
按族亲关系组成双向链表+树形结构:
- 树形结构为父子关系,父节点为父进程,子节点为子进程,用p_pptr、p_cptr两个指针管理
- 子进程间构成双向链表结构:用p_osptr和p_ysptr两个指针维护

5.2.4 进程实例
在两个窗口运行pro1.c,然后在另一个窗口显示进程信息


5.2.5 操作进程的工具
ps命令查看进程信息
(1)显示全部进程信息:ps -ef

(2)从ps命令中过滤出指定进程信息:ps -ef | grep bash

(3) ps -u命令显示当前用户拥有进程资源消耗信息

(4)ps l命令显示当前用户拥有进程的进程信息

各列含义:
- UID:用户ID
- PID:进程唯一编号
- PPID:父进程ID
- PRI:进程优先级
- STAT:进程状态
- TTY:进程启动端口
- COMMAND:进程是通过启动哪个命令产生的
用kill终止进程

进程被kill后,其状态发生何种变化?
答:kill之后终端1会自动关闭
后台执行进程(命令后加符号”&”)
好处:输入命令串 后,立即显示命令提示符,前面命令的执行和下一条命令的输入可并发执行

5.2.6 编程读取进程属性
应用程序常常需要读取进程标识PID、父进程标识PPID、用户标识UID、组标识GID等信息。
Linux提供了getpid、getppid系统调用函数,用于进程获取自身与父进程的PID,函数原型如下:
#include
#include
pid_t getpid(void); //返回当前进程PID
pid_t getppid(void); //返回父进程PPID
其他系统函数,获取相关信息
#include
#include
uid_t getuid(void); //返回当前进程实际用户ID(UID),
//启动进程用户ID
uid_t geteuid(void); //返回实际有效用户ID,EUID
//进程对文件实施某种操作时,
//如果用户EUID有该权限,系统就授权
gid_t getgid(void); //返回当前进程实际用户组ID,GID
//启动进程用户组ID,GID
uid_t getegid(void); //返回实际有效用户组ID,EGID
//进程对文件实施某种操作时,
//若EUID对文件无某种操作权限
//EGID有组权限,也可授权
实例:


5.3 进程控制
5.3.1 创建进程
- Linux系统启动时,生成一个名为init的进程,PID为1,称为初始化进程,是其他所有进程的祖先进程,其他进程都是由init进程或其子孙进程创建的
- 创建新进程归根结底是通过父进程执行fork系统调用函数来实现的,fork函数的作用是复制进程
- 一般父进程先调用fork函数复制出子进程,再让子进程调用exec系统来加载不同的程序代码
- 子进程也创建自己的子进程,最终创建一个丰富多彩的进程世界,形成一棵以init进程为祖先的进程树
创建进程
#include
#include
pid_t fork(void);
//成功子进程返回0,父进程返回PID;失败返回-1
示例1:


fork语句复制一个与父进程一样的代码,父进程执行pid>0分支,子进程执行pid==0分支。父子进程是两个独立的进程实体,互不相关,并发执行。
多进程并发
要编写多进程并发,首先确定要创建几个进程,亲族关系如何,画出框架图,然后填写代码。
示例:



5.3.2 多进程并发特性与执行流程分析
示例


使用 fork语句创建的子进程是一个独立的进程实体,父子进程的多并发活动可以交错进行、同时进行或错开进行。
5.3.3 进程的终止与回收
进程终止
1. 进程终止方式
一个进程完成其处理任务或非正常结束时,会归还分配给其程序代码与数据变量的存储器资源及所有其他资源,包括进程控制块占用的存储器。进程有正常终止和异常终止两种方式:
- 正常终止:正常终止有几种情况 完成main函数执行 在main函数执行return返回 执行exit函数调用而结束,一般为exit(0)
- 异常终止:也有几种方式 执行abort函数调用 按Ctrl_C键 程序执行出错:如被0除、存储器访问越界、数学运算溢出 被信号而终止:如收到kill命令发出的信号
2. 进程终止状态

#include
void exit (int status); //若进程因执行exit()正常终止,则其终止状态为status
void abort (void); //终止状态为06,人为规定,见表5-1 128+6=134
- 通过终端命令获取刚结束进程终止状态(通过环境变量 $?)


进程僵尸问题
1. 进程终止后的状态
- 进程终止后,大部分资源都已归还给系统,但仍占用进程PID,保留其进程控制块PCB,其中包含退出状态和一些其他对父进程有用的信息,等待父进程读取和处理
- 我们称已经执行结束、但PCB仍存在的进程为僵尸进程,僵尸进程虽然有PCB但已经不可能再次运行了,因为它仅剩下一个躯壳PCB,已经没有灵魂(代码、数据)了
- 子进程终止时,会向父进程发送一个通知信号,父进程可执行函数调用waitpid()来读取子进程退出状态和其他信息,并彻底销毁子进程,归还其PCB
- 进程状态为Z或者的进程为僵尸进程
2. 僵尸进程示例(zombie.c):子进程已经终止但父进程尚未执行waitpid对其执行清理


进程回收(清理)
1、回收函数
#include
#include
pid_t waitpid (pid_t pid, int *status , int options);
//返回:如果成功,则为子进程的PID,如果出错,则为-1..
pid_t wait (int *status);
//返回:如果成功,则为子进程的PID. 如果出错,则为-1.
2. 如何从waitpid获取的status判断进程退出方式
- WIFEXITED(status):如果子进程通过调用exit或者一个返回(return)正常终止,就返回真
- WIFSIGNALED(status):如果子进程是因为一个信号终止的,那么就返回真
3. 如何读取进程退出状态
WEXITSTATUS(status):返回一个正常终止的子进程的退出状态。只有在WIFEXITED返回为真时,才会定义这个状态
4、示例


5.3.4 让进程休眠
主要函数
主要函数:
#include
unsigned int sleep(unsigned int secs); //睡眠若干秒
void usleep(unsigned int usecs); //睡眠若干微秒
int pause(void); //无限期休眠
int scanf(…); //等待输入
int printf(…); //等待输出完成
5.3.5 加载并运行程序
加载函数
#include
int execve(const char *filename, const char *argv[] , const char *envp[] ) ;
int execvp(const char *filename, const char *argv[]) ;
int execlp(const char * file,const char * arg,....);
//成功不返回,错误返回-1
参数列表argv、环境变量列表组织结构

main函数执行时堆栈结构
为什么形式参数argv、envp是局部变量,因为在堆栈中为其分配地址

操作环境变量的函数
#include
char *getenv (const char *name); //读取环境变量值
//返回:若存在则为指向name的指针,若无匹配的,则为NULL.
int setenv (const char *name, const char *newvalue, int overwrite);
//更改环境变量
//返回:若成功则为0,若错误则为-1.
void unsetenv (const char *name); //取消环境变量
示例:

5.3.6 fork和exec函数的应用实例
用fork、exec实现shell (shellex.c)
要求:
- (1)外壳打印一个命令行提示符>
- (2) 等待用户在stdin上输入命令行,然后解析和执行命令
- (3)支持一条内置命令----quit命令,用于终止shell进程
示例:




实现IO重定向


5.3.7 进程与程序的区别
- (1)程序是永存的,作为源代码或目标模块存在于外存;进程是暂时的,是程序在数据集上的一次执行,有创建有撤销,存在是暂时的。
- (2)程序是静态的,关机后仍然存在,进程是动态的,有从产生到消亡的生命周期。
- (3)进程具有并发性,而程序没有。
- (4)进程和程序不是一一对应的:一个程序可对应多个进程即多个进程可执行同一程序;一个进程可以执行一个或几个程序。
5.4 信号机制
5.4.1 信号概念
CPU处理突发事件的机制—中断机制
在CPU执行程序的过程中,出现了某种紧急或异常的事件(如网卡数据到达、电源掉电、运算溢出), CPU将暂停正在执行的程序,转去(执行中断服务程序处理该事件,并在处理完毕后返回断点处继续执行被暂停的程序,这一过程称为中断,CPU有专门用于处理中断的硬件单元,称为中断机制

进程处理突发事件的机制--信号机制
1. 信号概念和种类
- 信号机制是在进程层面上对CPU中断机制的一种模拟,一个信号就是一条小消息,它通知进程系统中发生了一件与该进程相关某种事件。
- 这些事件可能来自于用户操作、内核、本进程或其它进程。
- 每种信号用一个1~31或1~63的整数表示,未处理的信号用一个32位或64位整型变量记录,每种信号对应其中一个二进制位。
- 在终端窗口输入“man 7 signal”就能查得该系统支持的所有信号名称及及其编号
- 进程收到某个信号后,都需执行某种操作(或某个程序)对其进行处理,缺省处理方式一般有忽略和终止进程两种,一般收到表示致命错的信号,进程都会显示相关描述信息而终止。
- 用户可设置信号处理函数,按定制方式进行信号处理。
2. Linux系统支持的信号种类

5.4.2 信号术语
信号传递有两个步骤
(1)发送信号:内核通过更新目的进程上下文中的某个状态,发送(递送)一个信号给目的进程。发送信号的原因有:
- 内核检测到一个系统事件,比如被零除错误或者子进程终止
- 一个进程调用kill函数(下一节讨论)给目的进程发信号
- 进程也可通过调用raise、alarm等函数给自己发信号
(2)接收信号:当目的进程被内核强迫以某种方式对信号的发送做出反应时,目的进程就接收了信号。
- 进程可以忽略这个信号,终止或者通过执行一个称为信号处理程序(signal handler)的用户层函数捕获这个信号
- 图5-23给出了信号处理程序捕获信号的基本思想

信号记录与术语
- 待处理信号(pending signal):一个已经发出但没有被接收的信号叫做待处理信号
- 由于传统上,所有待处理信号一般用一个整数变量表示,每种信号用其中一位表示
- 在任何时刻,一种类型至多只有一个待处理信号,如果前面有未处理的类型为k的信号,则其后类型为k的未处理信号会被丢弃,而不是进行信号排队
- 信号阻塞:一个进程可以有选择性地阻塞接收某种信号
- 当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞
- 内核为每个进程在pending位向量(整数变量)中维护着待处理信号的集合,而在blocked位向量(整数变量)中维护着被阻塞的信号集合,这两个字段都定义在task_struct结构体中
- 只要传送了一个类型为k的信号,内核就会设置pending中的第k位,而只要接收了一个类型为k的信号,内核就会清除pending中的第k位
- 调用信号处理程序称为捕获信号。执行信号处理程序称为处理信号
5.4.3 发送信号的过程
Unix系统提供了多种向进程发送信号的机制。所有这些机制都是基于进程组(process group)这个概念的
进程组概念
- 每个进程都只属于一个进程组,进程组是由一个正整数进程组ID来标识的。
- 进程组是一个或多个进程的集合,通常它们与一组作业相关联,可以接受来自同一终端的各种信号
- 一个进程调用”pid_t getpgrp(void)”函数返回当前进程的进程组ID
- 调用”int setpgid(pid_t pid, pid_t pgid)”函数来改变自己或者其他进程的进程组
- 如果pid是0,那么就使用当前进程的PID
- 如果pgid是0,那么就用pid指定的进程的PID作为进程组ID
- 示例:”setpgid(0, 0);”会创建一个新的进程组,其进程组ID是15213,并且把进程15213加入到这个新的进程组中
- 外壳为每个作业创建一个独立的进程组,一般来说,进程组ID是作业中父进程PID。
- 进程组示例:图5-24展示了有一个前台作业和两个后台作业的外壳。前台作业中的父进程PID为20,进程组ID也为20。父进程创建两个子进程,每个也都是进程组20的成员。

用/bin/kill程序发送信号
- /bin/kill程序可以向另外的进程发送任意的信号
- 示例: “$ ki11 -9 15213” 发送信号9 (SIGKILLL)给进程15213
从键盘发送信号
- 键入ctrl-c:发送SIGINT信号发送到这个前台进程组中的每个进程
- 键入ctrl-z:发送一个SIGTSTP信号到外壳,外壳捕获这个信号,并发送SIGTSTP信号给前台进程组中的每个进程
用kill和raise函数发送信号
函数原型
#include
#include
int kill(pid_t pid, int sig);
int raise(int sig);
//返回值;若成功,则为0;若失败,则为-1.
进程通过调用kill函数发送信号给其他进程(包括它们自己): int kill(pid_t pid, int sig);通过调用raise函数向自己发生信号。
用alarm函数发送信号
- 进程可以通过调用alarm函数向它自己发送SIGALRM信号
- alarm函数安排内核在secs秒内发送一个SIGALRM信号给调用进程,相当于给进程设置一个闹钟
- alarm函数调用都将取消任何待处理闹钟,返回待处理的闹钟在被发送前还剩下的秒数。
5.4.4 接收信号的过程
- 当内核从一个异常处理程序返回,准备将控制传递给进程p时,它会检查进程p的未被阻塞的待处理信号的集合(pending&-blocked)。
- 如果这个集合为空(通常情况下),那么内核将控制传递到p的逻辑控制流中下一条指令(Inext)
- 如果集合是非空的,那么内核选择集合中的某个信号k(通常是最小的k),并且强制p接收信号k,执行信号处理
- 一旦进程完成了这个行为,那么控制就传递回p的逻辑控制流中下一条指令(Inext)
- 每种信号都有一种缺省处理方式:进程终止、进程忽略该信号、进程终止并转储存储器、进程停止直到被SIGCONT信号重启
- 如:收到SIGKILL的默认行为就是终止接收进程
- 接收到SIGCHLD的默认行为就是忽略这个信号
- 进程可以通过使用signal函数修改和信号相关联的默认行为:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
- 如果handler是SIG_IGN,那么忽略类型为signum的信号
- 如果handler是SIG_DFL,那么类型为signum的信号恢复为默认行为
- 否则,handler就是用户定义的函数的地址
若调用成功,则为指向前次处理程序的指针
示例:


5.4.5 信号处理问题
对于只捕获一个信号并终止的程序来说,信号处理是简单直接的。然而,当一个程序要捕获多个信号时,会产生一些微妙的问题。
Unix信号处理程序通常会阻塞相同类型待处理信号。比如,假设一个进程捕获了一个SIGINT信号后,正在执行SIGINT处理程序。如果另一个SIGINT信号传递到进程,这个SIGINT将变成待处理,而不会被接收,直到处理程序返回。 待处理信号不会排队等待。每个类型至多只有一个待处理信号。因此,如果有两个类型为k的信号传递到某个进程,而该进程正在执行信号k的处理程序,则信号k是阻塞的,后续的信号k会被简单丢弃,而不会排队等待。
像read、wait和accept这样的系统调用潜在地会阻塞进程一段较长的时间,称为慢速系统调用。在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR。
示例一(signal1.c):父进程利用SIGCHLD来收割子进程



问题:
- 子进程向父进程发送了三个SIGCHLD信号,但父进程仅接收两个,子进程627没有被回收,成为僵死进程
- 原因是父进程在处理一个SIGCHLD信号时,收到第二、第三个信号,第二个信号被阻塞,第三个信号被丢弃
改进方法一:SIGCHLD处理程序每次被调用时,尽可能回收更多的僵死子进程(signal2.c)



改进方法二:手动地重启被终止的read调用(signal3.c)



5.4.6 可移植信号处理
不同系统之间,信号处理语义的差异(比如一个被中断的慢速系统调用是重启还是永久放弃)是Unix信号处理的一个缺陷。为了处理这个问题,Posix标准定义了sigaction函数,它允许像Linux和Solaris这样与Posix兼容的系统上的用户,明确地指定他们想要的信号处理语义
#include
int sigaction(int signum, struct sigaction *act, struct sigaction *oldact);
使用sigaction函数,只有这个处理程序当前正在处理的那种类型的信号被阻塞,信号不会排队等待,但允许被中断的系统调用会自动重启 sigAction使用比较复杂,要求用户设置多个结构条目, W. Richard Stevens编写了一个包装函数Signal,方便我们使用:

回收子进程改进方法四:使用Signal函数安装信号处理函数 (signal4.c)



5.5 守护进程
- 在Linux或者UNIX操作系统中,当系统引导的时候,会开启很多服务为用户提供某种功能,这些服务就叫做守护进程或 Daemon进程,例如ftp服务、计划任务进程crond、http进程httpd
- 守护进程是脱离于终端并且在后台运行的进程,避免进程在执行过程中产生的信息在任何终端上显示
- 守护进程也不会被任何终端所产生的信息所打断
创建守护进程的编程步骤
- 1.调用fork()创建新的进程作为守护,父进程调用exit()终止,让守护进程后台继续执行: if(pid=fork()) exit(0);
- 2.脱离控制终端、登录会话和进程组,使自己成为会话组长: setsid();
- 3. 关闭打开的文件描述符,归还系统资源 for (i = 0; i < NR_OPEN; i++) close (i);
- 4. 改变当前工作目录,不要指向特定用户目录 chdir (”/”);
- 5. 处理文件描述符0、1、2 重定向到/dev/null
守护程序自动初始化函数
- 许多UNIX系统提供了C库函数daemon()来自动完成守护程序初始化工作,从而简化一下繁杂的工作: #include int daemon(int nochdir, int noclose);
- 若参数nochdir为非0值,就不会将工作目录更改为根目录,如果参数noclose为非0值,就不会关闭所有打开的文件描述符,通常这些参数设置为0。函数执行成功时返回0,失败返回-1,并将errno设置为错误码。
5.6 进程、内核、系统调用间的关系
