操作系统—线程

操作系统—线程

进程与线程

进程

进程是操作系统提供的最古老的也是最重要的抽象概念之一,即使可以使用的CPU只有一个,但它们也具有支持(伪)并发操作的能力。

进程和程序间的区别是很微妙的:

想象有一位厨艺非凡的科学家正在为他的女儿制作蛋糕。他有做蛋糕的食谱,厨房里面有需要的原料:面粉,鸡蛋,糖等。

上面的例子中:做蛋糕的食谱就是程序(用适当形式描述的算法),科学家就是处理器,而做蛋糕的各种原料就是输入数据,进程就是科学家阅读食谱,取来各种原料以及制作蛋糕等一系列动作的总和。

现在假设科学家的儿子哭着跑了进来,说他的头被蜜蜂蛰了。科学家就记录下他照着食谱做到哪了(保存进程的当前状态),然后拿出一本急救手册,按照里面的指示处理儿子的蛰伤。这里,处理器就从一个进程(做蛋糕)切换到了另一个高优先级的进程(医疗救治)。每个进程拥有各自的程序(食谱和急救手册),当儿子的伤处理完了之后,科学家又回到厨房继续做蛋糕,从他离开时的那一步继续做下去。

这其中的关键思想:

一个进程是某种类型的一个活动,它有程序,输入,输出以及状态,单个处理器可以被若干进程共享,它使用某种调度算法来决定何时停止一个线程的工作,转而为另一个进程提供服务。

进程两个特点:

  • 资源所有权:进程包括存放进程映像的虚拟地址空间;进程映像是程序,数据,栈和进程控制块中定义的属性集;进程总具有对资源的控制权,这些资源包括内存,IO通道,IO设备,文件等。

  • 调度/执行:进程执行时采用一个或多程序的执行路径;不同进程的执行过程会交替进行。

线程

简单来说,线程就是存在一个进程里面的诸多的“迷你进程”。

而多线程是指操作系统在单个进程内支持多个并发执行路径的能力。下图展示了线程和进程:

图1.png

下图从进程管理的角度说明了线程和进程的区别:

图2.png

多线程环境下,进程仍然只有一个与之关联的进程控制块和用户地址空间,但每个线程现在会有许多单独的栈和一个单独的控制块,控制块中包含寄存器值,优先级和其他线程相关的状态信息。所以进程中的所有线程共享该进程的状态和资源,所有线程都驻留在同一块地址空间中,并可访问相同的数据。

线程分类

线程分为两大类:

  • 用户级线程(ULT)

  • 内核级线程(KLT)

用户级线程

在纯ULT软件中,管理线程的所有工作都由应用程序完成,内核意识不到线程的存在。

内核级线程

在纯KLT软件中,管理线程的所有工作均由内核完成,应用级没有线程管理代码,只有一个到内核线程设施的应用编程接口(API),Windows就是这种方法的一个例子。

下图展示了这两种模式以及一种组合模式:

图3.png

并发性:互斥和同步

并发原理

单处理器多道程序设计中,进程会交替的执行,因此表现出一种并发执行的外部特征。

多道程序设计系统有一个基本特性:进程的相对执行速度不可预测。

这就带来了问题:

  • 全局资源的共享充满了危险

  • 操作系统很难对资源进行最优化分配

  • 定位程序设计错误非常困难

一个简单例子


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,多个进程访问这个全局变量。

如果需要保护共享的全局变量,唯一的办法就是控制访问该变量的代码。

进程交互

下表列出了三种可能的感知程度以及每种感知程度的结果:

图4.png

进程间的资源竞争

进程竞争三个控制问题:

互斥

假设多个进程需要访问一个不可共享资源,如打印机,在执行过程中,每个进行都给该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之后因某些条件而被阻塞的进程

管程的结构:

图5.png

发布了229 篇原创文章 · 获赞 62 · 访问量 19万+

猜你喜欢

转载自blog.csdn.net/Coder_py/article/details/104792144