14、深入理解计算机系统之十: 并发编程(3)

六、用信号量同步线程

共享变量是十分方便,但是它们也引入了同步错误(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的更新后的计数器值。可以借助进度图的方法来阐明这些正确和不正确的指令顺序的概念。


1、进度图

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

2、信号量

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标准定义了许多操作信号量的函数。

//
#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的封装函数。

//
#include “csapp.h”

void P(sem_t *s);	/* wrapper function for sem_wait */
void V(sem_t *s);	/* wrapper function for sem_post */
//
P和V名字的起源:Edsger Dijkstra(1930-2002)出生于荷兰。P和V来源于荷兰语的Proberen(测试)和Verhogen(增加)。

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

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

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

从可操作性的意义上说,由P和V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行指令。话句话说,信号量操作确保了对临界区的互斥访问。总的来说,为了用信号量正确同步计数器程序示例,需要首先声明一个信号量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)加互斥锁

示例代码

//
#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;
}
//
注:进度图的局限性
进度图将在单处理器上的并发程序执行可视化,也便于理解为什么需要同步。但也有局限性,特别是对于在多处理器上的
并发执行,在多处理器上一组CPU/高速缓存对共享同一个主存,多处理器的工作方式是进度图不能解释的。特别是,一个
多处理器内存系统可以处于一种状态,不对应与进度图中的任何轨迹线。总之,无论多核还是单核处理器上运行程序

都要同步对共享变量的访问。

4、利用信号量来调度共享资源

除了提供互斥外,信号量的另一个重要作用是调度对共享资源的访问。在这种场景中,一个线程用信号量操作来通知另一个线程,程序状态中的某个条件已经为真了。两个经典而有用的例子是生产者-消费者读者-写者问题。

(1)生产者-消费者问题


上图给出了生产者-消费者问题。生产者和消费者线程共享一个有n个槽的有限缓冲区。生产者线程反复地生成新的项目,并把它们插入到缓冲区中。消费者线程不断地从缓冲区中取出这些项目,然后消费它们。也可能有多个生产者和消费者的变种。

因为插入和取出项目都涉及跟新共享变量,所以必须保证对缓冲区的访问是互斥的,同时还需要调度对缓冲区的访问。如果缓冲区是满的(没有空的槽位),那么生产者必须等待直到有一个槽位变为可用。与之相似,如果缓冲区是空的(没有可取用的项目),那么消费者必须等待直到有一个项目可用。

生产者-消费者的相互作用在现实系统中很普遍。例如,在一个多媒体系统中,生产者编码视频帧,而消费者解码并在屏幕上呈现出来。缓冲区的目的是为了减少视频流的抖动,而这种抖动是由各个帧的编码和解码时与数据相关的差异引起的。缓冲区为生产者提供了一个槽位池,而为消费者提供一个已经编码的帧池。另一个常见的示例是图形用户接口设计。另一个常见的示例是图形用户接口设计。生产者检测到鼠标和键盘事件,并将它们插入到缓冲区中。消费者以某种基于优先级的方式从缓冲区取出这些事件,并显示在屏幕上。

这里,我们将开发一个简单的包,叫做SBUF(全称:serial data buffer,中文名为串行数据缓冲器、SBUF是指串行口中的两个缓冲寄存器,一个是发送寄存器,一个是接收寄存器,在物理结构上是完全独立的,但地址是重叠的。),用来构造生产者-消费者程序。在 后面,我们将看到如何用它来构造一个基于预线程化的有趣并发服务器。SBUF操作类型为sbuf_t的有限缓冲区。项目存放在一个动态分配的n项整数数组(buf)中。front和rear索引值记录在该数组中的第一项和最后一项。三个信号量同步对缓冲区的访问。mutex信号量提供互斥的缓冲区访问。slots和items信号量分别记录空槽位和可用项目的数量。

//
typedef struct {
	int *buf;		/* buffer array */
	int n;			/* maximum number of slots */
	int front;		/* buf[(front+1)%n] is first item */
	int rear;		/* buf[rear%n] is last item */
	sem_t mutex;	/* protects accesses to buf */
	sem_t slots;	/* counts available slots */
	sem_t items;	/* counts available items */
} sbuf_t;  (SBUF包使用的有限缓冲区)
//
//
#include "csapp.h"
#include "sbuf.h"

/* create an empty, bounded, shared FIFO buffer with n slots */
void sbuf_init(sbuf_t *sp, int n)
{
	sp->buf = calloc(n, sizeof(int));
	sp->n = n;					/* buffer holds max of n items */
	sp->front = sp->rear = 0;	/* empty buffer iff front == rear */
	sem_init(&sp->mutex, 0, 1);	/* binary semaphore for locking */
	sem_init(&sp->slots, 0, n);	/* initially, buf has n empty slots */
	sbuf_init(&sp->items, 0, 0);/* initially, buf has zero data items */
}

/* clean up buffer sp */
void sbuf_deinit(sbuf_t *sp)
{
	free(sp->buf);
}

/* insert items onto the rear of shared buffer sp */
void sbuf_insert(sbuf_t *sp, int item)
{
	p(&sp->slots);							/* wait for available slot */
	p(&sp->mutex);							/* lock the buffer */
	sp->buf[(++sp->rear)%(sp->n)] = item;	/* insert the item */
	V(&sp->mutex);							/* unlock the buffer */
	V(&sp->items);							/* announce available item */
}

/* remove and return the first item from buffer sp */
int sbuf_remove(sbuf_t *sp)
{
	int item;
	p(&sp->item);							/* wait for available item */
	p(&sp->mutex);							/* lock the buffer */
	item = sp->buf[(++sp->front)%(sp->n)];	/* remove the item */
	V(&sp->mutex);							/* unlock the buffer */
	V(&sp->items);							/* announce available item */
	return item;
}
//

上面代码给出了SBUF函数的实现。sbuf_init函数为缓冲区分配堆内存,设置front和rear表示一个空的缓冲区,并为三个信号量赋初始值。这个函数在调用其他三个函数中的任何一个之前调用一次。sbuf_deinit函数是当应用程序使用完缓冲区时,释放缓冲区存储的。sbuf_insert函数等待一个可用的槽位,对互斥锁加锁,添加项目,对互斥锁解锁,然后宣布有一个新项目可用。sbuf_remove函数是与sbuf_insert函数对称的。在等待一个可用的缓冲区项目之后,对互斥锁加锁,从缓冲区的前面取出该项目,对互斥锁解锁,然后发信号通知一个新的槽位可供使用。

(2)读者-写着问题
读者-写着问题是互斥问题的一个概括。一组并发的线程要访问一个共享对象,例如一个主存中的数据结构,或者一个磁盘上的数据库。有些线程只读对象,而其他的线程只修改对象。修改对象的线程叫做写者。只读对象的线程叫做读者。写者必须拥有对象的独占的额访问,而读者可以和无线多个其他的读者共享对象。一般来说,有无线多个并发的读者和写者。读者-写者交互在现实系统中很常见。例如,一个在线航空预定系统中,允许有无线多个客户同时查看座位分配,但是正在 预定座位的客户必须有对数据库的独占的访问。亦例如,在一个多线程缓存Web代理中,无限多个线程可以从共享页面缓存中取出已经有的页面,但是任何向缓存中写入一个新页面的线程必须拥有独占的访问。读者-写者问题有几个变种,分别基于读者和写者的优先级。
A、第一类读者-写者问题,
读者优先,要求不让要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。
B、第二类读者-写者问题,写者优先,要求一旦一个写者准备好可以写,它就会尽可能地完成他的操作。在一个写者后到达的读者必须等待,即使这个写者也是在等待。

C、下面是第一类的示例,读者的优先级高于写者

//
/* global variables */
int readcnt;	/* initially = 0 */
sem_t mutex, w; /* both initially = 1 */

void reader(void)
{
	while (1) {
		P(&mutex);
		readcnt++;
		if (readcnt == 1)	/* first in */
			p(&w);
		V(&mutex);
		
		/* critical section */
		/* reading happens */
		
		P(&mutex);
		readcnt--;
		if (readcnt == 0)	/* last out */
			V(&w);
		V(&mutex);
	}
}

void write(void)
{
	while (1) {
		p(&w);
		
		/* critical section */
		/* writing happens */
		
		V(&w);
	}
}
//

信号量w控制对访问共享对象的临界区的访问。信号量mutex保护共享变量readcnt的访问,readcnt统计当前在临界区中的读者数量。每当一个写者进入临界区时,它对互斥锁w加锁,每当它离开临界区时,对w解锁。这就保证了任意时刻临界区中最多只有一个写者。另一方面,只有第一个进入临界区的读者对w加锁,而只有最后一个离开临界区的读者对w解锁。当一个读者进入和离开临界区时,如果还有其他读者在临界区中,那么这个读者会忽略互斥锁w。这就意味着只要还有一个读者占用互斥锁w,无线多数量的读者可以没有障碍地进入临界区。

D、对这两种读者-写者问题的正确解答可能导致饥饿,饥饿就是一个线程无线地阻塞,无法进展。例如上述的示例。如果读者不断地 到达。写者就可能无期限地等待。

E、其他同步机制。如Java线程是用一种叫做Java监控器的机制来同步实现的,它提供了对信号量互斥和调度能力的更高级别的抽像;

5、综合:基于预线程化的并发服务器


如上图,在并发服务中,我们为每一个新的客户端创建了一个新的线程。这种方法的缺点是为每个新 客户端创建一个新线程,导致不小的代价。一个基于预线程化的服务器试图使用如上图所示的生产者-消费者模型来降低这种开销。服务器是由一个主线程和一组工作者线程构成的。服务器是由一个主线程和一组工作者线程构成的。主线程不断地接受来自客户端的连接请求,并将得到的链接描述符放在一个有限缓冲区中。每一个工作者线程反复地从共享缓冲区中取出描述符,为客户端服务,然后等待下一个描述符。

(1)一个预线程化的并发echo服务器。这个服务器使用的是有一个生产者和多个消费者的生产者-消费者模型。

//
#include "csapp.h"
#include "sbuf.h"
#define NTHREADS 4
#define SBUFSIZE 16

void echo_cnt(int connfd);
void *thread(void *vargp);

sbuf_t sbuf;	/* shared buffer of connected descriptors */

int main(int argc, char **argv)
{
	int i, listenfd, connfd;
	socklen_t clientlen;
	struct sockaddr_storage clientaddr;
	pthread_t  tid;
	
	if (argc != 2) {
		fprintf(stderr, "usage: %s <port>\n", argv[0]);
		exit(0);
	}
	listenfd = open_listenfd(argv[1]);
	
	sbuf_init(&sbuf, SBUFSIZE);
	for (i = 0; i < NTHREADS; i++)	/* create worker threads */
		pthread_create(&tid, NULL, thread, NULL);
		
	while (1) {
		clientlen = sizeof(struct sockaddr_storage);
		connfd = accept(listenfd, (SA *) &clientaddr, &clientlen);
		sbuf_insert(&sbuf, connfd);	/* insert connfd in buffer */
	}
}

void *thread(void *vargp)
{
	pthread_detach(pthread_self());
	while (1) { 
		int connfd = sbuf_remove(&sbuf);	/* remove connfd from buffer */
		echo_cnt(connfd);		/* service  client */
		close(connfd);
	}
}
//

上面代码显示了怎样用SBUF包来实现一个预线程化的并发echo服务器。在初始化了缓冲区sbuf后(sbuf_init(&sbuf, SBUFSIZE);),主线程创建了一组工作者线程(for (i = 0; i < NTHREADS; i++) /* create worker threads */pthread_create(&tid, NULL, thread, NULL);)。然后它进入了无线的服务器循环,接受链接请求,并将得到的已经连接描述符插入到 缓冲区sbuf中。每个工作者线程的行为都非常简单。它等待知道它能从缓冲区中获取一个已经连接描述符,然后调用echo_cnt函数回送客户端的输入。

(2)echo_cnt:echo的一个版本,它对客户端接收的所有字节计数

//
#include "csapp.h"

static int byte_cnt;	/* byte counter */
static sem_t mutex;		/* and the mutex that protects it */

static void init_echo_cnt(void)
{
	sem_init(&mutex, 0, 1);
	byte_cnt = 0;
}

void echo_cnt(int connfd)
{
	int n;
	char  buf[MAXLINE];
	rio_t rio;
	static pthread_once_t once = PTHREAD_ONCE_INIT;
	
	pthread_once(&once, init_echo_cnt);
	rio_readinitb(&rio, connfd);
	while ((n = rio_readlineb(&rio, buf, MAXLINE)) != 0) {
		P(&mutex);
		byte_cnt += n;
		printf("server receive %d (%d total) bytes on fd %d\n", n, byte_cnt, connfd);
		V(&mutex);
		rio_writen(connfd, buf, n);
	}
}
//
上面示例的代码。echo函数在全局变量byte_cnt中记录了从所有客户端接收到的累积字节数。首先我们需要初始化byte_cnt计数器和mutex信号量。一个方法是在主线程显式第调用一个初始化函数。另一个方法是,使用pthread_once函数去调用初始化函数。这个方法的优点是它使程序包的使用更加容易。缺点是每次调用echo_cnt函数都会调用pthread_once函数,而在大多数时候它没做什么有用的事。

致谢

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




猜你喜欢

转载自blog.csdn.net/qq_38880380/article/details/80462823