[Modern OS] InterProcess Communication

InterProcess Communication

[TOC]

2.3.1 Race Conditions

2.3.2 Critical Regions

避免 race condition 的关键是防止多个进程同时读写共享资源。

换句话说,需要一个互斥锁mutual exclusion

对共享资源进行访问的部分程序被称为临界区critical section

2.3.3 Mutual Exclusion with Busy Waiting

Disabling Interrupts

缺点:

  1. 给用户进程关闭中断的权限是不明智的;
  2. 如果系统是多处理器系统,那么关闭中断只会影响执行了disable指令的CPU,运行在其他CPU上的进程依然可以访问共享内存。

Disabling interrupts is often a useful technique within the operating system itself but is not appropriate as a general mutual exclusion mechanism for user processes.

Lock Variables

是一种软件解决方案,但是同样面临打印机模型的问题。

  1. 假设进程 0 获取了 lock variable,值为 0,当它要将值改为 1 时,scheduler 调度进程 1 执行;
  2. 进程 1 fetch lock variable,值依然为 0,然后进程 1 将值设置为 1,进入其临界区开始执行;
  3. 调度程序调度进程 0 重新运行,进程 0 将 lock variable 从 1 设置为 1,然后进入自己的临界区。

这样我们就有了两个进程都进入了各自的临界区。

Strict Alternation

Continuous testing a variable until some value appears is called busy waiting. A lock that uses busy waiting is called a spin lock.

while(TRUE){
    while(turn != 0) /* loop */;
    critical_region();
    turn = 1;
    noncritical_region();
}

while(TRUE){
    while(turn != 1) /* loop */;
    critical_region();
    turn = 0;
    noncritical_region();
}

缺点:

  1. 持续检测变量浪费CPU
  2. 要求严格有序,否则效率低下

Peterson's Solution

#define FALSE 0
#define TRUE 1
#define N 2 /* number of processes */

int turn; /* whose turn is it? */
int interested[N]; /* all values initially 0 (FALSE) */

void enter region(int process) /* process is 0 or 1 */
{
    int other; /* number of the other process */
    other = 1 −process; /* the opposite of process */   
    interested[process] = TRUE; /* show that you are interested */
    turn =process; /* set flag */
    while (turn == process && interested[other] == TRUE) /* null statement */ ;
}

void leave region(int process) /* process: who is leaving */
{
    interested[process] = FALSE; /* indicate departure from critical region */
}

起初没有进程进入临界区。
进程 0 调用enter_region。它在interested数组中的对应元素被设置为 1,然后ture为 0。由于进程 1 还没有调用过enter_region,所以enter_region会立刻返回,进程 0 进入临界区。如果此时进程 1 调用了enter_region,那么在interested[0]变为FALSE之前,它都会等待。

如果两个进程几乎同时调用了enter_region。那么turn将会是后一个完成赋值操作的进程的号码。假设此时turn为 1。两个进程同时到达while语句,进程 0 将会直接退出,然后进入临界区。进程 1 将会等待,直到进程 0 从临界区退出。

本质上也是一个busy waiting

The TSL Instruction

TSL Test and Set Lock

一些计算机,尤其是那些具有多个处理器的设计,都有类似如下的指令:

TSL RX, LOCK

从内存LOCK中读取内容lock到寄存器RX,并且在内存地址lock处保存一个非零值。
The operations of reading the word and storing into it are guaranteed to be indivisible—no other processor can access the memory word until the instruction is finished.即,CPU在执行TSL指令期间将会lock内存总线来禁止其他CPU访问该内存,until it is done.

值得注意的是,lock memory bus is very different from disabling interrupts.

2.3.4 Sleep and Wakeup

上述方法都需要busy waiting。实际上,本质都是:当有进程需要进入临界区时,首先检查是否能够进入,如果不能进入,那么就会执行一个循环一直进行检测,直到能够进入临界区。

busy waiting不光消耗CPU时间,同时还会有一些bug。比如当发生优先级反转时,已经进入临界区的进程无法释放 lock,导致高优先级的进程永远无法进入临界区。

The Producer-Consumer Problem

也叫做有限缓冲区问题( bounded-buffer problem)。

两个进程共享一个公共的、固定大小的缓冲区。其中一个,生产者,将信息放入buffer,另一个,消费者,从buffer中取出消息。

Trouble arises when the producer wants to put a new item in the buffer, but it is already full. The solution is for the producer to go to sleep, to be awakened when the consumer has removed one or more items. Similarly, if the consumer wants to remove an item from the buffer and sees that the buffer is empty, it goes to sleep until the producer puts something in the buffer and wakes it up.

为了追踪缓冲区中物体的数量,我们需要一个变量count

#define N 100 /* number of slots in the buffer */
int count = 0; /* number of items in the buffer */
void producer(void)
{
    int item;
    while (TRUE) { /* repeat forever */
        item = produce item( ); /* generate next item */
        if (count == N) sleep( ); /* if buffer is full, go to sleep */
        inser t item(item); /* put item in buffer */
        count = count + 1; /* increment count of items in buffer */
        if (count == 1) wakeup(consumer); /* was buffer empty? */
    }
}

void consumer(void)
{
    int item;

    while (TRUE) { /* repeat forever */
        if (count == 0) sleep( ); /* if buffer is empty, got to sleep */
        item = remove item( ); /* take item out of buffer */
    count = count −1; /* decrement count of items in buffer */
    if (count == N − 1) wakeup(producer); /* was buffer full? */
    consume item(item); /* pr int item */
    }
}

然而对count的访问可能会出问题。当对count的访问不做限制时,就会出现rece condition。缓冲区已经空了,消费者读入count来测试它是否为0。在此时,调度器决定将消费者暂停,然后执行生产者。生产者增加了buffer中物体的数量,增加了count,ok那么现在count应该为1。根据生产者的代码,消费者此时会给消费者发出一个wakeup的消息,然而事实上,由于消费者之前被暂停,所以此时它在逻辑上并没有sleep,所以这个消息将会丢失。

当消费者再次执行时,它根据自己之前读到的count==0,将自己转入sleep。不久之后缓冲区满,生产者也进入sleep,这时,消费者和生产者两者都将永远sleep。

这个问题的本质是:wakeup消息被发送给了一个还没有真正sleep的进程。

2.3.5 Semaphores

1965年,Dijkstra提出使用信号量Semaphore。Semaphore是一个整数变量,当它为0时,表示没有保存任何wakeup信号,当它为其他正整数时,表示一个或者多个wakeup were pending。

Dijkstra提出了两个对信号量的操作,down 和 up。对一个信号量的 down 操作检查信号量的值是否为 0,如果不是 0,那么就减 1。如果是 0,那么进程就会sleep,而且暂时不会将 down 操作执行完。检查值的大小、改变值的大小、以及可能的go to sleep,这些行为都是单个的不可分的 原子操作(atomic action)。一旦对信号量的操作被进行,那么所有其他进程都无法访问该信号量,直到操作完成或者该进程阻塞。

up 操作将信号量的值增加1。如果有多个进程sleeping on that semaphore, unable to complete an earlier down operation, 那么系统将会随机选择一个进程完成它的 down operation。Thus, after an up on a semaphore with processes sleeping on it, the semaphore will still be 0, but there will be one fewer process sleeping on it.

Solving the Producer-Consumer Problem Using Semaphores

信号量的作用是为了解决前文提出的wakeup信号丢失的问题(lost-wakeup)。为了让它们正常工作,updown操作必须以一种不可分割的方式实现。通常做法是将updown实现成系统调用,操作系统在检测信号量、更新信号量、以及将进程转为sleep的时候关闭中断。而且这些操作只会执行很少的指令,因此再此期间中断中断将不会有危害。如果使用多个CPU,那么每个信号量都需要由一个lock变量来保护,并且使用TSL或者XCHG指令来保证在每个时刻只有一个CPU能够检查信号量。(因为TSLXCHG指令通过lock memory bus来禁止多个CPU同时访问lock variable,而不只是busy waiting,单纯的busy waiting只对单处理器有用,对多个处理器是无效的)

#define N 100 /* number of slots in the buffer */
typedef int semaphore; /* semaphores are a special kind of int */
semaphore mutex = 1; /* controls access to critical region */
semaphore empty = N; /* counts empty buffer slots */
semaphore full = 0; /* counts full buffer slots */
void producer(void)
{
    int item;
    while (TRUE) { /* TRUE is the constant 1 */
        item = produce item( ); /* generate something to put in buffer */
        down(&empty); /* decrement empty count */
        down(&mutex); /* enter critical region */
        inser t item(item); /* put new item in buffer */
        up(&mutex); /* leave critical region */
        up(&full); /* increment count of full slots */
    }
}

void consumer(void)
{
    int item;
    while (TRUE) { /* infinite loop */
        down(&full); /* decrement full count */
        down(&mutex); /* enter critical region */
        item = remove item( ); /* take item from buffer */
        up(&mutex); /* leave critical region */
        up(&empty); /* increment count of empty slots */
        consume item(item); /* do something with the item */
    }
}

This solution uses three semaphores: one called full for counting the number of slots that are full, one called empty for counting the number of slots that are empty, and one called mutex to make sure the producer and consumer do not access the buffer at the same time.

初始值为 1 并且被多个进程使用的信号量,保证了同时只有一个进程可以进入临界区,这种信号量被称为二值信号量(binary semaphores)。

2.3.6 Mutexes

当我们不需要信号量的计数功能时,信号量就可以当作互斥锁Mutex来使用。

A mutex is a shared variable that can be in one of two states: unlocked or locked.

由于互斥锁很简单,因此可以在具有TSL或者XCHG指令的用户空间中实现互斥锁。

mutex lock:
        TSL REGISTER,MUTEX  | copy mutex to register and set mutex to 1
        CMP REGISTER,#0     | was mutex zero?
        JZE ok              | if it was zero, mutex was unlocked, so return
        CALL thread yield   | mutex is busy; schedule another thread
        JMP mutex lock      | try again
ok:     RET                 | return to caller; critical region entered


mutex unlock:
        MOVE MUTEX,#0       | store a 0 in mutex
        RET                 |retur n to caller

迄今为止,有一个问题我们一直都没有提到,但是非常值得解释。对于用户空间的线程包来说,多线程对一些互斥锁或者其他的资源进行访问是没问题的,因为线程之间具有相同的地址空间。但是,先前提出的解决方案中,比如Peterson's algorithm and Semaphores,进程之间是具有不同的地址空间的,那么在那些解决方案中,进程之间如何贡献类似turn变量呢?

Two answers. 首先,这些共享的数据结构,比如信号量Semaphores,被保存在内核中,并且只能通过系统调用来访问。这种方式解决了该问题。第二,大多数现代操作系统为进程之间共享它们的部分地址空间提供了可能。换句话说,buffers and other data structures can be shared.

如果两个或者多个进程之间共享它们的大多数地址空间,那么进程和线程之间的区别就变得很模糊了,但这其实永远不会发生。两个共享相同地址空间的进程仍然具有不同的打开文件表、提醒计时器以及其他基于进程的属性。并且对于共享地址空间的多个进程来说,它们的效率远比不上用户空间的线程,因为多个进程之间的管理是和内核紧密相关的。

Futexes

随着并行度的增加,高效的同步以及锁策略对于性能变得越来越重要。如果等待时间很短,那么自旋锁会很快,但是会浪费CPU时间。如果竞争比较激烈,那么将进程阻塞然后等lock is free 再将它唤醒会更加高效。不幸的是,这种方式在 heavy contention 的情况下效率很高,但是如果只有很少的竞争,那么连续地 switching to kernel 的代价很高昂。

因此提出futex(fast user space mutex)。futex 是 linux 的一个特性,futex 实现了基本的锁,但是避免了陷入内核,除非强制要求。Futex包含两个部分:内核服务和用户函数库。内核服务提供了一个“等待队列”使得多个进程可以 wait on lock. 除非内核主动将他们解锁,否则它们都不会运行。对于一个进程来说,将其加入等待队列需要“昂贵”的系统调用,因此我们需要尽量避免这种情况。因此,在缺少多个进程竞争的情况下,futex 完全在用户空间下工作。具体来说,进程之间共享一个lock变量。如果lock变量被锁住,那么线程需要等待。在这种情况下,futex library 不会自旋,而是使用一个系统调用来讲线程放入内核中的等待队列。

Mutexes in Pthreads

Pthread提供了许多函数用于同步线程。基本的机制是使用一个mutex变量。关于mutex的详细使用见The Linux Programming Interface

除了mutex之外,pthread也提供了另一种线程同步机制condition variables。条件变量使得线程可以根据某个条件是否被满足来做出阻塞的决定。互斥锁和条件变量这两种机制几乎总是被一起使用。

再次考虑生产者消费者问题,一个线程向buffer中添加物品,另一个线程从buffer中取出物品。如果生产者发现buffer中没有空闲位置可以放置物品时,it has to block until one becomes available.

#include <stdio.h>
#include <pthread.h>
#define MAX 1000000000 /* how many numbers to produce */
pthread mutex t the mutex;
pthread cond t condc, condp; /* used for signaling */
int buffer = 0; /* buffer used between producer and consumer */
void *producer(void *ptr) /* produce data */
{ int i;
for (i= 1; i <= MAX; i++) {
pthread mutex lock(&the mutex); /* get exclusive access to buffer */
while (buffer != 0) pthread cond wait(&condp, &the mutex);
buffer = i; /* put item in buffer */
pthread cond signal(&condc); /* wake up consumer */
pthread mutex unlock(&the mutex); /* release access to buffer */
}
pthread exit(0);
}
void *consumer(void *ptr) /* consume data */
{ 
    int i;
    for (i = 1; i <= MAX; i++) {
        pthread mutex lock(&the mutex); /* get exclusive access to buffer */
        while (buffer ==0 ) pthread cond wait(&condc, &the mutex);
        buffer = 0; /* take item out of buffer */
        pthread cond signal(&condp); /* wake up producer */
        pthread mutex unlock(&the mutex); /* release access to buffer */
    }
    pthread exit(0);
}
int main(int argc, char **argv)
{
    pthread t pro, con;
    pthread mutex init(&the mutex, 0);
    pthread cond init(&condc, 0);
    pthread cond init(&condp, 0);
    pthread create(&con, 0, consumer, 0);
    pthread create(&pro, 0, producer, 0);
    pthread join(pro, 0);
    pthread join(con, 0);
    pthread cond destroy(&condc);
    pthread cond destroy(&condp);
    pthread mutex destroy(&the mutex);
}

2.3.7 Monitors

如果produce代码中的两个down操作调换顺序,也就是说,mutexempty之前被检测。那么如果buffer已经满了,则producer会阻塞,同时它不会释放mutex,mutex=0。结果,下次消费者尝试去对mutex加锁时,由于mutex=0,消费者也会阻塞。导致死锁发生。

这说明当使用信号量写代码时,必须异常小心。

1974年,Brinch Hanse and Hoare 提出一个更高等级的同步原语叫做监视器monitor。监视器是一系列过程、变量以及数据结构的集合。

2.3.8 Message Passing

2.3.9 Barriers

posted @ 2019-09-09 22:06  HZQTS  阅读(322)  评论(0编辑  收藏  举报