Thread Sychronization,线程同步,我们先来看一个场景,两个或多个进程同时修改一个变量,如图:
线程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。
线程同步讲完啦。后面几篇还要讲死锁的发生、死锁的解决办法~