线程、进程间通信

线程

在无线程的系统中,进程是:

  • 存储器、外设等资源的分配单位
  • 处理机调度的对象

在引入线程后:

  • 线程处理机调度的对象

  • 进程作为资源分配单位

  • 同一进程内可包含多个线程,他们共享进程的资源

线程的使用

引入线程的原因:

  • 并行实体共享同一个地址空间和所有可用数据的能力
  • 比进程更容易创建、撤销
  • 性能的提高。如果存在着大量的计算和大量的IO处理,多线程允许这些活动彼此重叠进行,加快应用程序执行的速度
  • 多处理机系统,多线程可以真正地并行

例子:文字处理软件

如果程序只有一个线程,那么只要已启动磁盘备份,键盘和鼠标输入的命令都会不予理睬,知道备份结束,用户将感觉到程序反应迟钝

使用三个线程:

  1. 一个线程和用户交互
  2. 第二个线程在后台重新进行格式处理
  3. 第三个线程处理磁盘备份

例子:万维网服务器

Web服务器:接受用户请求,将所请求的页面发回给客户机

多线程实现:

  1. 一个线程(分配程序)从网络中读入工作请求,检查请求后,提交给一个工作线程(唤醒睡眠的工作线程)

    while (true) {
        get_next_request(&buf);
        handoff_work(&buf);
    }
    
  2. 工作线程负责调入页面(读高度缓存或磁盘)

    while (true) {
        wait_for_work(&buf);
        look_for_page_in_cache(&buf, &page);
        if (page_not_in_cache(&page)) {
            read_page_from_disk(&page);
        }
        return_page(&page)
    }
    

例子:海量数据处理程序

线程的第三个例子时用于必须处理海量数据的应用程序。一般的方法时读入一块数据,处理它,然后再写回去。如果只有阻塞式系统调用,那么在数据读入和数据写出的时候,进程都会被阻塞,这就是问题。

多线程解决方案:

  1. 一个输入线程:将数据读入输入缓冲区
  2. 一个处理线程:从输入缓冲区取出数据,处理它们,并且将结果放入输出缓冲区
  3. 一个输出线程:把结果写回磁盘

线程模型

  • (a)每个有一个线程的三个进程
  • (b)一个有三个线程的进程

进程中不同的线程并没有像不同的进程那么独立。所有的线程严格地使用同一地址空间,也就是说他们共享相同的全局变量。

由于每个线程都可以访问进程地址空间中的所有内存地址,因此,一个线程可以读、写甚至清除掉另一个线程的堆栈。线程间时没有保护的。

每个进程的内容 每个线程的内容
地址空间 程序计数器
全局变量 寄存器
打开的文件 堆栈
子进程 状态
挂起的警告信号
信号和信号处理程序
账户信息
进程中所有线程共享的内容 每个线程私有的内容

进程间通信

进程间通信问题:

  1. 某个进程如何可以传递信息给另一个
  2. 必须确保两个或多个进程在进行关键活动(假设两个进程都试图抢夺最后1MB内存)时不至于彼此掐住
  3. 与固有的顺序有关:如果进程A产生数据,而进程B打印这些数据,在B开始打印之前,B必须等待A产生某些数据。

竞争条件(race condition)

两个或多个进程读或者写共享数据共享数据,而最后的结果取决于运行的精确时序,就称为竞争条件(race condition)

调试包含有竞争条件的程序比较困难,大部分测试运行的结果都没错,而一旦出现竞争,就会发生一些无法解释的怪异现象

两个进程要同时访问共享内存

设想我们的Spooler目录有很多槽,编号为0,1,2,……,每个槽都可以存放一个文件名。同时假设有两个共享变量:out指向下一个要打印的文件;in指向目录中下一个空槽,这两个变量都可能被保存在一个所有进程都可以访问的两个字长的文件中。在某一确定时刻,槽0到槽3为空,二槽4到槽6为满。几乎同时,进程A和进程B都决定排队打印一个文件,倾向如上图所示。

根据墨菲法则,可能发生如下情况:进程A读取in并将它的值7保存到一个局部变量next_free_slot中。与此同时,CPU切换到进程B,进程B也读取in,同样也得到7,同样也保存到他的局部变量next_free_slot中,在这一时刻,两个进程都认为下一个可用的槽为槽7。

现在,进程B继续运行。它将文件名存取槽7,并且更新in的值为8,然后,它接着执行其他操作。

最后,进程A再次运行,从上次中止的地方开始。它检查变量next_free_slot,发现其值为7,于是将它的文件名写入槽7,删掉了进程B刚刚写入的文件名,接着,它将计算next_free_slot+1,得到值8,于是设定in为8,此时Spooler目录内部时一直的,所以打印机端口监控程序发现不了任何错误,但进程B将永远的不到输出

临界区(critical region)

避免竞争条件的问题也可以用一种抽象的方式来阐述。部分时间内,进程做内部计算或者其他不会导致竞争条件的事情,但是,某些时候进程必须访问共享变量或文件,或者执行其他可能导致竞争的操作

对共享内存进行访问的程序部分就称为临界区(critical region)或者临界段(critical section)

如果我们能够安排使得两个进程不会同时处于其临界区,就可以避免竞争。

互斥(mutual exclusion)

如何避免竞争条件?实际上凡牵涉到共享内存、共享文件、以及共享任何资源的情况都会引发与前边类似的错误。要避免这种错误,关键是要找到某种途径来阻止多于一个的进程同时读写共享的数据。换言之,我们需要的是互斥(mutual exclusion)——即某种手段以确保当一个进程在使用一个共享变量或文件时,其他进程不能做同样的操作。为实现互斥而选择适当的原语是任何操作系统的主要设计内容之一。

使用临界区的互斥

进程A在时间T1进入其临界区,稍后,在时间T2进程B试图进入其临界区,但是由于其他进程已经在其临界区中而失败,因为我们在同一时刻只允许一个进程进入其临界区。

因此,B暂时挂起知道T3进程A离开临界区时,才允许B立即进入。

最后,B离开(在T4时刻),并且回到无进程处于临界区的情况。

互斥方案的四个条件

尽管这样的要求防止了竞争条件,但它还不能保证使用共享数据的并发进程能够正确和高效地进行操作。对于一个好的解决方案,我们需要以下四个条件:

  1. 没有两个进程同时处于其临界区
  2. 对CPU的速度或数目不做任何假设
  3. 在临界区外运行的进程不得阻碍其他进程
  4. 没有进程无休止地等待进去临界区

互斥方案

graph LR 非临界区-->互斥方案 互斥方案-->临界区 临界区-->互斥方案 互斥方案-->非临界区

忙等待的互斥

本节将看到几种实现互斥的方案。在这些方案中,当一个进程在临界区中更新共享内存时,其他进程将不会进入其临界区,也不会带来任何麻烦。

屏蔽中断

这种最简单的方法是使每个进程在进入临界区后先关中断,在离开之前再开中断。中断被关掉后,时钟中断也被屏蔽。CPU只有在发生时钟或其他中断时才会进行进程切换,这样关中断之后CPU将不会被切换到其他进程

设想一下若一个进程关中断之后不再开中断,其结果将会如何?系统可能会因此终止。而且,如系统有两个或多个共享内存的处理器,则关中断仅仅对执行本指令的CPU有效,其他CPU仍将继续运行,并可以访问共享内存

graph LR 非临界区-->关中断 关中断-->临界区 临界区-->开中断 开中断-->非临界区

锁变量

假设有一个共享(锁)变量,初值为0,当进程希望进入其临界区时,它首先测试该锁,如果锁为0,则进程将其置为1,并且进入临界区。如果锁已经为1,则进程将等待直到其变成0,因此,0表示没有进程处于其临界区内,而1表示某个进程进入了临界区。

int lock = 0;
void enter_region(void) {
    while (lock == 1) {}
    lock = 1;
}

void leave_region(void) {
    lock = 0;
}
graph LR 非临界区-->enter_region enter_region-->临界区 临界区-->leave_region leave_region-->非临界区

但是这种思路包含有和Spooler目录同样致命的缺陷。假设某个进程读锁,并且发现它为0,在它将锁置为1之前,另一个进程被调度、运行并且同样将锁置为1。当第一个进程再次运行时,它供养也将锁置为1,于是将同时有两个进程处于其临界区中

问题:对lock的访问产生了新的临界区

解决:硬件支持——TSL指令

TSL指令

TSL指令是一种需要硬件支持的方案。许多计算机,特别是那些为多处理机设计的计算机,都有一条指令叫做测试并上锁(TSL)。其工作如下所述:它将一个存储器字读到一个寄存器中,然后在该内存地址上存一个非零值。读数和写数操作保证是不可分割的——即该指令结束之前其他处理机均不允许访问该存储器字。执行TSL指令的CPU将锁住内存总线以禁止其他CPU在本指令结束之前访问内存。

为了使用TSL指令,我们必须用一个共享变量lock来协调对共享内存的访问。当lock为0时,任何进程都可以使用TSL指令将其置为1并读写共享内存。当操作结束时,进程用一条普通的MOVE指令将lock重新置为0。

现在,让我们来看一个需要一点硬件帮助的方案,许多计算机,特别是那些为多处理机设计的计算机,都有如下一条指令:

TSL RX,LOCK

称为测试并上锁(TSL,Test and Set Lock)。其工作原理如下所述

graph RL LOCK-->RX
graph RL 1-->LOCK

第一条指令将lock原来的值拷贝到寄存器中并将lock置为1,随后这个原先的值与0相比较。如果它非零,则说明先前已被上锁,则程序将回到开头并再次测试。经过或长或短的一段时间后它将变成0(当前处于临界区中的进程退出临界区时),于是子例程返回,并上锁。清除这个锁很简单,程序只需将0存入lock即可,不需要特殊的指令。

enter_region:
TSL REGISTER,LOCK	; 复制lock到寄存器并设置lock为1
CMP REGISTER,#0		; lock为0吗
JNE enter_region	; 如果非0,设置lock,再次循环
RET					; 返回调用者,进入临界区

leave_region:
MOVE LOCK,#0		; 设置lock为0
RET					; 返回调用者

现在就有一种很明确的解法了。进程在进入临界区之前先调用enter_region。这将导致忙等待,直到锁空闲为止。随后它获得锁变量并返回。在进程从临界区返回时它调用leave_region,这将把lock置为0。与临界区问题的所有解法一样,进程必须在正确的时间调用enter_region和leave_region,解法才能奏效。如果一个进程有欺诈行为,则互斥将会失败。

可替代TSL的指令——XCHG指令**

XCHG REGISTER,LOCK	; 交换寄存器和锁变量的值
MOVE REGISTER,1
XCHG REGISTER,LOCK

严格轮换法

互斥的第三种方法示于下图。

// 进程0
while (true) {     
    while (turn != 0) /*等待*/; 
    critical_region();       
    turn = 1;  
    noncritical_region();      
} 
// 进程1
while (true) { 
    while (turn != 1) /*等待*/; 
    critical_region();
    turn = 0;
    noncritical_region(); 
}

整型变量turn初值为0,它用于跟踪轮到哪个进程进入临界区来检查或更新共享内存。这其中涉及到了忙等待。忙等待是应该避免的,因为它浪费CPU时间。

忙等待:持续地检测一个变量知道某一个值出现

该方案要求两个进程严格地轮流进入它们的临界区,这种情况违反了以上条件3:进程0被一个临界区之外的进程阻塞。

后来,有人将轮换法和锁变量及警告变量的思想相结合,提出了一个不需要严格轮换的软件互斥解法。其中,Peterson算法最简单。

Peterson算法

#define FALSE 0
#define TRUE 1
#define N 2 /*进程数*/

int turn; /*轮到谁了?*/
int interested[N]; /*所有值初始为0(FALSE)*/

void enter_region(int process) /*进程号为0或1*/ {
    int other; /*另一个进程的进程号*/

    other = 1 - process; /*另一个进程*/
    interested[process] = TRUE; /*标识出希望进入临界区*/
    turn = process; /*设置标志位*/
    while (turn == process && interested[other] == TRUE) {
    	/*空语句*/;
    }
}

/*process:即将离开临界区的进程*/
void leave_region(int process) {
    interested[process] = FALSE; /*标识将离开临界区*/
}

首先,还是假设进程i第一次开始执行,那它可以顺利进入临界区,因为interested[j]=FALSE,进程j还不想进入临界区!
其次,交替执行的过程中,假设某一时刻进程i正处于临界区,那么interested[i] = TRUE 且 turn = i,那么这时如果进程j也想进临界区,它会先把interested[j]改为TRUE,然后把turn改为j。但它会在while循环那里忙等待,直到进程i退出了临界区,把interested[i]改为FALSE。

在使用共享变量之前,各进程使用其进程号0或1作为参数来调用enter_region,该调用在需要时将使进程等待,直到能安全地进入。进程在完成对共享变量的操作之后,将调用leave_region,表示操作已完成,若其他进程希望进入临界区,则现在可以进入。

这个方案是如何工作的?起初没有任何进程处于临界区,现在进程0调用enter_region,它通过将其数组元素置位和将turn置为0来标识它希望进入临界区。由于进程1并不想进入临界区,所以enter_region很快便返回。如果进程1现在调用enter_region,它将在此处挂起直到interested[0]变成FALSE,该事件只有当进程0调用leave_region退出临界区时才会发生。

现在考虑两个进程几乎同时调用enter_region的情况。它们都将自己的进程号存入turn。但只有后一个被保存进去的进程号才有效,前一个是无效的。假设进程1后存,则turn为1。当两个进程都运行到while语句时,进程0将循环0次并进入临界区,而进程1将不停地循环,并不得进入临界区。

代码示例:
#include <cstdio>
#include <cstdlib>

bool flag[2];
int turn;

void procedure0() {
    flag[0] = true;
    turn = 1;
    //若flag[1]为false,P0就进入临界区;若flag[1]为tureP0循环等待,只要P1退出临界区,P0即可进入
    while (flag[1] && turn == 1)
        //do nothing
        ;
    //访问临界区
    printf("我是0号进程\n");
    //访问临界区完成,procedure0释放出临界区
    flag[0] = false;
}

void procedure1() {
    flag[1] = true;
    turn = 0;
    while (flag[0] && turn == 0);
    //访问临界区
    printf("我是1号进程\n");/**/
    //访问临界区完成,procedure1释放出临界区
    flag[1] = false;
    //remainder section
}

int main() {
    flag[0] = flag[1] = false;
    procedure1();
    procedure0();
    procedure0();
    procedure1();
    //start procedure0 and procedure1;

    system("pause");
    return 0;
}
运行结果:

posted @ 2020-03-22 17:25  我係死肥宅  阅读(289)  评论(0编辑  收藏  举报