apue学习第十七天(2)——线程同步

Thread Sychronization,线程同步,我们先来看一个场景,两个或多个进程同时修改一个变量,如图:

apue学习第十七天(2)——线程同步_第1张图片

线程A,B同时进行增量操作。通常,读操作可以一步完成,而写操作(增量操作)可分解为3步:(1)从内存单元读入寄存器(2)将寄存器中变量进行增量操作(3)将新值写回内存单元;由上述步骤分析,最后i的值可能+1,也可能+2。

刚才说的这种情况是计算机体系结构引起的竞争,主要是memory cycle的影响,当然啦,如果修改操作是原子操作,那么就不存在竞争了。除了体系结构的问题外,程序使用变量的方式也会引起竞争。比如,对某个变量+1,再根据这个变量作出某种决定;这两个步骤并不是原子操作,所以当多个线程执行时,很容易引发不一致的问题。


下面,我们介绍5种线程同步的机制:mutex, reader-writer lock, condition variables, spin locks, barrier, 翻译过来就是:互斥量,读写锁,条件变量,自旋锁,屏障。有人会问,那信号量呢?别急,信号量是进程间的同步机制,我们到后边的进程间通信会提到。那么,我们就一个个来看吧!

1. Mutex

提到互斥量,我们很容易想到锁,然后进而想到死锁,对啦,自己对同一个mutex连续加两次锁便可以造成自身死锁。但是,关于死锁以及死锁的所有详细解决方案,先别着急,会在后面几节里说明。本节,我们只是结合代码,介绍一下同步机制和相关函数。先看mutex中涉及的相关函数声明:

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
    /* All return: 0 if OK, error number on failure */

关于上面几个函数,首先,pthread_mutex_init是mutex的初始化,静态变量调用PTHREAD_MUTEX_INITIALIZER可以进行初始化,或者在后面调用init函数初始化。初始化函数中有个mutex属性attr,当然可以直接写NULL,但这里我们简单提一下mutex的几个常见属性,只需了解:

(1)process shared 进程共享属性

我们在说线程,怎么又说到进程共享了呢?我们来看,默认情况下,该属性被设置为PTHREAD_PROCESS_PRIVATE,意思是一个进程中的多个线程可以访问同一同步对象(这也正是最通常的现象);假如一块共享内存被多个独立进程共享,当被设置为PTHREAD_PROCESS_SHARED时,那么多个进程就可以共用这块共享内存中的互斥量进行同步操作。

(2)robust 健壮属性

这个属性主要与多个进程共享互斥量有关。如果一个进程hold一个mutex但是又崩了,这时便需要robust attribute啦。

(3)type 类型属性

它控制这mutex的锁定特性,比如说PTHREAD_MUTEX_NORMAL是标准类型,不做任何特殊的错误检查和死锁检测;PTHREAD_MUTEX_RECURSIVE这个比较有意思,允许一个进程对该互斥量多次加锁;还有其它类型,这里就不一一列举啦。

在apue书中mutex这块函数的用法因为我们会在后面说明,所以就不贴代码了。


2. 读写锁

reader-writer lock,也叫shared-exclusive lock(互斥共享锁)。有没有想到读者写者问题啊?对啦,就是这个。读写锁有3种states:locked in read mode, locked in write mode, unlocked. 它的特性是,写加锁模式时,所有试图加锁的线程都会被阻塞;读加锁状态时,所有读者都可以对访问,但写者会被阻塞。往往,如果在读加锁模式中一个写者过来了,会阻塞这个写者后面的所有线程,这样做是防止后面有过多读者来使写者站在那里很尴尬。

需要注意的是,读写锁非常适合读者远大于写者的情况。为什么?当然是因为通常情况下你想读就能读,但想写必须等啊!与mutex相比,读写锁使用前必须pthread_rwlock_init(),释放时必须pthread_rwlock_destroy()。废话不多说,看代码11-14:

#include <stdlib.h>
#include <pthread.h>

struct job{
	struct job *j_next;
	struct job *j_prev;
	pthread_t j_id;	// tells which thread handles this job.
};
struct queue{
	struct job *q_head;
	struct job *q_tail;
	pthread_rwlock_t q_lock;
};

/*
*	Initialize a queue.
*/
int queue_init(struct queue *qp);

/*
 *	Insert a job at the head of the queue.
 */
 void 
 job_insert(struct queue *qp, struct job *jp)
 {
 	pthread_rwlock_wrlock(&qp->q_lock);
 	jp->j_next = qp->q_head;
 	jp-<j_prev = NULL;
 	if(qp->q_head != NULL)
 		qp->q_head->j_prev = jp;
 	else
 		qp->tail = jp;
 	qp->q_head = jp;
 	pthread_rwlock_unlock(&qp->q_lock);
 }

 /*
  *	Append a job on the tail of the queue.
  */
void job_append(struct queue *qp, struct job *jp);

/*
 *	Remove a given job from the queue
 */
 void job_remove(struct queue *qp, struct job *jp)
 {
 	pthread_rwlock_wrlock(&qp->q_lock);
 	if(jp == qp->q_head)
 	{
 		qp->q_head = jp->j_next;
 		if(qp->q_tail == jp)
 			qp->q_tail = NULL;
 		else
 			jp->j_next->j_prev = jp->j_prev;
 	} else if (jp == qp->q_tail)
 	{
 		qp->q_tail = jp->j_prev;
 		jp->j_prev->j_next = jp->j_next;
 	} else
 	{
 		jb->j_prev->j_next = jb->j_next;
 		jb->j_next->j_prev = jb->j_prev;
 	}

 	pthread_rwlock_unlock(&qp->q_lock);
 }

 /*
  *	Find a job for the given thread ID
  */
  struct job *job_find(struct queue *qp, pthread_t id);

11-14是用读写锁解决在队列struct queue中insert, append, remove, find的操作,有趣的是insert是向对头插,append是在队尾接。queue说白了就是俩指针,关键还是jobs组成的双向链表的操作,这儿的指针操作应该多加练习。当然还有timed读写锁,就不细说啦。

至于读写锁的属性?只有一个process shared属性啦,作用和mutex的一模一样。


3. 条件变量

线程的另外一种同步机制:一般和mutex一起使用,允许线程以无竞争的方式等待特定条件的发生。这句话很费解,书上解释的也一塌糊涂,那么先略过它,分析代码11-15,来看条件变量具体的使用方法:

#include <pthread.h>

struct msg{
	struct msg *m_next;
	/* ... more stuff here ... */
};
struct msg *workq;
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void
process_msg(void)
{
	struct msg *mp;

	for(; ;)
	{
		pthread_mutex_lock(&qlock);
		while(workq != NULL)
			pthread_cond_wait(&qready, &qlock);
		mp = workq;
		workq = mp->m_next;
		pthread_mutex_unlock(&qlock);
		/* now process the message mp */
	}
}

void enqueue_msg(struct msg *mp)
{
	pthread_mutex_lock(&qlock);
	mp->m_next = workq;
	mp = workq;
	pthread_mutex_unlock(&qlock);
	pthread_cond_signal(&qready);
}

首先,这段程序大概情景:假设A,B两个线程,A调用process_msg,B调用enqueue_msg。最初,队列为空,所以A无法处理msg,只能阻塞等待,当B执行enqueue时,将msg加入队列,然后唤醒A执行。

好啦,大概场景就是这样,但如何用条件变量来实现一个“事件等待器”,这方面的具体细节,还是深入上面代码来看。

我们只介绍一个函数:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex); 放到代码里,就是这一句:

pthread_cond_wait(&qready, &qlock);

它的作用是:将互斥量qlock解锁,使当前线程阻塞到条件变量qready上;并等待pthread_cond_signal或pthread_cond_broadcast将其唤醒(也可能被信号中断后唤醒);醒来时,对qlock重新加锁,并继续执行。(可见这一加锁一减锁的有趣)

那么,了解了这个,我们就可以详细还原程序执行的正常场景了:

首先初始化条件变量qready,互斥量qlock,A,B线程开始执行;

对于A:(1)对qlock加锁 (2)判断workq,若为空,调用pthread_cond_wait对qlock解锁,阻塞在qready上等待激活;

对于B:(1)对qlock加锁 (2)修改workq (3)对qlock解锁 (4)此时worq不为NULL,向qready发送信号;

对于A:(3)醒来,对qlock重新加锁workq不再为NULL,继续执行程序,处理消息。

这就是执行的步骤,或者可以说,这是个“模板”步骤,因为大多数“事件等待器”都是这样写的。这里面的while很重要,为什么呢?因为线程B对qlock解锁后,到A中cond_wait再次加锁前,很可能workq的值又被其它线程改变,所以需要再次判断。由此可见while的重要,也可以看出来这个“模板”的精妙~

(参考网站http://blog.csdn.net/ithomer/article/details/6031723)

当然,条件变量出错的话,比如说激活信号丢失之类的情况,看这里:(http://www.cppblog.com/Solstice/archive/2013/09/09/203094.aspx)

至于条件变量的属性嘛,目前只定义了两个:一个是和上面相同的进程共享属性;另一个是时钟属性,时钟属性是干嘛的?你看啊,如果调用pthread_cond_timedwait得有时钟控制它啥时候超时吧,这个属性就规定了计算时间用哪个时钟~


4. 自旋锁

曾经,这是个挺高深的词汇,spin lock,但现在看来,不过尔尔~

我来个形象的比喻:比如说,有人给你说,我2小时后来叫你,这个时候你可以走到床边,脱下衣服睡一觉,等别人来叫你的时候,再穿上衣服起来;但如果别人给你说,我两分钟后来叫你,那你还这样吗?脱衣服穿衣服睡觉都挺浪费时间的,所以一般情况下你会在原地走动,等待别人来叫。

bingo!原地打转的等待方式就是spin lock。它是使线程获取spin lock之前进入忙等待busy-waiting(spin),而不是休眠sleeping,为什么不脱下衣服睡觉呢?因为锁被持有时间很短,线程不希望在重新调度(脱衣服/穿衣服)上花费太多。

自旋锁通常作为底层原语(low-level primitives)用于实现其它类型的锁,在用户层不是很常用。好了,了解到这里,足够了~至于到底用在那些地方,还是那句话,用到时再说~


5. 屏障

先说作用:协调多个线程同步工作。barrier允许线程等待,直到所有合作线程都达到某一点,然后主线程从这点继续执行。

pthread_join?没错,它就是一种屏障,允许一个线程等待,直到另一个线程退出。

那么它的属性呢?目前只定义了一个,那就是那个哪儿都有的属性:进程共享属性。

那么我们来看,barrier究竟有什么用:

假设,在8核处理器系统上,用单个线程使用heapsort去排序800万个数,需要12.14秒;但是,如果用8个并行线程和一个用来合并结果的主线程,只需要1.19秒。6倍!看到了吧~这就是多线程的作用!

程序呢?图11-16使用barrier这个程序非常重要,尤其是merge函数,对我这个菜鸟来说实现的可谓精妙。这个程序分8个线程实现了总共800万个数的排序,每个线程排序100万个数,排完barrier之后由主线程进行merge工作,即把八段排序好的数组合并。下面我把11-16所有代码贴上来,以彰显我对此程序的重视,程序后面会具体分析merge函数。

#include "apue.h"
#include <pthread.h>
#include <limits.h>
#include <sys/time.h>

#define NTHR 8	/* number of threads */
#define NUMNUM 8000000L	/* number of numbers to sort */
#define TNUM (NUMNUM/NTHR)	/* number to sort per thread. It is vital, for this macro set the count each thread have to handle */

long nums[NUMNUM];	//store the original numbers
long snums[NUMNUM];	//store the sorted numbers

pthread_barrier_t b;

#ifdef SOLARIS
#define heapsort qsort
#else
extern int heapsort(void *, size_t, size_t, int (*)(const void *, const void *));
#endif

/*Compare two long intergers*/
int complong(const void *, const void *);

/*Worker thread to sort a portion of the set of numbers*/
void *thr_fn(void *);

/*Merge the results of the individual sorted range.*/
void merge();

int main()	//the whole overview is quite clear
{
	/*initialize variables*/
	unsigned long i;
	struct timeval start, end;
	long long startusec, endusec;
	double elapsed;
	int err;
	pthread_t tid;

	/*create the initial set of numbers to sort*/
	srandom(1);
	for(i=0; i<NUMNUM; i++)
		nums[i] = random();

	/*create 8 threads to sort the numbers*/
	gettimeofday(&start, NULL);
	pthread_barrier_init(&b, NULL, NTHR+1);
	for(i=0; i<NTHR; i++)
	{
		err = pthread_create(&tid, NULL, thr_fn, (void *)(i*TNUM));
		if(err != 0)
			err_exit(err, "can't create thread");
	}
	pthread_barrier_wait(&b);
	merge();
	gettimeofday(&end, NULL);

	/*print the sorted list*/
	startusec = start.tv_sec * 1000000 + start.tvusec;
	endusec = end.tv_sec * 1000000 +end.tv_usec;
	elapsed = (double)(endusec - startusec) / 1000000.0;
	printf("sort took %.4f seconds\n", elapsed);
/*	for(i=0; i<NUMNUM; i++)
		printf("%ld\n", snums[i]);*/

	exit(0);
} 

int complong(const void *arg1, const void *arg2)
{
	long l1 = *(long *)arg1;
	long l2 = *(long *)arg2;

	if(l1 == l2)
		return 0;
	else if(l1<l2)
		return -1;
	else
		return 1;
}

void *thr_fn(void *arg)
{
	long idx = (long)arg;

	heapsort(&nums[idx], TNUM, sizeof(long), complong);	// use heapsort to sort the 1000000 numbers
	pthread_barrier_wait(&b);

	/*Go off and perform more work... */
	return((void *)0);
}

void merge()
{
	long idx[NTHR];
	long i, minidx, sidx, num;

	/*initialize the 8 array headers*/
	for(i=0; i<NTHR; i++)
		idx[i] = i*TNUM;
	/*merge 8 sorted arrays*/
	for(sidx=0; sidx<NUMNUM; sidx++)
	{
		num = LONG_MAX;	// the max number for a long int number;
		for(i=0; i<NTHR; i++)
		{
			if((idx[i] < (i+1)*TNUM) && (nums[idx[i]] < num))
			{
				num = nums[idx[i]];
				minidx = i;
			}
		}
		snums[sidx] = nums[idx[minidx]];
		idx[minidx]++;
	}
}

哇哦,这段代码真够长的啊!原始的nums数组也够长,800万个数据。好啦,现在8个线程都已使用heapsort排序好(想想平均时间复杂度为O(nlogn)的三大排序算法,heap用数组存放,heapsort看这里http://blog.csdn.net/morewindows/article/details/6709644),barrier之后,开始merge啦。

首先merge的初始条件:连续存放数据的超长数组,0~999999, 1000000~1999999, ...每一段都是已排序好的数;idx[0],idx[1]...初始指向8段数字的第一个数的位置(也就是每段最小的数);

思路很简单:每轮拿出8段中的8个idx[]指向的最小的数,并将该段指向位置后移一位(也就是开始时idx[i]保存的是该段最小值的位置,那么把该最小值取出后,idx[i]++);把每次取出的值依次放在snums数组中,snums长度和nums长度相等。最终snums就是排序结果。

那我们就来看伪代码吧,我这个写的伪代码不是很严谨,也就是个程序思路:

for(给snums数组中的第sidx个位置选定数)

{

num = LONG_MAX;

for(每个线程求得的数组,一共8个)

{

if((位置约束:第i段中idx[i]指向的位置还在下一段初始位置之前) && (最小数约束:当前位置的数小于已求得的最小数))

{将最小的数给num; 将最小的段号给minidx;}

}

将本轮8个数求得的最小数给snums[sidx];

将最小数那一段idx指向的位置后挪一位;

}

好啦,就是这个思路。


等等,程序中有个细节,那就是8000000个随机数的生成,看程序中的40~43行:

如何生成呢?先用srandom创建种子,在用random生成随机数。初学者肯定不知道什么是种子,那我来说明一下:在用函数生成随机数的时候,计算机只是对随机现象的模拟,并不是真实的物理现象中的随机数,所以我们把它叫做伪随机数。关于伪随机数的生成最简单的就是给一个数a,通过一个数学模型(公式变换)生成另一个数b,再生成其它很多数。那么最初的a就是种子,以后生成的就是很多伪随机数。数学模型的选取当然要让生成的随机数满足一定分布,那我们就不朝细节看了,关于种子的设定是否要变换、随机数产生的周期、还有涉及到的加密或安全性等问题,看这里以及自己上网找找吧:【知乎:C++中随机数的生成问题】http://www.zhihu.com/question/20397465;【CSDN:那些年我们一起写的随机函数】http://blog.csdn.net/hackmind/article/details/7798769。


线程同步讲完啦。后面几篇还要讲死锁的发生、死锁的解决办法~

你可能感兴趣的:(apue学习第十七天(2)——线程同步)