互斥量的作用是用来多线程之间互斥排它的访问共享资源(比如一个读一个写等等)
多线程操作还有一个重要问题,不用说你也猜的到了:就是线程同步问题
线程同步的概念就是:多个线程之间相互协作完成某项任务
就是说线程A需要先执行某项操作,执行完后,线程B才能执行。
在window中,有个叫做“内核事件”的对象,线程B可以通过调用 WaitForSIngleObject 等函数,使自己进入阻塞状态以等待“内核事件”对象变成有效状态;
而线程A可以在执行完操作后,调用SetEent函数,使事件对象变成有效信号
在Linux中,系统虽然没有给我们提供上面那些高级的API,但却为我们提供了“条件变量”,这样就好办了,我们可以模仿Windows结合条件变量来封装我们自己的线程同步
OK,第一步,还是先介绍Linux 下有关条件变量的API
1、定义条件变量
pthread_cond_t cond;
2、初始化条件变量
pthread_cond_init(&cond,0); //同样,第2个参数是设置条件变量的属性,设置成0表示使用默认属性
3、条件变量的使用
(1)线程B阻塞的等待条件变量,直到条件变量变成有效信号
pthred_mutex_t mutex;
{
CEnterCriticalSection(&mutex);
pthread_cond_wait(&cond,&mutex);
}
注意等待条件变量wait的用法比较特殊:
mutex.lock()
pthread_cond_wait(&cond,&mutex);
mutex.unlock();
总结:等待条件变量wait的使用必须搭配一个互斥量,且wait前我们要先加锁,wait后我们还要解锁
原因:
i、为什么要使用互斥量先lock再unlock?:
条件变量结构体pthread_cond_t, 他里面有一个等待列表,记录了所有因为此条件变量而产生阻塞的线程,显然这个等待列表是多个线程共享的,在使用的时候我们必须保证互斥的使用,所以就需要使用互斥量先上锁再解锁
ii、为什么要给wait函数传入互斥量
先讲一下wait函数的内部操作流程:在互斥的使用完等待列表(把线程加到等待列表中)后,就会让该调用线程阻塞,然后再执行一个解锁动作 ,等到线程被唤醒时,再执行一个加锁动作
下面解释wait函数内部的执行一次解锁和一次加锁的原因:
先执行一次解锁:这一步操作的时间是,把线程插入到条件变量的等待列表之后,让线程阻塞之前;设想如果此时没有解锁操作,那么阻塞线程会抱着锁睡着,这样的后果时,当再来一个线程想等待条件变量时,它会被阻塞在mutex.lock(),而不是被插入到条件变量的等待列表中,所以我们要先解锁,好让其他的线程可以顺利的调用wait函数
再执行一次加锁:这个就比较好解释了,因为此时系统又要互斥的操作等待列表即把阻塞线程从等待列表中删除,显然这要先加锁
有点罗嗦,画个图解释先:
(4)线程A使能条件变量
pthread_cond_signal(&cond); //唤醒等待该条件变量类表上的一个线程
pthread_cond_broadcast(&cond); //唤醒等待列表上的说有线程
4、释放条件变量
pthread_cond_destroy(&cond);
先封装条件变量的基本用法
版本1:
class CConditionVariable { private: pthread_cond_t m_ConditionVariable; public: CConditionVariable() { int r = pthread_cond_init(&m_ConditionVariable, 0); ...//参数检查 } ~CConditionVariable() { int r = pthread_cond_destroy(&m_ConditionVariable); } Wait(CMutex * pMutex) { int r = pthread_cond_wait(&m_ConditionVariable, &(pMutex->m_Mutex)); ...//返回值检查 } Wakeup() { int r = pthread_cond_signal(&m_ConditionVariable); ...//返回值检查 } WakeupAll() { int r = pthread_cond_broadcast(&m_ConditionVariable); ...//返回值检查 } }
char str[20]; void * thread1_func(void * pContext) { CConditionVariable * pConditon = (CConditionVariable *)pContext; //发送线程睡2s strcpy(str,"Hello world!\n"); pCondition->Wakeup(); return 0; } void * thread2_func(void pContext) { CConditionVariable *pCondition = new CConditionVariable(); CMutex * pMutex = new CMutex(); pthread_t threadID; pthread_create(&threadID,0,thread1_func,pCondtion); sleep(2); { CEnterCriticalSection cs(pMutex); pCondition->Wait(pMutex); }//临界区 cout << str << endl; }
这里,为了引入一个使用条件变量时的问题,我们特意在thread2中加入了一个sleep(2),目的是:让thread1先执行,thread2后执行
运行后,结果什么也没有输出:
根本原因是:linux中的pthread_cond_t 中并没有资源计数量,即我们一开始的想象:pthread_cond_t中有一个标志记录资源的个数,我们暂时管他叫resourceNums , pthread_cond_signal会使该该变量+1,pthread_cond_wait 会先检查resourceNums的值,如果>0就获得资源不阻塞,否则阻塞线程。
实践证明上述猜想是错误的,pthread_cond_wait 并不会先检查某个标志,而是直接把调用线程阻塞,并插入到阻塞列表中。pthread_cond_signal 也不会使能或设置某个标志,而是直接去检查条件变量的阻塞列表中是否有线程等待,若有就唤醒它否则什么也不操作
好了根本原因知道了,再让我们看上述代码,thread1先执行,调用了wakeup后,此时由于条件变量的等待列表中还没有线程,所以不会唤醒任何线程。
再看thread2,thread2经过2s的睡眠后,会进入临界区,并调用wait函数直接把自己插入等待列表并阻塞,由于程序中不会再有其他线程去调用wakeup了,所以线程就会被永远阻塞下去.
结论:pthread_cond_t 条件变量的使用,wait操作必须要在signal操作之前执行
版本2:
既然wait操作必须要在signal操作之前执行,那我们就设置一个标志变量flag,flag只有在signal后才能使能,而wait操作必须在flag使能后才能执行(显然flag是共享资源,因为设计到多线程1读1写)
struct SPara { CConditionVariable condition; CMutex mutex; int flag; }; void * thread1_func(void * pContext) { SPara * p = (SPara *)pContext; { CEnterCriticalSection cs(&p->mutex); flag = 1; } p->condition.Wakeup(); } int main() { SPara * p = new SPara; p->flag = 0; pthread_t thread; pthread_create(&thread,0,thread1_func,(void *)p); { CEnterCriticalSection cs(&(p->mutex)); while(p->flag == 0) //如果子线程设置了flag==1,那么主线程就每必要再去等待子线程了,可以执行下面(while循环后)的操作 { p->condition.Wait(&(p->mutex)); } } }
好,先告诉大家这个版本的条件变量的封装也不是最终版本,不过这个版本中友好多即有意思又很重要的问题值得我们来讨论多线程同步时应该注意到的一些问题:
1、主线程为什么要循环的测试标志位:flag?
答:主线程调用wait阻塞后,很有可能因为其他“莫名奇妙”的信号唤醒,所以为了处理这种情况,当线程被唤醒后,我们再去检查一次flag操作,看看它是不是真的是被wakeup函数唤醒的(若是,flag!=0,),如果不是则应该让线程继续等待并阻塞。
2、考虑情况:如果先执行子线程且直到子线程执行完才去执行主线程的情况,
此时,子线程设置好了flag==1,然后又去调用了一次wakeup操作,由于此时条件变量的等待列表中还没有阻塞线程,所以不会唤醒任何线程。子线程全部执行完后,开始执行主线程,主线程进入临界区后,发现flag!= 0 ,所以退出while循环,说白了这一步就是子线程告诉了主线程,我已经做完我要做的事情了,你可以继续执行后面的工作,不用再等了。
3、p->condition.wakeup() 为什么要放在临界区外面(或者说原子操作外面)来执行?
答:如果单存从功能实现上来看,wakeup完全可以放在临界区内执行;
但如果我们把它放在临界区内执行,考虑一种情况:当子线程执行完了wakeup函数并唤醒主线程,同时当子线程还没来得及解锁临界区,cpu就产生调度,开始从子线程转到主线程执行,此时执行p->condition.Wait()操作时,由于p->mutex和子线程中用的锁是一把锁,所以它是加锁状态,所以主线程会再次陷入阻塞状态,可见这种情况下主线程会白醒一次,效率不高。
用同样的分析思路去分析把p->condition.wakeup放到临界区外面时的情况,发现不会出现“白醒一次的问题”
Tips:分析线程的运行状况,主要从以下三个方面来分析
1、子线程先执行且一次执行完毕
2、主线程先执行且一次完毕
3、子线程和主线程交叉执行(分析起来又有很多情况)
版本3:
版本2和版本1中,使用条件变量,都要规定用户必须按照一定的顺序(版本1规定,必须先调用wait才能再调用wakeup)或规则(版本2规定,wakeup执行前要设置flag值,而主线程要循环检查flag值等等)。
而再回到Windows中使用“内核事件”对象来开发多线程程序时,等待线程只需要调用waitForSingleObject 而被等待线程只需要再合适的地方调用setEvent函数就好了。根本不用管版本1的顺序和版本2中的各种复杂规定。
所以,版本3也是我们今天条件变量封装的终极版本就是来仿照windows的内核事件来进行封装
class CEvent { private: CMutex c_Mutex; CConditionVariable m_Cond; int m_Flag; bool m_RecordSemaphore; // 1 public: CEvent() { m_Flag = 0; m_RecourdSemaphore = false; } Set() { try { CEnterCriticalSection cs(&m_Mutex); m_Flag ++; // 2 } catch(...) { ... } CStatus s = m_Cond.Wakeup(); } Wait() { try { CEnterCriticalSection cs(&m_Mutex); while(m_Flag == 0) { CStatus s = m_Cond.Wait(&m_Mutex); ...//s返回值检查 } if(m_RecordSemaphore) //3 { m_Flag--; } else { m_Flag = 0; } } catch(...) { .... } }
另外,我比较唐突的添加了 1,2,3处代码,其聪明的读者稍微一想就知道,这样做的目的实际上是告诉用于当前条件变量可以变成一个记录型条件变量也可以变成一个只有2种状态的简单条件变量