17、深入理解计算机系统之十: 并发编程(信号量与互斥锁)

工程代码下载连接

一、信号量的介绍

Edsger Dijkstra,并发编程领域的先锋人物,提出了一种经典的解决同步不同执行线程问题的方法,该方法就是基于一种你叫做信号量(semaphore)的特殊类型变量的。信号量s是具有非负数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V:

(1)P(s): 如果s是非0的,那么P将s减1,并且立即返回。如果s为0,那么就挂起这个线程,直到s变为非0,而一个V操作会重启这个线程。在重启之后,P操作将s减1,并将控制返回给调用者。

(2)V(s): V操作将s加1。如果有任何线程阻塞在P操作等待s变成非0,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。、

P的测试和减1操作是不可分割的,也就是说,一旦预测信号量s变为非0,就会将s减1,不能有中断。V中的加1操作也是不可分割的,也就是加载、加1和存储信号量的过程中没有中断。注意,V的定义中没有定义等待线程被重启动的顺序。唯一的要求是V必须只能重启一个正在等待的线程。因此,当多个线程在等待同一个信号量时,你不能预测V操作要重启哪一个线程。P和V的定义确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化了的信号量有一个负值。这个属性称为信号量不变性,为控制并发程序的轨迹线提供了强有力的工具。

Posix标准定义了许多操作信号量的函数。 view pl

//
#include <semaphore.h>  
  
int sem_init(sem_t *sem, 0, unsigned int value);  
int sem_wait(sem_t *s); /* P(s) */  
int sem_post(sem_t *s); /* V(s) */  
返回:成功为0,出错为-1.  
//

sem_init函数将信号量sem初始化为value。每个信号量在使用前必须初始化。针对我们的目的,中间的参数总是零。程序分别通过调用sem_wait和sem_post函数来执行P操作和V操作。

P和V名字的起源 :Edsger Dijkstra(1930-2002)出生于荷兰。P和V来源于荷兰语的Proberen(测试)和Verhogen(增加)。

二、使用信号量来实现互斥

信号量提供了一种很方便的方法来确定对共享变量的互斥访问。基本思想是将每个共享变量(或者一组相关的共享变量)与一个信号量s(初始值为1)联系起来,然后用P(s)和V(s)操作将相应的临界区包围起来。以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或者1.以提供互斥为目的的二元信号量常常也称为互斥锁。在一个互斥锁上执行P操作称为对互斥锁加锁。执行V操作称为互斥锁解锁。对一个互斥锁加了锁但是还没有解锁的线程称为占用这个互斥锁。一个被用作一组可用资源的计数器的信号量被称为计数信号量。

上图中的进度图展示了我们如何利用二元信号量来正确地同步计数器程序示例。每个状态都标出了该状态中信号量s的值。关键思想是这种P和V操作的结合创建了一组状态,叫做禁止区,其中s<0。因为信号量的不变性,没有实际可行的轨迹线能够包含禁止区中的状态。而且,因为禁止区完全包括了不安全区,所有没有实际可行的轨迹能够接触不安全区的任何部分。因此,每条实际可行的轨迹线都是安全的,而且不管运行时指令顺序是怎样的,程序都会正确地增加计数器值。

从可操作性的意义上说,由P和V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行指令。话句话说,信号量操作确保了对临界区的互斥访问


三、信号量机制产生的原因

1、多线程共享变量是十分方便,但是它们也引入了同步错误(synchronization error)的可能性。

// 
/* WARING: This code is buggy */  
#include "csapp.h"  
  
void *thread(void *vargp);  /* thread routine prototype */  
  
/* globle shared variable */  
volatile long cnt = 0;  /* counter */  
  
int main(int argc, char **argv)  
{  
    long niters;  
    pthread_t tid1, tid2;  
      
    /* check input argument */  
    if (argc != 2) {  
        printf("usage: %s <niters>\n", argv[0]);  
        exit(0);  
    }  
    niters = atoi(argv[1]);  
      
    /* create threads and wait for them to finish */  
    pthread_create(&tid1, NULL, thread, &niters);  
    pthread_create(&tid2, NULL, thread, &niters);  
    pthread_join(tid1, NULL);  
    pthread_join(tid2, NULL);  
      
    /* check result */  
    if (cnt != (2 * niters))  
        printf("BOOM! cnt = %1d\n", cnt);  
    else  
        printf("OK cnt = %1d\n", cnt);  
    exit(0);  
}  
  
/* thread routine */  
void *thread(void *vargp)  
{  
    long i, niters = *((long *)vargp);  
      
    for (i = 0; i < niters; i++)  
        cnt++;  
          
    return NULL;  
}  
//

本例程创建了两个线程,每个线程都对共享计数变量cnt加1。
因为每个线程都对计数器增加了niters次,我们预计它的最终值是2 * niters。这看上去简单而直接。然而,当在Linux系统上
运行此例程时,我们不仅得到错误的法案,而且每次得到的答案都还不一样。
linux> ./badcnt 1000000
BOOM! cnt = 1445085
linux> ./badcnt 1000000
OK! cnt = 2000000
linux> ./badcnt 1000000

BOOM! cnt = 1148066

(1)分析上面的问题,我们需要这两行代码

for (i = 0; i < niters; i++)
cnt++;
A、Hi:在循环头部的指令快。
B、Li:加载共享变量cnt到累加寄存器%rdxi的指令,这里%rdxi表示线程i中的寄存器%rdx的值。
C、Ui: 更新(增加)%rdxi的指令。
D、Si:将%rdxi的更新值存回到共享变量cnt的指令。
E、Ti:循环尾部的指令块。
注意:头和尾只操作本地栈变量,而Li、Ui和Si操作共享计数器变量的内容。

当示例中的两个对等线程在一个单独处理器上并发运行时,机器指令以某种顺序一个接一个完成。因此,每个并发执行定义了两个线程中的指令的某种全序(交叉)。这些顺序中有的会产生正确结果,但是其他的则不会。


这里有个关键点:一般而言,无法预知操作系统是否将线程选择一个正确的顺序。如下图,a图展示了一个正确的指令顺序的分步操作。在每个线程更新了共享变量cnt之后,它在内存中的值就是2,这是期望值。但是在b图中的顺序产生了一个不正确的cnt的值。出现这样的问题的原因是线程2在第5步加载cnt,是在第2步线程1加载cnt之后,而在第6步线程1存储它的更新值之前。因此,每个线程最终都会存储一个值为1的更新后的计数器值。可以借助进度图的方法来阐明这些正确和不正确的指令顺序的概念。


2、进度图

进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。每条轴k对应于线程k的进度。每个点(I1,I2,...,In)代表线程k(k=1,...,n)已经完成了的指令Ik这一状态。图的原点对应于没有任何线程完成一条指令的初始状态。图12-19展示了badcnt.c程序第一次循环迭代的二维进度图。水平轴对应于线程1,垂直轴对应于线程2.点(L1,S2)对应于线程1完成了L1而线程2完成了S2的状态。进度图将指令执行模型化为从一种状态到另一种状态的转换。转换被表示为一条从一点到相邻点的有向边。命令是往下执行的,两条指令不能在同一时刻完成,所以合法的转化是向上和向右的,对角线是不允许的一个程序的执行历史被模型化为状态空间中的一条轨迹图


对于线程i,操作共享变量cnt内容的指令(Li,Ui,Si)构成了一个(关于共享变量cnt的)临界区,这个临界区不应该和其他进程的临界区交替执行。也就是我们要确保每个线程在执行它的临界区中指令时,拥有对共享变量的互斥的访问。通常这种现象称为互斥。



在进度图中,两个临界区的交集形成的状态空间区域称为不安全区。上图,展示了变量cnt的不安全区。注,不安全区与它交界的状态相毗邻,但并不包括这些状态。绕开不安全区的轨迹线叫做安全轨迹线。相反,接触到任何不安全区的轨迹线就叫做不安全轨迹线。任何安全轨迹线都将正确地更新共享计数器。为了保证线程化程序示例的正确执行,我们必须以某种方式同步线程,使它们总是在一条安全轨迹线。一个经典的方法是基于信号量的思想


四、示例代码

1、思路

为了用信号量正确同步计数器程序示例,需要首先声明一个信号量mutex;
volatile long cnt = 0; /* counter */
sem_t mutex; /* semaphore that protects counter */
然后在主例程中将mutex初始化为1;
sem_init(&mutex, 0, 1); /* mutex = 1 */
最后,我们通过把线程例程中对共享变量cnt的更新包围P和V操作,从而保护它们;
for (i = 0; i < niters; i++) {
P(&mutex);
cnt++;

V(&mutex);

}

即思路为:

(1)确定临界区

(2)加互斥锁

2、代码

//  
#include "csapp.h"  
  
void *thread(void *vargp);  /* thread routine prototype */  
  
/* globle shared variable */  
volatile long cnt = 0;  /* counter */  
/* semaphore that protects counter */  
sem_t mutex;  
  
int main(int argc, char **argv)  
{  
    long niters;  
    pthread_t tid1, tid2;  
       
    sem_init(&mutex, 0, 1);     /* mutex = 1 */  
  
    /* check input argument */  
    if (argc != 2) {  
        printf("usage: %s <niters>\n", argv[0]);  
        exit(0);  
    }  
    niters = atoi(argv[1]);  
      
    /* create threads and wait for them to finish */  
    pthread_create(&tid1, NULL, thread, &niters);  
    pthread_create(&tid2, NULL, thread, &niters);  
    pthread_join(tid1, NULL);  
    pthread_join(tid2, NULL);  
      
    /* check result */  
    if (cnt != (2 * niters))  
        printf("BOOM! cnt = %1d\n", cnt);  
    else  
        printf("OK cnt = %1d\n", cnt);  
    exit(0);  
}  
  
/* thread routine */  
void *thread(void *vargp)  
{  
    long i, niters = *((long *)vargp);  
      
    for (i = 0; i < niters; i++) {  
        P(&mutex);  
        cnt++;  
        V(&mutex);  
    }  
          
    return NULL;  
}  
//  

3、运行结果

linux> ./badcnt 1000000

OK! cnt = 2000000

linux> ./badcnt 1000000

OK! cnt = 2000000

linux> ./badcnt 1000000
OK! cnt = 2000000

4、分析



致谢

1、《深入理解计算机系统》[第3版],作者 Randal E.Bryant, David R.O`Hallaron 译者 龚奕利 贺莲

2、深入理解计算机系统之十: 并发编程

3、工程代码下载连接




猜你喜欢

转载自blog.csdn.net/qq_38880380/article/details/80446562
今日推荐