操作系统—线程
进程与线程
进程
进程是操作系统提供的最古老的也是最重要的抽象概念之一,即使可以使用的CPU只有一个,但它们也具有支持(伪)并发操作的能力。
进程和程序间的区别是很微妙的:
想象有一位厨艺非凡的科学家正在为他的女儿制作蛋糕。他有做蛋糕的食谱,厨房里面有需要的原料:面粉,鸡蛋,糖等。
上面的例子中:做蛋糕的食谱就是程序(用适当形式描述的算法),科学家就是处理器,而做蛋糕的各种原料就是输入数据,进程就是科学家阅读食谱,取来各种原料以及制作蛋糕等一系列动作的总和。
现在假设科学家的儿子哭着跑了进来,说他的头被蜜蜂蛰了。科学家就记录下他照着食谱做到哪了(保存进程的当前状态),然后拿出一本急救手册,按照里面的指示处理儿子的蛰伤。这里,处理器就从一个进程(做蛋糕)切换到了另一个高优先级的进程(医疗救治)。每个进程拥有各自的程序(食谱和急救手册),当儿子的伤处理完了之后,科学家又回到厨房继续做蛋糕,从他离开时的那一步继续做下去。
这其中的关键思想:
一个进程是某种类型的一个活动,它有程序,输入,输出以及状态,单个处理器可以被若干进程共享,它使用某种调度算法来决定何时停止一个线程的工作,转而为另一个进程提供服务。
进程两个特点:
-
资源所有权:进程包括存放进程映像的虚拟地址空间;进程映像是程序,数据,栈和进程控制块中定义的属性集;进程总具有对资源的控制权,这些资源包括内存,IO通道,IO设备,文件等。
-
调度/执行:进程执行时采用一个或多程序的执行路径;不同进程的执行过程会交替进行。
线程
简单来说,线程就是存在一个进程里面的诸多的“迷你进程”。
而多线程是指操作系统在单个进程内支持多个并发执行路径的能力。下图展示了线程和进程:
下图从进程管理的角度说明了线程和进程的区别:
多线程环境下,进程仍然只有一个与之关联的进程控制块和用户地址空间,但每个线程现在会有许多单独的栈和一个单独的控制块,控制块中包含寄存器值,优先级和其他线程相关的状态信息。所以进程中的所有线程共享该进程的状态和资源,所有线程都驻留在同一块地址空间中,并可访问相同的数据。
线程分类
线程分为两大类:
-
用户级线程(ULT)
-
内核级线程(KLT)
用户级线程
在纯ULT软件中,管理线程的所有工作都由应用程序完成,内核意识不到线程的存在。
内核级线程
在纯KLT软件中,管理线程的所有工作均由内核完成,应用级没有线程管理代码,只有一个到内核线程设施的应用编程接口(API),Windows就是这种方法的一个例子。
下图展示了这两种模式以及一种组合模式:
并发性:互斥和同步
并发原理
单处理器多道程序设计中,进程会交替的执行,因此表现出一种并发执行的外部特征。
多道程序设计系统有一个基本特性:进程的相对执行速度不可预测。
这就带来了问题:
-
全局资源的共享充满了危险
-
操作系统很难对资源进行最优化分配
-
定位程序设计错误非常困难
一个简单例子
void echo()
{
chin = getchar();
chout = chin;
putchar(chout);
}
上面这个过程显示了字符回显程序的基本步骤:
每敲击一下键盘,就可以从键盘得到输入。每个输入字符保存在变量chin中,然后传送给变量chout,并回送给显示器。
系统中的每个程序都可以使用过程echo,这就带来问题了:
-
1 进程P1调用echo,并在getchar返回它的值并存储于chin后立即中断。此时输入的字符X保存在变量chin。
-
2 进程P2被激活,调用echo,echo过程运行得出结果,输入然后在屏幕上显式单个字符Y
-
3 P1恢复,但此时chin中的X已经被覆写,所以丢失了。chin中的Y值传送给chout显示出来
因此,第一个字符丢失,第二个字符显示了两次
问题关键在于共享全局变量chin,多个进程访问这个全局变量。
如果需要保护共享的全局变量,唯一的办法就是控制访问该变量的代码。
进程交互
下表列出了三种可能的感知程度以及每种感知程度的结果:
进程间的资源竞争
进程竞争三个控制问题:
互斥
假设多个进程需要访问一个不可共享资源,如打印机,在执行过程中,每个进行都给该IO设备发命令,接受状态信息,把这类资源叫做临界资源
使用临界资源的那部分程序叫做程序的临界区
互斥产生两个额外问题
- 死锁
如:进程P1,P2,资源R1,R2,每个进程都要访问这两个资源,那么可能出现:
操作系统把R1分配给P2,把R2分给P1,每个进程都在等待另一个资源,并且获得其他资源完成功能前,都不肯释放当前自己拥有的资源,那么这两个进程就会发生死锁
- 饥饿
如三个进程:p1,p2,p3,三个进程都周期性的访问资源R,可能出现:
p1持有资源,p2,p3延迟,等待这个资源。p1退出临界区时,p2和p3都允许访问R,假设操作系统把访问权给了p3,那在p3退出临界区之前p1又访问该临界区,p3结束后操作系统又把访问权给p1,那p2可能被无限的拒绝访问资源
互斥:硬件的支持
中断禁用
单处理器机器,并发进程不能重叠,只能交替。
进程可通过如下方法实施互斥:
while(true)
{
/* 禁用中断*/
/* 临界区*/
/* 启用中断*/
/* 其余部分*/
}
由于临界区不能被中断,所以可以保持互斥
但这种方法代价太高,由于处理器被限制成只能交替执行程序,因此执行效率会明显下降。
专用机器指令
在多处理器下,几个处理器共享对内存的访问,不存在主从关系,处理器间的行为是无关的,表现出一种对等关系。
硬件级别上,对存储单元的访问排斥对相同单元的其他访问,因此处理器的设计人员提出了一些机器指令。用于保证两个动作的原子性:
如在一个取指令周期中对一个存储单元的读和写或读和测试,在这个指令执行过程,任何其他指令访问内存都将被禁止,而且这些动作在一个指令周期中完成。
两种常见指令
比较和交换指令
int compare_and_swap(int *word,int testval,int newval)
{
int oldval;
oldval = *word;
if (oldval == testval) {
*word = newval;
}
return oldval;
}
这个指令的一个版本是用一个测试值(testval)检查一个内存单元(*word),如果这个内存单元的当前值是testval,就用newval取代该值,否则保持不变。
该指令总是返回旧的内存值,因此,如果返回值与测试值一样,说明该内存单元已经被更新了,由此可见这个原子指令由两部分组成:
-
比较内存单元值和测试值
-
值相同时产生交换
整个比较和交换功能按照原子操作执行,即它不接受中断。
比较和交换指令:
const int n = /* 进程个数 */
int bolt;
void P(int i)
{
while(true)
{
while(compare_and_swap(bolt,0,1) == 1)
{
/*不做任何事*/
}
/* 临界区 */
bolt = 0;
/* 其余部分*/
}
}
void main()
{
bolt = 0;
parbegin(P(1),P(2),...,P(n));
}
上面给出的是这个指令的互斥规程,共享变量bolt初始化为0,唯一可以进入临界区的进程是发现bolt等于0的那个进程。所有试图进入临界区的其他进程进入忙等待模式。
忙等待或自旋等待:
进程在得到临界区访问权之前,它只能继续执行测试变量的指令来得到访问权,除此之外不能做任何事情。一个进程离开临界区,它把bolt重置为0,此时只允许一个等待进程进入临界区,进程的选择取决于哪个进程正好接着执行紧接着的compare&swamp指令。
exchange指令
exchange指令定义如下
void exchange(int *register, int *memory)
{
int temp;
temp = *memory;
*memory = *register;
*register = temp;
}
这个指令交换一个寄存器的内容和一个存储单元的内容。
int const n = /* 进程个数 */
int bolt;
void P(int i)
{
while(true)
{
int keyi = 1;
do exchange(keyi,bolt)
while(keyi != 0);
/* 临界区*/
bolt = 0;
/*其余部分*/
}
}
void main()
{
bolt = 0;
parbegin(P(1),P(2),...,P(n));
}
上面代码显示了基于exchange指令的互斥协议:
共享变量bolt初始化为0,每个进程都使用一个局部变量key且初始化为1,唯一可以进入临界区的进程是发现bolt等于0的那个进程,它通过吧bolt置为1来避免其他进程进入临界区,一个进程离开临界区,它把bolt重置为0,允许另一个进程进入它的临界区。
机器指令方法的特点:
使用专用的机器指令实施互斥的优点:
-
适用于单线程或共享内存的多处理器上的任意数量的进程
-
简单且易于证明
-
可以用于支持多个临界区
缺点:
-
使用了忙等待
-
可能饥饿
-
可能死锁
信号量
基本原理:
多个进程通过简单的信号进行合作,可以强迫一个进程在某个位置停止,直到它接收到一个特定的信号。
为了发信号,需要使用一个叫做信号量的特殊变量,要通过信号量s传送信号,进程必须执行原语semSignal(s);要通过信号量s接收信号,进程须执行原语semWait(s);若相应信号仍未发送,则阻塞进程,直到发送完为止。
可把信号量视为一个值为整数的比变量,整数值上定义了三个操作:
-
一个信号量可以初始化为非负数
-
semWait操作使得信号量-1,若变成负数,则阻塞执行semWait的进程,否则进程继续执行
-
semSignal操作使得信号量+1,若值小于等于0,则被semWait操作阻塞的进程解除阻塞
信号量原语定义:
struct Semaphore
{
int count;
queueType queue;
}
void semWait(semaphore s)
{
s.count--;
if(s.count < 0)
{
/* 当前进程插入队列*/
/*阻塞当前进程*/
}
}
void semSignal(semaphore s)
{
s.count++;
if(s.count <= 0)
{
/*进程P从队列中移除*/
/*进程P插入就绪队列*/
}
}
用信号量解决互斥问题
设有n个进程,用数组P(i)表示,所有进程都要访问共享资源,每个进程进入临界区前执行semWait(s),若s为负数,则进程阻塞;若值为1,则s减为0,进程立即进入临界区;由于s不在为正,则其他进程都不能进入临界区
const int n = /* 进程数*/;
semaphore s = 1;
void P(int i)
{
while(true)
{
semWait(s);
/* 临界区*/
semSignal(s);
/*其余部分*/
}
}
void main()
{
parbegin(P(1),P(2)...P(n));
}
管程
管程是一种程序设计语言结构,它提供的功能与信号量一样,但更加易于控制。
使用信号的管程
管程是由一个或多个过程,一个初始化序列和局部数据组成的软件模块,主要特点:
-
局部数据变量只能被管程的过程访问,任何外部过程都不能访问
-
一个进程通过调用管程的一个过程进入管程
-
任何时候,只能有一个进程在管程中执行,调用管程的任何其它进程都被阻塞
管程通过使用条件变量来支持同步,这些条件变量包含在管程中,并且只有在管程中才能被访问:
-
cwait©:调用进程的执行在条件c上阻塞
-
csignal©:恢复执行在cwait之后因某些条件而被阻塞的进程
管程的结构: