第二章 进程管理-操作系统原理和实践

上章回顾

在前一节的内容中我们讨论了操作系统的的一些相关内容,

包括:OS的目标和作业、OS的发展过程、OS的基本特性、OS的主要功能和OS的结构设计

其中需要特别强调的是OS的四大基本特征(并发性、共享性、虚拟技术、异步性)

1)并发性 -- 并发与并行

并行性是指多个事件在同一时刻同时发生,并发性是指多个事件在同一时间间隔内发生。

在多道程序环境下,并发性指在一段时间内宏观上有多个程序在同时运行,

在单处理机环境上同一时刻只能运行一道程序,所有微观上这些程序都是分时地交替执行。

如果有多个处理机这程序可以分配到不同的处理机上同时执行,

每个程序执行一个可并发执行的程序。实现并行。

程序是静态实体,不能独立运行,所以OS引入进程来实现程序并发执行。

2)共享性 --  系统中的资源可以供内存中多个并发执行的进程(或线程)共同使用。

对于资源复用的方式不同,主要以两种方式实现资源共享:互斥共享方式、同时访问方式。

并发和共享是OS的两个最基本的特征,互为存在条件。

3)虚拟技术 – 通过某种技术把一个物理实体变为若干个逻辑上的对应物。

两种方式:时分复用(虚拟处理机技术、虚拟设备技术)、

空分复用技术(虚拟磁盘、虚拟存储器技术)

4)异步性 --

通过上面的讨论,我们知道,在传统的操作系统中,程序并不能独立地运行,作为资源分配和独立运行的基本单位是进程,

操作系统的四大特征(并发性、共享性、虚拟技术、异步性)都是基于进程而形成的,我们可以从进程的观点来研究操作系统。

在操作系统中,进程是一个极其重要的概念。下面,我们就一起来讨论一下进程。

2.1 前趋图和程序执行:顺序和并发

2.1.1   前趋图  :有向无循环图

前趋图(Precedence  Graph)是一个有向无循环图 (DAG: Directed Acyclic Graph),

用于描述进程之间执行的前后关系。

前趋图中必须不存在循环,但在图2-2(b)中却有着下述的前趋关系:

S2→S3,S3→S2

显然,这种前趋关系是不可能满足的。

图2一2(a)所示的前趋图,关系:

P1→P2,P1→P3,P1→P4,P2→P5,P3→P5,P4→P6,P4→P7,P5→P8,P6 →P8,P7→P9,P8→P9

或表示为二元关系< P, → >

P={P1,P2,P3,P4,P5,P6,P7,P8,P9 }

→= { (P1,P2), (P1,P3), (P1,P4 ), (P2,P5 ), (P3,P5 ), (P4,P6 ), (P4,P7 ), (P5,Ps ), (P6,P8 ), (P7,P9 ), (P8,P9 )}

2.1.2 程序的顺序执行及其特征:顺序,封闭,可再现

1.程序的顺序执行:排队吃瓜

在为配置OS的计算机系统中,程序的执行方式是顺序执行,即必须在上一个程序执行完毕后,才允许下一个程序的执行;

而在多道程序的环境下,则允许多个程序并发执行。

程序的这两种执行方式有着显著的不同之处,也正是程序并发执行时的这种特征才导致了在操作系统中引入进程的概念。

因此,我们必须先要对程序的顺序执行和并发执行做一点简单的阐述。

首先,我们来看一下程序的顺序执行及其特征。

如图所示,图中描述了程序1和程序2的顺序执行关系。

其中,每个程序又由输入(Input)、计算(Calculate)、输出(Print)三个阶段构成,它们也是顺序执行的关系。

   例子:

   S1:  a := x+y;

   S2:  b := a-5;

   S3:  c := b+1;

2.程序顺序执行时的特征:顺序,封闭,可再现

(1)顺序性:处理机的操作严格按照程序所规定的顺序执行。

(2)封闭性:程序运行时独占全机资源,程序一旦开始执行,其执行结果不受外界因素影响。

(3)可再现性:只要程序执行时的环境和初始条件相同,都将获得相同的结果。(不论它是从头到尾不停顿地执行,还是“停停走走”地执行)

2.1.3 程序的并发执行及其特征:间断,无封闭,不再现

1.程序的并发执行:几个语句一起跑

   对于具有下述四条语句的程序段

          S1:a : = x十2

          S2:b : = y十4

          S3:c : = a十b

          S4:d : = c+b

可画出的并行图:

 2.程序并发执行时的特征:间断,无封闭,不再现

1)间断性:由于它们共享系统资源,以及为完成同一项任务而相互合作,致使在这些并发执行的程序之间,形成了相互制约的关系。

相互制约将导致并发程序具有“执行——暂停——执行”这种间断性的活动规律。

2)失去封闭性:是多个程序共享系统中的各种资源,因而这些资源的状态将由多个程序来改变,致使程序的运行已失去了封闭性。  

3)不可再现性:程序在并发执行时,由于失去了封闭性,导致不可再现性 。

举例:

有两个循环程序A和B它们共享一个变量N。

程序A每执行一次时,都要做 N = N+1 操作;

程序B每执行一次时,都要执行 Print(N) 操作,然后再将N置成“0”,即:N = 0。

程序A和B以不同的速度运行。

会出现什么样的结果?

程序A和B以不同的速度运行出现的情况:
1、N=N+1,在Print(N)和N=0之前执行,
      即执行次序:     
N=N+1         n+1
Print(N)     n+1         
 N=0         0

2、N=N+1,在Print和N=0之后执行,
     即执行次序:    
Print(N)     n
 N=0         0
 N=N+1         1

3、N=N+1,在Print和N=0之间执行,
    即执行次序:    
Print(N)     n    
 N=N+1         n+1
 N=0         0

执行结果,各不相同

程序A和B以不同的速度运行出现的结果:

计算的结果由于并发执行的不可再现性,

亦即,程序经过多次执行后,虽然它们执行时的环境和初始条件相同,但得到的结果却各不相同。

2.2 进程的描述

2.2.1 进程的特征与状态:进程<程序

1. 进程的定义:动态的过程

典型的进程定义有:

(1)进程是程序的一次执行

(2)进程是一个程序及其数据在处理机上顺序执行时所发生的活动

(3)进程是程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个独立单位。

在多道程序环境下,程序的执行属于并发执行,此时它们将失去其封闭性,并具有间断性及不可再现性的特征。

这决定了通常的程序是不能参与并发执行的,因为程序执行的结果是不可再现的。

这样,程序的运行也就失去了意义。

为使程序能并发执行,且为了对并发执行的程序加以描述和控制,人们引入了“进程”的概念。

2.进程的特征 :结构,动态,并发,独立,异步

为了能比较深刻地了解什么是进程,我们先对进程的特征加以描述:首先是进程的结构特征,然后是动态性,其次是并发性、独立性与异步性

1)结构特征:程序段、相关的数据段和PCB

  • 为使程序(含数据)能独立运行,应为之配置一进程控制块,即PCB(Process Control Block);
  • 而由程序段、相关的数据段和PCB三部分便构成了进程实体。
  • 所谓创建进程,实质上是创建进程实体中的PCB;而撤消进程,实质上是撤消进程的PCB。     

2)动态性:程序是过程

  • 进程的实质是进程实体的一次执行过程,因此,动态性是进程的最基本的特征。
  • 动态性表现:“它由创建而产生,由调度而执行,由撤消而消亡”。可见,进程实体有一定的生命期。
  • 程序是一组有序指令的集合,其本身并不具有运动的含义,因而是静态的。

 3)并发性:同一时间段

这是指多个进程实体同存于内存中,且能在一段时间内同时运行。

 4)独立性:进程之间相互独立

指进程实体是一个能独立运行、独立分配资源和独立接受调度的基本单位;

 5)异步性:不可知

指进程按各自独立的、不可预知的速度向前推进,或说进程实体按异步方式运行。

2.2.2 进程的基本状态及转换

1. 进程的三种基本状态:就绪、阻塞和执行

进程的间断性执行决定了进程可能具有多种状态。事实上,如图所示,运行中的进程可能具有以下三种状态:就绪、阻塞和执行

进程的间断性执行决定了进程可能具有多种状态。事实上,如图所示,运行中的进程可能具有以下三种状态:就绪、阻塞和执行

进程的三种基本状态

1)就绪(Ready)状态:当进程已分配到除CPU以外的所有必要资源后,只要再获得CPU,便可立即执行。

 2)执行状态:进程已获得CPU,其程序正在执行。

 3)阻塞状态:正在执行的进程由于发生某事件而暂时无法继续执行时,便放弃处理机而处于暂停状态,把这种暂停状态称为阻塞状态,有时也称为等待状态。

4. 进程五种状态及转换模型

  • Ready:准备执行
  • Running:占用处理机(单处理机环境中,某一时刻仅一个进程占用处理机)
  • Blocked:等待某事件发生才能执行,如等待I/O完成等
  • New:进程已经创建,但未被OS接纳为可执行进程,并且程序段和数据段还在辅存,PCB在内存
  • Exit:因停止或取消,被OS从执行状态释放

4. 进程五状态及转换模型

单阻塞队列:

多阻塞队列:

 5. 状态转换:新,就绪,执行,终止,阻塞

① 空 -> 新状态  新创建的进程首先处于新状态。

② 新状态 -> 就绪状态  当系统允许增加就绪进程时,操作系统接纳新建状态进程,将它变为就绪状态,插入就绪队列中。

③ 就绪状态 -> 执行状态  当处理机空闲时,将从就绪队列中选择一个进程执行,该选择过程称为进程调度,或将处理机分派给一个进程,该进程状态从就绪转变为执行。

④ 执行状态 -> 终止状态  执行状态的进程执行完毕出现诸如访问地址越界、非法指令等错误,而被异常结束,则进程从执行状态转换为终止状态。

⑤ 执行状态 -> 就绪状态  分时系统中,时间片用完,或优先级高的进程到来,将中断较低优先级进程的执行。进程从执行状态转变为就绪状态,等待下一次调度。

⑥ 执行状态 -> 阻塞状态  执行进程需要等待某事件发生。通常,会因为进程需要的系统调用不能立即完成,如读文件、共享虚拟内存、等待I/O操作、等待另一进程与之通信等事件而阻塞。

阻塞状态 -> 就绪状态  当阻塞进程等待的事件发生,就转换为就绪状态。进入就绪队列排队,等待被调度执行。

6. 多个进程竞争内存资源:进程都想向里冲但没内存,进程在里面堵死了等IO

内存资源紧张,进程太多,大量的进程处于创建状态,由于没有充足的内存资源无法进驻内存转换到就绪状态

无就绪进程,处理机空闲:I/O的速度比处理机的速度慢得多,可能出现全部进程阻塞等待I/O

解决方法:

  • 采用交换技术:换出一部分进程到外存,以腾出内存空间

将内存中暂时不能运行的进程,或暂时不用的数据和程序,换出到外存,以腾出足够的内存空间,

把已具备运行条件的进程,或进程所需要的数据和程序,换入内存。

进程被交换到外存,状态变为挂起状态

  • 采用虚拟存储技术:每个进程只能装入一部分程序和数据(存储管理部分)

挂起状态:进程被停止

    使执行的进程暂停执行、静止下来。我们把这种静止状态称为挂起状态。

1)引入挂起状态的原因:用户,父进程,系统,操作系统

  • 终端用户的请求。
  • 父进程请求。   
  • 负荷调节的需要。当实时系统中的工作负荷较重,把一些不重要的进程挂起,以保证系统能正常运行。
  • 操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。

被挂起进程的特征:

  • 不能立即执行
  • 可能是等待某事件发生,若是,则阻塞条件独立于挂起条件,即使阻塞事件发生,该进程也不能执行
  • 使之挂起的进程为:自身、其父进程、OS
  • 只有挂起它的进程才能使之由挂起状态转换为其他状态:解铃还须系铃人

挂起与阻塞:

区分两个概念: 进程是否等待事件,阻塞与否?  进程是否被换出内存,挂起与否?

4种状态组合:

就绪:进程在内存,准备执行

阻塞:进程在内存,等待事件

就绪/挂起:进程在外存,只要调入内存即可执

阻塞/挂起:进程在外存,等待事件

2)进程挂起状态的转换

2.2.4 进程管理中的数据结构

1.进程控制块的作用:使进程能并发

是进程存在的唯一标志;

PCB(process control block)常驻内存

进程控制块的作用是使一个在多道程序环境下不能独立运行的程序(含数据),成为一个能独立运行的基本单位,一个与其它进程并发执行的进程。

或者说,OS是根据PCB来对并发执行的进程进行控制和管理的。

在进程的整个生命期中,系统总是通过PCB对进程进行控制的,

亦即,系统是根据进程的PCB而不是任何别的什么而感知到该进程的存在的。所以说,PCB是进程存在的惟一标志。  

 2.进程控制块中的信息

 1)进程标识符

 2)处理机状态

 3)进程调度信息

 4)进程控制信息

pid

进程状态

现场

优先级

阻塞原因

程序地址

同步机制

资源清单

链接指针

在进程控制块中,主要包括下述四方面的信息。首先是进程标识符

1)进程标识符:每个进程都有唯一内部标识符,谁用进程谁创建外部标识符

 进程标识符用于惟一地标识一个进程。一个进程通常有两种标识符:

(1)内部标识符。为每一个进程赋予一个惟一的数字标识符。设置内部标识符主要是为了方便系统使用。

(2)外部标识符。它由创建者提供,通常是由字母、数字组成,往往是由用户(进程)在访问该进程时使用。

为了描述进程的家族关系,还应设置父进程标识及子进程标识。此外,还可设置用户标识,以指示拥有该进程的用户。

 2)处理机状态:通用、指针寄存器,PSW,用户栈指针

    处理机状态信息主要是由处理机的各种寄存器中的内容组成:

通用寄存器,又称为用户可视寄存器。

指令计数器,其中存放了要访问的下一条指令的地址。

程序状态字PSW,其中含有状态信息,如条件码、执行方式、中断屏蔽标志等

用户栈指针,用于存放过程和系统调用参数及调用地址。栈指针指向该栈的栈顶。

3)进程调度信息:状态,优先级,其它,阻塞原因

在PCB中还存放一些与进程调度和进程对换有关的信息,包括:

进程状态。指明进程的当前状态。

进程优先级,一个整数。

进程调度所需的其它信息。比如,进程已等待CPU的时间总和、进程已执行的时间总和等;

事件。是指进程由执行状态转变为阻塞状态所等待发生的事件,即阻塞原因。

4)进程控制信息:地址,同步、通信机制,要用的资源清单,下一进程的PCB首地址

程序和数据的地址,是指进程的程序和数据所在的内存或外存地址。

进程同步和通信机制,指实现进程同步和进程通信时必需的机制,如消息队列指针、信号量等。

资源清单。进程所需的全部资源及已经分配到该进程的资源的清单;

链接指针。下一进程的PCB首地址。

 3.PCB的组织方式  :线性表,队列,索引表

1)线性方式:线性表

    将系统中的所有PCB组织在一张线性表中,将该表的首地址存放在一个专用区域中。

2)链接方式:队列

    把具有同一状态的PCB,用其中的链接字链接成一个队列,排成就绪队列,若干个阻塞队列以及空白队列。

等待队列示例:

struct wait_queue {
	struct task_struct * task;
	struct wait_queue * next;
};

 

PCB多级队列的示例:

3)索引方式:索引表

    系统根据所有进程的状态建立几张索引表。

2.2.5 Linux进程控制块:了解即可

PCB结构

pid

进程状态

现场

优先级

阻塞原因

程序地址

同步机制

资源清单

链接指针

LinuxPCB结构

进程ID

用户ID

进程状态

调度信息

文件管理

虚拟内存管理

信号(进程间通信机制)

时间和定时器

……

task_struct结构:了解即可

pid_t pid;
uid_t uid,euid; 
gid_t gid,egid;
volatile long state;
int exit_state;
unsigned int rt_priority;
unsigned int policy;
struct list_head tasks;
struct task_struct *real_parent;
struct task_struct *parent; 

struct list_head children,sibling; 
struct fs_struct *fs;
struct files_struct *files;
struct mm_struct *mm;
struct signal_struct *signal;
struct sighand_struct *sighand;
cputime_t utime, stime;
struct timespec start_time; 
struct timespec real_start_time;

fs用来表示进程与文件系统的联系,包括当前目录和根目录; files表示进程当前打开的文件

Mm_struct进程所拥有的用户空间内存描述符

指向进程的信号描述符,指向进程的信号处理程序描述符

utime/stime用于记录进程在用户态/内核态下所经过的节拍数(定时器)

start_time/real_start_time进程创建时间,real_start_time还包含了进程睡眠时间

task_struct:进程状态:了解即可

进程状态:

volatile long state;   

state成员的可能取值如下:

#define TASK_RUNNING        0 

#define TASK_INTERRUPTIBLE  1 

#define TASK_UNINTERRUPTIBLE    2 

#define TASK_ZOMBIE      4 

#define TASK_STOPPED       8

Linux的进程状态切换:了解即可

 TASK_RUNNING:只有在该状态的进程才可能在CPU上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的task_struct结构(进程控制块)被放入对应CPU的可执行队列中(一个进程最多只能出现在一个CPU的可执行队列中)。进程调度器的任务就是从各个CPU的可执行队列中分别选择一个进程在该CPU上运行。 很多操作系统教科书将正在CPU上执行的进程定义为RUNNING状态、而将可执行但是尚未被调度执行的进程定义为READY状态,这两种状态在linux下统一为 TASK_RUNNING状态

TASK_INTERRUPTIBLE:处于这个状态的进程因为等待某某事件的发生(比如等待socket连接、等待信号量),而被挂起。这些进程的task_struct结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。

TASK_UNINTERRUPTIBLE:与TASK_INTERRUPTIBLE状态类似,进程处于睡眠状态,但是此刻进程是不可中断的。不可中断,指的并不是CPU不响应外部硬件的中断,而是指进程不响应异步信号。 绝大多数情况下,进程处在睡眠状态时,总是应该能够响应异步信号的。否则你将惊奇的发现,kill -9竟然杀不死一个正在睡眠的进程了!而TASK_UNINTERRUPTIBLE状态存在的意义就在于,内核的某些处理流程是不能被打断的。如果响应异步信号,程序的执行流程中就会被插入一段用于处理异步信号的流程(这个插入的流程可能只存在于内核态,也可能延伸到用户态),于是原有的流程就被中断了。 在进程对某些硬件进行操作时(比如进程调用read系统调用对某个设备文件进行读操作,而read系统调用最终执行到对应设备驱动的代码,并与对应的物理设备进行交互),可能需要使用TASK_UNINTERRUPTIBLE状态对进程进行保护,以避免进程与设备交互的过程被打断,造成设备陷入不可控的状态。这种情况下的TASK_UNINTERRUPTIBLE状态总是非常短暂的,通过ps命令基本上不可能捕捉到。

TASK_STOPPED: 向进程发送一个SIGSTOP信号,它就会因响应该信号而进入TASK_STOP状态。向进程发送一个SIGCONT信号,可以让其从TASK_STOP状态恢复到TASK_RUNNING状态。指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_STOP状态。

TASK_ZOMBIE:在这个退出过程中,进程占有的所有资源将被回收,除了task_struct结构(以及少数资源)以外。于是进程就只剩下task_struct这么个空壳,故称为僵尸。 之所以保留task_struct,是因为task_struct里面保存了进程的退出码、以及一些统计信息。而其父进程很可能会关心这些信息。

2.3 进程控制

进程控制是进程管理中最基本的功能。

它用于创建一个新进程,终止一个已完成的进程,或终止一个因出现某事件而使其无法运行下去的进程,还可负责进程运行中的状态转换。

如当一个正在执行的进程因等待某事件而暂时不能继续执行时,将其转换为阻塞状态,而当该进程所期待的事件出现时,又将该进程转换为就绪状态等等。

进程控制一般是由OS的内核中的原语来实现的。

原语(Primitive)是由若干条指令组成的,用于完成一定功能的一个过程。

它与一般过程的区别在于:它们是“原子操作(Action Operation)”。

所谓原子操作,是指一个操作中的所有动作要么全做,要么全不做。

换言之,它是一个不可分割的基本单位,因此,在执行过程中不允许被中断。

原子操作在管态下执行,常驻内存。

原语的作用是为了实现进程的通信和控制,系统对进程的控制如不使用原语,就会造成其状态的不确定性,从而达不到进程控制的目的。

2.3.1  操作系统内核:常驻内存,贴硬件模块、常用设备驱动、频率高模块

OS内核----常驻内存。

与硬件紧密相关的模块(中断处理)

常用设备驱动、运行频率高的模块(时钟管理、进程调度)

 目的:1、保护;2、提供OS效率

OS内核包含两大功能:

1、支撑功能

  • 中断处理
  • 时钟管理
  • 原语操作

2、资源管理功能

  • 进程管理
  • 存储器管理
  • 设备管理

2.3.2  进程的创建  

1.进程的层次结构:父子

父进程

子进程 -- 可以继承父进程所拥有的资源。

2.进程图(Process  Graph):父子进程树

进程图是用于描述一个进程的家族关系的有向树。

子进程可以继承父进程所拥有的资源。

当子进程被撤消时,应将其从父进程那里获得的资源归还给父进程。

在撤消父进程时,也必须同时撤消其所有的子进程。

3.引起创建进程的事件:用户登录、作业调度、提供服务、应用请求

导致一个进程去创建另一个进程的典型事件,可有以下四类:

(1)用户登录

(2)作业调度

(3)提供服务

(4)应用请求

在多道程序环境中,只有(作为)进程(时)才能在系统中运行。

因此,为使程序能运行,就必须为它创建进程。

导致一个进程去创建另一个进程的典型事件,可有以下四类:

(1) 用户登录。在分时系统中,用户在终端键入登录命令后,如果是合法用户,系统将为该终端建立一个进程,并把它插入就绪队列中。

(2) 作业调度。在批处理系统中,当作业调度程序按一定的算法调度到某作业时,便将该作业装入内存,为它分配必要的资源,并立即为它创建进程,再插入就绪队列中。

(3) 提供服务。当运行中的用户程序提出某种请求后,系统将专门创建一个进程来提供用户所需要的服务,例如,用户程序要求进行文件打印,操作系统将为它创建一个打印进程,这样,不仅可使打印进程与该用户进程并发执行,而且还便于计算出为完成打印任务所花费的时间。

(4) 应用请求。在上述三种情况下,都是由系统内核为它创建一个新进程;而第4 类事件则是基于应用进程的需求,由它自己创建一个新进程,以便使新进程以并发运行方式完成特定任务。例如,某应用程序需要不断地从键盘终端输入数据,继而又要对输入数据进行相应的处理,然后,再将处理结果以表格形式在屏幕上显示。该应用进程为使这几个操作能并发执行,以加速任务的完成,可以分别建立键盘输入进程、表格输出进程。

4.进程创建:申请PCB、分资源、初始化PCB、就绪

调用进程创建原语Creat(  )按下述步骤创建一个新进程:

(1)申请空白PCB。   

(2)为新进程分配资源。

(3)初始化进程控制块。包括:

      ①初始化标识信息。

      ②初始化处理机状态信息。

      ③初始化处理机控制信息。

 (4)将新进程插入就绪队列。

2.3.3 进程的终止

1.引起进程终止的事件   :正常、异常、外界

  1)正常结束:指令

        批处理中用Holt指令,分时中用Logs off指令。

  2)异常结束:八项

         ①越界错误。存储区。

         ②保护错。写一个只读文件。

         ③非法指令。执行一条不存在的指令。

         ④特权指令错。用户访问只允许OS执行的指令。

         ⑤运行超时。

         ⑥等待超时。

         ⑦算术运算错。被0除。

         ⑧I/O故障。

  3)外界干预:操作员、系统,父进程

外界干预并非指在本进程运行中出现了异常事件,而是指进程应外界的请求而终止运行

      ① 操作员或操作系统干预。

      ②  父进程请求终止该进程。

      ③ 当父进程终止时,OS也将他的所有子孙进程终止。

2.进程的终止过程:找PCB,停止他和子孙,归还资源,移除PCB

1)根据被终止进程的标识符ID,从PCB集合中检索出该进程的PCB,从中读出该进程的状态。

2)若被终止进程正处于执行状态,应立即终止该进程的执行,并置调度标志为真,用于指示该进程被终止后应重新进行调度。

3)若该进程还有子孙进程,还应将其所有子孙进程予以终止,以防他们成为不可控的进程

4)将被终止进程所拥有的全部资源,或者归还给其父进程,或者归还给系统。

5)将被终止进程(它的PCB)从所在队列(或链表)中移出,等待其他程序来搜集信息。

2.3.4  进程的阻塞与唤醒

1.进引起进程阻塞的事件:等IO,等操作,等数据,等工作

1)请求系统服务:提出I/O服务时,并不立即满足该进程的要求时,转变为阻塞状态来等待

2)启动某种操作:当进程启动某种操作后,在该操作完成之后才能继续执行。

3)新数据尚未到达:对于相互合作的进程而言。  

4)无新工作可做。如发送进程。

2.进程阻塞过程:自我阻塞、PCB进阻塞队列,换人

1)正在执行的进程,当发现上述某事件时,由于无法继续执行,于是进程便通过调用阻塞原语block( )把自己阻塞。

2)把进程控制块中的现行状态由“执行”改为“阻塞”,并将PCB插入阻塞队列。

3)转调度程序进行重新调度,将处理机分配给另一就绪进程,并进行切换。

3.进程的阻塞与唤醒:有关进程唤醒阻塞进程

当被阻塞进程所期待的事件出现时,则由有关进程(比如,用完并释放了该I/O设备的进程)调用唤醒原语wakeup(  ),将等待该事件的进程唤醒。

4.进程唤醒过程:移除阻塞队列,插入就绪队列

唤醒原语执行的过程是:

1)首先把被阻塞的进程从等待该事件的阻塞队列中移出,将其PCB中的现行状态由阻塞改为就绪

2)然后再将该PCB插入到就绪队列中。

2.3.5   进程的挂起与激活

1.进程的挂起:挂起就绪为静止就绪,挂起阻塞为静止阻塞

当出现了引起进程挂起的事件时,系统将利用挂起原语suspend(  )将指定进程进程挂起。

挂起原语的执行过程是:

首先检查被挂起进程的状态,若处于活动就绪状态,便将其改为静止就绪;

对于活动阻塞状态的进程,则将之改为静止阻塞状态。

2.进程的激活过程:外存进内存

1)当发生激活进程的事件时,则可将在外存上处于静止就绪状态的进程换入内存。

2)系统利用激活原语active(  )将指定进程激活:

激活原语先将进程从外存调入内存,检查该进程的现行状态;

若是静止就绪,便将之改为活动就绪;若为静止阻塞,便将之改为活动阻塞。

2.3.6   Linux的进程控制

1.进程的创建:fork函数创建

UNIX&Linux中创建进程的方式:

在shell中执行命令或可执行文件

  • 由shell进程调用fork函数创建子进程

在代码中(已经存在的进程中)调用fork 函数创建子进程

  • fork创建的进程为子进程
  • 原进程为父进程

Linux 操作系统下的进程与线程相同点是都有进程控制块(Process Control Block,PCB), 具体的类是 task_struct,

区别在于一个是独立的进程资源,一个是共享的进程资源。

内核线 程完全没有用户空间,进程资源包括进程的 PCB、线程的系统堆栈、进程的用户空间、进程 打开的设备(文件描述符表)等。

Linux 用户进程不能直接被创建,因为不存在这样的 API,它只能从某个进程中复制,有的需要通过 exec 这样的 API 来切换到实际想要运行的程序文件。

复制 API 包括 3 种:fork、clone、vfork。

在 Linux 源代码中,这 3 个函数的执行过程是执行 fork、clone、vfork 时,

通过一个系统 调用表映射到 sys_fork、sys_clone、sys_vfork,

再在这 3 个函数中调用 do_fork 做具体的创建 进程工作。

这 3 个 API 的内部实际都是调用一个内核内部函数 do_fork,只是填写的参数不 同而已。

  • Linux系统中进程0 (PID=0)是由内核创建,其他所有进程都是由父进程调用fork函数所创建的
  • Linux系统中进程0在创建子进程(PID=1,init进程)后,进程0就转为交换进程或空闲进程
  • 进程1(init进程)是系统中其他所有进程的共同祖先

  • fork、clone、vfork调用do_fork( )
  • 不同之处

fork函数:创建进程

函数原型

头文件:unistd.h

pid_t fork(void);

返回值

fork函数被正确调用后,将会在子进程中和父进程中分别返回!!

在子进程中返回值为0(不合法的PID,提示当前运行在子进程中)

在父进程中返回值为子进程ID(让父进程掌握所创建子进程的ID号)

出错返回-1

int main(void){
pid_t pid;
pid=fork();
if(pid==-1)
printf(“fork error\n”);
else if(pid==0){
      printf(“the returned value is %d\n”,pid);
   printf(“In child process!!\n”);
   printf(“My PID is %d\n”,getpid();)
else{
      printf(“the returned value is %d\n”,pid);
   printf(“In father process!!\n”);
   printf(“My PID is %d\n”,getpid();}
return 0;
}

调用 fork 的目的是复制自身,从而父、子进程能同时执行不同段的代码

#include<stdio.h> 
#include<sys/types.h> 
#include<unistd.h> 
#include<errno.h> 
int main()
{ 
    int a = 5; int b = 2; 
    pid_t pid; 
    pid = fork(); 
    if(pid == 0){   //如果返回的pid为0,是在子进程中
        a = a-4; 
        printf("I'm a child process with PID [%d], the value of a: %d, the value of b: %d.\n", pid, a, b); 
    }else if(pid < 0) { 
        perror("fork"); 
    }else {         //父进程中获得子进程的pid,大于0
        printf(“I‘m a parent process, with PID [%d],the value of a:%d, the value of b: %d.\n", pid, a, b); 
    }
    return 0;
}  

进程内存空间布局

 进程内存空间

进程内存空间布局

命令行参数

ls [参数] <路径或文件名>

ls –l /home

mkdir [参数] <目录名>

mkdir -p /home/xxxx/src

cp [参数] <源文件路径> <目标文件路径>

cp –r /usr/local/src /root

环境变量表

每个进程都会有自己的环境变量表,通过全局的环境指针(environ)可以直接访问环境变量表(字符串数组)

头文件unistd.h

extern char **environ;

环境变量字符串形式为“name=value”,name是环境变量名称,value为环境变量赋值

设置环境变量

设置环境变量的三种方法:putenv、setenv、unsetenv

putenv ( )函数将环境变量字符串放入环境变量表中;若该字符串已经存在,则覆盖

头文件stdlib.h

int putenv(char *str);

setenv ( )将指定环境变量的值设置为参数指定值(更改环境变量字符串)

头文件:stdlib.h

int setenv(const char* name,const char* value, int rewrite);

若name已经存在:

rewrite不等于0,则删除其原先的定义

rewrite等于0,则不删除其原先的定义

unsetenv ( )删除指定的环境变量字符串

头文件:stdlib.h

int unsetenv(const char* name);

fork函数执行后父子进程的主要异同

父子进程相同

  • 真实用户ID,真实组ID
  • 有效用户ID,有效组ID
  • 环境变量
  • 打开的文件

父子进程不同

  • fork的返回值
  • 进程ID及父进程ID
  • 子进程的

  tms_utime,

  tms_stime,

  tms_cutime,

  tms_ustime

  值被设置为 0

fork的用法

父进程希望复制自己(共享代码,复制数据空间),但父子进程执行相同代码中的不同分支

网络并发服务器中,父进程等待客户端的服务请求。当请求达到,父进程调用fork创建子进程处理该请求,而父进程继续等待下一个服务请求

2.进程的退出

Linux 下进程的退出方式分为正常退出和异常退出两种

 (1)正常退出

  1. 在 main 函数中执行 return
  2. 调用 exit 函数
  3. 调用_exit 函数。

 (2)异常退出

  1. 调用 abort 函数
  2. 进程收到某个信号,而该信号使程序终止

无论哪种退出方式,系统最终都会执行内核中的同一代码。这段代码用来关闭进程所有

已打开的文件描述符,释放它所占用的内存和其他资源。

几种方式的区别

  1. exit 是一个函数,执行完后把控制权交给系统
  2. return 是函数执行完后的返回。return 执行完后把控制权交给调用函数
  3. exit 是正常终止进程;abort 是异常终止进程
  4. _exit 执行后立即将控制权返回给内核;exit 执行后要先执行一些清除操作,然后才将控制权交给内核
  • exit 函数在调用 exit 系统之前要检查文件的打开情况,把文件缓冲区的内容写回文件。

exit 函数与_exit 函数的最大区别在于,exit 函数在调用 exit 系统之前要检查文件的打开

情况,把文件缓冲区的内容写回文件。由于 Linux 的标准函数库中有一种被称作“缓冲 I/O” 的操作,其特征就是对应每一个打开的文件,在内存中都有一片缓冲区。每次读文件时,会 连续地读取若干条记录,这样在下次读取文件时就可以直接从内存的缓冲区读取;同样,每 次写文件的时候也仅仅是写入内存的缓冲区,等满足了一定的条件(如达到一定数量或遇到 特定字符等),再将缓冲区中的内容一次性写入文件。这种技术大大增加了文件读/写的速度, 但也给编程带来了一点麻烦。比如有一些数据,我们认为已经写入了文件,实际上因为没有 满足特定的条件,它们还是保存在缓冲区内,这时用_exit 函数直接将进程关闭,缓冲区的数 据就会丢失。因此,要想保证数据的完整性,就一定要使用 exit 函数。

3.进程的控制—等待与睡眠

孤儿进程

僵尸进程

进程在退出之前会释放进程用户空间的所有资源,但PCB等内核空间资源不会被释放

对于已经终止但父进程尚未对其调用wait或waitpid函数的进程(TASK_ZOMBIE状态),称为僵尸进程

内核将根据情况关闭该进程打开的所有文件,释放PCB(释放内核空间资源)

wait 函数:自查子进程是否僵尸

父进程一旦调用了wait就立即阻塞自己,

由wait自动分析当前进程的某个子进程是否已经退出。

如果让它找到了这样一个已经变成僵尸的子进程,

wait就会收集这个子进程的信息,并把它彻底销毁后返回;

如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个子进程出现为止。

void waitprocess() 
{ 
    int count = 0; 
    pid_t pid = fork(); 
    int status = -1; 
    if(pid<0){
        printf("fork error for %m\n",errno); 
    }else if(pid>0){ 
        printf("this is parent ,pid = %d\n",getpid()); 
        wait(&status);/*父进程执行到此,马上阻塞自己,直到有子进程结束。当发现有子进程结束时, 就会回收它的资源*/ 
    }else {
        printf("this is child ,pid = % d ,ppid = %d\n",getpid(),getppid());
        int i;
        for(i = 0; i < 10; i++) {
           count++;
           sleep(1);
           printf(“count = %d\n”,count);
        }
        exit(5);
    } 
    printf(“child exit status is %d\n”, WEXITSTATUS(status)); 
    /*status是按位存储 的状态信息,需要调用相应的宏来还原*/
    printf("end of program from pid = %d\n",getpid()); 
} 

waitpid 函数:自查僵尸子进程

waitpid和wait函数的作用是完全相同的,但waitpid函数多出了两个可由用户控制的参数pid和options

waitpid函数可等待一个特定的进程,而wait函数则返回任意一个终止子进程的状态。

waitpid函数提供了一个wai 函数的未阻塞版本。当用户希望取得一个子进程的状态,但不想阻塞时,可使用 waitpid 函数。

#include <sys/types.h> 
#include <sys/wait.h> 
#include <unistd.h> 
int main() 
{
  pid_t pc,pr; 
  pc=fork();
  if(pc<0){           /*如果 fork 出错*/
      printf(“Error occured on forking.\n"); 
  }else if(pc==0){    /*如果是子进程*/ 
      sleep(10);      /*睡眠 10s*/ 
      exit(0); 
  }else{ 
      do{
          pr=waitpid(pc,NULL,WNOHANG); /*使用WNOHANG参数,
                                                                                                             waitpid不会在这里等待 */ 
          if(pr==0){
              printf("Nochild exited\n"); 
              sleep(1);
          }
      }while(pr==0); 
      if(pr==pc)/*如果是父进程*/ /*如果没有收集到子进程*/ 
          printf(“successfully get child %d\n”,pr); 
      else
          printf(“some error occured\n”);
  } 
  return 0;
} 

3.进程的控制—执行

exec 函数簇:创建子进程执行另一个程序

根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。

在 Linux 中使用 exec 函数簇主要有以下两种情况。

  1. 当进程认为自己不能再为系统和用户做出任何贡献时,就可以调用任何exec函数簇让自己重生。
  2. 如果一个进程想执行另一个程序,那么它就可以调用fork函数新建一个进程,然后调用exec函数使子进程重生。

fork函数用于创建一个子进程,该子进程几乎是父进程的副本。

而有时我们希望子进程去执行另外的程序,exec函数簇就提供了一个在进程中执行另一个程序的方法。

它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈 段。

在执行完之后,原调用进程的内容除了进程号外,其他内容全部被新程序的内容替换。

exec系列函数

execl execle execlp execv execve execvp

六个函数开头均为exec,所以称为exec系列函数

l:表示list,每个命令行参数都说明为一个单独的参数

v:表示vector,命令行参数放在数组中

e:表示由函数调用者提供环境变量表

p:表示通过环境变量PATH来指定路径,查找可执行文件

示例代码:execl

int main(void)
{
printf("entering main process---\n");
if(fork()==0){
    execl("/bin/ls","ls","-l",NULL); 
    printf("exiting main process ----\n");
}
    return 0; 
}

2.4 进程同步

  • 由于进程的异步性,也会给系统造成混乱,在OS中引入进程同步。
  • 进程同步的主要任务:是使并发执行的诸进程之间能有效地共享资源和相互合作,从而使程序的执行具有可再现性

同步:并发进程在执行次序上的协调,以达到有效的资源共享和相互合作,使程序执行有可再现性。

2.4.1  进程同步的基本概念

1.两种形式的制约关系:间接直接

1)间接相互制约关系。由于资源共享

2)直接相互制约关系。主要由于进程间的合作。  

2.临界资源(Critical  Resource  ):一次只能一个访问

一次仅允许一个进程访问的资源为临界资源 。

(生产者-消费者问题)

int   in=0, out=0;
Int   count=0;
item  buffer[ n ];

void producer{
  while(1){
              …
	produce an item in nextp;
              …
While(counter=n){
}   //do no-op
buffer[ in ]=nextp;
In = (in+1) % n;
counter=counter+1;
   }
}

void consumer{
  while(1){
     while( counter=0 ){
     }  //do no-op
nextc = buffer[out];
out = (out+1) % n;
counter = counter-1;
consumer the item in nextc;
  }
}

counter的初值为5

register1 = counter;             register2 = counter;

register1 = register1+1;       register2 = register2-1;

counter = register1;             counter  = register2;

Register1 = counter;               (register1=5)

Register1 = register1+1;       (register1=6)

Register2 = counter;               (register2=5)

Register2 = register2-1;       (register2=4)

counter = register1;  (counter=6)

counter = register2;   (counter=4)

3.临界区(critical  section)

把在每个进程中访问临界资源的那段代码称为临界区。

访问临界资源的描述:

进入区:检查有无进程进入

临界区:

退出区:将访问标志复位

Repeat

            Entry section

            Critical section

            Exit section

     Until false

4.同步机制应遵循的规则

1)空闲让进

2)忙则等待

3)有限等待

4)让权等待

(1)空闲让进。当无进程处于临界区时,应允许一个请求进入临界区的进程立即进入自己的临界区,以有效地利用临界资源。

(2)忙则等待。当已有进程进入临界区时,其他试图进入临界区的进程必须等待,以保证对临界资源的互斥访问。

(3)有限等待。对要求访问临界资源的进程,应保证在有限时间内能进入自己的临界区,以免陷入死等状态。

 (4)让权等待。当进程不能进入自己的临界区时,应立即释放处理机。以免进程陷入

2.4.2 硬件同步机制

利用计算机硬件指令解决临界区问题

对临界区管理将标识看做一个锁,“锁开”进入,“锁关”等待

初始打开,每个进入临界区的进程必须对锁进行测试

测试和关锁操作必须连续(原子操作)

1、关中断

2、利用Test-and-Set指令实现互斥

3、利用Swap指令实现进程互斥

1.关中断

进入锁测试前关闭中断,完成锁测试并上锁后打开中断

进程在临界区时计算机系统不响应中断,不会引发调度

缺点:

  • 滥用关中断会引发严重后果
  • 关中断时间过长会影响系统效率
  • 不适用于多CPU系统

2.Test-and-Set

boolen TS( boolen *lock){
    boolean old;
    old = *lock;
    *lock =TURE;
    return old;
}
do{
    …
    while TS( &lock);
    critical section;
    lock :=FALSE;
    remainder section;
}while(TRUE);

*lock=false表示资源空闲,*lock=TURE表示资源正在被使用。

当资源被使用时,TS返回ture,则while TS&lock);语句条件为真会一直循环等待。

3.Swap—交换两个字的内容

void swap( boolen *a, boolen *b){
    boolean temp;
    temp = *a;
    *a =*b;
    *b=temp;
}
do{
    key=TURE;
    do{
        swap(&lock,&key);
    }while(key!=FALSE);
    临界区操作;
    lock = FALSE;
   …
}while(TRUE);

2.4.3 信号量机制

信号量(Semaphores)机制:是一种卓有成效的进程同步工具。

1)整形信号量

2)记录型信号量

3)AND型信号量

4)信号量集

1965 年,荷兰学者Dijkstra 提出的信号量(Semaphores)机制是一种卓有成效的进程同步工具。在长期且广泛的应用中,信号量机制又得到了很大的发展,它从整型信号量经记录型信号量,进而发展为“信号量集”机制。

1.整型信号量

 定义为一个整型量 ,仅能通过两个标准的原子操作 wait(S)和signal(S)来访问。又称为P、V操作。

wait(S):  while  S<=0   do  no-op
                                S:= S - 1;
        signal(S):S:= S + 1;

最初由Dijkstra 把整型信号量定义为一个用于表示资源数目的整型量S

它与一般整型量不同,除初始化外,仅能通过两个标准的原子操作(Atomic Operation) wait(S)signal(S)来访问

2.记录型信号量

整形信号量机制的问题:忙等

wait操作中信号量S<=0时,会不停的测试

未遵循让权等待的原则

记录型信号量机制,则是一种不存在“忙等”现象的进程同步机制

记录型信号量的数据结构:

  type  semaphore = record
                  value  :integer;
                  L:list  of  process;
 end 

在信号量机制中,除了需要一个用于代表资源数目的整型变量value外,还应增加一个进程链表指针L,用于链接上述的所有等待进程。

记录型信号量是由于它采用了记录型的数据结构而得名的。

记录型信号量的wait(S)操作

procedure  wait(  S  )
    var  S:  semaphore;
       begin   
           S.value:=S.value-1;
           if  S.value<0  then  block(S.L);
       end
// S.value<0,该类资源已经分配完毕,进程必须放弃处理机,自我阻塞。

在记录型信号量机制中,S.value 的初值表示系统中某类资源的数目,因而又称为资源信号量。对它的每次wait 操作,意味着进程请求一个单位的该类资源,使系统中可供分配的该类资源数减少一个,因此描述为S.value:=S.value-1;当S.value<0 时,表示该类资源已分配完毕,因此进程应调用block原语,进行自我阻塞,放弃处理机,并插入到信号量链表S.L 中。可见,该机制遵循了“让权等待”准则。此时S.value 的绝对值表示在该信号量链表中已阻塞进程的数目。

记录型信号量的signal(S)操作

procedure  signal(S)
  var  S:semaphore; 
  begin
      S.value:=S.value+1;
      if  S.value≤0  then   wakeup(S.L);
  end
// S.value≤0 ,在信号量链表中,仍有等待该资源的进程被阻塞。

对信号量的每次signal操作,表示执行进程释放一个单位资源,使系统中可供分配的该类资源数增加一个,故S.value:=S.value+1 操作表示资源数目加1。若加1 后仍是S.value≤0,则表示在该信号量链表中,仍有等待该资源的进程被阻塞,故还应调用wakeup原语,将S.L链表中的第一个等待进程唤醒。如果S.value的初值为1,表示只允许一个进程访问临界资源,此时的信号量转化为互斥信号量,用于进程互斥。

整形型信号量与记录型信号量的问题:
只能用于共享一个临界资源
多个临界资源的情况:访问共享数据D和E
设置互斥型号量Dmutex和Emutex,初值为1,A、B进程的访问操作如下:

进程A和进程B按照下述次序交替执行wait操作:
 

   processA:wait(Dmutex);    //Dmutex=0
   processB:wait(Emutex);    //Emutex=0
   processA:wait(Emutex);    //Emutex=-1,A阻塞
   processB:wait(Dmutex);    //Dmutex=-1,B阻塞

3. AND型信号量

AND同步机制的基本思想:将进程在整个运行过程中需要的所有资源,一次性全都地分配给进程,待进程使用完后再一起释放。只要尚有一个资源未能分配给进程,其它所有可能为之分配的资源,也不分配给他。

原子操作:要么全部分配到进程,要么一个也不分配。

在wait操作中,增加了一个“AND”条件,故称为AND同步,或称为同时wait操作。

Swait(S1,S2,···,Sn ) {
     while(true){
         if( S1≥1 and S2≥1 and…and Sn≥1 ){
            for (i = 1 ; i<= n; i++){
                     Si =  Si – 1;
             }
             break;
        }else{
               place  the process  in  the waiting  queue  associated  with  the  first  Si  found  with  Si<1,  and  set  the program  count  of  this  process  to  the  beginning  of  Swait  operation
       }
}

Ssignal(S1,S2,···,Sn){
    for(  i = 1; i<= n; i++ ){
         Si = Si+1;
         Remove  all  the  process  waiting  in  the  queue  associated  with  Si  into  the  ready  queue。
    }
}

4. 信号量集

在每次分配时,采用信号量集来控制,可以分配多个资源。

在记录型信号量机制中,wait(S)或signal(S)操作仅能对信号量施以加1 或减1 操作,意味着每次只能获得或释放一个单位的临界资源。而当一次需要N 个某类临界资源时,便要进行N 次wait(S)操作,显然这是低效的。此外,在有些情况下,当资源数量低于某一下限值时,便不予以分配。因而,在每次分配之前,都必须测试该资源的数量,看其是否大于其下限值。基于上述两点,可以对AND 信号量机制加以扩充,形成一般化的“信号量集”机制。

Swait(S1,t1,d1,…,Sn,tn,dn)(满足ti≥ di)    
    if( S1 ≥t1 &…& Sn≥tn){  
          for(  i =1; i<=n; i++){
                    Si =Si - di;
          }
     }else{
          Place  the  executing  process  in  the  waiting  queue  of  the  first Si  with Si<ti  and  set  its  program    counter  to  the  beginning  of  the  Swait  operation。
    }//end if
}//end Swait 
 Ssignal(S1,d1,···,Sn,dn){
    for( i =1; i<= n; i++){  
        Si = Si + di;
        Remove  all  the  process  waiting  
        in  the  queue  associated  with  Si  
        into  the  ready  queue
    }
}

三种特例:

(1)Swait(S,d,d):允许每次申请d个资源。

当资源数少于d时,不予分配。

(2)Swait (S,1,1):S>1,记录型信号量。

S=1时,互斥型信号量。

(3)Swait(S,1,0),可控开关,当S>=1时,允许进入,S<1时,不能进入。

2.4.4 信号量的应用

1. 利用信号量实现进程互斥

为使多个进程能互斥地访问某临界资源,只须为该资源设置一互斥信号量mutex,并设其初始值为1,然后将各进程访问该资源的临界区CS置于wait(mutex)和signal(mutex)操作之间即可。

利用信号量实现进程互斥的进程可描述如下:

1)利用信号量实现进程互斥

 Var  mutex:semaphore:=1;
          begin
          parbegin
             process1:begin
                 repeat
                      wait(mutex);
                      critica1  section
                      signal(mutex);
                     remainder  section
                  until false;
             end
             
        process2:begin
            repeat
                wait(mutex);
                critical  section
                signal(mutex);
                remainder  section
            until false;
        end
   parend 
   end

注意:

  • wait(mutex)和signal(mutex)必须成对出现;
  • 缺少wait(mutex)导致系统混乱,不能保证对临界资源的互斥访问;
  • 缺少signal(mutex)会使临界资源永远不释放,等待该资源的进程不能被唤醒。

2.利用信号量实现前趋关系

p1( ){ S1; signal(a);signal(b);}
p2( ){ wait(a); S2;signal(c);signal(d); }
p3( ){ wait(b);S3;signal(e);}
p4( ){ wait(c);S4;signal(f);}
p5( ){ wait(d);S5;signal(g);}
p6( ){ wait(e);wait(f);wait(g);S6;}

void main( ){
    semaphore a,b,c,d,e,f,g;
    a.value=b.value=c.value=0;
    d.value=e.value=f.value=g.value=0;
    cobegin
         p1( ); p2( ); p3( ); p4( ); p5( ); p6( );
    coend;
}

2.4.5 管程机制

虽然信号量机制是一种既方便、又有效的进程同步机制,但每个要访问临界资源的进程都必须自备同步操作wait(S)和signal(s)。这就使大量的同步操作分散在各个进程中。这不仅给系统的管理带来了麻烦,而且还会因同步操作的使用不当而导致系统死锁。这样,在解决上述问题的过程中,便产生了一种新的进程同步工具——管程 。

共享资源共享数据结构表示时,

资源管理程序可用对该数据结构进行操作一组过程来表示

(例如,资源的请求和释放过程request和release),

我们把这样一组相关的数据结构和过程一并称为管程

Hansan为管程所下的定义是:

“一个管程定义了一个数据结构和能为并发进程所执行(在该数据结构上)的一组操作

这组操作能同步进程和改变管程中的数据”。

如图所示,管程由4部分组成:

1)管程的名字;

2)局部于管程的共享变量说明;

3)对该数据结构进行操作的一组过程;

4)对局部于管程的数据设置初始值的语句。

Monitor(管程)—— 面向对象方法  

前两个特点让人联想到面向对象软件中对象的特点。

的确,面向对象操作系统或程序设计语言可以很容易地把管程作为一种具有特殊特征的对象来实现。

管程的主要特点:

  • 局部数据变量只能被管程的过程访问,任何外部过程都不能访问。
  • 一个进程通过调用管程的一个过程进入管程。
  • 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被挂起,以等待管程变成可用的。

2.5 经典进程的同步问题

  • 生产者--消费者问题

  • 哲学家进餐问题

  • 读者--写者问题

2.5.1 生产者 -- 消费者问题

生产者与消费者是一个广义的概念,可以代表一类具有相同属性的进程

生产者和消费者进程共享一个大小固定的缓冲区

其中,一个或多个生产者生产数据,并将生产的数据存入缓冲区,并有一个消费者从缓冲区中取数据

假设缓冲区的大小为n(存储单元的个数),它可以被生产者和消费者循环使用

分别设置两个指针in和out,指向生产者将存放数据的存储单元消费者将取数据的存储单元,如图

 

1.利用记录型信号量解决生产者一消费者问题

假定在生产者和消费者之间的公用缓冲池中,具有n 个缓冲区,
这时可利用互斥信号量mutex 实现诸进程对缓冲池的互斥使用。
利用信号量empty和full分别表示缓冲池中空缓冲区和满缓冲区的数量。
又假定这些生产者和消费者相互等效,只要缓冲池未满,
生产者便可将消息送入缓冲池;
只要缓冲池未空,消费者便可从缓冲池中取走一个消息。
对生产者—消费者问题可描述如下:

 int  in=0, out=0;
 item    buffer [n];
 semaphore	mutex=1, empty=n, full=0;   
 void producer();
 void consumer();
 void main(){
      cobegin
             producer(); consumer();
      coend
}
//mutex:使诸进程互斥地访问缓冲区(n个缓冲区)
//empty、 full:空、满缓冲区数量
void producer( ){
    do{
      			…
		Produce an item in nextp;
              		…
wait(empty);
wait(mutex);
buffer(in):=nextp;
in:=(in+1) mod n;
signal(mutex);
signal(full);
}while(TRUE);
}
void consumer{
	do{
	      wait(full);
wait(mutex);
nextc:=buffer(out);
out:=(out+1) mod n;
signal(mutex);
signal(empty);
Consumer the item in nextc;
     ……
}while(TRUE);
}

在生产者—消费者问题中应注意:

首先,在每个程序中用于实现互斥的wait(mutex)和signal(mutex)必须成对地出现;

其次,对资源信号量empty和full的wait和signal操作,同样需要成对地出现,但它们分别处于不同的程序中。

例如,wait(empty)在计算进程中,而signal(empty)则在打印进程中,

计算进程若因执行wait(empty)而阻塞,则以后将由打印进程将它唤醒;

最后,在每个程序中的多个wait 操作顺序不能颠倒,应先执行对资源信号量的wait操作,

然后再执行对互斥信号量的wait操作,否则可能引起进程死锁。

在此举例说明:

初始状态:N=2,full.value=0, full.L=NULL; empty.value=n, empty.L=NULL; mutex.value=1, mutex.L=NULL.

Ready队列:C1、P1、P2、P3

第一个时间片:C1 --》 run, wait(full)导致full.vlaue=-1,full.L={C1},即C1阻塞,下一条语句wait(mutex);

状态:full.value=-1, full.L={C1},; empty.value=n, empty.L=NULL; mutex.value=1, mutex.L=NULL.

Ready队列:P1、P2、P3

阻塞队列: full.L={C1}

第二个时间片: P1 --》 run, wait(empty)导致empty.vlaue=2-1=1,然后wait(mutex)使mutex.value=0,进入临界区,这时时间片完,P1从RUN转换到Ready

状态:full.value=-1, full.L={C1},; empty.value=1, empty.L=NULL; mutex.value=0, mutex.L=NULL.

Ready队列:P2、P3 、P1

阻塞队列: full.L={C1}

第三个时间片: P2 --》 run, wait(empty)导致empty.vlaue=2,然后wait(mutex)使mutex.value=-1,mutex.L={P2}即P2阻塞, P2从RUN转换到Block

第五个时间片: P3 --》 run, wait(empty)导致empty.vlaue=1,然后wait(mutex)使mutex.value=-,2,mutex.L={P2,P3}即P3阻塞, P3从RUN转换到Block

第六个时间片: P4 --》 run, wait(empty)导致empty.vlaue=0,然后wait(mutex)使mutex.value=-3,mutex.L={P2,P3,P4}即P4阻塞, P4从RUN转换到Block

第七个时间片: P5 --》 run, wait(empty)导致empty.vlaue=-1,empty。L={P5}即P5阻塞, P4从RUN转换到Block

第八个时间片: P1 --》 run, 执行临界区代码,执行signal(mutex)导致mutex.value= -2 (-3+1<0),唤醒mutex.L中的第一个进程P2,使P2获得mutex信号量进入Ready队列;执行signal(full)导致full.value= -1 (-2+1<0),唤醒full.L中的第一个进程C1,使C1获得full信号量进入Ready队列;P1完成该时间片从Run转换到Ready

Ready队列:P2、C1、P1

第九个时间片: P2 --》 run,进入临界区;执行signal(mutex)导致mutex.value= -1 (-2+1<=0),唤醒mutex.L中的第二个进程P3,使P3获得mutex信号量进入Ready队列;执行signal(full)导致full.value= 0 (-1+1<=0),唤醒full.L中的第二个进程C2,使C2获得full信号量进入Ready队列;P2完成该时间片从Run转换到Ready

Ready队列:C1、P1、P3、C2、P2

第十个时间片:C1 --》 run,wait(mutex)

2.利用AND信号量解决生产者—消费者问题

  int  in=0, out=0;
  item    buffer[ n ];
  semaphore   mutex=1;
  semaphore  empty=n, full=0;

  void producer( ){
       do{
			…
		 produce an item in nextp;
			…
		Swait(empty, mutex);
		buffer[in] = nextp;
 in = (in+1) % n;
   Ssignal(mutex, full);
      }while(TRUE);
   } //end producer

void consumer{
   do{
	   Swait(full, mutex);
	   nextc = buffer[out];
	   out = (out+1) % n;
	   Ssignal(mutex, empty);
	   consumer the item in nextc;
     }while(TRUE);
}

2.5.2哲学家进餐问题

由Dijkstra提出并解决的哲学家进餐问题(The Dinning Philosophers Problem)是典型的同步问题。

该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,

在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。

平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。

进餐完毕,放下筷子继续思考。

1.利用记录型信号量解决哲学家进餐问题

semaphore chopstick[5]={1,1,1,1,1};
do{
    wait(chopstick[i]);
    wait(chopstick[(i+1) % 5]);
    …
   eat
    …
   signal(chopstick[i]);
   signal(chopstick[(i+1) % 5]);
    …
   think;
}while(TRUE);

2.利用AND信号量解决哲学家进餐问题

semaphore chopstick[5]={1,1,1,1,1};
do{
    ……;
    think;
    Sswait(chopstick[(i+1) % 5],chopstick[i]);
    eat;
    Ssignal(chopstick[(i+1) % 5],chopstick[i]);
}while(TRUE);

2.5.3 读者 — 写者问题

一个数据文件或记录,可被多个进程共享,

我们把只要求读该文件的进程称为“Reader进程”,

其他进程则称为“Writer 进程”。

允许多个进程同时读一个共享对象,因为读操作不会使数据文件混乱。

但不允许一个Writer 进程和其他Reader 进程或Writer 进程同时访问共享对象,

因为这种访问将会引起混乱。

所谓“读者—写者问题(Reader-Writer Problem)”是指保证一个Writer 进程必须与其他进程互斥地访问共享对象的同步问题。

读者—写者问题常被用来测试新同步原语。

特点:    

读进程可共享同一对象。

写进程不可共享同一对象

1.利用记录型信号量解决读者—写者问题

semaphore rmutex=1, wmutex = 1;
    int readcount = 0;
void reader( ){
	do{
	  wait(rmutex);
	  if  readcount=0  then  wait(wmutex);
	  readcount:=readcount+1;
	  signal(rmutex);
		    …				
      perform read operation
		    …
	  wait(rmutex);
	  readcount:=readcount-1;
	  if readcount=0  then signal(wmutex);
	  signal(rmutex);
	}while(TRUE);
}//end reader

	void writer( ){
	    do{
	       wait(wmutex)
           perform write operation;
		signal(wmutex)
		}while(TRUE);
	}
void main(){
    cobegin
        reader(); writer();
    coend
}

2.信号量集解决读者—写者问题(略)

int RN;
Semaphore L=RN, mx=1;
//RN标示同时允许多少读进程存在
void reader( ){
	   do{
              swait(L,1,1);
              swait(mx,1,0);
                   …
 	        perform read operation;
		        …
	         ssignal(L,1);
        }while(TRUE);
}//end reader

 void writer( ){
        do{
             swait(mx,1,1; L,RN,0);
             perform write operation;
             ssignal(mx, 1);
        }while(TRUE);
 } //end writer

 void main( ){
    cobegin
       reader(); writer();
    coedn
}

2.6 进程通信

进程通信——是指进程之间的信息交换。

进程通信分为两类:

①低级通信:信号量机制缺点:

(1)效率低

(2)通信对用户不透明

②高级通信:直接利用操作系统所提供的一组通信命令,高效地传送大量数据的一种通信方式。

特点:效率高,通信实现细节对用户透明

2.6.1 进程通信的类型

在消息传递系统中,进程间的数据交换,是以格式化的消息为单位的;

程序员直接利用系统提供的一组通信命令(原语)进行通信。

1.共享存储器系统

 (1)基于共享数据结构的通信方式。

在共享存储器系统(Shared-Memory System)中,

相互通信的进程共享某些数据结构或共享存储区,

进程之间能够通过这些空间进行通信。

据此,又可把它们分成以下两种类型:

基于共享数据结构的通信方式。

在这种通信方式中,要求诸进程公用某些数据结构,借以实现诸进程间的信息交换。

如在生产者—消费者问题中,就是用有界缓冲区这种数据结构来实现通信的。

这里,公用数据结构的设置及对进程间同步的处理,都是程序员的职责。

这无疑增加了程序员的负担,而操作系统却只须提供共享存储器。

因此,这种通信方式是低效的,只适于传递相对少量的数据。

 (2)基于共享存储区的通信方式。 

(2) 基于共享存储区的通信方式。

为了传输大量数据,在存储器中划出了一块共享存储区,

诸进程可通过对共享存储区中数据的读或写来实现通信。

这种通信方式属于高级通信。

进程在通信前,先向系统申请获得共享存储区中的一个分区,并指定该分区的关键字;

若系统已经给其他进程分配了这样的分区,则将该分区的描述符返回给申请者,

继之,由申请者把获得的共享存储分区连接到本进程上;

此后,便可像读、写普通存储器一样地读、写该公用存储分区。

2.消息传递系统

是目前的主要通信方式,信息单位:消息(报文)

实现:一组通信命令(原语),具有透明性->同步的实现。

     实现方式的不同,而分成:

   (1)直接通信方式

   (2)间接通信方式

3.管道(Pipe)通信

管道:连接一个读进程和一个写进程之间通信的共享文件。

功能:大量的数据发收。

注意:

(1)互斥

(2)同步

(3)对方是否存在

2.6.2 消息传递通信的实现方法

1.直接通信方式

    这是指发送进程利用OS所提供的发送命令,直接把消息发送给目标进程。

系统提供下述两条通信命令(原语):

     Send  (Receiver,  message);

     Receive(Sender,  message);

例:解决生产—消费问题。
 

    do{
          …        
                produce an item in nextp;
                 …
                send(consumer, nextp);
           }while(TRUE);
           do{
               receive( producer, nextc);
                 …
               consumer the item in nextc;
          }while(TRUE);

2.间接通信方式

指进程之间利用信箱的通信方式。发送进程发送给目标进程的消息存放信箱;

接收进程则从该信箱中,取出对方发送给自己的消息;消息在信箱中可以安全地保存,只允许核准的目标用户随时读取。

系统为信箱通信提供了若干条原语,分别用于信箱的创建、撤消和消息的发送、接收等。

优点:在读/写时间上的随机性

写进程->信箱(中间实体)->读进程原语

消息的发送和接收

Send (mailbox, message)

Receive (mailbox, message)

信箱分为以下三类:

(1)私用信箱

(2)公用信箱

(3)共享信箱

在利用信箱通信时,在发送进程和接收进程之间,存在以下四种关系:

(1)一对一关系。

(2)多对一关系,客户/服务器交互。

(3)一对多关系, 广播方式。

(4)多对多关系。  

*2.6.3 消息传递系统中的几个问题

1.消息格式:

消息头:含控制信息如:收/发进程名,消息长度、类型、编号

消息内容

定长消息:系统开销小,用户不便(特别是传长消息用户)

变长消息:开销大,用户方便。

2.消息格式进程同步方式:

1)发送和接收进程阻塞(汇合)

用于紧密同步,无缓冲区时。

2)发送进程不阻塞,接收进程阻塞(多个)

相当于接收进程(可能是多个)一直等待发送进程,如:打印进程等待打印任务。

3)发送/接收进程均不阻塞

一般在发、收进程间有多个缓冲区时。

*2.6.4 消息缓冲队列通信机制

1.数据结构

1)消息缓冲区
typedef struct message_buffer{ 
    int sender;   //发送者进程标识符
    int size;        //消息长度
    int *text;       // 消息正文
   struct message_buffer *next;   //指向下一缓冲区
}
2) PCB中应增数据项:
typedef struct pcb {
    struct message_buffer *mq;      //消息队列首指针
    semaphore  mutex;      //消息队列互斥信息量
    semaphore  sm;   //消息队列资源信息量(表资源消息数)
}

2.发送原语

void send(receiver, a){
	  getbuf(a.size, i);
	  i.sender:=a.sender;
	  i.size:=a.size;
	  copy( i.text, a.text);
	  i.next:=0;
	  getid(PCBset, receiver.j);
	  wait(j.mutex);
	  insert(&j.mq, i);
	  signal(j.mutex);
	  signal(j.sm);
}

3. 接收原语

void receive(b) {
	    j:=internal name;
	    wait(j.sm);
	    wait(j.mutex);
	    remove(j.mq, i);
	    signal(j.mutex);
	    b.sender:=i.sender;
	    b.size:=i.size;
	    copy( b.text, i.text);
        releasebuf(i);
}

练习:

试说明如果使用send_mailbox和receive_mailbox 原语实现打印文件的系统。欲打印的进程将要打印的文件名发送到邮箱printer,打印机假脱机将打印邮箱中出现名字的任何文件

//process wish to print
Char		filename[ ];
Status=send_mailbox(“printer”,filename);
If (status< 0)
{  //failure}

//print spooler
char filename[ ]
while (true)
{	status=receive_mailbox(“printer”,filename);
	if(status <0)
		{//failure}
	print(filename);
}

2.7 线程与线程控制

1.1 线程的引入:使多个程序更好的并发执行

为使程序能并发执行,系统必须进行以下的一系列操作。

         1) 创建进程

         2) 撤消进程

         3) 进程切换

由于进程是一个资源的拥有者,因此在创建、撤销和切换中,

系统必须为此付出较大的时间和空间的开销。

如何使多个程序更好的并发执行,同时又能减少系统开销?

引入线程

进程的概念体现出两个特点:资源所有权、调度执行

①资源所有权:一个进程包括一个保存进程映像的虚地址空间,并且随时分配对资源的控制或所有权,包括内存、I/O通道、I/O设备、文件等。

②调度/执行:进程是被操作系统调度的实体。

调度和分派的部分通常称为线程或轻型进程(lightweight process),而资源所有权的部分通常称为进程。

60年代:提出进程(Process)概念,在OS中一直都是以进程作为能拥有资源和独立运行的基本单位的。

80年代中期:提出线程(Thread)概念,线程是比进程更小的能独立运行的基本单位,目的是提高系统内程序并发执行的程度,进一步提高系统的吞吐量。

90年代:多处理机系统得到迅速发展,线程能比进程更好地提高程序的并行执行程度,充分发挥多处理机的优越性。

进程回顾

进程 = 资源 + 指令执行

将资源和指令执行分开

一个资源 + 多个指令执行序列

线程: 保留了并发的优点,避免了进程的高代价

多个执行序列+一个地址空间是否实用?

一个网页浏览器

  • 一个线程用来从服务器接收数据
  • 一个线程用来显示文本
  • 一个线程用来处理图片(如解压缩)
  • 一个线程用来显示图片

这些线程共用一个地址空间吗?

  • 同一个服务器建立连接并接收数据
  • 所有的文本、图片都显示在一个屏幕上

这几个线程并发执行。接收一段数据,切换到显示文本,再切换回接收数据。

这几个线程共用进程的同一个内存地址空间,线程切换时资源不会切换。

创建进程和线程

进程的概念体现出两个特点:资源(代码和数据空间、打开的文件等)以及调度执行

线程是进程内的独立执行代码的实体和调度单元

 1.2 线程的共享问题

进程内的所有线程共享进程的很多资源(这种共享又带来了同步问题)

 线程间共享                 线程私有

进程指令                       线程ID

全局变量                         寄存器集合(包括PC和栈指针)

打开的文件                         栈(用于存放局部变量)

信号处理程序                      信号掩码

当前工作目录                      优先级        

用户ID

线程的数据共享

线程间共享的数据和资源:进程代码段、进程中的全局变量、进程打开的文件……

每个线程私有的数据和资源:线程ID、线程上下文(一组寄存器值的集合)、线程局部变量(存储在栈中)

 1.3 线程的互斥问题

对全局变量进行访问的基本步骤

  • 将内存单元中的数据读入寄存器
  • 对寄存器中的值进行运算
  • 将寄存器中的值写回内存单元

2.线程与进程的比较

线程具有许多传统进程所具有的特征,所以又称为轻型进程(Light-Weight Process) ,

相应地把传统进程称为重型进程(Heavy-Weight Process),传统进程相当于只有一个线程的任务。

在引入了线程的操作系统中,通常一个进程都拥有若干个线程,至少也有一个线程。

线程只拥有少量在运行中必不可少的资源

  • PC指针:标识当前线程代码执行的位置
  • 寄存器:当前线程执行的上下文环境
  • 栈:用于实现函数调用、局部变量
  • 线程局部变量和私有数据(在栈中申请的数据)
  • 线程信号掩码(可以设置每个线程阻塞的信号)

进程占用资源多,线程占用资源少,使用灵活

线程不能脱离进程而存在,线程的层次关系,执行顺序并不明显,会增加程序的复杂度

没有通过代码显示创建线程的进程,可以看成是只有一个线程的进程

从调度性、并发性、系统开销和拥有资源等方面对线程和进程进行比较。

1) 调度

在传统的操作系统中,进程作为拥有资源和独立调度、分派的基本单位。而在引入线程的操作系统中,则把线程作为调度和分派的基本单位,而进程作为资源拥有的基本单位。

在同一进程中,线程的切换不会引起进程的切换;但从一个进程中的线程切换到另一个进程中的线程时,将会引起进程的切换。

2) 并发性

在引入线程的操作系统中,不仅进程之间可以并发执行,而且在一个进程中的多个线程之间亦可并发执行,使得操作系统具有更好的并发性,从而能更加有效地提高系统资源的利用率和系统的吞吐量。

例如,在一个未引入线程的单CPU操作系统中,若仅设置一个文件服务进程,当该进程由于某种原因而被阻塞时,便没有其它的文件服务进程来提供服务。

在引入线程的操作系统中,可以在一个文件服务进程中设置多个服务线程。当第一个线程等待时,文件服务进程中的第二个线程可以继续运行,以提供文件服务;当第二个线程阻塞时,则可由第三个继续执行,提供服务。显然,这样的方法可以显著地提高文件服务的质量和系统的吞吐量。

3) 拥有资源

一般而言,线程自己不拥有系统资源(也有一点必不可少的资源),但它可以访问其隶属进程的资源,即一个进程的代码段、数据段及所拥有的系统资源,如已打开的文件、I/O 设备等,可以供该进程中的所有线程所共享。

4) 独立性

同一进程中的不同线程共享进程的内存空间和资源。

同一进程中的不同线程的独立性低于不同进程。

5)系统开销

 线程的切换只需要保存和设置少量的寄存器内容,不涉及存储器管理方面的操作。

由于一个进程中的多个线程具有相同的地址空间,在同步和通信的实现方面线程也比进程容易。在一些操作系统中,线程的切换、同步和通信都无须操作系统内核的干预。

6)支持多处理机系统

一个进程分为多个线程分配到多个处理机上并行执行,可加速进程的完成。

3. 线程的属性

(1)轻型实体

线程自己基本不拥有系统资源,只拥有少量必不可少的资源:TCB,程序计数器、一组寄存器、栈。

(2)独立调度和分派的基本单位

在多线程OS中,线程是独立运行的基本单位,因而也是独立调度和分派的基本单位。

(3)可并发执行

同一进程中的多个线程之间可以并发执行,一个线程可以创建和撤消另一个线程。

(4)共享进程资源

它可与同属一个进程的其它线程共享进程所拥有的全部资源。

例子:
LAN中的一个文件服务器,在一段时间内需要处理几个文件请求
有效的方法是:为每一个请求创建一个线程
在一个SMP机器上:多个线程可以同时在不同的处理器上运行

下面关于线程的叙述中,正确的是( C )。
A.不论是系统支持线程还是用户级线程,其切换都需要内核的支持。
B.线程是资源的分配单位,进程是调度和分配的单位。
C.不管系统中是否有线程,进程都是拥有资源的独立单位。
D.在引入线程的系统中,进程仍是资源分配和调度分派的基本单位。

4.  线程的状态

同进程一样,线程之间也存在共享资源和相互合作的制约关系,致使线程在运行时也具有间断性。

线程运行时有以下3种状态:

    ①执行状态:表示线程正获得CPU而运行;

    ②就绪状态:表示线程已具备了各种运行条件,一旦获得CPU便可执行;

    ③阻塞状态:表示线程在运行中因某事件而受阻,处于暂停执行的状态;

   

线程的状态转换:

  三种状态:就绪、阻塞和运行。线程中的挂起状态是指线程的“不可运行状态”(阻塞状态)。

   5. 线程的组成

线程必须在某个进程内执行

一个进程可以包含一个线程或多个线程

每个线程有一个TCB结构,即线程控制块,用于保存自己私有的信息,主要由以下部分组成:

一个唯一的线程标识符

一组寄存器 :包括程序计数器、状态寄存器、通用寄存器的内容;

线程运行状态:用于描述线程正处于何种运行状态;

优先级:描述线程执行的优先程度;

线程专有存储器:用于保存线程自己的局部变量拷贝;

信号屏蔽:对某些信号加以屏蔽。

两个栈指针:核心栈、用户栈

线程ID

同进程一样,每个线程也有一个线程ID

进程ID在整个系统中是唯一的,线程ID只在它所属的进程环境中唯一

线程ID的类型是pthread_t,在Linux中的定义如下:

    typedef unsigned long int pthread_t

(/usr/include/bits/pthreadtypes.h)

获取线程ID

pthread_self函数可以让调用线程获取自己的线程ID

函数原型

头文件:pthread.h

pthread_t pthread_self();

返回调用线程的线程ID

比较线程ID

Linux中使用整型表示线程ID,而其他系统则不一定

FreeBSD 5.2.1、Mac OS X 10.3用一个指向pthread结构的指针来表示pthread_t类型。

为了保证应用程序的可移植性,在比较两个线程ID是否相同时,建议使用pthread_equal函数

pthread_equal函数

该函数用于比较两个线程ID是否相同

函数原型

头文件:pthread.h

int pthread_equal(pthread_t tid1, pthread_t tid2);

若相等则返回非0值,否则返回0

进程/线程控制操作对比

应用功能

线程

进程

创建

pthread_create

fork,vfork

退出

pthread_exit

exit

等待

pthread_join

waitwaitpid

取消/终止

pthread_cancel

abort

读取ID

pthread_self()

getpid()

同步互斥/通信机制

互斥锁、条件变量、读写锁

无名管道、有名管道、信号、消息队列、信号量、共享内存

 6. 线程的创建和终止

线程的创建:

  • 在多线程OS环境下,应用程序在启动时,通常仅有一个“初始化线程”线程在执行。
  • 在创建新线程时,需要利用一个线程创建函数(或系统调用),并提供相应的参数。
  • 如指向线程主程序的入口指针、堆栈的大小,以及用于调度的优先级等。
  • 在线程创建函数执行完后,将返回一个线程标识符供以后使用。

线程的终止:

  • 线程完成了自己的工作后自愿退出;
  • 或线程在运行中出现错误或由于某种原因而被其它线程强行终止。

Linux下多线程编程(C语言)

Linux系统下的多线程遵循POSIX线程接口,称为pthread。

进程创建:

#include <pthread.h>
int pthread_create(
    pthread_t  *restrict  tidp,              //指向线程标识符的指针
  const  pthread_attr_t  *restrict  attr,  //设置线程属性
  void  *(*start_rtn)(void),              //线程运行函数的起始地址
  void  *restrict  arg;    )                 //运行函数的参数

线程实现浏览器

void WebExplorer(char *URL)
{
   pthread_t tid1, tid2, tid3, tid4;
   pthread_attr_t attr1, attr2, attr3, attr4;
   pthread_attr_init(&attr1); ...
   pthread_create(&tid1,&attr1,GetData,URL);
    ...
}
void GetData(char *URL){...};
void ShowText(){...};
void ProcessImage(){...};
void ShowImage(){...};

创建子线程代码示例

void *childthread(void){
int i;
for(i=0;i<10;i++){
	printf(“childthread message\n”);
	sleep(3);
}
}
int main(){
	pthread_t tid;
	printf(“create childthread\n”); 
  pthread_create(&tid,NULL,(void *) childthread,NULL);
  sleep(3);
	printf(“process exit\n”);
}

线程的终止

线程的三种正常终止方式

  • 线程从启动线程函数中返回,函数返回值作为线程的退出码 return( )
  • 线程被同一进程中的其他线程取消 pthread_cancel( )
  • 线程在任意函数中调用pthread_exit函数终止执行

线程的终止函数

线程的退出:

  #include <pthread.h>
  void pthread_exit(void *rval_ptr);

由于pthread库不是Linux系统默认的库,连接时需要使用库libpthread.a,所以如果使用pthread_create、pthread_exit等函数时,在编译中要加-lpthread参数: #gcc -o XXX -lpthread XXX.c

父线程等待子线程终止

函数原型

头文件:pthread.h

int pthread_join(pthread_t thread, void **rval_ptr);

调用该函数的父线程将一直被阻塞,直到指定的子线程终止

返回值

成功返回0,否则返回错误编号

参数

thread:需要等待的子线程ID

rval_ptr:(若不关心线程返回值,可直接将该参数设置为空指针NULL)

若线程从启动例程返回,rval_ptr将包含返回码

若线程被取消,rval_ptr指向的内存单元值置为PTHREAD_CANCELED

若线程通过调用pthread_exit函数终止,rval_ptr就是调用pthread_exit时传入的参数

创建并等待子线程代码示例

void *childthread(void){
 int i;
 for(i=0;i<10;i++){
	   printf(“childthread message\n”);
	   sleep(3); 
 } 
}
int main(){
	pthread_t tid;
	printf(“create childthread\n”); 
  pthread_create(&tid,NULL,(void *) childthread,NULL);
	pthread_join(tid,NULL);
	printf(“childthread exit process exit\n”); 
}

取消线程

线程调用该函数可以取消同一进程中的其他线程(即让该线程终止)

函数原型

头文件: pthread.h

int pthread_cancel(pthread_t tid);

参数与返回值

tid:需要取消的线程ID

成功返回0, 出错返回错误编号

在默认情况下,pthread_cancel函数与线程ID等于tid的线程自身调用pthread_exit函数(参数为PTHREAD_CANCELED)效果等同;

线程可以选择忽略取消方式或者控制取消方式;

pthread_cancel并不等待线程终止,它仅仅是提出请求

线程清理处理函数

当线程终止时,可以调用自定义的线程清理处理函数,进行资源释放等操作。类似于atexit函数。

线程可以注册多个清理处理函数,这些函数被记录在栈中,它们的执行顺序与它们的注册顺序相反

线程清理处理函数的注册

头文件:pthread.h

void pthread_cleanup_push(void (*rtn)(void *),

                            void *arg);

void pthread_cleanup_pop(int execute);

参数

rtn:清理函数,无返回值,包含一个类型为指针的参数

arg:当清理函数被调用时,arg将被传递给清理函数

清理函数被调用的时机

线程自身调用pthread_exit时

线程响应取消线程请求时

以非0参数调用pthread_cleanup_pop时

线程退出与清理示例

void cleanup(void *arg){
    printf("clean...\n");
}

void *My_thread(void *arg){
    printf("My thread\n");
    pthread_cleanup_push(cleanup,"123");
    pthread_exit(NULL);        
    pthread_cleanup_pop(0);
}

int main(){
    pthread_t tid;
    int t = pthread_create(&tid,NULL,My_thread,NULL);
    pthread_join(tid,NULL);
    return 0;
}

pthread_detach函数

在任何一个时间点上,线程是可结合的(joinable)或者是分离的(detached)

可结合的线程能够被父线程回收其资源和杀死。在被父线程回收之前,它的存储器资源(例如栈)是不释放的

分离的线程是不能被父线程回收或杀死的,它的存储器资源在它终止时由系统自动释放

若线程已经处于分离状态,线程的底层存储资源可以在线程终止时立即被收回

当线程被分离时,并不能用pthread_join函数等待它的终止状态,此时pthread_join返回EINVAL

pthread_detach函数可以使线程进入分离状态

函数原型

头文件:pthread.h

int pthread_detach(pthread_t tid);

参数与返回值

tid:进入分离状态的线程的ID

成功返回0,出错返回错误编号

7、线程的属性

线程属性

前面讨论pthread_create时,针对线程属性,传入的参数都是NULL。

实际上,可以通过构建pthread_attr_t结构体,设置若干线程属性

要使用该结构体,必须首先对其进行初始化;使用完毕后,需要销毁它

POSIX规定的一些线程属性

初始化和销毁

函数原型

#include<pthread.h>

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destroy(pthread_attr_t *attr);

参数与返回值

成功返回0,否则返回错误编号

attr:线程属性,确保attr指向的存储区域有效

为了移植性,pthread_attr_t结构对应用程序是不可见的,应使用设置和查询等函数访问属性

线程属性操作示例代码

int main(void) {
int ret;
pthread_t pid;   /* 线程ID */
pthread_attr_t pattr;  /* 线程属性结构体 */
struct sched_param param; /* 线程优先级结构体 */ 

pthread_attr_init(&pattr);  /* 初始化线程属性对象,这时是默认值 */ 
pthread_attr_setscope(&pattr, PTHREAD_SCOPE_SYSTEM); /* 设置线程绑定 */
pthread_attr_getschedparam(&pattr, &param); /* 修改线程优先级 */
param.sched_priority = 20;
pthread_attr_setschedparam(&pattr, &param);
/* 使用设置好的线程属性来创建一个新的线程 */
ret = pthread_create(&pid, &pattr, (void *)thread, NULL);
……

初始化线程属性对象

属性

缺省值

描述

scope

PTHREAD_SCOPE_PROCESS

新线程与进程中的其他线程发生竞争

detachstate

PTHREAD_CREATE_JOINABLE

线程可以被其它线程等待

stackaddr

NULL

新线程具有系统分配的栈地址

stacksize

0

新线程具有系统定义的栈大小

priority

0

新线程的优先级为0

inheritsched

PTHREAD_EXPLICIT_SCHED

新线程不继承父线程调度优先级

schedpolicy

SCHED_OTHER

新线程使用优先级调用策略

获取线程栈属性

函数原型

#include<pthread.h>
int pthread_attr_getstack(
         const pthread_attr_t *attr,
          void **stackaddr, size_t *stacksize);

返回值
成功返回0,否则返回错误编号

设置线程栈属性

函数原型

#include<pthread.h>
int pthread_attr_setstack(
         const pthread_attr_t *attr,
          void *stackaddr, size_t *stacksize);

返回值
成功返回0,否则返回错误编号

 8.线程间的同步和通信

为使系统中的多线程能有条不紊的运行,系统必须提供用于实现线程间同步和通信的机制。在多线程OS中,通常提供多种同步机制:

      1、互斥锁(mutex)

      2、条件变量

      3、信号量机制

 1. 互斥锁(mutex)

 互斥锁是一种比较简单的、用于实现进程间对资源互斥访问的机制。

由于操作互斥锁的时间和空间开销都较低,因而较适合于高频度使用的关键共享数据和程序段。

互斥锁可以有两种状态, 即开锁(unlock)和关锁(lock)状态。

关锁的两种方式

阻塞方式

lock(mutex)

访问

unlock(mutex)

//用来锁住互斥体变量。如果参数mutex 所指的互斥体已经被锁住了,那么发出调用的线程将被阻塞直到其他线程对mutex 解锁。

非阻塞方式

if(trylock) then

else

//如果互斥体已经被上锁,该调用不会阻塞等待,而会返回一个错误代码。

Linux 中的线程互斥锁

int pthread_mutex_lock(pthread_mutex_t *mutex);  
    //返回时,互斥锁已被锁定。该线程使互斥锁锁住。如果互斥锁已被另一个线程锁定和拥有,则该线程将阻塞,直到互斥锁变为可用为止。

int pthread_mutex_unlock(pthread_mutex_t *mutex);
    //释放互斥锁,与pthread_mutex_lock成对存在。

2. 条件变量

  每一个条件变量通常都与一个互斥锁一起使用。

  单纯的互斥锁用于短期锁定,主要是用来保证对临界区的互斥进入。而条件变量则用于线程的长期等待, 直至所等待的资源成为可用的。

对资源申请操作的描述

Lock mutex    //第一步:执行关锁操作,若成功便进入临界区
      check data structures; 	
                            //查找用于描述资源状态的数据结构,了解资源的情况。
      while(resource busy); 
            wait(condition variable); 
//第二步:所需资源R正处于忙碌状态,线程便转为等待状态,并对mutex执行开锁操作后,等待该资源被释放;
       mark resource as busy;
//第三步:若资源处于空闲状态,表明线程可以使用该资源,于是将该资源设置为忙碌状态,再对mutex执行开锁操作。
 unlock mutex; 

对资源释放操作的描述:


Lock mutex
  mark resource as free;
unlock mutex;
  wakeup(condition variable);

    //原来占有资源R 的线程在使用完该资源后,便按照上述描述释放该资源,其中的wakeup()表示去唤醒在指定条件变量上等待的一个或多个线程。

3. 信号量机制

 (1) 私用信号量(private samephore)。

        当某线程需利用信号量来实现同一进程中各线程之间的同步时,可调用创建信号量的命令来创建一私用信号量,其数据结构存放在应用程序的地址空间中。

        私用信号量属于特定的进程所有,OS并不知道私用信号量的存在,因此,一旦发生私用信号量的占用者异常结束或正常结束,但并未释放该信号量所占有空间的情况时,系统将无法使它恢复为0(空),也不能将它传送给下一个请求它的线程。

 (2) 公用信号量(public semaphort)。

        公用信号量是为实现不同进程间或不同进程中各线程之间的同步而设置的。

有着一个公开的名字供所有的进程使用,故称为公用信号量。

其数据结构是存放在受保护的系统存储区中,由OS为它分配空间并进行管理,故也称为系统信号量。

如果信号量的占有者在结束时未释放该公用信号量,则OS会自动将该信号量空间回收,并通知下一进程。

公用信号量是一种比较安全的同步机制。

2.8  线程的实现方式

 1. 用户级线程(User Level Threads)

  用户级线程仅存在于用户空间中。对于这种线程的创建、撤消、线程之间的同步与通信等功能,都无须内核来实现。

 对于用户级线程的切换,通常是发生在一个应用进程的诸多线程之间,这时,也同样无须内核的支持。

由于切换的规则远比进程调度和管理的规则简单,因而使线程的切换速度特别快。可见,这种线程是与内核无关的。

由应用程序完成所有线程的管理

  线程库(用户空间):通过一组管理线程的函数库来提供一个线程运行管理系统(运行系统)

内核不知道线程的存在

线程切换不需要核心态特权

调度算法可以是进程专用的

用户级线程的工作模型

进程中的某个线程在内核阻塞,则整个进程阻塞!

 

用户级线程的优点和缺点

优点:

线程切换不调用内核

调度是应用程序特定的:可以选择最好的算法

可运行在任何操作系统上(只需要线程库),可以在一个不支持线程的OS上实现

缺点:

当线程执行一个系统调用时,该线程及其所属进程内的所有线程都会被阻塞。

多线程应用不能利用多处理机进行多重处理。

2. 内核支持线程 (Kernel Supported Threads)

 内核支持线程,是在内核的支持下运行的,即无论是用户进程中的线程,还是系统进程中的线程,他们的创建、撤消和切换等,是依靠内核实现的。

在内核空间中为每一个内核支持线程设置了一个线程控制块TCB, 内核是根据该控制块而感知某线程的存在的,并对其加以控制。

内核支持线程(Kernel Supported Threads)

所有线程管理由内核完成

没有线程库,但内核提供API

线程的创建、撤消是依靠内核实现的

线程之间的切换需要内核支持

以线程为基础进行调度

内核是根据每个线程的控制块来控制线程的

内核支持线程的工作模型

用户创建的就是核心级线程! 系统调用

内核支持线程的主要优点

(1) 在多处理器系统中,内核能够同时调度同一进程中多个线程并行执行;

(2) 如果进程中的一个线程被阻塞了,内核可以调度该进程中的其它线程占有处理器运行,也可以运行其它进程中的线程;

(3) 内核支持线程具有很小的数据结构和堆栈,线程的切换比较快,切换开销小;

(4) 内核本身也可以采用多线程技术,可以提高系统的执行速度和效率。

对于线程切换而言,其模式切换的开销较大

在同一个进程中,从一个线程切换到另一个线程时,

需要从用户态转到内核态再转到用户态进行,

这是因为用户进程的线程在用户态运行,

而线程调度和管理是在内核实现的,系统开销较大。

和用户级相比,内核支持线程有什么不同?

由内核完成线程的创建、调度等

主要工作仍是保存现场         在哪里?           内核栈

每个执行序列需有两个栈: 用户栈+内核栈

用户栈:普通的函数调用

内核栈:系统调用、中断处理

对于设置了用户级线程的系统,其调度仍是以进程为单位进行,在采用时间片轮转调度算法时,各进程间是公平的,但各进程的线程间是不公平的;

例:进程A包含了1个用户级线程,进程B包含了100个用户级线程,进程A中线程运行的时间和进程B中各线程的运行时间是否一样?

不一样,进程A中线程运行的时间是进程B中各线程的100倍

若进程A和进程B中的线程都是内核支持线程,两者的运行时间是否一样?

  若进程A和进程B中的线程都是内核支持线程,则调度是以线程为单位进行。进程B获得CPU的时间是进程A的100倍,两进程中的各线程获得相同的CPU时间;

3. 组合方式(用户级线程和内核支持线程并存)

用户级线程是在用户空间实现的。所有用户级线程都具有相同的数据结构,它们都运行在一个中间系统上。

当前有两种方式实现的中间系统:

1)运行时系统(又称为线程库)

  用于管理和控制线程的函数的集合,包括创建、撤消线程函数、线程同步和通信函数、线程调度函数等。

  用户级线程不能直接利用系统调用,必须通过运行时系统间接利用系统调用。

2)内核控制线程

  这种线程又称为轻型进程LWP(Light Weight Process)

  每个进程都可拥有多个LWP,每个LWP都有自己的TCB,其中包括线程标识符、优先级、状态、栈和局部存储区等

  LWP可通过系统调用来获得内核提供的服务,当一个用户级线程运行时,只要将它连接到一个LWP上,它便具有了内核支持线程的所有属性。

利用轻型进程作为中间系统实现用户级线程

用LWP实现用户级线程和内核级线程的捆绑

线程库(用户级)可以控制 LWP、并完成捆绑

每个 LWP一直捆绑在一个内核级线程上

利用轻型进程作为中间系统实现用户级线程

在一个系统中,用户级线程的数量可能很大,为节省系统开销不可能设置太多的LWP,而把这些LWP做成一个缓冲池,用户进程中的任一线程都可连接到LWP池的任一LWP上。

多个用户级线程多路复用一个LWP,只有当前连接到LWP上的线程才能与内核通信,其余线程或阻塞,或等待LWP。

每个LWP都要连接到一个内核支持线程上,由此,LWP把用户级线程与内核支持线程连接起来,用户级线程便可访问内核。

实现方案的对比

采用线程的优点:

1.在一个已有进程中创建一个新线程比创建一个全新进程所需的时间少。

2.终止一个线程比终止一个进程花费的时间少。

3.线程间切换比进程间切换花费的时间少。

4.线程提高了不同的执行程序间通信的效率。同一个进程中的线程共享存储空间和文件,它们无需调用内核就可以互相通信

线程总结

进程=地址空间+指令执行序列

一个地址空间+多个指令执行序列→引出线程

线程具有并发的优点,却比进程的代价低得多

WebExplorer表明线程简单实用→线程怎么实现

线程在同一地址空间中→线程库可以用户级实现

用户级线程,核心级线程,两者都有

各类线程的实现细节,其中上下文切换是核心

猜你喜欢

转载自blog.csdn.net/aiqq136/article/details/123192104