线程进程间通信

Posted iamfatotaku

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程进程间通信相关的知识,希望对你有一定的参考价值。

睡眠与唤醒

Peterson解法和TSL解法都是正确的,但它们都有忙等待的缺点。这些解法在本质上是这样的:当一个进程想进入临界区时,先检查是否允许进入,若不允许,则进程将踏步等待,直到许可为止

这种方法不仅浪费CPU时间,还可能引起预料不到的结果,即:优先级翻转问题(priority inverion problem)。

优先级翻转

所谓优先级翻转问题(Priority Inversion)即当一个高优先级任务通过信号量机制访问共享资源时,该信号量已被一低优先级任务占有,而这个低优先级任务在访问共享资源时可能又被其它一些中等优先级任务抢先,因此造成高优先级任务被许多具有较低优先级任务阻塞,实时性难以得到保证。

例如:有优先级为A、B和C三个任务,优先级A>B>C,任务A,B处于挂起状态,等待某一事件发生,任务C正在运行,此时任务C开始使用某一共享资源S。在使用中,任务A等待事件到来,任务A转为就绪态,因为它比任务C优先级高,所以立即执行。当任务A要使用共享资源S时,由于其正在被任务C使用,因此任务A被挂起,任务C开始运行。如果此时任务B等待事件到来,则任务B转为就绪态。由于任务B优先级比任务C高,因此任务B开始运行,直到其运行完毕,任务C才开始运行。直到任务C释放共享资源S后,任务A才得以执行。在这种情况下,优先级发生了翻转,任务B先于任务A运行。

现在来看几条进程间通信原语,它们在无法进入临界区时将阻塞,而不是忙等待。最简单的是睡眠(SLEEP)和唤醒(WAKEUP)。SLEEP系统调用将引起调用进程阻塞,即被挂起,直到另一进程将其唤醒。WAKEUP调用有一个参数,即要被唤醒的进程。另一种方法是SLEEP与WAKEUP各有一个参数,即一个用于匹配SLEEP和WAKEUP的内存地址。

生产者——消费者问题

两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,用于将消息放入缓冲区;另外一个是消费者,用于从缓冲区中取出消息。问题出现在当缓冲区已经满了,而此时生产者还想向其中放入一个新的数据项的情形,其解决方法是让生产者此时进行休眠,等待消费者从缓冲区中取走了一个或者多个数据后再去唤醒它。同样地,当缓冲区已经空了,而消费者还想去取消息,此时也可以让消费者进行休眠,等待生产者放入一个或者多个数据时再唤醒它。

听起来好像蛮对的,无懈可击似的,但其实在实现时会有一个竞争条件存在的。为了跟踪缓冲区中的消息数目,需要一个变量 count。如果缓冲区最多存放 N 个消息,则生产者的代码会首先检查 count 是否达到 N,如果是,则生产者休眠;否则,生产者向缓冲区中放入一个消息,并增加 count 的值。

含竞争条件的生产者消费者问题代码描述

#define N 100   					/* 缓冲区内的槽数 */
int count = 0;       				/* 缓冲区内数据项个数 */
void producer(void) {
    while (TRUE) { 					/* 无限循环 */
        producer_item();			/* 产生下一个数据项 */
        if (count == N)  sleep();	/* 缓冲区满,进入睡眠 */
        enter_item();  				/* 将一个数据项放入缓冲区 */
        count = count + 1; 			/* 增加缓冲区内数据项个数 */
        if (count == 1) wakeup(consumer);/* 缓冲区为空?*/
    }
}
void consumer(void) {
    while (TRUE) {     					/* 无限循环 */
        if (count == 0)  sleep();  	    /* 缓冲区空,进入睡眠 */
        remove_item();       			/* 从缓冲区中取走一个数据项 */
        count = count - 1;       		/* 递减缓冲区中数据项个数 */
        if (count == N - 1)
            wakeup(producer)     		/* 缓冲区满?*/
            consume_item();      		/* 打印数据项 */
    }
}

看上去很美,哪里出了问题,这里对count 的访问是有可能出现竞争条件的:缓冲区为空,消费者刚刚读取 count 的值为 0,而此时调度程序决定暂停消费者并启动执行生产者。生产者向缓冲区中加入一个数据项,count 加 1。现在 count 的值变成了 1,它推断刚才 count 为 0,所以此时消费者一定在休眠,于是生产者开始调用 wakeup(consumer) 来唤醒消费者。但是,此时消费者在逻辑上并没有休眠,所以wakeup 信号就丢失了。当消费者下次运行时,它将测试先前读到的 count 值,发现为 0(注意,其实这个时刻 count 已经为 1 了),于是开始休眠(逻辑上)。而生产者下次运行的时候,count 会继续递增,并且不会唤醒 consumer 了,所以迟早会填满缓冲区的,然后生产者也休眠,这样两个进程就都永远的休眠下去了。

信号量

通过例子说明

信号量Semaphore,跟交通信号等非常类似(Semaphore翻译过来就是信号灯的意思),以下面这幅图为例

技术图片 技术图片

如果两条铁轨都是空的,那么此时信号灯是绿色(信号量为2),允许火车通行。如果有列车请求通行则放行,同时信号灯变为黄色(信号量减一):

技术图片

当两条铁轨都有列车通行时,信号灯为红色(信号量为0),不允许火车通过。若果有列车请通行则阻塞:

技术图片

当一辆列车离开铁轨的后,信号灯变为黄色(信号量为1),此时等待的通行的列车被放行:

技术图片 技术图片

有了上面的感性认识后,我们来看看到底如何表示信号量。

什么是信号量

信号量是E. W. Dijkstra在1965年提出的一种方法,在他的建议中引入一个新的变量类型,称作信号量(sem)

信号量可以理解为是一个空位

一个信号量的值可以为0,表示没有积累下来的唤醒操作;或者为正值,表示有一个或多个被积累下来的唤醒操作。

P操作——passeren——通过

我要把这个信号量减1,如果这个信号量小于零,我们就认为当前执行这个P操作的进程需要去睡眠,如果是大于等于0,那执行P操作的进程它可以继续往下执行,可以进入临界区或者执行其他操作,它类似于之前讲到的获取lock的操作

V操作——vrijgeven——释放

把信号量的值加1,然后进入判断,如果信号量小于等于零,意味着当前有一些进程挡在了P操作上面,那就会唤醒挂在信号量上挡着的进程。

信号量上可以有三种操作

  • 初赋值,可以为0,表示没有保存的唤醒信号,或者为正数,表示有积累了一个或多个唤醒型号
  • down和up操作(也称作P、V操作或者wait、signal操作)

信号量结构

  • 整数部分(计数)
  • 队列(阻塞在信号量上的进程)

down操作和up操作

down(s):

s = s - 1;
if (s < 0) {
    该进程加入s的阻塞队列;
}

up(s):

s = s + 1;
if (s <= 0) {
    唤醒阻塞队列上的一个进程;
}

down和up操作都是原子操作——检查数值、改变数值以及可能发生的睡眠操作均作为单一的、不可分的原子动作(atomic action)来完成

这就确证了一旦某个信号量操作开始了,在该操作完成或者阻塞之前,别的进程都不能访问该信号量。这种原子性对于解决同步问题以及避免竞争条件绝对是必须的。

用信号量实现互斥

技术图片

mutex值的变化:

  • 1:无进程进入临界区
  • 0:一个进程进入临界区
  • -1:一个进程进入临界区,另一个进程等待进入临界区(阻塞)

用信号量解决生产者-消费者问题

伪代码

#define N 100				/* 缓冲区内槽数 */
typedef int semaphone;		/* 信号量是一种特殊的整形变量 */
//互斥
semaphone mutex = 1;	 	/* 控制对临界区的访问 */
//同步
semaphone empty = N;	 	/* 记录缓冲区内空的槽数 */
semaphone full = 0; 		/* 记录缓冲区内满的槽数 */

void producer(void) {
	int item;
	while (TRUE) { 				/* TRUE为常数1 */
		produce_item(&item); 	/* 产生一数据项 */
		down(&empty);	 		/* 递减空槽数 */
		down(&mutex); 			/* 进入临界区 */
		enter_item(item); 		/* 将一个新数据项放入缓冲区 */
		up(&mutex); 			/* 离开临界区 */
		up(&full); 				/* 递增满槽数 */
	}
}

void consumer(void) {
	int item;
	while (TRUE) { 				/* 无限循环 */
		down(&full); 			/* 递减满槽数 */
		down(&mutex);			/* 进入临界区 */
		remove_item(&item);		/* 从缓冲区中取走一个数据项 */
		up(&mutex); 			/* 离开临界区 */
		up(&empty); 			/* 递增空槽数 */
        consume_item(item); 	/* 对数据项进行操作 */
	}
}

该解决方案使用了三个信号量:full用来记录满的缓冲槽数目empty记录空的缓冲槽总数mutex用来确保生产者和消费者不会同时访问缓冲区full的初值为0,empty的初值为缓冲区内槽的数目,mutex初值为1。两个或多个进程使用的初值为1的信号量保证同时只有一个进程可以进入临界区,它被称作二进制信号量(binary semaphore)。如果每个进程在进入临界区前都执行一个DOWN操作,并在退出时执行一个UP操作,则能够实现互斥。实际上是通过两种不同的方式来使用信号量。其间的区别是很重要的。信号量mutex用于互斥。它用于保证任一时刻只有一个进程读写缓冲区和相关的变量。互斥是避免混乱所必需的。

注意点

对V操作交换顺序并没有什么问题,但是对P操作来说,交换了顺序会出现了死锁

如果仓库已经满了、待取(empty=0),一旦此刻有一个生产者进来,此时他先反手把门关上(mutex=0),然后执行P操作,empty变为-1,自己去睡觉了,还把门锁上了,消费者进也进不来,进入了占着茅坑不拉屎的状态

生产者进程先down(&empty),然后down(&mutex),消费者进程先down(&full),然后down(&mutex)。可不可以颠倒一下?即生产者进程先down(&mutex),然后down(&empty),消费者进程先down(&mutex), 然后down(&full)

回答是不行的。设想生产者进程已经将缓冲区放满,消费者进程并没有取产品,即empty=0。当下次仍然是生产者进程运行时,它先down(&mutex),封锁信号量,再down(&empty)时将被阻塞,希望消费者取出产品后将其唤醒;轮到消费者进程运行时,它先down(&mutex),然而由于生产者进程已经封锁mutex信号量,消费者进程也会被阻塞,这样一来生产者消费者进程都将阻塞,都指望对方唤醒自己,陷入了无休止的等待了。同理,如果消费者进程已经将缓冲区取空,full=0,下次如果还是消费者先运行,也会出现类似的死锁。不过生产者释放信号量时,mutexfull先释放哪一个无所谓,消费者先释放mutex还是empty都可以。

消息传递

  • 适合于分布式环境
  • 系统调用实现:send、receive

命名

直接通信

  1. 明确命名接收者和发送者
    • send(P, &m):发送消息到进程P
    • receive(Q, &m):接受来自进程Q的消息
  2. 发送者命名接收者
    • send(P, &m):发送消息到进程P
    • receive(id, &m):接受来自任何进程的消息

间接通信

通过邮箱或端口传递消息

  • send(A, &m):发送消息到邮箱A
  • receive(A, &m):接受来自邮箱A的消息

同步

  • 阻塞send:发送进程阻塞,直到消息被接受进程或邮箱接受
  • 非阻塞send:发送进程发送消息并继续
  • 阻塞receive:接收者阻塞直到有消息可用
  • 非阻塞receive:接收者收到一个有效消息或空消息

缓冲

  • 无缓冲:阻塞发送,阻塞接受
  • 有限缓冲:缓冲满,阻塞发送
  • 无限缓冲:不阻塞

具有N条消息的生产者——消费者问题

#define N 100			//缓冲区的容量 
void producer(void) {
        int item;
        message m;		//消息缓冲区

        while(TRUE){
            item = produce_item();			//产生数据项
            receive(consumer, &m);			//等待一条空消息到达
            build_message(&m, item);		//构造一条消息以供发送
            send(consumer, &m);				//发送一个数据项给消费者
        }
}

void consumer(void) {
    int item;
    message m;

    for(i = 0; i < N; i++)
        send(producer, &m);					//发送N条空消息
    while(TRUE) {
        receive(producer, &m);				//接受一条包含数据的消息
        item = extract_item(&m);			//从消息中提取数据
        send(producer, &m);					//回送空消息作为应答
        consume_item(item);					//对数据项进行操作
    }
}

屏障

最后一种同步机制是针对一组进程的情形,而不是两个进程的生产者—消费者类型。某些应用程序被分成阶段,而且规定除非所有进程都已就绪进入下一个阶段,否则任何进程都不许进入下一阶段

这种行为可以通过每个阶段的结束处放置一个屏障(barrier)来实现。

当一个进程到达屏障时,它被阻塞知道所有进程都到达该屏障。

技术图片

使用屏障

a) 进程接近屏障

b) 除了一个进程,其他进程都被屏障阻塞

c) 最后一个进程到达屏障,所有进程都可以通过

物理学或工程学中一个典型的张弛问题。他有一个包含某学初始值的矩阵。这些数值可能表示在一张金属片上各个不同点的温度。该方法可以用于计算角上的火焰热量多长时间才能传遍整个金属片

由当前数值开始,在矩阵上进行变化,得到第二阶矩阵,比方说,通过应用热力学原理来观察(Delta T)事件之后所有的温度是多少。因此,该算法随着时间流逝,产生一系列的矩阵。

现在,可以假设矩阵非常大的,因而需要并行进程来加速计算。不同的进程在矩阵的不同部分上工作,根据物理学原理从老的矩阵元素计算新的矩阵元素。但是没有进程可能开始在迭代(n+1),除非迭代(n)已经完成。

完成这一目标的方法就是,设计程序使得每个进程在完成它目前的迭代部分之后,执行一个barrier操作,当所有进程都完成时,新的矩阵也将完成,而且所有进程被同事释放已启动下一迭代。

以上是关于线程进程间通信的主要内容,如果未能解决你的问题,请参考以下文章

进程与线程的区别,进程间通信方式,线程间通信方式

c++ 线程间通信方式

为啥线程在进程间通信期间会破坏命名管道?

进程间通信与线程间通信

Java进程间通信和线程间通信?

进程间通信和线程间通信的几种方式